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

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:
2026-03-02 06:48:23 +00:00
committed by Forgejo
parent b2e923173f
commit 7d74be68aa
8 changed files with 933 additions and 12 deletions
+100
View File
@@ -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")
+44 -5
View File
@@ -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
+69
View File
@@ -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"
)
+49
View File
@@ -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
+200
View File
@@ -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(
+75 -7
View File
@@ -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"],