fix(config): remove undocumented LOCAL scope from ConfigLevel/ConfigScope enums and resolve() chain
The specification defines exactly 5 configuration precedence levels: 1. CLI flag 2. Environment variable 3. Project-scoped config (config.toml in project root) 4. Global config file (~/.cleveragents/config.toml) 5. Built-in default The implementation had an undocumented 6th level (LOCAL scope / config.local.toml) inserted between env var and project-scoped config. This creates a spec deviation that may cause user confusion and hidden reliance on undocumented behavior. Changes: - Remove LOCAL = 'local' from ConfigLevel enum (now has 5 spec-defined levels) - Remove LOCAL = 'local' from ConfigScope enum (now has 2 spec-defined scopes) - Remove local_config_path property from ConfigService - Remove read_local_config() method from ConfigService - Update read_merged_config() to merge only global and project scopes - Update write_scoped_config() to remove LOCAL scope branch - Update set_value() to remove LOCAL scope branch - Update resolve() to implement 5-level chain (remove Level 3 local config) - Update config_set CLI to reject --scope local as invalid - Remove config.local.toml from DEFAULT_IGNORE_PATTERNS in context_service - Update all BDD feature files and step definitions to reflect 5-level chain Closes #3432 --- **Automated by CleverAgents Bot** Supervisor: Implementation | Agent: ca-issue-worker
This commit is contained in:
@@ -138,11 +138,11 @@ Feature: Config CLI safety-net coverage
|
||||
# _resolution_chain (L177-209)
|
||||
# =====================================================================
|
||||
|
||||
Scenario: safety-net _resolution_chain returns six-entry list
|
||||
Scenario: safety-net _resolution_chain returns five-entry list
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net chain builder builds chain for key "core.log.level"
|
||||
Then the safety-net chain should have exactly 6 entries
|
||||
And the safety-net chain sources should be "cli_flag, env_var, local, project, global, default"
|
||||
Then the safety-net chain should have exactly 5 entries
|
||||
And the safety-net chain sources should be "cli_flag, env_var, project, global, default"
|
||||
|
||||
# =====================================================================
|
||||
# config_set - type coercion paths (L241-248)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
Feature: Config CLI scope coverage (cfcov3)
|
||||
Supplementary scenarios targeting uncovered lines 224-229 and 252-259
|
||||
in cleveragents/cli/commands/config.py — the --scope flag handling
|
||||
Supplementary scenarios targeting uncovered lines in
|
||||
cleveragents/cli/commands/config.py — the --scope flag handling
|
||||
in the config_set command.
|
||||
|
||||
Background:
|
||||
Given a cfcov3 isolated temp config directory
|
||||
|
||||
# ── Lines 224-229: invalid scope raises typer.BadParameter ──────────
|
||||
# ── Invalid scope raises typer.BadParameter ──────────────────────────
|
||||
|
||||
Scenario: config set with invalid --scope raises BadParameter
|
||||
When I invoke cfcov3 config set "plan.concurrency" "8" with scope "invalid"
|
||||
Then the cfcov3 CLI result should have a non-zero exit code
|
||||
And the cfcov3 CLI output should contain "Invalid scope"
|
||||
|
||||
# ── Lines 252-253, 258-259: --scope global branch ───────────────────
|
||||
# ── --scope global branch ─────────────────────────────────────────────
|
||||
|
||||
Scenario: config set with --scope global writes via set_value
|
||||
When I invoke cfcov3 config set "plan.concurrency" "16" with scope "global"
|
||||
@@ -21,7 +21,7 @@ Feature: Config CLI scope coverage (cfcov3)
|
||||
And the cfcov3 CLI output should contain "plan.concurrency"
|
||||
And the cfcov3 CLI output should contain "global"
|
||||
|
||||
# ── Lines 254-255, 258-259: --scope project branch ─────────────────
|
||||
# ── --scope project branch ────────────────────────────────────────────
|
||||
|
||||
Scenario: config set with --scope project writes via set_value
|
||||
When I invoke cfcov3 config set "plan.concurrency" "12" with scope "project"
|
||||
@@ -29,15 +29,7 @@ Feature: Config CLI scope coverage (cfcov3)
|
||||
And the cfcov3 CLI output should contain "plan.concurrency"
|
||||
And the cfcov3 CLI output should contain "project"
|
||||
|
||||
# ── Lines 257-259: --scope local branch ─────────────────────────────
|
||||
|
||||
Scenario: config set with --scope local writes via set_value
|
||||
When I invoke cfcov3 config set "plan.concurrency" "20" with scope "local"
|
||||
Then the cfcov3 CLI result should have exit code 0
|
||||
And the cfcov3 CLI output should contain "plan.concurrency"
|
||||
And the cfcov3 CLI output should contain "local"
|
||||
|
||||
# ── Lines 252-253 with pre-existing global value ────────────────────
|
||||
# ── --scope global with pre-existing value ────────────────────────────
|
||||
|
||||
Scenario: config set --scope global shows previous value from config_data
|
||||
Given the cfcov3 global config has "plan.concurrency" set to 4
|
||||
@@ -45,7 +37,7 @@ Feature: Config CLI scope coverage (cfcov3)
|
||||
Then the cfcov3 CLI result should have exit code 0
|
||||
And the cfcov3 set_value mock should have been called with scope GLOBAL
|
||||
|
||||
# ── Lines 254-255 reading previous from project config ──────────────
|
||||
# ── --scope project reads previous from project config ────────────────
|
||||
|
||||
Scenario: config set --scope project reads previous from project config
|
||||
Given the cfcov3 project config returns "plan.concurrency" as 7
|
||||
@@ -53,10 +45,9 @@ Feature: Config CLI scope coverage (cfcov3)
|
||||
Then the cfcov3 CLI result should have exit code 0
|
||||
And the cfcov3 read_project_config mock should have been called
|
||||
|
||||
# ── Lines 257-259 reading previous from local config ────────────────
|
||||
# ── --scope local is rejected as invalid ─────────────────────────────
|
||||
|
||||
Scenario: config set --scope local reads previous from local config
|
||||
Given the cfcov3 local config returns "plan.concurrency" as 3
|
||||
When I invoke cfcov3 config set "plan.concurrency" "30" with scope "local"
|
||||
Then the cfcov3 CLI result should have exit code 0
|
||||
And the cfcov3 read_local_config mock should have been called
|
||||
Scenario: config set with --scope local is rejected as invalid scope
|
||||
When I invoke cfcov3 config set "plan.concurrency" "20" with scope "local"
|
||||
Then the cfcov3 CLI result should have a non-zero exit code
|
||||
And the cfcov3 CLI output should contain "Invalid scope"
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
Feature: ConfigService coverage round 3 — path accessors, write_scoped_config, set_value, and resolve local
|
||||
Feature: ConfigService coverage round 3 — path accessors, write_scoped_config, set_value
|
||||
Exercises uncovered lines in config_service.py:
|
||||
- Line 1195: project_root property
|
||||
- Line 1201: project_config_path returns None
|
||||
- Line 1208: local_config_path returns None
|
||||
- Lines 1284-1285, 1289: write_scoped_config ValueError for missing project root
|
||||
- Lines 1297-1298: write_scoped_config reads existing target file
|
||||
- Lines 1336-1337: set_value redacts sensitive keys
|
||||
- Lines 1351-1352: set_value catches event_bus emit failure
|
||||
- Lines 1507-1508, 1510-1511: resolve verbose with local config winning
|
||||
|
||||
Background:
|
||||
Given cfscov3 a fresh temporary directory
|
||||
@@ -28,12 +26,6 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
|
||||
Given cfscov3 a ConfigService with no project root
|
||||
Then cfscov3 project_config_path should return None
|
||||
|
||||
# -- local_config_path returns None (line 1208) --
|
||||
|
||||
Scenario: local_config_path returns None when project root is absent
|
||||
Given cfscov3 a ConfigService with no project root
|
||||
Then cfscov3 local_config_path should return None
|
||||
|
||||
# -- write_scoped_config raises ValueError (lines 1284-1285, 1289) --
|
||||
|
||||
Scenario: write_scoped_config raises ValueError for PROJECT scope without project root
|
||||
@@ -41,11 +33,6 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
|
||||
When cfscov3 I call write_scoped_config with PROJECT scope
|
||||
Then cfscov3 a ValueError mentioning "no project root" should be stored
|
||||
|
||||
Scenario: write_scoped_config raises ValueError for LOCAL scope without project root
|
||||
Given cfscov3 a ConfigService with no project root
|
||||
When cfscov3 I call write_scoped_config with LOCAL scope
|
||||
Then cfscov3 a ValueError mentioning "no project root" should be stored
|
||||
|
||||
# -- write_scoped_config reads existing target (lines 1297-1298) --
|
||||
|
||||
Scenario: write_scoped_config merges into existing project config.toml
|
||||
@@ -53,11 +40,6 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
|
||||
When cfscov3 I call write_scoped_config with PROJECT scope adding a new key
|
||||
Then cfscov3 the project config.toml should contain both the old and new keys
|
||||
|
||||
Scenario: write_scoped_config merges into existing local config.local.toml
|
||||
Given cfscov3 a ConfigService with a project root containing an existing config.local.toml
|
||||
When cfscov3 I call write_scoped_config with LOCAL scope adding a new key
|
||||
Then cfscov3 the local config.local.toml should contain both the old and new keys
|
||||
|
||||
# -- set_value redacts sensitive keys (lines 1336-1337) --
|
||||
|
||||
Scenario: set_value redacts old and new values for a sensitive key
|
||||
@@ -71,11 +53,3 @@ Feature: ConfigService coverage round 3 — path accessors, write_scoped_config,
|
||||
Given cfscov3 a ConfigService with a failing event bus
|
||||
When cfscov3 I call set_value with key "core.log.level" and value "DEBUG"
|
||||
Then cfscov3 no error should have been raised
|
||||
|
||||
# -- resolve verbose with local config winning (lines 1507-1508, 1510-1511) --
|
||||
|
||||
Scenario: resolve in verbose mode returns local config value with chain entry
|
||||
Given cfscov3 a ConfigService with a local config containing "core.log.level" set to "TRACE"
|
||||
When cfscov3 I resolve "core.log.level" with verbose True
|
||||
Then cfscov3 the resolved value should be "TRACE" from LOCAL source
|
||||
And cfscov3 the chain should contain a local entry with value "TRACE" and a path
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
Feature: Three-scope configuration resolution
|
||||
As a developer working on a CleverAgents project
|
||||
I want configuration to resolve across global, project, and local scopes
|
||||
So that I can have developer-specific overrides without affecting shared config
|
||||
|
||||
Background:
|
||||
Given a clean three-scope config environment
|
||||
|
||||
# ── Scope precedence ──────────────────────────────────────────────────
|
||||
|
||||
Scenario: Local scope overrides project scope
|
||||
Given a project config value "core.automation-profile" = "supervised"
|
||||
And a local config value "core.automation-profile" = "manual"
|
||||
When I resolve three-scope key "core.automation-profile"
|
||||
Then the three-scope resolved value should be "manual"
|
||||
And the three-scope resolved source should be "local"
|
||||
|
||||
Scenario: Project scope overrides global scope
|
||||
Given a global config value for three-scope "core.automation-profile" = "full-auto"
|
||||
And a project config value "core.automation-profile" = "supervised"
|
||||
When I resolve three-scope key "core.automation-profile"
|
||||
Then the three-scope resolved value should be "supervised"
|
||||
And the three-scope resolved source should be "project"
|
||||
|
||||
Scenario: Local scope overrides both project and global scopes
|
||||
Given a global config value for three-scope "core.automation-profile" = "full-auto"
|
||||
And a project config value "core.automation-profile" = "supervised"
|
||||
And a local config value "core.automation-profile" = "manual"
|
||||
When I resolve three-scope key "core.automation-profile"
|
||||
Then the three-scope resolved value should be "manual"
|
||||
And the three-scope resolved source should be "local"
|
||||
|
||||
Scenario: Global scope used when no project or local override exists
|
||||
Given a global config value for three-scope "core.automation-profile" = "supervised"
|
||||
When I resolve three-scope key "core.automation-profile"
|
||||
Then the three-scope resolved value should be "supervised"
|
||||
And the three-scope resolved source should be "global"
|
||||
|
||||
Scenario: Default used when no scope provides a value
|
||||
When I resolve three-scope key "core.automation-profile"
|
||||
Then the three-scope resolved source should be "default"
|
||||
|
||||
# ── Deep merge ────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Deep merge combines nested keys from all scopes
|
||||
Given a global config with nested data "section_a.key1" = "global_val1"
|
||||
And a project config with nested data "section_a.key2" = "project_val2"
|
||||
And a local config with nested data "section_a.key3" = "local_val3"
|
||||
When I read the three-scope merged config
|
||||
Then the merged config "section_a" should contain key "key1" with value "global_val1"
|
||||
And the merged config "section_a" should contain key "key2" with value "project_val2"
|
||||
And the merged config "section_a" should contain key "key3" with value "local_val3"
|
||||
|
||||
Scenario: Deep merge local overrides project on conflict
|
||||
Given a project config with nested data "section_a.key1" = "project_val"
|
||||
And a local config with nested data "section_a.key1" = "local_val"
|
||||
When I read the three-scope merged config
|
||||
Then the merged config "section_a" should contain key "key1" with value "local_val"
|
||||
|
||||
# ── Project root discovery ────────────────────────────────────────────
|
||||
|
||||
Scenario: Project root discovered via cleveragents.toml marker
|
||||
Given a directory tree with "cleveragents.toml" marker at the root
|
||||
When I discover the project root from a subdirectory
|
||||
Then the discovered project root should match the marker directory
|
||||
|
||||
Scenario: Project root discovered via .cleveragents directory marker
|
||||
Given a directory tree with ".cleveragents" directory marker at the root
|
||||
When I discover the project root from a subdirectory
|
||||
Then the discovered project root should match the marker directory
|
||||
|
||||
Scenario: No project root returns None when no markers found
|
||||
Given a directory tree with no project markers
|
||||
When I discover the project root
|
||||
Then the discovered project root should be None
|
||||
|
||||
# ── config.local.toml file loading ────────────────────────────────────
|
||||
|
||||
Scenario: Local config file is loaded when present
|
||||
Given a local config file with "plan.concurrency" = "16"
|
||||
When I resolve three-scope key "plan.concurrency"
|
||||
Then the three-scope resolved value should be "16"
|
||||
And the three-scope resolved source should be "local"
|
||||
|
||||
Scenario: Missing local config file is handled gracefully
|
||||
When I read the local config
|
||||
Then the local config should be an empty dict
|
||||
|
||||
# ── Scoped write ──────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Set value with local scope writes to config.local.toml
|
||||
When I set three-scope value "core.automation-profile" to "manual" with scope "local"
|
||||
Then the local config file should contain "core.automation-profile" = "manual"
|
||||
|
||||
Scenario: Set value with project scope writes to project config.toml
|
||||
When I set three-scope value "core.automation-profile" to "supervised" with scope "project"
|
||||
Then the project config file should contain "core.automation-profile" = "supervised"
|
||||
|
||||
Scenario: Set value with global scope writes to global config.toml
|
||||
When I set three-scope value "core.automation-profile" to "full-auto" with scope "global"
|
||||
Then the global config file should contain "core.automation-profile" = "full-auto"
|
||||
|
||||
# ── ConfigScope enum ──────────────────────────────────────────────────
|
||||
|
||||
Scenario: ConfigScope enum has all three values
|
||||
Then ConfigScope should have value "global"
|
||||
And ConfigScope should have value "project"
|
||||
And ConfigScope should have value "local"
|
||||
|
||||
# ── ConfigLevel enum includes LOCAL ───────────────────────────────────
|
||||
|
||||
Scenario: ConfigLevel enum includes LOCAL between ENV_VAR and PROJECT
|
||||
Then ConfigLevel should have value "local"
|
||||
And ConfigLevel "local" should exist between "env_var" and "project"
|
||||
|
||||
# ── .gitignore template includes config.local.toml ────────────────────
|
||||
|
||||
Scenario: Default ignore patterns include config.local.toml
|
||||
Then the default ignore patterns should include "config.local.toml"
|
||||
@@ -0,0 +1,89 @@
|
||||
Feature: Two-scope configuration resolution
|
||||
As a developer working on a CleverAgents project
|
||||
I want configuration to resolve across global and project scopes
|
||||
So that I can have project-specific overrides without affecting global config
|
||||
|
||||
Background:
|
||||
Given a clean two-scope config environment
|
||||
|
||||
# ── Scope precedence ──────────────────────────────────────────────────
|
||||
|
||||
Scenario: Project scope overrides global scope
|
||||
Given a global config value for two-scope "core.automation-profile" = "full-auto"
|
||||
And a project config value "core.automation-profile" = "supervised"
|
||||
When I resolve two-scope key "core.automation-profile"
|
||||
Then the two-scope resolved value should be "supervised"
|
||||
And the two-scope resolved source should be "project"
|
||||
|
||||
Scenario: Global scope used when no project override exists
|
||||
Given a global config value for two-scope "core.automation-profile" = "supervised"
|
||||
When I resolve two-scope key "core.automation-profile"
|
||||
Then the two-scope resolved value should be "supervised"
|
||||
And the two-scope resolved source should be "global"
|
||||
|
||||
Scenario: Default used when no scope provides a value
|
||||
When I resolve two-scope key "core.automation-profile"
|
||||
Then the two-scope resolved source should be "default"
|
||||
|
||||
# ── Deep merge ────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Deep merge combines nested keys from global and project scopes
|
||||
Given a global config with nested data "section_a.key1" = "global_val1"
|
||||
And a project config with nested data "section_a.key2" = "project_val2"
|
||||
When I read the two-scope merged config
|
||||
Then the merged config "section_a" should contain key "key1" with value "global_val1"
|
||||
And the merged config "section_a" should contain key "key2" with value "project_val2"
|
||||
|
||||
Scenario: Deep merge project overrides global on conflict
|
||||
Given a global config with nested data "section_a.key1" = "global_val"
|
||||
And a project config with nested data "section_a.key1" = "project_val"
|
||||
When I read the two-scope merged config
|
||||
Then the merged config "section_a" should contain key "key1" with value "project_val"
|
||||
|
||||
# ── Project root discovery ────────────────────────────────────────────
|
||||
|
||||
Scenario: Project root discovered via cleveragents.toml marker
|
||||
Given a directory tree with "cleveragents.toml" marker at the root
|
||||
When I discover the project root from a subdirectory
|
||||
Then the discovered project root should match the marker directory
|
||||
|
||||
Scenario: Project root discovered via .cleveragents directory marker
|
||||
Given a directory tree with ".cleveragents" directory marker at the root
|
||||
When I discover the project root from a subdirectory
|
||||
Then the discovered project root should match the marker directory
|
||||
|
||||
Scenario: No project root returns None when no markers found
|
||||
Given a directory tree with no project markers
|
||||
When I discover the project root
|
||||
Then the discovered project root should be None
|
||||
|
||||
# ── Scoped write ──────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Set value with project scope writes to project config.toml
|
||||
When I set two-scope value "core.automation-profile" to "supervised" with scope "project"
|
||||
Then the project config file should contain "core.automation-profile" = "supervised"
|
||||
|
||||
Scenario: Set value with global scope writes to global config.toml
|
||||
When I set two-scope value "core.automation-profile" to "full-auto" with scope "global"
|
||||
Then the global config file should contain "core.automation-profile" = "full-auto"
|
||||
|
||||
# ── ConfigScope enum ──────────────────────────────────────────────────
|
||||
|
||||
Scenario: ConfigScope enum has exactly two spec-defined values
|
||||
Then ConfigScope should have value "global"
|
||||
And ConfigScope should have value "project"
|
||||
|
||||
# ── ConfigLevel enum matches spec 5-level chain ───────────────────────
|
||||
|
||||
Scenario: ConfigLevel enum has exactly five spec-defined levels
|
||||
Then ConfigLevel should have value "cli_flag"
|
||||
And ConfigLevel should have value "env_var"
|
||||
And ConfigLevel should have value "project"
|
||||
And ConfigLevel should have value "global"
|
||||
And ConfigLevel should have value "default"
|
||||
|
||||
Scenario: ConfigLevel enum does not include undocumented local level
|
||||
Then ConfigLevel should not have value "local"
|
||||
|
||||
Scenario: ConfigScope enum does not include undocumented local scope
|
||||
Then ConfigScope should not have value "local"
|
||||
@@ -172,10 +172,9 @@ Feature: Consolidated Config
|
||||
Scenario: Verbose mode returns full resolution chain
|
||||
Given a clean config service test environment
|
||||
When I resolve config key "core.log.level" with verbose mode
|
||||
Then the resolution chain should have 6 entries
|
||||
Then the resolution chain should have 5 entries
|
||||
And the chain should include source "cli_flag"
|
||||
And the chain should include source "env_var"
|
||||
And the chain should include source "local"
|
||||
And the chain should include source "project"
|
||||
And the chain should include source "global"
|
||||
And the chain should include source "default"
|
||||
@@ -294,8 +293,8 @@ Feature: Consolidated Config
|
||||
# Feature: ConfigService full coverage
|
||||
# ============================================================
|
||||
|
||||
Scenario: ConfigLevel enum has six precedence levels
|
||||
Then ConfigLevel should have values CLI_FLAG ENV_VAR LOCAL PROJECT GLOBAL DEFAULT
|
||||
Scenario: ConfigLevel enum has five spec-defined precedence levels
|
||||
Then ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT
|
||||
|
||||
# ---------- ConfigEntry and ResolvedValue dataclasses ----------
|
||||
|
||||
@@ -547,7 +546,7 @@ Feature: Consolidated Config
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with empty config for resolve tests
|
||||
When I resolve key "core.log.level" with verbose True and no overrides
|
||||
Then the resolved chain should have 6 entries covering all levels
|
||||
Then the resolved chain should have 5 entries covering all levels
|
||||
|
||||
|
||||
Scenario: resolve verbose chain includes env_name for ENV_VAR level
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Step definitions for config_cli_scope_coverage.feature (cfcov3).
|
||||
|
||||
Targets uncovered lines in cleveragents/cli/commands/config.py:
|
||||
- Lines 224-229: invalid --scope triggers ValueError → typer.BadParameter
|
||||
- Lines 252-253: --scope global reads previous from config_data
|
||||
- Lines 254-255: --scope project reads previous from read_project_config()
|
||||
- Lines 257-259: --scope local reads previous from read_local_config(),
|
||||
then calls svc.set_value() and sets scope_display
|
||||
- Invalid --scope triggers ValueError → typer.BadParameter
|
||||
- --scope global reads previous from config_data
|
||||
- --scope project reads previous from read_project_config()
|
||||
- --scope local is rejected as invalid (not in spec)
|
||||
|
||||
All step text uses a 'cfcov3' prefix to avoid collisions with other
|
||||
step definition files.
|
||||
@@ -68,7 +67,6 @@ def _teardown_cfcov3_temp(context: Context) -> None:
|
||||
def _build_mock_svc(
|
||||
config_data: dict[str, Any] | None = None,
|
||||
project_config: dict[str, Any] | None = None,
|
||||
local_config: dict[str, Any] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a MagicMock that behaves like ConfigService for config_set."""
|
||||
mock_svc = MagicMock()
|
||||
@@ -76,9 +74,6 @@ def _build_mock_svc(
|
||||
mock_svc.read_project_config.return_value = (
|
||||
project_config if project_config is not None else {}
|
||||
)
|
||||
mock_svc.read_local_config.return_value = (
|
||||
local_config if local_config is not None else {}
|
||||
)
|
||||
|
||||
# validate_type should pass through the value, coercing to int when possible
|
||||
def _validate_type(key: str, value: Any) -> Any:
|
||||
@@ -110,7 +105,7 @@ def step_cfcov3_temp_dir(context: Context) -> None:
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Lines 224-229: invalid scope
|
||||
# Scope invocation
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -162,7 +157,7 @@ def step_cfcov3_output_contains(context: Context, text: str) -> None:
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Lines 252-253: --scope global with pre-existing value
|
||||
# --scope global with pre-existing value
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -185,7 +180,7 @@ def step_cfcov3_set_value_global(context: Context) -> None:
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Lines 254-255: --scope project reads from read_project_config
|
||||
# --scope project reads from read_project_config
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -200,21 +195,3 @@ def step_cfcov3_read_project_called(context: Context) -> None:
|
||||
mock_svc = context.cfcov3_mock_svc
|
||||
assert mock_svc is not None, "No mock service was set up"
|
||||
mock_svc.read_project_config.assert_called_once()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Lines 257-259: --scope local reads from read_local_config
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given('the cfcov3 local config returns "{key}" as {value:d}')
|
||||
def step_cfcov3_local_preset(context: Context, key: str, value: int) -> None:
|
||||
mock_svc = _build_mock_svc(local_config={key: value})
|
||||
context.cfcov3_mock_svc = mock_svc
|
||||
|
||||
|
||||
@then("the cfcov3 read_local_config mock should have been called")
|
||||
def step_cfcov3_read_local_called(context: Context) -> None:
|
||||
mock_svc = context.cfcov3_mock_svc
|
||||
assert mock_svc is not None, "No mock service was set up"
|
||||
mock_svc.read_local_config.assert_called_once()
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
Targets uncovered lines in config_service.py:
|
||||
- Line 1195: project_root property
|
||||
- Line 1201: project_config_path returns None
|
||||
- Line 1208: local_config_path returns None
|
||||
- Lines 1284-1285, 1289: write_scoped_config ValueError (no project root)
|
||||
- Lines 1297-1298: write_scoped_config reads existing target file
|
||||
- Lines 1336-1337: set_value redacts sensitive keys
|
||||
- Lines 1351-1352: set_value catches event_bus emit failure
|
||||
- Lines 1507-1508, 1510-1511: resolve verbose with local config winning
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,13 +15,12 @@ import tempfile
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import tomlkit
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.services.config_service import (
|
||||
ConfigLevel,
|
||||
ConfigScope,
|
||||
ConfigService,
|
||||
)
|
||||
@@ -92,28 +89,6 @@ def step_cfscov3_svc_with_existing_project_config(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
"cfscov3 a ConfigService with a project root containing an existing config.local.toml"
|
||||
)
|
||||
def step_cfscov3_svc_with_existing_local_config(context: Any) -> None:
|
||||
project_root = context.cfscov3_tmpdir / "proj_existing_local"
|
||||
project_root.mkdir(parents=True, exist_ok=True)
|
||||
context.cfscov3_project_root = project_root
|
||||
|
||||
# Write an initial config.local.toml with an existing key
|
||||
target = project_root / "config.local.toml"
|
||||
doc = tomlkit.document()
|
||||
doc["local_existing"] = "local_value"
|
||||
with open(target, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
context.cfscov3_svc = ConfigService(
|
||||
config_dir=context.cfscov3_config_dir,
|
||||
config_path=context.cfscov3_config_path,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
|
||||
@given("cfscov3 a ConfigService with a mock event bus")
|
||||
def step_cfscov3_svc_with_mock_event_bus(context: Any) -> None:
|
||||
bus = MagicMock()
|
||||
@@ -139,28 +114,6 @@ def step_cfscov3_svc_with_failing_event_bus(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
'cfscov3 a ConfigService with a local config containing "core.log.level" set to "TRACE"'
|
||||
)
|
||||
def step_cfscov3_svc_with_local_config_log_level(context: Any) -> None:
|
||||
project_root = context.cfscov3_tmpdir / "proj_local_resolve"
|
||||
project_root.mkdir(parents=True, exist_ok=True)
|
||||
context.cfscov3_project_root = project_root
|
||||
|
||||
# Write config.local.toml with the key
|
||||
local_cfg = project_root / "config.local.toml"
|
||||
doc = tomlkit.document()
|
||||
doc["core.log.level"] = "TRACE"
|
||||
with open(local_cfg, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
context.cfscov3_svc = ConfigService(
|
||||
config_dir=context.cfscov3_config_dir,
|
||||
config_path=context.cfscov3_config_path,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -174,14 +127,6 @@ def step_cfscov3_write_scoped_project(context: Any) -> None:
|
||||
context.cfscov3_error = exc
|
||||
|
||||
|
||||
@when("cfscov3 I call write_scoped_config with LOCAL scope")
|
||||
def step_cfscov3_write_scoped_local(context: Any) -> None:
|
||||
try:
|
||||
context.cfscov3_svc.write_scoped_config({"key": "val"}, ConfigScope.LOCAL)
|
||||
except (ValueError, TypeError) as exc:
|
||||
context.cfscov3_error = exc
|
||||
|
||||
|
||||
@when("cfscov3 I call write_scoped_config with PROJECT scope adding a new key")
|
||||
def step_cfscov3_write_scoped_project_add_key(context: Any) -> None:
|
||||
try:
|
||||
@@ -192,16 +137,6 @@ def step_cfscov3_write_scoped_project_add_key(context: Any) -> None:
|
||||
context.cfscov3_error = exc
|
||||
|
||||
|
||||
@when("cfscov3 I call write_scoped_config with LOCAL scope adding a new key")
|
||||
def step_cfscov3_write_scoped_local_add_key(context: Any) -> None:
|
||||
try:
|
||||
context.cfscov3_svc.write_scoped_config(
|
||||
{"new_key": "new_value"}, ConfigScope.LOCAL
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
context.cfscov3_error = exc
|
||||
|
||||
|
||||
@when('cfscov3 I call set_value with sensitive key "{key}" and value "{value}"')
|
||||
def step_cfscov3_set_value_sensitive(context: Any, key: str, value: str) -> None:
|
||||
try:
|
||||
@@ -218,24 +153,6 @@ def step_cfscov3_set_value(context: Any, key: str, value: str) -> None:
|
||||
context.cfscov3_error = exc
|
||||
|
||||
|
||||
@when('cfscov3 I resolve "{key}" with verbose True')
|
||||
def step_cfscov3_resolve_verbose(context: Any, key: str) -> None:
|
||||
# Ensure no env var interferes with resolution
|
||||
entry = ConfigService.get_entry(key)
|
||||
env_var_name = entry.env_var if entry else ""
|
||||
patcher = patch.dict("os.environ", {}, clear=False)
|
||||
patcher.start()
|
||||
context.add_cleanup(patcher.stop)
|
||||
# Remove the specific env var if present to ensure local config wins
|
||||
import os
|
||||
|
||||
saved = os.environ.pop(env_var_name, None)
|
||||
if saved is not None:
|
||||
context.add_cleanup(lambda: os.environ.__setitem__(env_var_name, saved))
|
||||
|
||||
context.cfscov3_result = context.cfscov3_svc.resolve(key, verbose=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -260,12 +177,6 @@ def step_cfscov3_project_config_path_none(context: Any) -> None:
|
||||
assert actual is None, f"Expected project_config_path=None, got {actual}"
|
||||
|
||||
|
||||
@then("cfscov3 local_config_path should return None")
|
||||
def step_cfscov3_local_config_path_none(context: Any) -> None:
|
||||
actual = context.cfscov3_svc.local_config_path
|
||||
assert actual is None, f"Expected local_config_path=None, got {actual}"
|
||||
|
||||
|
||||
@then('cfscov3 a ValueError mentioning "no project root" should be stored')
|
||||
def step_cfscov3_valueerror_no_project_root(context: Any) -> None:
|
||||
assert context.cfscov3_error is not None, (
|
||||
@@ -299,25 +210,6 @@ def step_cfscov3_verify_merged_project_config(context: Any) -> None:
|
||||
assert data["new_key"] == "new_value"
|
||||
|
||||
|
||||
@then("cfscov3 the local config.local.toml should contain both the old and new keys")
|
||||
def step_cfscov3_verify_merged_local_config(context: Any) -> None:
|
||||
assert context.cfscov3_error is None, (
|
||||
f"write_scoped_config raised: {context.cfscov3_error}"
|
||||
)
|
||||
target = context.cfscov3_project_root / "config.local.toml"
|
||||
assert target.exists(), "config.local.toml should exist"
|
||||
with open(target, "rb") as fh:
|
||||
data = tomllib.load(fh)
|
||||
assert "local_existing" in data, (
|
||||
f"Expected 'local_existing' in config, got keys: {list(data.keys())}"
|
||||
)
|
||||
assert data["local_existing"] == "local_value"
|
||||
assert "new_key" in data, (
|
||||
f"Expected 'new_key' in config, got keys: {list(data.keys())}"
|
||||
)
|
||||
assert data["new_key"] == "new_value"
|
||||
|
||||
|
||||
@then("cfscov3 the event bus should have received redacted values")
|
||||
def step_cfscov3_verify_redacted_event(context: Any) -> None:
|
||||
assert context.cfscov3_error is None, f"set_value raised: {context.cfscov3_error}"
|
||||
@@ -339,31 +231,3 @@ def step_cfscov3_no_error(context: Any) -> None:
|
||||
assert context.cfscov3_error is None, (
|
||||
f"Expected no error, but got: {context.cfscov3_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('cfscov3 the resolved value should be "{expected}" from LOCAL source')
|
||||
def step_cfscov3_resolved_local(context: Any, expected: str) -> None:
|
||||
result = context.cfscov3_result
|
||||
assert result.value == expected, (
|
||||
f"Expected resolved value '{expected}', got '{result.value}'"
|
||||
)
|
||||
assert result.source == ConfigLevel.LOCAL, (
|
||||
f"Expected source LOCAL, got {result.source}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'cfscov3 the chain should contain a local entry with value "{expected}" and a path'
|
||||
)
|
||||
def step_cfscov3_chain_local_entry(context: Any, expected: str) -> None:
|
||||
chain = context.cfscov3_result.chain
|
||||
local_entries = [e for e in chain if e["source"] == ConfigLevel.LOCAL.value]
|
||||
assert len(local_entries) == 1, (
|
||||
f"Expected 1 LOCAL chain entry, got {len(local_entries)}: {chain}"
|
||||
)
|
||||
entry = local_entries[0]
|
||||
assert entry["value"] == expected, (
|
||||
f"Expected LOCAL chain value '{expected}', got '{entry['value']}'"
|
||||
)
|
||||
assert "path" in entry, "Expected 'path' key in LOCAL chain entry"
|
||||
assert entry["path"], "Expected non-empty path in LOCAL chain entry"
|
||||
|
||||
@@ -25,15 +25,17 @@ from cleveragents.application.services.config_service import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("ConfigLevel should have values CLI_FLAG ENV_VAR LOCAL PROJECT GLOBAL DEFAULT")
|
||||
@then("ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT")
|
||||
def step_config_level_enum(context: Any) -> None:
|
||||
assert ConfigLevel.CLI_FLAG.value == "cli_flag"
|
||||
assert ConfigLevel.ENV_VAR.value == "env_var"
|
||||
assert ConfigLevel.LOCAL.value == "local"
|
||||
assert ConfigLevel.PROJECT.value == "project"
|
||||
assert ConfigLevel.GLOBAL.value == "global"
|
||||
assert ConfigLevel.DEFAULT.value == "default"
|
||||
assert len(ConfigLevel) == 6
|
||||
assert len(ConfigLevel) == 5
|
||||
# Verify LOCAL is not present (spec defines only 5 levels)
|
||||
values = [m.value for m in ConfigLevel]
|
||||
assert "local" not in values, f"LOCAL should not be in ConfigLevel: {values}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -680,15 +682,14 @@ def step_resolve_verbose(context: Any) -> None:
|
||||
context.result = context.svc.resolve("core.log.level", verbose=True)
|
||||
|
||||
|
||||
@then("the resolved chain should have 6 entries covering all levels")
|
||||
@then("the resolved chain should have 5 entries covering all levels")
|
||||
def step_verify_verbose_chain_length(context: Any) -> None:
|
||||
chain = context.result.chain
|
||||
assert len(chain) == 6, f"Expected 6 chain entries, got {len(chain)}"
|
||||
assert len(chain) == 5, f"Expected 5 chain entries, got {len(chain)}"
|
||||
sources = [entry["source"] for entry in chain]
|
||||
assert sources == [
|
||||
ConfigLevel.CLI_FLAG.value,
|
||||
ConfigLevel.ENV_VAR.value,
|
||||
ConfigLevel.LOCAL.value,
|
||||
ConfigLevel.PROJECT.value,
|
||||
ConfigLevel.GLOBAL.value,
|
||||
ConfigLevel.DEFAULT.value,
|
||||
|
||||
+37
-90
@@ -1,4 +1,4 @@
|
||||
"""Step definitions for config_three_scope.feature — three-scope resolution."""
|
||||
"""Step definitions for config_two_scope.feature — two-scope resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,7 +27,7 @@ from cleveragents.application.services.config_service import (
|
||||
|
||||
|
||||
def _svc(context: Context) -> ConfigService:
|
||||
return context._three_scope_service
|
||||
return context._two_scope_service
|
||||
|
||||
|
||||
def _write_toml(path: Path, data: dict[str, Any]) -> None:
|
||||
@@ -48,7 +48,7 @@ def _read_toml(path: Path) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _cleanup(context: Context) -> None:
|
||||
tmpdir = getattr(context, "_three_scope_tmpdir", None)
|
||||
tmpdir = getattr(context, "_two_scope_tmpdir", None)
|
||||
if tmpdir and os.path.isdir(tmpdir):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
@@ -58,10 +58,10 @@ def _cleanup(context: Context) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a clean three-scope config environment")
|
||||
def step_clean_three_scope_env(context: Context) -> None:
|
||||
@given("a clean two-scope config environment")
|
||||
def step_clean_two_scope_env(context: Context) -> None:
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
context._three_scope_tmpdir = tmpdir
|
||||
context._two_scope_tmpdir = tmpdir
|
||||
base = Path(tmpdir)
|
||||
|
||||
# Global config dir
|
||||
@@ -73,10 +73,10 @@ def step_clean_three_scope_env(context: Context) -> None:
|
||||
project_root.mkdir()
|
||||
(project_root / "cleveragents.toml").touch()
|
||||
|
||||
context._three_scope_global_dir = global_dir
|
||||
context._three_scope_project_root = project_root
|
||||
context._two_scope_global_dir = global_dir
|
||||
context._two_scope_project_root = project_root
|
||||
|
||||
context._three_scope_service = ConfigService(
|
||||
context._two_scope_service = ConfigService(
|
||||
config_dir=global_dir,
|
||||
config_path=global_dir / "config.toml",
|
||||
project_root=project_root,
|
||||
@@ -92,7 +92,7 @@ def step_clean_three_scope_env(context: Context) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a global config value for three-scope "{key}" = "{value}"')
|
||||
@given('a global config value for two-scope "{key}" = "{value}"')
|
||||
def step_global_value(context: Context, key: str, value: str) -> None:
|
||||
svc = _svc(context)
|
||||
data = svc.read_config()
|
||||
@@ -102,15 +102,7 @@ def step_global_value(context: Context, key: str, value: str) -> None:
|
||||
|
||||
@given('a project config value "{key}" = "{value}"')
|
||||
def step_project_value(context: Context, key: str, value: str) -> None:
|
||||
path = context._three_scope_project_root / "config.toml"
|
||||
data = _read_toml(path)
|
||||
data[key] = value
|
||||
_write_toml(path, data)
|
||||
|
||||
|
||||
@given('a local config value "{key}" = "{value}"')
|
||||
def step_local_value(context: Context, key: str, value: str) -> None:
|
||||
path = context._three_scope_project_root / "config.local.toml"
|
||||
path = context._two_scope_project_root / "config.toml"
|
||||
data = _read_toml(path)
|
||||
data[key] = value
|
||||
_write_toml(path, data)
|
||||
@@ -128,16 +120,7 @@ def step_global_nested(context: Context, dotted: str, value: str) -> None:
|
||||
|
||||
@given('a project config with nested data "{dotted}" = "{value}"')
|
||||
def step_project_nested(context: Context, dotted: str, value: str) -> None:
|
||||
path = context._three_scope_project_root / "config.toml"
|
||||
data = _read_toml(path)
|
||||
section, key = dotted.split(".", 1)
|
||||
data.setdefault(section, {})[key] = value
|
||||
_write_toml(path, data)
|
||||
|
||||
|
||||
@given('a local config with nested data "{dotted}" = "{value}"')
|
||||
def step_local_nested(context: Context, dotted: str, value: str) -> None:
|
||||
path = context._three_scope_project_root / "config.local.toml"
|
||||
path = context._two_scope_project_root / "config.toml"
|
||||
data = _read_toml(path)
|
||||
section, key = dotted.split(".", 1)
|
||||
data.setdefault(section, {})[key] = value
|
||||
@@ -194,30 +177,21 @@ def step_dir_tree_no_markers(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
# Given — local config file
|
||||
@given('a local config file with "{key}" = "{value}"')
|
||||
def step_local_file_with_key(context: Context, key: str, value: str) -> None:
|
||||
path = context._three_scope_project_root / "config.local.toml"
|
||||
data = _read_toml(path)
|
||||
data[key] = value
|
||||
_write_toml(path, data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I resolve three-scope key "{key}"')
|
||||
def step_resolve_three_scope(context: Context, key: str) -> None:
|
||||
@when('I resolve two-scope key "{key}"')
|
||||
def step_resolve_two_scope(context: Context, key: str) -> None:
|
||||
svc = _svc(context)
|
||||
context._three_scope_resolved = svc.resolve(key)
|
||||
context._two_scope_resolved = svc.resolve(key)
|
||||
|
||||
|
||||
@when("I read the three-scope merged config")
|
||||
@when("I read the two-scope merged config")
|
||||
def step_read_merged(context: Context) -> None:
|
||||
svc = _svc(context)
|
||||
context._three_scope_merged = svc.read_merged_config()
|
||||
context._two_scope_merged = svc.read_merged_config()
|
||||
|
||||
|
||||
@when("I discover the project root from a subdirectory")
|
||||
@@ -230,13 +204,7 @@ def step_discover_project_root(context: Context) -> None:
|
||||
context._discovered_root = discover_project_root(context._discovery_sub)
|
||||
|
||||
|
||||
@when("I read the local config")
|
||||
def step_read_local(context: Context) -> None:
|
||||
svc = _svc(context)
|
||||
context._local_config = svc.read_local_config()
|
||||
|
||||
|
||||
@when('I set three-scope value "{key}" to "{value}" with scope "{scope}"')
|
||||
@when('I set two-scope value "{key}" to "{value}" with scope "{scope}"')
|
||||
def step_set_scoped(context: Context, key: str, value: str, scope: str) -> None:
|
||||
svc = _svc(context)
|
||||
config_scope = ConfigScope(scope)
|
||||
@@ -248,15 +216,15 @@ def step_set_scoped(context: Context, key: str, value: str, scope: str) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the three-scope resolved value should be "{value}"')
|
||||
@then('the two-scope resolved value should be "{value}"')
|
||||
def step_check_value(context: Context, value: str) -> None:
|
||||
actual = str(context._three_scope_resolved.value)
|
||||
actual = str(context._two_scope_resolved.value)
|
||||
assert actual == value, f"Expected '{value}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the three-scope resolved source should be "{source}"')
|
||||
@then('the two-scope resolved source should be "{source}"')
|
||||
def step_check_source(context: Context, source: str) -> None:
|
||||
actual = context._three_scope_resolved.source.value
|
||||
actual = context._two_scope_resolved.source.value
|
||||
assert actual == source, f"Expected source '{source}', got '{actual}'"
|
||||
|
||||
|
||||
@@ -264,7 +232,7 @@ def step_check_source(context: Context, source: str) -> None:
|
||||
def step_merged_section_key(
|
||||
context: Context, section: str, key: str, value: str
|
||||
) -> None:
|
||||
merged = context._three_scope_merged
|
||||
merged = context._two_scope_merged
|
||||
assert section in merged, f"Section '{section}' not in merged config: {merged}"
|
||||
sec = merged[section]
|
||||
assert key in sec, f"Key '{key}' not in section '{section}': {sec}"
|
||||
@@ -288,24 +256,9 @@ def step_root_none(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@then("the local config should be an empty dict")
|
||||
def step_local_empty(context: Context) -> None:
|
||||
assert context._local_config == {}, (
|
||||
f"Expected empty dict, got {context._local_config}"
|
||||
)
|
||||
|
||||
|
||||
@then('the local config file should contain "{key}" = "{value}"')
|
||||
def step_local_file_contains(context: Context, key: str, value: str) -> None:
|
||||
path = context._three_scope_project_root / "config.local.toml"
|
||||
data = _read_toml(path)
|
||||
assert key in data, f"Key '{key}' not in local config: {data}"
|
||||
assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'"
|
||||
|
||||
|
||||
@then('the project config file should contain "{key}" = "{value}"')
|
||||
def step_project_file_contains(context: Context, key: str, value: str) -> None:
|
||||
path = context._three_scope_project_root / "config.toml"
|
||||
path = context._two_scope_project_root / "config.toml"
|
||||
data = _read_toml(path)
|
||||
assert key in data, f"Key '{key}' not in project config: {data}"
|
||||
assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'"
|
||||
@@ -325,29 +278,23 @@ def step_scope_enum_value(context: Context, value: str) -> None:
|
||||
assert scope.value == value, f"Expected ConfigScope.{value}, got {scope}"
|
||||
|
||||
|
||||
@then('ConfigScope should not have value "{value}"')
|
||||
def step_scope_enum_not_have_value(context: Context, value: str) -> None:
|
||||
values = [s.value for s in ConfigScope]
|
||||
assert value not in values, (
|
||||
f"ConfigScope should NOT have value '{value}', but found it in {values}"
|
||||
)
|
||||
|
||||
|
||||
@then('ConfigLevel should have value "{value}"')
|
||||
def step_level_enum_value(context: Context, value: str) -> None:
|
||||
level = ConfigLevel(value)
|
||||
assert level.value == value, f"Expected ConfigLevel.{value}, got {level}"
|
||||
|
||||
|
||||
@then('ConfigLevel "{value}" should exist between "{before}" and "{after}"')
|
||||
def step_level_ordering(context: Context, value: str, before: str, after: str) -> None:
|
||||
members = [m.value for m in ConfigLevel]
|
||||
idx_before = members.index(before)
|
||||
idx_value = members.index(value)
|
||||
idx_after = members.index(after)
|
||||
assert idx_before < idx_value < idx_after, (
|
||||
f"Expected {before} < {value} < {after} in {members}"
|
||||
)
|
||||
|
||||
|
||||
@then('the default ignore patterns should include "{pattern}"')
|
||||
def step_ignore_patterns(context: Context, pattern: str) -> None:
|
||||
from cleveragents.application.services.context_service import (
|
||||
DEFAULT_IGNORE_PATTERNS,
|
||||
)
|
||||
|
||||
assert pattern in DEFAULT_IGNORE_PATTERNS, (
|
||||
f"'{pattern}' not in DEFAULT_IGNORE_PATTERNS: {DEFAULT_IGNORE_PATTERNS}"
|
||||
@then('ConfigLevel should not have value "{value}"')
|
||||
def step_level_enum_not_have_value(context: Context, value: str) -> None:
|
||||
values = [m.value for m in ConfigLevel]
|
||||
assert value not in values, (
|
||||
f"ConfigLevel should NOT have value '{value}', but found it in {values}"
|
||||
)
|
||||
@@ -59,7 +59,7 @@ Config Resolution Project Scope
|
||||
Should Contain ${result.stdout} config-resolution-project-ok
|
||||
|
||||
Config Resolution Verbose Chain
|
||||
[Documentation] Verify that verbose=True returns all 6 chain entries
|
||||
[Documentation] Verify that verbose=True returns all 5 chain entries
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-verbose-chain cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
@@ -182,17 +182,16 @@ def resolve_project() -> None:
|
||||
|
||||
|
||||
def resolve_verbose_chain() -> None:
|
||||
"""Verify verbose=True populates all 6 chain entries."""
|
||||
"""Verify verbose=True populates all 5 chain entries."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
result = svc.resolve("core.log.level", verbose=True)
|
||||
chain_len = len(result.chain)
|
||||
if chain_len == 6:
|
||||
if chain_len == 5:
|
||||
# Verify the sources in order
|
||||
expected_sources = [
|
||||
ConfigLevel.CLI_FLAG.value,
|
||||
ConfigLevel.ENV_VAR.value,
|
||||
ConfigLevel.LOCAL.value,
|
||||
ConfigLevel.PROJECT.value,
|
||||
ConfigLevel.GLOBAL.value,
|
||||
ConfigLevel.DEFAULT.value,
|
||||
@@ -208,7 +207,7 @@ def resolve_verbose_chain() -> None:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected 6 chain entries, got {chain_len}",
|
||||
f"FAIL: expected 5 chain entries, got {chain_len}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Config service with multi-level resolution chain and typed key registry.
|
||||
|
||||
Provides a resolution layer on top of ``Settings`` (pydantic-settings) that
|
||||
manages TOML file persistence and implements a five-level precedence chain:
|
||||
manages TOML file persistence and implements the spec-defined five-level
|
||||
precedence chain:
|
||||
|
||||
CLI flag > environment variable > project-scoped > global config > default
|
||||
|
||||
@@ -36,22 +37,27 @@ _logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ConfigLevel(Enum):
|
||||
"""Precedence levels from highest to lowest priority."""
|
||||
"""Precedence levels from highest to lowest priority.
|
||||
|
||||
Matches the five-level chain defined in the specification:
|
||||
CLI flag > env var > project-scoped > global config > default.
|
||||
"""
|
||||
|
||||
CLI_FLAG = "cli_flag"
|
||||
ENV_VAR = "env_var"
|
||||
LOCAL = "local"
|
||||
PROJECT = "project"
|
||||
GLOBAL = "global"
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
class ConfigScope(Enum):
|
||||
"""File-based configuration scopes for ``--scope`` CLI flag."""
|
||||
"""File-based configuration scopes for ``--scope`` CLI flag.
|
||||
|
||||
Only ``global`` and ``project`` scopes are defined in the specification.
|
||||
"""
|
||||
|
||||
GLOBAL = "global"
|
||||
PROJECT = "project"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -1230,13 +1236,6 @@ class ConfigService:
|
||||
return None
|
||||
return self._project_root / "config.toml"
|
||||
|
||||
@property
|
||||
def local_config_path(self) -> Path | None:
|
||||
"""Return the path to the local-scoped ``config.local.toml``."""
|
||||
if self._project_root is None:
|
||||
return None
|
||||
return self._project_root / "config.local.toml"
|
||||
|
||||
# -- TOML file management -------------------------------------------------
|
||||
|
||||
def read_config(self) -> dict[str, Any]:
|
||||
@@ -1254,23 +1253,14 @@ class ConfigService:
|
||||
with open(path, "rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
|
||||
def read_local_config(self) -> dict[str, Any]:
|
||||
"""Read the local-scoped ``config.local.toml`` from the project root."""
|
||||
path = self.local_config_path
|
||||
if path is None or not path.exists():
|
||||
return {}
|
||||
with open(path, "rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
|
||||
def read_merged_config(self) -> dict[str, Any]:
|
||||
"""Return the three-scope deep-merged configuration.
|
||||
"""Return the two-scope deep-merged configuration.
|
||||
|
||||
Merge order (lowest to highest priority):
|
||||
``global < project < local``
|
||||
``global < project``
|
||||
"""
|
||||
merged = self.read_config()
|
||||
merged = _deep_merge(merged, self.read_project_config())
|
||||
merged = _deep_merge(merged, self.read_local_config())
|
||||
return merged
|
||||
|
||||
def write_config(self, data: dict[str, Any]) -> None:
|
||||
@@ -1302,8 +1292,7 @@ class ConfigService:
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the project root is not discovered and scope is
|
||||
``PROJECT`` or ``LOCAL``.
|
||||
If the project root is not discovered and scope is ``PROJECT``.
|
||||
"""
|
||||
if scope == ConfigScope.GLOBAL:
|
||||
self.write_config(data)
|
||||
@@ -1317,10 +1306,7 @@ class ConfigService:
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if scope == ConfigScope.PROJECT:
|
||||
target = self._project_root / "config.toml"
|
||||
else:
|
||||
target = self._project_root / "config.local.toml"
|
||||
target = self._project_root / "config.toml"
|
||||
|
||||
if target.exists():
|
||||
with open(target) as fh:
|
||||
@@ -1397,10 +1383,8 @@ class ConfigService:
|
||||
|
||||
if effective_scope == ConfigScope.GLOBAL:
|
||||
data = self.read_config()
|
||||
elif effective_scope == ConfigScope.PROJECT:
|
||||
data = self.read_project_config()
|
||||
else:
|
||||
data = self.read_local_config()
|
||||
data = self.read_project_config()
|
||||
|
||||
old_value = data.get(key)
|
||||
data[key] = value
|
||||
@@ -1490,7 +1474,7 @@ class ConfigService:
|
||||
project_name: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> ResolvedValue:
|
||||
"""Resolve *key* through the six-level precedence chain.
|
||||
"""Resolve *key* through the five-level precedence chain.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -1511,8 +1495,7 @@ class ConfigService:
|
||||
|
||||
Precedence (highest → lowest)::
|
||||
|
||||
CLI flag > env var > local (config.local.toml)
|
||||
> project (config.toml) > global > default
|
||||
CLI flag > env var > project (config.toml) > global > default
|
||||
"""
|
||||
entry = self.validate_key(key)
|
||||
chain: list[dict[str, Any]] = []
|
||||
@@ -1553,33 +1536,7 @@ class ConfigService:
|
||||
}
|
||||
)
|
||||
|
||||
# Level 3: Local config (config.local.toml in project root)
|
||||
local_val: Any | None = None
|
||||
if winner_source is None:
|
||||
local_data = self.read_local_config()
|
||||
local_val = local_data.get(key)
|
||||
if local_val is not None and winner_source is None:
|
||||
coerced = self.validate_type(key, local_val)
|
||||
if verbose:
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.LOCAL.value,
|
||||
"value": coerced,
|
||||
"path": str(self.local_config_path or ""),
|
||||
}
|
||||
)
|
||||
winner_value = coerced
|
||||
winner_source = ConfigLevel.LOCAL
|
||||
elif verbose:
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.LOCAL.value,
|
||||
"value": None,
|
||||
"path": str(self.local_config_path or ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Level 4: Project-scoped config (config.toml in project root,
|
||||
# Level 3: Project-scoped config (config.toml in project root,
|
||||
# or legacy per-project section in global config)
|
||||
project_val: Any | None = None
|
||||
if winner_source is None:
|
||||
@@ -1617,7 +1574,7 @@ class ConfigService:
|
||||
}
|
||||
)
|
||||
|
||||
# Level 5: Global config file
|
||||
# Level 4: Global config file
|
||||
global_val: Any | None = None
|
||||
if winner_source is None:
|
||||
config_data = self.read_config()
|
||||
@@ -1643,7 +1600,7 @@ class ConfigService:
|
||||
}
|
||||
)
|
||||
|
||||
# Level 6: Default
|
||||
# Level 5: Default
|
||||
if verbose:
|
||||
chain.append({"source": ConfigLevel.DEFAULT.value, "value": entry.default})
|
||||
if winner_source is None:
|
||||
|
||||
@@ -41,7 +41,6 @@ DEFAULT_IGNORE_PATTERNS = [
|
||||
"venv",
|
||||
".env",
|
||||
".agentsignore",
|
||||
"config.local.toml",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.pyd",
|
||||
|
||||
@@ -189,7 +189,7 @@ def config_set(
|
||||
typer.Option(
|
||||
"--scope",
|
||||
"-s",
|
||||
help="Config scope: global, project, or local (default: global)",
|
||||
help="Config scope: global or project (default: global)",
|
||||
),
|
||||
] = None,
|
||||
project: Annotated[
|
||||
@@ -202,13 +202,12 @@ def config_set(
|
||||
|
||||
Writes the value to the config file for the specified scope.
|
||||
Scopes: ``global`` (~/.cleveragents/config.toml),
|
||||
``project`` (<project-root>/config.toml),
|
||||
``local`` (<project-root>/config.local.toml).
|
||||
``project`` (<project-root>/config.toml).
|
||||
|
||||
Examples::
|
||||
|
||||
agents config set core.log.level DEBUG
|
||||
agents config set plan.concurrency 8 --scope local
|
||||
agents config set plan.concurrency 8 --scope project
|
||||
agents config set core.automation-profile manual --project local/prod
|
||||
"""
|
||||
normalized = _validate_key(key)
|
||||
@@ -225,7 +224,7 @@ def config_set(
|
||||
config_scope = ConfigScope(scope.lower())
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(
|
||||
f"Invalid scope: '{scope}'. Use 'global', 'project', or 'local'."
|
||||
f"Invalid scope: '{scope}'. Use 'global' or 'project'."
|
||||
) from exc
|
||||
|
||||
# Read previous value
|
||||
@@ -248,13 +247,11 @@ def config_set(
|
||||
svc.write_config(config_data)
|
||||
scope_display = f"project:{project}"
|
||||
elif config_scope is not None:
|
||||
# New three-scope set (--scope flag)
|
||||
# Two-scope set (--scope flag)
|
||||
if config_scope == ConfigScope.GLOBAL:
|
||||
previous = config_data.get(normalized)
|
||||
elif config_scope == ConfigScope.PROJECT:
|
||||
previous = svc.read_project_config().get(normalized)
|
||||
else:
|
||||
previous = svc.read_local_config().get(normalized)
|
||||
previous = svc.read_project_config().get(normalized)
|
||||
svc.set_value(normalized, coerced, scope=config_scope)
|
||||
scope_display = config_scope.value
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user