# 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 | `/packages///` — a `uv` venv | `/packages//.json` | | git checkout | `/repos///` — a clone; its `.venv` (from `uv sync`) is the environment | `/packages//git-.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-` 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: ```python 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, `/packages//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: - **PyPI** — `uv venv --relocatable --python ">=3.12"` into a staging directory, `uv pip install --upgrade `, read the version that actually landed (`uv pip list --format json`), then move the directory to `packages///`. 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///`, 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 `/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 `/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`, 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` (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.