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.
- {plesty_server-0.2.2 → plesty_server-0.2.4}/PKG-INFO +2 -2
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/console.qss +36 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/host.py +175 -17
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/device.py +46 -2
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/device_manager.py +24 -2
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/presenter/bench.py +34 -4
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/installer.py +33 -3
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/launch.py +19 -6
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/supervisor.py +3 -2
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/dialogs.py +55 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/pyproject.toml +2 -2
- {plesty_server-0.2.2 → plesty_server-0.2.4}/LICENSE +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/LICENSES/LGPL-3.0-or-later.txt +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/README.md +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/__init__.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/__main__.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/env.example +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/fleet.example.yaml +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/assets/plesty-mark.svg +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/cli/__init__.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/cli/main.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/config.yaml +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/__init__.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/catalogue.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/envfile.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/errors.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/field_test.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/fleet.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/home.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/job.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/ports.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/model/process.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/presenter/__init__.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/reexec.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/__init__.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/agent.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/catalogue.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/services/jobs.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/__init__.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/app.py +0 -0
- {plesty_server-0.2.2 → plesty_server-0.2.4}/plesty/server/view/window.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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 =
|
|
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=
|
|
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]
|
|
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
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
133
|
+
def forward(_signum: int, _frame: Any) -> None:
|
|
121
134
|
child = holder.get("child")
|
|
122
135
|
if child is not None:
|
|
123
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|