Files
cleveragents-core/features/steps/config_project_scope_steps.py
T
freemo 7d74be68aa
CI / lint (pull_request) Successful in 14s
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 28s
CI / typecheck (pull_request) Successful in 33s
CI / build (pull_request) Successful in 14s
CI / security (pull_request) Successful in 35s
CI / unit_tests (pull_request) Successful in 2m52s
CI / docker (pull_request) Successful in 39s
CI / integration_tests (pull_request) Successful in 3m53s
CI / coverage (pull_request) Successful in 3m42s
CI / lint (push) Successful in 13s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 16s
CI / security (push) Successful in 29s
CI / typecheck (push) Successful in 32s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 1m53s
CI / docker (push) Successful in 53s
CI / integration_tests (push) Successful in 2m49s
CI / coverage (push) Successful in 3m50s
CI / benchmark-publish (push) Successful in 13m7s
CI / benchmark-regression (pull_request) Successful in 28m12s
feat(config): add project-scoped config overrides
Add --project flag to config set, config get, and config list CLI
commands, enabling per-project configuration overrides stored under
[project."<name>"] TOML tables in the global config file.

Project-scoped resolution slots between environment variable and global
levels in the ConfigService resolution chain. config list --project
shows only overrides for the named project with source annotations.

Implementation:
- ConfigService: add set_project_value() and get_project_overrides()
  methods for TOML-backed project-scoped persistence and retrieval
- CLI config commands: wire --project flag through set, get, and list
  subcommands; project-scoped list filters to overrides only
- Database persistence: project-scoped config stored as alternative
  backend for projects not using TOML
- Documentation: update docs/reference/config_resolution.md with
  project-scopable key lists, CLI examples, precedence diagram, and
  non-scopable key rejection behavior

Tests:
- Behave: 12 BDD scenarios in features/config_project_scope.feature
  covering set/get/list, precedence over global defaults, and
  non-scopable key rejection
- Robot: 5 integration smoke tests in robot/config_project_scope.robot
  for end-to-end project-scoped round-trip verification
- ASV: benchmarks/config_project_scope_bench.py measuring resolution
  overhead with project scope active

All nox quality gates pass: lint, typecheck, unit_tests (7522 scenarios),
integration_tests (Config Project Scope suite passed), and coverage at
98% line rate (threshold 97%).

Closes #259
2026-03-02 15:05:20 +00:00

313 lines
11 KiB
Python

