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
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
354 lines
12 KiB
Python
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}"
|
|
)
|