Skip to content

py-strictcli@v0.44.0

Choose a tag to compare

@smm-h smm-h released this 29 Sep 17:45
· 167 commits to main since this release

Framework-owned exits and output, --json interface_version 3 with a stdout guard, check values with failing-checks, declared runtime requirements, help and version commands, and stricter flag and naming rules.

Context

The three implementations move together toward one behavior contract. The framework now owns a command's whole exit path: an early exit raised from anywhere in the call stack, the answer written through ctx.out, the --json document's new output member (interface_version 3), a runtime guard that fails a machine-mode run when anything writes to stdout outside the framework, signal handling, and a --lint-framework-use scan that finds the bypasses statically. Checks gain per-check values, a failing-checks command, and named hook selections; commands gain declared runtime requirements; every app answers help and version commands, and help --json replaces --dump-schema. Several changes are breaking, which in 0.x is a minor: one naming rule refused at declaration, a non-repeatable flag given twice is refused instead of silently keeping the last value, a repeatable global given before and after the command collects every occurrence, check --ignore-warnings is removed in favor of failing-checks, warnings and errors carry prefixes in human mode, and a spawned child still running when its handler ends is killed.

Breaking

  • [strictcli] ctx.out and the output member. ctx.out(text) writes a command's answer to stdout, never hidden by --quiet; under --json the same bytes go into the document's new output member, and interface_version is now 3. Consumers validating the document's key set must accept output.
  • [strictcli] Prefixed warnings and errors. In human mode ctx.error prints error: <message> and ctx.warn prints warning: <message> on stderr; --json diagnostics stay unprefixed. A handler that spelled its own error: prefix now prints it twice.
  • [strictcli] Runtime stdout guard under --json. While a handler runs in machine mode, stdout is redirected: any byte written outside the framework (a print, os.write(1, ...), an unmanaged child) fails the run with exit 1 and a diagnostic naming the bytes, and os._exit ends the run through the framework. Owns-stdout commands must write their document through ctx.document().
  • [strictcli] A child started through ctx.effects.spawn that is still running when its handler ends is now killed (SIGTERM, then SIGKILL after one second) and fails the run: exit 1 unless the status is already nonzero, plus error: child process <pid> was still running when the handler ended and was killed: <argv>. The exit step no longer waits for such a child. Wait on every child you spawn, or end it with the new Spawned.kill(); Spawned.send_signal(signum) sends any signal.
  • [strictcli] check --ignore-warnings and run_checks(ignore_warnings=) are removed: check stays the full report and exits nonzero on warnings, and the new failing-checks command (same selection flags) is the run where only error-level failures fail. Every [checks.<name>] in checks.toml must now declare description (one non-empty line) and subject (the options subject file its option belongs in: lowercase letters, digits, and hyphens, never manifest); a checks.toml lacking either is refused at construction with checks.toml: check "<name>": missing required field "description" (or "subject"), so add both to every declared check. --dump-schema publishes them on each check entry.
  • [strictcli] Names follow one rule, refused where they are declared: command, group, deprecated-command, and long flag names (at every level, globals and choice scopes included), choice, constraint, tag, check, hook, grant, and update-resource names must be lowercase kebab-case of at least two characters ([a-z][a-z0-9]*(-[a-z0-9]+)*), and a short= must be one ASCII letter (uppercase allowed). help and version are framework command names, refused for any command, group, or deprecated command at any depth. Rename a violating name, for example @strictcli.flag("m", short="m", ...) to @strictcli.flag("message", short="m", ...); positional argument names and the App name are unaffected.
  • [strictcli] --dump-schema is replaced by <app> help --json, which prints the help document on stdout (the --json document goes to stderr) and writes no file: produce a committed schema with <app> help --json > .strictcli/schema.json. App(schema_path=...) is removed, project_id now names the project holding the module that constructed the App (the installed distribution that records it, or in a checkout the nearest pyproject.toml's [project] name) rather than the pyproject.toml in the working directory, and passing --dump-schema is refused naming help --json. --help and --version stay text only: with --json they are refused naming the command that prints the machine form. The root and group help pages now end with Use '<app> help <command>' for more information.
  • [strictcli] Breaking: a flag that is not repeatable is refused when it is given more than once. --commits a --commits b used to keep only b silently; it now exits 1 with --commits: given more than once, as '--commits a' and '--commits b'; it takes one value. Every spelling counts -- --commits=a, a short alias, and for a bool --cache given twice or beside --no-cache -- and so do flags inside a choice's scope, member flags, a global given before and after the command, a repeated --unset-<prop>, and the reserved --config. Repeatable, list, and dict flags still collect every occurrence, and environment variables and config files are sources, not occurrences, so their precedence is unchanged. A script or test that passes a flag twice to override an earlier value must pass it once.
  • [strictcli] Breaking: a repeatable global flag given both before and after the command collects every occurrence. app --label a run --label b used to deliver only b, silently dropping a; it now delivers [a, b], in command-line order, exactly as if every occurrence had been typed on one side -- so unique refuses a value repeated across the command name. This applies to repeatable, list, and dict globals; a global that is not repeatable is still refused when given on both sides. Environment variables and config files are sources, not occurrences: they are never joined to command-line values, and an occurrence on either side still replaces them. A script that repeated a global after the command to override the value before it must pass it once.

Features

  • [strictcli] Early exit. strictcli.exit_now(code, message) ends a command from anywhere in its call stack through the framework: finally blocks run, the message is printed as error: <message> or recorded in the --json diagnostics, and a dry run still renders its would-do log. app.call() raises strictcli.ExitError with the code and the message, and MCP answers exit code <code>: <message>.
  • [strictcli] Payload renderers. A command with a payload_schema= may declare payload_renderer=; human mode prints the rendering of the supplied payload, --json emits only the payload.
  • [strictcli] Document writer. ctx.document() gives an owns_stdout=True command a binary writer to the real stdout, unchanged in both modes.
  • [strictcli] Signals. app.run() catches SIGINT and SIGTERM while the handler runs and sets ctx.canceled; when the handler returns, the command exits 130 or 143 with error: canceled by signal <SIGNAL>. A second signal gets the default action.
  • [strictcli] --lint-framework-use. Every app reserves --lint-framework-use, which scans the program's own package for process exits, direct stdout and stderr writes, argv access, and environment reads, and exits 1 listing each finding as <path>:<line>: <rule>: <message>.
  • [strictcli] ExitError carries payload: what the command supplied through ctx.payload before its early exit, None when it supplied nothing.
  • [strictcli] Check values, failing-checks, and named hooks. app.set_check_value_resolver(fn) lets an app assign each check strictcli.CheckValue(value=..., source=...): off does not run the check (shown as an OFF row with its source), warn reports its failures as warnings, error runs it as registered; a value above the registered severity, outside the three, or without a source is refused. The new failing-checks command runs the same selection as check and prints only error-level failures, exiting nonzero exactly when one exists. [hooks.<name>] tables in checks.toml (tag = "<expression>") name a selection that check --hook <name> and failing-checks --hook <name> run, and check --list shows each check's value and where it came from.
  • [strictcli] The help and version commands. Every app answers <app> help <groups> <command> with the page --help prints, <app> help <command> --<flag> with that one flag's help (a scoped flag with the selector and choice that own it), <app> help --depth N [<group>] with the command tree N levels deep, and <app> version with the version line; under --json, help prints the help document (pruned to the address, which it records) and version prints {"name", "version"}.
  • [strictcli] Declared runtime requirements. strictcli.Requirement(name=, help=, install=, load=) declares something a command needs at run time once, requires=[...] references it from each command that needs it, and ctx.need(requirement) returns the loaded value. The framework loads a command's requirements before its handler on every door (run(), test(), call(), and MCP) and in dry mode; a missing one ends the command with exit 1 and command '<path>' needs <name> (<help>), which is not available: <reason>; install it: <install>. --help shows a Requirements: section and the help document a requires list.

Fixes

  • [strictcli] Child stdout under --json. A child run through ctx.effects.run(..., stream=True) or ctx.effects.spawn no longer writes beside the --json document: its stdout is captured into the output member.
  • [strictcli] The config set, config init, config edit, and check commands report their own errors as error: <message> and, under --json, as error diagnostics instead of bare stderr lines; a check --tag expression that does not parse is reported instead of crashing.
  • [strictcli] The check commands' payload_schema in --dump-schema lists items before type, as Go writes it, so a checks-enabled app's schema dump is byte-identical across the Python, Go, and TypeScript implementations.
  • [strictcli] --lint-framework-use now refuses a source file that does not parse with --lint-framework-use: source file '<path>' does not parse: <detail>, the sentence Go and TypeScript print, instead of the parser's bare message, which named neither the scan nor the file.