7c4663b8ee
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / build (pull_request) Successful in 19s
CI / typecheck (pull_request) Successful in 38s
CI / security (pull_request) Successful in 46s
CI / integration_tests (pull_request) Successful in 2m52s
CI / unit_tests (pull_request) Successful in 12m35s
CI / docker (pull_request) Successful in 38s
CI / benchmark-regression (pull_request) Successful in 20m57s
CI / coverage (pull_request) Successful in 47m23s
CI / lint (push) Successful in 12s
CI / build (push) Successful in 14s
CI / quality (push) Successful in 17s
CI / typecheck (push) Successful in 32s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 40s
CI / integration_tests (push) Successful in 2m52s
CI / benchmark-publish (push) Successful in 12m29s
CI / unit_tests (push) Successful in 12m31s
CI / docker (push) Successful in 38s
CI / coverage (push) Successful in 47m17s
Implement the complete configuration system with multi-level resolution chain, typed key registry, and CLI integration per specification. ConfigService changes: - Expand _build_catalog() to register all 102 spec-aligned config keys across 8 groups: core (14), server (4), actor (5), plan (8), sandbox (5), index (12), context (43), provider (11) - Each key carries exact dotted-dash name, Python type, default value, explicit env var name per spec, project-scopability flag, and description - Fix _env_name() to convert dots and dashes to underscores - Provider keys use standard env var names (e.g., OPENAI_API_KEY) CLI commands rewiring: - Rewrite config set/get/list to use ConfigService instead of Settings - Add --verbose flag to config get showing full 5-level resolution chain - Add --project flag to config set/get/list for project-scoped overrides - Support both glob and regex patterns in config list - Validate keys against ConfigService registry with actionable errors - Retain backward-compatible helper functions delegating to ConfigService Documentation: - Add docs/reference/config_resolution.md covering resolution chain, all 102 config keys, CLI commands, TOML format, and provider credentials Testing: - Update all 4 Behave feature files and step definitions to use new spec-aligned key names, env vars, and defaults (119 scenarios passing) - Add robot/config_resolution.robot with 10 integration test cases - Add benchmarks/config_resolution_bench.py with 8 time + 2 memory suites ISSUES CLOSED: #258
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""Step definitions for the Config CLI feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands import config as config_mod
|
|
from cleveragents.cli.commands.config import app as config_app
|
|
|
|
_runner = CliRunner()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _patch_config_paths(context: Context) -> None:
|
|
"""Redirect config dir/path to a temp directory."""
|
|
if not hasattr(context, "_config_tmpdir"):
|
|
context._config_tmpdir = tempfile.mkdtemp()
|
|
tmp = Path(context._config_tmpdir)
|
|
context._config_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
|
|
context._config_path_patch = patch.object(
|
|
config_mod, "_CONFIG_PATH", tmp / "config.toml"
|
|
)
|
|
context._config_dir_patch.start()
|
|
context._config_path_patch.start()
|
|
|
|
|
|
def _unpatch(context: Context) -> None:
|
|
"""Stop patches and clean up temp dir."""
|
|
for attr in ("_config_dir_patch", "_config_path_patch"):
|
|
patcher = getattr(context, attr, None)
|
|
if patcher is not None:
|
|
patcher.stop()
|
|
tmpdir = getattr(context, "_config_tmpdir", None)
|
|
if tmpdir and os.path.isdir(tmpdir):
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a clean config CLI test environment")
|
|
def step_clean_config_env(context: Context) -> None:
|
|
"""Set up an isolated temp config directory."""
|
|
_patch_config_paths(context)
|
|
# Register cleanup
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers: list[Any] = []
|
|
context._cleanup_handlers.append(lambda: _unpatch(context))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SET
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I run config set "{key}" "{value}"')
|
|
def step_run_config_set(context: Context, key: str, value: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["set", key, value])
|
|
|
|
|
|
@then("the config set should succeed")
|
|
def step_config_set_succeed(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit 0, got {context.result.exit_code}: "
|
|
f"{context.result.output}{context.result.stderr}"
|
|
)
|
|
|
|
|
|
@then('the config set output should contain key "{key}"')
|
|
def step_config_set_contains_key(context: Context, key: str) -> None:
|
|
assert key in context.result.output, (
|
|
f"Expected '{key}' in output: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then('the config set output should contain value "{value}"')
|
|
def step_config_set_contains_value(context: Context, value: str) -> None:
|
|
assert value in context.result.output, (
|
|
f"Expected '{value}' in output: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the config set should fail with unknown key error")
|
|
def step_config_set_fail_unknown(context: Context) -> None:
|
|
assert context.result.exit_code != 0, (
|
|
f"Expected non-zero exit, got {context.result.exit_code}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I run config get "{key}"')
|
|
def step_run_config_get(context: Context, key: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["get", key])
|
|
|
|
|
|
@when('I run config get "{key}" with verbose')
|
|
def step_run_config_get_verbose(context: Context, key: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["get", key, "--verbose"])
|
|
|
|
|
|
@when('I run config get key "{key}" formatted as "{fmt}"')
|
|
def step_run_config_get_fmt(context: Context, key: str, fmt: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["get", key, "--format", fmt])
|
|
|
|
|
|
@then("the config get should succeed")
|
|
def step_config_get_succeed(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then('the config get output should contain key "{key}"')
|
|
def step_config_get_contains_key(context: Context, key: str) -> None:
|
|
assert key in context.result.output, (
|
|
f"Expected '{key}' in output: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the config get output should show the resolution chain")
|
|
def step_config_get_resolution_chain(context: Context) -> None:
|
|
output = context.result.output
|
|
assert "Resolution chain" in output or "resolution_chain" in output, (
|
|
f"Expected resolution chain in output: {output}"
|
|
)
|
|
|
|
|
|
@then("the config get should fail with unknown key error")
|
|
def step_config_get_fail_unknown(context: Context) -> None:
|
|
assert context.result.exit_code != 0, (
|
|
f"Expected non-zero exit, got {context.result.exit_code}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LIST
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I run config list")
|
|
def step_run_config_list(context: Context) -> None:
|
|
context.result = _runner.invoke(config_app, ["list"])
|
|
|
|
|
|
@when('I run config list "{pattern}"')
|
|
def step_run_config_list_filter(context: Context, pattern: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["list", pattern])
|
|
|
|
|
|
@when('I run config list with --filter-values "{regex}"')
|
|
def step_run_config_list_filter_values(context: Context, regex: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["list", "--filter-values", regex])
|
|
|
|
|
|
@when('I run config list with invalid regex "{regex}"')
|
|
def step_run_config_list_invalid_regex(context: Context, regex: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["list", regex])
|
|
|
|
|
|
@when('I run config list with invalid value regex "{regex}"')
|
|
def step_run_config_list_invalid_val_regex(context: Context, regex: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["list", "--filter-values", regex])
|
|
|
|
|
|
@when('I run config list with format "{fmt}"')
|
|
def step_run_config_list_format(context: Context, fmt: str) -> None:
|
|
context.result = _runner.invoke(config_app, ["list", "--format", fmt])
|
|
|
|
|
|
@when("I run config list with --show-secrets")
|
|
def step_run_config_list_show_secrets(context: Context) -> None:
|
|
context.result = _runner.invoke(config_app, ["list", "--show-secrets"])
|
|
|
|
|
|
@then("the config list should succeed")
|
|
def step_config_list_succeed(context: Context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit 0, got {context.result.exit_code}: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the config list output should contain multiple settings")
|
|
def step_config_list_multiple(context: Context) -> None:
|
|
# The rich table should have many rows; check for known registry keys
|
|
assert "core.log.level" in context.result.output, (
|
|
f"Expected 'core.log.level' in output: {context.result.output}"
|
|
)
|
|
assert "plan.concurrency" in context.result.output, (
|
|
f"Expected 'plan.concurrency' in output: {context.result.output}"
|
|
)
|
|
|
|
|
|
@then('every listed key should match "{pattern}"')
|
|
def step_every_key_matches(context: Context, pattern: str) -> None:
|
|
import re
|
|
|
|
pat = re.compile(pattern)
|
|
# We check that all lines with key-like content match
|
|
# In table output, the key column is the leftmost data
|
|
output = context.result.output
|
|
# At minimum, no keys outside the filter should appear
|
|
assert pat.search(output), (
|
|
f"Expected at least one match for '{pattern}' in: {output}"
|
|
)
|
|
|
|
|
|
@then("the config list should fail with regex error")
|
|
def step_config_list_fail_regex(context: Context) -> None:
|
|
assert context.result.exit_code != 0, (
|
|
f"Expected non-zero exit, got {context.result.exit_code}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SECRET MASKING
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("secret keys should show masked values")
|
|
def step_secrets_masked(context: Context) -> None:
|
|
output = context.result.output
|
|
# If any API key field is shown, it should be masked
|
|
if "api-key" in output or "token" in output:
|
|
assert "****" in output, f"Expected masked values (****) in output: {output}"
|
|
|
|
|
|
@then("secret keys should show actual values")
|
|
def step_secrets_shown(context: Context) -> None:
|
|
# With --show-secrets, the mask should not appear for fields with None
|
|
# Since defaults are None, this just checks the flag is accepted
|
|
assert context.result.exit_code == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RESOLUTION CHAIN
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the resolution chain should include "{source}" source')
|
|
def step_resolution_chain_source(context: Context, source: str) -> None:
|
|
output = context.result.output
|
|
assert source in output, f"Expected '{source}' in resolution chain: {output}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ROUNDTRIP HELPERS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('I have set config "{key}" to "{value}"')
|
|
def step_preset_config(context: Context, key: str, value: str) -> None:
|
|
result = _runner.invoke(config_app, ["set", key, value])
|
|
assert result.exit_code == 0, f"Pre-set failed: {result.output}"
|
|
|
|
|
|
@then('the get value source should be "{source}"')
|
|
def step_get_source_is(context: Context, source: str) -> None:
|
|
output = context.result.output
|
|
assert source in output, f"Expected source '{source}' in: {output}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FORMAT CHECKS
|
|
# ---------------------------------------------------------------------------
|
|
# NOTE: "the output should be valid JSON" step is defined in
|
|
# cli_output_formats_steps.py and reused here.
|