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 @@ -20,6 +20,9 @@ and versions are tracked in the repo-root `VERSION` file.
- Treat plain profile-callback exceptions as private internal failures. Profiles
that used `ValueError` for expected configuration problems should raise
`ConfigurationError` instead.
- Route `base_cli.testing.invoke()` through the production `run_app()` boundary
and add a keyword-only `reraise_unexpected` opt-in for tests that need the
original exception.

### Fixed

Expand All @@ -34,6 +37,8 @@ and versions are tracked in the repo-root `VERSION` file.
command outcome or skip cleanup, context reset, and logger shutdown.
- Allow finished history records to omit `log_path` when file logging is
disabled.
- Restore Click-native `--option=value` parsing, including redaction of
sensitive equals-form values.

## [0.3.0] - 2026-08-01

Expand Down
55 changes: 36 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,10 @@ hello --keep-temp --name Ada
hello --log-file /tmp/hello.log --name Ada
```

Long options with values use space-separated syntax. `base_cli.run_app()` rejects
equals-form values such as `--name=Ada` before Click parses arguments.
These options belong to the application-level lifecycle. A consumer may expose
them through its own launcher or compose them with a higher-level command
wrapper.
Long options use Click's native syntax, so both `--name Ada` and `--name=Ada`
are accepted. These options belong to the application-level lifecycle. A
consumer may expose them through its own launcher or compose them with a
higher-level command wrapper.

## Command Registration

Expand Down Expand Up @@ -230,9 +229,8 @@ def main(ctx: base_cli.Context, token: str) -> None:
...
```

Both `--token secret` and an externally supplied `--token=secret` token are
redacted in debug logs. The lifecycle rejects equals-form option values before
Click parses them.
Both `--token secret` and `--token=secret` are accepted and redacted in debug
logs.

Use `dry_run=True` when a nonstandard option should drive `ctx.dry_run` and
the lifecycle's default durable-write suppression:
Expand Down Expand Up @@ -300,8 +298,8 @@ available. The traceback is kept in the persistent log when enabled and is
shown on stderr with an effective `--debug` setting. A failure before option
parsing can provide a traceback only when `--debug` is an unambiguous leading
flag; otherwise the message says that diagnostic context was unavailable.
Tests or embedding code that need the original exception can pass
`reraise_unexpected=True`.
Embedding code that needs the original exception can pass the keyword-only
`reraise_unexpected=True` argument to `run_app()`.

