plesty.sdk.commands.field_test ============================== .. py:module:: plesty.sdk.commands.field_test .. autoapi-nested-parse:: 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 ---------- .. autoapisummary:: plesty.sdk.commands.field_test._WRITABLE_TYPES plesty.sdk.commands.field_test._CONFIGURE_EXCLUSION plesty.sdk.commands.field_test._MAX_EXCLUSIONS_ASKED plesty.sdk.commands.field_test._IDENTIFIER plesty.sdk.commands.field_test._MAX_OFFERED plesty.sdk.commands.field_test.STATE_DIRNAME plesty.sdk.commands.field_test.ANSWERS_FILENAME plesty.sdk.commands.field_test._LEGACY_PARTIAL_FILENAME plesty.sdk.commands.field_test._LINE_LENGTH Exceptions ---------- .. autoapisummary:: plesty.sdk.commands.field_test.ScaffoldError Classes ------- .. autoapisummary:: plesty.sdk.commands.field_test.ParamCandidate plesty.sdk.commands.field_test.ArgCandidate plesty.sdk.commands.field_test.OpCandidate plesty.sdk.commands.field_test.Candidates plesty.sdk.commands.field_test.FieldTestConfig plesty.sdk.commands.field_test.FieldTestScaffold Functions --------- .. autoapisummary:: plesty.sdk.commands.field_test._comment plesty.sdk.commands.field_test._parse_payload plesty.sdk.commands.field_test._op_payloads_block plesty.sdk.commands.field_test._safe_ops_block plesty.sdk.commands.field_test._unsafe_ops_block plesty.sdk.commands.field_test._op_kinds_block plesty.sdk.commands.field_test._only_keys_block plesty.sdk.commands.field_test._drain_probe_block plesty.sdk.commands.field_test._slow_op_block plesty.sdk.commands.field_test._param_schema_block plesty.sdk.commands.field_test._from_doc_model plesty.sdk.commands.field_test._declared_kind plesty.sdk.commands.field_test._would_lose plesty.sdk.commands.field_test._top_level_names plesty.sdk.commands.field_test._literal plesty.sdk.commands.field_test._matching_config_key plesty.sdk.commands.field_test._arg_candidates plesty.sdk.commands.field_test._one_line plesty.sdk.commands.field_test._append_missing_variables plesty.sdk.commands.field_test._model_from_project plesty.sdk.commands.field_test._summaries plesty.sdk.commands.field_test._wrap plesty.sdk.commands.field_test._block plesty.sdk.commands.field_test._preflight_import plesty.sdk.commands.field_test._preflight_block plesty.sdk.commands.field_test._asset plesty.sdk.commands.field_test.terminal_ask plesty.sdk.commands.field_test._typed_selection plesty.sdk.commands.field_test._parse_selection plesty.sdk.commands.field_test.scaffold_field_test plesty.sdk.commands.field_test._client_params_block plesty.sdk.commands.field_test._client_types_block plesty.sdk.commands.field_test._client_resources_block plesty.sdk.commands.field_test._client_safe_ops_block plesty.sdk.commands.field_test.credit plesty.sdk.commands.field_test._git_identity plesty.sdk.commands.field_test.format_generated Module Contents --------------- .. py:data:: _WRITABLE_TYPES :value: ('float', 'int', 'bool', 'str') .. py:data:: _CONFIGURE_EXCLUSION :value: 'configure: changes a setting that outlives the call; the round-trip gate writes settings back... .. py:data:: _MAX_EXCLUSIONS_ASKED :value: 12 .. py:data:: _IDENTIFIER .. py:data:: _MAX_OFFERED :value: 10 .. py:data:: STATE_DIRNAME :value: '.plesty' .. py:data:: ANSWERS_FILENAME :value: 'field-test.json' .. py:data:: _LEGACY_PARTIAL_FILENAME :value: '.plesty-field-test.partial.json' .. py:exception:: ScaffoldError(message: str) Bases: :py:obj:`click.ClickException` The module does not carry what a field test needs to be generated. Initialize self. See help(type(self)) for accurate signature. .. py:class:: ParamCandidate One configuration key the module declares, as the interview offers it. :ivar key: Configuration key. :ivar dtype: Declared type name. :ivar summary: One line for the chooser — type, unit, range or options. :ivar read_only: Declared read-only; the round-trip gate queries it only. :ivar write_only: Declared write-only; the round-trip gate skips it. .. py:attribute:: key :type: str .. py:attribute:: dtype :type: str .. py:attribute:: summary :type: str .. py:attribute:: read_only :type: bool :value: False .. py:attribute:: write_only :type: bool :value: False .. py:property:: round_trippable :type: bool ``True`` when the round-trip gate could write this key back. .. py:class:: 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. :ivar name: Argument name, as the payload key. :ivar dtype: Declared type name. :ivar summary: One line for the chooser — type, unit, range or options. :ivar bounds: Declared ``(low, high)``, or ``None``. The high end is what makes a call slowest, so it is what the value prompt offers. .. py:attribute:: name :type: str .. py:attribute:: dtype :type: str .. py:attribute:: summary :type: str .. py:attribute:: bounds :type: tuple[Any, Any] | None :value: None .. py:class:: OpCandidate One operation the module declares. :ivar name: Operation name. :ivar summary: One line for the chooser — its description, truncated. :ivar 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". :ivar 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. :ivar 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. .. py:attribute:: name :type: str .. py:attribute:: summary :type: str .. py:attribute:: declared_safe :type: bool :value: False .. py:attribute:: iparams :type: tuple[ArgCandidate, Ellipsis] :value: () .. py:attribute:: kind :type: dict[str, Any] | None :value: None .. py:property:: kind_name :type: str The declared kind, ``read`` when nothing was declared. .. py:class:: Candidates Everything the device itself declares. :ivar parameters: Declared configuration keys. :ivar operations: Declared operations. :ivar module_name: Python module name under ``plesty/``. :ivar device_class: Name of the device class to instantiate. :ivar 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. :ivar model: The instrument model implied by the project name. Empty only for a name that implies nothing. .. py:attribute:: parameters :type: list[ParamCandidate] .. py:attribute:: operations :type: list[OpCandidate] .. py:attribute:: module_name :type: str .. py:attribute:: device_class :type: str .. py:attribute:: param_schema :type: str | None .. py:attribute:: model :type: str :value: '' .. py:attribute:: resources :type: list[str] :value: [] .. py:class:: FieldTestConfig What a module needs beyond the gates themselves. :ivar safe_ops: Operations the function and stability gates may call. :ivar unsafe_ops: Operations excluded, mapped to why — the more useful half. An operation nobody classified is in neither list and simply never runs. :ivar only_keys: Parameters the round-trip gate may write back. :ivar drain_probe: The two distinguishable keys the drain gate uses. :ivar slow_op: An operation slow enough to be timed out on demand. :ivar 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. :ivar 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. :ivar 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. :ivar 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. :ivar expensive_connect: Connecting starts an application. :ivar instrument: Model and the interchangeable part fitted. :ivar reasons: Answer key → the reason the developer gave, recorded as a comment in the generated file. .. py:attribute:: safe_ops :type: list[str] :value: [] .. py:attribute:: unsafe_ops :type: dict[str, str] .. py:attribute:: only_keys :type: list[str] :value: [] .. py:attribute:: drain_probe :type: tuple[str, str] | None :value: None .. py:attribute:: slow_op :type: str | None :value: None .. py:attribute:: slow_op_payload :type: dict[str, Any] .. py:attribute:: provoke_restore_keys :type: list[str] :value: [] .. py:attribute:: op_kinds :type: dict[str, dict[str, Any]] .. py:attribute:: op_payloads :type: dict[str, dict[str, Any]] .. py:attribute:: expensive_connect :type: bool :value: False .. py:attribute:: instrument :type: dict[str, str] .. py:attribute:: reasons :type: dict[str, str] .. py:attribute:: answered :type: list[str] :value: [] .. py:attribute:: complete :type: bool :value: False .. py:method:: to_dict() -> dict[str, Any] Return the answers so far, for an interrupted interview to save. .. py:method:: from_dict(saved: dict[str, Any]) -> FieldTestConfig :classmethod: Rebuild the answers from a saved interview. :param saved: The mapping written by :meth:`to_dict`. .. py:class:: FieldTestScaffold(project_root: pathlib.Path, ask: collections.abc.Callable[Ellipsis, Any]) Build a module's field-test configuration by asking about its device. :param project_root: Root directory of the module. :param ask: 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. .. py:attribute:: project_root .. py:attribute:: ask .. py:attribute:: _questions :type: dict[str, Any] | None :value: None .. py:attribute:: _asked :value: 0 .. py:attribute:: config .. py:method:: 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. .. py:method:: _doc_device(module_name: str) -> Any Construct the module's hardware-free doc device. :param module_name: 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. .. py:method:: 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. :param candidates: What :meth:`candidates` found. :returns: The configuration to render. .. py:method:: _ask_safe_ops(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) -> None Ask which operations may be called unattended, and why each one may. .. py:method:: _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. :param questions: The authored questions. :param candidates: What the device declares. :param config: The answers so far, updated in place. :param declared: Operation → declared kind fields, from the candidates. .. py:method:: _drop_motion(config: FieldTestConfig, name: str, why: str) -> None Take a motion the test cannot place or bound off the allow-list. .. py:method:: _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. :param questions: The authored questions. :param candidates: What the device declares. :param config: The answers so far, updated in place. .. py:method:: _ask_only_keys(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) -> None Ask which parameters survive being written back their own value. .. py:method:: _ask_drain_probe(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) -> None Ask for two parameters whose values can never be confused. .. py:method:: _ask_slow_op(questions: dict[str, Any], candidates: Candidates, config: FieldTestConfig) -> None Ask which call is slow enough to be interrupted on purpose. .. py:method:: _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. :param questions: The authored questions. :param candidates: What the device declares. :param config: The answers so far, updated in place. .. py:method:: _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. :param question: The authored question, for its value prompt. :param arg: 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. .. py:method:: _ask_connect(questions: dict[str, Any], config: FieldTestConfig) -> None Ask whether connecting starts an application. .. py:method:: _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. .. py:method:: render(config: FieldTestConfig, candidates: Candidates) -> str Emit ``tests/field_test.py`` — the configuration and its reasons. :param config: The answers given. :param candidates: What the schemas offered. :returns: The file content. .. py:method:: _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. :param target: The file about to be written. :param force: Replace without asking. :param content: What would be written, for naming what it does not carry. :returns: ``True`` when the file may be written. .. py:method:: 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. :param config: The answers given. :param candidates: What the device declared. :returns: The file content. .. py:method:: _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. :param 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. .. py:method:: write_client(content: str, force: bool = False) -> pathlib.Path Write ``tests/field_test_client.py``. :param content: The rendered file. :param force: Overwrite an existing file. :returns: The path written. :raises ScaffoldError: If the file exists and *force* is not set. .. py:property:: partial_path :type: pathlib.Path Where an interrupted interview's answers are kept. .. py:method:: save_partial() -> pathlib.Path Write the answers so far, so the next run starts from them. :param None.: :returns: The path written. .. py:method:: 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. .. py:method:: 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. .. py:method:: _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. .. py:method:: 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. .. py:method:: has_saved_answers() -> bool Whether a completed set of answers is on disk. :returns: ``True`` when a previous run left answers to regenerate from. .. py:method:: 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. .. py:method:: _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``. .. py:method:: 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. :param path: The file just written. :param config: The answers it was written from. :returns: The text to print. .. py:method:: write(content: str, force: bool = False) -> pathlib.Path Write ``tests/field_test.py``. :param content: The rendered file. :param force: 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. .. py:method:: _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. :param question: The authored question. :param detected: Whether a value was read from the module, in which case the prompt says so — pressing Enter is then the usual answer. .. py:method:: _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 :data:`_MAX_OFFERED` is not asked at all: the stored answer is kept and the developer is told where to edit it. .. py:method:: _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 :meth:`_multi`, a list longer than :data:`_MAX_OFFERED` keeps the stored answer rather than being asked. .. py:method:: _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. :param question: The authored question, whose ``key`` names the entry in the answers file. :param offered: The full list. :param summaries: Per item, what it is. :param stored: What the answers file already holds for this question. .. py:method:: _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. :param questions: The loaded interview. :param chosen: The operations just allowed. :returns: The subset that acts rather than only reads. .. py:method:: _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. :param questions: The loaded interview. :param config: The configuration being built. :param name: The operation to justify. .. py:method:: _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. :param count: How many reasons will be asked for. :param required: How many of them cannot be skipped. .. py:method:: _reason(question: dict[str, Any], item: str, current: str = '') -> str Ask why an answer was given; the reason is recorded in the file. :param question: The authored question, for its reason prompt. :param item: What the reason is about. :param current: 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. .. py:method:: _load_questions() -> dict[str, Any] Load the packaged interview (cached). .. py:method:: _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. .. py:method:: _declared_name() -> str Return ``[project] name`` from ``pyproject.toml``, or an empty string. .. py:data:: _LINE_LENGTH :value: 99 .. py:function:: _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. :param text: The reason, as given. :param indent: Leading whitespace for each line. :param marker: What starts each line — ``"# "``, or ``"#: "`` for a constant. :returns: Comment lines, or an empty list when there is no reason. .. py:function:: _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. :param answer: What the developer typed. :param known: The argument names the operation declares. :returns: The payload, empty for a blank answer. .. py:function:: _op_payloads_block(config: FieldTestConfig) -> str Render the arguments each allow-listed operation is called with. .. py:function:: _safe_ops_block(config: FieldTestConfig) -> str Render the allow-list, each entry carrying the reason it was allowed. .. py:function:: _unsafe_ops_block(config: FieldTestConfig) -> str Render the exclusions, which are the more useful half of the answer. .. py:function:: _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. .. py:function:: _only_keys_block(config: FieldTestConfig) -> str Render the round-trip allow-list. .. py:function:: _drain_probe_block(config: FieldTestConfig) -> str Render the drain gate's two probe keys. .. py:function:: _slow_op_block(config: FieldTestConfig) -> str Render the recovery gate's provocation. .. py:function:: _param_schema_block(param_schema: str | None) -> str Render the schema path the run compares the instrument against. :param param_schema: Project-relative schema path, or ``None`` when the module registers its parameters in Python. .. py:function:: _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. :param device: A hardware-free device instance. :returns: Parameter and operation candidates. .. py:function:: _declared_kind(doc: Any) -> dict[str, Any] | None Read the kind a ``FuncDoc`` carries, as plain fields. :param doc: 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``). .. py:function:: _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. :param existing: The file on disk. :param replacement: 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. .. py:function:: _top_level_names(source: str) -> list[str] Return the names defined at module level, in order of appearance. :param source: Python source. :returns: ``__all__`` moving is not a loss anyone needs warning about. :rtype: Function, class and assignment target names. Dunders are left out :raises SyntaxError: If *source* does not parse. .. py:function:: _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. :param value: The value to render. :returns: Its source form. .. py:function:: _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. :param arg: The payload argument chosen as the provocation. :param 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. .. py:function:: _arg_candidates(doc: Any) -> list[ArgCandidate] Read one operation's declared inputs, for the provocation question. :param doc: A ``FuncDoc`` from ``function_docs()``. :returns: One candidate per declared input, in declaration order. .. py:function:: _one_line(text: str, limit: int = 76) -> str Collapse *text* to one line, cut at a word boundary rather than mid-word. :param text: The description, typically the first line of a docstring. :param limit: Longest line to emit, before the ellipsis. .. py:function:: _append_missing_variables(example: pathlib.Path, template: str) -> list[str] Add the variables *template* declares that *example* does not yet. :param example: The module's existing ``.env.example``. :param template: The packaged example, whose ``KEY=`` lines are the ones this command depends on. :returns: The variable names appended, in order. .. py:function:: _model_from_project(module_name: str) -> str Return the instrument model the project name implies. A device project is called ``plesty-``, 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. :param module_name: 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. .. py:function:: _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. :param candidates: What the device declared. :param offered: The keys being offered. .. py:function:: _wrap(text: str, indent: str = ' ', marker: str = '') -> str Wrap a paragraph to the terminal, so prose does not become a wall. :param text: The paragraph, whose existing line breaks are ignored. :param indent: Prefix for every line. :param marker: 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. .. py:function:: _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. :param text: The authored block. :param indent: Prefix for every line. .. py:function:: _preflight_import(module_name: str, has_preflight: bool) -> str Return the import of the module's preflight, or nothing. :param module_name: The module under ``plesty/``. :param has_preflight: Whether ``plesty//preflight.py`` exists. .. py:function:: _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. :param has_preflight: Whether ``plesty//preflight.py`` exists. .. py:function:: _asset(name: str) -> Any Return a packaged asset under ``assets/init/``. .. py:function:: 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. :param prompt: 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. :param kind: ``multi``, ``one``, ``yesno``, ``text``, or ``pause`` — wait for a keypress and return nothing. :param key: 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. :param choices: Offered items for ``multi`` and ``one``. :param default: Value returned when the developer just presses Enter. :param summaries: Per choice, what it is — the type and range of a parameter, the first line of an operation's description. :param when_empty: What an empty selection means for this question, shown live while it is empty. :param limit: Maximum selections for ``multi``. :returns: A list of names for ``multi``, a name or ``None`` for ``one``, a bool for ``yesno``, a string otherwise. .. py:function:: _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. :param offered: The choices, in order. :param summaries: Per choice, what it is. :param kind: ``multi`` or ``one``. :param limit: Maximum selections. .. py:function:: _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. :param answer: What was typed. :param offered: The numbered choices, in order. .. py:function:: 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. :param project_root: Root directory of the device module. :param force: Replace an existing field test. :returns: The path written. :raises ScaffoldError: If the module cannot be read, or the answers cannot be. .. py:function:: _client_params_block(config: FieldTestConfig, candidates: Candidates) -> str Render the keys the client tier round-trips over the wire. .. py:function:: _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. .. py:function:: _client_resources_block(candidates: Candidates) -> str Render the lockable resources the allocation gate contends for. .. py:function:: _client_safe_ops_block(config: FieldTestConfig) -> str Render the operation allow-list, inherited from the host tier. .. py:function:: 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. :param content: The rendered file. :returns: The file with the author credited, or unchanged when git has no identity configured to credit. .. py:function:: _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. .. py:function:: 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. :param target: The file just written.