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:

        graph TD
    CLI["CLI layer<br/>cli.py · context.py"]
    CHECK["Check engine<br/>commands/check.py · authorship.py · semver.py · report.py"]
    DOCS["Docs builder<br/>commands/docs/*"]
    SCAFFOLD["Scaffolding<br/>commands/init.py · update.py · field_test.py · mock_test.py"]
    LICENSE["Licensing<br/>commands/license.py"]
    MANUAL["Manuals<br/>commands/manual.py"]
    ASSETS["Packaged assets<br/>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:

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 <module> --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 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 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/<module>/ 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.<module> 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 — per the governance DoD, permanent architectural debt is filed before the shortcut lands.