Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ and versions are tracked in the repo-root `VERSION` file.
values, and header-style `key: value` arguments before they reach logs or
persisted history.

### Fixed

- Honor combined positive/negative JSON option declarations and explicit
`--no-json` values when deciding whether pre-parse errors use JSON output.

### Added

- Add a framework choice guide, five-minute evaluation path, and clearer
Expand Down
24 changes: 16 additions & 8 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2901,18 +2901,26 @@ def _json_requested(args: list[str], lifecycle_options: LifecycleOptions) -> boo
option = lifecycle_options.json
if option is None:
return False
if option.default is True:
return True

positive_declarations, negative_declarations = _lifecycle_flag_declarations(option)
explicit_value: bool | None = None
for argument in args:
if any(
argument == declaration or argument.startswith(f"{declaration}=") for declaration in positive_declarations
):
explicit_value = True
elif any(
argument == declaration or argument.startswith(f"{declaration}=") for declaration in negative_declarations
):
explicit_value = False
if explicit_value is not None:
return explicit_value

if option.envvar is not None:
envvars = (option.envvar,) if isinstance(option.envvar, str) else option.envvar
if any(os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} for name in envvars):
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
)
return option.default is True


def _captured_stdout(output_capture: io.StringIO | None) -> str:
Expand Down
42 changes: 42 additions & 0 deletions tests/test_json_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,48 @@ def main(ctx: base_cli.Context, x: bool) -> None:
"hello from combined flags\n",
)

def test_json_preparse_errors_honor_combined_positive_declaration(self) -> None:
app = base_cli.App(
name="json-combined-declaration",
log_to_file=False,
lifecycle_options=base_cli.LifecycleOptions(
json=base_cli.LifecycleOption("--json/--no-json"),
),
)

@app.command()
def main(ctx: base_cli.Context) -> None:
del ctx

with tempfile.TemporaryDirectory() as home:
result = base_cli.testing.invoke(app, ["--json", "--unknown"], home=Path(home))

self.assertEqual(result.exit_code, 2)
envelope = json.loads(result.stdout)
self.assertEqual(envelope["schema"], "base-cli.error")
self.assertEqual(result.stderr, "")
self.assertIn("No such option", envelope["message"])

def test_json_preparse_errors_honor_explicit_negative_declaration_over_true_default(self) -> None:
app = base_cli.App(
name="json-negative-declaration",
log_to_file=False,
lifecycle_options=base_cli.LifecycleOptions(
json=base_cli.LifecycleOption("--json/--no-json", default=True),
),
)

@app.command()
def main(ctx: base_cli.Context) -> None:
del ctx

with tempfile.TemporaryDirectory() as home:
result = base_cli.testing.invoke(app, ["--no-json", "--unknown"], home=Path(home))

self.assertEqual(result.exit_code, 2)
self.assertEqual(result.stdout, "")
self.assertIn("No such option", result.stderr or result.output)

def test_json_mode_captures_default_map_values(self) -> None:
app = base_cli.App(
name="json-default-map",
Expand Down
Loading