| Command result or exception | `outcome` | Exit code | Default message |
| --- | --- | ---: | --- |
Expand Down Expand Up @@ -555,15 +553,34 @@ def test_command(tmp_path: Path) -> None:
assert "hello Ada" in result.stdout
```

The helper wraps Click's `CliRunner`, sets `HOME` plus the relevant
`USERPROFILE`, `LOCALAPPDATA`, and `XDG_CACHE_HOME` values when requested, and
supplies `cwd` to the invocation for the duration of the test. Calls that use
`cwd` are serialized and the caller's cwd is restored afterward, but this
remains process-global: do not use it concurrently with code that changes cwd
outside `invoke()` or from threads spawned by the invoked command. A
generic profile should receive project fixtures through its
`discover_project` callback. The helper does not create or interpret any
product-specific manifest fixture.
The helper wraps Click's `CliRunner` but routes the invocation through the same
`run_app()` boundary used by production entry points. Option parsing (including
native forms such as `--name=Ada`), effective and logged argv, exit-code and
error normalization, lifecycle behavior, and command-group dispatch therefore
follow the production path.

By default, unexpected exceptions receive the production-safe rendering and
exit code. Pass the keyword-only `reraise_unexpected=True` argument when a test
needs the original unexpected exception in `result.exception`:

```python
result = invoke(app, [], home=tmp_path, reraise_unexpected=True)
assert isinstance(result.exception, RuntimeError)
```

As with direct `CliRunner` use, a handled nonzero exit normally also gives
`Result.exception` a `SystemExit` carrying that exit code. That does not by
itself indicate an unexpected crash; assert `result.exit_code` and the rendered
stdout or stderr for expected usage or application failures.

`invoke()` sets `HOME` plus the relevant `USERPROFILE`, `LOCALAPPDATA`, and
`XDG_CACHE_HOME` values when requested, and supplies `cwd` to the invocation for
the duration of the test. Calls that use `cwd` are serialized and the caller's
cwd is restored afterward, but this remains process-global: do not use it
concurrently with code that changes cwd outside `invoke()` or from threads
spawned by the invoked command. A generic profile should receive project
fixtures through its `discover_project` callback. The helper does not create or
interpret any product-specific manifest fixture.

When `home` is supplied, `invoke()` provides an isolated default cache
environment for tests. Pass `env={"BASE_CLI_CACHE_DIR": str(path)}` when a test
Expand Down
16 changes: 0 additions & 16 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,6 @@ def run_app(app: App, argv: list[str] | None = None, *, reraise_unexpected: bool
state_token = _INVOCATION_STATE.set(state)
try:
try:
_reject_equals_option_values(click, args)
display_command = app.profile.display_command()
invocation_argv = _effective_invocation_argv(app, args, explicit_argv, display_command)
invocation_token = _INVOCATION_ARGV.set(invocation_argv)
Expand Down Expand Up @@ -768,21 +767,6 @@ def _validate_standard_options(click: Any, standard: dict[str, Any]) -> None:
raise click.UsageError("--debug and --quiet cannot be used together.")


def _reject_equals_option_values(click: Any, argv: list[str]) -> None:
for token in argv:
if token == "--":
return
if token.startswith("--") and "=" in token and len(token) > 2:
option_name, value = token.split("=", 1)
if value:
raise click.UsageError(
f"Option '{option_name}' uses unsupported equals syntax. Use '{option_name} {value}' instead."
)
raise click.UsageError(
f"Option '{option_name}' uses unsupported equals syntax. Pass its value as the next argument."
)


def _group_standard_options(click: Any) -> dict[str, Any]:
context = click.get_current_context(silent=True)
parent = context.parent if context is not None else None
Expand Down
50 changes: 47 additions & 3 deletions lib/python/base_cli/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import inspect
import os
import sys
from pathlib import Path
from threading import RLock
from typing import TYPE_CHECKING, Any
Expand All @@ -22,13 +23,19 @@ def invoke(
home: Path | None = None,
cwd: Path | str | None = None,
env: dict[str, str] | None = None,
*,
reraise_unexpected: bool = False,
) -> Result:
"""Invoke an app through the production ``run_app`` boundary."""

cwd_path = Path(cwd).expanduser().resolve() if cwd is not None else None
invocation_argv = list(args or [])

try:
from click.testing import CliRunner
from click import testing as click_testing
except ImportError as exc:
raise RuntimeError("Click is required for base_cli.testing. Install it with 'pip install click'.") from exc
CliRunner = click_testing.CliRunner

invoke_env = dict(env or {})
if home is not None:
Expand All @@ -41,15 +48,52 @@ def invoke(
if "mix_stderr" in inspect.signature(CliRunner).parameters:
runner_kwargs["mix_stderr"] = False
runner = CliRunner(**runner_kwargs)
command = _RunAppInvocation(
app,
invocation_argv,
reraise_unexpected=reraise_unexpected,
)
if cwd_path is None:
with use_working_dir(None):
return runner.invoke(app.click_command, args or [], env=invoke_env)
return runner.invoke(command, [], env=invoke_env)

with _INVOKE_CWD_LOCK:
with use_working_dir(cwd_path):
original_cwd = Path.cwd()
os.chdir(cwd_path)
try:
return runner.invoke(app.click_command, args or [], env=invoke_env)
return runner.invoke(command, [], env=invoke_env)
finally:
os.chdir(original_cwd)


class _RunAppInvocation:
"""Minimal command interface used only by ``CliRunner.invoke``."""

name = "base-cli-testing-invoke"

def __init__(
self,
app: Any,
argv: list[str],
*,
reraise_unexpected: bool,
) -> None:
self._app = app
self._argv = tuple(argv)
self._reraise_unexpected = reraise_unexpected

def main(self, *_args: Any, **_kwargs: Any) -> None:
from .app import run_app

status = run_app(
self._app,
list(self._argv),
reraise_unexpected=self._reraise_unexpected,
)
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except Exception: # pylint: disable=broad-exception-caught
pass
raise SystemExit(status)
25 changes: 24 additions & 1 deletion tests/test_app_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,37 @@ def main(ctx: base_cli.Context) -> None:
result = invoke(app, [], home=Path(tmpdir))

self.assertEqual(result.exit_code, 1)
self.assertIs(result.exception, primary_failure)
self.assertIsInstance(result.exception, SystemExit)
self.assertIsNot(result.exception, primary_failure)
self.assertTrue(seen["cleanup_called"])
self.assertFalse(Path(seen["temp_dir"]).exists())
self.assertEqual(seen["logger"].handlers, [])

with self.assertRaisesRegex(RuntimeError, "context is not active"):
base_cli.get_current_context()
self.assertIn("History finalization failed: history unavailable", result.stderr)
self.assertIn("Error: Unexpected internal error.", result.stderr)
self.assertNotIn("command failed", result.stderr)

def test_invoke_can_capture_original_unexpected_exception_for_debugging(self) -> None:
app = base_cli.App(name="lifecycle-reraise", log_to_file=False)
primary_failure = _CommandFailure("command failed")

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

with tempfile.TemporaryDirectory() as tmpdir:
result = invoke(
app,
[],
home=Path(tmpdir),
reraise_unexpected=True,
)

self.assertEqual(result.exit_code, 1)
self.assertIs(result.exception, primary_failure)

def test_non_os_temp_cleanup_failure_still_closes_handlers(self) -> None:
app = base_cli.App(name="cleanup-runtime-failure")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_app_quiet.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def main(ctx: base_cli.Context) -> None:
result = invoke(app, ["--debug", "--quiet"], home=home)

self.assertEqual(result.exit_code, 2, result.output)
self.assertIn("--debug and --quiet cannot be used together", result.output)
self.assertIn("--debug and --quiet cannot be used together", result.stderr)

def test_quiet_before_subcommand_uses_warning_user_stream(self) -> None:
app = base_cli.App(name="quiet-subcommand", log_to_file=False)
Expand Down
15 changes: 6 additions & 9 deletions tests/test_app_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ def main(ctx: base_cli.Context) -> dict[str, str]:
self.assertIn("Commands must return None or an int exit code", stderr.getvalue())

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_run_app_rejects_equals_form_long_option_values(self) -> None:
def test_run_app_accepts_click_native_equals_form_long_option_values(self) -> None:
app = base_cli.App(name="space-options", log_to_file=False)
seen = {}

Expand All @@ -216,13 +216,9 @@ def main(ctx: base_cli.Context, name: str) -> None:
), redirect_stderr(stderr):
status = base_cli.run_app(app, ["--name=demo"])

self.assertEqual(status, 2)
self.assertEqual(seen, {})
self.assertIn(
"Option '--name' uses unsupported equals syntax. Use '--name demo' instead.",
stderr.getvalue(),
)
self.assertNotIn("Traceback", stderr.getvalue())
self.assertEqual(status, 0)
self.assertEqual(seen, {"name": "demo"})
self.assertEqual(stderr.getvalue(), "")

@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
def test_run_app_uses_delegated_display_command_for_usage_errors(self) -> None:
Expand All @@ -245,7 +241,8 @@ def main(ctx: base_cli.Context) -> None:

self.assertEqual(status, 2)
self.assertIn("Usage: tool demo", stderr.getvalue())
self.assertIn("No such option '--bad-option'.", stderr.getvalue())
self.assertIn("No such option", stderr.getvalue())
self.assertIn("--bad-option", stderr.getvalue())
self.assertNotIn("internal-cli", stderr.getvalue())


Expand Down
Loading