Files
cleveragents-core/features/steps/config_three_scope_steps.py
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

354 lines
12 KiB
Python

"""Step definitions for config_three_scope.feature — three-scope resolution."""
from __future__ import annotations
import os
import shutil
import tempfile
import tomllib
from pathlib import Path
from typing import Any
import tomlkit
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services import config_service as _config_service_mod
from cleveragents.application.services.config_service import (
ConfigLevel,
ConfigScope,
ConfigService,
discover_project_root,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _svc(context: Context) -> ConfigService:
return context._three_scope_service
def _write_toml(path: Path, data: dict[str, Any]) -> None:
"""Write *data* as TOML to *path*, creating parent dirs."""
path.parent.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
for key, value in data.items():
doc[key] = value
with open(path, "w") as fh:
tomlkit.dump(doc, fh)
def _read_toml(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
with open(path, "rb") as fh:
return tomllib.load(fh)
def _cleanup(context: Context) -> None:
tmpdir = getattr(context, "_three_scope_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean three-scope config environment")
def step_clean_three_scope_env(context: Context) -> None:
tmpdir = tempfile.mkdtemp()
context._three_scope_tmpdir = tmpdir
base = Path(tmpdir)
# Global config dir
global_dir = base / "global_config"
global_dir.mkdir()
# Project root (with marker)
project_root = base / "project"
project_root.mkdir()
(project_root / "cleveragents.toml").touch()
context._three_scope_global_dir = global_dir
context._three_scope_project_root = project_root
context._three_scope_service = ConfigService(
config_dir=global_dir,
config_path=global_dir / "config.toml",
project_root=project_root,
)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: _cleanup(context))
# ---------------------------------------------------------------------------
# Given — scope values
# ---------------------------------------------------------------------------
@given('a global config value for three-scope "{key}" = "{value}"')
def step_global_value(context: Context, key: str, value: str) -> None:
svc = _svc(context)
data = svc.read_config()
data[key] = value
svc.write_config(data)
@given('a project config value "{key}" = "{value}"')
def step_project_value(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.toml"
data = _read_toml(path)
data[key] = value
_write_toml(path, data)
@given('a local config value "{key}" = "{value}"')
def step_local_value(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
data = _read_toml(path)
data[key] = value
_write_toml(path, data)
# Given — nested data for deep merge
@given('a global config with nested data "{dotted}" = "{value}"')
def step_global_nested(context: Context, dotted: str, value: str) -> None:
svc = _svc(context)
data = svc.read_config()
section, key = dotted.split(".", 1)
data.setdefault(section, {})[key] = value
svc.write_config(data)
@given('a project config with nested data "{dotted}" = "{value}"')
def step_project_nested(context: Context, dotted: str, value: str) -> None:
path = context._three_scope_project_root / "config.toml"
data = _read_toml(path)
section, key = dotted.split(".", 1)
data.setdefault(section, {})[key] = value
_write_toml(path, data)
@given('a local config with nested data "{dotted}" = "{value}"')
def step_local_nested(context: Context, dotted: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
data = _read_toml(path)
section, key = dotted.split(".", 1)
data.setdefault(section, {})[key] = value
_write_toml(path, data)
# Given — project root discovery
@given('a directory tree with "{marker}" marker at the root')
def step_dir_tree_with_marker_file(context: Context, marker: str) -> None:
tmpdir = tempfile.mkdtemp()
context._discovery_tmpdir = tmpdir
root = Path(tmpdir) / "project_root"
root.mkdir()
sub = root / "a" / "b"
sub.mkdir(parents=True)
target = root / marker
if marker.startswith("."):
target.mkdir(parents=True, exist_ok=True)
else:
target.touch()
context._discovery_root = root
context._discovery_sub = sub
@given('a directory tree with ".cleveragents" directory marker at the root')
def step_dir_tree_with_dir_marker(context: Context) -> None:
tmpdir = tempfile.mkdtemp()
context._discovery_tmpdir = tmpdir
root = Path(tmpdir) / "project_root"
root.mkdir()
sub = root / "a" / "b"
sub.mkdir(parents=True)
(root / ".cleveragents").mkdir()
context._discovery_root = root
context._discovery_sub = sub
@given("a directory tree with no project markers")
def step_dir_tree_no_markers(context: Context) -> None:
tmpdir = tempfile.mkdtemp()
context._discovery_tmpdir = tmpdir
context._discovery_sub = Path(tmpdir)
# Temporarily replace markers with a name that cannot exist in the
# ancestor chain so the walk-up never matches a real directory such
# as /tmp/.cleveragents left by a prior CleverAgents installation.
context._original_markers = _config_service_mod._PROJECT_ROOT_MARKERS
_config_service_mod._PROJECT_ROOT_MARKERS = ("__cleveragents_nonexistent_marker__",)
context._cleanup_handlers.append(
lambda: setattr(
_config_service_mod,
"_PROJECT_ROOT_MARKERS",
context._original_markers,
)
)
# Given — local config file
@given('a local config file with "{key}" = "{value}"')
def step_local_file_with_key(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
data = _read_toml(path)
data[key] = value
_write_toml(path, data)
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when('I resolve three-scope key "{key}"')
def step_resolve_three_scope(context: Context, key: str) -> None:
svc = _svc(context)
context._three_scope_resolved = svc.resolve(key)
@when("I read the three-scope merged config")
def step_read_merged(context: Context) -> None:
svc = _svc(context)
context._three_scope_merged = svc.read_merged_config()
@when("I discover the project root from a subdirectory")
def step_discover_from_subdir(context: Context) -> None:
context._discovered_root = discover_project_root(context._discovery_sub)
@when("I discover the project root")
def step_discover_project_root(context: Context) -> None:
context._discovered_root = discover_project_root(context._discovery_sub)
@when("I read the local config")
def step_read_local(context: Context) -> None:
svc = _svc(context)
context._local_config = svc.read_local_config()
@when('I set three-scope value "{key}" to "{value}" with scope "{scope}"')
def step_set_scoped(context: Context, key: str, value: str, scope: str) -> None:
svc = _svc(context)
config_scope = ConfigScope(scope)
svc.set_value(key, value, scope=config_scope)
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then('the three-scope resolved value should be "{value}"')
def step_check_value(context: Context, value: str) -> None:
actual = str(context._three_scope_resolved.value)
assert actual == value, f"Expected '{value}', got '{actual}'"
@then('the three-scope resolved source should be "{source}"')
def step_check_source(context: Context, source: str) -> None:
actual = context._three_scope_resolved.source.value
assert actual == source, f"Expected source '{source}', got '{actual}'"
@then('the merged config "{section}" should contain key "{key}" with value "{value}"')
def step_merged_section_key(
context: Context, section: str, key: str, value: str
) -> None:
merged = context._three_scope_merged
assert section in merged, f"Section '{section}' not in merged config: {merged}"
sec = merged[section]
assert key in sec, f"Key '{key}' not in section '{section}': {sec}"
assert str(sec[key]) == value, (
f"Expected '{value}' for {section}.{key}, got '{sec[key]}'"
)
@then("the discovered project root should match the marker directory")
def step_root_matches(context: Context) -> None:
expected = context._discovery_root.resolve()
actual = context._discovered_root
assert actual is not None, "Expected a project root, got None"
assert actual.resolve() == expected, f"Expected {expected}, got {actual.resolve()}"
@then("the discovered project root should be None")
def step_root_none(context: Context) -> None:
assert context._discovered_root is None, (
f"Expected None, got {context._discovered_root}"
)
@then("the local config should be an empty dict")
def step_local_empty(context: Context) -> None:
assert context._local_config == {}, (
f"Expected empty dict, got {context._local_config}"
)
@then('the local config file should contain "{key}" = "{value}"')
def step_local_file_contains(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.local.toml"
data = _read_toml(path)
assert key in data, f"Key '{key}' not in local config: {data}"
assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'"
@then('the project config file should contain "{key}" = "{value}"')
def step_project_file_contains(context: Context, key: str, value: str) -> None:
path = context._three_scope_project_root / "config.toml"
data = _read_toml(path)
assert key in data, f"Key '{key}' not in project config: {data}"
assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'"
@then('the global config file should contain "{key}" = "{value}"')
def step_global_file_contains(context: Context, key: str, value: str) -> None:
svc = _svc(context)
data = svc.read_config()
assert key in data, f"Key '{key}' not in global config: {data}"
assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'"
@then('ConfigScope should have value "{value}"')
def step_scope_enum_value(context: Context, value: str) -> None:
scope = ConfigScope(value)
assert scope.value == value, f"Expected ConfigScope.{value}, got {scope}"
@then('ConfigLevel should have value "{value}"')
def step_level_enum_value(context: Context, value: str) -> None:
level = ConfigLevel(value)
assert level.value == value, f"Expected ConfigLevel.{value}, got {level}"
@then('ConfigLevel "{value}" should exist between "{before}" and "{after}"')
def step_level_ordering(context: Context, value: str, before: str, after: str) -> None:
members = [m.value for m in ConfigLevel]
idx_before = members.index(before)
idx_value = members.index(value)
idx_after = members.index(after)
assert idx_before < idx_value < idx_after, (
f"Expected {before} < {value} < {after} in {members}"
)
@then('the default ignore patterns should include "{pattern}"')
def step_ignore_patterns(context: Context, pattern: str) -> None:
from cleveragents.application.services.context_service import (
DEFAULT_IGNORE_PATTERNS,
)
assert pattern in DEFAULT_IGNORE_PATTERNS, (
f"'{pattern}' not in DEFAULT_IGNORE_PATTERNS: {DEFAULT_IGNORE_PATTERNS}"
)