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.

Why it matters for the pipeline. PLESTY runs device drivers → analyzers → experiments. Two of those three tiers could be scaffolded; the middle one could not. An analyzer author had to copy a device project and delete the half that did not apply — which is how analyzers ended up without a shared contract to check.

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
OptionDefaultWhat it does
--nameinferredPython module name, snake_case. Inferred from the project directory.
--author / --emailJohn DoeWritten into pyproject.toml and REUSE.toml.
--standardpixelCompliance standard for the generated CI pipeline. A fresh scaffold is a prototype, so CI starts at pixel; bump it alongside your releases.
--lib-versionnewest on PyPIPin 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-cioffSkip .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

plesty-peak-fit/ ├── plesty/ │ └── peak_fit/ │ ├── __init__.py # exports Analyzer + load_config │ ├── config.yaml # in-package, every module type │ ├── analyzer.py # analyzer │ ├── base_device.py, device.py # device │ ├── experiment.py # experiment │ └── __main__.py # device + experiment only ├── docs/index.md, docs/toc.yaml ├── tests/test_peak_fit.py # contract gates + behaviour tests ├── .env.example # device + experiment only ├── config/default.yaml # experiment only ├── pyproject.toml, README.md, CHANGELOG.md ├── LICENSE, LICENSES/, REUSE.toml └── .gitlab-ci.yml # omitted with --no-ci
analyzerdeviceexperimenteverything unmarked: every type

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:

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 testWhat it holds you to
test_analyzer_subclassA public Analyzer subclass exists and is instantiable with its declared parameters.
test_schema_integrityBoth schemas are non-empty, every dtype resolves, every shape is well-formed.
test_analyze_signatureanalyze() accepts exactly the names declared in input_schema — no drift between the schema and the code.
test_mock_roundtripSynthetic inputs built from the schema produce outputs matching output_schema.
test_provenance_stampedResults 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()
When synthetic inputs are not enough. The pipeline builds mock inputs from 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.
The gate is a floor, not the test suite. Keep the scaffolded contract tests and add your own behaviour tests beside them. The scaffold ships one as an example — 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:

AssetHolds
assets/templates/copier.ymlThe 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.yamlThe Next-steps text, keyed setup / per type / verify.
assets/check/guidance.yamlPer-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.

Two 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