plesty.server.host

Everything that differs between operating systems, in one place.

plesty-server runs on Windows benches and on macOS/Linux developer machines. Every branch on the platform lives in this module and nowhere else: the rest of the package asks host for the answer and stays platform-blind. When a new difference turns up, add it here — one function per question, each documenting the Windows and the POSIX answer side by side.

Rules of the module:

  • No other module tests sys.platform or os.name.

  • Every function is total — it returns a sensible answer on every platform, never raises for “not supported here”.

  • Functions take the platform as a parameter where that makes them testable from any host (venv_python()); the module-level windows() is the default.

Attributes

Report

_RETRY_DELAYS

_BOUND

_STOP_POLL

KILLED_ON_STOP

Functions

windows(→ bool)

Whether platform is Windows — the only one with console windows and Scripts/.

venv_python(→ pathlib.Path)

The interpreter inside a virtual environment.

executable(→ str)

The file name of an executable called name: name.exe on Windows, name on POSIX.

split_args(→ list[str])

Split a written command line into arguments the way platform reads it.

_unquote(→ str)

Strip one surrounding pair of quotes, as POSIX splitting would.

link_or_copy(→ None)

Make link stand in for the file target.

remove_tree(→ None)

Delete a directory tree even when git left read-only files in it.

move_tree(→ None)

Rename a directory, waiting out whoever is holding it open.

_clear_readonly_and_retry(→ None)

spawn_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child that shows no window.

detached_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child that outlives this process, unseen.

windowed_python(→ pathlib.Path)

The interpreter that runs a window script without a console window of its own.

help_menu_title(→ str)

title for the Help menu, without the search field macOS adds to one.

base_python(→ pathlib.Path)

The interpreter this process's virtual environment was made from.

die_with_me_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child that must not outlive this process.

bind_to_me(→ None)

Make child end when this process ends, however it ends (Windows).

echoing_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child whose output must reach our stdout.

waiter_kwargs(→ dict[str, Any])

subprocess keyword arguments for the launcher that waits on a command.

interruptible_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child this process must be able to stop.

request_stop(→ None)

Ask the waiter pid to shut its command down cleanly.

watch_for_stop(→ None)

Call on_stop when stop_file appears — the Windows half of a clean stop.

split_command(→ list[str])

Split a typed command line into an argument list.

exit_note(→ str)

What returncode says that the server's own log cannot, or "".

stop_signal(→ int)

The signal a clean stop arrives as, for a process that must catch one.

deliver_stop(→ None)

Ask child to end cleanly, from the process that started it.

detach(→ int)

Run run in a process nobody has to reap, and tell report its pid.

reap_launcher(→ None)

Wait for a launcher process once it has reported the detached pid.

forward_signals(→ None)

Route the signals that mean "stop" to handler in the current process.

pid_alive(→ bool)

Whether a process with pid exists (no psutil).

_pid_alive_windows(→ bool)

terminate(→ None)

Tell pid to end (SIGTERM). Windows has no equivalent; kill_tree() is next.

kill_tree(→ None)

End pid and everything it started, without asking.

_signal(→ None)

Send sig to pid (or its process group), ignoring a vanished target.

Module Contents

plesty.server.host.Report
plesty.server.host.windows(platform: str = sys.platform) → bool

Whether platform is Windows — the only one with console windows and Scripts/.

Parameters:

platform (str)

Return type:

bool

plesty.server.host.venv_python(venv: pathlib.Path, platform: str = sys.platform) → pathlib.Path

The interpreter inside a virtual environment.

Windows: <venv>/Scripts/python.exe. POSIX: <venv>/bin/python.

Parameters:
  • venv (pathlib.Path)

  • platform (str)

Return type:

pathlib.Path

plesty.server.host.executable(name: str, platform: str = sys.platform) → str

The file name of an executable called name: name.exe on Windows, name on POSIX.

Parameters:
  • name (str)

  • platform (str)

Return type:

str

plesty.server.host.split_args(text: str, platform: str = sys.platform) → list[str]

Split a written command line into arguments the way platform reads it.

POSIX: shlex.split() — backslash escapes the next character and quotes group; both are removed. Windows: backslash is the path separator and escapes nothing. POSIX splitting eats it silently, so --config C:\lab\dev.ini arrives as C:labdev.ini and the device is handed a path that cannot exist. Only quotes group here, and they are stripped as POSIX splitting strips them.

Parameters:
  • text (str) – The command line as the fleet file or the console spells it.

  • platform (str) – The host to split for; defaults to this one.

Return type:

list[str]

plesty.server.host._unquote(token: str) → str

Strip one surrounding pair of quotes, as POSIX splitting would.

Parameters:

token (str)

Return type:

str

Make link stand in for the file target.

