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("