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
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
313 lines
11 KiB
Python
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"
|
|
)
|