From 1c40c1adb8b6d283be40569101050457b6ba877b Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:23:35 +0200 Subject: [PATCH 01/10] 0.10.6: the reproducibility badge is a quiet pill that gives the title room A soft tint with a dot and a few words, the sentence on hover; it keeps its natural place beside the title and collapses to its dot when the header is short of room. Before, it took the title's width. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 9 +++++++ CITATION.cff | 2 +- openprocess/__init__.py | 2 +- openprocess/flow/reproducibility.py | 9 +++++++ openprocess/gui/flow/page.py | 2 +- openprocess/gui/studio/style.py | 5 ++-- openprocess/gui/studio/widgets.py | 38 ++++++++++++++++++++++------- tests/test_flow.py | 1 + tests/test_flow_gui.py | 13 +++++++--- 9 files changed, 64 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d8f25..73799d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ 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. +- 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..c956a97 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 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 +version: 0.10.6 date-released: 2026-10-10 repository-code: https://github.com/s4mstruthers/openprocess url: https://github.com/s4mstruthers/openprocess 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/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/gui/flow/page.py b/openprocess/gui/flow/page.py index 62e7355..82d41c2 100644 --- a/openprocess/gui/flow/page.py +++ b/openprocess/gui/flow/page.py @@ -341,7 +341,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) diff --git a/openprocess/gui/studio/style.py b/openprocess/gui/studio/style.py index f3dd6e8..66bb20b 100644 --- a/openprocess/gui/studio/style.py +++ b/openprocess/gui/studio/style.py @@ -354,8 +354,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..822b82d 100644 --- a/openprocess/gui/studio/widgets.py +++ b/openprocess/gui/studio/widgets.py @@ -527,6 +527,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 +547,48 @@ 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) + self.badge.setStyleSheet(f"QPushButton#reproBadge {{ background: {tint}; color: {ink}; }}") + 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/tests/test_flow.py b/tests/test_flow.py index 2ff11c8..5b818a1 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -580,6 +580,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..1eca50a 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 From 0f27078dd25062ece595a05805a6bffa2dd887d0 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:09:42 +0200 Subject: [PATCH 02/10] Zenodo: metadata for the archive, and the DOI flows from CITATION.cff .zenodo.json describes the software for Zenodo's archive of each release. The app's citations and BibTeX take the all-versions DOI from CITATION.cff once it is there; the README says how to cite. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 153c4e204fcb50b4fa549d952cb9cc36f1c9583a) --- .zenodo.json | 23 +++++++++++++++++++++++ README.md | 10 ++++++++++ openprocess/citation.py | 40 ++++++++++++++++++++++++++++++++-------- tests/test_citation.py | 13 +++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 .zenodo.json diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 0000000..3df313a --- /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/README.md b/README.md index 11e735a..686529f 100644 --- a/README.md +++ b/README.md @@ -930,6 +930,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/openprocess/citation.py b/openprocess/citation.py index 9e4b951..4a20415 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,34 +155,39 @@ 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) @@ -175,7 +198,8 @@ def cff_text(version: str | None = None, released: _dt.date | None = None) -> st f"title: {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/tests/test_citation.py b/tests/test_citation.py index 9751e07..b413fe9 100644 --- a/tests/test_citation.py +++ b/tests/test_citation.py @@ -53,3 +53,16 @@ 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 From 8ac096ccfc8ae6642f7537438d6cb4d943d9881b Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:33:43 +0200 Subject: [PATCH 03/10] Zenodo: the licence by its id in Zenodo's vocabulary (mit) Co-Authored-By: Claude Fable 5.1 --- .zenodo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.zenodo.json b/.zenodo.json index 3df313a..ddcff84 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -5,7 +5,7 @@ "creators": [ {"name": "Struthers, Sam"} ], - "license": "MIT", + "license": "mit", "access_right": "open", "keywords": [ "process mining", From c749b795882153357129a6af83dd56be407d8581 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:34:48 +0200 Subject: [PATCH 04/10] CITATION.cff: quote the title and message, which hold colons; Zenodo could not parse the file Co-Authored-By: Claude Fable 5.1 --- CITATION.cff | 6 +++--- openprocess/citation.py | 7 +++++-- tests/test_citation.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index c956a97..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 +title: "OpenProcess: process mining, Petri nets and workflows in one open app" version: 0.10.6 -date-released: 2026-10-10 +date-released: 2026-10-11 repository-code: https://github.com/s4mstruthers/openprocess url: https://github.com/s4mstruthers/openprocess license: MIT diff --git a/openprocess/citation.py b/openprocess/citation.py index 4a20415..4fafb77 100644 --- a/openprocess/citation.py +++ b/openprocess/citation.py @@ -191,11 +191,14 @@ def cff_text(version: str | None = None, released: _dt.date | None = None, doi: 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"doi: {doi}\n" if doi else "") diff --git a/tests/test_citation.py b/tests/test_citation.py index b413fe9..52893e1 100644 --- a/tests/test_citation.py +++ b/tests/test_citation.py @@ -66,3 +66,17 @@ def test_the_doi_comes_from_citation_cff_when_there_is_one(tmp_path, monkeypatch 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" From 7ea7c969f128d2135206974fca20e768dca06e48 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:40:31 +0200 Subject: [PATCH 05/10] The reproducibility badge is a round pill on macOS too The rounding was in the app stylesheet while the colours were set on the badge itself; on the native macOS style that gave a square frame. The whole rule is on the badge now, fully rounded. Co-Authored-By: Claude Fable 5.1 --- openprocess/gui/studio/widgets.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openprocess/gui/studio/widgets.py b/openprocess/gui/studio/widgets.py index 822b82d..5dfd81c 100644 --- a/openprocess/gui/studio/widgets.py +++ b/openprocess/gui/studio/widgets.py @@ -564,7 +564,12 @@ def set_badge(self, text: str, tone: str = "muted", tooltip: str = "", slot=None 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) - self.badge.setStyleSheet(f"QPushButton#reproBadge {{ background: {tint}; color: {ink}; }}") + # 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)) From c0a79b7978db970b42391445630f32cf933a13f7 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:03:58 +0200 Subject: [PATCH 06/10] =?UTF-8?q?Install=E2=80=A6=20works=20in=20the=20dow?= =?UTF-8?q?nloaded=20app,=20and=20a=20page=20opened=20from=20a=20box=20is?= =?UTF-8?q?=20a=20view=20until=20kept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connections ▸ Install… no longer needs a Python install. The downloaded app carries pip (collect_all in the spec), runs it in a copy of itself (OpenProcess --pip install …, dispatched before Qt is imported) and installs ready-built wheels into a folder of the user's own, which openprocess.packages puts on the import path at start-up and right after an install, so the boxes that need the package come alive without a restart. PM4Py is left to a Python install (parts of it are built from source, which would need pip to start a Python, and the app's executable would open a window). Open as log › and Open as model › no longer write a file into the folder the moment they open: the page is a view of the box's result, with Keep in folder for when you want a file, and ✕ closes it without a question and leaves nothing in the sidebar. Files you opened yourself still close to a lighter row; deleting stays an explicit Move to Bin. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 11 +++ README.md | 6 +- openprocess/gui/studio/app.py | 47 ++++++++++-- openprocess/gui/studio/connections.py | 29 +++++-- openprocess/packages.py | 106 ++++++++++++++++++++++++++ packaging/openprocess.spec | 13 +++- packaging/openprocess_studio.py | 4 + tests/test_flow_gui.py | 57 ++++++++++++++ tests/test_packages.py | 61 +++++++++++++++ 9 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 openprocess/packages.py create mode 100644 tests/test_packages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 73799d6..8a8c031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ notice, not how it was done. Versions before 0.7.0 were released as CPNpy. 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. - Releases are archived on Zenodo; the DOI goes into `CITATION.cff` and the app's citations once the first archive is there. diff --git a/README.md b/README.md index 686529f..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. 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..1165b1a 100644 --- a/openprocess/gui/studio/connections.py +++ b/openprocess/gui/studio/connections.py @@ -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]: @@ -152,14 +160,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/packages.py b/openprocess/packages.py new file mode 100644 index 0000000..d90e86e --- /dev/null +++ b/openprocess/packages.py @@ -0,0 +1,106 @@ +"""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",) + + +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 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", "activate", "can_install", "frozen", "install_command", "installable", + "name_of", "run_pip", "user_site", "where_text"] diff --git a/packaging/openprocess.spec b/packaging/openprocess.spec index ab1379a..39240ef 100644 --- a/packaging/openprocess.spec +++ b/packaging/openprocess.spec @@ -35,7 +35,11 @@ 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") # 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 +50,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 +65,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, # 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_flow_gui.py b/tests/test_flow_gui.py index 1eca50a..d52eb75 100644 --- a/tests/test_flow_gui.py +++ b/tests/test_flow_gui.py @@ -1163,3 +1163,60 @@ def test_an_object_centric_log_flows_through_the_canvas(app, tmp_path): assert page.drawings()[graph.id].startswith("=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) From 6ca3ff1c37fc5f994cf79da5637fbdc867bac2a6 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:10:26 +0200 Subject: [PATCH 07/10] The status chips on cards are rounded pills A QLabel's background is only rounded when the same rule sets a border (even none); the Connections chips also set their background in a widget-level stylesheet with no radius at all. Both rules now carry border, radius and padding, so "not installed", "none yet" and the Learn chips are pills like the reproducibility badge. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 +++ openprocess/gui/studio/connections.py | 5 ++++- openprocess/gui/studio/style.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8c031..df67255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ notice, not how it was done. Versions before 0.7.0 were released as CPNpy. *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*, *none yet*, *ready*) are rounded + pills, like the reproducibility badge: a label's background is only rounded + when its rule also sets a border, so the rules now do. - Releases are archived on Zenodo; the DOI goes into `CITATION.cff` and the app's citations once the first archive is there. diff --git a/openprocess/gui/studio/connections.py b/openprocess/gui/studio/connections.py index 1165b1a..30c05dc 100644 --- a/openprocess/gui/studio/connections.py +++ b/openprocess/gui/studio/connections.py @@ -126,7 +126,10 @@ def card(title: str, text: str, boxes: list[str], status: str = "", tone: str = item.setMaximumWidth(300) if status: chip = label(status, "statusChip") - chip.setStyleSheet(f"background: {_tone(tone)}; color: white;") + # The whole rule on the widget: a rounded pill needs its border (none) + # and radius in the same stylesheet as its background. + chip.setStyleSheet(f"QLabel#statusChip {{ background: {_tone(tone)}; color: white; border: none; " + "border-radius: 10px; padding: 3px 9px; font-size: 11px; font-weight: 600; }") item.header.addWidget(chip) if boxes: item.add(label("Boxes: " + ", ".join(boxes), "muted", wrap=True)) diff --git a/openprocess/gui/studio/style.py b/openprocess/gui/studio/style.py index 66bb20b..e38a565 100644 --- a/openprocess/gui/studio/style.py +++ b/openprocess/gui/studio/style.py @@ -280,7 +280,7 @@ 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 {{ font-size: 11px; font-weight: 600; border: none; border-radius: 10px; padding: 3px 9px; }} #statusChip[state="good"] {{ color: {STATUS["good"]}; background: {qc(STATUS["good"], 0.13).name(QColor.HexArgb)}; }} #statusChip[state="warning"] {{ color: {t.text}; From 996364430c3b357f77c465d075ec32968187acb6 Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:26:50 +0200 Subject: [PATCH 08/10] Chips paint themselves, and the downloaded app carries the whole standard library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status chips were styled labels, and a label's background and corner radius come from the style's box model, which polishes when it likes: the downloaded app showed three square chips beside one round one. Chip is a widget that draws a pill from its font metrics, so Connections (not installed, installing…, ready) and the Learn chips look the same everywhere; the #statusChip rules go. Plot failed after installing matplotlib from Connections: the bundled Python had no unittest, because PyInstaller packs only what the app imports. The spec now lists every standard library module (openprocess.packages. stdlib_modules, minus test suites, IDEs, toys and the dead batteries), so a package installed later can import what it likes. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 ++-- openprocess/gui/learn/answer_boxes.py | 8 ++-- openprocess/gui/studio/connections.py | 9 +--- openprocess/gui/studio/style.py | 7 --- openprocess/gui/studio/widgets.py | 69 ++++++++++++++++++++++++++- openprocess/packages.py | 37 +++++++++++++- packaging/openprocess.spec | 10 +++- tests/test_packages.py | 8 ++++ tests/test_studio_gui.py | 19 ++++++++ 9 files changed, 151 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df67255..7c7b2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,13 @@ notice, not how it was done. Versions before 0.7.0 were released as CPNpy. *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*, *none yet*, *ready*) are rounded - pills, like the reproducibility badge: a label's background is only rounded - when its rule also sets a border, so the rules now do. +- 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. +- 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. 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/connections.py b/openprocess/gui/studio/connections.py index 30c05dc..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 = [ @@ -125,12 +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") - # The whole rule on the widget: a rounded pill needs its border (none) - # and radius in the same stylesheet as its background. - chip.setStyleSheet(f"QLabel#statusChip {{ background: {_tone(tone)}; color: white; border: none; " - "border-radius: 10px; padding: 3px 9px; font-size: 11px; font-weight: 600; }") - 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 diff --git a/openprocess/gui/studio/style.py b/openprocess/gui/studio/style.py index e38a565..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: none; border-radius: 10px; padding: 3px 9px; }} - #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)}; }} diff --git a/openprocess/gui/studio/widgets.py b/openprocess/gui/studio/widgets.py index 5dfd81c..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]], diff --git a/openprocess/packages.py b/openprocess/packages.py index d90e86e..1734615 100644 --- a/openprocess/packages.py +++ b/openprocess/packages.py @@ -25,6 +25,12 @@ #: 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: @@ -89,6 +95,33 @@ def where_text() -> str: 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: @@ -102,5 +135,5 @@ def run_pip(arguments: list[str]) -> int: return int(pip_main(list(arguments)) or 0) -__all__ = ["PIP_FLAG", "SOURCE_ONLY", "activate", "can_install", "frozen", "install_command", "installable", - "name_of", "run_pip", "user_site", "where_text"] +__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 39240ef..02bafc2 100644 --- a/packaging/openprocess.spec +++ b/packaging/openprocess.spec @@ -41,6 +41,14 @@ from PyInstaller.utils.hooks import collect_all, collect_submodules # 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 # its compiled form, so the app shows the code it runs (0.7.1). @@ -66,7 +74,7 @@ analysis = Analysis( # (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", "openprocess.packages"] - + collect_submodules("openprocess.flow.boxes") + PIP_HIDDEN, + + 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/tests/test_packages.py b/tests/test_packages.py index 12aa78f..6812447 100644 --- a/tests/test_packages.py +++ b/tests/test_packages.py @@ -59,3 +59,11 @@ def test_run_pip_hands_the_arguments_to_pip(monkeypatch, tmp_path): 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" From bcfe60e9960229193c6457786610e061b76553da Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:44:52 +0200 Subject: [PATCH 09/10] A box with nothing plugged in waits with a hint instead of failing Plot's inputs are all optional, so a Plot just added to the canvas ran on nothing and showed a red Failed with a traceback. A box that has input ports, none of them connected, now sits idle with "Connect scores or tables", the way a box missing a required input already did. Boxes with no inputs at all still run. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 ++ openprocess/flow/runner.py | 8 ++++++++ tests/test_flow.py | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c7b2d1..7d4a7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ notice, not how it was done. Versions before 0.7.0 were released as CPNpy. 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 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). diff --git a/openprocess/flow/runner.py b/openprocess/flow/runner.py index de2dacd..2e5a5ab 100644 --- a/openprocess/flow/runner.py +++ b/openprocess/flow/runner.py @@ -270,6 +270,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: diff --git a/tests/test_flow.py b/tests/test_flow.py index 5b818a1..d4132cb 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -207,6 +207,30 @@ def explode(ts: TransitionSystem, clusters: int = 1) -> PetriNet: assert run.status(after) == "idle" and "failed" in run.result(after).message +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) From 467a8a2908f3112de5b568f37661406572051cbc Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:11:16 +0200 Subject: [PATCH 10/10] A column setting is a dropdown of the connected table's columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new type, Column, is a plain string to a box's function and a setting kind to the app: the side panel shows it as a list of the columns of the tables connected to the box, once they have run (columns_connected), with "—" for no column at all; a name can still be typed, and the field says why the list is empty before anything is connected. Plot's x and y are Column settings now. As with every other kind, the type hint is the whole of what a box author writes. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +++ docs/workflows.md | 1 + openprocess/flow/__init__.py | 3 ++- openprocess/flow/box.py | 9 ++++--- openprocess/flow/boxes/science.py | 4 +-- openprocess/flow/runner.py | 21 +++++++++++++++- openprocess/flow/types.py | 12 +++++++++ openprocess/gui/flow/page.py | 6 +++-- openprocess/gui/flow/viewers.py | 41 ++++++++++++++++++++++++++++++- tests/test_flow.py | 26 ++++++++++++++++++++ tests/test_flow_gui.py | 24 ++++++++++++++++++ 11 files changed, 141 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4a7e9..172648b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ notice, not how it was done. Versions before 0.7.0 were released as CPNpy. 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 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/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/runner.py b/openprocess/flow/runner.py index 2e5a5ab..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).""" @@ -384,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 82d41c2..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 @@ -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/tests/test_flow.py b/tests/test_flow.py index d4132cb..0d4933e 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -207,6 +207,32 @@ 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 diff --git a/tests/test_flow_gui.py b/tests/test_flow_gui.py index d52eb75..6c5e332 100644 --- a/tests/test_flow_gui.py +++ b/tests/test_flow_gui.py @@ -1220,3 +1220,27 @@ def test_a_page_opened_from_a_box_is_a_view_until_kept(app, tmp_path, monkeypatc for d in window.documents: d.dirty = False window.close() + + +def test_a_column_setting_is_a_dropdown_of_the_tables_columns(app): + from openprocess.flow import Column, Table, box + from openprocess.gui.flow.viewers import SettingsWidget + + @box + def pick(tables: list[Table] = (), x: Column = "") -> 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()