diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 0000000..ddcff84 --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,23 @@ +{ + "title": "OpenProcess: process mining, Petri nets and workflows in one open app", + "upload_type": "software", + "description": "OpenProcess is a desktop app for process mining and Petri net modelling in which every algorithm is a function whose code and source paper are one click away, and every analysis keeps a record of its inputs, versions and seeds so that its numbers can be checked again. Mine (event logs, discovery, conformance), Model (Petri nets and coloured nets) and Learn (exercise packs) in one window.", + "creators": [ + {"name": "Struthers, Sam"} + ], + "license": "mit", + "access_right": "open", + "keywords": [ + "process mining", + "Petri nets", + "workflow nets", + "coloured Petri nets", + "conformance checking", + "event logs", + "teaching", + "reproducible research" + ], + "related_identifiers": [ + {"identifier": "https://github.com/s4mstruthers/openprocess", "relation": "isSupplementTo", "scheme": "url"} + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d8f25..172648b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ and each [release on GitHub](https://github.com/s4mstruthers/openprocess/release uses it as its description. Write for the people using the app: what they will notice, not how it was done. Versions before 0.7.0 were released as CPNpy. +## 0.10.6 + +- **The reproducibility badge is a quiet pill** beside the title: a soft tint + with a dot and a few words ("● Reproducible", "● 2 changes since", + "● Not recorded"), the full sentence on hover. It no longer pushes the + title aside, and when the header is short of room it keeps only its dot. +- **Install a tool from the downloaded app too.** Connections ▸ *Install…* + now works in the app you download, not only from a Python install: the + app carries pip, installs into a folder of your own (on a Mac, + Library/Application Support/OpenProcess/packages), and the boxes that need + the package come alive without a restart. Ready-built packages only, so + PM4Py still needs a Python install. +- **A page opened from a box is a view, not a file.** *Open as log ›* no + longer writes a file into the folder the moment it opens. The page has + *Keep in folder* for when you want one; ✕ closes it without a question and + leaves nothing in the sidebar. Files you opened yourself still close to a + lighter row, and deleting stays an explicit Move to Bin. +- The status chips on cards (*not installed*, *installing…*, *ready*, the + Learn chips) are pills everywhere. They draw themselves now: a styled + label's corners depended on when the style polished it, and the downloaded + app showed square chips beside round ones. +- **A column setting is a dropdown.** Plot's *x* and *y* list the columns of + the table connected to the box, once it has run; a name can still be + typed. A box author writes `x: Column = ""`, nothing more: the new + `Column` type is a plain string to the function. +- A box with nothing plugged in (Plot, just added to the canvas) waits with + *Connect scores or tables* instead of failing with a traceback. +- The downloaded app carries the whole standard library, so a package + installed from Connections can import what it needs (matplotlib wanted + `unittest`, and Plot failed after the install). +- Releases are archived on Zenodo; the DOI goes into `CITATION.cff` and the + app's citations once the first archive is there. + ## 0.10.5 - **Object-centric event logs.** *Open object-centric log* reads OCEL 2.0 diff --git a/CITATION.cff b/CITATION.cff index 4ab990d..3f15c9e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,9 +1,9 @@ cff-version: 1.2.0 -message: If you use OpenProcess in teaching or research, please cite it as below. +message: "If you use OpenProcess in teaching or research, please cite it as below." type: software -title: OpenProcess: process mining, Petri nets and workflows in one open app -version: 0.10.5 -date-released: 2026-10-10 +title: "OpenProcess: process mining, Petri nets and workflows in one open app" +version: 0.10.6 +date-released: 2026-10-11 repository-code: https://github.com/s4mstruthers/openprocess url: https://github.com/s4mstruthers/openprocess license: MIT diff --git a/README.md b/README.md index 11e735a..9085be2 100644 --- a/README.md +++ b/README.md @@ -794,7 +794,11 @@ extra) and *Predict the next activity*. Or start empty. draws what flows in, OpenProcess and its boxes, what flows out, and the optional tools (pandas, numpy, scipy, matplotlib, …) with whether each is installed, how to install it and the boxes it brings. All of it is read - from the box library. + from the box library. *Install…* works in the downloaded app too: it carries + pip and installs into a folder of your own (on a Mac, + `~/Library/Application Support/OpenProcess/packages`), and the boxes that + need the package come alive without a restart. PM4Py needs a Python + install (`pip install pm4py`), as parts of it are built from source. - **Motion** is kept short: a space switch, the side panel and Canvas ↔ Summary settle in a fifth of a second, and a box pulses once when it finishes. `OPENPROCESS_NO_MOTION=1` turns it off. @@ -930,6 +934,16 @@ Plainly stated: Next up: several instances of a subpage, CPN monitors, and signed apps that open without a security prompt. +## Cite + +If OpenProcess helped with a course, a thesis or a paper, cite it. *Help ▸ +Cite OpenProcess…* in the app gives BibTeX and a one-line citation, and +each algorithm's Code tab has *Cite* for the paper it follows. GitHub's +*Cite this repository* reads [CITATION.cff](CITATION.cff). Releases are +archived on Zenodo, which gives each version a DOI; the all-versions DOI +goes into `CITATION.cff` (`doi:`) once the first release is archived, and +the app's citations pick it up from there. + ## Licence MIT. See [LICENSE](LICENSE). diff --git a/docs/workflows.md b/docs/workflows.md index af6eef6..787a072 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -53,6 +53,7 @@ What the decorator reads, and nothing else: | the return type | the output; a `@dataclass` whose fields are OpenProcess types gives several outputs (one per field) | | `int`, `float`, `bool`, `str` with a default | a setting, the default as its value | | `Literal["a", "b"]` | a setting that is a choice | +| `Column` | a setting that is a column of a table connected to the box: in the app, a list of its columns to pick from (a name can still be typed) | | `Path` | a setting that is a file in the workflow's folder | | the docstring | the help text; a `name: text` line in it is the help of that setting | | `@box(name=…, group=…)` | the name on the canvas and the group under *+ Add box* (default: the function's name, and *Other*, which is listed under *Show all*) | diff --git a/openprocess/__init__.py b/openprocess/__init__.py index d7bdb66..8118f9b 100644 --- a/openprocess/__init__.py +++ b/openprocess/__init__.py @@ -48,7 +48,7 @@ #: The one place the version number is written (pyproject.toml, the update #: check and packaging/build.py read it from here). A release is tagged #: "v" + this, e.g. v0.2.0. -__version__ = "0.10.5" +__version__ = "0.10.6" __all__ = [ "Arc", "BindingElement", "CPNet", "Marking", "Multiset", "Page", "Place", diff --git a/openprocess/citation.py b/openprocess/citation.py index 9e4b951..4fafb77 100644 --- a/openprocess/citation.py +++ b/openprocess/citation.py @@ -14,6 +14,7 @@ import datetime as _dt import re from dataclasses import dataclass, field +from pathlib import Path from . import references as _references @@ -27,6 +28,23 @@ def app_version() -> str: return __version__ +def concept_doi() -> str: + """The DOI Zenodo gives the software (all versions), from ``CITATION.cff`` + at the top of the repository or installed beside the package; "" until + the first release is archived.""" + import re + for candidate in (Path(__file__).resolve().parents[1] / "CITATION.cff", + Path(__file__).resolve().parent / "CITATION.cff"): + try: + text = candidate.read_text(encoding="utf-8") + except OSError: + continue + found = re.search(r"^doi:\s*(\S+)\s*$", text, re.M) + if found: + return found.group(1).strip("'\"") + return "" + + # --------------------------------------------------------------------------- # One entry # --------------------------------------------------------------------------- @@ -137,45 +155,54 @@ def entries_for_module(module: str) -> list[Entry]: # --------------------------------------------------------------------------- # The app itself # --------------------------------------------------------------------------- -def app_entry(version: str | None = None, year: int | None = None) -> Entry: +def app_entry(version: str | None = None, year: int | None = None, doi: str | None = None) -> Entry: version = version or app_version() year = year or _dt.date.today().year + doi = concept_doi() if doi is None else doi return Entry("software", "openprocess", { "author": " and ".join(f"{last}, {first}" for last, first in APP_AUTHORS), "title": APP_TITLE, "version": version, "year": str(year), + "doi": doi, "url": APP_URL, "note": "Every algorithm shows its code and the paper it follows; every analysis keeps a record " "that runs again", }) -def app_bibtex(version: str | None = None, year: int | None = None) -> str: - return app_entry(version, year).bibtex() +def app_bibtex(version: str | None = None, year: int | None = None, doi: str | None = None) -> str: + return app_entry(version, year, doi).bibtex() -def app_text(version: str | None = None, year: int | None = None) -> str: +def app_text(version: str | None = None, year: int | None = None, doi: str | None = None) -> str: """The citation in one line, as a reference list would have it.""" version = version or app_version() year = year or _dt.date.today().year + doi = concept_doi() if doi is None else doi authors = ", ".join(f"{first} {last}" for last, first in APP_AUTHORS) - return f"{authors}. {APP_TITLE} (version {version}) [software], {year}. {APP_URL}" + where = f"https://doi.org/{doi}" if doi else APP_URL + return f"{authors}. {APP_TITLE} (version {version}) [software], {year}. {where}" -def cff_text(version: str | None = None, released: _dt.date | None = None) -> str: - """The contents of ``CITATION.cff`` (Citation File Format 1.2.0) for ``version``.""" +def cff_text(version: str | None = None, released: _dt.date | None = None, doi: str = "") -> str: + """The contents of ``CITATION.cff`` (Citation File Format 1.2.0) for + ``version``; ``doi`` is Zenodo's concept DOI once the first release is archived.""" version = version or app_version() released = released or _dt.date.today() authors = "\n".join(f" - family-names: {last}\n given-names: {first}" for last, first in APP_AUTHORS) + def quoted(text: str) -> str: # a YAML string: a colon in the title would otherwise break it + return '"' + text.replace('\\', '\\\\').replace('"', '\\"') + '"' + return ( "cff-version: 1.2.0\n" - "message: If you use OpenProcess in teaching or research, please cite it as below.\n" + f"message: {quoted('If you use OpenProcess in teaching or research, please cite it as below.')}\n" "type: software\n" - f"title: {APP_TITLE}\n" + f"title: {quoted(APP_TITLE)}\n" f"version: {version}\n" f"date-released: {released.isoformat()}\n" - f"repository-code: {APP_URL}\n" + + (f"doi: {doi}\n" if doi else "") + + f"repository-code: {APP_URL}\n" f"url: {APP_URL}\n" "license: MIT\n" "keywords:\n - process mining\n - Petri nets\n - workflow nets\n - conformance checking\n" diff --git a/openprocess/flow/__init__.py b/openprocess/flow/__init__.py index b41f244..b8a47e6 100644 --- a/openprocess/flow/__init__.py +++ b/openprocess/flow/__init__.py @@ -35,12 +35,13 @@ def my_miner(log: EventLog, threshold: float = 0.5) -> PetriNet: from .record import check, differences, load, save from .runner import Cache, Result, Run, Runner from .sweep import Sweep -from .types import (AlignmentResult, Any, CPNet, DFG, Dataset, EventLog, Figure, Footprint, Marking, +from .types import (Column, AlignmentResult, Any, CPNet, DFG, Dataset, EventLog, Figure, Footprint, Marking, PetriNet, Predictions, Predictor, ProcessTree, Regions, ReplayResult, Scores, SimpleLog, Table, Text, TransitionSystem, register_type) from .workflow import Edge, Group, Node, Workflow, WorkflowError, to_python, workflow __all__ = [ + "Column", "AlignmentResult", "Any", "Box", "BoxError", "BoxSpec", "CPNet", "Cache", "DFG", "Dataset", "Edge", "EventLog", "Explanation", "Figure", "Footprint", "Group", "Library", "Marking", "Node", "PetriNet", "Port", "Predictions", "Predictor", "ProcessTree", "Regions", "ReplayResult", "Result", "Run", diff --git a/openprocess/flow/box.py b/openprocess/flow/box.py index 7481dab..5b382a0 100644 --- a/openprocess/flow/box.py +++ b/openprocess/flow/box.py @@ -18,6 +18,7 @@ def learned_states(log: EventLog, clusters: int = 8, seed: int = 0) -> Transitio the return type the output; a dataclass of typed fields gives several ``int``, ``float``, ``bool``, ``str`` with a default a setting, with the default as its value ``Literal["a", "b"]`` a setting that is a choice +``Column`` a setting that is a column of a connected table ``Path`` a setting that is a file in the folder the docstring the help text ``inspect.getsource`` the Code tab @@ -44,7 +45,7 @@ def learned_states(log: EventLog, clusters: int = 8, seed: int = 0) -> Transitio from pathlib import Path from typing import Any, Callable, Literal, Union, get_args, get_origin, get_type_hints -from .types import TypeInfo, is_known, type_info +from .types import Column, TypeInfo, is_known, type_info SETTING_TYPES = (int, float, bool, str) @@ -106,7 +107,7 @@ class Setting: """A parameter that is not an input: shown as a control in the side panel.""" name: str - kind: str # "int", "float", "bool", "str", "choice", "path" + kind: str # "int", "float", "bool", "str", "choice", "path", "column" default: Any choices: tuple = () help: str = "" @@ -273,12 +274,14 @@ def _setting_from(name: str, annotation, default, has_default: bool, doc_hints: kind = "int" elif annotation is float: kind = "float" + elif annotation is Column: + kind = "column" elif annotation is str: kind = "str" else: raise BoxError( f"parameter {name!r} has type {_type_text(annotation)}, which is neither a OpenProcess " - f"type (an input) nor int, float, bool, str, Literal[...] or Path (a setting)") + f"type (an input) nor int, float, bool, str, Literal[...], Column or Path (a setting)") if not has_default: raise BoxError(f"setting {name!r} needs a default value (e.g. {name}: {kind} = ...)") if kind == "float" and isinstance(default, int) and not isinstance(default, bool): diff --git a/openprocess/flow/boxes/science.py b/openprocess/flow/boxes/science.py index 44e4f3a..bbaa0b3 100644 --- a/openprocess/flow/boxes/science.py +++ b/openprocess/flow/boxes/science.py @@ -17,7 +17,7 @@ from ...mining.petrinet import PetriNet from ..box import box from ..convert import convert -from ..types import Figure, Scores, Table +from ..types import Column, Figure, Scores, Table @box(name="Describe log", group="Science", needs="pandas") @@ -109,7 +109,7 @@ def correlate(table: Table) -> Table: @box(name="Plot", group="Science", needs="matplotlib") def plot(scores: list[Scores] = (), tables: list[Table] = (), - x: str = "", y: str = "") -> Figure: + x: Column = "", y: Column = "") -> Figure: """Bars per model for scores, histograms for bootstrap tables, or a line of column *y* against column *x* of a table (a sweep). The figure is a matplotlib figure, converted to SVG; its code is this box's code. diff --git a/openprocess/flow/reproducibility.py b/openprocess/flow/reproducibility.py index 8097f43..8c5e2fe 100644 --- a/openprocess/flow/reproducibility.py +++ b/openprocess/flow/reproducibility.py @@ -34,6 +34,15 @@ class Status: def tone(self) -> str: return {"reproduces": "good", "changed": "warning", "differs": "critical"}.get(self.state, "muted") + @property + def short(self) -> str: + """Two or three words for a badge; :attr:`headline` is the sentence.""" + if self.state == "changed": + count = len(self.details) + return f"{count} change{'s' if count != 1 else ''} since" + return {"unrecorded": "Not recorded", "reproduces": "Reproducible", "differs": "Differs from the record", + "unchecked": "Recorded, not run"}.get(self.state, self.headline) + def status(workflow: Workflow, record: Record | None, run: Run | None, folder=None) -> Status: """The analysis's reproducibility, from its record and its latest run.""" diff --git a/openprocess/flow/runner.py b/openprocess/flow/runner.py index de2dacd..47e890a 100644 --- a/openprocess/flow/runner.py +++ b/openprocess/flow/runner.py @@ -41,6 +41,25 @@ "waiting", "running", "done", "failed", "idle", "blocked", "skipped") +def columns_connected(workflow: Workflow, run: "Run | None", node: Node) -> list[str]: + """The columns of the tables connected to ``node``, as of ``run``: what a + ``Column`` setting lists. Empty until the boxes before it have run.""" + if run is None: + return [] + from .types import Table + out: list[str] = [] + for sources in workflow.inputs_of(node).values(): + for source_id, output in sources: + result = run.result(source_id) + if result is None or result.status != DONE: + continue + value = result.values.get(output, result.value) + for item in (value if isinstance(value, (list, tuple)) else [value]): + if isinstance(item, Table): + out.extend(column for column in item.columns if column not in out) + return out + + @dataclass class Result: """What one box produced in one run (one per sweep variant).""" @@ -270,6 +289,14 @@ def _run_node(self, workflow: Workflow, node: Node, run: Run, variant: int, values.append(self._fit(value, port)) keys.append(upstream.key + ":" + output) inputs[port.name] = values if port.many else values[0] + # Nothing plugged in at all (every input optional, as Plot's are): the box + # would run on nothing and fail. Wait instead, and say what to connect. + if spec.inputs and not any(fed[port.name] for port in spec.inputs): + labels = [port.label or port.name for port in spec.inputs] + result.status = IDLE + result.message = "Connect " + (labels[0] if len(labels) == 1 else + ", ".join(labels[:-1]) + " or " + labels[-1]) + return result # A file not chosen yet (a box just added from the list): wait and say # so, rather than run and fail with a traceback about None. for setting in spec.settings: @@ -376,5 +403,5 @@ def run_in_process(spec: BoxSpec, inputs: dict, settings: dict, timeout: float | return outcome[1], outcome[2] -__all__ = ["BLOCKED", "Cache", "DONE", "FAILED", "IDLE", "RUNNING", "Result", "Run", "Runner", +__all__ = ["columns_connected", "BLOCKED", "Cache", "DONE", "FAILED", "IDLE", "RUNNING", "Result", "Run", "Runner", "WAITING", "run_in_process", "settings_key"] diff --git a/openprocess/flow/types.py b/openprocess/flow/types.py index 0ec49ea..9d45cab 100644 --- a/openprocess/flow/types.py +++ b/openprocess/flow/types.py @@ -112,6 +112,17 @@ def to_csv(self, path) -> None: writer.writerows(self.rows) + +class Column(str): + """The name of a column of a table connected to the box. + + A setting typed ``Column`` (``x: Column = ""``) is a plain string to the + function. In the app it is a list of the connected table's columns to + pick from, once the box before it has run; a name can still be typed. + It is a setting, not an input: the table is the input. + """ + + @dataclass class Figure: """A picture: SVG text, or PNG bytes, as a box made it.""" @@ -286,6 +297,7 @@ def is_known(cls) -> bool: register_type(_cls, _key, _name, _colour) __all__ = [ + "Column", "AlignmentResult", "Any", "CPNet", "DFG", "Dataset", "EventLog", "Figure", "Footprint", "OCDFG", "OCEL", "Marking", "PetriNet", "Predictions", "Predictor", "ProcessTree", "Regions", "ReplayResult", "Scores", "SimpleLog", "Table", "Text", "TransitionSystem", "TypeInfo", "TYPES", diff --git a/openprocess/gui/flow/page.py b/openprocess/gui/flow/page.py index 62e7355..e712559 100644 --- a/openprocess/gui/flow/page.py +++ b/openprocess/gui/flow/page.py @@ -36,7 +36,8 @@ from ...flow import record as records from ...flow import reproducibility, snapshots from ...flow.library import Library -from ...flow.runner import BLOCKED, DONE, FAILED, IDLE, RUNNING, WAITING, Cache, Result, Run, Runner +from ...flow.runner import (BLOCKED, DONE, FAILED, IDLE, RUNNING, WAITING, Cache, Result, Run, Runner, + columns_connected) from ...flow.types import EventLog, Figure, PetriNet, Scores, Table from ...flow.workflow import Edge, Workflow, group_to_python, to_python from .. import theme @@ -341,7 +342,7 @@ def _subtitle(self) -> str: def refresh_title(self) -> None: self.header.set_text(self.workflow.name, self._subtitle()) status = self._repro - self.header.set_badge(status.headline, status.tone, + self.header.set_badge(status.short, status.tone, "\n".join([status.headline] + ["· " + d for d in status.details] + ["", "Click for the record."]), self.show_record) @@ -879,7 +880,8 @@ def _tab_widget(self, node, spec, result: Result | None, index: int) -> QWidget: return how_widget(result.explanation, self, result.value) if index == 2: return code_widget(spec, self) - settings = SettingsWidget(spec, node.settings, self.folder) + settings = SettingsWidget(spec, node.settings, self.folder, + columns=columns_connected(self.workflow, self.run, node)) settings.changed.connect(lambda name, value, nid=node.id: self._setting_changed(nid, name, value)) return settings diff --git a/openprocess/gui/flow/viewers.py b/openprocess/gui/flow/viewers.py index f08420d..3a564c7 100644 --- a/openprocess/gui/flow/viewers.py +++ b/openprocess/gui/flow/viewers.py @@ -658,9 +658,11 @@ class SettingsWidget(QWidget): changed = Signal(str, object) - def __init__(self, spec: BoxSpec, settings: dict, folder: Path | None = None, parent=None) -> None: + def __init__(self, spec: BoxSpec, settings: dict, folder: Path | None = None, parent=None, + columns: list[str] | None = None) -> None: super().__init__(parent) self.spec, self.folder = spec, folder + self.columns = list(columns or []) # of the tables connected to the box, for Column settings layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(10) @@ -706,6 +708,8 @@ def _row(self, setting: Setting, value) -> QWidget: control.valueChanged.connect(lambda v, s=setting: self.changed.emit(s.name, v)) elif setting.kind == "path": control = self._path_control(setting, value) + elif setting.kind == "column": + control = self._column_control(setting, value) else: control = QLineEdit(str(value) if not swept else "") control.editingFinished.connect(lambda s=setting, c=control: self.changed.emit(s.name, c.text())) @@ -757,6 +761,41 @@ def _sweep(self, setting: Setting, edit: QLineEdit) -> None: edit.setStyleSheet("") self.changed.emit(setting.name, sweep) + def _column_control(self, setting: Setting, value) -> QComboBox: + """A column of a connected table: the table's columns to pick from, once + the box before this one has run, or a name typed in. The first entry + is no column at all (Plot without x and y draws bars, not a line).""" + control = QComboBox() + control.setEditable(True) + control.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + control.setMinimumContentsLength(12) + control.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + control.setMaxVisibleItems(14) + control.lineEdit().setPlaceholderText( + "Pick a column…" if self.columns else "Connect a table and run it; its columns are listed here") + control.addItem("—", "") + for name in self.columns: + control.addItem(name, name) + control.setCurrentText("" if value in (None, "") else str(value)) + last = {"text": control.currentText()} + + def report(text: str, s=setting): + if text == last["text"]: # only a real change: see _path_control + return + last["text"] = text + self.changed.emit(s.name, text) + + def picked(index: int, c=control): + text = c.itemData(index) if index >= 0 else c.currentText() + text = "" if text is None else str(text) + if c.currentText() != text: + c.setCurrentText(text) + report(text) + + control.currentIndexChanged.connect(picked) + control.lineEdit().editingFinished.connect(lambda c=control: report(c.currentText().strip())) + return control + def _path_control(self, setting: Setting, value) -> QComboBox: """The file field: a list of the folder's files to pick from, or a path typed or pasted (quotes and shell escapes are tolerated).""" diff --git a/openprocess/gui/learn/answer_boxes.py b/openprocess/gui/learn/answer_boxes.py index 2e7cbf7..8f2276c 100644 --- a/openprocess/gui/learn/answer_boxes.py +++ b/openprocess/gui/learn/answer_boxes.py @@ -27,7 +27,7 @@ from ...learn import answers, notation from ...learn.checks import CORRECT, INCORRECT, PARTIAL, UNKNOWN from ..studio.markdown_view import MarkdownLabel -from ..studio.widgets import button, hbox, label +from ..studio.widgets import Chip, button, hbox, label #: What the card's caption says above each kind of box. CAPTIONS = { @@ -781,7 +781,7 @@ def __init__(self, task, editor: Editor, folder=None, parent=None, if task.points != 1: caption += f" · {task.points:g} points" self.caption = label(caption.upper(), "taskCaption") - self.chip = label("", "statusChip") + self.chip = Chip() self.chip.setHidden(True) layout.addLayout(hbox(self.caption, None, self.chip)) layout.addWidget(editor) @@ -852,9 +852,7 @@ def set_status(self, status: str | None) -> None: if shown is not None: text, colour = shown self.chip.setText(("✓ " if colour == "good" else "") + text) - self.chip.setProperty("state", colour) - self.chip.style().unpolish(self.chip) - self.chip.style().polish(self.chip) + self.chip.set_state(colour) self.setProperty("state", (shown or ("", ""))[1]) self.style().unpolish(self) self.style().polish(self) diff --git a/openprocess/gui/studio/app.py b/openprocess/gui/studio/app.py index c5c0b2f..bf601ba 100644 --- a/openprocess/gui/studio/app.py +++ b/openprocess/gui/studio/app.py @@ -883,11 +883,12 @@ def install_tool(self, name: str) -> None: """*Install…* on a Connections card: pip installs the package into this app's Python, in the background; the boxes that need it come alive.""" import importlib + from ... import packages from .connections import package_for package = package_for(name) - command = [sys.executable, "-m", "pip", "install", package] + command = packages.install_command(package) answer = QMessageBox.question( - self, "Install a tool", f"Install {package} into this app's Python?\n\nThis runs:\n" + self, "Install a tool", f"Install {package} {packages.where_text()}?\n\nThis runs:\n" f"{' '.join(command)}\n\nThe app stays usable meanwhile.", QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Yes) if answer != QMessageBox.Yes: @@ -899,6 +900,7 @@ def install_tool(self, name: str) -> None: def done(result) -> None: code, output = result self.connections_page.installing.discard(name) + packages.activate() # the downloaded app: the new folder, on the path importlib.invalidate_caches() self.connections_page.refresh(self.library(), self.workspace.folder if self.workspace else None) if code == 0: @@ -1465,7 +1467,9 @@ def add_document(self, document, options: dict | None = None, if options: self.open_options[document.id] = options self._make_page(document) - if not keep_unsaved: + # A view of a box's result waits for Keep in folder (a model has its own Keep, + # which _materialise shows); everything else becomes a file now. + if not keep_unsaved and (not self.is_view(document) or isinstance(document, ModelDocument)): self._materialise(document, near) if document.path: key = self._key(document.path) @@ -1567,6 +1571,12 @@ def _make_page(self, document) -> QWidget: origin = getattr(document, "origin", "") if isinstance(document, CpnDocument) else "" text = f"‹ A copy, {origin}" if origin else f"‹ Back to {source.name}" page.header.set_back(text, lambda _checked=False, s=source: self.back_to(s)) + if self.is_view(document) and self.workspace is not None and not hasattr(page, "keep_button"): + # A view is not a file of the folder until you say so. + page.keep_view_button = button("Keep in folder", lambda _checked=False, d=document: self.keep_view(d), + kind="primary", tooltip="Save this as a file in the folder; until " + "then it is only a view of the box's result") + page.header.actions.insertWidget(0, page.keep_view_button) self.pages[document.id] = page # The page sits in a scroll area: if the window is made smaller than # the page's minimum size, scroll bars appear instead of the window @@ -1581,6 +1591,30 @@ def _make_page(self, document) -> QWidget: self.content.addWidget(holder) return page + @staticmethod + def is_view(document) -> bool: + """A page opened from a box's result (Open as log ›): a view of something + the workflow can make again. Not saved to the folder unless kept, and + closed without a question.""" + return getattr(document, "opened_from", None) is not None and not document.path and \ + isinstance(document, (LogDocument, TransitionSystemDocument, ModelDocument)) + + def keep_view(self, document) -> None: + """A view's *Keep in folder*: now it is a file of the folder like any other.""" + source = getattr(document, "opened_from", None) + near = getattr(source, "path", None) + self._materialise(document, near) + if document.path: + page = self.pages.get(document.id) + keep = getattr(page, "keep_view_button", None) + if keep is not None: + keep.setVisible(False) + self._remember_identity(document) + self._update_autosave(document) + self._rebuild_sidebar() + self._select_document(document) + self.statusBar().showMessage(f"Kept as {Path(document.path).name}", 6000) + def back_to(self, source) -> None: """A page's Back link: the analysis it was opened from, if it is still open.""" if source in self.documents: @@ -1769,8 +1803,9 @@ def remove_documents(self, ids: list[int], confirm: bool = True) -> bool: if isinstance(other, ComparisonDocument) and other not in documents and \ any(log.id in removed_logs for log in other.logs): documents.append(other) - # A comparison is recreated in a moment, so it needs no confirmation. - unsaved = [d for d in documents if not isinstance(d, ComparisonDocument) and + # A comparison is recreated in a moment, and a view of a box's result + # can be opened again from the box, so neither needs a confirmation. + unsaved = [d for d in documents if not isinstance(d, ComparisonDocument) and not self.is_view(d) and (not d.path or d.missing or getattr(d, "dirty", False))] if unsaved and confirm: names = "\n".join(f"• {d.name}" for d in unsaved[:8]) @@ -4459,6 +4494,8 @@ def main(argv: list[str] | None = None) -> int: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("OpenProcess.Studio") except (AttributeError, OSError): pass + from ... import packages + packages.activate() # packages installed from Connections application = QApplication.instance() or QApplication(argv) application.setApplicationName(APPLICATION_NAME) application.setWindowIcon(app_icon()) diff --git a/openprocess/gui/studio/connections.py b/openprocess/gui/studio/connections.py index 876d9c9..016b354 100644 --- a/openprocess/gui/studio/connections.py +++ b/openprocess/gui/studio/connections.py @@ -18,7 +18,7 @@ from PySide6.QtWidgets import QScrollArea, QVBoxLayout, QWidget from ...flow.library import Library -from .widgets import Card, PageHeader, button, flow, hbox, label +from .widgets import Card, Chip, PageHeader, button, flow, hbox, label #: (title, what it is, the input boxes' function names) INPUTS = [ @@ -50,9 +50,17 @@ def package_for(tool: str) -> str: def can_install() -> bool: - """Whether the app can run pip for itself: a Python install with pip, not - the downloaded app (whose bundled Python has no pip and is read-only).""" - return not getattr(sys, "frozen", False) and find_spec("pip") is not None + """Whether Install… can work here (see :mod:`openprocess.packages`: from a + Python install pip runs in that Python; the downloaded app carries pip and + installs into a folder of the user's own).""" + from ... import packages + return packages.can_install() + + +def installable(tool: str) -> bool: + """Whether Install… works for this tool here (pip, and a package this build can install).""" + from ... import packages + return can_install() and packages.installable(package_for(tool)) def _by_function(library: Library) -> dict[str, object]: @@ -117,9 +125,7 @@ def card(title: str, text: str, boxes: list[str], status: str = "", tone: str = item.setMinimumWidth(200) item.setMaximumWidth(300) if status: - chip = label(status, "statusChip") - chip.setStyleSheet(f"background: {_tone(tone)}; color: white;") - item.header.addWidget(chip) + item.header.addWidget(Chip(status, fill=_tone(tone), ink="white")) if boxes: item.add(label("Boxes: " + ", ".join(boxes), "muted", wrap=True)) self.cards[title] = item @@ -152,14 +158,19 @@ def card(title: str, text: str, boxes: list[str], status: str = "", tone: str = text, status, tone = "Installed.", "ready", "good" elif name in self.installing: text, status, tone = "Installing… (a minute or two; the app stays usable)", "installing", "muted" + elif installable(name): + from ... import packages + text, status, tone = (f"Not installed. Install… puts it {packages.where_text()}; the boxes " + "that need it come alive without a restart."), "not installed", "warning" elif can_install(): - text, status, tone = f"Not installed. Install… runs {tool['install']} for this app.", \ - "not installed", "warning" + text, status, tone = (f"Not installed. The downloaded app installs only packages that come " + f"ready-built, and this one needs building: in a Python install, run " + f"{tool['install']}."), "not installed", "warning" else: - text, status, tone = (f"Not installed. The downloaded app cannot install packages: in a " - f"Python install, run {tool['install']}."), "not installed", "warning" + text, status, tone = (f"Not installed, and this build has no pip: in a Python install, run " + f"{tool['install']}."), "not installed", "warning" item = card(name, text, tool["boxes"], status, tone) - if not tool["installed"] and can_install() and name not in self.installing: + if not tool["installed"] and installable(name) and name not in self.installing: install = button("Install…", lambda _checked=False, n=name: self.install_requested.emit(n), kind="primary", tooltip=f"Runs {tool['install']} in the background") self.install_buttons[name] = install diff --git a/openprocess/gui/studio/style.py b/openprocess/gui/studio/style.py index f3dd6e8..19e5327 100644 --- a/openprocess/gui/studio/style.py +++ b/openprocess/gui/studio/style.py @@ -280,13 +280,6 @@ def stylesheet() -> str: #taskCard[state="warning"] {{ border-color: {qc(STATUS["warning"], 0.7).name(QColor.HexArgb)}; }} #taskCaption {{ color: {t.text_muted}; font-size: 10px; font-weight: 700; letter-spacing: 0.8px; }} - #statusChip {{ font-size: 11px; font-weight: 600; border-radius: 9px; padding: 2px 8px; }} - #statusChip[state="good"] {{ color: {STATUS["good"]}; - background: {qc(STATUS["good"], 0.13).name(QColor.HexArgb)}; }} - #statusChip[state="warning"] {{ color: {t.text}; - background: {qc(STATUS["warning"], 0.25).name(QColor.HexArgb)}; }} - #statusChip[state="critical"] {{ color: {STATUS["critical"]}; - background: {qc(STATUS["critical"], 0.12).name(QColor.HexArgb)}; }} #feedback {{ border-radius: 8px; padding: 7px 10px; background: {qc(t.text, 0.05).name(QColor.HexArgb)}; }} #feedback[state="good"] {{ background: {qc(STATUS["good"], 0.12).name(QColor.HexArgb)}; }} @@ -354,8 +347,9 @@ def stylesheet() -> str: QListWidget#paletteList::item:selected {{ background: {t.accent_soft}; color: {t.text}; }} /* The reproducibility badge beside an analysis's title. */ - QPushButton#reproBadge {{ border: none; border-radius: 9px; padding: 2px 9px; font-size: 11px; - font-weight: 600; }} + QPushButton#reproBadge {{ border: none; border-radius: 10px; padding: 3px 10px 3px 8px; font-size: 11px; + font-weight: 600; letter-spacing: 0.1px; }} + QPushButton#reproBadge:hover {{ text-decoration: underline; }} /* The Summary's tiles and the Model palette. */ #summaryValue {{ font-size: 22px; font-weight: 600; }} diff --git a/openprocess/gui/studio/widgets.py b/openprocess/gui/studio/widgets.py index 0d608cf..e0a9904 100644 --- a/openprocess/gui/studio/widgets.py +++ b/openprocess/gui/studio/widgets.py @@ -10,7 +10,7 @@ import sys from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal -from PySide6.QtGui import QColor, QFontMetricsF, QPainter, QPainterPath, QPen +from PySide6.QtGui import QColor, QFont, QFontMetricsF, QPainter, QPainterPath, QPen from PySide6.QtWidgets import ( QButtonGroup, QComboBox, QFrame, QMenu, QHBoxLayout, QLabel, QLayout, QPushButton, QSizePolicy, QStyle, QStyledItemDelegate, QVBoxLayout, QWidget, @@ -357,6 +357,73 @@ def fitness_status(value: float) -> str: # --------------------------------------------------------------------------- +class Chip(QWidget): + """A small pill with a word or two in it: "not installed", "ready", "✓ correct". + + Painted here rather than styled. A label's background and corner radius + come from the style's box model, which polishes when it likes: the + downloaded app showed square chips beside round ones. A pill must look + the same everywhere, so this one draws itself from its font metrics. + ``fill`` and ``ink`` are colours; :meth:`set_state` picks the soft tints of + the status colours instead (good, warning, critical, or muted). + """ + + def __init__(self, text: str = "", fill=None, ink=None, parent=None) -> None: + super().__init__(parent) + self._text = text + self._fill = QColor(fill) if fill is not None else QColor(style.tokens().text_muted) + self._ink = QColor(ink) if ink is not None else QColor("white") + font = QFont(self.font()) + font.setPixelSize(11) + font.setWeight(QFont.DemiBold) + self.setFont(font) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + def text(self) -> str: + return self._text + + def setText(self, text: str) -> None: # noqa: N802 - a label's name, so a chip can stand in for one + self._text = text + self.updateGeometry() + self.update() + + def set_colours(self, fill, ink) -> None: + self._fill, self._ink = QColor(fill), QColor(ink) + self.update() + + def set_state(self, state: str) -> None: + """The soft look of a status: a tint of its colour behind ink of the same.""" + t = style.tokens() + if state == "good": + self.set_colours(style.qc(style.STATUS["good"], 0.13), style.STATUS["good"]) + elif state == "warning": + self.set_colours(style.qc(style.STATUS["warning"], 0.25), t.text) + elif state == "critical": + self.set_colours(style.qc(style.STATUS["critical"], 0.12), style.STATUS["critical"]) + else: + self.set_colours(style.qc(t.text, 0.08), t.text_secondary) + + def sizeHint(self) -> QSize: # noqa: N802 + metrics = QFontMetricsF(self.font()) + return QSize(int(metrics.horizontalAdvance(self._text)) + 18, max(20, int(metrics.height()) + 6)) + + def minimumSizeHint(self) -> QSize: # noqa: N802 + return self.sizeHint() + + def paintEvent(self, event) -> None: # noqa: N802 + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + radius = rect.height() / 2 + painter.setPen(Qt.NoPen) + painter.setBrush(self._fill) + painter.drawRoundedRect(rect, radius, radius) + painter.setPen(QPen(self._ink)) + painter.setFont(self.font()) + painter.drawText(self.rect(), Qt.AlignCenter, self._text) + painter.end() + + # Activity chips # --------------------------------------------------------------------------- def paint_chips(painter: QPainter, rect: QRectF, sequence: list[tuple[str, str, str | None]], @@ -527,6 +594,9 @@ def __init__(self, title: str = "", subtitle: str = "", parent=None) -> None: text = QVBoxLayout() text.setSpacing(2) self.title = ElidedLabel(title, "pageTitle") + # In a row with the badge, the title keeps its natural width and only + # shrinks (eliding) when the header is short of space. + self.title.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred) self.subtitle = ElidedLabel(subtitle, "pageSubtitle") title_row = QHBoxLayout() title_row.setSpacing(10) @@ -544,31 +614,53 @@ def __init__(self, title: str = "", subtitle: str = "", parent=None) -> None: layout.addLayout(self.actions) def set_badge(self, text: str, tone: str = "muted", tooltip: str = "", slot=None) -> None: - """A small chip beside the title ("Recorded · this run reproduces it"). - ``tone``: good, warning, critical or muted. Empty text hides it.""" + """A pill beside the title ("● Reproducible"): a soft tint of the tone with + a dot and a few words; the full sentence is the tooltip. ``tone``: good, + warning, critical or muted. Empty text hides it. Short of room, the + pill keeps only its dot (see :meth:`resizeEvent`).""" if self.badge is None: self.badge = QPushButton() self.badge.setObjectName("reproBadge") self.badge.setCursor(Qt.PointingHandCursor) self.badge.setFocusPolicy(Qt.NoFocus) + self.badge.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self._title_row.insertWidget(1, self.badge, 0, Qt.AlignVCenter) self._badge_slot = None from . import style - colours = {"good": (style.STATUS["good"], "#ffffff"), - "warning": (style.STATUS["warning"], "#1d1d1f"), - "critical": (style.STATUS["critical"], "#ffffff"), - "muted": (style.qc(style.tokens().text, 0.10).name(QColor.HexArgb), style.tokens().text_secondary)} - background, ink = colours.get(tone, colours["muted"]) - self.badge.setStyleSheet(f"QPushButton#reproBadge {{ background: {background}; color: {ink}; }}") - self.badge.setText(text) + t = style.tokens() + ink = {"good": style.STATUS["good"], "warning": style.STATUS["warning"], + "critical": style.STATUS["critical"]}.get(tone, t.text_secondary) + tint = style.qc(ink, 0.14 if tone != "muted" else 0.08).name(QColor.HexArgb) + # The whole rule here, not only the colours: on macOS a button whose own + # sheet sets a background but no border falls back to a square frame. + self.badge.setStyleSheet( + f"QPushButton#reproBadge {{ background: {tint}; color: {ink}; border: none; border-radius: 11px; " + f"min-height: 22px; max-height: 22px; padding: 0 11px 0 9px; font-size: 11px; font-weight: 600; }}" + f"QPushButton#reproBadge:hover {{ background: {style.qc(ink, 0.22 if tone != 'muted' else 0.14).name(QColor.HexArgb)}; }}") + self._badge_text = text self.badge.setToolTip(tooltip) self.badge.setVisible(bool(text)) + self._fit_badge() if self._badge_slot is not None: self.badge.clicked.disconnect(self._badge_slot) self._badge_slot = slot if slot is not None: self.badge.clicked.connect(slot) + def _fit_badge(self) -> None: + """The pill's words when there is room for them beside the title, else its dot alone.""" + if self.badge is None or not getattr(self, "_badge_text", ""): + return + metrics = self.badge.fontMetrics() + full = f"● {self._badge_text}" + needed = metrics.horizontalAdvance(full) + 24 + self.title.sizeHint().width() + self._title_row.spacing() + room = self._title_row.contentsRect().width() if self._title_row.contentsRect().width() > 0 else needed + self.badge.setText(full if needed <= room else "●") + + def resizeEvent(self, event) -> None: # noqa: N802 + super().resizeEvent(event) + self._fit_badge() + def set_back(self, text: str, slot) -> None: """A link above the title, back to where this page was opened from ("‹ Back to Discover and check").""" diff --git a/openprocess/packages.py b/openprocess/packages.py new file mode 100644 index 0000000..1734615 --- /dev/null +++ b/openprocess/packages.py @@ -0,0 +1,139 @@ +"""Optional packages, installed from inside the app. + +Some boxes need a package the app does not ship (pandas, numpy, scipy, +matplotlib). From a Python install, the app runs ``pip`` in that Python. +The downloaded app has a Python of its own with no pip on any path and no +writable site-packages, so it carries pip inside, runs it in a copy of +itself (``OpenProcess --pip install …``), and installs into a folder of +the user's own (:func:`user_site`), which :func:`activate` puts on the import +path at start-up and right after an install. Either way the boxes that +need the package come alive without a restart. +""" + +from __future__ import annotations + +import importlib +import os +import re +import sys +from importlib.util import find_spec +from pathlib import Path + +#: The flag that makes the downloaded app run pip instead of opening a window. +PIP_FLAG = "--pip" +#: Packages that need building from source (theirs or a dependency's), which +#: the downloaded app cannot do: it installs ready-built wheels only, so that +#: pip never has to start a Python (the app's own executable would open a window). +SOURCE_ONLY = ("pm4py",) +#: Standard library modules the downloaded app leaves out: test suites, IDEs +#: and toys, and the "dead batteries" Python 3.13 removed. +STDLIB_SKIP = frozenset({ + "test", "idlelib", "tkinter", "turtle", "turtledemo", "lib2to3", "pydoc_data", "ensurepip", "venv", + "antigravity", "this", "aifc", "audioop", "cgi", "cgitb", "chunk", "crypt", "imghdr", "mailcap", "msilib", + "nis", "nntplib", "ossaudiodev", "pipes", "sndhdr", "spwd", "sunau", "telnetlib", "uu", "xdrlib"}) + + +def frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def user_site() -> Path: + """Where the downloaded app installs packages: the user's own application + data, per Python version (compiled packages are built for one).""" + tag = f"py{sys.version_info.major}.{sys.version_info.minor}" + if sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" / "OpenProcess" + elif sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "OpenProcess" + else: + base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "openprocess" + return base / "packages" / tag + + +def activate() -> bool: + """Put :func:`user_site` on the import path when it exists (after the + app's own modules, which stay as shipped). Returns whether it was added + (False when absent or already there).""" + site = user_site() + if not site.is_dir(): + return False + text = str(site) + if text in sys.path: + return False + sys.path.append(text) + importlib.invalidate_caches() + return True + + +def can_install() -> bool: + """Whether Install… can work here: pip is importable (bundled, in the + downloaded app) and, from a Python install, this Python can run it.""" + return find_spec("pip") is not None + + +def name_of(requirement: str) -> str: + """``"pandas>=2.0"`` → ``"pandas"``.""" + return re.split(r"[<>=!~\[; ]", requirement.strip(), maxsplit=1)[0].lower() + + +def installable(requirement: str) -> bool: + """Whether this build can install ``requirement``: any package from a + Python install; in the downloaded app, one that comes as wheels.""" + return not (frozen() and name_of(requirement) in SOURCE_ONLY) + + +def install_command(requirement: str) -> list[str]: + """The command that installs ``requirement`` for this app.""" + if frozen(): + return [sys.executable, PIP_FLAG, "install", "--upgrade", "--only-binary", ":all:", + "--target", str(user_site()), requirement] + return [sys.executable, "-m", "pip", "install", requirement] + + +def where_text() -> str: + """A few words on where Install… puts a package, for the Connections cards.""" + return "into the app's own packages folder" if frozen() else "into this app's Python" + + +def stdlib_modules() -> list[str]: + """Every standard library module, for the downloaded app to carry. + + PyInstaller packs only what the app imports, but a package installed from + Connections imports what it likes (matplotlib wants ``unittest``), so the + build lists the whole library: the top-level modules this Python has, and + every submodule of its packages, found without importing the packages. + """ + import pkgutil + names: list[str] = [] + for name in sorted(getattr(sys, "stdlib_module_names", ())): + if name.startswith("_") or name in STDLIB_SKIP: + continue + try: + spec = find_spec(name) + except (ImportError, ValueError): + continue + if spec is None: + continue + names.append(name) + if spec.submodule_search_locations: + for info in pkgutil.walk_packages(spec.submodule_search_locations, name + ".", onerror=lambda _n: None): + if not info.name.endswith(".__main__"): + names.append(info.name) + return names + + +def run_pip(arguments: list[str]) -> int: + """Run pip in this process (the downloaded app, launched with ``--pip``).""" + try: + from pip._internal.cli.main import main as pip_main + except ImportError: + print("pip is not available in this build", file=sys.stderr) + return 1 + target = user_site() + if "--target" in arguments: + target.mkdir(parents=True, exist_ok=True) + return int(pip_main(list(arguments)) or 0) + + +__all__ = ["PIP_FLAG", "SOURCE_ONLY", "STDLIB_SKIP", "activate", "can_install", "frozen", "install_command", + "installable", "name_of", "run_pip", "stdlib_modules", "user_site", "where_text"] diff --git a/packaging/openprocess.spec b/packaging/openprocess.spec index ab1379a..02bafc2 100644 --- a/packaging/openprocess.spec +++ b/packaging/openprocess.spec @@ -35,7 +35,19 @@ elif sys.platform == "win32": else: ICON = None # Linux: the icon goes in the .desktop file -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_all, collect_submodules + +# pip travels inside the app, so Connections ▸ Install… can add pandas, numpy, +# scipy or matplotlib to a folder of the user's own (openprocess.packages). +PIP_DATAS, PIP_BINARIES, PIP_HIDDEN = collect_all("pip") + +# A package installed that way imports what it likes from the standard library +# (matplotlib wants unittest), and PyInstaller packs only what the app imports: +# so the whole library is listed (openprocess.packages.stdlib_modules). +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +from openprocess.packages import stdlib_modules +STDLIB = stdlib_modules() # The Code tab reads a box's source with inspect.getsource, and the algorithm # it calls likewise: the bundle carries every .py file of the package next to @@ -46,9 +58,10 @@ SOURCES = [(str(path), str(pathlib.Path("openprocess") / path.relative_to(ROOT / analysis = Analysis( [str(PACKAGING / "openprocess_studio.py")], pathex=[str(ROOT)], + binaries=PIP_BINARIES, # Files the code opens by path at run time (the window icon, and the demo # exercises that File ▸ Open Demo Exercises copies to Documents). - datas=[(str(ROOT / "openprocess" / "gui" / "resources" / "openprocess-icon.png"), + datas=PIP_DATAS + [(str(ROOT / "openprocess" / "gui" / "resources" / "openprocess-icon.png"), "openprocess/gui/resources"), (str(ROOT / "openprocess" / "exercises"), "openprocess/exercises"), # Help ▸ Writing Exercise Packs. @@ -60,8 +73,8 @@ analysis = Analysis( # listed so they are never missed. certifi brings its certificate file # (PyInstaller's hook), which the update check needs to reach GitHub: the # bundled Python cannot use the system's. - hiddenimports=["openprocess.analysis.state_space_process", "certifi"] - + collect_submodules("openprocess.flow.boxes"), + hiddenimports=["openprocess.analysis.state_space_process", "certifi", "openprocess.packages"] + + collect_submodules("openprocess.flow.boxes") + PIP_HIDDEN + STDLIB, # Not needed by the app. PM4Py is an optional extra (AGPL-3.0) and is # never bundled; the app hides its PM4Py options when it is missing. excludes=["pm4py", "tkinter", "pytest", "matplotlib", "numpy", "pandas", "scipy"], diff --git a/packaging/openprocess_studio.py b/packaging/openprocess_studio.py index 9132e58..588b70d 100644 --- a/packaging/openprocess_studio.py +++ b/packaging/openprocess_studio.py @@ -96,9 +96,13 @@ def main() -> int: # Decide what this run is before importing Qt: a state space worker must # stay light and must never open a window. from openprocess.analysis.state_space_process import WORKER_FLAG, run_worker + from openprocess.packages import PIP_FLAG, activate, run_pip if len(sys.argv) > 1 and sys.argv[1] == WORKER_FLAG: return run_worker(sys.argv[2:5]) + if len(sys.argv) > 1 and sys.argv[1] == PIP_FLAG: + return run_pip(sys.argv[2:]) # Install… from Connections, in a copy of the app + activate() # packages installed that way, on the import path if len(sys.argv) > 1 and sys.argv[1] == SELF_TEST_FLAG: return self_test(sys.argv[2] if len(sys.argv) > 2 else None) diff --git a/tests/test_citation.py b/tests/test_citation.py index 9751e07..52893e1 100644 --- a/tests/test_citation.py +++ b/tests/test_citation.py @@ -53,3 +53,30 @@ def test_the_app_cites_itself_and_the_cff_file_is_current(): "CITATION.cff names another version than openprocess.__version__: regenerate it with citation.cff_text" generated = citation.cff_text("1.2.3", datetime.date(2030, 1, 2)) assert "cff-version: 1.2.0" in generated and "date-released: 2030-01-02" in generated + + +def test_the_doi_comes_from_citation_cff_when_there_is_one(tmp_path, monkeypatch): + without = citation.cff_text("1.0.0", datetime.date(2030, 1, 2)) + assert "doi:" not in without + with_doi = citation.cff_text("1.0.0", datetime.date(2030, 1, 2), doi="10.5281/zenodo.1234567") + assert "doi: 10.5281/zenodo.1234567\n" in with_doi + assert "doi" not in citation.app_entry("1.0.0", 2030, doi="").fields or not citation.app_entry("1.0.0", 2030, doi="").fields["doi"] + entry = citation.app_entry("1.0.0", 2030, doi="10.5281/zenodo.1234567") + assert "doi = {10.5281/zenodo.1234567}" in entry.bibtex() + assert citation.app_text("1.0.0", 2030, doi="10.5281/zenodo.1234567").endswith("https://doi.org/10.5281/zenodo.1234567") + assert citation.app_text("1.0.0", 2030, doi="").endswith(citation.APP_URL) + assert citation.concept_doi() == "" # not archived yet: CITATION.cff has no doi line + + +def test_citation_cff_is_valid_yaml_despite_the_colon_in_the_title(): + """Zenodo's archive of v0.10.5 failed on CITATION.cff: the title's colon + was unquoted. String values with a colon are quoted now.""" + text = citation.cff_text("1.0.0", datetime.date(2030, 1, 2)) + title_line = next(line for line in text.splitlines() if line.startswith("title:")) + assert title_line == 'title: "' + citation.APP_TITLE + '"' + try: + import yaml # noqa: F401 - only when it happens to be installed + except ImportError: + return + data = yaml.safe_load(text) + assert data["title"] == citation.APP_TITLE and data["cff-version"] == "1.2.0" diff --git a/tests/test_flow.py b/tests/test_flow.py index 2ff11c8..0d4933e 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -207,6 +207,56 @@ def explode(ts: TransitionSystem, clusters: int = 1) -> PetriNet: assert run.status(after) == "idle" and "failed" in run.result(after).message +def test_a_column_setting_lists_the_connected_tables_columns(): + """``x: Column = ""`` is a setting (a plain string to the function), and the + app lists the columns of the tables connected to the box once they have run.""" + from openprocess.flow.runner import columns_connected + from openprocess.flow.types import Column, Table + + @box + def table() -> Table: + return Table("t", ["fitness", "precision"], [[1, 2]]) + + @box + def pick(tables: list[Table] = (), x: Column = "") -> Table: + return Table("p", [x or "all"], []) + + spec = pick.spec + assert [p.name for p in spec.inputs] == ["tables"] and [s.kind for s in spec.settings] == ["column"] + assert spec.settings[0].coerce("fitness") == "fitness" and spec.settings[0].default == "" + wf = Workflow("columns") + t, p = wf.add(table), wf.add(pick) + wf.connect(t, p) + assert columns_connected(wf, None, p) == [] # not run yet + run = Runner().run(wf) + assert run.status(p) == "done" + assert columns_connected(wf, run, p) == ["fitness", "precision"] and columns_connected(wf, run, t) == [] + + +def test_a_box_with_nothing_plugged_in_waits_rather_than_fails(): + """Every input optional and none connected (Plot, just added): idle with a + hint about what to connect, not a red Failed with a traceback. A box with + no inputs at all still runs.""" + @box + def make() -> PetriNet: + return PetriNet() + + @box + def gather(nets: list[PetriNet] = (), more: list[PetriNet] = ()) -> PetriNet: + if not nets and not more: + raise ValueError("nothing to gather") + return PetriNet() + + wf = Workflow("plug") + g = wf.add(gather) + run = Runner().run(wf) + assert run.status(g) == "idle" and run.result(g).message == "Connect nets or more" + m = wf.add(make) + wf.connect(m, g) + run = Runner().run(wf) + assert run.status(m) == "done" and run.status(g) == "done" + + def test_a_box_without_its_input_waits_with_a_message(): wf = Workflow("waiting") q = wf.add(check_fit) @@ -580,6 +630,7 @@ def test_reproducibility_status_follows_the_record_and_the_run(tmp_path): (tmp_path / "orders.log.txt").write_text("[^9]", encoding="utf-8") status = reproducibility.status(wf, record, run, tmp_path) assert status.state == "changed" and status.headline == "Recorded · 1 change since" and "has changed" in status.details[0] + assert status.short == "1 change since" again = runner.run(wf) # the changed log gives another model status = reproducibility.status(wf, record, again, tmp_path) assert status.state == "differs" and status.tone == "critical" diff --git a/tests/test_flow_gui.py b/tests/test_flow_gui.py index e523c77..6c5e332 100644 --- a/tests/test_flow_gui.py +++ b/tests/test_flow_gui.py @@ -951,15 +951,22 @@ def test_the_header_says_whether_the_analysis_is_reproducible(app, tmp_path): assert window.open_workspace(str(week)) window.action_new_workflow(TEMPLATES[0][1]) # on the folder's first log page = window.current_page() - assert page.header.badge is not None and page.header.badge.text() == "Not recorded yet" + assert page.header.badge is not None and page.header.badge.text() == "● Not recorded" + assert "Not recorded yet" in page.header.badge.toolTip() _wait_run(app, page) window._flush_autosaves() # the run is recorded on save - assert page.header.badge.text() == "Recorded · this run reproduces it" + assert page.header.badge.text() == "● Reproducible" assert page.document.record is not None and page.document.record.results (week / "orders.log.txt").write_text("[^9]", encoding="utf-8") page.refresh_reproducibility() - assert page.header.badge.text() == "Recorded · 1 change since" + assert page.header.badge.text() == "● 1 change since" assert "has changed" in page.header.badge.toolTip() + window.resize(700, 600) # short of room: the dot alone + _pump(app, 0.3) + assert page.header.badge.text() == "●" + window.resize(1400, 900) + _pump(app, 0.3) + assert page.header.badge.text() == "● 1 change since" page.header.badge.click() # the record, with the status on top _pump(app, 0.2) from PySide6.QtWidgets import QDialog, QLabel @@ -1156,3 +1163,84 @@ def test_an_object_centric_log_flows_through_the_canvas(app, tmp_path): assert page.drawings()[graph.id].startswith(" Table: + return Table("p", [x], []) + + widget = SettingsWidget(pick.spec, {"x": ""}, None, columns=["fitness", "precision"]) + combo = widget.controls["x"] + assert [combo.itemText(i) for i in range(combo.count())] == ["—", "fitness", "precision"] + assert combo.currentText() == "" and combo.lineEdit().placeholderText() == "Pick a column…" + got = [] + widget.changed.connect(lambda name, value: got.append((name, value))) + combo.setCurrentIndex(1) + assert got == [("x", "fitness")] and combo.currentText() == "fitness" + combo.setCurrentIndex(0) # back to no column at all + assert got[-1] == ("x", "") and combo.currentText() == "" + # Nothing connected or not run yet: a name typed in still works, and the field says why the list is empty. + empty = SettingsWidget(pick.spec, {"x": "fitness"}, None, columns=[]) + assert empty.controls["x"].currentText() == "fitness" + assert "Connect a table" in empty.controls["x"].lineEdit().placeholderText() diff --git a/tests/test_packages.py b/tests/test_packages.py new file mode 100644 index 0000000..6812447 --- /dev/null +++ b/tests/test_packages.py @@ -0,0 +1,69 @@ +"""Installing optional packages from the app: where, how, and in the downloaded app.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from openprocess import packages + + +def test_the_user_site_is_per_platform_and_python(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + site = packages.user_site() + assert site == tmp_path / "Library" / "Application Support" / "OpenProcess" / "packages" / \ + f"py{sys.version_info.major}.{sys.version_info.minor}" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "AppData" / "Local")) + assert packages.user_site().parts[-4:-2] == ("Local", "OpenProcess") + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) + assert packages.user_site() == tmp_path / "xdg" / "openprocess" / "packages" / \ + f"py{sys.version_info.major}.{sys.version_info.minor}" + + +def test_the_command_depends_on_how_the_app_runs(monkeypatch, tmp_path): + monkeypatch.delattr(sys, "frozen", raising=False) + assert packages.install_command("pandas>=2.0") == [sys.executable, "-m", "pip", "install", "pandas>=2.0"] + assert packages.where_text() == "into this app's Python" + assert packages.installable("pm4py") and packages.installable("numpy>=1.24") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(packages, "user_site", lambda: tmp_path / "site") + command = packages.install_command("pandas>=2.0") + assert command[:2] == [sys.executable, "--pip"] and "--target" in command and str(tmp_path / "site") in command + assert "--only-binary" in command # pip must never start a Python: it would open the app + assert packages.where_text() == "into the app's own packages folder" + assert packages.installable("numpy>=1.24") and not packages.installable("pm4py") + assert packages.name_of("matplotlib>=3.7") == "matplotlib" and packages.name_of("pm4py[extra]") == "pm4py" + + +def test_activate_puts_the_site_first_once(monkeypatch, tmp_path): + site = tmp_path / "site" + monkeypatch.setattr(packages, "user_site", lambda: site) + monkeypatch.setattr(sys, "path", list(sys.path)) + assert not packages.activate() # no folder yet: nothing to add + site.mkdir() + assert packages.activate() and sys.path[-1] == str(site) # after the app's own modules + assert not packages.activate() and sys.path.count(str(site)) == 1 + + +def test_run_pip_hands_the_arguments_to_pip(monkeypatch, tmp_path): + import types + calls = [] + fake = types.ModuleType("pip._internal.cli.main") + fake.main = lambda args: calls.append(list(args)) or 0 + monkeypatch.setitem(sys.modules, "pip._internal.cli.main", fake) + monkeypatch.setattr(packages, "user_site", lambda: tmp_path / "site") + assert packages.run_pip(["install", "--target", str(tmp_path / "site"), "numpy"]) == 0 + assert calls == [["install", "--target", str(tmp_path / "site"), "numpy"]] + assert (tmp_path / "site").is_dir() # made for pip, so --target has somewhere to go + assert packages.can_install() == (__import__("importlib").util.find_spec("pip") is not None) + + +def test_the_whole_standard_library_is_listed_for_the_downloaded_app(): + names = packages.stdlib_modules() + assert {"unittest", "unittest.mock", "json", "xml.etree.ElementTree", "sqlite3", "decimal"} <= set(names) + assert not any(n == "test" or n.startswith("test.") for n in names) # CPython's own tests + assert "tkinter" not in names and "idlelib" not in names and "asyncio.__main__" not in names + assert len(names) == len(set(names)) diff --git a/tests/test_studio_gui.py b/tests/test_studio_gui.py index 73b3b18..318b53b 100644 --- a/tests/test_studio_gui.py +++ b/tests/test_studio_gui.py @@ -2557,3 +2557,22 @@ def test_the_command_palette_finds_and_runs_anything(app, tmp_path): for document in window.documents: document.dirty = False window.close() + + +def test_a_chip_is_a_painted_pill(app): + """The status chips on cards draw themselves: round ends wherever they are shown.""" + from openprocess.gui.studio.widgets import Chip + + chip = Chip("not installed", fill="#fab219", ink="white") + chip.show() + app.processEvents() + assert chip.height() >= 20 and chip.width() > 60 + image = chip.grab().toImage() + inside = image.pixelColor(4, image.height() // 2).name() + corner = image.pixelColor(0, 0).name() + assert inside == "#fab219" and corner != "#fab219" # fill inside, the window behind the corner + chip.setText("ready") + assert chip.sizeHint().width() < 70 + chip.set_state("good") + app.processEvents() + assert chip.grab().toImage().pixelColor(4, image.height() // 2).name() != "#fab219"