Installing device modules

A bench holds several versions of a module side by side. A pinned instance keeps running the release it was verified with while a newer one is installed next to it; rolling back is pointing the instance at the version that is still there.

Layout

Source

Environment

Record

PyPI release

<home>/packages/<package>/<version>/ — a uv venv

<home>/packages/<package>/<version>.json

git checkout

<home>/repos/<package>/<ref>/ — a clone; its .venv (from uv sync) is the environment

<home>/packages/<package>/git-<ref>.json, with path pointing at the checkout

Two instances pinned to the same release share one environment. Two instances of the same package at different versions do not interfere. Records are keyed by slot: the version for a release, git-<ref> for a checkout (a checkout moves with every pull, so it is kept by the ref it tracks; two refs at the same commit never collide).

The inventory — DeviceManager

The model side only keeps the books. InstalledVersion is one record:

InstalledVersion(package="plesty-pm100d", version="0.2.1", source="pypi", python="3.12.4")
InstalledVersion(
    package="plesty-pm100d",
    version="0.4.0.dev3+abc1234",
    source="git",
    git="https://…/plesty-pm100d.git",
    ref="exp",
    commit="abc1234",
    path="~/.plesty/server/repos/plesty-pm100d/exp",
)

DeviceManager.resolve(spec) is the question the presenter asks before starting anything: is there an installed version that serves this spec?

  • a PyPI spec is served by the pinned version (any PyPI version when unpinned — the newest install wins);

  • a git spec is served by an install from the same repository at the same ref (or commit prefix);

  • the two never cross: a checkout never serves a PyPI spec and vice versa.

None means install first.

The package record — PackageInfo

Beside the install records, <home>/packages/<package>/package.json keeps what the bench knows about the module as a project: its repository URL, project page, latest release tag/version, vendor, description. It is learned once — from the catalogue when a module is installed there, from any git install (the URL), or when the catalogue is consulted — and never asked again: the declaration form’s URL/ref/version drop-downs, the .env.example fetch for release installs and the project link shown under a device all read it. A package installed from the catalogue is thereby linked to one repository, and its releases to the same project.

The installer — Installer

Installer.install(spec) makes an environment exist and returns its record:

  • PyPIuv venv --relocatable --python ">=3.12" into a staging directory, uv pip install --upgrade <requirement>, read the version that actually landed (uv pip list --format json), then move the directory to packages/<package>/<version>/. Staging first is what lets an unpinned spec end up under the version it resolved to, and --relocatable is what makes the move safe.

  • git — clone (or fetch) repos/<package>/<ref>/, check out the ref, fast-forward when on a branch, uv sync. The record stores the commit and the checkout path. A pulled branch reports a new version, so the previous record of that checkout is dropped and replaced.

install() is a no-op when resolve() already finds a satisfying record whose interpreter still exists; force=True reinstalls. update(installed) is “install again, in place”: a git checkout is pulled and re-synced, a PyPI env reinstalled at the same version. uninstall(installed) removes the directory and forgets the record.

uv and git are the only tools used, driven through an injectable Runner; every line they print goes to an Echo — the job log when the install runs as a job, which is how the GUI shows progress of a minutes-long install instead of silence.

Where git comes from

Neither tool has to be on PATH. uv is looked up there; git is looked up there, then where Windows puts it (%ProgramFiles%\Git\cmd, %LOCALAPPDATA%\Programs\Git\cmd), then under <home>/tools/git. A Windows bench that has none gets one: gitbin.install_git downloads the portable MinGit build pinned in config.yaml, checks it against the pinned SHA-256, refuses any archive member that would write outside the target, and unpacks it into <home>/tools/git — no installer and no administrator. PLESTY_SERVER_AUTO_GIT=0 turns that off and reports instead. A POSIX bench is told to install git from its package manager: system-wide is the right place there.

When the release is not on the index

A module is on the hub before it is on PyPI: the registry knows a release from its tag, and the package reaches the index only once the release job has run. So a release install that finds nothing is a routine state of a young module, not a broken bench — and the same version is in the repository, tagged v<version>, which is the ref a spec with a version already resolves to.

Installer.install therefore falls back: when a release install fails and the package record knows a repository, it installs that repository at v<version> (or latest_tag when the spec named no version) and says so in the log. A bench that has never seen the module knows no repository — which is precisely the first install of a young module — so the presenter asks the hub for it first (Bench.learn_repository, one lookup, remembered). Without a known repository the release failure stands as it is, and when the tag fails too the error carries both reasons — so a bench that is simply offline does not report a confusing git error for a PyPI problem.

Pre-release dependencies

A module under development depends on a .dev release of plesty-lib, and uv resolves only stable versions unless it is told otherwise — it fails with a hint naming the pre-release. The installer retries that one command with --prerelease=allow and says so in the log. It is the retry and not the rule, so an install with a stable answer still gets the stable one.

Whether it fires depends on the bench’s uv. For a requirement whose own specifier names a pre-release (plesty-lib>=0.3.5.dev3), uv 0.11.10 refuses and the retry rescues the install; uv 0.12.5 resolves it first time and the retry never runs. Both benches end up with the same environment — one of them says so in its log. So the retry is not dead code: it is what keeps an older uv working, and it still fires on any uv for a specifier that does not name the pre-release it needs.

Why a checkout and not just a wheel

Hub devices publish to PyPI (the catalogue prefers a release, which needs no git on the bench), but the developer loop from afar needs the repository: plesty check, tests/field_test.py and plesty init field-test run inside the checkout. A release is what a finished module gets; a checkout is what a module under development gets.