plesty.sdk.commands.field_test

Generate a module’s tests/field_test.py by asking about its device.

plesty.lib.test.field_test.FieldTestPipeline is now field-tested on three modules across three transports. The gates turned out to be the easy part: they are generic and already shared. What took the time, and what each module got wrong at least once, was the configuration — and every line of it is a judgement about the instrument that no amount of schema inspection can make:

  • which operations are safe to call unattended — the wrong answer moved a stage, fired an exposure, and rewrote a sensor’s zero calibration

  • which parameters survive being written back their own value — one crashed the controlling application outright, killing every call after it

  • what is slow enough to be timed out on demand, since a call returning in two milliseconds cannot be abandoned and its gate silently skips

  • which two parameters are distinguishable enough that a stale reply is unambiguous

  • whether connecting is cheap, or starts an application and costs half a minute

So the schemas supply the candidates and the developer supplies the judgement. Prompting matters more than defaulting. A generated allow-list that quietly included a motion command would be worse than no generator at all: anything the developer does not classify stays out, and the gate skips saying so — the pipeline already treats a skip as a question left unanswered rather than as a pass.

Attributes

_WRITABLE_TYPES

_CONFIGURE_EXCLUSION

_MAX_EXCLUSIONS_ASKED

_IDENTIFIER

_MAX_OFFERED

STATE_DIRNAME

ANSWERS_FILENAME

_LEGACY_PARTIAL_FILENAME

_LINE_LENGTH

Exceptions

ScaffoldError

The module does not carry what a field test needs to be generated.

Classes

ParamCandidate

One configuration key the module declares, as the interview offers it.

ArgCandidate

One input an operation declares, as the interview offers it.

OpCandidate

One operation the module declares.

Candidates

Everything the device itself declares.

FieldTestConfig

What a module needs beyond the gates themselves.

FieldTestScaffold

Build a module's field-test configuration by asking about its device.

Functions

_comment(→ list[str])

Wrap a developer's reason into comment lines that fit the line length.

_parse_payload(→ dict[str, Any])

Read name=value, name=value into a payload.

_op_payloads_block(→ str)

Render the arguments each allow-listed operation is called with.

_safe_ops_block(→ str)

Render the allow-list, each entry carrying the reason it was allowed.

_unsafe_ops_block(→ str)

Render the exclusions, which are the more useful half of the answer.

_op_kinds_block(→ str)

Render what each allow-listed operation does, for the pipeline to drive it by.

_only_keys_block(→ str)

Render the round-trip allow-list.

_drain_probe_block(→ str)

Render the drain gate's two probe keys.

_slow_op_block(→ str)

Render the recovery gate's provocation.

_param_schema_block(→ str)

Render the schema path the run compares the instrument against.

