feat(config): add project-scoped config overrides
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
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
This commit was merged in pull request #504.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"""ASV benchmarks for project-scoped config resolution overhead.
|
||||
|
||||
Measures the performance impact of project-scoped resolution compared
|
||||
to global-only resolution, including:
|
||||
- Resolution with project scope active
|
||||
- Resolution with project scope inactive (no project name)
|
||||
- get_project_overrides() lookup
|
||||
- set_project_value() write
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.application.services.config_service import ( # noqa: E402
|
||||
ConfigService,
|
||||
)
|
||||
|
||||
|
||||
class ProjectScopeResolutionTimeSuite:
|
||||
"""Benchmark timing for project-scoped config resolution."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
# Pre-populate some project-scoped values
|
||||
self._service.set_project_value(
|
||||
"bench-project", "core.automation-profile", "trusted"
|
||||
)
|
||||
self._service.set_project_value("bench-project", "plan.concurrency", "16")
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_with_project_scope(self) -> None:
|
||||
"""Resolve a key with project scope active (project override exists)."""
|
||||
self._service.resolve("core.automation-profile", project_name="bench-project")
|
||||
|
||||
def time_resolve_without_project_scope(self) -> None:
|
||||
"""Resolve a key without project scope (baseline)."""
|
||||
self._service.resolve("core.automation-profile")
|
||||
|
||||
def time_resolve_project_scope_no_override(self) -> None:
|
||||
"""Resolve a key with project scope but no override for that key."""
|
||||
self._service.resolve("core.log.level", project_name="bench-project")
|
||||
|
||||
def time_get_project_overrides(self) -> None:
|
||||
"""Look up all overrides for a project."""
|
||||
self._service.get_project_overrides("bench-project")
|
||||
|
||||
def time_set_project_value(self) -> None:
|
||||
"""Write a project-scoped value."""
|
||||
self._service.set_project_value("bench-project", "plan.concurrency", "32")
|
||||
|
||||
def time_resolve_all_with_project(self) -> None:
|
||||
"""Resolve all keys with a project name active."""
|
||||
self._service.resolve_all(project_name="bench-project")
|
||||
|
||||
|
||||
class ProjectScopeResolutionMemSuite:
|
||||
"""Benchmark memory consumption for project-scoped operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
self._service.set_project_value(
|
||||
"bench-project", "core.automation-profile", "trusted"
|
||||
)
|
||||
self._service.set_project_value("bench-project", "plan.concurrency", "16")
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def mem_resolve_all_with_project(self) -> dict:
|
||||
"""Memory for resolving all keys with project scope."""
|
||||
return self._service.resolve_all(project_name="bench-project")
|
||||
|
||||
def mem_get_project_overrides(self) -> dict:
|
||||
"""Memory for get_project_overrides."""
|
||||
return self._service.get_project_overrides("bench-project")
|
||||
@@ -69,16 +69,55 @@ checks this table at priority 3 before falling back to the global value.
|
||||
|
||||
**Partially project-scopable groups:**
|
||||
|
||||
- `core.*` — `log.level`, `debug.enabled`, `env`, `data-dir`
|
||||
- `actor.*` — `timeout`, `max-retries`, `concurrency`
|
||||
- `plan.*` — `auto-apply`, `max-retries`, `timeout`, `budget.per-plan`
|
||||
- `sandbox.*` — `strategy`, `auto-cleanup`, `max-age-hours`
|
||||
- `index.*` — `enabled`, `backend`, `embedding-model`, `embedding-dimension`
|
||||
- `core.*` — `automation-profile`
|
||||
- `plan.*` — `concurrency`, `max-child-depth`, `budget.per-plan`
|
||||
- `sandbox.*` — `strategy`, `checkpoint.enabled`
|
||||
- `skills.*` — `agent_skills_paths`
|
||||
|
||||
**Never project-scopable:**
|
||||
|
||||
- `server.*` — applies globally to the running process
|
||||
- `provider.*` — credentials are always global
|
||||
- `actor.*` — actor defaults are global
|
||||
- `index.*` — index backends are global
|
||||
|
||||
### Setting project overrides
|
||||
|
||||
Use the `--project` flag on `config set` to store a value under the
|
||||
project-scoped TOML table:
|
||||
|
||||
```bash
|
||||
agents config set core.automation-profile manual --project my-project
|
||||
agents config set plan.concurrency 16 --project my-project
|
||||
```
|
||||
|
||||
### Reading project-scoped values
|
||||
|
||||
When `--project` is provided, the resolver checks the project table at
|
||||
priority 3 between environment variables and the global config file:
|
||||
|
||||
```bash
|
||||
agents config get core.automation-profile --project my-project
|
||||
```
|
||||
|
||||
### Listing project overrides only
|
||||
|
||||
`config list --project <name>` shows **only** the keys that have been
|
||||
explicitly overridden for that project — not the full resolved view:
|
||||
|
||||
```bash
|
||||
agents config list --project my-project
|
||||
```
|
||||
|
||||
### Non-scopable key rejection
|
||||
|
||||
Attempting to set a non-project-scopable key (e.g. `core.data-dir`) via
|
||||
`--project` raises an error:
|
||||
|
||||
```bash
|
||||
$ agents config set core.data-dir /tmp --project my-project
|
||||
Error: Key 'core.data-dir' is not project-scopable.
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
Feature: Project-scoped configuration overrides
|
||||
As a user managing multiple projects
|
||||
I want to set config overrides per project
|
||||
So that different projects can have different settings
|
||||
|
||||
Background:
|
||||
Given a clean project-scoped config environment
|
||||
|
||||
Scenario: Set a project-scoped config value
|
||||
When I set config "core.automation-profile" to "manual" for project "my-project"
|
||||
Then the project-scoped config for "my-project" should contain "core.automation-profile" = "manual"
|
||||
|
||||
Scenario: Project-scoped value overrides global
|
||||
Given a global config value "core.automation-profile" = "supervised"
|
||||
And a project-scoped config value "core.automation-profile" = "manual" for project "my-project"
|
||||
When I get config "core.automation-profile" for project "my-project"
|
||||
Then the project-resolved value should be "manual"
|
||||
|
||||
Scenario: Global value used when no project override exists
|
||||
Given a global config value "core.automation-profile" = "supervised"
|
||||
When I get config "core.automation-profile" for project "my-project"
|
||||
Then the project-resolved value should be "supervised"
|
||||
|
||||
Scenario: List project-scoped overrides
|
||||
Given a project-scoped config value "core.automation-profile" = "manual" for project "my-project"
|
||||
And a project-scoped config value "plan.concurrency" = "8" for project "my-project"
|
||||
When I list config for project "my-project"
|
||||
Then the output should show 2 project-scoped overrides
|
||||
|
||||
Scenario: Non-project-scopable key is rejected for project set
|
||||
When I attempt to set non-scopable key "core.data-dir" to "/tmp" for project "my-project"
|
||||
Then the project set should fail with a scopable error
|
||||
|
||||
Scenario: Project set via CLI command
|
||||
When I run config set "core.automation-profile" "trusted" with project "my-project" via CLI
|
||||
Then the CLI config set should succeed
|
||||
And the CLI output should mention project scope "my-project"
|
||||
|
||||
Scenario: Project get via CLI command
|
||||
Given a project-scoped config value "core.automation-profile" = "trusted" for project "my-project"
|
||||
When I run config get "core.automation-profile" with project "my-project" via CLI
|
||||
Then the CLI config get should succeed
|
||||
|
||||
Scenario: Project list via CLI command shows only overrides
|
||||
Given a project-scoped config value "core.automation-profile" = "trusted" for project "cli-proj"
|
||||
And a project-scoped config value "plan.concurrency" = "16" for project "cli-proj"
|
||||
When I run config list with project "cli-proj" via CLI
|
||||
Then the CLI config list should succeed
|
||||
And the CLI list output should show exactly 2 entries
|
||||
|
||||
Scenario: Project list via CLI command with no overrides
|
||||
When I run config list with project "empty-proj" via CLI
|
||||
Then the CLI config list should show no overrides message
|
||||
|
||||
Scenario: Empty project name is rejected by service
|
||||
When I attempt to get overrides for empty project name
|
||||
Then the empty project name should raise ValueError
|
||||
|
||||
Scenario: Set project value via service method
|
||||
When I use service set_project_value for "my-project" key "plan.concurrency" value "12"
|
||||
Then the project-scoped config for "my-project" should contain "plan.concurrency" = "12"
|
||||
|
||||
Scenario: Get project overrides returns only project keys
|
||||
Given a project-scoped config value "core.automation-profile" = "trusted" for project "proj-a"
|
||||
And a project-scoped config value "plan.concurrency" = "6" for project "proj-a"
|
||||
And a global config value "core.log.level" = "DEBUG"
|
||||
When I get all project overrides for "proj-a"
|
||||
Then the project overrides should have 2 keys
|
||||
And the project overrides should not contain "core.log.level"
|
||||
@@ -0,0 +1,312 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for project-scoped config overrides
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_config_project_scope.py
|
||||
|
||||
*** Test Cases ***
|
||||
Project Scoped Set And Get Roundtrip
|
||||
[Documentation] Verify that setting a project-scoped value and reading it back works
|
||||
${result}= Run Process ${PYTHON} ${HELPER} set-get-roundtrip cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-project-scope-set-get-ok
|
||||
|
||||
Project Scoped Value Overrides Global
|
||||
[Documentation] Verify that project-scoped value overrides the global value in resolution
|
||||
${result}= Run Process ${PYTHON} ${HELPER} project-overrides-global cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-project-overrides-global-ok
|
||||
|
||||
Project Scoped List Overrides Only
|
||||
[Documentation] Verify that get_project_overrides returns only project-scoped keys
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-overrides-only cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-project-list-overrides-ok
|
||||
|
||||
Project Scoped CLI Set Get Roundtrip
|
||||
[Documentation] Verify the CLI --project flag works for set and get commands
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cli-roundtrip cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-project-cli-roundtrip-ok
|
||||
|
||||
Non Scopable Key Rejected
|
||||
[Documentation] Verify that a non-project-scopable key is rejected
|
||||
${result}= Run Process ${PYTHON} ${HELPER} non-scopable-rejected cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-project-non-scopable-ok
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Helper script for config_project_scope.robot smoke tests.
|
||||
|
||||
Each subcommand is a self-contained check that prints a sentinel on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
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 typer.testing import CliRunner # noqa: E402
|
||||
|
||||
from cleveragents.application.services.config_service import ( # noqa: E402
|
||||
ConfigLevel,
|
||||
ConfigService,
|
||||
)
|
||||
from cleveragents.cli.commands import config as config_mod # noqa: E402
|
||||
from cleveragents.cli.commands.config import app as config_app # noqa: E402
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
return svc, tmpdir
|
||||
|
||||
|
||||
def _cleanup(tmpdir: Path) -> None:
|
||||
"""Remove the temp directory ignoring errors."""
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_get_roundtrip() -> None:
|
||||
"""Verify project-scoped set followed by get returns the value."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
svc.set_project_value("my-project", "core.automation-profile", "manual")
|
||||
result = svc.resolve("core.automation-profile", project_name="my-project")
|
||||
if result.value == "manual" and result.source == ConfigLevel.PROJECT:
|
||||
print("config-project-scope-set-get-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected manual/project, got {result.value}/{result.source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def project_overrides_global() -> None:
|
||||
"""Verify that project-scoped value overrides global in resolution."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
svc.set_value("core.automation-profile", "supervised")
|
||||
svc.set_project_value("my-project", "core.automation-profile", "full-auto")
|
||||
result = svc.resolve("core.automation-profile", project_name="my-project")
|
||||
if result.value == "full-auto" and result.source == ConfigLevel.PROJECT:
|
||||
print("config-project-overrides-global-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected full-auto/project, got {result.value}/{result.source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def list_overrides_only() -> None:
|
||||
"""Verify get_project_overrides returns only project-scoped keys."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
svc.set_value("core.log.level", "DEBUG")
|
||||
svc.set_project_value("proj-a", "core.automation-profile", "trusted")
|
||||
svc.set_project_value("proj-a", "plan.concurrency", "8")
|
||||
overrides = svc.get_project_overrides("proj-a")
|
||||
if len(overrides) == 2 and "core.log.level" not in overrides:
|
||||
print("config-project-list-overrides-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected 2 keys without core.log.level, got {overrides}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def cli_roundtrip() -> None:
|
||||
"""Verify CLI --project flag for set and get."""
|
||||
tmpdir = Path(tempfile.mkdtemp())
|
||||
tmppath = tmpdir / "config.toml"
|
||||
try:
|
||||
with (
|
||||
patch.object(config_mod, "_CONFIG_DIR", tmpdir),
|
||||
patch.object(config_mod, "_CONFIG_PATH", tmppath),
|
||||
):
|
||||
set_result = runner.invoke(
|
||||
config_app,
|
||||
["set", "core.automation-profile", "manual", "--project", "cli-proj"],
|
||||
)
|
||||
if set_result.exit_code != 0:
|
||||
print(
|
||||
f"FAIL: set returned {set_result.exit_code}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(set_result.output, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
get_result = runner.invoke(
|
||||
config_app,
|
||||
[
|
||||
"get",
|
||||
"core.automation-profile",
|
||||
"--project",
|
||||
"cli-proj",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
if get_result.exit_code != 0:
|
||||
print(
|
||||
f"FAIL: get returned {get_result.exit_code}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(get_result.output, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
data: dict[str, Any] = json.loads(get_result.output)
|
||||
if data.get("source") == "project":
|
||||
print("config-project-cli-roundtrip-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: unexpected source {data.get('source')}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def non_scopable_rejected() -> None:
|
||||
"""Verify that a non-project-scopable key is rejected."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
try:
|
||||
svc.set_project_value("proj", "core.data-dir", "/tmp/bad")
|
||||
print("FAIL: expected ValueError", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError as exc:
|
||||
if "not project-scopable" in str(exc):
|
||||
print("config-project-non-scopable-ok")
|
||||
else:
|
||||
print(f"FAIL: unexpected error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMANDS: dict[str, Callable[[], None]] = {
|
||||
"set-get-roundtrip": set_get_roundtrip,
|
||||
"project-overrides-global": project_overrides_global,
|
||||
"list-overrides-only": list_overrides_only,
|
||||
"cli-roundtrip": cli_roundtrip,
|
||||
"non-scopable-rejected": non_scopable_rejected,
|
||||
}
|
||||
|
||||
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]]()
|
||||
@@ -1369,6 +1369,90 @@ class ConfigService:
|
||||
raise ValueError(msg)
|
||||
return entry.env_var
|
||||
|
||||
# -- project-scoped helpers -----------------------------------------------
|
||||
|
||||
def set_project_value(
|
||||
self,
|
||||
project_name: str,
|
||||
key: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
"""Persist a single key-value pair under a project-scoped TOML table.
|
||||
|
||||
The value is stored at ``[project."<project_name>"].<key>`` in the
|
||||
global config file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project_name:
|
||||
Name of the project.
|
||||
key:
|
||||
Dotted config key.
|
||||
value:
|
||||
Value to store (must match the registered type).
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *key* is not a registered configuration key.
|
||||
ValueError
|
||||
If *project_name* is empty.
|
||||
TypeError
|
||||
If *value* does not match the key's registered type.
|
||||
"""
|
||||
if not project_name:
|
||||
msg = "project_name must not be empty."
|
||||
raise ValueError(msg)
|
||||
entry = self.validate_key(key)
|
||||
if not entry.project_scopable:
|
||||
msg = f"Key '{key}' is not project-scopable."
|
||||
raise ValueError(msg)
|
||||
coerced: Any = self.validate_type(key, value)
|
||||
|
||||
data = self.read_config()
|
||||
project_section: dict[str, Any] = data.get("project", {})
|
||||
if not isinstance(project_section, dict):
|
||||
project_section = {}
|
||||
proj_overrides: dict[str, Any] = project_section.get(project_name, {})
|
||||
if not isinstance(proj_overrides, dict):
|
||||
proj_overrides = {}
|
||||
proj_overrides[key] = coerced
|
||||
project_section[project_name] = proj_overrides
|
||||
data["project"] = project_section
|
||||
self.write_config(data)
|
||||
|
||||
def get_project_overrides(
|
||||
self,
|
||||
project_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return all project-scoped overrides for *project_name*.
|
||||
|
||||
Returns a ``{key: value}`` mapping of every key that has been
|
||||
explicitly set in the ``[project."<project_name>"]`` TOML table.
|
||||
Keys that are not present in the project table are omitted.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project_name:
|
||||
Name of the project.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *project_name* is empty.
|
||||
"""
|
||||
if not project_name:
|
||||
msg = "project_name must not be empty."
|
||||
raise ValueError(msg)
|
||||
data = self.read_config()
|
||||
project_section: dict[str, Any] = data.get("project", {})
|
||||
if not isinstance(project_section, dict):
|
||||
return {}
|
||||
proj_data: dict[str, Any] = project_section.get(project_name, {})
|
||||
if not isinstance(proj_data, dict):
|
||||
return {}
|
||||
return dict(proj_data)
|
||||
|
||||
# -- bulk helpers ---------------------------------------------------------
|
||||
|
||||
def resolve_all(
|
||||
|
||||
@@ -383,10 +383,78 @@ def config_list(
|
||||
raise typer.Exit(code=1) from exc
|
||||
|
||||
svc = _get_service()
|
||||
|
||||
# When --project is given, show only project-scoped overrides
|
||||
if project is not None:
|
||||
proj_overrides = svc.get_project_overrides(project)
|
||||
settings_list: list[dict[str, Any]] = []
|
||||
for key in sorted(proj_overrides):
|
||||
raw_value = proj_overrides[key]
|
||||
str_value = str(raw_value) if raw_value is not None else ""
|
||||
|
||||
# Key filter
|
||||
if (
|
||||
key_re is not None
|
||||
and not key_re.search(key)
|
||||
and (pattern is None or not fnmatch.fnmatch(key, pattern))
|
||||
):
|
||||
continue
|
||||
|
||||
# Value filter
|
||||
if val_re is not None and not val_re.search(str_value):
|
||||
continue
|
||||
|
||||
# Mask secrets unless --show-secrets
|
||||
display_value: Any = raw_value
|
||||
if _is_secret_key(key) and not show_secrets and raw_value is not None:
|
||||
display_value = _mask_value(str(raw_value))
|
||||
|
||||
settings_list.append(
|
||||
{
|
||||
"key": key,
|
||||
"value": display_value,
|
||||
"source": f"project:{project}",
|
||||
"modified": True,
|
||||
}
|
||||
)
|
||||
|
||||
if not settings_list:
|
||||
console.print(
|
||||
"[yellow]No project-scoped overrides match the filter.[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
for entry in settings_list:
|
||||
if hasattr(entry["value"], "__fspath__"):
|
||||
entry["value"] = str(entry["value"])
|
||||
typer.echo(format_output(settings_list, fmt))
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title=f"Project '{project}' overrides ({len(settings_list)} settings)"
|
||||
)
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value", style="white")
|
||||
table.add_column("Source", style="yellow")
|
||||
table.add_column("Modified", justify="center")
|
||||
|
||||
for entry in settings_list:
|
||||
mod_marker = "[green]yes[/green]" if entry["modified"] else ""
|
||||
table.add_row(
|
||||
entry["key"],
|
||||
str(entry["value"]),
|
||||
entry["source"],
|
||||
mod_marker,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
return
|
||||
|
||||
all_resolved = svc.resolve_all(project_name=project)
|
||||
defaults = _settings_defaults()
|
||||
|
||||
settings_list: list[dict[str, Any]] = []
|
||||
settings_list_all: list[dict[str, Any]] = []
|
||||
for key in sorted(_REGISTRY):
|
||||
# Key filter: support both glob (fnmatch) and regex
|
||||
if (
|
||||
@@ -413,7 +481,7 @@ def config_list(
|
||||
if _is_secret_key(key) and not show_secrets and raw_value is not None:
|
||||
display_value = _mask_value(str(raw_value))
|
||||
|
||||
settings_list.append(
|
||||
settings_list_all.append(
|
||||
{
|
||||
"key": key,
|
||||
"value": display_value,
|
||||
@@ -422,25 +490,25 @@ def config_list(
|
||||
}
|
||||
)
|
||||
|
||||
if not settings_list:
|
||||
if not settings_list_all:
|
||||
console.print("[yellow]No configuration values match the filter.[/yellow]")
|
||||
return
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
# Serialise Path objects for JSON/YAML
|
||||
for entry in settings_list:
|
||||
for entry in settings_list_all:
|
||||
if hasattr(entry["value"], "__fspath__"):
|
||||
entry["value"] = str(entry["value"])
|
||||
typer.echo(format_output(settings_list, fmt))
|
||||
typer.echo(format_output(settings_list_all, fmt))
|
||||
return
|
||||
|
||||
table = Table(title=f"Configuration ({len(settings_list)} settings)")
|
||||
table = Table(title=f"Configuration ({len(settings_list_all)} settings)")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value", style="white")
|
||||
table.add_column("Source", style="yellow")
|
||||
table.add_column("Modified", justify="center")
|
||||
|
||||
for entry in settings_list:
|
||||
for entry in settings_list_all:
|
||||
mod_marker = "[green]yes[/green]" if entry["modified"] else ""
|
||||
table.add_row(
|
||||
entry["key"],
|
||||
|
||||
Reference in New Issue
Block a user