plesty.server.services ====================== .. py:module:: plesty.server.services .. autoapi-nested-parse:: Services: the model's side effects on the host. Everything that touches the operating system — ``uv`` and ``git`` for installing, subprocesses for running servers and jobs — lives here. Services take model objects in and hand model objects back; presenters drive them and never call the tools directly, so a service is the one place a tool's quirks are handled and the one seam tests replace. Submodules ---------- .. toctree:: :maxdepth: 1 /reference/plesty/server/services/agent/index /reference/plesty/server/services/catalogue/index /reference/plesty/server/services/gitbin/index /reference/plesty/server/services/installer/index /reference/plesty/server/services/jobs/index /reference/plesty/server/services/launch/index /reference/plesty/server/services/supervisor/index Attributes ---------- .. autoapisummary:: plesty.server.services.CHECKOUT_VENV plesty.server.services.PYTHON_SPEC plesty.server.services.Echo plesty.server.services.Runner plesty.server.services.PROBE_HOST Exceptions ---------- .. autoapisummary:: plesty.server.services.GitUnavailable plesty.server.services.InstallError plesty.server.services.CatalogueError plesty.server.services.AlreadyRunning plesty.server.services.NotRunning plesty.server.services.PortBusy plesty.server.services.SupervisorError Classes ------- .. autoapisummary:: plesty.server.services.Installer plesty.server.services.Agent plesty.server.services.Catalogue plesty.server.services.JobRunner plesty.server.services.Supervisor Functions --------- .. autoapisummary:: plesty.server.services.bench_git plesty.server.services.find_git plesty.server.services.install_git plesty.server.services.default_runner plesty.server.services.serve_in_thread plesty.server.services.http_fetch plesty.server.services.pypi_release plesty.server.services.probe plesty.server.services.request Package Contents ---------------- .. py:exception:: GitUnavailable Bases: :py:obj:`RuntimeError` No ``git`` on the bench, and none could be installed. Initialize self. See help(type(self)) for accurate signature. .. py:function:: bench_git(home: plesty.server.model.home.Home) -> pathlib.Path Where the bench keeps its own git (whether or not one is there yet). .. py:function:: find_git(home: plesty.server.model.home.Home) -> str | None A usable ``git``: ``PATH`` first, then the usual installs, then the bench's own. :param home: The bench home, which may hold a git of its own. :returns: The executable, or ``None`` when the bench has none. .. py:function:: install_git(home: plesty.server.model.home.Home, echo: Echo | None = None, fetch: Fetcher | None = None) -> str Unpack a portable git into ``/tools/git``; returns the executable. Windows only: it is the platform where a bench routinely has no git and no way to install one. The archive is verified against the pinned digest before anything is unpacked — a mismatch is an error, not a warning. :param home: The bench home to install into. :param echo: Where progress lines go. :param fetch: Reads the archive URL; injected by tests. :raises GitUnavailable: Not Windows, unknown architecture, download failed, digest mismatch, or the archive held no ``git``. .. py:data:: CHECKOUT_VENV :value: '.venv' .. py:data:: PYTHON_SPEC :value: '>=3.12' .. py:data:: Echo .. py:class:: Installer(home: plesty.server.model.home.Home, manager: plesty.server.model.device_manager.DeviceManager | None = None, uv: str | None = None, git: str | None = None, runner: Runner | None = None, echo: Echo | None = None) Install, update and remove device environments; records them in the manager. Bind the installer to a home. :param home: The bench home. :param manager: The inventory to record into; a fresh one on *home* by default. :param uv: Path of the ``uv`` executable; looked up on ``PATH`` by default. :param git: Path of the ``git`` executable; found (or installed) by default. :param runner: Executes a command line — injected by tests. :param echo: Where each line ``uv`` and ``git`` write goes while they run. Defaults to stdout, which is the job log when run as a job. .. py:attribute:: home .. py:attribute:: manager .. py:attribute:: _uv :value: None .. py:attribute:: _git :value: None .. py:attribute:: _run .. py:attribute:: _echo :type: Echo .. py:property:: uv :type: str The ``uv`` executable; raises :class:`InstallError` when the bench has none. .. py:method:: _find_uv() -> str ``PATH`` first, then where uv installs itself. :raises InstallError: When there is no uv to be found. .. py:property:: git :type: str The ``git`` executable, installing one for the bench when it has none. :raises InstallError: When the bench has no git and cannot be given one. .. py:method:: _on_path(git: str) -> str Put *git*'s directory on ``PATH`` for this process and its children. Calling git by absolute path is enough for the installer's own clone and checkout — and not enough for the install. A module's dependencies may be ``git+https`` URLs, and the ``uv`` this spawns to resolve them looks for git on ``PATH`` and nowhere else: on the very bench this feature exists for, one with no git at all, the install would fetch a portable git, clone with it, and then fail in ``uv sync`` with "Git executable not found". The same holds for a git found under ``%ProgramFiles%`` but absent from ``PATH``. .. py:method:: _find_or_install_git() -> str Locate git, or fetch a portable one (Windows); explains itself either way. .. py:method:: python(installed: plesty.server.model.device_manager.InstalledVersion) -> pathlib.Path The interpreter that runs *installed*. .. py:method:: install(spec: plesty.server.model.device.DeviceSpec, force: bool = False, job_id: str | None = None) -> plesty.server.model.device_manager.InstalledVersion Make sure an environment serving *spec* exists; returns its record. :param spec: What to install: package plus a pinned version (PyPI) or a repository and ref (git checkout). :param force: Reinstall even when a satisfying environment is on record. :param job_id: The job this runs as, stored on the record. :raises InstallError: When ``uv``/``git`` fail or the package did not end up installed. .. py:method:: _install_the_tag_instead(spec: plesty.server.model.device.DeviceSpec, job_id: str | None, failure: InstallError) -> plesty.server.model.device_manager.InstalledVersion After a failed release install, install the same version from the repository. A module is on the hub before it is on PyPI: the registry knows the release from its tag, and the package reaches the index only once the release job has run — so "that release is not on the index" is a routine state of a young module, not a broken bench. The repository holds the same version under ``v``, which is the ref a spec with a version already resolves to. :raises InstallError: When no repository is known for the package, or the repository has no such tag either — carrying both reasons. .. py:method:: update(installed: plesty.server.model.device_manager.InstalledVersion, ref: str | None = None, job_id: str | None = None) -> plesty.server.model.device_manager.InstalledVersion Bring *installed* up to date; returns the (possibly new) record. Git checkout: fetch, check out *ref* (default: the recorded ref), fast-forward, ``uv sync``. PyPI: reinstall the same version. :param installed: The environment to refresh. :param ref: Branch, tag or commit to move a checkout to. :param job_id: The job this runs as. .. py:method:: uninstall(installed: plesty.server.model.device_manager.InstalledVersion) -> bool Delete the environment of *installed* and forget it. :returns: ``True`` when a directory was removed. .. py:method:: _install_release(spec: plesty.server.model.device.DeviceSpec, job_id: str | None) -> plesty.server.model.device_manager.InstalledVersion .. py:method:: _install_checkout(spec: plesty.server.model.device.DeviceSpec, job_id: str | None) -> plesty.server.model.device_manager.InstalledVersion .. py:method:: _uv_call(args: list[str], what: str, quiet: bool = False) -> subprocess.CompletedProcess[str] .. py:method:: _uv_resolve(args: list[str], what: str) -> subprocess.CompletedProcess[str] Run a resolving ``uv`` command, retrying once with pre-releases allowed. A hub module under development depends on a ``.dev`` release of plesty-lib, and uv considers only stable versions unless it is told otherwise — it fails with a hint naming the pre-release. Allowing them from the start would pull a pre-release into every install that has a stable answer, so it is the retry, not the rule. .. py:method:: _git_call(args: list[str], what: str, quiet: bool = False) -> subprocess.CompletedProcess[str] .. py:method:: _call(cmd: list[str], what: str, quiet: bool = False) -> subprocess.CompletedProcess[str] Run *cmd*; *quiet* keeps a query's output out of the log (it is read, not watched). .. py:method:: _installed_version(python: pathlib.Path, package: str) -> str | None .. py:method:: _python_version(python: pathlib.Path) -> str .. py:exception:: InstallError Bases: :py:obj:`RuntimeError` ``uv``/``git`` failed, or the environment is not in the shape expected. Initialize self. See help(type(self)) for accurate signature. .. py:data:: Runner .. py:function:: default_runner(cmd: list[str], echo: Echo | None = None) -> subprocess.CompletedProcess[str] Run *cmd*, streaming its merged output to *echo* as it arrives. stderr is folded into stdout so the order the tool wrote them in survives; the text is returned as well, so a failure still carries its own detail. .. py:class:: Agent(home: plesty.server.model.home.Home, fleet_path: str | os.PathLike[str] | None = None, bind: str = DEFAULT_BIND, token: str | None = None, workers: int = 4) Serve ``plesty.bench`` clients for one bench. Configure the agent. :param home: The bench home every command runs against. :param fleet_path: Fleet file passed to every command (``-f``), or ``None``. :param bind: ZMQ endpoint to bind. :param token: Shared secret; ``None`` reads ``PLESTY_BENCH_TOKEN``; empty means no check (a bench on a trusted network). :param workers: Concurrent requests. .. py:attribute:: home .. py:attribute:: fleet_path .. py:attribute:: bind :value: 'tcp://*:5550' .. py:attribute:: token .. py:attribute:: workers :value: 4 .. py:attribute:: bound :type: str | None :value: None .. py:attribute:: served :value: 0 .. py:method:: handle(message: Any) -> dict[str, Any] Answer one decoded request (pure; no socket involved). .. py:method:: description() -> dict[str, Any] What ``describe`` returns. .. py:method:: exec(argv: collections.abc.Sequence[Any]) -> dict[str, Any] Run ``plesty-server `` in-process against this bench; capture everything. .. py:method:: fetch(path: str) -> dict[str, Any] Read a file under the bench home; *path* is relative to it. .. py:method:: serve(stop: threading.Event, poll_ms: int = 100, max_replies: int | None = None) -> int Bind and answer requests until *stop* is set; returns the number answered. :param stop: Set to end the loop. :param poll_ms: Socket poll interval. :param max_replies: Stop once this many replies went out (tests). .. py:function:: serve_in_thread(agent: Agent, stop: threading.Event | None = None) -> tuple[threading.Thread, threading.Event] Run *agent* on a daemon thread; returns the thread and its stop event. .. py:class:: Catalogue(index_url: str | None = None, fetch: Fetcher | None = None) The hub catalogue at one index URL. Bind to an index. :param index_url: ``/api/modules/`` endpoint; the public hub by default. :param fetch: Reads a URL; injected by tests. .. py:attribute:: index_url :value: 'https://hub.plesty.net/api/modules/' .. py:attribute:: _fetch .. py:attribute:: _cache :type: list[plesty.server.model.catalogue.CatalogueEntry] | None :value: None .. py:method:: entries(refresh: bool = False) -> list[plesty.server.model.catalogue.CatalogueEntry] Every module the index lists (fetched once, then cached). :raises CatalogueError: When the index cannot be fetched or is not JSON. .. py:method:: search(query: str = '', vendor: str = '', category: str = 'devices', standard: str = REQUIRED_STANDARD) -> list[plesty.server.model.catalogue.CatalogueEntry] Entries matching *query*, filtered by vendor, category and standard (``""`` = any). .. py:method:: vendors(category: str = 'devices') -> list[str] The vendor groups present in *category*. .. py:method:: lookup(package: str) -> plesty.server.model.catalogue.CatalogueEntry | None The entry named *package* (any category, any standard), or ``None``. .. py:exception:: CatalogueError Bases: :py:obj:`RuntimeError` The catalogue could not be fetched or read. Initialize self. See help(type(self)) for accurate signature. .. py:function:: http_fetch(url: str, timeout: float = 15.0) -> bytes GET *url* with the standard library (the bench may have no ``requests``). .. py:function:: pypi_release(package: str, fetch: Fetcher | None = None) -> str | None The newest release of *package* on PyPI, or ``None`` when it has none there. :param package: Distribution name. :param fetch: Reads a URL; injected by tests. .. py:class:: JobRunner(home: plesty.server.model.home.Home, store: plesty.server.model.job.JobStore | None = None) Start, observe and cancel jobs under a :class:`Home`. Bind to a home. :param home: The bench home. :param store: The record store; a fresh one on *home* by default. .. py:attribute:: home .. py:attribute:: store .. py:method:: exit_file(job_id: str) -> pathlib.Path Where the launcher records the exit code of *job_id*. .. py:method:: start(name: str, kind: str, command: list[str], cwd: str | os.PathLike[str], env: collections.abc.Mapping[str, str] | None = None, detail: collections.abc.Mapping[str, Any] | None = None) -> plesty.server.model.job.Job Launch *command* as a job and return its record. :param name: Device instance the job belongs to. :param kind: Job kind, part of the id. :param command: The command line. :param cwd: Working directory. :param env: Environment on top of the bench's own. :param detail: Kind-specific data stored on the record. :raises JobError: When the command cannot be launched. .. py:method:: get(job_id: str) -> plesty.server.model.job.Job | None The job *job_id* with its state brought up to date, or ``None``. .. py:method:: require(job_id: str) -> plesty.server.model.job.Job Like :meth:`get` but raises :class:`JobError` when unknown. .. py:method:: list(name: str | None = None, kind: str | None = None) -> list[plesty.server.model.job.Job] Jobs on record, newest first, brought up to date. .. py:method:: wait(job_id: str, timeout: float | None = None, poll: float = 0.25) -> plesty.server.model.job.Job Block until the job finishes (or *timeout* seconds pass); returns it. .. py:method:: cancel(job_id: str) -> bool Kill a running job (its whole process tree); ``True`` when it was running. .. py:data:: PROBE_HOST :value: '127.0.0.1' .. py:exception:: AlreadyRunning Bases: :py:obj:`SupervisorError` ``start`` was asked for a device whose server is already up. Initialize self. See help(type(self)) for accurate signature. .. py:exception:: NotRunning Bases: :py:obj:`SupervisorError` ``stop`` was asked for a device that has no live server. Initialize self. See help(type(self)) for accurate signature. .. py:exception:: PortBusy Bases: :py:obj:`SupervisorError` ``start`` found the device's port already answering — another server holds it. Initialize self. See help(type(self)) for accurate signature. .. py:class:: Supervisor(home: plesty.server.model.home.Home, ready_timeout: float | None = None, stop_timeout: float | None = None, probe_timeout_ms: int | None = None) Launch and control device servers under a :class:`Home`. Bind the supervisor to a home. :param home: The bench home. :param ready_timeout: Seconds :meth:`start` waits for the first probe answer. :param stop_timeout: Seconds :meth:`stop` waits before escalating. :param probe_timeout_ms: Milliseconds one probe waits for its reply. :param (Defaults come from ``config.yaml`` ``running``.): .. py:attribute:: home .. py:attribute:: ready_timeout .. py:attribute:: stop_timeout .. py:attribute:: probe_timeout_ms .. py:method:: record(name: str) -> plesty.server.model.process.ProcessRecord | None The persisted launch record of *name*, or ``None``. .. py:method:: exit_file(name: str) -> pathlib.Path Where the launcher records the exit code of the server of *name*. .. py:method:: known() -> list[str] Names that have a launch record (alive or not). .. py:method:: _drop_record(name: str) -> None .. py:method:: alive(name: str) -> bool Whether the recorded server of *name* still runs. .. py:method:: poll(name: str) -> int | None Exit code of the server of *name*, ``None`` while it runs. :data:`~plesty.server.model.job.LOST_EXIT` when the process is gone without a recorded exit (the bench rebooted, or someone killed the launcher) — and when nothing is known at all. .. py:method:: status(name: str, probe_port: bool = True) -> plesty.server.model.process.ProcessStatus Observe *name*: record, process existence and (optionally) a probe. :param name: Device instance name. :param probe_port: Whether to send a ``describe`` to tell ``running`` from ``starting``; skipping it is cheaper for bulk listings. .. py:method:: wait_ready(name: str, timeout: float | None = None) -> bool Poll until the server of *name* answers a probe. :returns: ``True`` once it answers; ``False`` on timeout or when the process exits first. .. py:method:: start(spec: plesty.server.model.device.DeviceSpec, python: pathlib.Path, wait: bool = True, timeout: float | None = None, package: str = '', version: str = '') -> plesty.server.model.process.ProcessStatus Launch the server of *spec* with the interpreter *python*. :param spec: Module, arguments, environment and port to run with. :param python: Interpreter of the environment the module is installed in. :param wait: Block until the server answers a probe (or fails). :param timeout: Readiness wait in seconds; defaults to :attr:`ready_timeout`. :param package: Distribution name, stored on the record for listings. :param version: Installed version, likewise. :returns: The status after launch — ``running`` when it came up, ``starting`` when *wait* is off or it has not answered yet, ``exited`` when it died before answering (``detail`` carries the log tail). :raises AlreadyRunning: When a live server of that name exists. :raises SupervisorError: When the interpreter cannot be executed. .. py:method:: _start_locked(spec: plesty.server.model.device.DeviceSpec, python: pathlib.Path, wait: bool, timeout: float | None, package: str, version: str) -> plesty.server.model.process.ProcessStatus .. py:method:: stop(name: str, timeout: float | None = None) -> bool Interrupt the server of *name*, escalating to a kill if it lingers. Interrupt → wait *timeout* → terminate → wait 3 s → kill tree. :returns: ``True`` when a process was stopped, ``False`` when only a stale record was cleared. :raises NotRunning: When nothing (not even a stale record) is known. .. py:method:: restart(spec: plesty.server.model.device.DeviceSpec, python: pathlib.Path, wait: bool = True, timeout: float | None = None) -> plesty.server.model.process.ProcessStatus Stop the server of *spec* if it runs, then :meth:`start` it again. .. py:method:: relaunch(record: plesty.server.model.process.ProcessRecord, wait: bool = True) -> plesty.server.model.process.ProcessStatus Start the server again exactly as *record* says it was started. Used after a host-tier field test: the interpreter, arguments and environment of the interrupted launch are what the bench expects back. .. py:method:: stop_all(names: list[str] | None = None) -> list[str] Stop every known (or the given) server; returns the names stopped. .. py:method:: tail(name: str, lines: int = 50) -> str The last *lines* lines of the log of *name* (``""`` when no log). .. py:method:: _wait_exit(name: str, timeout: float) -> bool .. py:exception:: SupervisorError Bases: :py:obj:`RuntimeError` A start or stop request could not be carried out. Initialize self. See help(type(self)) for accurate signature. .. py:function:: probe(port: int, timeout_ms: int = 2000, host_addr: str = PROBE_HOST) -> bool Ask the server on *port* to describe itself. :returns: ``True`` when a plesty-lib server answered ``status: ok`` in time. .. py:function:: request(port: int, message: dict[str, Any], timeout_ms: int = 5000, host_addr: str = PROBE_HOST) -> dict[str, Any] Send one request to the server on *port* and return its reply. The same DEALER socket :func:`probe` uses, with the message left to the caller: this is how a developer asks a running device server anything its plesty-lib command solver understands, without a client library. :raises SupervisorError: When nobody answers in time, or the answer is not JSON the wire format allows.