openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,439 @@
1
+ """Checking for a newer OpenProcess, and installing it from within the app.
2
+
3
+ The latest release is asked of GitHub (no account needed for a public
4
+ repository), its version compared with the running one, and, if it is newer,
5
+ the download for this system is chosen by the names the build gives them
6
+ (``packaging/build.py``)::
7
+
8
+ OpenProcess-<version>-macOS-<arch>.dmg
9
+ OpenProcess-<version>-Windows-<arch>.zip
10
+ OpenProcess-<version>-Linux-<arch>.tar.gz
11
+
12
+ Installing replaces the app the user is running. The app cannot overwrite
13
+ itself while it runs, so it downloads and unpacks the new version, writes a
14
+ small script that waits for the app to quit, swaps the old app for the new
15
+ one (keeping the old one until the new one is in place) and starts it, and
16
+ then quits. OpenProcess run from source (``git clone`` + ``pip install -e``) is
17
+ never touched: it only gets told what to run.
18
+
19
+ Everything but :class:`UpdateDialog` (at the end) works without a window.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import platform
28
+ import re
29
+ import shutil
30
+ import ssl
31
+ import subprocess
32
+ import sys
33
+ import tarfile
34
+ import tempfile
35
+ import zipfile
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Callable
39
+ from urllib.request import Request, urlopen
40
+
41
+ from PySide6.QtCore import Signal
42
+ from PySide6.QtWidgets import (
43
+ QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QLabel, QPushButton, QTextBrowser, QToolButton,
44
+ QVBoxLayout,
45
+ )
46
+
47
+ from ... import __version__
48
+
49
+ REPOSITORY = "s4mstruthers/openprocess"
50
+ RELEASES_URL = f"https://api.github.com/repos/{REPOSITORY}/releases?per_page=30"
51
+ RELEASES_PAGE = f"https://github.com/{REPOSITORY}/releases/latest"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Versions
56
+ # ---------------------------------------------------------------------------
57
+ def parse_version(text: str) -> tuple:
58
+ """``"v0.10.2"`` → ``(0, 10, 2, 1)``; a pre-release (``0.3.0rc1``, ``0.3.0-beta``)
59
+ sorts before its release: ``(0, 3, 0, 0)``."""
60
+ match = re.match(r"^\s*v?(\d+(?:\.\d+)*)(.*)$", text or "")
61
+ if match is None:
62
+ return (0,)
63
+ numbers = [int(part) for part in match.group(1).split(".")]
64
+ while len(numbers) < 3:
65
+ numbers.append(0)
66
+ final = 0 if match.group(2).strip(" .-+") and not match.group(2).startswith("+") else 1
67
+ return (*numbers, final)
68
+
69
+
70
+ def is_newer(latest: str, current: str = __version__) -> bool:
71
+ return parse_version(latest) > parse_version(current)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # This system and this installation
76
+ # ---------------------------------------------------------------------------
77
+ def system_name() -> str:
78
+ """As in the download names (see ``packaging/build.py``)."""
79
+ return {"darwin": "macOS", "win32": "Windows"}.get(sys.platform, "Linux")
80
+
81
+
82
+ def architecture() -> str:
83
+ machine = platform.machine().lower()
84
+ return {"amd64": "x64", "x86_64": "x64", "arm64": "arm64",
85
+ "aarch64": "arm64"}.get(machine, machine)
86
+
87
+
88
+ def installed_app() -> Path | None:
89
+ """The app being run (``OpenProcess.app`` or the ``OpenProcess`` folder), or None from source."""
90
+ if not getattr(sys, "frozen", False):
91
+ return None
92
+ executable = Path(sys.executable).resolve()
93
+ if sys.platform == "darwin":
94
+ bundle = executable.parents[2] # OpenProcess.app/Contents/MacOS/OpenProcess
95
+ return bundle if bundle.suffix == ".app" else None
96
+ return executable.parent # OpenProcess/OpenProcess(.exe)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # The latest release
101
+ # ---------------------------------------------------------------------------
102
+ @dataclass
103
+ class Release:
104
+ version: str # "0.3.0"
105
+ tag: str # "v0.3.0"
106
+ notes: str # Markdown
107
+ page: str # the release's web page
108
+ assets: dict[str, str] = field(default_factory=dict) # file name → download URL
109
+ #: file name → SHA-256 (hex), as GitHub computes it for every upload
110
+ digests: dict[str, str] = field(default_factory=dict)
111
+ prerelease: bool = False
112
+
113
+ @classmethod
114
+ def from_github(cls, data: dict) -> "Release":
115
+ tag = data.get("tag_name") or ""
116
+ return cls(version=tag.lstrip("vV"), tag=tag, notes=data.get("body") or "",
117
+ page=data.get("html_url") or RELEASES_PAGE,
118
+ assets={a["name"]: a["browser_download_url"]
119
+ for a in data.get("assets", [])
120
+ if a.get("name") and a.get("browser_download_url")},
121
+ digests={a["name"]: a["digest"].split(":", 1)[1].lower()
122
+ for a in data.get("assets", [])
123
+ if a.get("name") and str(a.get("digest", "")).startswith("sha256:")},
124
+ prerelease=bool(data.get("prerelease") or data.get("draft")))
125
+
126
+ def download_for(self, system: str | None = None,
127
+ arch: str | None = None) -> tuple[str, str] | None:
128
+ """(file name, URL) of the app for this system, or None if there is none."""
129
+ system, arch = system or system_name(), arch or architecture()
130
+ extension = {"macOS": ".dmg", "Windows": ".zip"}.get(system, ".tar.gz")
131
+ wanted = f"-{system}-{arch}{extension}".lower()
132
+ for name, url in self.assets.items():
133
+ if name.lower().startswith("openprocess-") and name.lower().endswith(wanted):
134
+ return name, url
135
+ return None
136
+
137
+ def checksum_for(self, name: str) -> str | None:
138
+ """The SHA-256 GitHub reports for ``name`` (None for an old upload without one)."""
139
+ return self.digests.get(name)
140
+
141
+
142
+ def ssl_context() -> ssl.SSLContext:
143
+ """Certificates to trust when talking to GitHub.
144
+
145
+ The standalone app carries its own Python, which cannot find the
146
+ system's certificates (on macOS it then fails with "CERTIFICATE_VERIFY_
147
+ FAILED: unable to get local issuer certificate"), so it uses certifi's
148
+ bundle; PyInstaller packs that file into the app. Without certifi (run
149
+ from source), the system's own certificates are used.
150
+ """
151
+ try:
152
+ import certifi
153
+ except ImportError:
154
+ return ssl.create_default_context()
155
+ return ssl.create_default_context(cafile=certifi.where())
156
+
157
+
158
+ def _get(url: str, timeout: float) -> bytes:
159
+ request = Request(url, headers={"Accept": "application/vnd.github+json",
160
+ "User-Agent": f"OpenProcess/{__version__}"})
161
+ with urlopen(request, timeout=timeout, context=ssl_context()) as response: # noqa: S310
162
+ return response.read()
163
+
164
+
165
+ def fetch_latest(timeout: float = 10.0, current: str = __version__) -> Release:
166
+ """The newest published release (raises on a network or GitHub problem).
167
+
168
+ Its :attr:`~Release.notes` say what is new in every version newer than
169
+ ``current``, newest first, so someone several versions behind sees all of
170
+ it, not just the last step.
171
+ """
172
+ releases = [Release.from_github(data) for data in json.loads(_get(RELEASES_URL, timeout))
173
+ if isinstance(data, dict) and not data.get("draft")]
174
+ releases = sorted((r for r in releases if not r.prerelease),
175
+ key=lambda r: parse_version(r.version), reverse=True)
176
+ if not releases:
177
+ raise RuntimeError("OpenProcess has no published releases yet.")
178
+ latest = releases[0]
179
+ newer = [r for r in releases if is_newer(r.version, current)]
180
+ if len(newer) > 1:
181
+ latest.notes = "\n\n".join(f"### What's new in {r.version}\n\n"
182
+ f"{r.notes.strip() or '_No notes._'}" for r in newer)
183
+ return latest
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Downloading and installing
188
+ # ---------------------------------------------------------------------------
189
+ def download(url: str, target: Path, progress: Callable[[int, int], None] | None = None,
190
+ cancelled: Callable[[], bool] | None = None, timeout: float = 30.0) -> Path:
191
+ """Download ``url`` to ``target``, reporting (bytes so far, total) as it goes."""
192
+ request = Request(url, headers={"User-Agent": f"OpenProcess/{__version__}"})
193
+ with urlopen(request, timeout=timeout, context=ssl_context()) as response, \
194
+ open(target, "wb") as out: # noqa: S310
195
+ total = int(response.headers.get("Content-Length") or 0)
196
+ done = 0
197
+ while chunk := response.read(1 << 16):
198
+ if cancelled is not None and cancelled():
199
+ raise InterruptedError("cancelled")
200
+ out.write(chunk)
201
+ done += len(chunk)
202
+ if progress is not None:
203
+ progress(done, total)
204
+ return target
205
+
206
+
207
+ def verify(path: Path, expected: str) -> bool:
208
+ """Does the file's SHA-256 match ``expected`` (hex)?"""
209
+ expected = expected.strip().lower()
210
+ digest = hashlib.sha256()
211
+ with open(path, "rb") as handle:
212
+ for block in iter(lambda: handle.read(1 << 20), b""):
213
+ digest.update(block)
214
+ return digest.hexdigest() == expected
215
+
216
+
217
+ def unpack(archive: Path, folder: Path) -> Path:
218
+ """The new app out of the download: ``OpenProcess.app`` (macOS) or the ``OpenProcess`` folder."""
219
+ name = archive.name.lower()
220
+ if name.endswith(".dmg"):
221
+ mount = folder / "mounted"
222
+ mount.mkdir()
223
+ subprocess.run(["hdiutil", "attach", "-nobrowse", "-readonly", "-noautoopen",
224
+ "-mountpoint", str(mount), str(archive)], check=True,
225
+ capture_output=True, timeout=120)
226
+ try:
227
+ app = next(mount.glob("*.app"))
228
+ target = folder / app.name
229
+ # ditto keeps the bundle exactly (symlinks, permissions, signature).
230
+ subprocess.run(["ditto", str(app), str(target)], check=True,
231
+ capture_output=True, timeout=300)
232
+ finally:
233
+ subprocess.run(["hdiutil", "detach", str(mount), "-quiet"], check=False,
234
+ capture_output=True, timeout=60)
235
+ return target
236
+ out = folder / "unpacked"
237
+ if name.endswith(".zip"):
238
+ with zipfile.ZipFile(archive) as bundle:
239
+ bundle.extractall(out)
240
+ else:
241
+ with tarfile.open(archive) as bundle:
242
+ bundle.extractall(out, filter="data") if hasattr(tarfile, "data_filter") \
243
+ else bundle.extractall(out) # noqa: S202 - our own release
244
+ found = [p for p in out.iterdir() if p.is_dir()]
245
+ return found[0] if len(found) == 1 else out
246
+
247
+
248
+ def looks_like_openprocess(app: Path) -> bool:
249
+ """A safety check before the old app is replaced: is this really OpenProcess?"""
250
+ if sys.platform == "darwin":
251
+ return app.suffix == ".app" and (app / "Contents" / "MacOS" / "OpenProcess").exists()
252
+ if sys.platform == "win32":
253
+ return (app / "OpenProcess.exe").exists()
254
+ return (app / "OpenProcess").exists()
255
+
256
+
257
+ def installer_script(new: Path, old: Path, pid: int, work: Path,
258
+ system: str | None = None) -> tuple[Path, list[str]]:
259
+ """Write the script that swaps the apps once this one has quit; (script, command).
260
+
261
+ The old app is renamed out of the way first and only removed once the
262
+ new one is in its place; if that fails, the old one is put back.
263
+ """
264
+ system = system or system_name()
265
+ if system == "Windows":
266
+ script = work / "install-update.bat"
267
+ executable = old / "OpenProcess.exe"
268
+ script.write_text(
269
+ "@echo off\r\n"
270
+ ":wait\r\n"
271
+ f'tasklist /FI "PID eq {pid}" 2>NUL | find "{pid}" >NUL\r\n'
272
+ "if not errorlevel 1 (timeout /t 1 /nobreak >NUL & goto wait)\r\n"
273
+ f'move "{old}" "{old}.previous" >NUL || exit /b 1\r\n'
274
+ f'move "{new}" "{old}" >NUL || (move "{old}.previous" "{old}" >NUL & exit /b 1)\r\n'
275
+ f'rmdir /s /q "{old}.previous"\r\n'
276
+ f'start "" "{executable}"\r\n'
277
+ f'rmdir /s /q "{work}"\r\n', encoding="utf-8")
278
+ return script, ["cmd", "/c", str(script)]
279
+ script = work / "install-update.sh"
280
+ start = f'open "{old}"' if system == "macOS" else f'"{old}/OpenProcess" >/dev/null 2>&1 &'
281
+ quarantine = (f'xattr -dr com.apple.quarantine "{old}" 2>/dev/null\n'
282
+ if system == "macOS" else "")
283
+ script.write_text(
284
+ "#!/bin/sh\n"
285
+ f"while kill -0 {pid} 2>/dev/null; do sleep 0.5; done\n"
286
+ f'rm -rf "{old}.previous"\n'
287
+ f'mv "{old}" "{old}.previous" || exit 1\n'
288
+ f'if mv "{new}" "{old}"; then rm -rf "{old}.previous"; '
289
+ f'else mv "{old}.previous" "{old}"; exit 1; fi\n'
290
+ + quarantine +
291
+ f"{start}\n"
292
+ f'rm -rf "{work}"\n', encoding="utf-8")
293
+ script.chmod(0o755)
294
+ return script, ["/bin/sh", str(script)]
295
+
296
+
297
+ def prepare(release: Release, progress: Callable[[int, int], None] | None = None,
298
+ cancelled: Callable[[], bool] | None = None) -> list[str]:
299
+ """Download, check and unpack the update; the command that installs it.
300
+
301
+ Run the command (detached) and quit the app: the command waits for that.
302
+ """
303
+ app = installed_app()
304
+ if app is None or not looks_like_openprocess(app):
305
+ raise RuntimeError("This copy of OpenProcess was not installed from a download, so it "
306
+ "cannot update itself.")
307
+ if not os.access(app.parent, os.W_OK):
308
+ raise PermissionError(f"OpenProcess cannot replace itself in {app.parent} (no permission "
309
+ "to write there).")
310
+ chosen = release.download_for()
311
+ if chosen is None:
312
+ raise RuntimeError(f"Release {release.tag} has no download for {system_name()} "
313
+ f"({architecture()}).")
314
+ name, url = chosen
315
+ # Unpack next to the app, so the final move is a rename on one disk.
316
+ work = Path(tempfile.mkdtemp(prefix=".openprocess-update-", dir=app.parent))
317
+ try:
318
+ archive = download(url, work / name, progress, cancelled)
319
+ checksum = release.checksum_for(name)
320
+ if checksum is not None and not verify(archive, checksum):
321
+ raise RuntimeError(f"{name} did not download correctly (its checksum does not "
322
+ "match). Try again later.")
323
+ new = unpack(archive, work)
324
+ if not looks_like_openprocess(new):
325
+ raise RuntimeError(f"{name} does not contain the OpenProcess app.")
326
+ archive.unlink()
327
+ _, command = installer_script(new, app, os.getpid(), work)
328
+ except BaseException:
329
+ shutil.rmtree(work, ignore_errors=True)
330
+ raise
331
+ return command
332
+
333
+
334
+ def run_detached(command: list[str]) -> None:
335
+ """Start the installer so that it outlives the app."""
336
+ if sys.platform == "win32":
337
+ flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
338
+ subprocess.Popen(command, creationflags=flags, close_fds=True) # noqa: S603
339
+ else:
340
+ subprocess.Popen(command, start_new_session=True, close_fds=True, # noqa: S603
341
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # The dialog
346
+ # ---------------------------------------------------------------------------
347
+ class UpdateDialog(QDialog):
348
+ """"OpenProcess 0.3.0 is available": its release notes, and what to do about it."""
349
+
350
+ INSTALL, LATER, SKIP, PAGE = range(4)
351
+
352
+ def __init__(self, release: Release, can_install: bool, parent=None) -> None:
353
+ super().__init__(parent)
354
+ self.setWindowTitle("Software Update")
355
+ self.setMinimumSize(520, 420)
356
+ self.outcome = self.LATER
357
+ layout = QVBoxLayout(self)
358
+ layout.setSpacing(10)
359
+ heading = QLabel(f"<b>OpenProcess {release.version} is available</b> — you have "
360
+ f"{__version__}. Here is what's new:")
361
+ heading.setWordWrap(True)
362
+ layout.addWidget(heading)
363
+ notes = QTextBrowser()
364
+ notes.setOpenExternalLinks(True)
365
+ notes.setMarkdown(release.notes or "_No release notes._")
366
+ layout.addWidget(notes, 1)
367
+ if not can_install:
368
+ hint = QLabel("You are running OpenProcess from its source code, which it does not "
369
+ "change by itself. To update, run <code>git pull</code> in your "
370
+ "OpenProcess folder, then <code>conda env update -f environment.yml "
371
+ "--prune</code> (or <code>pip install -e \".[app]\"</code>).")
372
+ hint.setWordWrap(True)
373
+ layout.addWidget(hint)
374
+ buttons = QDialogButtonBox()
375
+ if can_install:
376
+ install = buttons.addButton("Download && Install", QDialogButtonBox.AcceptRole)
377
+ install.clicked.connect(lambda: self._finish(self.INSTALL))
378
+ install.setDefault(True)
379
+ else:
380
+ page = buttons.addButton("Open Release Page", QDialogButtonBox.AcceptRole)
381
+ page.clicked.connect(lambda: self._finish(self.PAGE))
382
+ buttons.addButton("Later", QDialogButtonBox.RejectRole).clicked.connect(
383
+ lambda: self._finish(self.LATER))
384
+ buttons.addButton("Skip This Version", QDialogButtonBox.DestructiveRole) \
385
+ .clicked.connect(lambda: self._finish(self.SKIP))
386
+ layout.addWidget(buttons)
387
+
388
+ def _finish(self, outcome: int) -> None:
389
+ self.outcome = outcome
390
+ if outcome in (self.INSTALL, self.PAGE):
391
+ self.accept()
392
+ else:
393
+ self.reject()
394
+
395
+
396
+ class UpdateBar(QFrame):
397
+ """A slim bar at the top of the window: a new version is out.
398
+
399
+ Shown by the check at launch instead of a dialog, so it never gets in
400
+ the way: carry on working, or choose What's New, Install Now (or, run
401
+ from source, How to Update), Skip This Version, or close it (✕).
402
+ """
403
+
404
+ whats_new = Signal()
405
+ install = Signal()
406
+ skip = Signal()
407
+
408
+ def __init__(self, parent=None) -> None:
409
+ super().__init__(parent)
410
+ self.setObjectName("updateBar")
411
+ layout = QHBoxLayout(self)
412
+ layout.setContentsMargins(16, 7, 8, 7)
413
+ layout.setSpacing(8)
414
+ self.text = QLabel()
415
+ self.text.setWordWrap(True)
416
+ layout.addWidget(self.text, 1)
417
+ self.notes_button = QPushButton("What's New")
418
+ self.notes_button.clicked.connect(self.whats_new.emit)
419
+ self.install_button = QPushButton("Install Now")
420
+ self.install_button.setObjectName("primary")
421
+ self.install_button.clicked.connect(self.install.emit)
422
+ self.skip_button = QPushButton("Skip This Version")
423
+ self.skip_button.clicked.connect(self.skip.emit)
424
+ close = QToolButton()
425
+ close.setObjectName("updateBarClose")
426
+ close.setText("✕")
427
+ close.setToolTip("Close (you are reminded next time OpenProcess opens)")
428
+ close.clicked.connect(self.hide)
429
+ for widget in (self.notes_button, self.install_button, self.skip_button, close):
430
+ layout.addWidget(widget)
431
+ self.release: Release | None = None
432
+ self.setVisible(False)
433
+
434
+ def offer(self, release: Release, can_install: bool) -> None:
435
+ self.release = release
436
+ self.text.setText(f"<b>OpenProcess {release.version} is available</b> — you have "
437
+ f"{__version__}.")
438
+ self.install_button.setText("Install Now" if can_install else "How to Update")
439
+ self.setVisible(True)