"""Step definitions for config_project_scope.feature."""
from __future__ import annotations
import json
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.application.services.config_service import ConfigService
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import app as config_app
_runner = CliRunner()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_service(context: Context) -> ConfigService:
"""Return the test-scoped ConfigService instance."""
svc: ConfigService = context._ps_config_service
return svc
def _cleanup(context: Context) -> None:
"""Clean up temp dir."""
tmpdir: str | None = getattr(context, "_ps_config_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean project-scoped config environment")
def step_clean_project_scoped_env(context: Context) -> None:
"""Set up an isolated temp config directory for project-scoped tests."""
tmpdir = tempfile.mkdtemp()
context._ps_config_tmpdir = tmpdir
tmp_path = Path(tmpdir)
context._ps_config_service = ConfigService(
config_dir=tmp_path,
config_path=tmp_path / "config.toml",
)
context._ps_config_dir = tmp_path
context._ps_config_path = tmp_path / "config.toml"
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: _cleanup(context))
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given('a global config value "{key}" = "{value}"')
def step_set_global_config(context: Context, key: str, value: str) -> None:
svc = _get_service(context)
data = svc.read_config()
data[key] = value
svc.write_config(data)
@given('a project-scoped config value "{key}" = "{value}" for project "{project}"')
def step_set_project_config(
context: Context, key: str, value: str, project: str
) -> None:
svc = _get_service(context)
data = svc.read_config()
proj_section: dict[str, Any] = data.get("project", {})
if not isinstance(proj_section, dict):
proj_section = {}
proj_overrides: dict[str, Any] = proj_section.get(project, {})
if not isinstance(proj_overrides, dict):
proj_overrides = {}
proj_overrides[key] = value
proj_section[project] = proj_overrides
data["project"] = proj_section
svc.write_config(data)
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when('I set config "{key}" to "{value}" for project "{project}"')
def step_set_config_for_project(
context: Context, key: str, value: str, project: str
) -> None:
svc = _get_service(context)
data = svc.read_config()
proj_section: dict[str, Any] = data.get("project", {})
if not isinstance(proj_section, dict):
proj_section = {}
proj_overrides: dict[str, Any] = proj_section.get(project, {})
if not isinstance(proj_overrides, dict):
proj_overrides = {}
proj_overrides[key] = value
proj_section[project] = proj_overrides
data["project"] = proj_section
svc.write_config(data)
@when('I get config "{key}" for project "{project}"')
def step_get_config_for_project(context: Context, key: str, project: str) -> None:
svc = _get_service(context)
context.resolved = svc.resolve(key, project_name=project)
@when('I list config for project "{project}"')
def step_list_config_for_project(context: Context, project: str) -> None:
svc = _get_service(context)
context.project_overrides = svc.get_project_overrides(project)
@when('I attempt to set non-scopable key "{key}" to "{value}" for project "{project}"')
def step_attempt_set_non_scopable(
context: Context, key: str, value: str, project: str
) -> None:
svc = _get_service(context)
try:
svc.set_project_value(project, key, value)
context.raised_error = None
except (ValueError, TypeError) as exc:
context.raised_error = exc
@when('I run config set "{key}" "{value}" with project "{project}" via CLI')
def step_run_cli_set_with_project(
context: Context, key: str, value: str, project: str
) -> None:
tmp_dir: Path = context._ps_config_dir
tmp_path: Path = context._ps_config_path
with (
patch.object(config_mod, "_CONFIG_DIR", tmp_dir),
patch.object(config_mod, "_CONFIG_PATH", tmp_path),
):
context.result = _runner.invoke(
config_app, ["set", key, value, "--project", project]
)
@when('I run config get "{key}" with project "{project}" via CLI')
def step_run_cli_get_with_project(context: Context, key: str, project: str) -> None:
tmp_dir: Path = context._ps_config_dir
tmp_path: Path = context._ps_config_path
with (
patch.object(config_mod, "_CONFIG_DIR", tmp_dir),
patch.object(config_mod, "_CONFIG_PATH", tmp_path),
):
context.result = _runner.invoke(
config_app, ["get", key, "--project", project, "--format", "json"]
)
@when('I run config list with project "{project}" via CLI')
def step_run_cli_list_with_project(context: Context, project: str) -> None:
tmp_dir: Path = context._ps_config_dir
tmp_path: Path = context._ps_config_path
with (
patch.object(config_mod, "_CONFIG_DIR", tmp_dir),
patch.object(config_mod, "_CONFIG_PATH", tmp_path),
):
context.result = _runner.invoke(
config_app, ["list", "--project", project, "--format", "json"]
)
@when("I attempt to get overrides for empty project name")
def step_attempt_empty_project_name(context: Context) -> None:
svc = _get_service(context)
try:
svc.get_project_overrides("")
context.raised_error = None
except ValueError as exc:
context.raised_error = exc
@when('I use service set_project_value for "{project}" key "{key}" value "{value}"')
def step_use_service_set_project_value(
context: Context, project: str, key: str, value: str
) -> None:
svc = _get_service(context)
svc.set_project_value(project, key, value)
@when('I get all project overrides for "{project}"')
def step_get_all_project_overrides(context: Context, project: str) -> None:
svc = _get_service(context)
context.project_overrides = svc.get_project_overrides(project)
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then('the project-scoped config for "{project}" should contain "{key}" = "{value}"')
def step_project_config_contains(
context: Context, project: str, key: str, value: str
) -> None:
svc = _get_service(context)
overrides = svc.get_project_overrides(project)
assert key in overrides, (
f"Expected key '{key}' in project overrides, got: {list(overrides.keys())}"
)
assert str(overrides[key]) == value, f"Expected '{value}', got '{overrides[key]}'"
@then('the project-resolved value should be "{value}"')
def step_project_resolved_value(context: Context, value: str) -> None:
assert str(context.resolved.value) == value, (
f"Expected '{value}', got '{context.resolved.value}'"
)
@then("the output should show {count:d} project-scoped overrides")
def step_project_override_count(context: Context, count: int) -> None:
overrides: dict[str, Any] = context.project_overrides
assert len(overrides) == count, (
f"Expected {count} overrides, got {len(overrides)}: {list(overrides.keys())}"
)
@then("the project set should fail with a scopable error")
def step_project_set_fail_scopable(context: Context) -> None:
assert context.raised_error is not None, "Expected ValueError but none was raised"
assert "not project-scopable" in str(context.raised_error), (
f"Expected 'not project-scopable' in: {context.raised_error}"
)
@then("the CLI config set should succeed")
def step_cli_set_succeed(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}: {context.result.output}"
)
@then('the CLI output should mention project scope "{project}"')
def step_cli_output_mentions_project(context: Context, project: str) -> None:
output: str = context.result.output
assert project in output, f"Expected '{project}' in output: {output}"
@then("the CLI config get should succeed")
def step_cli_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 CLI config list should succeed")
def step_cli_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 CLI list output should show exactly {count:d} entries")
def step_cli_list_exact_count(context: Context, count: int) -> None:
output: str = context.result.output
data: list[dict[str, Any]] = json.loads(output)
assert len(data) == count, f"Expected {count} entries, got {len(data)}: {data}"
@then("the CLI config list should show no overrides message")
def step_cli_list_no_overrides(context: Context) -> None:
output: str = context.result.output
# When no overrides and using JSON format, an empty or message output
# The command should still succeed (exit 0) but indicate nothing found
assert context.result.exit_code == 0 or "No project-scoped overrides" in output, (
f"Expected no-overrides output, got: {output}"
)
@then("the empty project name should raise ValueError")
def step_empty_project_raises(context: Context) -> None:
assert context.raised_error is not None, "Expected ValueError but none was raised"
assert isinstance(context.raised_error, ValueError), (
f"Expected ValueError, got {type(context.raised_error).__name__}"
)
@then("the project overrides should have {count:d} keys")
def step_project_overrides_key_count(context: Context, count: int) -> None:
overrides: dict[str, Any] = context.project_overrides
assert len(overrides) == count, (
f"Expected {count} keys, got {len(overrides)}: {list(overrides.keys())}"
)
@then('the project overrides should not contain "{key}"')
def step_project_overrides_not_contain(context: Context, key: str) -> None:
overrides: dict[str, Any] = context.project_overrides
assert key not in overrides, (
f"Expected '{key}' NOT in project overrides, but it was present"
)