Files
cleveragents-core/robot/helper_config_resolution.py
T
freemo 7b3fcaf466
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 19s
CI / helm (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 3m19s
CI / quality (pull_request) Successful in 3m49s
CI / typecheck (pull_request) Successful in 3m57s
CI / integration_tests (pull_request) Successful in 3m57s
CI / security (pull_request) Successful in 4m6s
CI / unit_tests (pull_request) Successful in 7m19s
CI / docker (pull_request) Successful in 1m18s
CI / coverage (pull_request) Successful in 11m53s
CI / e2e_tests (pull_request) Successful in 21m25s
CI / status-check (pull_request) Successful in 1s
CI / build (push) Successful in 18s
CI / helm (push) Successful in 22s
CI / lint (push) Successful in 3m16s
CI / quality (push) Successful in 3m41s
CI / integration_tests (push) Successful in 3m48s
CI / typecheck (push) Successful in 3m54s
CI / unit_tests (push) Successful in 3m54s
CI / security (push) Successful in 4m4s
CI / docker (push) Successful in 1m19s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Successful in 14m58s
CI / coverage (push) Successful in 11m55s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 28m23s
CI / benchmark-regression (pull_request) Successful in 54m47s
feat(config): implement three-scope config resolution with local config file support
Implement three-scope configuration resolution (local > project > global)
with deep merge. Add ConfigScope enum, project-root discovery, and
config.local.toml file support.

Changes:
- Add LOCAL to ConfigLevel enum and new ConfigScope enum (GLOBAL, PROJECT, LOCAL)
- Implement discover_project_root() walking up from CWD for cleveragents.toml/.cleveragents
- Implement config.local.toml file loading in ConfigService
- Implement three-scope deep merge via _deep_merge() helper
- Add read_project_config(), read_local_config(), read_merged_config() methods
- Add write_scoped_config() for writing to any scope file
- Update set_value() to accept optional scope parameter
- Update resolve() with six-level precedence chain (CLI > env > local > project > global > default)
- Update CLI config set with --scope flag (global/project/local)
- Add config.local.toml to .gitignore and DEFAULT_IGNORE_PATTERNS
- Add Behave BDD scenarios for three-scope resolution, deep merge, and project-root discovery

ISSUES CLOSED: #937
2026-03-30 21:48:01 +00:00

335 lines
11 KiB
Python

"""Helper script for config_resolution.robot end-to-end tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from unittest.mock import patch
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.application.services.config_service import ( # noqa: E402
ConfigLevel,
ConfigService,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_service() -> tuple[ConfigService, Path]:
"""Create a ConfigService backed by an isolated temp directory."""
tmpdir = Path(tempfile.mkdtemp())
svc = ConfigService(
config_dir=tmpdir,
config_path=tmpdir / "config.toml",
project_root=None,
)
return svc, tmpdir
def _cleanup(tmpdir: Path) -> None:
"""Remove the temp directory ignoring errors."""
shutil.rmtree(str(tmpdir), ignore_errors=True)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def registry_key_count() -> None:
"""Verify that the registry contains exactly 105 keys.
102 spec-defined + 1 skills extension + 2 server stub keys
(server.tls-verify, server.namespace).
"""
registry = ConfigService.registry()
count = len(registry)
if count == 105:
print("config-registry-key-count-ok")
else:
print(f"FAIL: expected 105 keys, got {count}", file=sys.stderr)
sys.exit(1)
def resolve_default() -> None:
"""Verify default resolution with no overrides."""
svc, tmpdir = _make_service()
try:
result = svc.resolve("core.log.level")
if result.value == "FATAL" and result.source == ConfigLevel.DEFAULT:
print("config-resolution-default-ok")
else:
print(
f"FAIL: expected FATAL/default, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
_cleanup(tmpdir)
def resolve_global() -> None:
"""Verify that a TOML file value overrides the default."""
svc, tmpdir = _make_service()
try:
svc.set_value("core.log.level", "DEBUG")
result = svc.resolve("core.log.level")
if result.value == "DEBUG" and result.source == ConfigLevel.GLOBAL:
print("config-resolution-global-ok")
else:
print(
f"FAIL: expected DEBUG/global, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
_cleanup(tmpdir)
def resolve_env() -> None:
"""Verify that env var overrides the global config value."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_LOG_LEVEL"
old_val = os.environ.get(env_key)
try:
# Set a global value first so we can prove env wins
svc.set_value("core.log.level", "DEBUG")
os.environ[env_key] = "WARNING"
result = svc.resolve("core.log.level")
if result.value == "WARNING" and result.source == ConfigLevel.ENV_VAR:
print("config-resolution-env-ok")
else:
print(
f"FAIL: expected WARNING/env_var, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
if old_val is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
_cleanup(tmpdir)
def resolve_cli() -> None:
"""Verify that cli_value has the highest priority."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_LOG_LEVEL"
old_val = os.environ.get(env_key)
try:
# Set all lower levels so we can prove CLI wins
svc.set_value("core.log.level", "DEBUG")
os.environ[env_key] = "WARNING"
result = svc.resolve("core.log.level", cli_value="ERROR")
if result.value == "ERROR" and result.source == ConfigLevel.CLI_FLAG:
print("config-resolution-cli-ok")
else:
print(
f"FAIL: expected ERROR/cli_flag, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
if old_val is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
_cleanup(tmpdir)
def resolve_project() -> None:
"""Verify project-scoped config resolution via project_name."""
svc, tmpdir = _make_service()
try:
# Write project-scoped override into the TOML file
import tomlkit
doc = tomlkit.document()
project_table = tomlkit.table()
myproj_table = tomlkit.table()
myproj_table["core.automation-profile"] = "full-auto"
project_table["myproject"] = myproj_table
doc["project"] = project_table
svc._config_dir.mkdir(parents=True, exist_ok=True)
with open(svc._config_path, "w") as fh:
tomlkit.dump(doc, fh)
result = svc.resolve("core.automation-profile", project_name="myproject")
if result.value == "full-auto" and result.source == ConfigLevel.PROJECT:
print("config-resolution-project-ok")
else:
print(
f"FAIL: expected full-auto/project, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
_cleanup(tmpdir)
def resolve_verbose_chain() -> None:
"""Verify verbose=True populates all 6 chain entries."""
svc, tmpdir = _make_service()
try:
result = svc.resolve("core.log.level", verbose=True)
chain_len = len(result.chain)
if chain_len == 6:
# Verify the sources in order
expected_sources = [
ConfigLevel.CLI_FLAG.value,
ConfigLevel.ENV_VAR.value,
ConfigLevel.LOCAL.value,
ConfigLevel.PROJECT.value,
ConfigLevel.GLOBAL.value,
ConfigLevel.DEFAULT.value,
]
actual_sources = [entry["source"] for entry in result.chain]
if actual_sources == expected_sources:
print("config-resolution-verbose-chain-ok")
else:
print(
f"FAIL: chain sources mismatch: {actual_sources}",
file=sys.stderr,
)
sys.exit(1)
else:
print(
f"FAIL: expected 6 chain entries, got {chain_len}",
file=sys.stderr,
)
sys.exit(1)
finally:
_cleanup(tmpdir)
def env_var_provider() -> None:
"""Verify that provider.openai.api-key maps to OPENAI_API_KEY."""
env_name = ConfigService.env_var_for_key("provider.openai.api-key")
if env_name == "OPENAI_API_KEY":
print("config-env-var-provider-ok")
else:
print(
f"FAIL: expected OPENAI_API_KEY, got {env_name}",
file=sys.stderr,
)
sys.exit(1)
def type_coercion() -> None:
"""Verify str→int, str→float, and str→bool coercion."""
errors: list[str] = []
# str → int
result_int = ConfigService.validate_type("core.log.retention-days", "45")
if result_int != 45 or not isinstance(result_int, int):
errors.append(
f"int coercion: expected 45 (int), got {result_int!r} "
f"({type(result_int).__name__})"
)
# str → float
result_float = ConfigService.validate_type("plan.budget.per-plan", "3.14")
if result_float != 3.14 or not isinstance(result_float, float):
errors.append(
f"float coercion: expected 3.14 (float), got {result_float!r} "
f"({type(result_float).__name__})"
)
# str → bool (true)
result_bool_t = ConfigService.validate_type("core.log.file-enabled", "true")
if result_bool_t is not True:
errors.append(f"bool coercion (true): got {result_bool_t!r}")
# str → bool (false)
result_bool_f = ConfigService.validate_type("core.log.file-enabled", "false")
if result_bool_f is not False:
errors.append(f"bool coercion (false): got {result_bool_f!r}")
if errors:
for err in errors:
print(f"FAIL: {err}", file=sys.stderr)
sys.exit(1)
else:
print("config-type-coercion-ok")
def cli_list_integration() -> None:
"""Verify ``agents config list`` outputs all registered keys."""
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()
tmpdir = Path(tempfile.mkdtemp())
tmppath = tmpdir / "config.toml"
try:
with (
patch.object(config_mod, "_CONFIG_DIR", tmpdir),
patch.object(config_mod, "_CONFIG_PATH", tmppath),
):
result = runner.invoke(config_app, ["list"])
if result.exit_code != 0:
print(f"FAIL: config list exited {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
output = result.output
# Spot-check several representative keys from different sections
missing: list[str] = []
for key_fragment in ["log.level", "automation-profile", "openai.api-key"]:
if key_fragment not in output:
missing.append(key_fragment)
if missing:
print(
f"FAIL: missing key fragments in output: {missing}",
file=sys.stderr,
)
sys.exit(1)
print("config-cli-list-integration-ok")
finally:
_cleanup(tmpdir)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"registry-key-count": registry_key_count,
"resolve-default": resolve_default,
"resolve-global": resolve_global,
"resolve-env": resolve_env,
"resolve-cli": resolve_cli,
"resolve-project": resolve_project,
"resolve-verbose-chain": resolve_verbose_chain,
"env-var-provider": env_var_provider,
"type-coercion": type_coercion,
"cli-list-integration": cli_list_integration,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
_COMMANDS[sys.argv[1]]()