# Architecture
This page is the living architecture document for **plesty-sdk** — the command-line tool
behind the `plesty` command. It records the module boundaries, the public contracts other
PLESTY repositories rely on, the reasoning behind the major design decisions, the sanctioned
dependency list, and the known technical debt. Keep it current: any change that moves a
module boundary, alters a contract, or adds a dependency must update this page in the same
merge request.
## High-level module view
The SDK is a single distribution (`plesty-sdk`) providing one namespace package,
`plesty.sdk`. Everything is reached through the `plesty` CLI — the package has no public
Python API beyond the entry point (`plesty = "plesty.sdk.cli:app"`). Internally it separates
into five areas:
```{mermaid}
graph TD
CLI["CLI layer
cli.py · context.py"]
CHECK["Check engine
commands/check.py · authorship.py · semver.py · report.py"]
DOCS["Docs builder
commands/docs/*"]
SCAFFOLD["Scaffolding
commands/init.py · update.py · field_test.py · mock_test.py"]
LICENSE["Licensing
commands/license.py"]
MANUAL["Manuals
commands/manual.py"]
ASSETS["Packaged assets
assets/*"]
CLI --> CHECK
CLI --> DOCS
CLI --> SCAFFOLD
CLI --> LICENSE
CLI --> MANUAL
CHECK -->|gate 11, gate d3| DOCS
SCAFFOLD --> ASSETS
CHECK --> ASSETS
DOCS --> ASSETS
LICENSE --> CHECK
```
| Area | Modules | Responsibility |
|---|---|---|
| CLI layer | `cli.py`, `context.py` | Click command group; resolves `--project-dir` into an `ApplicationContext` that lazily parses `pyproject.toml` and derives `DocumentationSettings`. Every command receives this context — no command parses `pyproject.toml` itself. |
| Check engine | `commands/check.py`, `authorship.py`, `semver.py`, `report.py` | The compliance gates behind `plesty check`. Each gate is a plain function raising `click.ClickException` (or exiting with the failing tool's code) on violation. `authorship.py` holds the git-history authorship analysis shared with `plesty license author`; `semver.py` the public-API diff against the published release for gate 8; `report.py` the `reports/check.json` document every run writes. |
| Docs builder | `commands/docs/` (`serve.py`, `deploy.py`, `sphinx_builder.py`, `generate.py`, `picker.py`) | Sphinx configuration and build (`sphinx_builder.py`), live-reload preview (`serve`), pages-branch deployment with versioned releases (`deploy`), zero-effort device reference-page generation from a live `doc_model()` (`generate.py`), and stamping the version picker into release trees built before it existed (`picker.py`). `cache.py` maintains the git-ignored `.plesty/` build cache used by `serve`. |
| Scaffolding | `commands/init.py`, `commands/update.py`, `commands/field_test.py`, `commands/mock_test.py`, `tui.py` | Project generation from the copier template (`plesty init`) and in-place maintenance of existing projects (`plesty update`). `update` reuses `init`'s CI-generation and hook-installation helpers so both commands emit identical artifacts. `field_test.py` and `mock_test.py` generate a device module's field test and mock pipeline test (`plesty init field-test` / `mock-test`); `tui.py` is the dependency-free checkable list the field-test interview uses. |
| Licensing and manuals | `commands/license.py`, `commands/manual.py` | SPDX header maintenance via the REUSE tool, and Markdown/PDF manual generation for external (non-hub) device packages. |
| Packaged assets | `plesty/sdk/assets/` | Data shipped inside the wheel: the canonical `ruff.toml`, the copier scaffold under `templates/`, the shared Sphinx theme (`hub-theme/`), and the pages-branch CI file (`docs-build-gitlab-ci.yml`). Assets are always accessed through `importlib.resources`, never by path relative to the source tree. |
Dependency direction is strictly downward: the CLI layer knows all command modules; command
modules know only `context.py`, `authorship.py`/`cache.py` helpers, and assets. The check
engine imports the docs builder lazily (inside gate 11) so `plesty check --standard pixel`
never pays the Sphinx import cost. Nothing imports `cli.py`.
## Interface contracts
### CLI commands
The `plesty` CLI is the SDK's public surface. Each command's options and behaviour are
documented on its own page — those pages are the contract, and changes to them are breaking
changes for the hub:
- [`plesty check`](commands/check.md) — compliance gates and standard resolution
- [`plesty init`](commands/init.md) — project scaffolding and naming conventions
- [`plesty docs`](commands/docs.md) — documentation preview and deployment
- [`plesty license`](commands/license.md) — SPDX headers and authorship currency
- [`plesty manual`](commands/manual.md) — external device manuals
- [`plesty update`](commands/update.md) — metadata, CI, and hook maintenance
All commands honour the global `--project-dir` (`-d`) option; a command must never read
state from the process working directory when a project directory is configured.
### The `[tool.plesty]` configuration contract
Projects configure the SDK exclusively through `pyproject.toml`:
| Key | Section | Meaning |
|---|---|---|
| `standard` | `[tool.plesty]` | Pins the compliance level (`pixel`/`nebula`/`quantum`). Optional — when absent the level is derived from the release version (see below). Core repos pin `quantum`. |
| `module_type` | `[tool.plesty]` | One of `device`, `experiment`, `analyzer`, `core`. Optional; validated by gate 1 when present. Activates the module-type gates and device docs generation. |
| `site-name` | `[tool.plesty.docs]` | Docs site title; defaults to the project name. |
| `docs-dir` | `[tool.plesty.docs]` | Documentation source directory; defaults to `docs`. |
| `toc-file` | `[tool.plesty.docs]` | External TOC file relative to the docs dir; defaults to `toc.yaml`, empty string disables it. |
| `api-ignore` | `[tool.plesty.docs]` | Top-level package directories excluded from the generated API reference scan. |
There is deliberately no `version_tag` configuration key: the version shown in deployed
docs is derived from the installed package metadata by `plesty docs deploy --release`, so
it can never drift from the actual release.
### The gate architecture
`plesty check` runs a fixed, ordered list of gates. Gates 1–11 run locally; the same
engine is re-run by CI (`hub-check` in the `plesty-standard-ci` component) as the
authoritative final gate. Gates carry a tier label:
- **`[SDK+CI]`** (gates 1–10) — enforced locally and repeated in CI.
- **`[SDK]`** (gate 11, docs build) — runs locally and in merge-request pipelines. In a
branch pipeline (`CI=true` without `CI_PIPELINE_SOURCE=merge_request_event`) gate 11
reports itself skipped, because the dedicated `hub-deploy-docs` CI job builds the docs
instead. Tests exercising the Sphinx path must therefore clear the `CI` variable.
- **`[Device]` / `[Analyzer]` / `[Experiment]`** (d1–d3, a1, e1–e3) — module-type gates,
active only when `[tool.plesty] module_type` is `"device"`, `"analyzer"`, or
`"experiment"` respectively; every other module type gets an explicit "N/A" line, so
the gate list is identical everywhere and activation is declarative, never heuristic.
Gate d1 verifies the eight `DevicePipeline` mock tests, d2 the telemetry
return-annotation contract, d3 that the device renders a populated `doc_model()`;
a1 verifies the five `AnalyzerPipeline` contract tests; e1 verifies the five
`ExperimentPipeline` contract tests, e2 that persistence goes through the unified
data layer, e3 that `python -m --manifest` publishes the run contract and
that its configuration schema and the module's own `config/*.yaml` say the same
thing.
The standard selects how deep the run goes: `pixel` runs gates 1–2, `nebula` gates 1–6
(static, no network), `quantum` everything including the module-type gates. The full
per-gate reference lives on the [`plesty check`](commands/check.md) page and is not
duplicated here.
### The pre-push hook
`plesty init` (and `plesty update --install-hook` for cloned projects) writes
`.git/hooks/pre-push` containing a single call: `uv run plesty check`. The hook passes no
`--standard`, so enforcement always follows the project's own standard resolution — pinning
or releasing a version changes what the hook enforces without touching the hook. Gate 10
needs network access (OSV queries), so pushing requires connectivity under `quantum`.
### Version-derived default standard
When `[tool.plesty] standard` is unset, `derive_standard()` maps the release version to a
level, so shared prototypes ramp up automatically — pixel before `0.1.0`, nebula before
`1.0.0`, quantum from then on. The mapping and its fallbacks are specified once, under
[Standard resolution](commands/check.md#standard-resolution) on the `plesty check` page.
`plesty check` echoes which rule applied, so the resolution is never silent.
## Design Decisions
**Version-derived compliance standard, not a fixed default.** A fixed default forces a
choice between blocking day-one prototypes (`quantum`) and under-enforcing mature modules
(`pixel`). Deriving the level from the release version encodes the actual policy — rigour
should grow with maturity — and makes ramp-up automatic at the moment a project tags
`0.1.0` or `1.0.0`. The tradeoff is a strictness change that arrives "by surprise" with a
release tag; this is mitigated by echoing the resolution reason on every run and by letting
an explicit `[tool.plesty] standard` always win.
**Licensing via the REUSE tool, not hand-rolled header checks.** The previous
template-based header workflow (`licenseheaders` + `spdx.tmpl`) was our own code to
maintain and could silently drift from the SPDX spec. Delegating to `reuse annotate` /
`reuse lint` buys a community-maintained implementation of a published specification,
machine-verifiable compliance in gate 9, and `--skip-existing` semantics that leave curated
per-file contributor headers untouched. The cost is an extra runtime dependency and REUSE's
opinionated file layout (`LICENSES/`, `REUSE.toml`), which every repo had to adopt once.
**One standard Read-the-Docs theme for every module, dropping pydata for core.** Two
themes meant two CSS surfaces, two logo/favicon pipelines, and a version switcher that only
existed on one of them — for no reader benefit. Converging on `sphinx-rtd-theme` with the
shared `hub-theme` assets gives every PLESTY docs site the same navigation, branding, and
zero-warning build behaviour, and halves the surface gate 11 has to keep warning-free. The
tradeoff was losing pydata's own version switcher; the theme carries its own instead — a
version picker in the sidebar header (`plesty-versions.js`), fed by the deploy-side
`versions.json`, which `plesty docs deploy` also stamps into releases built before it existed.
**Schema-usage enforcement inside the data-layer gate.** plesty-lib defines the data every
module exchanges — a device's configuration and operations, its status and telemetry, an
experiment's plan and journal, a stored result — as a fixed set of classes (the *schema
models*). A module that wrote its own class of the same name — its own `DeviceStatus`, say —
would *shadow* the library's: code in that module would silently use the local copy, which
no other module, console or reader understands. Gate 4 therefore scans every Python file
under `plesty//` of **every** module type — device, analyzer, experiment and core
alike — and fails on a class definition whose name is one of plesty-lib's schema models
(`ConfigParameter`, `FuncMeta`, `DeviceStatus`, `TelemetryEvent`, `ResultDocument`, `Plan`,
`Journal`, … — the full list is `_PROTECTED_SCHEMA_NAMES` in `commands/check.py`), and on a
public function without a return type annotation. Two further checks apply to one module
type each: gate d2 (devices) requires `status()` to be annotated `DeviceStatus`, and gate e2
(experiments) requires results to be stored through `save_result` rather than written by
hand. The alternative — trusting convention and review — had already produced silent forks
of the data contract. Reading the source as a syntax tree, without importing it, is cheap,
runs offline in the pre-push hook, and cannot be fooled by a module that fails to import;
the tradeoff is that such a check sees only what is written literally (a file mode computed
at run time is deliberately left alone), so the gates are a floor, not a proof.
**Generated device documentation (gate d3) over hand-written reference pages.**
Hand-written parameter and operation tables drift the moment a schema changes. Rendering
Usage/Parameters/Functions/Standard-Methods pages from the live `doc_model()` makes drift
impossible and reduces the author's obligation to a single QuickStart page, while gate d3
keeps the property machine-checked (the device must construct hardware-free and render a
populated model). The tradeoff is that generated prose is uniform rather than tailored, and
device classes must keep `__init__` connection-free — an intentional constraint that also
benefits testing.
**Scaffolding a one-line CI component include, not a copied pipeline.** The generated
`.gitlab-ci.yml` includes the `plesty-standard-ci` component with three inputs instead of
copying the full job definitions. Pipeline fixes and new gates then reach every module by
updating one component in plesty-ci, rather than by patching dozens of generated files that
have already diverged. The cost is a coupling to the component's ref and input contract —
a rename or ref change in plesty-ci breaks scaffolds (see technical debt below) — which we
accept because centralised evolution has proven far cheaper than fleet-wide file surgery.
**A generated device carries its own control panel, with Qt in an extra.** The panel is
plesty-lib's `DevicePanel`, and it reaches a device only through a four-call interface,
`DeviceHandle` — `describe()`, `query()`, `write()`, `call()`. Where the device is decides
only which handle the panel is given: in the same process, the device object wrapped in a
`LocalHandle`; over the network, a TCP client (`DeviceTCPIPClient`) satisfies the same
interface; from a remote `plesty-bench`, the bench agent relays the same four calls. So
the one panel drives a device locally or remotely alike. What the scaffold generates is the
local case: `--gui` on the module's existing entry point opens the panel on the machine the
instrument is plugged into, rather than shipping a separate console or leaving each module
to hand-write one.
`--gui` and serving are alternatives *within one process*: both open the instrument, and
most instruments grant a single session, so `python -m plesty.` does one or the
other. Both at once is the remote case — serve the device, and open the panel from a client
on the same or another machine.
The controls are built from `describe()`, the device's schema serialized as plain data, so
a module gets a usable window the moment its schema is right, and a hole in that payload
surfaces at the bench instead of only across a network. Operations are filtered by their
declared `kind`, which makes the schema the single place that decides what a panel offers,
local or remote.
A module with a GUI of its own is not overridden by anything: `--gui` is ordinary generated
code in the module's `__main__.py` (`open_panel()`), and the module replaces it — with its
own `Panel` subclass, several panels in one window, or no panel — by editing that function.
Nothing loads a module's panel code from elsewhere.
Qt lives in a generated `gui` extra rather than in the generated `dependencies` — a headless
bench serves the device and never opens a window — which is why `plesty.lib.ui` is imported
inside the panel path and not at module scope. How far Qt reaches is deliberately bounded:
the panel contract (`Panel`), the device handle, the worker thread that keeps device calls
off the window's thread, and the `describe()` payload import no toolkit; Qt is confined to
`plesty.lib.ui.qt` and to the widget each panel builds. Moving to another toolkit, or
offering a second kind of interface such as a web page, would therefore mean new widget
code and a new shell, while the data a panel renders and the way it reaches a device stay
as they are. The tradeoff is that this widget code is Qt today, and a second toolkit would
be a second implementation of it rather than a switch.
**`plesty update --install-hook` for cloned projects.** Git hooks live in `.git/` and are
never cloned, so `plesty init`'s pre-push hook silently vanishes for every contributor
after the first. Rather than adopting a hook-manager dependency (pre-commit) or committing
wrapper scripts, `plesty update --install-hook` re-creates the identical hook on demand and
is cheap to document as a one-time setup step. The tradeoff is that it remains opt-in per
clone — CI stays the authoritative backstop for contributors who skip it.
## Allowed dependencies
The sanctioned runtime dependencies (from `[project] dependencies` in `pyproject.toml`):
| Package | Why it is here |
|---|---|
| `click` | CLI framework — command group, options, context passing, error reporting. |
| `ruff` | Canonical linter and formatter enforced by gate 2 (shipped so the SDK pins the toolchain, not the user). |
| `mypy` | Static type checking in gate 2. |
| `pytest` / `pytest-cov` | Test runner and coverage measurement for gates 7, d1, e1. |
| `reuse` | SPDX header maintenance and REUSE compliance linting (gate 9, `plesty license`). |
| `copier` | Template engine driving `plesty init` scaffolds. |
| `sphinx` | Documentation build engine (gate 11, `plesty docs`). |
| `myst-parser` | Markdown (MyST) source support for Sphinx. |
| `sphinx-autoapi` | Generated API reference from the project's packages. |
| `sphinx-external-toc` | `toc.yaml`-driven navigation. |
| `sphinx-rtd-theme` | The single documentation theme for all modules. |
| `sphinx-design` | Badges/shields used by the generated device reference pages. |
| `sphinx-copybutton` | Copy buttons on code blocks. |
| `sphinxcontrib-mermaid` | Mermaid diagrams in docs (including this page). |
| `livereload` / `watchfiles` | Local preview server and rebuild-on-change for `plesty docs serve`. |
| `md2pdf` | PDF rendering for `plesty manual generate --with-pdf`. |
| `griffe` | Static public-API extraction for the gate 8 diff — the analyzed package is never imported. |
| `packaging` | Version and specifier parsing (gate 8 baselines, `--lib-version`, versioned docs deploy). |
| `pyside6-essentials` (extra: `gui`) | Optional GUI support; not installed by default. |
**Policy:** a new third-party library may only be added if it is actively maintained
(released or meaningfully committed to within the last two years), it carries a license
compatible with `LGPL-3.0-or-later`, and it is added to this table with a one-line
justification in the same merge request. Exact pins (`==`) are forbidden by our own gate 6;
use `>=x.y` lower bounds.
## Technical debt
Technical debt lives in the issue tracker, not in this document: every debt item is filed
as a GitLab issue labeled
[`type::debt`](https://gitlab.com/plesty/core/plesty-sdk/-/issues/?label_name%5B%5D=type%3A%3Adebt)
— per the
[governance DoD](https://gitlab.com/plesty/governance/-/blob/main/CONTRIBUTING.md),
permanent architectural debt is filed before the shortcut lands.