b4a514e0f4
CI / benchmark-regression (push) Failing after 34s
CI / tdd_quality_gate (push) Has been skipped
CI / push-validation (push) Successful in 1m5s
CI / helm (push) Successful in 1m6s
CI / build (push) Successful in 1m23s
CI / lint (push) Successful in 1m55s
CI / typecheck (push) Successful in 2m4s
CI / quality (push) Successful in 2m2s
CI / security (push) Successful in 2m2s
CI / integration_tests (push) Successful in 7m35s
CI / e2e_tests (push) Successful in 4m30s
CI / unit_tests (push) Successful in 9m19s
CI / docker (push) Successful in 1m47s
CI / coverage (push) Successful in 12m33s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (push) Has been cancelled
Implements ADR-021 §Global CLI Flags and ADR-024 §Resolution Chain.
All three global options were missing from main_callback(), causing any
invocation with these flags to crash with 'No such option'.
Changes:
- src/cleveragents/cli/main.py
* Add _VERBOSITY_LOG_LEVELS tuple mapping verbose count to log levels
(0=CRITICAL/silent, 1=ERROR, 2=WARNING, 3=INFO, 4=DEBUG, 5+=DEBUG)
* Add --data-dir PATH option: validates path is a directory if it exists,
sets CLEVERAGENTS_DATA_DIR env var, resets Settings singleton
* Add --config-path PATH option: validates file exists and is a file,
sets CLEVERAGENTS_CONFIG_PATH env var for ConfigService to pick up
* Add -v (count=True) option: wires verbose count to configure_structlog
* Store all three values in ctx.obj for subcommand access
* Update _print_basic_help() to include the three new global options so
'cleveragents --help' (fast path) also lists them
* In the catch-all Exception handler, print the original exception
type+message directly to err_console so actionable details remain
visible even when log level is CRITICAL (e.g. 'No such option')
- src/cleveragents/application/services/config_service.py
* ConfigService.__init__ now checks CLEVERAGENTS_CONFIG_PATH env var
when no explicit config_path is passed, completing the resolution
chain for --config-path CLI override
Tests (all passing):
- features/cli_global_options.feature (21 Behave scenarios)
- features/steps/cli_global_options_steps.py
- robot/cli_global_options.robot (8 Robot Framework integration tests)
- robot/helper_cli_global_options.py
Quality gates: lint ✓ typecheck ✓ unit_tests ✓ integration_tests ✓
Coverage: 96.52% (threshold 96.5% ✓)
ISSUES CLOSED: #6785
347 lines
13 KiB
Python
347 lines
13 KiB
Python
"""Step definitions for CLI global options: --data-dir, --config-path, -v.
|
|
|
|
Tests for issue #6785 — spec-required global options that were missing from
|
|
main_callback() and caused fatal "No such option" crashes.
|
|
|
|
All step patterns use the prefix "global options" to avoid any ambiguity
|
|
with the many generic CLI step definitions in other step files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.formatting import OutputFormat
|
|
from cleveragents.cli.main import app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Regex to strip ANSI escape sequences (CSI sequences and OSC sequences).
|
|
# Used to make output assertions environment-agnostic — Rich may emit ANSI
|
|
# colour/style codes when the CI environment does not set TERM=dumb.
|
|
_ANSI_ESCAPE_RE = re.compile(
|
|
r"\x1b(?:"
|
|
r"\[[0-9;]*[a-zA-Z]" # CSI sequences (SGR, cursor, etc.)
|
|
r"|"
|
|
r"\][^\x07]*\x07" # OSC sequences (hyperlink, title, etc.)
|
|
r")"
|
|
)
|
|
|
|
|
|
def _strip_ansi(text: str) -> str:
|
|
"""Remove ANSI escape sequences from *text*."""
|
|
return _ANSI_ESCAPE_RE.sub("", text)
|
|
|
|
|
|
def _invoke(args: list[str]) -> Any:
|
|
"""Invoke the Typer app with the given args and return the result."""
|
|
runner = CliRunner()
|
|
return runner.invoke(app, args, catch_exceptions=True)
|
|
|
|
|
|
def _combined_output(result: Any) -> str:
|
|
"""Return combined output from a CliRunner result, with ANSI codes stripped.
|
|
|
|
In non-TTY environments (CI), Typer/Rich may split output between
|
|
stdout and stderr. Check both explicitly so that ``--help`` output
|
|
assertions work regardless of the terminal detection.
|
|
"""
|
|
stdout = _strip_ansi((getattr(result, "stdout", "") or "").strip())
|
|
stderr = _strip_ansi((getattr(result, "stderr", "") or "").strip())
|
|
combined = (
|
|
(stdout + "\n" + stderr).strip() if (stdout and stderr) else (stdout or stderr)
|
|
)
|
|
return combined or _strip_ansi((result.output or "").strip())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("global options test env is clean")
|
|
def step_global_opts_env_clean(context: Context) -> None:
|
|
"""Ensure test-specific env vars are not polluted from previous tests.
|
|
|
|
Registers a cleanup handler that restores the original env var values after
|
|
the scenario ends. The global ``after_scenario`` hook in
|
|
``features/environment.py`` runs all ``context._cleanup_handlers``.
|
|
"""
|
|
orig_data_dir = os.environ.get("CLEVERAGENTS_DATA_DIR")
|
|
orig_config_path = os.environ.get("CLEVERAGENTS_CONFIG_PATH")
|
|
# Clean up any leftover from previous scenarios
|
|
for var in ("CLEVERAGENTS_DATA_DIR", "CLEVERAGENTS_CONFIG_PATH"):
|
|
os.environ.pop(var, None)
|
|
|
|
# Register restoration handler so that env vars set by main_callback()
|
|
# during CLI invocation do not pollute subsequent scenarios.
|
|
def _restore() -> None:
|
|
for key, val in [
|
|
("CLEVERAGENTS_DATA_DIR", orig_data_dir),
|
|
("CLEVERAGENTS_CONFIG_PATH", orig_config_path),
|
|
]:
|
|
if val is not None:
|
|
os.environ[key] = val
|
|
else:
|
|
os.environ.pop(key, None)
|
|
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(_restore)
|
|
|
|
|
|
@given("a temp data dir is prepared")
|
|
def step_temp_data_dir_prepared(context: Context) -> None:
|
|
"""Create a real temporary directory for --data-dir testing.
|
|
|
|
Registers a cleanup handler so the directory is removed after the scenario.
|
|
"""
|
|
tmp_dir = tempfile.mkdtemp(prefix="ca_test_data_dir_")
|
|
context.temp_data_dir = tmp_dir
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
|
|
|
|
|
|
@given("a temp config file is prepared")
|
|
def step_temp_config_file_prepared(context: Context) -> None:
|
|
"""Create a real temporary TOML config file for --config-path testing.
|
|
|
|
Registers a cleanup handler so the file is removed after the scenario.
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
suffix=".toml", prefix="ca_test_config_", delete=False
|
|
) as tmp_file:
|
|
tmp_file.write(b"# test config\n")
|
|
tmp_path = tmp_file.name
|
|
context.temp_config_file = tmp_path
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(lambda: os.unlink(tmp_path))
|
|
|
|
|
|
@given("a temp file path is prepared")
|
|
def step_temp_file_path_prepared(context: Context) -> None:
|
|
"""Create a temporary FILE (not directory) to trigger --data-dir validation.
|
|
|
|
Registers a cleanup handler so the file is removed after the scenario.
|
|
"""
|
|
with tempfile.NamedTemporaryFile(prefix="ca_test_file_", delete=False) as tmp_file:
|
|
tmp_path = tmp_file.name
|
|
context.temp_file_path = tmp_path
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(lambda: os.unlink(tmp_path))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps - CLI invocation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I run the global options CLI with data-dir flag and "{subcommand}"')
|
|
def step_run_global_opts_data_dir(context: Context, subcommand: str) -> None:
|
|
"""Run the CLI with --data-dir pointing at the temp directory."""
|
|
args = ["--data-dir", context.temp_data_dir, subcommand]
|
|
result = _invoke(args)
|
|
context.global_opts_result = result
|
|
context.global_opts_exit_code = result.exit_code
|
|
context.global_opts_output = _combined_output(result)
|
|
|
|
|
|
@when('I run the global options CLI with config-path flag and "{subcommand}"')
|
|
def step_run_global_opts_config_path(context: Context, subcommand: str) -> None:
|
|
"""Run the CLI with --config-path pointing at the temp config file."""
|
|
args = ["--config-path", context.temp_config_file, subcommand]
|
|
result = _invoke(args)
|
|
context.global_opts_result = result
|
|
context.global_opts_exit_code = result.exit_code
|
|
context.global_opts_output = _combined_output(result)
|
|
|
|
|
|
@when('I run the global options CLI with both path flags and "{subcommand}"')
|
|
def step_run_global_opts_both(context: Context, subcommand: str) -> None:
|
|
"""Run the CLI with both --data-dir and --config-path."""
|
|
args = [
|
|
"--data-dir",
|
|
context.temp_data_dir,
|
|
"--config-path",
|
|
context.temp_config_file,
|
|
subcommand,
|
|
]
|
|
result = _invoke(args)
|
|
context.global_opts_result = result
|
|
context.global_opts_exit_code = result.exit_code
|
|
context.global_opts_output = _combined_output(result)
|
|
|
|
|
|
@when('I run the global options CLI with data-dir as temp file and "{subcommand}"')
|
|
def step_run_global_opts_data_dir_file(context: Context, subcommand: str) -> None:
|
|
"""Run CLI with --data-dir pointing to a FILE (triggers validation error)."""
|
|
args = ["--data-dir", context.temp_file_path, subcommand]
|
|
result = _invoke(args)
|
|
context.global_opts_result = result
|
|
context.global_opts_exit_code = result.exit_code
|
|
context.global_opts_output = _combined_output(result)
|
|
|
|
|
|
@when('I run the global options CLI with invalid config-path and "{subcommand}"')
|
|
def step_run_global_opts_invalid_config_path(context: Context, subcommand: str) -> None:
|
|
"""Run CLI with --config-path pointing to a non-existent file."""
|
|
args = ["--config-path", "/tmp/no_such_file_xyz_99999.toml", subcommand]
|
|
result = _invoke(args)
|
|
context.global_opts_result = result
|
|
context.global_opts_exit_code = result.exit_code
|
|
context.global_opts_output = _combined_output(result)
|
|
|
|
|
|
@when('I run the global options CLI with args "{args}"')
|
|
def step_run_global_opts_with_args(context: Context, args: str) -> None:
|
|
"""Run the CLI with space-separated args string."""
|
|
result = _invoke(args.split())
|
|
context.global_opts_result = result
|
|
context.global_opts_exit_code = result.exit_code
|
|
context.global_opts_output = _combined_output(result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps - direct callback testing for verbosity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I call main_callback with verbosity {count:d}")
|
|
def step_call_main_callback_verbosity(context: Context, count: int) -> None:
|
|
"""Invoke main_callback directly, capturing the log level configured."""
|
|
from cleveragents.cli.main import main_callback
|
|
|
|
context.global_opts_captured_log_level: str | None = None
|
|
context.global_opts_captured_ctx_obj: dict[str, Any] = {}
|
|
|
|
def _capture_log_level(
|
|
*, env: str = "development", log_level: str = "INFO"
|
|
) -> None:
|
|
context.global_opts_captured_log_level = log_level
|
|
|
|
mock_ctx = MagicMock()
|
|
mock_ctx.obj = {}
|
|
|
|
with patch(
|
|
"cleveragents.config.logging.configure_structlog",
|
|
side_effect=_capture_log_level,
|
|
):
|
|
main_callback(
|
|
ctx=mock_ctx,
|
|
version=None, # type: ignore[arg-type]
|
|
show_secrets=False,
|
|
fmt=OutputFormat.RICH,
|
|
data_dir=None,
|
|
config_path=None,
|
|
verbose=count,
|
|
)
|
|
|
|
context.global_opts_captured_ctx_obj = mock_ctx.obj
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps - exit codes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the global options CLI should succeed")
|
|
def step_global_opts_cli_succeed(context: Context) -> None:
|
|
actual = context.global_opts_exit_code
|
|
output = context.global_opts_output
|
|
assert actual == 0, f"Expected exit code 0, got {actual}.\nOutput:\n{output}"
|
|
|
|
|
|
@then("the global options CLI should fail")
|
|
def step_global_opts_cli_fail(context: Context) -> None:
|
|
actual = context.global_opts_exit_code
|
|
assert actual != 0, (
|
|
f"Expected non-zero exit code, got {actual}.\nOutput:\n{context.global_opts_output}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps - env var assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the CLEVERAGENTS_DATA_DIR env var should match the temp data dir")
|
|
def step_env_data_dir_matches(context: Context) -> None:
|
|
"""Verify that --data-dir set CLEVERAGENTS_DATA_DIR to the temp directory."""
|
|
env_val = os.environ.get("CLEVERAGENTS_DATA_DIR")
|
|
expected = str(Path(context.temp_data_dir).resolve())
|
|
assert env_val is not None, (
|
|
"CLEVERAGENTS_DATA_DIR was not set after --data-dir invocation"
|
|
)
|
|
assert str(Path(env_val).resolve()) == expected, (
|
|
f"CLEVERAGENTS_DATA_DIR={env_val!r} does not match expected {expected!r}"
|
|
)
|
|
|
|
|
|
@then("the CLEVERAGENTS_CONFIG_PATH env var should match the temp config file")
|
|
def step_env_config_path_matches(context: Context) -> None:
|
|
"""Verify that --config-path set CLEVERAGENTS_CONFIG_PATH to the temp file."""
|
|
env_val = os.environ.get("CLEVERAGENTS_CONFIG_PATH")
|
|
expected = str(Path(context.temp_config_file).resolve())
|
|
assert env_val is not None, (
|
|
"CLEVERAGENTS_CONFIG_PATH was not set after --config-path invocation"
|
|
)
|
|
assert str(Path(env_val).resolve()) == expected, (
|
|
f"CLEVERAGENTS_CONFIG_PATH={env_val!r} does not match expected {expected!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps - ctx.obj assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the global options ctx obj "{key}" should be {value:d}')
|
|
def step_global_opts_ctx_obj_int(context: Context, key: str, value: int) -> None:
|
|
"""Verify ctx.obj[key] has the expected integer value."""
|
|
obj = context.global_opts_captured_ctx_obj
|
|
assert key in obj, f"ctx.obj missing key '{key}'. ctx.obj={obj}"
|
|
actual = obj[key]
|
|
assert actual == value, f"ctx.obj['{key}']={actual!r}, expected {value!r}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps - log level assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the global options log level should be "{level}"')
|
|
def step_global_opts_log_level(context: Context, level: str) -> None:
|
|
"""Verify that configure_structlog was called with the expected log level."""
|
|
actual = context.global_opts_captured_log_level
|
|
assert actual is not None, "configure_structlog was not called"
|
|
assert actual.upper() == level.upper(), (
|
|
f"Expected log level {level!r}, got {actual!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps - output assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the global options output should contain "{text}"')
|
|
def step_global_opts_output_contains(context: Context, text: str) -> None:
|
|
output = context.global_opts_output
|
|
assert text in output, f"Expected '{text}' in output:\n{output}"
|