_from_doc_model(→ tuple[list[ParamCandidate], ...)

Read the candidates from a constructed device.

_declared_kind(→ dict[str, Any] | None)

Read the kind a FuncDoc carries, as plain fields.

_would_lose(→ list[str])

Return top-level names existing defines that replacement does not.

_top_level_names(→ list[str])

Return the names defined at module level, in order of appearance.

_literal(→ str)

Render value as Python source the SDK formatter will not rewrite.

_matching_config_key(→ str | None)

Return the configuration key arg names, or None if it names none.

_arg_candidates(→ list[ArgCandidate])

Read one operation's declared inputs, for the provocation question.

_one_line(→ str)

Collapse text to one line, cut at a word boundary rather than mid-word.

_append_missing_variables(→ list[str])

Add the variables template declares that example does not yet.

_model_from_project(→ str)

Return the instrument model the project name implies.

_summaries(→ dict[str, str])

Return what each offered parameter is — its type, unit and range.

_wrap(→ str)

Wrap a paragraph to the terminal, so prose does not become a wall.

_block(→ str)

Render an authored block, wrapping paragraphs but keeping its bullets.

_preflight_import(→ str)

Return the import of the module's preflight, or nothing.

_preflight_block(→ str)

Return the preflight() the field test calls before a run.

_asset(→ Any)

Return a packaged asset under assets/init/.

terminal_ask(→ Any)

Ask one question at a terminal.

_typed_selection(→ list[str])

Ask for the choices as typed numbers, for a run with no keyboard.

_parse_selection(→ list[str])

Turn a comma-separated list of numbers into the names they select.

scaffold_field_test(→ pathlib.Path)

Run the interview and write tests/field_test.py.

_client_params_block(→ str)

Render the keys the client tier round-trips over the wire.

_client_types_block(→ str)

Render the declared type of each round-tripped key.

_client_resources_block(→ str)

Render the lockable resources the allocation gate contends for.

_client_safe_ops_block(→ str)

Render the operation allow-list, inherited from the host tier.

credit(→ str)

Add the author's SPDX lines to a generated file.

_git_identity(→ str)

Return the configured git user name, or an empty string.

format_generated(→ None)

Format a generated file with the SDK's own ruff configuration.

Module Contents

plesty.sdk.commands.field_test._WRITABLE_TYPES = ('float', 'int', 'bool', 'str')
plesty.sdk.commands.field_test._CONFIGURE_EXCLUSION = 'configure: changes a setting that outlives the call; the round-trip gate writes settings back...
plesty.sdk.commands.field_test._MAX_EXCLUSIONS_ASKED = 12
plesty.sdk.commands.field_test._IDENTIFIER
plesty.sdk.commands.field_test._MAX_OFFERED = 10
plesty.sdk.commands.field_test.STATE_DIRNAME = '.plesty'
plesty.sdk.commands.field_test.ANSWERS_FILENAME = 'field-test.json'
plesty.sdk.commands.field_test._LEGACY_PARTIAL_FILENAME = '.plesty-field-test.partial.json'
exception plesty.sdk.commands.field_test.ScaffoldError(message: str)

Bases: click.ClickException

The module does not carry what a field test needs to be generated.

Initialize self. See help(type(self)) for accurate signature.

Parameters:

message (str)

class plesty.sdk.commands.field_test.ParamCandidate

One configuration key the module declares, as the interview offers it.

Variables:
  • key – Configuration key.

  • dtype – Declared type name.

  • summary – One line for the chooser — type, unit, range or options.

  • read_only – Declared read-only; the round-trip gate queries it only.

  • write_only – Declared write-only; the round-trip gate skips it.

key: str
dtype: str
summary: str
read_only: bool = False
write_only: bool = False
property round_trippable: bool

True when the round-trip gate could write this key back.

Return type:

bool

class plesty.sdk.commands.field_test.ArgCandidate

One input an operation declares, as the interview offers it.

The recovery gate needs a call it can interrupt, and on most instruments what makes a call slow is an argument it already takes — a sample count, a dwell, an exposure. Reading them off the operation means the interview can offer the answer rather than ask for it in prose.

Variables:
  • name – Argument name, as the payload key.

  • dtype – Declared type name.

  • summary – One line for the chooser — type, unit, range or options.

  • bounds – Declared (low, high), or None. The high end is what makes a call slowest, so it is what the value prompt offers.

name: str
dtype: str
summary: str
bounds: tuple[Any, Any] | None = None
class plesty.sdk.commands.field_test.OpCandidate

One operation the module declares.

Variables:
  • name – Operation name.

  • summary – One line for the chooser — its description, truncated.

  • declared_safe – The schema marks it safe. Offered as a hint, never as a default: safe in a schema means “does not change device state”, which is not the same question as “safe to call unattended, twenty times, while nobody is watching”.

  • iparams – The inputs it declares. What the interview offers when asking which of them can be turned up until the call is slow enough to interrupt.

  • kind – What the module says the operation does, as the fields of plesty.lib.device.device_utils.OpKind (kind, position_key, target, relative, step), or None when the module — or its plesty-lib — declares nothing. A declared kind pre-fills the classification the interview would otherwise ask for, and a motion one tells the pipeline how to drive the operation and put it back.

name: str
summary: str
declared_safe: bool = False
iparams: tuple[ArgCandidate, Ellipsis] = ()
kind: dict[str, Any] | None = None
property kind_name: str

The declared kind, read when nothing was declared.

Return type:

str

class plesty.sdk.commands.field_test.Candidates

Everything the device itself declares.

Variables:
  • parameters – Declared configuration keys.

  • operations – Declared operations.

  • module_name – Python module name under plesty/.

  • device_class – Name of the device class to instantiate.

  • param_schema – Project-relative path of the parameter schema, or None for a module that registers everything in Python — there is then no schema for the run to propose corrections to.

  • model – The instrument model implied by the project name. Empty only for a name that implies nothing.

parameters: list[ParamCandidate]
operations: list[OpCandidate]
module_name: str
device_class: str
param_schema: str | None
model: str = ''
resources: list[str] = []
class plesty.sdk.commands.field_test.FieldTestConfig

What a module needs beyond the gates themselves.

Variables:
  • safe_ops – Operations the function and stability gates may call.

  • unsafe_ops – Operations excluded, mapped to why — the more useful half. An operation nobody classified is in neither list and simply never runs.

  • only_keys – Parameters the round-trip gate may write back.

  • drain_probe – The two distinguishable keys the drain gate uses.

  • slow_op – An operation slow enough to be timed out on demand.

  • slow_op_payload – The arguments that make it slow, read off the operation’s own declared inputs. A slow_op without one names a provocation it cannot perform: the gate calls it, the call returns in milliseconds, and the gate skips saying there was nothing to recover from.

  • provoke_restore_keys – Configuration keys the provocation writes, to be put back afterwards. Only the payload arguments the device also declares as parameters — an argument with no matching key cannot be queried or written back.

  • op_kinds – Operation → the fields of OpKind the pipeline drives it by. Filled from the module’s declaration for every allow-listed operation of a kind other than read, completed by the interview for a motion whose position_key or step the module left open. Written into both generated files so the reader sees how a motion is placed and bounded without opening the module.

  • op_payloads – Allow-listed operation → the arguments to call it with. An operation that needs a value nothing can generate — the name of something registered on the device — gets one here; an operation without an entry is called with what the gate generates.

  • expensive_connect – Connecting starts an application.

  • instrument – Model and the interchangeable part fitted.

  • reasons – Answer key → the reason the developer gave, recorded as a comment in the generated file.

safe_ops: list[str] = []
unsafe_ops: dict[str, str]
only_keys: list[str] = []
drain_probe: tuple[str, str] | None = None
slow_op: str | None = None
slow_op_payload: dict[str, Any]
provoke_restore_keys: list[str] = []
op_kinds: dict[str, dict[str, Any]]
op_payloads: dict[str, dict[str, Any]]
expensive_connect: bool = False
instrument: dict[str, str]
reasons: dict[str, str]
answered: list[str] = []
complete: bool = False
to_dict() → dict[str, Any]

Return the answers so far, for an interrupted interview to save.

Return type:

dict[str, Any]

classmethod from_dict(saved: dict[str, Any]) → FieldTestConfig

Rebuild the answers from a saved interview.

Parameters:

saved (dict[str, Any]) – The mapping written by to_dict().

Return type:

FieldTestConfig

class plesty.sdk.commands.field_test.FieldTestScaffold(project_root: pathlib.Path, ask: collections.abc.Callable[Ellipsis, Any])

Build a module’s field-test configuration by asking about its device.

Parameters:
  • project_root (pathlib.Path) – Root directory of the module.

  • ask (collections.abc.Callable[Ellipsis, Any]) – Prompt function. Takes the question text, the kind of answer wanted and the offered choices; returns what the developer said. Injected so the interview can be driven by a test as well as by a terminal.

Store the project root and the prompt function.

project_root
ask
_questions: dict[str, Any] | None = None
_asked = 0
config
candidates() → Candidates

Ask the device itself what it offers.

The device is the only authority. A module may register parameters and operations in Python — register_config in __init__, an @expose_to_api method — and none of that appears in a schema file. doc_model() unifies both, which is why the generated documentation is built from it and why the field test is too.

Two things go wrong when the JSON files are read instead, and both are silent. Anything registered in code is invisible, so the interview never offers it and the field test never touches it — plesty-pm100d alone has two such operations. And for a grouped schema the keys are wrong: get_config_list() answers group_prefix.param_key while the file is keyed by the bare name, so an only_keys list built from the file names parameters the pipeline cannot resolve. That is the group-name against command-prefix mismatch three modules hit independently, and a generator that reproduced it would be worse than no generator.

The device is constructed hardware-free, exactly as plesty docs does it. There is no fallback: a device module that cannot build its doc model already fails gate d3, and generating a field test from a partial picture would produce a file that looks complete and tests less than it claims.

Returns:

Parameters with their types and ranges, and every operation the device offers.

Raises:

ScaffoldError – If the module has no package, cannot be imported, exports no device factory, or will not build a doc model.

Return type:

Candidates

_doc_device(module_name: str) → Any

Construct the module’s hardware-free doc device.

Parameters:

module_name (str) – Python module name under plesty/.

Returns:

The constructed device.

Raises:

ScaffoldError – If the module will not import, exports no factory, or cannot build a doc model without hardware.

Return type:

Any

interview(candidates: Candidates) → FieldTestConfig

Ask only what the schemas cannot answer.

Nothing is assumed safe: an operation the developer does not classify is left out of both lists, and its gate skips saying so.

Parameters:

candidates (Candidates) – What candidates() found.

Returns:

The configuration to render.

Return type:

FieldTestConfig

_ask_safe_ops(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) → None

Ask which operations may be called unattended, and why each one may.

Parameters:
Return type:

None

_ask_motion(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig, declared: dict[str, dict[str, Any]]) → None

Complete what a declared kind leaves open, for every allow-listed operation.

A declared kind is copied into op_kinds as it is, so the generated file says how each operation is driven. A motion needs two things the module may have left to the bench: which parameter reports the part’s position, and how far the test may move it. Neither is guessed; a motion left without them is dropped from the allow-list, since the pipeline would refuse it anyway.

Parameters:
  • questions (dict[str, Any]) – The authored questions.

  • candidates (Candidates) – What the device declares.

  • config (FieldTestConfig) – The answers so far, updated in place.

  • declared (dict[str, dict[str, Any]]) – Operation → declared kind fields, from the candidates.

Return type:

None

_drop_motion(config: FieldTestConfig, name: str, why: str) → None

Take a motion the test cannot place or bound off the allow-list.

Parameters:
Return type:

None

_ask_op_payloads(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) → None

Ask for the arguments an allow-listed operation must be called with.

The gate generates a value for every argument it is not given, which is right for a count or a voltage and wrong for an argument that must name something registered — set_voltage(device=...) called with a random string raises KeyError, and the operation had to be excluded. Motions are not asked: the pipeline derives their arguments from where the part is.

Parameters:
  • questions (dict[str, Any]) – The authored questions.

  • candidates (Candidates) – What the device declares.

  • config (FieldTestConfig) – The answers so far, updated in place.

Return type:

None

_ask_only_keys(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) → None

Ask which parameters survive being written back their own value.

Parameters:
Return type:

None

_ask_drain_probe(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) → None

Ask for two parameters whose values can never be confused.

Parameters:
Return type:

None

_ask_slow_op(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) → None

Ask which call is slow enough to be interrupted on purpose.

Parameters:
Return type:

None

_ask_slow_op_arg(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) → None

Ask which of the slow call’s own arguments can be turned up.

Naming the call is half the provocation; the payload is the other half. Without it the gate calls the operation plainly, it returns in milliseconds, and the gate skips saying there was nothing to recover from — which reads as a healthy device rather than an unconfigured test.

The arguments come off the operation itself, so this is a choice rather than a question in prose.

Parameters:
  • questions (dict[str, Any]) – The authored questions.

  • candidates (Candidates) – What the device declares.

  • config (FieldTestConfig) – The answers so far, updated in place.

Return type:

None

_provocation_value(question: dict[str, Any], arg: ArgCandidate) → Any

Ask what value of arg makes the call slow enough to interrupt.

The declared upper bound is offered as the default: the slowest the operation is allowed to be is the value most certain to outlast the gate’s timeout.

Parameters:
  • question (dict[str, Any]) – The authored question, for its value prompt.

  • arg (ArgCandidate) – The argument chosen as the provocation.

Returns:

The value, converted to the argument’s declared type when it can be. A value that will not convert is kept as text rather than dropped — the generated file is read before it is run.

Return type:

Any

_ask_connect(questions: dict[str, Any], config: FieldTestConfig) → None

Ask whether connecting starts an application.

Parameters:
Return type:

None

_ask_instrument(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) → None

Ask what the published report should say this run was pointed at.

The model is read from the project name, so the usual answer is Enter. It is still asked: the convention is a convention rather than a rule, and a wrong model names the directory every future run of this instrument is filed under.

Parameters:
Return type:

None

render(config: FieldTestConfig, candidates: Candidates) → str

Emit tests/field_test.py — the configuration and its reasons.

Parameters:
Returns:

The file content.

Return type:

str

_may_replace(target: pathlib.Path, force: bool, content: str = '') → bool

Return whether target may be written, asking when it exists.

Regenerating is the normal path now that the answers are kept, so refusing outright and demanding --force turned every ordinary re-run into an error at the very end — after the whole interview had been answered. It asks instead, and only when there is somebody to ask: a non-interactive run still refuses rather than overwriting silently.

Whatever the file has gained since it was generated is named before it goes. A module accumulates hooks for months — which operations the fitted head cannot answer, how to orphan a reply on this driver, which parameter will not survive its own value — and none of that is knowable at generation time, so none of it can be regenerated. Losing it silently is the failure; being told and choosing is not.

Parameters:
  • target (pathlib.Path) – The file about to be written.

  • force (bool) – Replace without asking.

  • content (str) – What would be written, for naming what it does not carry.

Returns:

True when the file may be written.

Return type:

bool

render_client(config: FieldTestConfig, candidates: Candidates) → str

Emit tests/field_test_client.py from the same answers.

One interview, two tiers. The client tier inherits the safety judgement rather than making a second one: an operation classified unsafe here must not be reachable over the wire either, and a second allow-list is a second chance to disagree with the first.

Parameters:
Returns:

The file content.

Return type:

str

_spawn_block(candidates: Candidates) → str

Render the factory --spawn starts a local server from.

A bare device class is the wrong thing to hand the runner: it is constructed with no arguments in a subprocess, and a module whose hardware-free path is address="mock" then tries to open a real instrument and never binds. The probe that plesty init mock-test already uses answers this, so it is reused rather than guessed.

Parameters:

candidates (Candidates) – What the device declared.

Returns:

The block defining SPAWN_FACTORY, or one setting it to None when the module has no hardware-free construction.

Return type:

str

write_client(content: str, force: bool = False) → pathlib.Path

Write tests/field_test_client.py.

Parameters:
  • content (str) – The rendered file.

  • force (bool) – Overwrite an existing file.

Returns:

The path written.

Raises:

ScaffoldError – If the file exists and force is not set.

Return type:

pathlib.Path

property partial_path: pathlib.Path

Where an interrupted interview’s answers are kept.

Return type:

pathlib.Path

save_partial() → pathlib.Path

Write the answers so far, so the next run starts from them.

Parameters:

None.

Returns:

The path written.

Return type:

pathlib.Path

load_partial() → bool

Load a previously interrupted interview, if there is one.

Returns:

True when answers were restored.

Raises:

ScaffoldError – If the saved answers cannot be read. Starting over silently would discard work the developer expected back.

Return type:

bool

ensure_answers() → pathlib.Path

Load the answers file, creating it when the module has none.

Called before every interview, so there is always a file to align with: the interview is a view onto it, and editing it by hand is a supported way to change an answer.

Returns:

The path to the answers file.

Return type:

pathlib.Path

_migrate_legacy_answers() → None

Move answers written before they lived in .plesty/.

Silently, and only when the new location is empty: an interrupted interview from an older SDK is work somebody did, and losing it because a path changed would be the worst kind of upgrade.

Return type:

None

keep_answers() → pathlib.Path

Persist the answers once both tiers are written.

Kept rather than deleted: it is the state the next run starts from, and every question is answered against what it holds.

The cost is worth stating: this file is the source of truth, so an answer hand-edited in the generated .py is overwritten by the next run. Change it here instead.

Returns:

The path written.

Return type:

pathlib.Path

has_saved_answers() → bool

Whether a completed set of answers is on disk.

Returns:

True when a previous run left answers to regenerate from.

Return type:

bool

ensure_env() → list[str]

Make it safe for this module to have a .env, and say what changed.

The generated field test reads its address from .env and writes reports beside the module, so this command is the reason a module needs both rules. Several modules predate the convention: plesty-pm100d had no .env.example, ignored env/ but not .env, and ignored neither form of report. Telling a developer to put a VISA resource string — which contains the instrument’s serial — into a file the repository would commit, and then to write a markdown report carrying the host name beside it, is not a warning to leave for later.

Nothing already present is modified. A missing .env.example is written, and the two ignore rules are appended if absent.

Returns:

One line per change made, for the caller to print.

Return type:

list[str]

_cover_reports_in_reuse() → list[str]

Give reports/** a licence, so gate 9 accepts what a run writes.

reuse lint requires copyright and licensing information for every file, and a JSON document has nowhere to put a comment. Without an annotation the first published report fails gate 9 in the module that produced it — which every module running a field test would hit, so the fix belongs here rather than in each of them.

Returns:

One line if the annotation was added, empty if it was already there or the module has no REUSE.toml.

Return type:

list[str]

next_steps(path: pathlib.Path, config: FieldTestConfig) → str

Say where the file went, how to run it, and where each answer lives.

The map of answer to constant matters more than it looks. A developer who cannot find where an answer landed re-runs the whole interview to change one line, and the second run is where the considered answers get replaced by quick ones.

Parameters:
  • path (pathlib.Path) – The file just written.

  • config (FieldTestConfig) – The answers it was written from.

Returns:

The text to print.

Return type:

str

write(content: str, force: bool = False) → pathlib.Path

Write tests/field_test.py.

Parameters:
  • content (str) – The rendered file.

  • force (bool) – Overwrite an existing file. Off by default: the file holds answers somebody thought about, and a hardware run may since have corrected them.

Returns:

The path written.

Raises:

ScaffoldError – If the file exists and force is not set.

Return type:

pathlib.Path

_present(question: dict[str, Any], detected: bool = False) → None

Print one question: what it is for, then how to choose.

A developer who understands what the answer is used for gives a better one, and that is the entire reason this command asks rather than defaults. The rationale therefore comes before the choices, wrapped to the terminal rather than run out as one long line.

Parameters:
  • question (dict[str, Any]) – The authored question.

  • detected (bool) – Whether a value was read from the module, in which case the prompt says so — pressing Enter is then the usual answer.

Return type:

None

_multi(question: dict[str, Any], offered: collections.abc.Sequence[str], summaries: dict[str, str] | None = None, limit: int | None = None, current: collections.abc.Sequence[str] | None = None) → list[str]

Ask a multiple-choice question, showing what each choice is.

current is what the answers file already holds, offered back as the selection so a re-run is a review rather than a re-decision. A list longer than _MAX_OFFERED is not asked at all: the stored answer is kept and the developer is told where to edit it.

Parameters:
  • question (dict[str, Any])

  • offered (collections.abc.Sequence[str])

  • summaries (dict[str, str] | None)

  • limit (int | None)

  • current (collections.abc.Sequence[str] | None)

Return type:

list[str]

_one(question: dict[str, Any], offered: collections.abc.Sequence[str], summaries: dict[str, str] | None = None, current: str | None = None) → str | None

Ask a single-choice question that may be declined.

current is the stored answer, offered back as the default. As in _multi(), a list longer than _MAX_OFFERED keeps the stored answer rather than being asked.

Parameters:
  • question (dict[str, Any])

  • offered (collections.abc.Sequence[str])

  • summaries (dict[str, str] | None)

  • current (str | None)

Return type:

str | None

_too_long(question: dict[str, Any], offered: collections.abc.Sequence[str], summaries: dict[str, str], stored: collections.abc.Sequence[str]) → None

Say why a list is not being asked, and show the head of it.

Said before the list rather than after: a developer who has just watched two hundred lines scroll past has already stopped reading.

Parameters:
  • question (dict[str, Any]) – The authored question, whose key names the entry in the answers file.

  • offered (collections.abc.Sequence[str]) – The full list.

  • summaries (dict[str, str]) – Per item, what it is.

  • stored (collections.abc.Sequence[str]) – What the answers file already holds for this question.

Return type:

None

_ask_acting_ops(questions: dict[str, Any], chosen: collections.abc.Sequence[str]) → list[str]

Ask which of the chosen operations move something or emit.

Nothing can infer this. The point of asking is narrow: an operation that only reads may be allow-listed with no reason given, while one that acts on the world may not — and the two are indistinguishable from here.

Parameters:
  • questions (dict[str, Any]) – The loaded interview.

  • chosen (collections.abc.Sequence[str]) – The operations just allowed.

Returns:

The subset that acts rather than only reads.

Return type:

list[str]

_require_safeguard(questions: dict[str, Any], config: FieldTestConfig, name: str) → None

Ask for the safeguard, and drop the operation if none is named.

Everywhere else a blank answer is accepted, because a missing reason is a small loss. Here it is not: this operation moves something or emits, and allow-listing it with nothing recorded is how a stage gets driven twenty times by someone who never saw the justification.

Refusing twice removes it from the allow-list rather than blocking the interview. That is the same rule as everywhere else — what is not classified is not called — arrived at from the other direction.

Parameters:
  • questions (dict[str, Any]) – The loaded interview.

  • config (FieldTestConfig) – The configuration being built.

  • name (str) – The operation to justify.

Return type:

None

_reason_intro(count: int, required: int = 0) → None

Warn that a run of one-line questions is starting.

A developer who has just made one selection does not expect ten prompts, and a run of them with no framing reads as the program having lost its place.

Parameters:
  • count (int) – How many reasons will be asked for.

  • required (int) – How many of them cannot be skipped.

Return type:

None

_reason(question: dict[str, Any], item: str, current: str = '') → str

Ask why an answer was given; the reason is recorded in the file.

Parameters:
  • question (dict[str, Any]) – The authored question, for its reason prompt.

  • item (str) – What the reason is about.

  • current (str) – The reason already stored, offered back so Enter keeps it rather than clearing it. Without this a re-run silently emptied every reason the developer had written.

Returns:

The reason given, or the stored one when Enter was pressed.

Return type:

str

_load_questions() → dict[str, Any]

Load the packaged interview (cached).

Return type:

dict[str, Any]

_module_name() → str

Resolve the Python module name of the project being scaffolded.

plesty init infers this from the directory name because there is nothing else yet. Here the module already exists, so the directory name is the worst source: a checkout under a different name, a worktree or a copy would all send the scaffold looking for a package that is not there. The package under plesty/ is asked first, then pyproject.toml, and the directory name only as a last resort.

Raises:

ScaffoldError – If no candidate yields a valid module name.

Return type:

str

_declared_name() → str

Return [project] name from pyproject.toml, or an empty string.

Return type:

str

plesty.sdk.commands.field_test._LINE_LENGTH = 99
plesty.sdk.commands.field_test._comment(text: str, indent: str = '    ', marker: str = '# ') → list[str]

Wrap a developer’s reason into comment lines that fit the line length.

Reasons are written by hand and are as long as the thought needs. Emitting one verbatim produced a 109-character comment and a generated file that failed its own module’s lint. The marker is part of the width: wrapping for "# " and then writing "#: " put a full line at 100 columns.

Parameters:
  • text (str) – The reason, as given.

  • indent (str) – Leading whitespace for each line.

  • marker (str) – What starts each line — "# ", or "#: " for a constant.

Returns:

Comment lines, or an empty list when there is no reason.

Return type:

list[str]

plesty.sdk.commands.field_test._parse_payload(answer: str, known: collections.abc.Sequence[str]) → dict[str, Any]

Read name=value, name=value into a payload.

Each value is read as YAML, so 0.5 is a float, true a bool and LED1 a string. A name the operation does not declare is dropped, with a line saying so: the call would refuse it.

Parameters:
  • answer (str) – What the developer typed.

  • known (collections.abc.Sequence[str]) – The argument names the operation declares.

Returns:

The payload, empty for a blank answer.

Return type:

dict[str, Any]

plesty.sdk.commands.field_test._op_payloads_block(config: FieldTestConfig) → str

Render the arguments each allow-listed operation is called with.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test._safe_ops_block(config: FieldTestConfig) → str

Render the allow-list, each entry carrying the reason it was allowed.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test._unsafe_ops_block(config: FieldTestConfig) → str

Render the exclusions, which are the more useful half of the answer.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test._op_kinds_block(config: FieldTestConfig) → str

Render what each allow-listed operation does, for the pipeline to drive it by.

Plain mappings rather than OpKind instances, so the file reads without an import and a plesty-lib that predates kinds still parses it.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test._only_keys_block(config: FieldTestConfig) → str

Render the round-trip allow-list.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test._drain_probe_block(config: FieldTestConfig) → str

Render the drain gate’s two probe keys.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test._slow_op_block(config: FieldTestConfig) → str

Render the recovery gate’s provocation.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test._param_schema_block(param_schema: str | None) → str

Render the schema path the run compares the instrument against.

Parameters:

param_schema (str | None) – Project-relative schema path, or None when the module registers its parameters in Python.

Return type:

str

plesty.sdk.commands.field_test._from_doc_model(device: Any) → tuple[list[ParamCandidate], list[OpCandidate]]

Read the candidates from a constructed device.

Parameter keys come from get_config_list() rather than from the doc model’s groups, because that is the form the pipeline resolves: a grouped schema answers group_prefix.param_key, and a bare name from the group would be a key the field test cannot look up.

Parameters:

device (Any) – A hardware-free device instance.

Returns:

Parameter and operation candidates.

Return type:

tuple[list[ParamCandidate], list[OpCandidate]]

plesty.sdk.commands.field_test._declared_kind(doc: Any) → dict[str, Any] | None

Read the kind a FuncDoc carries, as plain fields.

Parameters:

doc (Any) – One entry of function_docs().

Returns:

The OpKind fields, or None for a plesty-lib that predates kinds or a module that declared nothing (a bare read).

Return type:

dict[str, Any] | None

plesty.sdk.commands.field_test._would_lose(existing: str, replacement: str) → list[str]

Return top-level names existing defines that replacement does not.

Names, not regions. Splicing hand-written blocks back into a regenerated file needs markers, and markers need every module to have been generated with them — which the modules that matter were not. Reading what each file defines needs nothing, works on files written before this check existed, and answers the only question worth asking before an overwrite: what is here that will not be here afterwards.

Parameters:
  • existing (str) – The file on disk.

  • replacement (str) – What would be written over it.

Returns:

The lost names in the order they appear, empty when nothing is lost or when either file cannot be parsed — a syntactically broken file is not evidence of anything, and refusing to overwrite it would be worse.

Return type:

list[str]

plesty.sdk.commands.field_test._top_level_names(source: str) → list[str]

Return the names defined at module level, in order of appearance.

Parameters:

source (str) – Python source.

Returns:

__all__ moving is not a loss anyone needs warning about.

Return type:

Function, class and assignment target names. Dunders are left out

Raises:

SyntaxError – If source does not parse.

plesty.sdk.commands.field_test._literal(value: Any) → str

Render value as Python source the SDK formatter will not rewrite.

repr quotes strings with apostrophes and ruff rewrites them to double quotes, so a generated file that used it failed the formatter gate the moment it contained one.

Parameters:

value (Any) – The value to render.

Returns:

Its source form.

Return type:

str

plesty.sdk.commands.field_test._matching_config_key(arg: str, candidates: Candidates) → str | None

Return the configuration key arg names, or None if it names none.

An operation declares its inputs by bare name while a grouped schema answers prefix.name, so averaging and MO.averaging are the same setting under two spellings. Comparing the bare forms is what connects them; comparing the full keys silently finds nothing and generates a provocation with nothing to put back.

Parameters:
  • arg (str) – The payload argument chosen as the provocation.

  • candidates (Candidates) – What the device declares.

Returns:

The full configuration key, preferring an exact match over a grouped one, or None when the argument is not a setting.

Return type:

str | None

plesty.sdk.commands.field_test._arg_candidates(doc: Any) → list[ArgCandidate]

Read one operation’s declared inputs, for the provocation question.

Parameters:

doc (Any) – A FuncDoc from function_docs().

Returns:

One candidate per declared input, in declaration order.

Return type:

list[ArgCandidate]

plesty.sdk.commands.field_test._one_line(text: str, limit: int = 76) → str

Collapse text to one line, cut at a word boundary rather than mid-word.

Parameters:
  • text (str) – The description, typically the first line of a docstring.

  • limit (int) – Longest line to emit, before the ellipsis.

Return type:

str

plesty.sdk.commands.field_test._append_missing_variables(example: pathlib.Path, template: str) → list[str]

Add the variables template declares that example does not yet.

Parameters:
  • example (pathlib.Path) – The module’s existing .env.example.

  • template (str) – The packaged example, whose KEY= lines are the ones this command depends on.

Returns:

The variable names appended, in order.

Return type:

list[str]

plesty.sdk.commands.field_test._model_from_project(module_name: str) → str

Return the instrument model the project name implies.

A device project is called plesty-<model>, so plesty-pm100d means a PM100D. Where the name carries more than the model — plesty-amc300-piezo-controller — the first segment containing a digit is the model and the rest describes it: model numbers carry digits and the words around them do not.

Offered as the default the developer accepts or corrects, because this names the directory every future run of that instrument is filed under and the convention is a convention rather than a rule.

Parameters:

module_name (str) – The package under plesty/, which is the project name with its plesty- prefix stripped.

Returns:

The model in upper case, or "" for a name that implies nothing.

Return type:

str

plesty.sdk.commands.field_test._summaries(candidates: Candidates, offered: collections.abc.Sequence[str]) → dict[str, str]

Return what each offered parameter is — its type, unit and range.

Choosing from bare keys means looking each one up somewhere else, which is how a parameter gets picked because its name sounded harmless.

Parameters:
  • candidates (Candidates) – What the device declared.

  • offered (collections.abc.Sequence[str]) – The keys being offered.

Return type:

dict[str, str]

plesty.sdk.commands.field_test._wrap(text: str, indent: str = '  ', marker: str = '') → str

Wrap a paragraph to the terminal, so prose does not become a wall.

Parameters:
  • text (str) – The paragraph, whose existing line breaks are ignored.

  • indent (str) – Prefix for every line.

  • marker (str) – Prefix for the first line only, e.g. an arrow introducing a consequence. Continuation lines are padded to match, so a repeated arrow does not read as a list of three separate remarks.

Return type:

str

plesty.sdk.commands.field_test._block(text: str, indent: str = '  ') → str

Render an authored block, wrapping paragraphs but keeping its bullets.

decide is written as prose plus a bulleted list. The prose is joined into paragraphs first and then wrapped to the terminal — wrapping each authored line on its own leaves the ragged right edge of the source file showing. The bullets are already short and keep their line breaks, or the list stops being a list.

Parameters:
  • text (str) – The authored block.

  • indent (str) – Prefix for every line.

Return type:

str

plesty.sdk.commands.field_test._preflight_import(module_name: str, has_preflight: bool) → str

Return the import of the module’s preflight, or nothing.

Parameters:
  • module_name (str) – The module under plesty/.

  • has_preflight (bool) – Whether plesty/<module>/preflight.py exists.

Return type:

str

plesty.sdk.commands.field_test._preflight_block(has_preflight: bool) → str

Return the preflight() the field test calls before a run.

A module scaffolded since the device template shipped preflight.py runs its own check — the same one python -m runs before it binds. An older module gets a function that says what is missing and lets the run go on, so plesty init field-test keeps working on every module the hub already has.

Parameters:

has_preflight (bool) – Whether plesty/<module>/preflight.py exists.

Return type:

str

plesty.sdk.commands.field_test._asset(name: str) → Any

Return a packaged asset under assets/init/.

Parameters:

name (str)

Return type:

Any

plesty.sdk.commands.field_test.terminal_ask(prompt: str, kind: str = 'text', key: str = '', choices: collections.abc.Sequence[str] | None = None, default: Any = None, summaries: dict[str, str] | None = None, when_empty: str = 'nothing selected', limit: int | None = None) → Any

Ask one question at a terminal.

Choices are offered as a checkable list when there is a keyboard: the options stay on screen while you decide, what is ticked is visible, and a mistyped digit cannot select something you never looked at. A piped or redirected run has no keyboard, so it falls back to typed numbers.

Parameters:
  • prompt (str) – The label shown at the input line. Short for a question the presenter has already printed in full; the whole question for a reason or an exclusion, which have no heading above them.

  • kind (str) – multi, one, yesno, text, or pause — wait for a keypress and return nothing.

  • key (str) – Stable identifier for the question, ignored here. It exists so a caller can tell questions apart without matching on the text a developer reads, which changes whenever the wording improves.

  • choices (collections.abc.Sequence[str] | None) – Offered items for multi and one.

  • default (Any) – Value returned when the developer just presses Enter.

  • summaries (dict[str, str] | None) – Per choice, what it is — the type and range of a parameter, the first line of an operation’s description.

  • when_empty (str) – What an empty selection means for this question, shown live while it is empty.

  • limit (int | None) – Maximum selections for multi.

Returns:

A list of names for multi, a name or None for one, a bool for yesno, a string otherwise.

Return type:

Any

plesty.sdk.commands.field_test._typed_selection(offered: collections.abc.Sequence[str], summaries: dict[str, str], kind: str, limit: int | None) → list[str]

Ask for the choices as typed numbers, for a run with no keyboard.

Parameters:
  • offered (collections.abc.Sequence[str]) – The choices, in order.

  • summaries (dict[str, str]) – Per choice, what it is.

  • kind (str) – multi or one.

  • limit (int | None) – Maximum selections.

Return type:

list[str]

plesty.sdk.commands.field_test._parse_selection(answer: str, offered: collections.abc.Sequence[str]) → list[str]

Turn a comma-separated list of numbers into the names they select.

Unparseable and out-of-range entries are dropped rather than guessed at: the whole point of asking is that a wrong answer here calls something the developer did not mean to call.

Parameters:
  • answer (str) – What was typed.

  • offered (collections.abc.Sequence[str]) – The numbered choices, in order.

Return type:

list[str]

plesty.sdk.commands.field_test.scaffold_field_test(project_root: pathlib.Path, force: bool = False) → pathlib.Path

Run the interview and write tests/field_test.py.

Ctrl-C saves the answers given so far rather than throwing them away. These questions take thought — which operations are safe to run unsupervised, which safeguard makes a motion command repeatable — and a developer who has to leave the bench mid-interview should not have to reconstruct that thinking later. Each answer is written to .plesty/field-test.json as it is given, so running the command again carries on from what is already there.

Parameters:
  • project_root (pathlib.Path) – Root directory of the device module.

  • force (bool) – Replace an existing field test.

Returns:

The path written.

Raises:

ScaffoldError – If the module cannot be read, or the answers cannot be.

Return type:

pathlib.Path

plesty.sdk.commands.field_test._client_params_block(config: FieldTestConfig, candidates: Candidates) → str

Render the keys the client tier round-trips over the wire.

Parameters:
Return type:

str

plesty.sdk.commands.field_test._client_types_block(config: FieldTestConfig, candidates: Candidates) → str

Render the declared type of each round-tripped key.

The timeout gate needs these: a reply belonging to another parameter is invisible when both are floats.

Parameters:
Return type:

str

plesty.sdk.commands.field_test._client_resources_block(candidates: Candidates) → str

Render the lockable resources the allocation gate contends for.

Parameters:

candidates (Candidates)

Return type:

str

plesty.sdk.commands.field_test._client_safe_ops_block(config: FieldTestConfig) → str

Render the operation allow-list, inherited from the host tier.

Parameters:

config (FieldTestConfig)

Return type:

str

plesty.sdk.commands.field_test.credit(content: str) → str

Add the author’s SPDX lines to a generated file.

Gate 9 rejects a commit whose author is not credited on the files it changed, so a generated file with only the project’s copyright line blocks the push that would deliver it — after the developer has answered the whole interview. The author comes from git, which is where the commit identity comes from too.

Parameters:

content (str) – The rendered file.

Returns:

The file with the author credited, or unchanged when git has no identity configured to credit.

Return type:

str

plesty.sdk.commands.field_test._git_identity() → str

Return the configured git user name, or an empty string.

Returns:

user.name from git config, empty when unset or git is absent.

Return type:

str

plesty.sdk.commands.field_test.format_generated(target: pathlib.Path) → None

Format a generated file with the SDK’s own ruff configuration.

Without this every generated file fails gate 2 the first time somebody pushes it, which is the wrong moment to discover it. Best-effort: a formatter that cannot run is not a reason to lose the file.

Parameters:

target (pathlib.Path) – The file just written.

Return type:

None