Until this month plesty init default was accepted by the CLI and then raised “Default project template is not yet available”, and analyzer was half-supported — the module type validated, but no scaffold offered it and no analyzer gate existed. Now all four project types scaffold, an analyzer arrives as a working schema-declared transform, and a1 holds it to the Analyzer contract.
Four project types, one template
plesty init [PROJECT_TYPE] takes default, device, analyzer or experiment — the argument now defaults to default rather than to a device. All four come out of the same Copier template; the type decides which files are rendered, which extra module is copied in, and which contract gate becomes active.
default
A plain library module: the plesty namespace package with an in-package config.yaml and load_config(), its coverage tests, docs, README and CI. No device or server artifacts, and no module_type key.
device·
The device-server skeleton — base_device.py, device.py, a runnable __main__.py and an .env.example for connection credentials.
analyzernew
A plesty.lib.analyzer.Analyzer transform: a working example analyzer.py, the five AnalyzerPipeline contract tests, analyzer docs and README, module_type = "analyzer" — and no server artifacts.
experiment
The plan/journal experiment skeleton with the E1 gate tests, a RIG declaration in __main__.py and a config/default.yaml.
UsersScaffolding a project today
The standard workflow is two commands: make the directory, initialize inside it. The module name is inferred from the directory — the plesty- prefix is stripped and hyphens become underscores — so --name is usually unnecessary.
# A plain library module (the new default)
mkdir plesty-fit-utils && cd plesty-fit-utils
plesty init
# An analyzer — plesty.lib.analyzer.Analyzer + the a1 contract tests
mkdir plesty-peak-fit && cd plesty-peak-fit
plesty init analyzer
# A device server, explicit about author and CI standard
plesty --project-dir ~/projects/plesty-power-meter init device \
--name power_meter --author "Jane Doe" --email jane@example.com \
--standard nebula
| Option | Default | What it does |
|---|---|---|
--name | inferred | Python module name, snake_case. Inferred from the project directory. |
--author / --email | John Doe | Written into pyproject.toml and REUSE.toml. |
--standard | pixel | Compliance standard for the generated CI pipeline. A fresh scaffold is a prototype, so CI starts at pixel; bump it alongside your releases. |
--lib-version | newest on PyPI | Pin plesty-lib instead of looking it up. A bare version becomes a lower bound; a full specifier is written as given. PyPI is not consulted, so the scaffold is reproducible. |
--no-ci | off | Skip .gitlab-ci.yml entirely. |
Scaffolding is not an interview — every answer comes from an option or from the directory name. init then formats the generated files with the SDK’s own ruff config, makes the initial git commit (versioningit needs it to resolve a version), and installs the pre-push hook that runs plesty check before every push. The scaffold is plesty check-clean from the first commit.
What lands on disk
The type-specific module (analyzer.py, device.py, experiment.py) is copied from the template’s extras/<type>/ directory after the Copier render; the rest is one shared tree with Jinja branches. __main__.py and .env.example became device/experiment-only in this change — an analyzer has no server to run and no connection credentials to hold.
The scaffold tells you what to do next
plesty init ends with a numbered, project-type-specific Next steps list — environment setup, the files to implement, then the verify/publish loop. For an analyzer that reads:
1. Install the environment: uv sync
2. Declare your input/output schemas and implement analyze() in plesty/peak_fit/analyzer.py.
3. Keep calibration and analysis parameters as constructor arguments — they land in
each result's provenance stamp.
4. Adapt the AnalyzerPipeline gate tests in tests/ (override synthetic inputs via
mock_inputs if your transform needs realistic data).
5. Keep the generated tests green: uv run pytest
6. Run the compliance gates: uv run plesty check
7. Publish via CI: push to GitLab and tag a release when ready.
The step text ships in the wheel at plesty/sdk/assets/init/next-steps.yaml rather than living in the code, so the guidance is editable without touching the command. Pass the global --quiet flag to print only the result line — plesty --quiet --project-dir plesty-peak-fit init analyzer.
DevelopersWriting an analyzer against the scaffold
The generated analyzer.py is a complete working transform — it normalizes a trace to its peak — so the module is green before you have written a line. You replace the two schemas and the analyze() body.
from plesty.lib.analyzer import Analyzer as BaseAnalyzer
from plesty.lib.data import PlestyArray
class Analyzer(BaseAnalyzer):
"""Example analyzer: normalizes a signal trace to its peak value."""
input_schema = {
"signal": {"dtype": "array_float", "unit": "a.u.",
"description": "Raw signal trace to normalize."},
}
output_schema = {
"normalized": {"dtype": "array_float", "unit": "a.u.",
"description": "Signal scaled to peak 1."},
}
def __init__(self, smoothing: int = 1) -> None:
# Analysis parameters go here — they land in every result's provenance.
super().__init__(smoothing=smoothing)
def analyze(self, signal: PlestyArray) -> dict[str, Any]:
data = np.asarray(signal, dtype=float)
peak = float(np.max(np.abs(data))) if data.size else 0.0
return {"normalized": PlestyArray(data / (peak or 1.0))}
Three rules follow from the base class, and the gates check all three:
- Declare, don’t document.
input_schemaandoutput_schemause the device-schema vocabulary —float,int,str,bool,array_<numpy dtype>,table2d. Arrays may pin a shape ([None, 2], whereNoneis a free dimension) and an input may setrequired: False. - The call validates. You implement
analyze(); callers useAnalyzer()(signal=data). The validating__call__checks inputs againstinput_schema, fills unset name/unit/description on returned values fromoutput_schema, and stamps every array or table result with provenance — analyzer class, package version, constructor parameters. - Calibration is a constructor parameter, never module state. Anything passed to
__init__is recorded in that provenance stamp; a threshold read from a module global is invisible in the result and unreproducible from it.
Gate a1 — Analyzer Contract quantum
New in plesty check this month, mirroring the device gate d1 and the experiment gate e1. It activates on [tool.plesty] module_type = "analyzer" in pyproject.toml — which the scaffold writes for you — and is reported as N/A for every other module type. All five contract test functions must be present in tests/ and pass on schema-generated synthetic inputs:
| Contract test | What it holds you to |
|---|---|
test_analyzer_subclass | A public Analyzer subclass exists and is instantiable with its declared parameters. |
test_schema_integrity | Both schemas are non-empty, every dtype resolves, every shape is well-formed. |
test_analyze_signature | analyze() accepts exactly the names declared in input_schema — no drift between the schema and the code. |
test_mock_roundtrip | Synthetic inputs built from the schema produce outputs matching output_schema. |
test_provenance_stamped | Results carry the provenance stamp. |
The generated tests/test_<module>.py wires them in five lines — the pipeline holds the logic, the module holds the delegation:
from plesty.lib.test.analyzer_pipeline import AnalyzerPipeline
from plesty.peak_fit import Analyzer, load_config
PIPELINE = AnalyzerPipeline(Analyzer)
def test_analyze_signature() -> None:
"""Gate 3: analyze() accepts exactly the declared input names."""
PIPELINE.test_analyze_signature()
input_schema. A transform that needs realistic data — a fit that will not converge on noise, a peak finder that needs a peak — overrides them at construction: AnalyzerPipeline(Analyzer, mock_inputs={"signal": my_trace}). Only the named inputs are replaced; the rest stay schema-generated.test_normalized_peak_is_one asserts the transform scales to peak 1 and keeps the unit.What the scaffold already satisfies
A fresh init analyzer passes the full quantum check — including a1, at 100% coverage — before you change anything. That means REUSE licensing headers, the docs build, the packaging metadata and the CI file are all in place, and the first failure you see is one you introduced. Docs are one of the fixes that came with this work: the generated docs/index.md used to embed an automodule block that duplicated the always-on generated API reference, costing seven Sphinx warnings on every non-device scaffold; it now points at the generated reference pages instead.
DevelopersContributing to the templates
Everything the scaffold emits ships inside the SDK wheel, so extending it is editing assets, not code:
| Asset | Holds |
|---|---|
assets/templates/copier.yml | The template questions and the project_type choice list. |
assets/templates/template/ | The shared tree. Type-specific files are Jinja-conditional, including in filenames — {% if project_type in ['device','experiment'] %}__main__.py{% endif %}.jinja. |
assets/templates/extras/<type>/ | The type’s implementation module, copied into the package after the render. |
assets/init/next-steps.yaml | The Next-steps text, keyed setup / per type / verify. |
assets/check/guidance.yaml | Per-gate failure guidance, including a1. |
A new project type is therefore: a copier.yml choice, an extras/<type>/ module, Jinja branches in __init__.py.jinja and the test template, a next-steps.yaml block, and — if the type carries a contract — a gate in check.py that keys off module_type and stays silent for everyone else.
init subcommands add to an existing module rather than creating one. plesty init mock-test probes a device module for a construction that runs without hardware, verifies that strategy against the gates it will face, and writes tests/test_mock_pipeline.py — it asks nothing. plesty init field-test does ask, because every one of its questions is a safety judgement about a real instrument. It reads the device’s own doc_model() for the candidates and now reads the kinds the module declares too: an operation exposed as @expose_to_api(kind="motion", …), "lifecycle", "control" or "acquire" shows up pre-classified and the “does this act on the world?” question is asked only about operations that declared nothing. Declaring kinds on your operations is the cheapest way to make your module’s field test generate cleanly.Where to look
- plesty-sdk —
plesty/sdk/commands/init.py,plesty/sdk/commands/check.py, and theplesty/sdk/assets/tree;docs/commands/init.mdanddocs/commands/check.md. - plesty-lib —
plesty.lib.analyzerfor the base class,plesty.lib.test.analyzer_pipelinefor the five gates. uv run plesty checkin any module prints each gate with its tier and, on failure, the guidance for that gate.