plesty.server.presenter.bench ============================= .. py:module:: plesty.server.presenter.bench .. autoapi-nested-parse:: The local bench: one object that composes the model and the services. :class:`Bench` is what the CLI and the GUI talk to. It owns a :class:`Home`, a :class:`Fleet`, and the four services, and answers the operator's questions — *what is declared, what is installed, what is running* — and carries out the operator's intents — *install, start, stop, field-test, bring the fleet up*. Anything that takes longer than a request/reply (an install, a field test) it starts as a job by re-invoking ``plesty-server _job …`` through the detached launcher (:meth:`Bench.install`, :meth:`Bench.field_test`); the synchronous twins (:meth:`install_now`, :meth:`field_test_now`) are what those jobs run. Attributes ---------- .. autoapisummary:: plesty.server.presenter.bench.Say Exceptions ---------- .. autoapisummary:: plesty.server.presenter.bench.BenchError Classes ------- .. autoapisummary:: plesty.server.presenter.bench.DeviceView plesty.server.presenter.bench.SourceOptions plesty.server.presenter.bench.Bench Functions --------- .. autoapisummary:: plesty.server.presenter.bench._example_blocks plesty.server.presenter.bench.release_tag plesty.server.presenter.bench.module_spec plesty.server.presenter.bench._stderr plesty.server.presenter.bench._now Module Contents --------------- .. py:data:: Say .. py:exception:: BenchError Bases: :py:obj:`RuntimeError` An operation could not be carried out as asked. Initialize self. See help(type(self)) for accurate signature. .. py:class:: DeviceView What the operator sees about one device instance. :ivar spec: The fleet declaration. :ivar installed: The installed version that serves it, or ``None``. :ivar status: The server's observed state. :ivar field_test_blocked: Why a field test cannot run, or ``None`` when it can. :ivar info: The package-level record (repository, project page, latest release). .. py:attribute:: spec :type: plesty.server.model.DeviceSpec .. py:attribute:: installed :type: plesty.server.model.InstalledVersion | None .. py:attribute:: status :type: plesty.server.model.ProcessStatus .. py:attribute:: field_test_blocked :type: str | None :value: None .. py:attribute:: info :type: plesty.server.model.PackageInfo | None :value: None .. py:property:: name :type: str The instance name. .. py:property:: source :type: str the requirement plus the commit for a checkout. :type: One line .. py:class:: SourceOptions What a declaration form can offer for a package's source. :ivar versions: Release versions known for the package (installed on this bench, then the catalogue's), newest first. :ivar refs: Git refs known for it (installed checkouts, the catalogue's latest tag, the usual branches). :ivar git_urls: Repository URLs known for it. .. py:attribute:: versions :type: tuple[str, Ellipsis] :value: () .. py:attribute:: refs :type: tuple[str, Ellipsis] :value: () .. py:attribute:: git_urls :type: tuple[str, Ellipsis] :value: () .. py:class:: Bench(home: plesty.server.model.Home, fleet: plesty.server.model.Fleet | None = None, manager: plesty.server.model.DeviceManager | None = None, installer: plesty.server.services.Installer | None = None, jobs: plesty.server.services.JobRunner | None = None, supervisor: plesty.server.services.Supervisor | None = None, say: Say | None = None) The local bench. Compose a bench. :param home: The bench home. :param fleet: The fleet file, or ``None`` for a bench with no declarations (instances are then declared through :meth:`declare`). :param manager: Installed-version inventory; default on *home*. :param installer: Environment creator; default on *home* and *manager*. :param jobs: Job runner; default on *home*. :param supervisor: Server supervisor; default on *home*. :param say: Where progress lines go; default: stderr. .. py:attribute:: home .. py:attribute:: fleet .. py:attribute:: manager .. py:attribute:: installer .. py:attribute:: jobs .. py:attribute:: supervisor .. py:attribute:: _say :type: Say .. py:method:: open(home: str | os.PathLike[str] | None = None, fleet_path: str | os.PathLike[str] | None = None, say: Say | None = None) -> Bench :classmethod: Build a bench from the environment: the home, and the fleet file it finds. :param home: Explicit home directory (``--home``); else the default. :param fleet_path: Explicit fleet file (``-f``); else ``./fleet.yaml``, then ``/fleet.yaml``, then none. :param say: Progress sink. .. py:method:: say(line: str) -> None Emit a progress line. .. py:method:: say_to(say: Say) -> None Send progress lines to *say* from now on. The console does this at start-up. Without it these lines go to stderr, which a windowed process on Windows discards outright — so "port 5551 is in use — moving to 5553" was written to nowhere while the fleet file was quietly edited. .. py:method:: spec(name: str) -> plesty.server.model.DeviceSpec The declaration of *name*. :raises BenchError: When the fleet does not declare it. .. py:method:: declare(spec: plesty.server.model.DeviceSpec, save: bool = True) -> plesty.server.model.DeviceSpec Add or replace *spec* in the fleet, allocating a port when it names none. :param spec: The declaration. :param save: Write the fleet file back (creating one when there is none). .. py:method:: forget(name: str, save: bool = True) -> None Remove *name* from the fleet (no error when absent). .. py:method:: _save_fleet() -> pathlib.Path Write the fleet back, giving it ``/fleet.yaml`` when it has no file yet. A declaration that is not on disk is invisible to the jobs the console starts — they are separate processes that read the fleet file. So a bench that declares a device gets a fleet file, whether or not anyone asked for one. .. py:method:: remove(name: str) -> bool Drop *name* from the fleet, stopping its server first; ``True`` when one was stopped. The environment stays installed (another instance may use it); the ``.env`` stays too, in case the device is declared again. .. py:method:: set_autostart(name: str, flag: bool) -> plesty.server.model.DeviceSpec Include *name* in (or drop it from) the working fleet; persists to the fleet file. .. py:property:: working :type: list[plesty.server.model.DeviceSpec] the declared devices marked ``autostart``. :type: The working fleet .. py:method:: save_fleet_as(path: str | os.PathLike[str], names: collections.abc.Sequence[str] | None = None) -> pathlib.Path Write the working fleet (or *names*) to *path* as a new fleet file; returns it. The file is portable: it carries the declarations, not this bench's ``home``. Every device written is marked ``autostart`` — it *is* the fleet of that file. .. py:method:: create_fleet(path: str | os.PathLike[str]) -> plesty.server.model.Fleet Write an empty fleet file at *path* and make it the current one. :raises BenchError: When *path* already exists. .. py:method:: load_fleet(path: str | os.PathLike[str]) -> plesty.server.model.Fleet Replace the declarations with the fleet file at *path* (the home stays). .. py:method:: remember_fleet() -> None Note the current fleet file as the one to reopen next time (console start-up). .. py:method:: fleet_text() -> str The current fleet as YAML text (what the fleet-file editor shows). .. py:method:: replace_fleet_text(text: str) -> plesty.server.model.Fleet Validate *text* as a fleet file, write it to the fleet path and adopt it. :raises FleetError: When the text is not a valid fleet document. .. py:method:: env_file(name: str) -> pathlib.Path The device's ``.env`` file: ``/run//.env``. .. py:method:: read_env_text(name: str) -> str The ``.env`` text of *name* (empty when there is none). .. py:method:: write_env_text(name: str, text: str) -> pathlib.Path Write the ``.env`` of *name*; returns the path. .. py:method:: process_env(spec: plesty.server.model.DeviceSpec) -> dict[str, str] The environment a server or field test of *spec* runs with. The bench's own environment, then the device's ``.env`` file, then the fleet's ``env:`` (explicit in the declaration, so it wins). .. py:method:: catalogue(index_url: str | None = None) -> plesty.server.services.Catalogue The hub catalogue (the fleet's ``index_url`` when it names one). .. py:method:: install_entry(entry: plesty.server.model.CatalogueEntry, version: str | None = None, ref: str | None = None, repository: bool = False) -> plesty.server.model.Job Install a catalogue module onto this bench as a job; nothing is declared. The catalogue is a market place: it puts modules on the bench, and the fleet then declares device instances of what is on it. Which is why this touches no fleet file and needs none. :param entry: The catalogue module. :param version: Release to install; ``None`` takes the newest release. :param ref: Ref for a repository install; ``None`` takes the entry's latest tag, and no tag means the default branch. :param repository: Install the repository instead of a release. :raises BenchError: When the entry is not an installable device. .. py:method:: module_release(package: str) -> str | None The newest release of *package* on PyPI, or ``None`` — one network call. What the console asks before it offers a source: a release install needs nothing but ``uv``, a repository install needs git. .. py:method:: source_options(package: str, fetch: bool = False) -> SourceOptions Versions, refs and repository URLs the bench knows for *package*. Reads the inventory and the fleet; the catalogue only when it has already been fetched — unless *fetch* is set, which asks the hub (a network call: callers on a GUI thread do that on a worker first, so later calls find the cache). .. py:method:: seed_env(name: str, installed: plesty.server.model.InstalledVersion | None = None) -> pathlib.Path | None Bring the device's ``.env`` in line with the module's ``.env.example``. On the first install the example is copied **verbatim** — comments and all — as the ``.env`` (an empty or missing file counts as "first"). On a later install of another version or ref, keys the new example has and the ``.env`` lacks are appended together with the example's own comment lines above them; the operator's values are never touched. A checkout's own ``.env.example`` is used; a PyPI install (no repository on disk) gets the generic example shipped with plesty-server. :returns: The written path, or ``None`` when nothing had to change. .. py:method:: env_example(name: str, installed: plesty.server.model.InstalledVersion | None = None) -> str The ``.env.example`` text that applies to device *name*. A checkout's own file first; for a release install (no repository on disk) the file is fetched from the module's repository as the catalogue names it — the wheel does not carry it; and the generic example shipped with plesty-server when neither is reachable. .. py:method:: _remote_env_example(package: str) -> str | None ``.env.example`` from the module's repository (GitLab raw URL), or ``None``. The repository comes from the package record; the catalogue is asked only when the bench has never learned it. .. py:method:: _startable_port(spec: plesty.server.model.DeviceSpec) -> plesty.server.model.DeviceSpec *spec* with a port nothing else is holding; declares the choice. A port the **bench** allocated is not a promise it can keep against another process on the machine — a second bench allocates from the same range, and a server left over from an earlier start holds what it holds. When it is taken the device cannot serve a client there whatever we do, so it moves and the fleet file records where it went. A port the **declaration names** — ``--tcp-port`` in ``args``, or an env value the device itself reads — is a different thing: that number is passed to the device, an operator chose it, and moving it silently would start the server somewhere its own arguments deny. That clash is reported instead. .. py:method:: _with_port(spec: plesty.server.model.DeviceSpec) -> plesty.server.model.DeviceSpec Give *spec* a port of its own unless it asked for one. .. py:method:: installed(name: str) -> plesty.server.model.InstalledVersion | None The installed version serving *name*, or ``None``. .. py:method:: require_installed(name: str) -> plesty.server.model.InstalledVersion Like :meth:`installed` but raises :class:`BenchError` when absent. .. py:method:: status(name: str, probe: bool = True) -> plesty.server.model.ProcessStatus The server state of *name*. .. py:method:: device(name: str, probe: bool = True) -> DeviceView Declaration, installation and state of *name* in one view. .. py:method:: devices(probe: bool = True) -> list[DeviceView] Every declared device, in fleet order. .. py:method:: log(name: str, lines: int = 50) -> str The tail of the server log of *name*. .. py:method:: reports(name: str) -> list[plesty.server.model.FieldTestReport] Published field-test reports of *name* (git installs only). .. py:method:: install_now(name: str, force: bool = False, job_id: str | None = None) -> plesty.server.model.InstalledVersion Install what *name* declares, in the foreground; returns the record. .. py:method:: update_now(name: str, ref: str | None = None, job_id: str | None = None) -> plesty.server.model.InstalledVersion Update the environment of *name* in the foreground. A git checkout moves to *ref* (default: its recorded ref) and re-syncs; a PyPI install is reinstalled. The declaration follows a new *ref*. .. py:method:: install(name: str, force: bool = False) -> plesty.server.model.Job Install what the device *name* declares, as a job; returns it at once. .. py:method:: install_module(package: str, version: str | None = None, git: str | None = None, ref: str | None = None, force: bool = False) -> plesty.server.model.Job Install a *module* onto the bench as a job — no device, no fleet. The environment is keyed by the package, never by a device name, so installing one is a market-place act: it stocks the bench, and a declaration later picks from what is in stock. :param package: Distribution name (``plesty-pm100d``). :param version: Release to install; ``None`` takes the newest. :param git: Repository to install from instead of a release. :param ref: Branch, tag or commit for *git*. :param force: Reinstall even when it is already on the bench. .. py:method:: install_module_now(package: str, version: str | None = None, git: str | None = None, ref: str | None = None, force: bool = False, job_id: str | None = None) -> plesty.server.model.InstalledVersion Install a module in the foreground; returns the record. .. py:method:: learn_repository(package: str) -> str Make sure the bench knows where *package* lives; returns the URL or ``""``. The release→tag fallback can only reach a repository the package record names, and a bench that has never seen the module names none — which is exactly the first install of a young module, the case the fallback exists for. The hub is what knows: one lookup, best effort, remembered so it is not asked twice. A bench that cannot reach it simply keeps the release failure it would have had anyway. .. py:method:: update(name: str, ref: str | None = None) -> plesty.server.model.Job Update *name* as a job; returns it at once. .. py:method:: uninstall(name: str) -> bool Stop *name* if it runs and remove its environment; ``True`` when one was removed. .. py:method:: start(name: str, wait: bool = True, timeout: float | None = None) -> plesty.server.model.ProcessStatus Start the server of *name* (installing first when nothing serves it). :raises BenchError: When it is not installed and cannot be, or the launch fails. .. py:method:: stop(name: str, timeout: float | None = None) -> bool Stop the server of *name*; ``True`` when a process was stopped. .. py:method:: restart(name: str, wait: bool = True) -> plesty.server.model.ProcessStatus Stop (if running) and start the server of *name*. .. py:method:: up(names: collections.abc.Sequence[str] | None = None, install: bool = True, wait: bool = True) -> list[plesty.server.model.ProcessStatus] Install (unless *install* is off) and start the autostart devices, or *names*. A device that is already running is left alone. Returns one status per device, in fleet order. .. py:method:: down(names: collections.abc.Sequence[str] | None = None) -> list[str] Stop everything running (or *names*); returns the names stopped. ``fleet up`` starts the working fleet, so ``down`` starts there — but a device declared ``autostart: false`` and started by hand is running all the same, and stopping only the working fleet left it up and called the bench down. Anything with a launch record is included. .. py:method:: run(stop: threading.Event, names: collections.abc.Sequence[str] | None = None, interval: float = 5.0, max_sweeps: int | None = None, leave_running: bool = False, max_backoff: float = 60.0) -> int Bring the fleet up and keep it up until *stop* is set (headless bench mode). Every *interval* seconds each device is inspected; one found ``exited`` is started again, after a backoff that doubles per consecutive crash (capped at *max_backoff*) and resets once it has answered a probe. Devices stopped on purpose (no record) are left alone: the loop keeps up what it started, it does not fight the operator. :param stop: Set to end the loop. :param names: Devices to keep up; default: the autostart ones. :param interval: Seconds between sweeps. :param max_sweeps: Stop after this many sweeps (tests). :param leave_running: Do not bring the servers down when the loop ends. :param max_backoff: Longest wait before a restart. :returns: The number of restarts performed. .. py:method:: field_test_now(name: str, run: plesty.server.model.FieldTestRun, say: Say | None = None) -> int Run the field test of *name* in the foreground; returns the script's exit code. Host tier: the server is stopped for the run and relaunched afterwards exactly as it was (unless ``run.keep_server``). Client tier: the server is started when it is not up, and the address defaults to it. :raises BenchError: When *name* is not a git install with that tier's script. .. py:method:: field_test_options(name: str, tier: str = 'host') -> dict[str, list[str]] What the device's field-test script accepts, from its ``--help``. :returns: ``{"options": [...], "gates": [...]}`` — the long options and the gate choices (the standard gates when the script does not list them); both empty when the script cannot be asked. .. py:method:: field_test_availability(name: str, tier: str = 'host') -> str | None Why the field test of *name* cannot run, or ``None`` when it can. The reason is what a tooltip shows: not installed, a release install (no repository on disk), or a checkout without the tier's script. .. py:method:: field_test(name: str, run: plesty.server.model.FieldTestRun) -> plesty.server.model.Job Run the field test of *name* as a job; returns it at once. .. py:method:: _field_test_command(run: plesty.server.model.FieldTestRun, spec: plesty.server.model.DeviceSpec, python: pathlib.Path, checkout: pathlib.Path) -> list[str] .. py:method:: _script_help(python: pathlib.Path, script: pathlib.Path) -> str :staticmethod: The ``--help`` text of a field-test script (``""`` when it cannot be asked). .. py:method:: _script_options(python: pathlib.Path, script: pathlib.Path) -> set[str] | None :classmethod: The options a field-test script accepts, read from its ``--help``. Scaffolds differ between SDK releases (``--address`` now, ``--port``/ ``--mock`` before); asking the script is cheaper than guessing wrong. ``None`` when it cannot be asked (the current scaffold is assumed). .. py:method:: job(job_id: str) -> plesty.server.model.Job | None The job *job_id*, up to date, or ``None``. .. py:method:: job_list(name: str | None = None, kind: str | None = None) -> list[plesty.server.model.Job] Jobs on record, newest first. .. py:method:: job_log(job_id: str, offset: int = 0) -> tuple[str, int] Job log from byte *offset*; returns text and the next offset. .. py:method:: cancel(job_id: str) -> bool Cancel a running job. .. py:method:: _job(name: str, kind: str, argv: list[str], detail: dict[str, object] | None = None, fleet_needed: bool = True) -> plesty.server.model.Job Start ``plesty-server _job `` against this bench as a job. :raises BenchError: When the job reads declarations this bench has not written anywhere — a job is another process, and it opens the fleet *file*. .. py:function:: _example_blocks(template: str) -> dict[str, str] Each ``KEY=`` line of an example with the comment lines directly above it, keyed by KEY. .. py:function:: release_tag(ref: str) -> bool Whether *ref* names a release (``v0.3.1``) rather than a branch. :param ref: A git ref as the inventory or the catalogue reports it. .. py:function:: module_spec(package: str, version: str | None = None, git: str | None = None, ref: str | None = None) -> plesty.server.model.DeviceSpec A declaration standing in for a bare module install. The installer works from a :class:`DeviceSpec` because a device install is the common case; an environment is keyed by ``(package, version)`` or ``(package, ref)`` either way, so the name is only along for the ride and the package's own is the honest one. .. py:function:: _stderr(line: str) -> None .. py:function:: _now() -> str