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
Exceptions
The module does not carry what a field test needs to be generated. |
Classes
One configuration key the module declares, as the interview offers it. |
|
One input an operation declares, as the interview offers it. |
|
One operation the module declares. |
|
Everything the device itself declares. |
|
What a module needs beyond the gates themselves. |
|
Build a module's field-test configuration by asking about its device. |
Functions
|
Wrap a developer's reason into comment lines that fit the line length. |
|
Read |
|
Render the arguments each allow-listed operation is called with. |
|
Render the allow-list, each entry carrying the reason it was allowed. |
|
Render the exclusions, which are the more useful half of the answer. |
|
Render what each allow-listed operation does, for the pipeline to drive it by. |
|
Render the round-trip allow-list. |
|
Render the drain gate's two probe keys. |
|
Render the recovery gate's provocation. |
|
Render the schema path the run compares the instrument against. |
|
Read the candidates from a constructed device. |
|
Read the kind a |
|
Return top-level names existing defines that replacement does not. |
|
Return the names defined at module level, in order of appearance. |
|
Render value as Python source the SDK formatter will not rewrite. |
|
Return the configuration key arg names, or |
|
Read one operation's declared inputs, for the provocation question. |
|
Collapse text to one line, cut at a word boundary rather than mid-word. |
|
Add the variables template declares that example does not yet. |
|
Return the instrument model the project name implies. |
|
Return what each offered parameter is — its type, unit and range. |
|
Wrap a paragraph to the terminal, so prose does not become a wall. |
|
Render an authored block, wrapping paragraphs but keeping its bullets. |
|
Return the import of the module's preflight, or nothing. |
|
Return the |
|
Return a packaged asset under |
|
Ask one question at a terminal. |
|
Ask for the choices as typed numbers, for a run with no keyboard. |
|
Turn a comma-separated list of numbers into the names they select. |
|
Run the interview and write |
|
Render the keys the client tier round-trips over the wire. |
|
Render the declared type of each round-tripped key. |
|
Render the lockable resources the allocation gate contends for. |
|
Render the operation allow-list, inherited from the host tier. |
|
Add the author's SPDX lines to a generated file. |
|
Return the configured git user name, or an empty string. |
|
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.ClickExceptionThe 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
Truewhen 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), orNone. 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:safein 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), orNonewhen the module — or its plesty-lib — declares nothing. A declared kind pre-fills the classification the interview would otherwise ask for, and amotionone 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,
readwhen 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
Nonefor 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
OpKindthe pipeline drives it by. Filled from the module’s declaration for every allow-listed operation of a kind other thanread, completed by the interview for a motion whoseposition_keyorstepthe 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:
- 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_configin__init__, an@expose_to_apimethod — 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()answersgroup_prefix.param_keywhile the file is keyed by the bare name, so anonly_keyslist 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 docsdoes 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:
- _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:
- _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:
questions (dict[str, Any])
candidates (Candidates)
config (FieldTestConfig)
- 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_kindsas it is, so the generated file says how each operation is driven. Amotionneeds 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:
config (FieldTestConfig)
name (str)
why (str)
- 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 raisesKeyError, 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:
questions (dict[str, Any])
candidates (Candidates)
config (FieldTestConfig)
- 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:
questions (dict[str, Any])
candidates (Candidates)
config (FieldTestConfig)
- 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:
questions (dict[str, Any])
candidates (Candidates)
config (FieldTestConfig)
- 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:
questions (dict[str, Any])
config (FieldTestConfig)
- 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:
questions (dict[str, Any])
candidates (Candidates)
config (FieldTestConfig)
- Return type:
None
- render(config: FieldTestConfig, candidates: Candidates) str
Emit
tests/field_test.py— the configuration and its reasons.- Parameters:
config (FieldTestConfig) – The answers given.
candidates (Candidates) – What the schemas offered.
- 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
--forceturned 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:
Truewhen the file may be written.- Return type:
bool
- render_client(config: FieldTestConfig, candidates: Candidates) str
Emit
tests/field_test_client.pyfrom 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:
config (FieldTestConfig) – The answers given.
candidates (Candidates) – What the device declared.
- Returns:
The file content.
- Return type:
str
- _spawn_block(candidates: Candidates) str
Render the factory
--spawnstarts 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 thatplesty init mock-testalready 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 toNonewhen 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:
Truewhen 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
.pyis 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:
Truewhen 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
.envand writes reports beside the module, so this command is the reason a module needs both rules. Several modules predate the convention:plesty-pm100dhad no.env.example, ignoredenv/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.exampleis 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 lintrequires 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_OFFEREDis 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_OFFEREDkeeps 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
keynames 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 initinfers 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 underplesty/is asked first, thenpyproject.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] namefrompyproject.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=valueinto a payload.Each value is read as YAML, so
0.5is a float,truea bool andLED1a 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
OpKindinstances, 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
Nonewhen 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 answersgroup_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
FuncDoccarries, as plain fields.- Parameters:
doc (Any) – One entry of
function_docs().- Returns:
The
OpKindfields, orNonefor a plesty-lib that predates kinds or a module that declared nothing (a bareread).- 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.
reprquotes 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
Noneif it names none.An operation declares its inputs by bare name while a grouped schema answers
prefix.name, soaveragingandMO.averagingare 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
Nonewhen 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
FuncDocfromfunction_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>, soplesty-pm100dmeans 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 itsplesty-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.
decideis 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.pyexists.
- 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.pyruns its own check — the same onepython -mruns before it binds. An older module gets a function that says what is missing and lets the run go on, soplesty init field-testkeeps working on every module the hub already has.- Parameters:
has_preflight (bool) – Whether
plesty/<module>/preflight.pyexists.- 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, orpause— 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
multiandone.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 orNoneforone, a bool foryesno, 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) –
multiorone.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.jsonas 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:
config (FieldTestConfig)
candidates (Candidates)
- 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:
config (FieldTestConfig)
candidates (Candidates)
- 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.namefrom 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