fix(config): integrate emit_config_changed() helper with scoped set_value()
CI / security (pull_request) Failing after 1s
CI / quality (pull_request) Failing after 2s
CI / integration_tests (pull_request) Failing after 1s
CI / e2e_tests (pull_request) Failing after 1s
CI / build (pull_request) Failing after 2s
CI / helm (pull_request) Failing after 2s
CI / lint (pull_request) Successful in 21s
CI / typecheck (pull_request) Successful in 45s
CI / unit_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / benchmark-publish (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
CI / security (pull_request) Failing after 1s
CI / quality (pull_request) Failing after 2s
CI / integration_tests (pull_request) Failing after 1s
CI / e2e_tests (pull_request) Failing after 1s
CI / build (pull_request) Failing after 2s
CI / helm (pull_request) Failing after 2s
CI / lint (pull_request) Successful in 21s
CI / typecheck (pull_request) Successful in 45s
CI / unit_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / benchmark-publish (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
Resolve merge conflict between master's scoped config (ConfigScope, write_scoped_config, scoped set_value) and PR's atomicity helpers (emit_config_changed, snapshot/restore, compensating events). - Keep master's ConfigScope enum, scoped set_value(key, value, *, scope) - Add emit_config_changed() helper with scope parameter for audit events - Refactor set_value() to delegate event emission to emit_config_changed() - Preserve scope in CONFIG_CHANGED event details per master's convention ISSUES CLOSED: #993
This commit is contained in:
@@ -40,11 +40,20 @@ class ConfigLevel(Enum):
|
||||
|
||||
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."""
|
||||
|
||||
GLOBAL = "global"
|
||||
PROJECT = "project"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigEntry:
|
||||
"""Metadata for a registered configuration key."""
|
||||
@@ -1095,6 +1104,40 @@ _build_catalog()
|
||||
_DEFAULT_CONFIG_DIR: Path = Path.home() / ".cleveragents"
|
||||
_DEFAULT_CONFIG_PATH: Path = _DEFAULT_CONFIG_DIR / "config.toml"
|
||||
|
||||
# Markers that identify a CleverAgents project root directory.
|
||||
_PROJECT_ROOT_MARKERS: tuple[str, ...] = ("cleveragents.toml", ".cleveragents")
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively merge *override* into *base* (override wins on conflict).
|
||||
|
||||
Both *base* and *override* remain unmodified; a new dict is returned.
|
||||
"""
|
||||
merged: dict[str, Any] = dict(base)
|
||||
for key, value in override.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def discover_project_root(start: Path | None = None) -> Path | None:
|
||||
"""Walk up from *start* (default ``Path.cwd()``) to find the project root.
|
||||
|
||||
The project root is the nearest ancestor directory that contains one
|
||||
of the marker files/directories defined in ``_PROJECT_ROOT_MARKERS``
|
||||
(``cleveragents.toml`` or ``.cleveragents/``).
|
||||
|
||||
Returns ``None`` when no project root is found.
|
||||
"""
|
||||
current = (start or Path.cwd()).resolve()
|
||||
for directory in (current, *current.parents):
|
||||
for marker in _PROJECT_ROOT_MARKERS:
|
||||
if (directory / marker).exists():
|
||||
return directory
|
||||
return None
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""Service providing multi-level config resolution with TOML persistence.
|
||||
@@ -1105,6 +1148,9 @@ class ConfigService:
|
||||
Override for the configuration directory (default ``~/.cleveragents``).
|
||||
config_path:
|
||||
Override for the TOML file path (default ``config_dir / config.toml``).
|
||||
project_root:
|
||||
Override for the project root directory. When ``None`` the service
|
||||
will auto-discover the project root by walking up from ``Path.cwd()``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -1113,10 +1159,16 @@ class ConfigService:
|
||||
config_dir: Path | None = None,
|
||||
config_path: Path | None = None,
|
||||
event_bus: EventBus | None = None,
|
||||
project_root: Path | None = ..., # type: ignore[assignment]
|
||||
) -> None:
|
||||
self._config_dir: Path = config_dir or _DEFAULT_CONFIG_DIR
|
||||
self._config_path: Path = config_path or (self._config_dir / "config.toml")
|
||||
self._event_bus = event_bus
|
||||
# ``...`` means "auto-discover"; ``None`` means "no project root".
|
||||
if project_root is ...:
|
||||
self._project_root: Path | None = discover_project_root()
|
||||
else:
|
||||
self._project_root = project_root
|
||||
|
||||
# -- public registry API --------------------------------------------------
|
||||
|
||||
@@ -1135,6 +1187,27 @@ class ConfigService:
|
||||
"""Return all registered key names sorted alphabetically."""
|
||||
return sorted(_REGISTRY.keys())
|
||||
|
||||
# -- path accessors -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def project_root(self) -> Path | None:
|
||||
"""Return the discovered (or overridden) project root directory."""
|
||||
return self._project_root
|
||||
|
||||
@property
|
||||
def project_config_path(self) -> Path | None:
|
||||
"""Return the path to the project-scoped ``config.toml``."""
|
||||
if self._project_root is None:
|
||||
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]:
|
||||
@@ -1144,8 +1217,35 @@ class ConfigService:
|
||||
with open(self._config_path, "rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
|
||||
def read_project_config(self) -> dict[str, Any]:
|
||||
"""Read the project-scoped ``config.toml`` from the project root."""
|
||||
path = self.project_config_path
|
||||
if path is None or not path.exists():
|
||||
return {}
|
||||
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.
|
||||
|
||||
Merge order (lowest to highest priority):
|
||||
``global < project < local``
|
||||
"""
|
||||
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:
|
||||
"""Write *data* to the TOML config file, creating dirs if needed."""
|
||||
"""Write *data* to the global TOML config file, creating dirs if needed."""
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self._config_path.exists():
|
||||
@@ -1160,12 +1260,58 @@ class ConfigService:
|
||||
with open(self._config_path, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
def write_scoped_config(self, data: dict[str, Any], scope: ConfigScope) -> None:
|
||||
"""Write *data* to the config file for the given *scope*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data:
|
||||
Key/value pairs to persist.
|
||||
scope:
|
||||
Which scope file to write to.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the project root is not discovered and scope is
|
||||
``PROJECT`` or ``LOCAL``.
|
||||
"""
|
||||
if scope == ConfigScope.GLOBAL:
|
||||
self.write_config(data)
|
||||
return
|
||||
|
||||
if self._project_root is None:
|
||||
msg = (
|
||||
f"Cannot write to {scope.value} scope: "
|
||||
"no project root discovered. "
|
||||
"Run from within a CleverAgents project directory."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if scope == ConfigScope.PROJECT:
|
||||
target = self._project_root / "config.toml"
|
||||
else:
|
||||
target = self._project_root / "config.local.toml"
|
||||
|
||||
if target.exists():
|
||||
with open(target) as fh:
|
||||
doc = tomlkit.load(fh)
|
||||
else:
|
||||
doc = tomlkit.document()
|
||||
|
||||
for key, value in data.items():
|
||||
doc[key] = value
|
||||
|
||||
with open(target, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
def emit_config_changed(
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
scope: str | None = None,
|
||||
compensating: bool = False,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
@@ -1185,6 +1331,8 @@ class ConfigService:
|
||||
"old_value": emitted_old,
|
||||
"new_value": emitted_new,
|
||||
}
|
||||
if scope is not None:
|
||||
details["scope"] = scope
|
||||
if compensating:
|
||||
details["compensating"] = True
|
||||
if reason is not None:
|
||||
@@ -1201,13 +1349,38 @@ class ConfigService:
|
||||
except Exception:
|
||||
_logger.warning("audit_emit_failed", event_type="CONFIG_CHANGED")
|
||||
|
||||
def set_value(self, key: str, value: Any) -> None:
|
||||
"""Persist a single key-value pair into the global TOML config."""
|
||||
data = self.read_config()
|
||||
def set_value(
|
||||
self, key: str, value: Any, *, scope: ConfigScope | None = None
|
||||
) -> None:
|
||||
"""Persist a single key-value pair into a scoped TOML config.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key:
|
||||
Configuration key.
|
||||
value:
|
||||
Value to persist.
|
||||
scope:
|
||||
Target scope. Defaults to ``ConfigScope.GLOBAL``.
|
||||
"""
|
||||
effective_scope = scope or ConfigScope.GLOBAL
|
||||
|
||||
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()
|
||||
|
||||
old_value = data.get(key)
|
||||
data[key] = value
|
||||
self.write_config(data)
|
||||
self.emit_config_changed(key=key, old_value=old_value, new_value=value)
|
||||
self.write_scoped_config(data, effective_scope)
|
||||
self.emit_config_changed(
|
||||
key=key,
|
||||
old_value=old_value,
|
||||
new_value=value,
|
||||
scope=effective_scope.value,
|
||||
)
|
||||
|
||||
# -- validation -----------------------------------------------------------
|
||||
|
||||
@@ -1291,7 +1464,7 @@ class ConfigService:
|
||||
project_name: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> ResolvedValue:
|
||||
"""Resolve *key* through the five-level precedence chain.
|
||||
"""Resolve *key* through the six-level precedence chain.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -1309,6 +1482,11 @@ class ConfigService:
|
||||
ResolvedValue
|
||||
Contains the winning value, its source level, and (if
|
||||
*verbose*) the full resolution chain for display.
|
||||
|
||||
Precedence (highest → lowest)::
|
||||
|
||||
CLI flag > env var > local (config.local.toml)
|
||||
> project (config.toml) > global > default
|
||||
"""
|
||||
entry = self.validate_key(key)
|
||||
chain: list[dict[str, Any]] = []
|
||||
@@ -1349,25 +1527,71 @@ class ConfigService:
|
||||
}
|
||||
)
|
||||
|
||||
# Level 3: Project-scoped config
|
||||
# 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,
|
||||
# or legacy per-project section in global config)
|
||||
project_val: Any | None = None
|
||||
if project_name and entry.project_scopable and winner_source is None:
|
||||
config_data = self.read_config()
|
||||
project_section = config_data.get("project", {})
|
||||
if isinstance(project_section, dict):
|
||||
project_overrides = project_section.get(project_name, {})
|
||||
if isinstance(project_overrides, dict):
|
||||
project_val = project_overrides.get(key)
|
||||
if winner_source is None:
|
||||
# First try file-based project config
|
||||
proj_file_data = self.read_project_config()
|
||||
project_val = proj_file_data.get(key)
|
||||
|
||||
# Fall back to legacy per-project section in global config
|
||||
if project_val is None and project_name and entry.project_scopable:
|
||||
config_data = self.read_config()
|
||||
project_section = config_data.get("project", {})
|
||||
if isinstance(project_section, dict):
|
||||
project_overrides = project_section.get(project_name, {})
|
||||
if isinstance(project_overrides, dict):
|
||||
project_val = project_overrides.get(key)
|
||||
|
||||
if project_val is not None and winner_source is None:
|
||||
coerced = self.validate_type(key, project_val)
|
||||
if verbose:
|
||||
chain.append({"source": ConfigLevel.PROJECT.value, "value": coerced})
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.PROJECT.value,
|
||||
"value": coerced,
|
||||
"path": str(self.project_config_path or ""),
|
||||
}
|
||||
)
|
||||
winner_value = coerced
|
||||
winner_source = ConfigLevel.PROJECT
|
||||
elif verbose:
|
||||
chain.append({"source": ConfigLevel.PROJECT.value, "value": None})
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.PROJECT.value,
|
||||
"value": None,
|
||||
"path": str(self.project_config_path or ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Level 4: Global config file
|
||||
# Level 5: Global config file
|
||||
global_val: Any | None = None
|
||||
if winner_source is None:
|
||||
config_data = self.read_config()
|
||||
@@ -1393,7 +1617,7 @@ class ConfigService:
|
||||
}
|
||||
)
|
||||
|
||||
# Level 5: Default
|
||||
# Level 6: Default
|
||||
if verbose:
|
||||
chain.append({"source": ConfigLevel.DEFAULT.value, "value": entry.default})
|
||||
if winner_source is None:
|
||||
|
||||
Reference in New Issue
Block a user