Files
cleveragents-core/features/steps/cli_global_options_steps.py
hurui200320 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
fix(cli): add spec-required --data-dir, --config-path, and -v global options to main_callback
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
2026-05-12 01:05:10 +00:00

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}"