JSON contracts¶
Machine-facing output is opt-in. Configure a JSON lifecycle option on an app when a command is intended for scripts or automation:
import base_cli
app = base_cli.App(
name="example",
lifecycle_options=base_cli.LifecycleOptions(
json=base_cli.LifecycleOption("--json"),
),
)
example --json captures command stdout and emits exactly one success or error
envelope on stdout. Logs remain on stderr. Human mode, including the default
Click error rendering and command stdout behavior, is unchanged.
JSON output remains all-or-nothing at that envelope boundary: a failed command emits an error envelope rather than exposing a partially emitted JSON document. CSV and TSV are intentionally different streaming formats; if a later row cannot be encoded, rows already written to the sink remain available.
Capture is activated only when JSON mode is selected, including an environment
variable or Click default_map. Human and NDJSON invocations write directly to
the caller's stdout, preserving progress visibility and flush behavior. JSON
capture allows at most 8 MiB of UTF-8 command stdout. It keeps the first 1 MiB
in memory and rolls the remainder to a temporary file, so both temporary-disk
use and finalization memory remain bounded. The temporary file is removed when
the invocation ends.
The JSON capture boundary temporarily redirects process-wide file descriptor 1.
run_app() therefore rejects concurrent invocations in one process; callers
that need parallel CLI work should use separate processes or serialize the
invocations.
The mode check respects Click option arity: a value such as
--payload --json does not activate JSON when --json is the payload. It does
not run consumer callbacks, defaults, type converters, or close hooks as a
second parse before the real invocation.
If a command exceeds the limit, base-cli emits one base-cli.error envelope
with code: "capture_limit" and exit code 1; it never silently truncates
the captured text. Use the NDJSON contract for larger record sets.
If a child retains the inherited stdout descriptor after the command returns,
base-cli emits code: "capture_incomplete" and includes the output drained
before the timeout in the error envelope. Detached children should use
subprocess.DEVNULL for stdout/stderr (and may use start_new_session=True)
when running under JSON mode.
Output and errors¶
Both envelopes use schema_version: 1 and stable fields:
{
"schema_version": 1,
"schema": "base-cli.output",
"code": "ok",
"type": "success",
"message": "Success",
"details": {"exit_code": 0, "stdout": "hello\n"},
"run_id": "20260804T192202_dd231351"
}
Failures use schema: "base-cli.error", type: "error", and a deterministic
code derived from the lifecycle outcome (usage_error, click_error,
capture_limit, capture_incomplete, aborted, interrupted, unexpected_error, and so on). details always
contains the numeric exit_code and captured command stdout. A command's
human output is represented as a JSON string, so it cannot introduce prose or
ANSI escapes as a second stdout record.
Commands that have machine-facing fields can call ctx.set_result(mapping) while
running. JSON mode adds the redacted mapping as details.result without changing
details.stdout; a second call replaces the first mapping. The mapping is
strictly JSON-validated and snapshotted when set, so non-serializable values,
non-finite numbers, and later nested mutations cannot break or alter the emitted
envelope. Commands that also support NDJSON or human output can use the same
source data with NdjsonWriter or render_records in their non-JSON branch,
but those lower-level output helpers do not apply lifecycle JSON redaction
automatically. Apply redact_json_value() first when the shared data contains
sensitive fields, or define an explicit non-JSON redaction policy; do not assume
that ctx.set_result() protects a separate output call.
All JSON and NDJSON emitters use strict JSON serialization and reject
non-finite numeric values (NaN, positive infinity, and negative infinity).
An invalid NDJSON record is fully serialized before it is written, so it does
not leave a partial line in the output stream.
run_id is the lifecycle run identifier when startup reached a runtime
context, otherwise it is null. Unexpected failures intentionally expose only
the generic message Unexpected internal error.; diagnostics stay in logs.
For large or long-running record sets, use the ndjson output contract in
output-contracts.md. NDJSON is intentionally a stream
of versioned records rather than a single success/error envelope; command
errors and diagnostics still use the normal stderr and exit-code boundary.
The lower-level success_envelope(), error_envelope(), dumps_envelope(),
and redact_json_value() helpers are public for commands that need to publish
their own structured details records. Secret-looking keys and
credential-bearing URLs are redacted recursively. The heuristic covers
token, password, passwd, pwd, passphrase, secret, credential,
private-key, access-key, api-key, authorization, bearer, session,
cookie, signature, otp, salt, sas, and pem, including camelCase
forms such as accessToken, sessionToken, dbPassword, and bearerToken.
The private-key, access-key, and api-key compounds are also recognized
when written as privateKey, accessKey, or apiKey. A generic key name,
including key-file, sort-key, and public-key, is not treated as secret by
itself; explicit sensitive=True remains the authoritative control for
domain-specific names.
Traversal is bounded to 100 container levels; cyclic or more deeply nested
values are replaced with [TRUNCATED], distinct from the [REDACTED] marker
used for secrets, so public helpers cannot recurse indefinitely while preparing
a contract.
Golden payloads for each public contract live in
tests/fixtures/contracts.
CI validates them against the packaged schemas with both a Python validator and
a dependency-free Node.js reader; consumers can use the same fixtures as
cross-language conformance tests.
Inspection envelopes¶
Read-only inspection commands can use the stable inspection helpers when their
result is a diagnostic snapshot rather than a command success or failure. The
public inspection_envelope() helper returns a versioned mapping with the
command name, an inspection status, caller-owned data, and an optional error:
import base_cli
payload = base_cli.inspection_envelope(
command="release check",
status="ok",
data={"project": "demo", "version": "0.4.0"},
)
The v1 shape is:
{
"schema_version": 1,
"command": "release check",
"status": "ok",
"data": {"project": "demo", "version": "0.4.0"},
"error": null
}
status is one of ok, warn, or error. The data mapping is copied into
the envelope; error is either null or a caller-defined mapping containing
diagnostic details. These envelopes are intended for idempotent, read-only
queries and do not replace the success/error lifecycle envelopes used for
command execution outcomes.
render_inspection_json() produces the same envelope as indented JSON with a
trailing newline and no ASCII-only escaping. Use it when the inspection result
is written directly to a machine-readable output stream.
JSON logs¶
Pass json_logs=True and the run identifier to configure_logger() when an
integration needs structured logs without enabling machine output:
logger = base_cli.configure_logger(
"example",
log_file,
debug=True,
json_logs=True,
run_id="run-123",
)
Each line is a JSON object with schema_version, schema, timestamp (UTC),
level, logger, message, and run_id. Messages are redacted and capped at
8 KiB; persistent files retain base-cli's owner-only permissions and JSON mode
bounds default-log retention to the most recent 20 run bundles (or the
explicit RetentionPolicy setting). The legacy max_log_files option remains
available for compatibility. JSON logs never use terminal color codes.
Child processes and native stdout¶
JSON mode captures Python stdout, os.write(1, ...), sys.__stdout__, and
subprocesses inheriting descriptor 1. The descriptor is restored before emitting
the single envelope. Use subprocess.run([...], check=True) or explicitly wait
for each Popen child before returning. A child retaining stdout after return
produces a capture error after a bounded wait. Native libraries must flush their
own stdio buffers before returning; writes after the invocation boundary cannot
be captured. Invalid UTF-8 bytes are represented with Unicode replacement characters.
The 8 MiB JSON capture limit applies to native/child output as well. Exceeding it produces an error envelope rather than a success with silently truncated output. NDJSON and human output keep their streaming behavior.