POSIX: a symlink. Windows: creating one needs SeCreateSymbolicLinkPrivilege, held by an elevated shell or a machine in Developer Mode and by neither a bench nor a CI runner — the attempt raises WinError 1314. The file is copied instead, which resolves to the same interpreter or program.

Parameters:
  • target (pathlib.Path) – The existing file.

  • link (pathlib.Path) – The path to create.

Return type:

None

plesty.server.host.remove_tree(path: pathlib.Path) → None

Delete a directory tree even when git left read-only files in it.

Windows: shutil.rmtree() refuses read-only files, and git marks everything under .git/objects read-only — so a checkout could not be uninstalled where a PyPI venv could. The retry clears the bit first. A file that is open elsewhere cannot be deleted at all here, where POSIX unlinks it happily: an antivirus or the search indexer holds one for a moment (the retry waits that out), a running device server holds its own interpreter for as long as it runs (PermissionError reaches the caller, which is the honest answer — the device has to be stopped first). POSIX: the owner may delete read-only and open files; no retry is reached.

Parameters:

path (pathlib.Path)

Return type:

None

plesty.server.host._RETRY_DELAYS = (0.1, 0.3, 0.6)
plesty.server.host.move_tree(source: pathlib.Path, target: pathlib.Path) → None

Rename a directory, waiting out whoever is holding it open.

Windows: renaming a directory fails with PermissionError while any process holds a handle to it or to a file inside it, and something always does for a moment right after a tree is written — an antivirus scanning a fresh venv, the search indexer. POSIX renames it out from under an open handle and never reaches the retry. When the waiting does not help, the error reaches the caller: whatever holds it is not letting go.

Parameters:
  • source (pathlib.Path)

  • target (pathlib.Path)

Return type:

None

plesty.server.host._clear_readonly_and_retry(func: collections.abc.Callable[[str], None], path: str, _exc: BaseException) → None
Parameters:
  • func (collections.abc.Callable[[str], None])

  • path (str)

  • _exc (BaseException)

Return type:

None

plesty.server.host.spawn_kwargs(new_group: bool = False) → dict[str, Any]

subprocess keyword arguments for a child that shows no window.

Windows: a console program started from a process without a console of its own gets a brand-new console window — one black box per uv call over the GUI. CREATE_NO_WINDOW stops it; the child keeps its handles, so job logs still fill. new_group adds CREATE_NEW_PROCESS_GROUP so it can be signalled on its own. POSIX: there is no window; new_group starts a new session so the child outlives (and is not interrupted with) its parent.

Parameters:

new_group (bool) – Detach the child from this process’s signals.

Return type:

dict[str, Any]

plesty.server.host.detached_kwargs() → dict[str, Any]

subprocess keyword arguments for a child that outlives this process, unseen.

