Skip to content

[v1.0] Preserve streaming output for JSON-capable applications #268

Description

@codeforester

Goal

Keep normal human and NDJSON output streaming, and bound memory when lifecycle JSON is requested.

Background

When an application merely declares a lifecycle JSON option, run_app() redirects all stdout to an in-memory StringIO before Click parses the option:

output_capture: io.StringIO | None = None
try:
try:
display_command = app.profile.display_command()
invocation_argv = _effective_invocation_argv(app, args, display_command)
command = app.click_command
click = dialect_for_command(command)
invocation_token = _INVOCATION_ARGV.set(invocation_argv)
try:
bypass_token = _INVOCATION_MAIN_BYPASS.set(command)
# A configured JSON option may be enabled by any Click-supported
# source (for example ``default_map`` or a combined short flag),
# so raw argv cannot determine capture eligibility. Buffer the
# command whenever JSON mode exists and let the parsed lifecycle
# value decide whether to emit an envelope or replay human text.
output_capture = io.StringIO() if app.lifecycle_options.json is not None else None
try:
if output_capture is None:
result = command.main(
args=args,
prog_name=display_command or app.name,
standalone_mode=False,
)
else:
with redirect_stdout(output_capture):
result = command.main(
args=args,
prog_name=display_command or app.name,
standalone_mode=False,
)
. If JSON was not selected, the entire buffer is replayed only after the command finishes:
try:
if state.attached_completion:
if state.json_output:
_emit_json_success(state, ExitCode.SUCCESS, output_capture)
return ExitCode.SUCCESS
exit_code = _normalize_command_result(result)
if state.json_output:
if exit_code == ExitCode.SUCCESS:
_emit_json_success(state, exit_code, output_capture)
else:
_emit_json_error(
state,
outcome_from_exit_code(exit_code),
"Command returned a non-zero exit code.",
output_capture,
)
return exit_code
except TypeError as exc:
if state.json_output:
outcome = outcome_from_exception(click, exc)
_emit_json_error(state, outcome, str(exc), output_capture)
return outcome.exit_code
print(f"ERROR: {exc}", file=sys.stderr)
return ExitCode.FAILURE
finally:
if output_capture is not None and not state.json_output:
sys.stdout.write(output_capture.getvalue())
.

This breaks progress output and pipeline latency for the default human path, defeats NdjsonWriter.write()'s flush guarantee, and makes output memory proportional to command output. It regresses the bounded/streaming commitments in #65 and #192.

Scope

  • Preserve the real stdout stream whenever lifecycle JSON is not active.
  • Retain support for Click default_map, environment variables, aliases, combined short options, and negated flags.
  • Bound or spool any capture required for a single JSON envelope.
  • Define overflow and stream-failure behavior.

Acceptance Criteria

  • A JSON-capable app with no JSON request streams and flushes output immediately.
  • NDJSON records remain observable one at a time and use bounded memory.
  • Human terminal behavior is unchanged by declaring JSON capability.
  • Active --json still emits exactly one valid v1 envelope with no prose contamination.
  • Large captured output has a documented deterministic limit or disk-spooling policy.
  • Tests assert write/flush timing, backpressure/error behavior, large output, env/default-map activation, and negation.

Validation

Run JSON-contract, output, invocation-parity, performance, and subprocess tests.

Non-Goals

Do not remove the existing JSON envelope contract.

Project Fields

  • Status: Backlog
  • Priority: P1
  • Area: CLI
  • Initiative: v1.0 Readiness
  • Size: M

Ownership

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething is not working

Type

No type

Projects

  • Status
    Done

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions