plesty-server 0.2.2__tar.gz → 0.2.4__tar.gz

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 (42) hide show
  1. {plesty_server-0.2.2 → plesty_server-0.2.4}/PKG-INFO +2 -2
  2. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/console.qss +36 -0
  3. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/host.py +175 -17
  4. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/device.py +46 -2
  5. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/device_manager.py +24 -2
  6. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/presenter/bench.py +34 -4
  7. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/installer.py +33 -3
  8. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/launch.py +19 -6
  9. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/supervisor.py +3 -2
  10. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/dialogs.py +55 -0
  11. {plesty_server-0.2.2 → plesty_server-0.2.4}/pyproject.toml +2 -2
  12. {plesty_server-0.2.2 → plesty_server-0.2.4}/LICENSE +0 -0
  13. {plesty_server-0.2.2 → plesty_server-0.2.4}/LICENSES/LGPL-3.0-or-later.txt +0 -0
  14. {plesty_server-0.2.2 → plesty_server-0.2.4}/README.md +0 -0
  15. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/__init__.py +0 -0
  16. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/__main__.py +0 -0
  17. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/env.example +0 -0
  18. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/fleet.example.yaml +0 -0
  19. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/plesty-mark.svg +0 -0
  20. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/cli/__init__.py +0 -0
  21. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/cli/main.py +0 -0
  22. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/config.yaml +0 -0
  23. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/__init__.py +0 -0
  24. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/catalogue.py +0 -0
  25. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/envfile.py +0 -0
  26. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/errors.py +0 -0
  27. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/field_test.py +0 -0
  28. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/fleet.py +0 -0
  29. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/home.py +0 -0
  30. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/job.py +0 -0
  31. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/ports.py +0 -0
  32. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/process.py +0 -0
  33. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/presenter/__init__.py +0 -0
  34. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/reexec.py +0 -0
  35. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/__init__.py +0 -0
  36. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/agent.py +0 -0
  37. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/catalogue.py +0 -0
  38. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/jobs.py +0 -0
  39. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/__init__.py +0 -0
  40. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/app.py +0 -0
  41. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/window.py +0 -0
  42. {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/workers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plesty-server
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: The PLESTY bench application: installs, runs and field-tests device servers.
5
5
  Project-URL: Homepage, https://gitlab.com/plesty/core/plesty-bench
6
6
  Project-URL: Documentation, https://plesty.gitlab.io/core/plesty-bench/
@@ -11,7 +11,7 @@ License-File: LICENSE
11
11
  License-File: LICENSES/LGPL-3.0-or-later.txt
12
12
  Requires-Python: >=3.12
13
13
  Requires-Dist: click>=8.1
14
- Requires-Dist: plesty-bench==0.2.2
14
+ Requires-Dist: plesty-bench==0.2.4
15
15
  Requires-Dist: plesty-lib>=0.3.3
16
16
  Requires-Dist: pyyaml>=6.0
17
17
  Requires-Dist: pyzmq>=26
@@ -112,6 +112,20 @@ QTableWidget::item {
112
112
  padding: 4px 8px;
113
113
  }
114
114
 
115
+ /* Styling ::item at all hands item painting to the stylesheet, and the view's
116
+ selection-background-color above stops being drawn with it: a selected row
117
+ then keeps only a sliver of highlight at the edge of each cell. The rule has
118
+ to be repeated here. :!active keeps the row marked once focus moves on —
119
+ picking a module in the catalogue and then clicking Install must not look
120
+ like nothing is picked. */
121
+ QTableWidget::item:selected,
122
+ QTableView::item:selected,
123
+ QTableWidget::item:selected:!active,
124
+ QTableView::item:selected:!active {
125
+ background: {{selection}};
126
+ color: {{text_bright}};
127
+ }
128
+
115
129
  /* State chip text colours — the window-chrome language of plesty.net. */
116
130
  #stateChip {
117
131
  font-size: 12px;
@@ -251,6 +265,28 @@ QCheckBox::indicator:checked {
251
265
  border-color: {{cyan}};
252
266
  }
253
267
 
268
+ /* The radio indicator needs the same treatment as the checkbox: under a
269
+ stylesheet Windows stops theming it natively, and against the dark
270
+ background an unstyled indicator is invisible — the "Source — one of"
271
+ choice then looks unselectable. Round, so it reads as a radio. */
272
+
273
+ QRadioButton::indicator {
274
+ width: 14px;
275
+ height: 14px;
276
+ border: 1px solid {{line_strong}};
277
+ border-radius: 8px;
278
+ background: {{surface2}};
279
+ }
280
+
281
+ QRadioButton::indicator:checked {
282
+ background: {{cyan}};
283
+ border-color: {{cyan}};
284
+ }
285
+
286
+ QRadioButton::indicator:disabled {
287
+ border-color: {{line}};
288
+ }
289
+
254
290
  QGroupBox {
255
291
  border: 1px solid {{line}};
256
292
  border-radius: 8px;
@@ -22,10 +22,13 @@ Rules of the module:
22
22
  from __future__ import annotations
23
23
 
24
24
  import os
25
+ import shlex
25
26
  import signal
26
27
  import stat
27
28
  import subprocess
28
29
  import sys
30
+ import threading
31
+ import time
29
32
  from collections.abc import Callable
30
33
  from pathlib import Path
31
34
  from typing import Any
@@ -63,22 +66,91 @@ def executable(name: str, platform: str = sys.platform) -> str:
63
66
  return f"{name}.exe" if windows(platform) else name
64
67
 
65
68
 
69
+ def split_args(text: str, platform: str = sys.platform) -> list[str]:
70
+ r"""Split a written command line into arguments the way *platform* reads it.
71
+
72
+ POSIX: :func:`shlex.split` — backslash escapes the next character and
73
+ quotes group; both are removed.
74
+ Windows: backslash is the path separator and escapes nothing. POSIX
75
+ splitting eats it silently, so ``--config C:\lab\dev.ini`` arrives as
76
+ ``C:labdev.ini`` and the device is handed a path that cannot exist. Only
77
+ quotes group here, and they are stripped as POSIX splitting strips them.
78
+
79
+ Args:
80
+ text: The command line as the fleet file or the console spells it.
81
+ platform: The host to split for; defaults to this one.
82
+ """
83
+ if not windows(platform):
84
+ return shlex.split(text)
85
+ lexer = shlex.shlex(text, posix=False)
86
+ lexer.whitespace_split = True
87
+ lexer.commenters = ""
88
+ return [_unquote(token) for token in lexer]
89
+
90
+
91
+ def _unquote(token: str) -> str:
92
+ """Strip one surrounding pair of quotes, as POSIX splitting would."""
93
+ for quote in ('"', "'"):
94
+ if len(token) >= 2 and token.startswith(quote) and token.endswith(quote):
95
+ return token[1:-1]
96
+ return token
97
+
98
+
99
+ def link_or_copy(target: Path, link: Path) -> None:
100
+ """Make *link* stand in for the file *target*.
101
+
102
+ POSIX: a symlink.
103
+ Windows: creating one needs ``SeCreateSymbolicLinkPrivilege``, held by an
104
+ elevated shell or a machine in Developer Mode and by neither a bench nor a
105
+ CI runner — the attempt raises ``WinError 1314``. The file is copied
106
+ instead, which resolves to the same interpreter or program.
107
+
108
+ Args:
109
+ target: The existing file.
110
+ link: The path to create.
111
+ """
112
+ import shutil
113
+
114
+ if windows(): # pragma: no cover — exercised on the Windows CI job
115
+ shutil.copy2(target, link)
116
+ return
117
+ link.symlink_to(target)
118
+
119
+
66
120
  def remove_tree(path: Path) -> None:
67
121
  """Delete a directory tree even when git left read-only files in it.
68
122
 
69
123
  Windows: :func:`shutil.rmtree` refuses read-only files, and git marks
70
124
  everything under ``.git/objects`` read-only — so a checkout could not be
71
- uninstalled where a PyPI venv could. The retry clears the bit first.
72
- POSIX: the owner may delete read-only files; the retry is never reached.
125
+ uninstalled where a PyPI venv could. The retry clears the bit first. A
126
+ file that is *open* elsewhere cannot be deleted at all here, where POSIX
127
+ unlinks it happily: an antivirus or the search indexer holds one for a
128
+ moment (the retry waits that out), a running device server holds its own
129
+ interpreter for as long as it runs (:exc:`PermissionError` reaches the
130
+ caller, which is the honest answer — the device has to be stopped first).
131
+ POSIX: the owner may delete read-only and open files; no retry is reached.
73
132
  """
74
133
  import shutil
75
134
 
76
135
  shutil.rmtree(path, onexc=_clear_readonly_and_retry)
77
136
 
78
137
 
138
+ #: Waits between attempts to delete a file another process is holding open.
139
+ _RETRY_DELAYS = (0.1, 0.3, 0.6)
140
+
141
+
79
142
  def _clear_readonly_and_retry(func: Callable[[str], None], path: str, _exc: BaseException) -> None:
80
- os.chmod(path, stat.S_IWRITE)
81
- func(path)
143
+ try:
144
+ os.chmod(path, stat.S_IWRITE)
145
+ except OSError: # gone already, or not ours to change — let *func* say so
146
+ pass
147
+ for delay in _RETRY_DELAYS:
148
+ try:
149
+ func(path)
150
+ return
151
+ except PermissionError:
152
+ time.sleep(delay)
153
+ func(path) # out of patience: raise whatever it raises now
82
154
 
83
155
 
84
156
  # --------------------------------------------------------------------------- #
@@ -108,6 +180,105 @@ def spawn_kwargs(new_group: bool = False) -> dict[str, Any]:
108
180
  return {"creationflags": flags} # pragma: no cover
109
181
 
110
182
 
183
+ def interruptible_kwargs() -> dict[str, Any]:
184
+ """:mod:`subprocess` keyword arguments for a child *this* process must be able to stop.
185
+
186
+ Windows: ``CREATE_NEW_PROCESS_GROUP`` makes the child the leader of a
187
+ group that can be sent ``CTRL_BREAK_EVENT``. It deliberately does *not*
188
+ add ``CREATE_NO_WINDOW``: that would give the child a console of its own,
189
+ and a console control event cannot cross from one console to another. The
190
+ child inherits ours instead — already invisible, because the waiter itself
191
+ was started with ``CREATE_NO_WINDOW`` — so no window appears either way.
192
+ POSIX: nothing; the child stays in our group and ``send_signal`` reaches it.
193
+ """
194
+ if not windows():
195
+ return {}
196
+ return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} # type: ignore[attr-defined]
197
+
198
+
199
+ #: How often the Windows waiter looks for the stop file (seconds).
200
+ _STOP_POLL = 0.2
201
+
202
+
203
+ def request_stop(pid: int, stop_file: Path) -> None:
204
+ """Ask the waiter *pid* to shut its command down cleanly.
205
+
206
+ POSIX: ``SIGINT``, which the waiter forwards to the command.
207
+ Windows: no console control event can reach it. The waiter runs on an
208
+ invisible console of its own and the process asking may have no console at
209
+ all — ``plesty-server-gui.exe`` is a GUI binary — and
210
+ ``GenerateConsoleCtrlEvent`` only reaches the caller's own console. It
211
+ would fail silently and the bench would fall through to killing every
212
+ device outright, so the waiter watches for *stop_file* instead
213
+ (:func:`watch_for_stop`) and creating it is the request.
214
+
215
+ Args:
216
+ pid: The waiter that owns the command.
217
+ stop_file: The file that waiter watches.
218
+ """
219
+ if windows(): # pragma: no cover — exercised on the Windows CI job
220
+ stop_file.parent.mkdir(parents=True, exist_ok=True)
221
+ stop_file.write_text("stop", encoding="utf-8")
222
+ return
223
+ _signal(pid, signal.SIGINT)
224
+
225
+
226
+ def watch_for_stop(stop_file: Path, on_stop: Callable[[], None]) -> None:
227
+ """Call *on_stop* when *stop_file* appears — the Windows half of a clean stop.
228
+
229
+ POSIX: returns at once. A stop arrives as a signal there, and the handler
230
+ :func:`forward_signals` installed does the same work.
231
+ Windows: starts a daemon thread polling for the file, so the waiter can be
232
+ asked to stop by a process that shares no console with it.
233
+
234
+ Args:
235
+ stop_file: The file whose appearance means "stop".
236
+ on_stop: Run once, on the watching thread, when it appears.
237
+ """
238
+ if not windows():
239
+ return
240
+
241
+ def poll() -> None: # pragma: no cover — exercised on the Windows CI job
242
+ while not stop_file.exists():
243
+ time.sleep(_STOP_POLL)
244
+ on_stop()
245
+
246
+ threading.Thread(target=poll, daemon=True).start() # pragma: no cover
247
+
248
+
249
+ def stop_signal() -> int:
250
+ """The signal a clean stop arrives as, for a process that must catch one.
251
+
252
+ POSIX: ``SIGINT``.
253
+ Windows: ``SIGBREAK``. ``CTRL_BREAK_EVENT`` is the only console control
254
+ event that can be aimed at a single process group — ``CTRL_C_EVENT`` is
255
+ disabled outright for a group made with ``CREATE_NEW_PROCESS_GROUP`` — so
256
+ it is what :func:`deliver_stop` sends, and Python delivers it as
257
+ ``SIGBREAK``. A server that only handles ``SIGINT`` is killed outright
258
+ here (exit ``0xC000013A``) instead of closing its device.
259
+ """
260
+ if windows(): # pragma: no cover — exercised on the Windows CI job
261
+ return int(signal.SIGBREAK) # type: ignore[attr-defined]
262
+ return int(signal.SIGINT)
263
+
264
+
265
+ def deliver_stop(child: subprocess.Popen[bytes]) -> None:
266
+ """Ask *child* to end cleanly, from the process that started it.
267
+
268
+ POSIX: ``SIGINT``.
269
+ Windows: ``CTRL_BREAK_EVENT`` to the group the child leads — it shares
270
+ this process's console, which is what :func:`interruptible_kwargs` buys.
271
+ A child that has already gone is not an error.
272
+ """
273
+ try:
274
+ if windows(): # pragma: no cover — exercised on the Windows CI job
275
+ os.kill(child.pid, signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
276
+ else:
277
+ child.send_signal(signal.SIGINT)
278
+ except OSError:
279
+ return
280
+
281
+
111
282
  def detach(run: Callable[[], int], report: Report) -> int:
112
283
  r"""Run *run* in a process nobody has to reap, and tell *report* its pid.
113
284
 
@@ -215,19 +386,6 @@ def _pid_alive_windows(pid: int) -> bool: # pragma: no cover — exercised on t
215
386
  kernel32.CloseHandle(handle)
216
387
 
217
388
 
218
- def interrupt(pid: int) -> None:
219
- """Ask *pid* to shut down cleanly.
220
-
221
- POSIX: ``SIGINT`` (a launcher forwards it to its child).
222
- Windows: ``CTRL_BREAK_EVENT`` to the process group started with
223
- ``CREATE_NEW_PROCESS_GROUP``.
224
- """
225
- if windows(): # pragma: no cover — exercised on the Windows CI job
226
- _signal(pid, signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
227
- return
228
- _signal(pid, signal.SIGINT)
229
-
230
-
231
389
  def terminate(pid: int) -> None:
232
390
  """Tell *pid* to end (``SIGTERM``). Windows has no equivalent; :func:`kill_tree` is next."""
233
391
  if not windows():
@@ -5,14 +5,57 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- import shlex
9
8
  from collections.abc import Mapping
10
9
  from dataclasses import dataclass, field
11
10
  from typing import Any
12
11
 
12
+ from plesty.server import host
13
13
  from plesty.server.model.errors import FleetError
14
14
  from plesty.server.model.ports import declares_port, port_from
15
15
 
16
+ #: Names Windows keeps for its own devices — reserved with any extension, in
17
+ #: any case, so ``com3`` cannot become ``logs/com3.log``. A fleet has to load
18
+ #: on every bench, so the names are refused everywhere, not only on Windows.
19
+ _RESERVED = frozenset(
20
+ ["con", "prn", "aux", "nul"]
21
+ + [f"com{d}" for d in "123456789"]
22
+ + [f"lpt{d}" for d in "123456789"]
23
+ )
24
+
25
+ #: A device name becomes a directory, a log file and a state file, so it has to
26
+ #: be a portable file name: no separators, no spaces, no leading dot.
27
+ _NAME_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
28
+
29
+
30
+ def check_name(name: str) -> None:
31
+ """Raise :exc:`FleetError` unless *name* is usable as a file name on any bench.
32
+
33
+ The name of a device is not only a label: it names its virtual environment,
34
+ its log, its run directory and its state file. A name that a Windows bench
35
+ cannot open (``com3``) or that reaches out of its directory (``../x``)
36
+ would declare fine on a developer machine and fail on the instrument.
37
+
38
+ Args:
39
+ name: The key the fleet file gives the device.
40
+
41
+ Raises:
42
+ FleetError: When the name cannot be a file name everywhere.
43
+ """
44
+ if not name:
45
+ raise FleetError("device name must not be empty")
46
+ if len(name) > 64:
47
+ raise FleetError(f"device name {name!r}: longer than 64 characters")
48
+ bad = sorted(set(name) - _NAME_CHARS)
49
+ if bad:
50
+ raise FleetError(
51
+ f"device name {name!r}: {''.join(bad)!r} not allowed — "
52
+ "letters, digits, dot, dash and underscore only"
53
+ )
54
+ if name[0] in ".-":
55
+ raise FleetError(f"device name {name!r}: must start with a letter or a digit")
56
+ if name.split(".")[0].lower() in _RESERVED:
57
+ raise FleetError(f"device name {name!r}: reserved by Windows for a device file")
58
+
16
59
 
17
60
  def module_for(package: str) -> str:
18
61
  """The import path served by ``python -m`` for a PLESTY distribution.
@@ -129,13 +172,14 @@ class DeviceSpec:
129
172
  Raises:
130
173
  FleetError: On unknown keys or wrong value types.
131
174
  """
175
+ check_name(name)
132
176
  known = {"package", "version", "git", "ref", "module", "args", "env", "port", "autostart"}
133
177
  unknown = set(raw) - known
134
178
  if unknown:
135
179
  raise FleetError(f"device {name!r}: unknown keys {sorted(unknown)}")
136
180
  args = raw.get("args", ())
137
181
  if isinstance(args, str):
138
- args = shlex.split(args)
182
+ args = host.split_args(args)
139
183
  if not isinstance(args, (list, tuple)):
140
184
  raise FleetError(f"device {name!r}: 'args' must be a list or a string")
141
185
  env = raw.get("env", {})
@@ -206,7 +206,29 @@ class InstalledPackage:
206
206
  """The most recently installed version, or ``None`` when empty."""
207
207
  if not self.versions:
208
208
  return None
209
- return max(self.versions.values(), key=lambda v: v.installed_utc)
209
+ return max(self.versions.values(), key=install_order)
210
+
211
+
212
+ def version_order(version: str) -> tuple[int, ...]:
213
+ """*version* as numbers, so ``0.10.0`` sorts above ``0.9.0``."""
214
+ order = []
215
+ for chunk in version.split("."):
216
+ digits = "".join(c for c in chunk if c.isdigit())
217
+ order.append(int(digits) if digits else 0)
218
+ return tuple(order)
219
+
220
+
221
+ def install_order(installed: InstalledVersion) -> tuple[str, tuple[int, ...]]:
222
+ """Order installs by when they happened, then by version.
223
+
224
+ ``installed_utc`` is stamped to the second, so two installs of the same
225
+ package in one second tie — and a bare ``max``/``sorted`` then falls back
226
+ to the order the records happened to be read in. That order is the
227
+ filesystem's, so "the latest version" was whichever the host listed last
228
+ and differed between a Windows bench and a POSIX machine. The version
229
+ breaks the tie.
230
+ """
231
+ return (installed.installed_utc, version_order(installed.version))
210
232
 
211
233
 
212
234
  class DeviceManager:
@@ -295,7 +317,7 @@ class DeviceManager:
295
317
  installed = self.get(package, path.stem)
296
318
  if installed is not None:
297
319
  versions[installed.slot] = installed
298
- ordered = dict(sorted(versions.items(), key=lambda kv: kv[1].installed_utc))
320
+ ordered = dict(sorted(versions.items(), key=lambda kv: install_order(kv[1])))
299
321
  return InstalledPackage(package, ordered)
300
322
 
301
323
  def list(self) -> list[InstalledPackage]:
@@ -433,6 +433,10 @@ class Bench:
433
433
  for spec in self.fleet:
434
434
  if spec.package == package and spec.git and spec.git not in urls:
435
435
  urls.append(spec.git)
436
+ # A release tag is what the *release* source offers: listing v0.1.0 under
437
+ # the repository as well says the same install twice, in the form that
438
+ # needs a clone. What only the repository can give is a branch.
439
+ refs = [r for r in refs if not release_tag(r)]
436
440
  for branch in ("main", "exp"):
437
441
  if branch not in refs:
438
442
  refs.append(branch)
@@ -691,10 +695,22 @@ class Bench:
691
695
  return statuses
692
696
 
693
697
  def down(self, names: Sequence[str] | None = None) -> list[str]:
694
- """Stop the working fleet (or *names*); returns the names stopped."""
695
- targets = list(names) if names else [s.name for s in self.working]
696
- for n in targets:
697
- self.spec(n)
698
+ """Stop everything running (or *names*); returns the names stopped.
699
+
700
+ ``fleet up`` starts the working fleet, so ``down`` starts there — but a
701
+ device declared ``autostart: false`` and started by hand is running all
702
+ the same, and stopping only the working fleet left it up and called the
703
+ bench down. Anything with a launch record is included.
704
+ """
705
+ if names:
706
+ targets = list(names)
707
+ for n in targets:
708
+ self.spec(n)
709
+ else:
710
+ working = [s.name for s in self.working]
711
+ # Names that only have a record are still stoppable: stopping needs
712
+ # the record, not the declaration.
713
+ targets = working + [n for n in self.supervisor.known() if n not in working]
698
714
  return self.supervisor.stop_all(targets)
699
715
 
700
716
  def run(
@@ -905,6 +921,11 @@ class Bench:
905
921
  [str(python), str(script), "--help"],
906
922
  capture_output=True,
907
923
  text=True,
924
+ # A device's help says µm and °C; the host code page on a
925
+ # Windows bench cannot always decode that, and text=True alone
926
+ # would raise instead of showing the help.
927
+ encoding="utf-8",
928
+ errors="replace",
908
929
  timeout=60,
909
930
  check=False,
910
931
  cwd=script.parent.parent,
@@ -973,6 +994,15 @@ def _example_blocks(template: str) -> dict[str, str]:
973
994
  return blocks
974
995
 
975
996
 
997
+ def release_tag(ref: str) -> bool:
998
+ """Whether *ref* names a release (``v0.3.1``) rather than a branch.
999
+
1000
+ Args:
1001
+ ref: A git ref as the inventory or the catalogue reports it.
1002
+ """
1003
+ return len(ref) > 1 and ref[0] == "v" and ref[1].isdigit()
1004
+
1005
+
976
1006
  def _default_name(package: str) -> str:
977
1007
  """``plesty-pm100d`` → ``pm100d``: the instance name a fresh install gets."""
978
1008
  stem = package.strip().lower()
@@ -54,6 +54,26 @@ Echo = Callable[[str], None]
54
54
  Runner = Callable[[list[str], "Echo | None"], "subprocess.CompletedProcess[str]"]
55
55
 
56
56
 
57
+ def _remove_env(env: Path, package: str) -> None:
58
+ """Delete the environment of *package*, saying why when the host will not.
59
+
60
+ Windows keeps a file open by a running process undeletable, so replacing
61
+ the environment of a device that is still serving fails there where it
62
+ succeeds on POSIX. The bare ``PermissionError`` names a file deep inside a
63
+ venv; this names the device instead.
64
+
65
+ Raises:
66
+ InstallError: When the environment is in use.
67
+ """
68
+ try:
69
+ remove_tree(env)
70
+ except PermissionError as exc:
71
+ raise InstallError(
72
+ f"{package}: its environment is in use and cannot be replaced "
73
+ f"({exc}) — stop the devices running it first"
74
+ ) from exc
75
+
76
+
57
77
  class InstallError(RuntimeError):
58
78
  """``uv``/``git`` failed, or the environment is not in the shape expected."""
59
79
 
@@ -65,7 +85,17 @@ def default_runner(cmd: list[str], echo: Echo | None = None) -> subprocess.Compl
65
85
  the text is returned as well, so a failure still carries its own detail.
66
86
  """
67
87
  if echo is None:
68
- return subprocess.run(cmd, capture_output=True, text=True, check=False, **spawn_kwargs())
88
+ # encoding: text=True alone decodes with the host locale, which on a
89
+ # Windows bench is a code page that uv's UTF-8 output can break.
90
+ return subprocess.run(
91
+ cmd,
92
+ capture_output=True,
93
+ text=True,
94
+ encoding="utf-8",
95
+ errors="replace",
96
+ check=False,
97
+ **spawn_kwargs(),
98
+ )
69
99
  echo(f"$ {' '.join(cmd)}")
70
100
  proc = subprocess.Popen(
71
101
  cmd,
@@ -188,7 +218,7 @@ class Installer:
188
218
  env = self.manager.env_dir(installed)
189
219
  removed = env.exists()
190
220
  if removed:
191
- remove_tree(env)
221
+ _remove_env(env, installed.package)
192
222
  parent = env.parent
193
223
  if parent != self.home.packages and parent.is_dir() and not any(parent.iterdir()):
194
224
  parent.rmdir()
@@ -213,7 +243,7 @@ class Installer:
213
243
  raise InstallError(f"{spec.package} is not present in the venv after install")
214
244
  final = self.home.package_dir(spec.package, version)
215
245
  if final.exists():
216
- remove_tree(final)
246
+ _remove_env(final, spec.package)
217
247
  staging.rename(final)
218
248
  return self.manager.record(
219
249
  spec.package,
@@ -33,6 +33,16 @@ from plesty.server import host
33
33
  from plesty.server.reexec import launcher_command
34
34
 
35
35
 
36
+ def stop_file(exit_file: str | os.PathLike[str]) -> Path:
37
+ """The file that asks the waiter of *exit_file* to stop its command.
38
+
39
+ Alongside the exit file, so anything that knows where a command reports
40
+ its end also knows how to ask it to end (see
41
+ :func:`plesty.server.host.request_stop`).
42
+ """
43
+ return Path(f"{exit_file}.stop")
44
+
45
+
36
46
  def launch(
37
47
  command: Sequence[str],
38
48
  cwd: str | os.PathLike[str],
@@ -60,6 +70,8 @@ def launch(
60
70
  if not (os.path.exists(executable) or shutil.which(executable)):
61
71
  raise OSError(f"cannot launch {executable}: no such executable")
62
72
  Path(exit_file).unlink(missing_ok=True)
73
+ stop = stop_file(exit_file)
74
+ stop.unlink(missing_ok=True) # a stale one would stop the new command at once
63
75
  Path(log).parent.mkdir(parents=True, exist_ok=True)
64
76
  spec = {
65
77
  "command": list(command),
@@ -67,6 +79,7 @@ def launch(
67
79
  "env": dict(env),
68
80
  "log": str(log),
69
81
  "exit_file": str(exit_file),
82
+ "stop_file": str(stop),
70
83
  "banner": banner,
71
84
  }
72
85
  # Read the pid the launcher prints; never wait for the launcher to finish —
@@ -117,13 +130,10 @@ def _wait(spec: dict[str, Any]) -> int:
117
130
  """The waiter: run the command, forward the interrupt, record the exit code."""
118
131
  holder: dict[str, subprocess.Popen[bytes]] = {}
119
132
 
120
- def forward(signum: int, _frame: Any) -> None:
133
+ def forward(_signum: int, _frame: Any) -> None:
121
134
  child = holder.get("child")
122
135
  if child is not None:
123
- try:
124
- child.send_signal(signum)
125
- except OSError:
126
- pass
136
+ host.deliver_stop(child)
127
137
 
128
138
  host.forward_signals(forward)
129
139
  with open(spec["log"], "ab") as log:
@@ -138,12 +148,15 @@ def _wait(spec: dict[str, Any]) -> int:
138
148
  stdin=subprocess.DEVNULL,
139
149
  stdout=log,
140
150
  stderr=subprocess.STDOUT,
141
- **host.spawn_kwargs(),
151
+ **host.interruptible_kwargs(),
142
152
  )
143
153
  except OSError as exc:
144
154
  log.write(f"=== cannot start: {exc}\n".encode())
145
155
  _write_exit(spec["exit_file"], 127)
146
156
  return 127
157
+ # Windows cannot signal us across consoles; the stop file is how a stop
158
+ # arrives there. Registered once the child exists, so it can be delivered.
159
+ host.watch_for_stop(Path(spec["stop_file"]), lambda: host.deliver_stop(holder["child"]))
147
160
  code = holder["child"].wait()
148
161
  stamp = datetime.now(UTC).isoformat(timespec="seconds")
149
162
  with open(spec["log"], "ab") as log:
@@ -44,7 +44,7 @@ from plesty.server.model.process import (
44
44
  ProcessRecord,
45
45
  ProcessStatus,
46
46
  )
47
- from plesty.server.services.launch import launch, read_exit
47
+ from plesty.server.services.launch import launch, read_exit, stop_file
48
48
 
49
49
  #: Host every probe targets — servers bind ``tcp://*`` on the bench itself.
50
50
  PROBE_HOST = "127.0.0.1"
@@ -174,6 +174,7 @@ class Supervisor:
174
174
  def _drop_record(self, name: str) -> None:
175
175
  self.home.state_file(name).unlink(missing_ok=True)
176
176
  self.exit_file(name).unlink(missing_ok=True)
177
+ stop_file(self.exit_file(name)).unlink(missing_ok=True)
177
178
 
178
179
  # ── liveness ─────────────────────────────────────────────────────────
179
180
 
@@ -370,7 +371,7 @@ class Supervisor:
370
371
  self._drop_record(name)
371
372
  return False
372
373
  wait = self.stop_timeout if timeout is None else timeout
373
- host.interrupt(rec.pid)
374
+ host.request_stop(rec.pid, stop_file(self.exit_file(name)))
374
375
  if not self._wait_exit(name, wait):
375
376
  host.terminate(rec.pid)
376
377
  if not self._wait_exit(name, 3.0):
@@ -15,6 +15,7 @@ from collections.abc import Callable
15
15
  from typing import Any
16
16
 
17
17
  from PySide6.QtCore import Qt, QTimer
18
+ from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat
18
19
  from PySide6.QtWidgets import (
19
20
  QCheckBox,
20
21
  QComboBox,
@@ -33,10 +34,61 @@ from PySide6.QtWidgets import (
33
34
  QWidget,
34
35
  )
35
36
 
37
+ from plesty.lib.ui.theme import Theme
36
38
  from plesty.server.model import DeviceSpec, FieldTestRun, FleetError, Job
37
39
  from plesty.server.presenter import Bench, SourceOptions
38
40
 
39
41
 
42
+ class EnvHighlighter(QSyntaxHighlighter):
43
+ """Dim what a ``.env`` line does not set: its comments.
44
+
45
+ A device ``.env`` is mostly prose — what a key is for, an example address,
46
+ a value commented out for later — and at the weight of the settings it
47
+ explains the file reads as a wall of text. Comments are drawn in the muted
48
+ tone instead, so the eye lands on the ``KEY=VALUE`` lines that act.
49
+
50
+ What is dimmed is exactly what :func:`plesty.server.model.parse_env`
51
+ throws away: a line whose first non-space character is ``#``, and the
52
+ `` #`` tail of an *unquoted* value. A ``#`` inside a quoted value is part
53
+ of the value — a password may contain one — and stays at full weight.
54
+ """
55
+
56
+ def __init__(self, document: Any, colour: str) -> None:
57
+ """Highlight *document*, drawing comments in *colour* (a hex string)."""
58
+ from PySide6.QtGui import QColor
59
+
60
+ super().__init__(document)
61
+ self._comment = QTextCharFormat()
62
+ self._comment.setForeground(QColor(colour))
63
+
64
+ def highlightBlock(self, text: str) -> None: # noqa: N802 — Qt's name
65
+ """Colour the comment part of one line, if it has one."""
66
+ start = comment_start(text)
67
+ if start is not None:
68
+ self.setFormat(start, len(text) - start, self._comment)
69
+
70
+
71
+ def comment_start(line: str) -> int | None:
72
+ """Where the comment of a ``.env`` *line* begins, or ``None`` when it has none.
73
+
74
+ Args:
75
+ line: One line of dotenv text.
76
+ """
77
+ stripped = line.lstrip()
78
+ if not stripped:
79
+ return None
80
+ if stripped.startswith("#"):
81
+ return len(line) - len(stripped)
82
+ body = stripped[len("export ") :] if stripped.startswith("export ") else stripped
83
+ if "=" not in body:
84
+ return None
85
+ value = body.split("=", 1)[1].strip()
86
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
87
+ return None # quoted: a # in there belongs to the value
88
+ marker = line.find(" #", line.index("=") + 1)
89
+ return marker if marker != -1 else None
90
+
91
+
40
92
  def _form(parent: QWidget | None = None) -> QFormLayout:
41
93
  """A form whose fields take the full width (macOS keeps them at size hint otherwise)."""
42
94
  form = QFormLayout(parent) if parent is not None else QFormLayout()
@@ -590,6 +642,9 @@ class DeviceConfigDialog(QDialog):
590
642
  "KEY=VALUE per line — DEVICE_ADDRESS, credentials, PLESTY_REPORT_ARCHIVE …\n"
591
643
  "Read by the device server and the field test; never leaves this bench."
592
644
  )
645
+ # The comments carry the explanations, the KEY=VALUE lines carry the
646
+ # settings: the muted tone tells them apart at a glance.
647
+ self.env_highlighter = EnvHighlighter(self.env.document(), Theme.load().color("slate"))
593
648
  env_page = QWidget()
594
649
  env_layout = QVBoxLayout(env_page)
595
650
  note = QLabel(f"{bench.env_file(name)}")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plesty-server"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  license = "LGPL-3.0-or-later"
5
5
  license-files = ["LICENSE", "LICENSES/*"]
6
6
  description = "The PLESTY bench application: installs, runs and field-tests device servers."
@@ -9,7 +9,7 @@ maintainers = [{ name = "Plesty Development Team", email = "plesty.dev@example.c
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
11
11
  dependencies = [
12
- "plesty-bench==0.2.2",
12
+ "plesty-bench==0.2.4",
13
13
  "plesty-lib>=0.3.3",
14
14
  "pyyaml>=6.0",
15
15
  "pyzmq>=26",
File without changes
File without changes