Summary
_json_requested(), the raw-argv heuristic run_app() uses to decide whether a pre-parse Click error should be rendered as a JSON envelope or plain text, does not understand combined boolean-flag declarations ("--json/--no-json") the way the framework's own _lifecycle_flag_declarations/_leading_output_flags do, and short-circuits to True whenever the option's default is True regardless of what the user actually typed.
Details
lib/python/base_cli/app.py:2900-2915:
def _json_requested(args: list[str], lifecycle_options: LifecycleOptions) -> bool:
option = lifecycle_options.json
if option is None:
return False
if option.default is True:
return True
...
declarations = tuple(declaration for declaration in option.param_decls if declaration.startswith(("-", "/")))
return any(
argument == declaration or argument.startswith(f"{declaration}=")
for argument in args
for declaration in declarations
)
Combined declarations like "--debug/--no-debug" are a real, tested, documented pattern elsewhere in this exact framework (tests/test_lifecycle_options.py:134,648,1112), and nothing prevents a consumer from configuring LifecycleOptions.json the same way. But _json_requested never splits on / the way _lifecycle_flag_declarations (app.py:3011-3026) does for debug/quiet — it compares raw argv tokens against the unsplit string "--json/--no-json", which a user never types.
Reproduced directly against the current code:
opt = LifecycleOption('--json/--no-json', default=False)
_json_requested(['--json', '--nonexistent-flag'], LifecycleOptions(json=opt))
# -> False (should be True: the user explicitly passed --json)
opt2 = LifecycleOption('--json/--no-json', default=True)
_json_requested(['--no-json', '--nonexistent-flag'], LifecycleOptions(json=opt2))
# -> True (should be False: the user explicitly passed --no-json)
Impact
run_app() (app.py:2772-2784) uses this value as state.json_output, which determines how an unexpected pre-parse error is rendered (_show_unexpected_error/_emit_json_error vs. plain text). A consumer using the documented combined-declaration pattern for their JSON flag gets the wrong rendering whenever Click aborts parsing before the option callback runs (e.g. an unrelated unknown-option error): a script that explicitly requested --json gets a plain-text error instead of a JSON envelope, or a script that explicitly passed --no-json gets an unwanted JSON envelope anyway. This is a narrower follow-up to #139 (which fixed the larger "prose+JSON both land on stdout" problem by always buffering output when JSON is configured) — the remaining gap is specifically in how the buffered output gets rendered when Click never reaches the real option parser.
Suggested fix
Reuse _lifecycle_flag_declarations (already correct for debug/quiet) to split positive/negative declarations for the json option too, and check argv against the negative declarations before trusting option.default is True.
Summary
_json_requested(), the raw-argv heuristicrun_app()uses to decide whether a pre-parse Click error should be rendered as a JSON envelope or plain text, does not understand combined boolean-flag declarations ("--json/--no-json") the way the framework's own_lifecycle_flag_declarations/_leading_output_flagsdo, and short-circuits toTruewhenever the option's default isTrueregardless of what the user actually typed.Details
lib/python/base_cli/app.py:2900-2915:Combined declarations like
"--debug/--no-debug"are a real, tested, documented pattern elsewhere in this exact framework (tests/test_lifecycle_options.py:134,648,1112), and nothing prevents a consumer from configuringLifecycleOptions.jsonthe same way. But_json_requestednever splits on/the way_lifecycle_flag_declarations(app.py:3011-3026) does fordebug/quiet— it compares raw argv tokens against the unsplit string"--json/--no-json", which a user never types.Reproduced directly against the current code:
Impact
run_app()(app.py:2772-2784) uses this value asstate.json_output, which determines how an unexpected pre-parse error is rendered (_show_unexpected_error/_emit_json_errorvs. plain text). A consumer using the documented combined-declaration pattern for their JSON flag gets the wrong rendering whenever Click aborts parsing before the option callback runs (e.g. an unrelated unknown-option error): a script that explicitly requested--jsongets a plain-text error instead of a JSON envelope, or a script that explicitly passed--no-jsongets an unwanted JSON envelope anyway. This is a narrower follow-up to #139 (which fixed the larger "prose+JSON both land on stdout" problem by always buffering output when JSON is configured) — the remaining gap is specifically in how the buffered output gets rendered when Click never reaches the real option parser.Suggested fix
Reuse
_lifecycle_flag_declarations(already correct fordebug/quiet) to split positive/negative declarations for thejsonoption too, and check argv against the negative declarations before trustingoption.default is True.