The update helper (#29): it runs on after the console has exited, and must not be taken down with it or show a window while it waits. Windows: a hidden console of its own and a process group of its own. POSIX: a new session.

Not DETACHED_PROCESS: Windows PowerShell 5.1 started with no console at all exits 0 before it reads its -File, so the update helper never ran and the update window waited on a log that never came. The child outlives the console either way: nothing ties it to the console’s process.

Return type:

dict[str, Any]

plesty.server.host.windowed_python(python: pathlib.Path, platform: str = sys.platform) → pathlib.Path

The interpreter that runs a window script without a console window of its own.

Windows: pythonw.exe beside python, when there is one — python.exe started from a process with no console opens one. Elsewhere: python itself.

Parameters:
  • python (pathlib.Path)

  • platform (str)

Return type:

pathlib.Path

plesty.server.host.help_menu_title(title: str, platform: str = sys.platform) → str

title for the Help menu, without the search field macOS adds to one.

macOS gives a menu titled “Help” a search field of its own, which the console does not use. A zero-width space ends the title, so it reads the same and macOS no longer takes it for its Help menu. Elsewhere: title.

Parameters:
  • title (str)

  • platform (str)

Return type:

str

plesty.server.host.base_python() → pathlib.Path

The interpreter this process’s virtual environment was made from.

The update window runs on it (#29): it lies outside the environment the update replaces. Outside a virtual environment, this interpreter.

Return type:

pathlib.Path

plesty.server.host.die_with_me_kwargs() → dict[str, Any]

subprocess keyword arguments for a child that must not outlive this process.

Linux: the kernel sends the child SIGTERM when this process dies, however it dies (PR_SET_PDEATHSIG). Windows has no such flag at spawn time — bind_to_me() does it once the child exists. macOS has neither, and the child is left as before.

Return type:

dict[str, Any]

plesty.server.host._BOUND: list[Any] = []
plesty.server.host.bind_to_me(child: subprocess.Popen[Any]) → None

Make child end when this process ends, however it ends (Windows).

A job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE holds the child; its one handle is this process’s, so a wrapper killed from the task manager takes its child with it rather than leaving it running alone. Nothing on another system, where die_with_me_kwargs() does it. A child that cannot be bound runs unbound: it worked so before.

Parameters:

child (subprocess.Popen[Any])

Return type:

None

plesty.server.host.echoing_kwargs() → dict[str, Any]

subprocess keyword arguments for a child whose output must reach our stdout.

Windows: CREATE_NO_WINDOW leaves the child with no console, and a child with neither a console nor handles of its own writes its output nowhere — a field test’s whole transcript vanished from the job log that way, leaving only the lines the bench itself had written around it. Naming the streams makes subprocess pass real handles, so the flag can suppress the window without taking the output with it. POSIX: inheriting is enough and there is no window to suppress; naming them changes nothing.

Falls back to plain inheritance when this process has no usable stdout — a windowed process has nothing to hand down in any case.

Return type:

dict[str, Any]

plesty.server.host.waiter_kwargs() → dict[str, Any]

subprocess keyword arguments for the launcher that waits on a command.

Windows: the waiter needs a console of its own — hidden — for two reasons that pull in opposite directions. Its child must inherit a console, or, being a console program with none to inherit, Windows gives it a new and visible one: a black box on the operator’s screen for every device server and every job. And the waiter must share that console with the child, because GenerateConsoleCtrlEvent cannot cross from one console to another, and a clean stop is exactly that event.

CREATE_NO_WINDOW gives neither: it runs the waiter with no console at all, so the child allocates the visible one. CREATE_NEW_CONSOLE with SW_HIDE gives a real console created hidden — no window, nothing to flash, and an event the waiter can deliver.

POSIX: a new session, as for any detached child.

Return type:

dict[str, Any]

plesty.server.host.interruptible_kwargs() → dict[str, Any]

subprocess keyword arguments for a child this process must be able to stop.

Windows: CREATE_NEW_PROCESS_GROUP makes the child the leader of a group that can be sent CTRL_BREAK_EVENT. It deliberately does not add CREATE_NO_WINDOW: that would give the child a console of its own, and a console control event cannot cross from one console to another. The child inherits the waiter’s console instead, which waiter_kwargs() creates hidden for exactly this — so no window appears either way. POSIX: nothing; the child stays in our group and send_signal reaches it.

Return type:

dict[str, Any]

plesty.server.host._STOP_POLL = 0.2
plesty.server.host.request_stop(pid: int, stop_file: pathlib.Path) → None

Ask the waiter pid to shut its command down cleanly.

POSIX: SIGINT, which the waiter forwards to the command. Windows: no console control event can reach it. The waiter runs on an invisible console of its own and the process asking may have no console at all — plesty-server-gui.exe is a GUI binary — and GenerateConsoleCtrlEvent only reaches the caller’s own console. It would fail silently and the bench would fall through to killing every device outright, so the waiter watches for stop_file instead (watch_for_stop()) and creating it is the request.

Parameters:
  • pid (int) – The waiter that owns the command.

  • stop_file (pathlib.Path) – The file that waiter watches.

Return type:

None

plesty.server.host.watch_for_stop(stop_file: pathlib.Path, on_stop: collections.abc.Callable[[], None]) → None

Call on_stop when stop_file appears — the Windows half of a clean stop.

POSIX: returns at once. A stop arrives as a signal there, and the handler forward_signals() installed does the same work. Windows: starts a daemon thread polling for the file, so the waiter can be asked to stop by a process that shares no console with it.

Parameters:
  • stop_file (pathlib.Path) – The file whose appearance means “stop”.

  • on_stop (collections.abc.Callable[[], None]) – Run once, on the watching thread, when it appears.

Return type:

None

plesty.server.host.split_command(line: str, platform: str = sys.platform) → list[str]

Split a typed command line into an argument list.

A double quote groups and is removed on both platforms; what differs is the backslash and the apostrophe. On POSIX the backslash escapes. On Windows it separates a path, so escaping is turned off there — plesty check C:\dev\pm100d has to arrive as that path and not as C:\devpm100d, which is what POSIX rules make of it.

The apostrophe is not a quote on Windows, deliberately: CommandLineToArgvW gives it no meaning, and an apostrophe is legal in a path and common in a user name. While it grouped, plesty check --note don't failed with a message naming a quote the operator had not typed. It stays a quote on POSIX, where a shell would treat it as one. Do not “restore” it here.

A space still separates on Windows whatever the token contains, so a path with a space in it needs double quotes there exactly as it does anywhere else — C:\Program Files\x has always split, and C:\Users\John's Data\x splits the same way rather than being a special case.

shlex.split(posix=False) is not the Windows answer: it keeps the quote characters in the token, so "two words" would reach the process with its quotes attached. The lexer is built by hand instead — POSIX quoting, no escape character, no apostrophe.

No shell is involved either way: the result is passed to the process directly, so there is no pipe, no redirection and no glob.

Parameters:
  • line (str) – What the operator typed.

  • platform (str) – Overridden by tests to reach the other branch.

Raises:

ValueError – When a quote is left open.

Return type:

list[str]

plesty.server.host.KILLED_ON_STOP
plesty.server.host.exit_note(returncode: int | None) → str

What returncode says that the server’s own log cannot, or "".

A server killed by the stop event never reached its shutdown, so it wrote nothing about ending: the log stops mid-run and reads like a crash with no message. The exit code is the only witness left, and it is exact — this is a stop the server did not handle, not a fault in the device.

Parameters:

returncode (int | None)

Return type:

str

plesty.server.host.stop_signal() → int

The signal a clean stop arrives as, for a process that must catch one.

POSIX: SIGINT. Windows: SIGBREAK. CTRL_BREAK_EVENT is the only console control event that can be aimed at a single process group — CTRL_C_EVENT is disabled outright for a group made with CREATE_NEW_PROCESS_GROUP — so it is what deliver_stop() sends, and Python delivers it as SIGBREAK. A server that only handles SIGINT is killed outright here (exit 0xC000013A) instead of closing its device.

Return type:

int

plesty.server.host.deliver_stop(child: subprocess.Popen[bytes]) → None

Ask child to end cleanly, from the process that started it.

POSIX: SIGINT. Windows: CTRL_BREAK_EVENT to the group the child leads — it shares this process’s console, which is what interruptible_kwargs() buys. A child that has already gone is not an error.

Parameters:

child (subprocess.Popen[bytes])

Return type:

None

plesty.server.host.detach(run: collections.abc.Callable[[], int], report: Report) → int

Run run in a process nobody has to reap, and tell report its pid.

POSIX: fork; the child starts a new session, points its standard streams at /dev/null, runs and _exits. The parent reports the child’s pid and returns 0 — it exits at once, so the child is reparented to init and no zombie is left for whoever started us. Windows: there is no fork; this process is the detached one. It reports its own pid, then runs — so a caller must never wait for it to finish (see reap_launcher()).

Parameters:
  • run (collections.abc.Callable[[], int]) – The work of the detached process; its return value is its exit code.

  • report (Report) – Receives the pid the caller should track.

Returns:

The exit code of the calling process.

Return type:

int

plesty.server.host.reap_launcher(proc: subprocess.Popen[bytes], timeout: float = 10) → None

Wait for a launcher process once it has reported the detached pid.

POSIX: the launcher is the intermediate parent of detach(); it has exited already, and waiting collects it so it is not left a zombie. Windows: the launcher is the long-lived process — waiting would block until the server or job it runs has ended, so nothing is done.

Parameters:
  • proc (subprocess.Popen[bytes])

  • timeout (float)

Return type:

None

plesty.server.host.forward_signals(handler: collections.abc.Callable[[int, Any], None]) → None

Route the signals that mean “stop” to handler in the current process.

POSIX: SIGINT, SIGTERM and SIGHUP (the terminal closed). Windows: SIGINT/SIGTERM (as Python maps them) plus SIGBREAK, which is ignored rather than forwarded — a console child in the same group receives CTRL_BREAK_EVENT on its own.

Installed before any child starts: a caught signal resets to its default across exec, whereas an ignored one is inherited.

Parameters:

handler (collections.abc.Callable[[int, Any], None])

Return type:

None

plesty.server.host.pid_alive(pid: int) → bool

Whether a process with pid exists (no psutil).

POSIX: kill(pid, 0) — a permission error still means it exists. Windows: OpenProcess + GetExitCodeProcess = STILL_ACTIVE. Servers and jobs are never children of the asking process (see detach()), so there are no zombies to mistake for live processes.

Parameters:

pid (int)

Return type:

bool

plesty.server.host._pid_alive_windows(pid: int) → bool
Parameters:

pid (int)

Return type:

bool

plesty.server.host.terminate(pid: int) → None

Tell pid to end (SIGTERM). Windows has no equivalent; kill_tree() is next.

Parameters:

pid (int)

Return type:

None

plesty.server.host.kill_tree(pid: int) → None

End pid and everything it started, without asking.

POSIX: SIGKILL to the process group (the detached process leads one). Windows: taskkill /T /F.

Parameters:

pid (int)

Return type:

None

plesty.server.host._signal(pid: int, sig: int, group: bool = False) → None

Send sig to pid (or its process group), ignoring a vanished target.

Parameters:
  • pid (int)

  • sig (int)

  • group (bool)

Return type:

None