feat(config): add config service with multi-level resolution #474
@@ -81,11 +81,11 @@ class ConfigCLIGetSuite:
|
||||
|
||||
def time_get_key(self) -> None:
|
||||
"""Benchmark getting a single config key."""
|
||||
_runner.invoke(config_app, ["get", "log_level"])
|
||||
_runner.invoke(config_app, ["get", "core.log.level"])
|
||||
|
||||
def time_get_key_json(self) -> None:
|
||||
"""Benchmark getting a key in JSON format."""
|
||||
_runner.invoke(config_app, ["get", "log_level", "--format", "json"])
|
||||
_runner.invoke(config_app, ["get", "core.log.level", "--format", "json"])
|
||||
|
||||
|
||||
class ConfigCLISetSuite:
|
||||
@@ -106,9 +106,9 @@ class ConfigCLISetSuite:
|
||||
|
||||
def time_set_key(self) -> None:
|
||||
"""Benchmark setting a config value."""
|
||||
_runner.invoke(config_app, ["set", "log_level", "DEBUG"])
|
||||
_runner.invoke(config_app, ["set", "core.log.level", "DEBUG"])
|
||||
|
||||
def time_set_get_roundtrip(self) -> None:
|
||||
"""Benchmark set followed by get."""
|
||||
_runner.invoke(config_app, ["set", "log_level", "DEBUG"])
|
||||
_runner.invoke(config_app, ["get", "log_level"])
|
||||
_runner.invoke(config_app, ["set", "core.log.level", "DEBUG"])
|
||||
_runner.invoke(config_app, ["get", "core.log.level"])
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""ASV benchmarks for Config Service resolution chain performance.
|
||||
|
||||
Measures the performance of:
|
||||
- Single key resolution (default, global, env var, CLI flag)
|
||||
- Single key resolution (default, env var, verbose chain)
|
||||
- Full registry resolution (resolve_all)
|
||||
- Key validation and type coercion
|
||||
- Registry lookup and enumeration
|
||||
- Memory consumption for bulk operations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,8 +32,8 @@ from cleveragents.application.services.config_service import ( # noqa: E402
|
||||
)
|
||||
|
||||
|
||||
class ConfigResolutionDefaultSuite:
|
||||
"""Benchmark default resolution (no overrides)."""
|
||||
class ConfigResolutionTimeSuite:
|
||||
"""Benchmark timing for config resolution, validation, and registry ops."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
@@ -41,111 +43,60 @@ class ConfigResolutionDefaultSuite:
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
os.environ.pop("CLEVERAGENTS_LOG_LEVEL", None)
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_default(self) -> None:
|
||||
"""Benchmark resolving a single key from defaults."""
|
||||
self._service.resolve("core.log_level")
|
||||
def time_resolve_single_key(self) -> None:
|
||||
"""Resolve a single key (core.log.level) with no overrides."""
|
||||
self._service.resolve("core.log.level")
|
||||
|
||||
def time_resolve_default_verbose(self) -> None:
|
||||
"""Benchmark resolving a single key with verbose chain."""
|
||||
self._service.resolve("core.log_level", verbose=True)
|
||||
|
||||
|
||||
class ConfigResolutionGlobalSuite:
|
||||
"""Benchmark resolution from global config file."""
|
||||
|
||||
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_value("core.log_level", "DEBUG")
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_global(self) -> None:
|
||||
"""Benchmark resolving from global config file."""
|
||||
self._service.resolve("core.log_level")
|
||||
|
||||
|
||||
class ConfigResolutionEnvSuite:
|
||||
"""Benchmark resolution from env var."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
os.environ["CLEVERAGENTS_CORE_LOG_LEVEL"] = "WARNING"
|
||||
|
||||
def teardown(self) -> None:
|
||||
os.environ.pop("CLEVERAGENTS_CORE_LOG_LEVEL", None)
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_env(self) -> None:
|
||||
"""Benchmark resolving from environment variable."""
|
||||
self._service.resolve("core.log_level")
|
||||
|
||||
|
||||
class ConfigResolutionCLISuite:
|
||||
"""Benchmark resolution with CLI flag."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_cli(self) -> None:
|
||||
"""Benchmark resolving with CLI flag (highest priority)."""
|
||||
self._service.resolve("core.log_level", cli_value="ERROR")
|
||||
|
||||
|
||||
class ConfigResolutionBulkSuite:
|
||||
"""Benchmark bulk operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_all(self) -> None:
|
||||
"""Benchmark resolving all registered keys."""
|
||||
def time_resolve_all_keys(self) -> None:
|
||||
"""Resolve all 103 registered keys via resolve_all()."""
|
||||
self._service.resolve_all()
|
||||
|
||||
def time_registry_keys(self) -> None:
|
||||
"""Benchmark listing all registered keys."""
|
||||
def time_resolve_with_env_var(self) -> None:
|
||||
"""Resolve with an env var set."""
|
||||
os.environ["CLEVERAGENTS_LOG_LEVEL"] = "DEBUG"
|
||||
self._service.resolve("core.log.level")
|
||||
|
||||
def time_resolve_verbose(self) -> None:
|
||||
"""Resolve with verbose=True (builds resolution chain)."""
|
||||
self._service.resolve("core.log.level", verbose=True)
|
||||
|
||||
def time_validate_key(self) -> None:
|
||||
"""Call validate_key() for a known key."""
|
||||
ConfigService.validate_key("core.log.level")
|
||||
|
||||
def time_validate_type_coercion(self) -> None:
|
||||
"""Call validate_type() for str->int coercion."""
|
||||
ConfigService.validate_type("core.log.retention-days", "90")
|
||||
|
||||
def time_registry_lookup(self) -> None:
|
||||
"""Look up an entry via get_entry()."""
|
||||
ConfigService.get_entry("core.log.level")
|
||||
|
||||
def time_registered_keys_sorted(self) -> None:
|
||||
"""Get sorted list of all registered keys."""
|
||||
ConfigService.registered_keys()
|
||||
|
||||
|
||||
class ConfigValidationSuite:
|
||||
"""Benchmark key validation and type coercion."""
|
||||
class ConfigResolutionMemSuite:
|
||||
"""Benchmark memory consumption for bulk config operations."""
|
||||
|
||||
def time_validate_known_key(self) -> None:
|
||||
"""Benchmark validating a known key."""
|
||||
ConfigService.validate_key("core.log_level")
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
|
||||
def time_validate_type_str(self) -> None:
|
||||
"""Benchmark type coercion for string."""
|
||||
ConfigService.validate_type("core.log_level", "DEBUG")
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_validate_type_int(self) -> None:
|
||||
"""Benchmark type coercion for integer."""
|
||||
ConfigService.validate_type("core.server_port", "8080")
|
||||
def mem_resolve_all(self) -> dict:
|
||||
"""Memory for resolving all keys."""
|
||||
return self._service.resolve_all()
|
||||
|
||||
def time_validate_type_bool(self) -> None:
|
||||
"""Benchmark type coercion for boolean."""
|
||||
ConfigService.validate_type("core.debug_enabled", "true")
|
||||
def mem_registry_copy(self) -> dict:
|
||||
"""Memory for .registry() (dict copy)."""
|
||||
return ConfigService.registry()
|
||||
|
||||
+144
-104
@@ -1,151 +1,191 @@
|
||||
# Configuration Resolution Chain
|
||||
# Configuration Resolution
|
||||
|
||||
CleverAgents uses a multi-level configuration resolution chain that determines
|
||||
the effective value for each configuration key. This document describes the
|
||||
resolution order, all registered configuration keys, their types, defaults,
|
||||
and environment variable mappings.
|
||||
CleverAgents resolves every configuration value through a **5-level precedence
|
||||
chain**. The first level that supplies a non-`None` value wins.
|
||||
|
||||
## Resolution Order
|
||||
|
||||
Values are resolved from **highest to lowest** priority. The first level that
|
||||
provides a non-`None` value wins:
|
||||
|
||||
| Priority | Level | Description |
|
||||
|----------|------------------|------------------------------------------------------|
|
||||
| 1 | **CLI flag** | Value passed explicitly via `--key=value` on the CLI |
|
||||
| 2 | **Env var** | Environment variable (`CLEVERAGENTS_<SECTION>_<KEY>`) |
|
||||
| 3 | **Project scope**| Per-project override in `[project."<name>"]` TOML table |
|
||||
| 4 | **Global config**| Value in `~/.cleveragents/config.toml` |
|
||||
| 5 | **Default** | Built-in default from the key registry |
|
||||
|
||||
### Example
|
||||
| Priority | Source | Example |
|
||||
|----------|--------|---------|
|
||||
| 1 (highest) | **CLI flag** | `--format json`, `--data-dir /tmp` |
|
||||
| 2 | **Environment variable** | `CLEVERAGENTS_CORE_LOG_LEVEL=WARNING` |
|
||||
| 3 | **Project-scoped config** | `[project."myapp"]` table in TOML |
|
||||
| 4 | **Global config file** | `~/.cleveragents/config.toml` |
|
||||
| 5 (lowest) | **Built-in default** | Hardcoded in key registry |
|
||||
|
||||
```
|
||||
$ export CLEVERAGENTS_CORE_LOG_LEVEL=WARNING
|
||||
$ agents config set core.log_level DEBUG # writes to global config
|
||||
$ agents config get core.log_level
|
||||
# -> WARNING (env var wins over global config)
|
||||
# -> WARNING (env var at priority 2 beats global config at priority 4)
|
||||
```
|
||||
|
||||
## Environment Variable Convention
|
||||
## Key Format
|
||||
|
||||
All registered keys map to environment variables following the pattern:
|
||||
Keys use hierarchical dot-separated names. The segment before the first dot is
|
||||
the **group**; everything after it is the **key name** within that group.
|
||||
|
||||
```
|
||||
CLEVERAGENTS_<SECTION>_<KEY>
|
||||
core.log.level
|
||||
plan.budget.per-plan
|
||||
provider.openai.api-key
|
||||
```
|
||||
|
||||
Where `<SECTION>` and `<KEY>` are uppercased. For example:
|
||||
Environment variable mapping follows the pattern
|
||||
`CLEVERAGENTS_<GROUP>_<KEY>` with dots and hyphens replaced by underscores and
|
||||
all characters uppercased:
|
||||
|
||||
- `core.log_level` -> `CLEVERAGENTS_CORE_LOG_LEVEL`
|
||||
- `plan.max_retries` -> `CLEVERAGENTS_PLAN_MAX_RETRIES`
|
||||
- `provider.temperature` -> `CLEVERAGENTS_PROVIDER_TEMPERATURE`
|
||||
- `core.log.level` → `CLEVERAGENTS_CORE_LOG_LEVEL`
|
||||
- `plan.budget.per-plan` → `CLEVERAGENTS_PLAN_BUDGET_PER_PLAN`
|
||||
|
||||
## Configuration Keys
|
||||
**Exception:** `provider.*` credential keys use standard provider env vars
|
||||
(see [Provider Credentials](#provider-credentials)).
|
||||
|
||||
### `core.*` — Core Runtime
|
||||
## Configuration Groups
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|----------------------|--------|------------------------------|------------------------------------|-------------------|-------------------------------|
|
||||
| `core.log_level` | `str` | `INFO` | `CLEVERAGENTS_CORE_LOG_LEVEL` | Yes | Logging verbosity level |
|
||||
| `core.debug_enabled` | `bool` | `false` | `CLEVERAGENTS_CORE_DEBUG_ENABLED` | Yes | Enable debug mode |
|
||||
| `core.env` | `str` | `development` | `CLEVERAGENTS_CORE_ENV` | Yes | Runtime environment name |
|
||||
| `core.data_dir` | `str` | `data` | `CLEVERAGENTS_CORE_DATA_DIR` | Yes | Base data directory path |
|
||||
| `core.database_url` | `str` | `sqlite:///cleveragents.db` | `CLEVERAGENTS_CORE_DATABASE_URL` | No | Primary database URL |
|
||||
| `core.server_host` | `str` | `0.0.0.0` | `CLEVERAGENTS_CORE_SERVER_HOST` | No | Server bind host |
|
||||
| `core.server_port` | `int` | `8080` | `CLEVERAGENTS_CORE_SERVER_PORT` | No | Server bind port |
|
||||
| Group | Keys | Scope | Description |
|
||||
|-------|------|-------|-------------|
|
||||
| `core.*` | 14 | Mixed | Core system settings — logging, data dirs, database, runtime env |
|
||||
| `server.*` | 4 | Global only | Server mode — bind host, port, TLS, workers |
|
||||
| `actor.*` | 5 | Mixed | Actor defaults — timeout, retry, concurrency |
|
||||
| `plan.*` | 8 | Mixed | Plan execution — budget, retries, auto-apply, timeout |
|
||||
| `sandbox.*` | 5 | Mixed | Sandbox and checkpointing — strategy, cleanup, max age |
|
||||
| `index.*` | 12 | Mixed | Code intelligence and indexing — backend, embedding model, dimensions |
|
||||
| `context.*` | 43 | **All project-scopable** | Context tier defaults — token limits, file caps, inclusion rules |
|
||||
| `provider.*` | 11 | Global only | LLM provider credentials and model defaults |
|
||||
| `skills.*` | 1 | Project-scopable | Agent Skills discovery paths |
|
||||
|
||||
### `plan.*` — Plan Execution
|
||||
**Total: 103 registered keys.**
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|------------------------|--------|---------|--------------------------------------|-------------------|---------------------------------------|
|
||||
| `plan.auto_apply` | `bool` | `false` | `CLEVERAGENTS_PLAN_AUTO_APPLY` | Yes | Auto-apply plans on completion |
|
||||
| `plan.max_retries` | `int` | `3` | `CLEVERAGENTS_PLAN_MAX_RETRIES` | Yes | Maximum plan retry count |
|
||||
| `plan.timeout_seconds` | `int` | `300` | `CLEVERAGENTS_PLAN_TIMEOUT_SECONDS` | Yes | Plan execution timeout in seconds |
|
||||
## Project-Scopable Keys
|
||||
|
||||
### `provider.*` — LLM Provider
|
||||
Keys marked as project-scopable can be overridden per-project in the
|
||||
`[project."<name>"]` TOML table. When a project name is active, the resolver
|
||||
checks this table at priority 3 before falling back to the global value.
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|--------------------------|---------|---------|----------------------------------------|-------------------|-------------------------------|
|
||||
| `provider.default_provider` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_PROVIDER` | No | Default LLM provider name |
|
||||
| `provider.default_model` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_MODEL` | No | Default LLM model name |
|
||||
| `provider.temperature` | `float` | `0.7` | `CLEVERAGENTS_PROVIDER_TEMPERATURE` | Yes | LLM sampling temperature |
|
||||
| `provider.max_tokens` | `int` | `4096` | `CLEVERAGENTS_PROVIDER_MAX_TOKENS` | Yes | Max tokens per LLM response |
|
||||
**Fully project-scopable groups:**
|
||||
|
||||
### `sandbox.*` — Sandbox Isolation
|
||||
- `context.*` — all 43 keys
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|------------------------|--------|----------------|--------------------------------------|-------------------|---------------------------------------|
|
||||
| `sandbox.strategy` | `str` | `git_worktree` | `CLEVERAGENTS_SANDBOX_STRATEGY` | Yes | Default sandbox strategy |
|
||||
| `sandbox.auto_cleanup` | `bool` | `true` | `CLEVERAGENTS_SANDBOX_AUTO_CLEANUP` | Yes | Auto-clean sandbox after use |
|
||||
| `sandbox.max_age_hours`| `int` | `48` | `CLEVERAGENTS_SANDBOX_MAX_AGE_HOURS` | Yes | Max sandbox age before cleanup |
|
||||
**Partially project-scopable groups:**
|
||||
|
||||
### `context.*` — Context Management
|
||||
- `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`
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|--------------------------------|--------|----------|-------------------------------------------------|-------------------|--------------------------------------------|
|
||||
| `context.max_files` | `int` | `100` | `CLEVERAGENTS_CONTEXT_MAX_FILES` | Yes | Maximum context files per session |
|
||||
| `context.max_tokens` | `int` | `128000` | `CLEVERAGENTS_CONTEXT_MAX_TOKENS` | Yes | Maximum context token window |
|
||||
| `context.auto_include_gitignore` | `bool` | `true` | `CLEVERAGENTS_CONTEXT_AUTO_INCLUDE_GITIGNORE` | Yes | Auto-include gitignore in context filtering |
|
||||
**Never project-scopable:**
|
||||
|
||||
### `index.*` — Vector Store Indexing
|
||||
- `server.*` — applies globally to the running process
|
||||
- `provider.*` — credentials are always global
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|--------------------------|--------|---------|------------------------------------------|-------------------|---------------------------------|
|
||||
| `index.enabled` | `bool` | `false` | `CLEVERAGENTS_INDEX_ENABLED` | Yes | Enable vector-store indexing |
|
||||
| `index.backend` | `str` | `faiss` | `CLEVERAGENTS_INDEX_BACKEND` | Yes | Vector store backend name |
|
||||
| `index.embedding_model` | `str` | `fake` | `CLEVERAGENTS_INDEX_EMBEDDING_MODEL` | Yes | Embedding model for indexing |
|
||||
| `index.embedding_dimension` | `int` | `1536` | `CLEVERAGENTS_INDEX_EMBEDDING_DIMENSION` | Yes | Embedding vector dimension |
|
||||
## CLI Commands
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Unknown Keys
|
||||
|
||||
Attempting to get or set an unregistered key will produce an actionable error:
|
||||
### `agents config set`
|
||||
|
||||
```
|
||||
ValueError: Unknown configuration key: 'bogus.key'.
|
||||
Valid keys include: context.auto_include_gitignore, context.max_files, ...
|
||||
agents config set <key> <value> [--project <name>]
|
||||
```
|
||||
|
||||
### Type Mismatches
|
||||
Writes a value to the global config file (or to the project-scoped table when
|
||||
`--project` is supplied). The value is coerced to the key's registered type.
|
||||
|
||||
Values are coerced to the registered type. If coercion fails, an actionable
|
||||
error is raised:
|
||||
### `agents config get`
|
||||
|
||||
```
|
||||
TypeError: Type mismatch for key 'core.server_port': expected int,
|
||||
got str with value 'not_a_number'.
|
||||
agents config get <key> [--verbose] [--project <name>]
|
||||
```
|
||||
|
||||
Boolean coercion accepts: `true`/`false`, `1`/`0`, `yes`/`no` (case-insensitive).
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--verbose` | Print the full resolution chain showing every level |
|
||||
| `--project` | Resolve as if inside the named project |
|
||||
|
||||
## TOML File Location
|
||||
|
||||
The global configuration file is stored at:
|
||||
Verbose output example:
|
||||
|
||||
```
|
||||
~/.cleveragents/config.toml
|
||||
```
|
||||
|
||||
Parent directories are created automatically on first write. Project-scoped
|
||||
overrides live under `[project."<name>"]` tables within the same file.
|
||||
|
||||
## Verbose Mode
|
||||
|
||||
When `--verbose` is passed to `config get`, the full resolution chain is
|
||||
displayed, showing which level provided (or could provide) the value:
|
||||
|
||||
```
|
||||
$ agents config get core.log_level --verbose
|
||||
Key: core.log_level
|
||||
$ agents config get core.log.level --verbose
|
||||
Key: core.log.level
|
||||
Value: INFO
|
||||
Source: default
|
||||
|
||||
Resolution chain (highest -> lowest priority):
|
||||
cli_flag -> (not set)
|
||||
env_var -> (not set) [CLEVERAGENTS_CORE_LOG_LEVEL]
|
||||
project -> (not set)
|
||||
global -> (not set) [~/.cleveragents/config.toml]
|
||||
default -> INFO <-- active
|
||||
Resolution chain (highest → lowest):
|
||||
cli_flag → (not set)
|
||||
env_var → (not set) [CLEVERAGENTS_CORE_LOG_LEVEL]
|
||||
project → (not set)
|
||||
global → (not set) [~/.cleveragents/config.toml]
|
||||
default → INFO ← active
|
||||
```
|
||||
|
||||
### `agents config list`
|
||||
|
||||
```
|
||||
agents config list [pattern] [--filter-values] [--show-secrets] [--project <name>]
|
||||
```
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `pattern` | Glob filter on key names (e.g. `core.*`, `plan.budget.*`) |
|
||||
| `--filter-values` | Only show keys whose resolved value differs from the default |
|
||||
| `--show-secrets` | Unmask secret values (provider keys are masked by default) |
|
||||
| `--project` | Resolve values in the context of the named project |
|
||||
|
||||
## TOML File Format
|
||||
|
||||
`~/.cleveragents/config.toml`:
|
||||
|
||||
```toml
|
||||
[core]
|
||||
log_level = "DEBUG"
|
||||
data_dir = "/var/lib/cleveragents"
|
||||
|
||||
[plan]
|
||||
auto_apply = true
|
||||
max_retries = 5
|
||||
|
||||
[sandbox]
|
||||
strategy = "container"
|
||||
|
||||
[index]
|
||||
enabled = true
|
||||
backend = "faiss"
|
||||
embedding_model = "text-embedding-3-small"
|
||||
|
||||
# Project-scoped overrides
|
||||
[project."myapp"]
|
||||
core.log_level = "WARNING"
|
||||
plan.auto_apply = false
|
||||
context.max_tokens = 64000
|
||||
|
||||
[project."data-pipeline"]
|
||||
plan.budget.per_plan = 2.50
|
||||
sandbox.strategy = "git_worktree"
|
||||
```
|
||||
|
||||
Parent directories are created automatically on the first write.
|
||||
|
||||
## Provider Credentials
|
||||
|
||||
Provider keys use **standard provider environment variable names**, not the
|
||||
`CLEVERAGENTS_*` convention. This avoids requiring users to duplicate
|
||||
credentials under a project-specific prefix.
|
||||
|
||||
| Key | Environment Variable |
|
||||
|-----|---------------------|
|
||||
| `provider.openai.api-key` | `OPENAI_API_KEY` |
|
||||
| `provider.anthropic.api-key` | `ANTHROPIC_API_KEY` |
|
||||
| `provider.google.api-key` | `GOOGLE_API_KEY` |
|
||||
| `provider.azure.api-key` | `AZURE_OPENAI_API_KEY` |
|
||||
| `provider.default-provider` | `CLEVERAGENTS_PROVIDER_DEFAULT_PROVIDER` |
|
||||
| `provider.default-model` | `CLEVERAGENTS_PROVIDER_DEFAULT_MODEL` |
|
||||
| `provider.temperature` | `CLEVERAGENTS_PROVIDER_TEMPERATURE` |
|
||||
|
||||
Provider credential values are **masked** in `config list` output by default.
|
||||
Use `--show-secrets` to reveal them.
|
||||
|
||||
## Validation
|
||||
|
||||
- **Unknown keys** produce an actionable error listing valid keys.
|
||||
- **Type mismatches** raise `TypeError` with the expected type and actual value.
|
||||
- Boolean coercion accepts `true`/`false`, `1`/`0`, `yes`/`no` (case-insensitive).
|
||||
|
||||
+20
-20
@@ -8,15 +8,15 @@ Feature: Config CLI commands
|
||||
|
||||
# --- SET ---
|
||||
Scenario: Set a configuration value
|
||||
When I run config set "log_level" "DEBUG"
|
||||
When I run config set "core.log.level" "DEBUG"
|
||||
Then the config set should succeed
|
||||
And the config set output should contain key "log_level"
|
||||
And the config set output should contain key "core.log.level"
|
||||
And the config set output should contain value "DEBUG"
|
||||
|
||||
Scenario: Set a configuration value with dot-path
|
||||
When I run config set "server.port" "9090"
|
||||
Scenario: Set a configuration value with underscore normalization
|
||||
When I run config set "core_log_level" "INFO"
|
||||
Then the config set should succeed
|
||||
And the config set output should contain key "server_port"
|
||||
And the config set output should contain key "core.log.level"
|
||||
|
||||
Scenario: Set an unknown key fails
|
||||
When I run config set "nonexistent_key" "value"
|
||||
@@ -24,20 +24,20 @@ Feature: Config CLI commands
|
||||
|
||||
# --- GET ---
|
||||
Scenario: Get a configuration value
|
||||
When I run config get "log_level"
|
||||
When I run config get "core.log.level" with verbose
|
||||
Then the config get should succeed
|
||||
And the config get output should contain key "log_level"
|
||||
And the config get output should contain key "core.log.level"
|
||||
And the config get output should show the resolution chain
|
||||
|
||||
Scenario: Get a value after setting it
|
||||
Given I have set config "log_level" to "WARNING"
|
||||
When I run config get "log_level"
|
||||
Given I have set config "core.log.level" to "WARNING"
|
||||
When I run config get "core.log.level"
|
||||
Then the config get should succeed
|
||||
|
||||
Scenario: Get with dot-path alias
|
||||
When I run config get "server.port"
|
||||
Scenario: Get with underscore alias
|
||||
When I run config get "plan_concurrency"
|
||||
Then the config get should succeed
|
||||
And the config get output should contain key "server_port"
|
||||
And the config get output should contain key "plan.concurrency"
|
||||
|
||||
Scenario: Get an unknown key fails
|
||||
When I run config get "totally_bogus_key"
|
||||
@@ -50,12 +50,12 @@ Feature: Config CLI commands
|
||||
And the config list output should contain multiple settings
|
||||
|
||||
Scenario: List with key regex filter
|
||||
When I run config list "log.*"
|
||||
When I run config list "core\.log.*"
|
||||
Then the config list should succeed
|
||||
And every listed key should match "log.*"
|
||||
And every listed key should match "core\.log.*"
|
||||
|
||||
Scenario: List with value regex filter
|
||||
When I run config list with --filter-values "INFO"
|
||||
When I run config list with --filter-values "FATAL"
|
||||
Then the config list should succeed
|
||||
|
||||
Scenario: List with invalid key regex fails
|
||||
@@ -77,15 +77,15 @@ Feature: Config CLI commands
|
||||
|
||||
# --- RESOLUTION CHAIN ---
|
||||
Scenario: Resolution chain shows default source
|
||||
When I run config get key "log_level" formatted as "json"
|
||||
When I run config get key "core.log.level" formatted as "json"
|
||||
Then the resolution chain should include "default" source
|
||||
|
||||
# --- SET/GET ROUNDTRIP ---
|
||||
Scenario: Set and get roundtrip
|
||||
Given I have set config "debug_enabled" to "true"
|
||||
When I run config get key "debug_enabled" formatted as "json"
|
||||
Given I have set config "core.log.file-enabled" to "true"
|
||||
When I run config get key "core.log.file-enabled" formatted as "json"
|
||||
Then the config get should succeed
|
||||
And the get value source should be "config_file"
|
||||
And the get value source should be "global"
|
||||
|
||||
# --- FORMAT ---
|
||||
Scenario: Config list with JSON format
|
||||
@@ -94,6 +94,6 @@ Feature: Config CLI commands
|
||||
And the output should be valid JSON
|
||||
|
||||
Scenario: Config get with JSON format
|
||||
When I run config get key "env" formatted as "json"
|
||||
When I run config get key "core.format" formatted as "json"
|
||||
Then the config get should succeed
|
||||
And the output should be valid JSON
|
||||
|
||||
@@ -7,21 +7,21 @@ Feature: Config CLI safety-net coverage
|
||||
# _normalize_key (L91-96)
|
||||
# =====================================================================
|
||||
|
||||
Scenario: safety-net _normalize_key converts dots to underscores
|
||||
When the safety-net normalizer processes key "server.port"
|
||||
Then the safety-net normalized result should be "server_port"
|
||||
Scenario: safety-net _normalize_key returns registry key as-is
|
||||
When the safety-net normalizer processes key "core.log.level"
|
||||
Then the safety-net normalized result should be "core.log.level"
|
||||
|
||||
Scenario: safety-net _normalize_key converts dashes to underscores
|
||||
When the safety-net normalizer processes key "server-port"
|
||||
Then the safety-net normalized result should be "server_port"
|
||||
Scenario: safety-net _normalize_key converts underscores to dots for registry match
|
||||
When the safety-net normalizer processes key "plan_concurrency"
|
||||
Then the safety-net normalized result should be "plan.concurrency"
|
||||
|
||||
Scenario: safety-net _normalize_key lowercases and strips whitespace
|
||||
When the safety-net normalizer processes key " LOG_LEVEL "
|
||||
Then the safety-net normalized result should be "log_level"
|
||||
Scenario: safety-net _normalize_key strips whitespace
|
||||
When the safety-net normalizer processes key " core.log.level "
|
||||
Then the safety-net normalized result should be "core.log.level"
|
||||
|
||||
Scenario: safety-net _normalize_key handles mixed separators
|
||||
When the safety-net normalizer processes key "My.Log-Level"
|
||||
Then the safety-net normalized result should be "my_log_level"
|
||||
Scenario: safety-net _normalize_key returns unrecognized key as-is
|
||||
When the safety-net normalizer processes key "unknown_random_key"
|
||||
Then the safety-net normalized result should be "unknown_random_key"
|
||||
|
||||
# =====================================================================
|
||||
# _is_secret_key (L116-118)
|
||||
@@ -116,8 +116,8 @@ Feature: Config CLI safety-net coverage
|
||||
Then the safety-net validator should raise BadParameter with "Unknown configuration key"
|
||||
|
||||
Scenario: safety-net _validate_key accepts valid key and returns normalized form
|
||||
When the safety-net validator checks valid key "log.level"
|
||||
Then the safety-net validator should return "log_level"
|
||||
When the safety-net validator checks valid key "core.log.level"
|
||||
Then the safety-net validator should return "core.log.level"
|
||||
|
||||
# =====================================================================
|
||||
# _resolve_source (L163-174) - default path
|
||||
@@ -125,24 +125,24 @@ Feature: Config CLI safety-net coverage
|
||||
|
||||
Scenario: safety-net _resolve_source returns default when no env or file
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net source resolver checks key "log_level"
|
||||
When the safety-net source resolver checks key "core.log.level"
|
||||
Then the safety-net resolved source should be "default"
|
||||
|
||||
Scenario: safety-net _resolve_source returns config_file when key in file
|
||||
Scenario: safety-net _resolve_source returns env_var when env var is set
|
||||
Given a safety-net isolated temp config directory
|
||||
And a safety-net toml config file containing key "log_level" with value "WARNING"
|
||||
When the safety-net source resolver checks key "log_level"
|
||||
Then the safety-net resolved source should be "config_file"
|
||||
And the safety-net env var "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
|
||||
When the safety-net source resolver checks key "core.log.level"
|
||||
Then the safety-net resolved source should be "env_var"
|
||||
|
||||
# =====================================================================
|
||||
# _resolution_chain (L177-209)
|
||||
# =====================================================================
|
||||
|
||||
Scenario: safety-net _resolution_chain returns four-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 "log_level"
|
||||
Then the safety-net chain should have exactly 4 entries
|
||||
And the safety-net chain sources should be "cli_flag, env_var, config_file, default"
|
||||
When the safety-net chain builder builds chain for key "core.log.level"
|
||||
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)
|
||||
@@ -150,46 +150,45 @@ Feature: Config CLI safety-net coverage
|
||||
|
||||
Scenario: safety-net config set coerces boolean true
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI sets key "debug_enabled" to value "true" with format "json"
|
||||
When the safety-net CLI sets key "index.auto-reindex" to value "true" with format "json"
|
||||
Then the safety-net set output should be valid JSON
|
||||
And the safety-net set JSON field "value" should be boolean true
|
||||
|
||||
Scenario: safety-net config set coerces boolean false
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI sets key "debug_enabled" to value "false" with format "json"
|
||||
When the safety-net CLI sets key "index.auto-reindex" to value "false" with format "json"
|
||||
Then the safety-net set output should be valid JSON
|
||||
And the safety-net set JSON field "value" should be boolean false
|
||||
|
||||
Scenario: safety-net config set coerces integer value
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI sets key "server_port" to value "8080" with format "json"
|
||||
When the safety-net CLI sets key "plan.concurrency" to value "8080" with format "json"
|
||||
Then the safety-net set output should be valid JSON
|
||||
And the safety-net set JSON field "value" should be integer 8080
|
||||
|
||||
Scenario: safety-net config set coerces float value
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI sets key "server_port" to value "3.14" with format "json"
|
||||
When the safety-net CLI sets key "plan.budget.warn-threshold" to value "3.14" with format "json"
|
||||
Then the safety-net set output should be valid JSON
|
||||
And the safety-net set JSON field "value" should be float 3.14
|
||||
|
||||
Scenario: safety-net config set keeps string when not numeric or bool
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI sets key "log_level" to value "DEBUG" with format "json"
|
||||
When the safety-net CLI sets key "core.log.level" to value "DEBUG" with format "json"
|
||||
Then the safety-net set output should be valid JSON
|
||||
And the safety-net set JSON field "value" should be string "DEBUG"
|
||||
|
||||
Scenario: safety-net config set shows previous value in result
|
||||
Scenario: safety-net config set captures previous value field
|
||||
Given a safety-net isolated temp config directory
|
||||
And the safety-net CLI has previously set "log_level" to "INFO"
|
||||
When the safety-net CLI sets key "log_level" to value "DEBUG" with format "json"
|
||||
When the safety-net CLI sets key "core.log.level" to value "DEBUG" with format "json"
|
||||
Then the safety-net set output should be valid JSON
|
||||
And the safety-net set JSON field "previous_value" should be string "INFO"
|
||||
And the safety-net set JSON should contain a "previous_value" field
|
||||
|
||||
Scenario: safety-net config set rich format shows panel output
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI sets key "log_level" to value "DEBUG" with format "rich"
|
||||
When the safety-net CLI sets key "core.log.level" to value "DEBUG" with format "rich"
|
||||
Then the safety-net set rich output should contain "Configuration Updated"
|
||||
And the safety-net set rich output should contain "log_level"
|
||||
And the safety-net set rich output should contain "core.log.level"
|
||||
|
||||
# =====================================================================
|
||||
# config_get - rich format (L319-335)
|
||||
@@ -197,19 +196,19 @@ Feature: Config CLI safety-net coverage
|
||||
|
||||
Scenario: safety-net config get rich format displays panel and chain
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI gets key "log_level" with format "rich"
|
||||
When the safety-net CLI gets key "core.log.level" with format "rich" and verbose
|
||||
Then the safety-net get rich output should contain "Configuration Value"
|
||||
And the safety-net get rich output should contain "Resolution chain"
|
||||
|
||||
Scenario: safety-net config get yaml format produces valid YAML
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI gets key "log_level" with format "yaml"
|
||||
When the safety-net CLI gets key "core.log.level" with format "yaml"
|
||||
Then the safety-net get output should be valid YAML
|
||||
And the safety-net get YAML should contain key "key"
|
||||
|
||||
Scenario: safety-net config get json format includes type field
|
||||
Given a safety-net isolated temp config directory
|
||||
When the safety-net CLI gets key "log_level" with format "json"
|
||||
When the safety-net CLI gets key "core.log.level" with format "json" and verbose
|
||||
Then the safety-net get output should be valid JSON with type field
|
||||
|
||||
# =====================================================================
|
||||
@@ -256,15 +255,15 @@ Feature: Config CLI safety-net coverage
|
||||
|
||||
Scenario: safety-net config list masks secret values by default
|
||||
Given a safety-net isolated temp config directory
|
||||
And safety-net settings fields include a secret key with a non-pattern value
|
||||
And the safety-net env var "AZURE_OPENAI_API_KEY" is set to "test-secret-value"
|
||||
When the safety-net CLI lists all config in json format
|
||||
Then the safety-net list JSON should contain masked value "****" for the secret key
|
||||
Then the safety-net list JSON should contain masked "****" for key "provider.azure.api-key"
|
||||
|
||||
Scenario: safety-net config list reveals secrets with show-secrets flag
|
||||
Given a safety-net isolated temp config directory
|
||||
And safety-net settings fields include a secret key with a non-pattern value
|
||||
And the safety-net env var "AZURE_OPENAI_API_KEY" is set to "test-secret-value"
|
||||
When the safety-net CLI lists all config in json format with show-secrets
|
||||
Then the safety-net list JSON should contain the actual secret value
|
||||
Then the safety-net list JSON should contain value "test-secret-value" for key "provider.azure.api-key"
|
||||
|
||||
# =====================================================================
|
||||
# config_list - modified flag (L405-407)
|
||||
@@ -283,5 +282,5 @@ Feature: Config CLI safety-net coverage
|
||||
Scenario: safety-net config get rich format marks active source in chain
|
||||
Given a safety-net isolated temp config directory
|
||||
And a safety-net patched console for capturing rich output
|
||||
When the safety-net CLI gets key "log_level" with format "rich"
|
||||
When the safety-net CLI gets key "core.log.level" with format "rich" and verbose
|
||||
Then the safety-net captured console output should contain "active"
|
||||
|
||||
@@ -1,58 +1,112 @@
|
||||
Feature: Config CLI uncovered branches
|
||||
Cover missed lines and branches in config.py:
|
||||
_settings_defaults factory/None paths, _validate_key empty,
|
||||
_settings_defaults registry path, _validate_key empty,
|
||||
_write_config_file existing-file path, _resolve_source env path,
|
||||
config_set/get non-rich formats, config_list empty result.
|
||||
config_set/get non-rich formats, config_list empty result,
|
||||
config_set --project flag, config_get --verbose flag.
|
||||
|
||||
# -- _settings_defaults with default_factory (L81-85) --
|
||||
Scenario: config cli branch settings defaults uses default_factory when default is None
|
||||
Given a config cli branch mocked Settings with a default_factory field
|
||||
# -- _settings_defaults returns defaults from registry --
|
||||
Scenario: config cli branch settings defaults returns registry defaults
|
||||
When I config cli branch call _settings_defaults
|
||||
Then the config cli branch defaults should contain the factory value
|
||||
Then the config cli branch defaults should contain registry default values
|
||||
|
||||
# -- _settings_defaults with both default and factory None (L87) --
|
||||
Scenario: config cli branch settings defaults returns None when no default and no factory
|
||||
Given a config cli branch mocked Settings with a None-only field
|
||||
# -- _settings_defaults with a mocked registry entry having None default --
|
||||
Scenario: config cli branch settings defaults returns None for None-default entry
|
||||
Given a config cli branch mocked registry entry with None default
|
||||
When I config cli branch call _settings_defaults
|
||||
Then the config cli branch defaults should contain None for the field
|
||||
Then the config cli branch defaults should contain None for the mocked key
|
||||
|
||||
# -- _validate_key with empty key (L102-103) --
|
||||
Scenario: config cli branch validate_key rejects empty key
|
||||
When I config cli branch call _validate_key with an empty key
|
||||
Then the config cli branch call should raise BadParameter
|
||||
|
||||
# -- _write_config_file when file already exists (L145-147) --
|
||||
# -- _write_config_file when file already exists --
|
||||
Scenario: config cli branch write_config_file merges into existing file
|
||||
Given a config cli branch temp config directory
|
||||
And a config cli branch existing config file with key "log_level" set to "DEBUG"
|
||||
When I config cli branch write config with key "server_port" set to "9090"
|
||||
And a config cli branch existing config file with key "core.log.level" set to "DEBUG"
|
||||
When I config cli branch write config with key "plan.concurrency" set to "4"
|
||||
Then the config cli branch config file should contain both keys
|
||||
|
||||
# -- _resolve_source returns "env" (L169-170) --
|
||||
Scenario: config cli branch resolve_source returns env when env var is set
|
||||
# -- _resolve_source returns "env_var" when env var is set --
|
||||
Scenario: config cli branch resolve_source returns env_var when env var is set
|
||||
Given a config cli branch temp config directory
|
||||
And the config cli branch env var "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
|
||||
When I config cli branch call _resolve_source for "log_level"
|
||||
Then the config cli branch source should be "env"
|
||||
When I config cli branch call _resolve_source for "core.log.level"
|
||||
Then the config cli branch source should be "env_var"
|
||||
|
||||
# -- config_set with non-rich format (L261-262) --
|
||||
# -- config_set with non-rich format --
|
||||
Scenario: config cli branch config set with json format uses format_output
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config set "log_level" "DEBUG" with format "json"
|
||||
When I config cli branch run config set "core.log.level" "DEBUG" with format "json"
|
||||
Then the config cli branch set result should be valid JSON
|
||||
And the config cli branch set JSON should contain key "key"
|
||||
And the config cli branch set JSON should contain key "value"
|
||||
|
||||
# -- config_get with non-rich format and Path value (L311-312, L314-315) --
|
||||
# -- config_set with --project flag --
|
||||
Scenario: config cli branch config set with project flag writes project-scoped value
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config set "core.automation-profile" "manual" with project "local/prod"
|
||||
Then the config cli branch set result for project should succeed
|
||||
And the config cli branch set result should contain scope "project:local/prod"
|
||||
|
||||
# -- config_get with --verbose flag --
|
||||
Scenario: config cli branch config get with verbose shows resolution chain
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config get "core.log.level" with verbose
|
||||
Then the config cli branch get verbose result should succeed
|
||||
And the config cli branch get verbose output should contain resolution chain
|
||||
|
||||
# -- config_get with non-rich format and Path value --
|
||||
Scenario: config cli branch config get with json format serialises Path values
|
||||
Given a config cli branch temp config directory
|
||||
And config cli branch settings_fields returns a Path value for "log_dir"
|
||||
When I config cli branch run config get "log_dir" with format "json"
|
||||
And config cli branch resolve returns a Path value for "core.log.dir"
|
||||
When I config cli branch run config get "core.log.dir" with format "json"
|
||||
Then the config cli branch get result should be valid JSON
|
||||
And the config cli branch get JSON value should be a string not a Path
|
||||
|
||||
# -- config_list with filter matching nothing (L422-423) --
|
||||
# -- config_set with bool value coercion --
|
||||
Scenario: config cli branch config set coerces bool value
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config set "core.log.file-enabled" "false" with format "json"
|
||||
Then the config cli branch set result should be valid JSON
|
||||
And the config cli branch set JSON value should be bool false
|
||||
|
||||
# -- config_set with int value coercion --
|
||||
Scenario: config cli branch config set coerces int value
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config set "plan.concurrency" "8" with format "json"
|
||||
Then the config cli branch set result should be valid JSON
|
||||
And the config cli branch set JSON value should be int 8
|
||||
|
||||
# -- config_set with float value coercion --
|
||||
Scenario: config cli branch config set coerces float value
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config set "plan.budget.warn-threshold" "0.5" with format "json"
|
||||
Then the config cli branch set result should be valid JSON
|
||||
And the config cli branch set JSON value should be float 0.5
|
||||
|
||||
# -- config_list with filter matching nothing --
|
||||
Scenario: config cli branch config list prints message when no matches
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config list with pattern "zzz_nonexistent_pattern_xyz"
|
||||
Then the config cli branch list output should say no values match
|
||||
|
||||
# -- config_list with invalid regex --
|
||||
Scenario: config cli branch config list with invalid regex fails
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config list with pattern "[invalid"
|
||||
Then the config cli branch list result should fail with regex error
|
||||
|
||||
# -- config_get with unknown key --
|
||||
Scenario: config cli branch config get with unknown key fails
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config get "totally.bogus.key" with format "json"
|
||||
Then the config cli branch get result should fail with unknown key error
|
||||
|
||||
# -- secret masking in list --
|
||||
Scenario: config cli branch config list masks secret values
|
||||
Given a config cli branch temp config directory
|
||||
When I config cli branch run config list with format "json"
|
||||
Then the config cli branch list result should be valid JSON
|
||||
And the config cli branch list JSON should mask api-key values
|
||||
|
||||
@@ -9,56 +9,60 @@ Feature: Config service with multi-level resolution
|
||||
# --- Resolution levels ---
|
||||
|
||||
Scenario: Default value is returned when nothing else is set
|
||||
When I resolve config key "core.log_level"
|
||||
Then the resolved value should be "INFO"
|
||||
When I resolve config key "core.log.level"
|
||||
Then the resolved value should be "FATAL"
|
||||
And the resolved source should be "default"
|
||||
|
||||
Scenario: Global config file overrides default
|
||||
Given I have written global config key "core.log_level" with value "DEBUG"
|
||||
When I resolve config key "core.log_level"
|
||||
Given I have written global config key "core.log.level" with value "DEBUG"
|
||||
When I resolve config key "core.log.level"
|
||||
Then the resolved value should be "DEBUG"
|
||||
And the resolved source should be "global"
|
||||
|
||||
Scenario: Environment variable overrides global config
|
||||
Given I have written global config key "core.log_level" with value "DEBUG"
|
||||
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
|
||||
When I resolve config key "core.log_level"
|
||||
Given I have written global config key "core.log.level" with value "DEBUG"
|
||||
And the environment variable "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
|
||||
When I resolve config key "core.log.level"
|
||||
Then the resolved value should be "WARNING"
|
||||
And the resolved source should be "env_var"
|
||||
|
||||
Scenario: CLI flag overrides environment variable
|
||||
Given the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
|
||||
When I resolve config key "core.log_level" with CLI value "ERROR"
|
||||
Given the environment variable "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
|
||||
When I resolve config key "core.log.level" with CLI value "ERROR"
|
||||
Then the resolved value should be "ERROR"
|
||||
And the resolved source should be "cli_flag"
|
||||
|
||||
Scenario: Project-scoped value overrides global config
|
||||
Given I have written global config key "core.log_level" with value "DEBUG"
|
||||
And I have written project "myproj" config key "core.log_level" with value "TRACE"
|
||||
When I resolve config key "core.log_level" for project "myproj"
|
||||
Then the resolved value should be "TRACE"
|
||||
Given I have written global config key "core.automation-profile" with value "review"
|
||||
And I have written project "myproj" config key "core.automation-profile" with value "trusted"
|
||||
When I resolve config key "core.automation-profile" for project "myproj"
|
||||
Then the resolved value should be "trusted"
|
||||
And the resolved source should be "project"
|
||||
|
||||
Scenario: Env var still overrides project-scoped value
|
||||
Given I have written project "myproj" config key "core.log_level" with value "TRACE"
|
||||
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "ERROR"
|
||||
When I resolve config key "core.log_level" for project "myproj"
|
||||
Then the resolved value should be "ERROR"
|
||||
Given I have written project "myproj" config key "core.automation-profile" with value "trusted"
|
||||
And the environment variable "CLEVERAGENTS_AUTOMATION_PROFILE" is set to "manual"
|
||||
When I resolve config key "core.automation-profile" for project "myproj"
|
||||
Then the resolved value should be "manual"
|
||||
And the resolved source should be "env_var"
|
||||
|
||||
# --- Env var interpolation ---
|
||||
|
||||
Scenario: Env var name matches CLEVERAGENTS convention
|
||||
When I look up the env var for key "core.log_level"
|
||||
Then the env var name should be "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
When I look up the env var for key "core.log.level"
|
||||
Then the env var name should be "CLEVERAGENTS_LOG_LEVEL"
|
||||
|
||||
Scenario: Env var interpolation for plan keys
|
||||
When I look up the env var for key "plan.max_retries"
|
||||
Then the env var name should be "CLEVERAGENTS_PLAN_MAX_RETRIES"
|
||||
When I look up the env var for key "plan.concurrency"
|
||||
Then the env var name should be "CLEVERAGENTS_PLAN_CONCURRENCY"
|
||||
|
||||
Scenario: Env var interpolation for provider keys
|
||||
When I look up the env var for key "provider.temperature"
|
||||
Then the env var name should be "CLEVERAGENTS_PROVIDER_TEMPERATURE"
|
||||
Scenario: Env var interpolation for plan budget keys
|
||||
When I look up the env var for key "plan.budget.warn-threshold"
|
||||
Then the env var name should be "CLEVERAGENTS_PLAN_BUDGET_WARN"
|
||||
|
||||
Scenario: Provider keys use custom env var names
|
||||
When I look up the env var for key "provider.openai.api-key"
|
||||
Then the env var name should be "OPENAI_API_KEY"
|
||||
|
||||
# --- Unknown key rejection ---
|
||||
|
||||
@@ -73,37 +77,37 @@ Feature: Config service with multi-level resolution
|
||||
# --- Type validation ---
|
||||
|
||||
Scenario: Integer value is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_CORE_SERVER_PORT" is set to "9090"
|
||||
When I resolve config key "core.server_port"
|
||||
Given the environment variable "CLEVERAGENTS_PLAN_CONCURRENCY" is set to "9090"
|
||||
When I resolve config key "plan.concurrency"
|
||||
Then the resolved value should be integer 9090
|
||||
|
||||
Scenario: Boolean value true is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "true"
|
||||
When I resolve config key "core.debug_enabled"
|
||||
Given the environment variable "CLEVERAGENTS_LOG_FILE_ENABLED" is set to "true"
|
||||
When I resolve config key "core.log.file-enabled"
|
||||
Then the resolved value should be boolean true
|
||||
|
||||
Scenario: Boolean value false is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "false"
|
||||
When I resolve config key "core.debug_enabled"
|
||||
Given the environment variable "CLEVERAGENTS_LOG_FILE_ENABLED" is set to "false"
|
||||
When I resolve config key "core.log.file-enabled"
|
||||
Then the resolved value should be boolean false
|
||||
|
||||
Scenario: Float value is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_PROVIDER_TEMPERATURE" is set to "0.9"
|
||||
When I resolve config key "provider.temperature"
|
||||
Given the environment variable "CLEVERAGENTS_PLAN_BUDGET_WARN" is set to "0.9"
|
||||
When I resolve config key "plan.budget.warn-threshold"
|
||||
Then the resolved value should be float 0.9
|
||||
|
||||
Scenario: Invalid boolean string raises TypeError
|
||||
When I attempt to validate type for key "core.debug_enabled" with value "notabool"
|
||||
When I attempt to validate type for key "core.log.file-enabled" with value "notabool"
|
||||
Then the config TypeError message should contain "Cannot convert"
|
||||
|
||||
Scenario: Invalid integer string raises TypeError
|
||||
When I attempt to validate type for key "core.server_port" with value "notanumber"
|
||||
When I attempt to validate type for key "plan.concurrency" with value "notanumber"
|
||||
Then the config TypeError message should contain "Type mismatch"
|
||||
|
||||
# --- Verbose resolution chain ---
|
||||
|
||||
Scenario: Verbose mode returns full resolution chain
|
||||
When I resolve config key "core.log_level" with verbose mode
|
||||
When I resolve config key "core.log.level" with verbose mode
|
||||
Then the resolution chain should have 5 entries
|
||||
And the chain should include source "cli_flag"
|
||||
And the chain should include source "env_var"
|
||||
@@ -114,47 +118,55 @@ Feature: Config service with multi-level resolution
|
||||
# --- Key registry ---
|
||||
|
||||
Scenario: Registry contains core keys
|
||||
Then the registry should contain key "core.log_level"
|
||||
And the registry should contain key "core.debug_enabled"
|
||||
And the registry should contain key "core.env"
|
||||
Then the registry should contain key "core.log.level"
|
||||
And the registry should contain key "core.log.file-enabled"
|
||||
And the registry should contain key "core.data-dir"
|
||||
|
||||
Scenario: Registry contains plan keys
|
||||
Then the registry should contain key "plan.auto_apply"
|
||||
And the registry should contain key "plan.max_retries"
|
||||
And the registry should contain key "plan.timeout_seconds"
|
||||
Then the registry should contain key "plan.concurrency"
|
||||
And the registry should contain key "plan.max-child-depth"
|
||||
And the registry should contain key "plan.budget.per-plan"
|
||||
|
||||
Scenario: Registry contains provider keys
|
||||
Then the registry should contain key "provider.default_provider"
|
||||
And the registry should contain key "provider.default_model"
|
||||
And the registry should contain key "provider.temperature"
|
||||
Then the registry should contain key "provider.openai.api-key"
|
||||
And the registry should contain key "provider.anthropic.api-key"
|
||||
|
||||
Scenario: Registry contains sandbox keys
|
||||
Then the registry should contain key "sandbox.strategy"
|
||||
And the registry should contain key "sandbox.auto_cleanup"
|
||||
And the registry should contain key "sandbox.max_age_hours"
|
||||
And the registry should contain key "sandbox.cleanup"
|
||||
And the registry should contain key "sandbox.checkpoint.enabled"
|
||||
|
||||
Scenario: Registry contains context keys
|
||||
Then the registry should contain key "context.max_files"
|
||||
And the registry should contain key "context.max_tokens"
|
||||
Then the registry should contain key "context.hot.max-tokens"
|
||||
And the registry should contain key "context.warm.max-decisions"
|
||||
|
||||
Scenario: Registry contains index keys
|
||||
Then the registry should contain key "index.enabled"
|
||||
And the registry should contain key "index.backend"
|
||||
Then the registry should contain key "index.auto-reindex"
|
||||
And the registry should contain key "index.text.backend"
|
||||
|
||||
Scenario: Registry contains server keys
|
||||
Then the registry should contain key "server.url"
|
||||
And the registry should contain key "server.token"
|
||||
And the registry should contain key "server.sync.auto"
|
||||
|
||||
Scenario: Registry contains actor keys
|
||||
Then the registry should contain key "actor.default.strategy"
|
||||
And the registry should contain key "actor.default.execution"
|
||||
|
||||
# --- TOML file management ---
|
||||
|
||||
Scenario: Writing and reading TOML config roundtrips
|
||||
Given I write config data with key "core.log_level" and value "TRACE"
|
||||
Given I write config data with key "core.log.level" and value "TRACE"
|
||||
When I read the config file
|
||||
Then the config data should contain key "core.log_level" with value "TRACE"
|
||||
Then the config data should contain key "core.log.level" with value "TRACE"
|
||||
|
||||
Scenario: Config directory is auto-created
|
||||
When I write config data to a new directory
|
||||
Then the config directory should exist
|
||||
|
||||
Scenario: Non-project-scopable keys ignore project scope
|
||||
Given I have written project "proj" config key "core.database_url" with value "sqlite:///other.db"
|
||||
When I resolve config key "core.database_url" for project "proj"
|
||||
Given I have written project "proj" config key "core.data-dir" with value "/tmp/other"
|
||||
When I resolve config key "core.data-dir" for project "proj"
|
||||
Then the resolved source should be "default"
|
||||
|
||||
# --- Resolve all ---
|
||||
@@ -166,14 +178,14 @@ Feature: Config service with multi-level resolution
|
||||
# --- Set value ---
|
||||
|
||||
Scenario: Set value persists to TOML file
|
||||
When I set config value "core.log_level" to "CRITICAL"
|
||||
When I set config value "core.log.level" to "CRITICAL"
|
||||
And I read the config file
|
||||
Then the config data should contain key "core.log_level" with value "CRITICAL"
|
||||
Then the config data should contain key "core.log.level" with value "CRITICAL"
|
||||
|
||||
# --- Entry lookup ---
|
||||
|
||||
Scenario: Get entry returns ConfigEntry for valid key
|
||||
When I get entry for key "core.log_level"
|
||||
When I get entry for key "core.log.level"
|
||||
Then the entry should have section "core"
|
||||
And the entry should have python type "str"
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ Feature: ConfigService full coverage
|
||||
Then it should return a dict with the same keys as the internal registry
|
||||
|
||||
Scenario: get_entry returns the entry for a known key
|
||||
When I call ConfigService.get_entry with "core.log_level"
|
||||
Then it should return a ConfigEntry with key "core.log_level"
|
||||
When I call ConfigService.get_entry with "core.log.level"
|
||||
Then it should return a ConfigEntry with key "core.log.level"
|
||||
|
||||
Scenario: get_entry returns None for an unknown key
|
||||
When I call ConfigService.get_entry with "nonexistent.key"
|
||||
@@ -67,7 +67,7 @@ Feature: ConfigService full coverage
|
||||
|
||||
Scenario: registered_keys returns a sorted list of all keys
|
||||
When I call ConfigService.registered_keys
|
||||
Then it should return a sorted list containing "core.log_level"
|
||||
Then it should return a sorted list containing "core.log.level"
|
||||
|
||||
# ---------- read_config ----------
|
||||
|
||||
@@ -78,9 +78,9 @@ Feature: ConfigService full coverage
|
||||
|
||||
Scenario: read_config parses an existing TOML file
|
||||
Given a temporary directory for config service
|
||||
And a TOML config file with key "core.log_level" set to "DEBUG"
|
||||
And a TOML config file with key "core.log.level" set to "DEBUG"
|
||||
When I create a ConfigService pointing to that TOML file
|
||||
Then read_config should return a dict with "core.log_level" equal to "DEBUG"
|
||||
Then read_config should return a dict with "core.log.level" equal to "DEBUG"
|
||||
|
||||
# ---------- write_config ----------
|
||||
|
||||
@@ -108,8 +108,8 @@ Feature: ConfigService full coverage
|
||||
# ---------- validate_key ----------
|
||||
|
||||
Scenario: validate_key returns entry for a known key
|
||||
When I call validate_key with "core.log_level"
|
||||
Then it should return the ConfigEntry for "core.log_level" without error
|
||||
When I call validate_key with "core.log.level"
|
||||
Then it should return the ConfigEntry for "core.log.level" without error
|
||||
|
||||
Scenario: validate_key raises ValueError for an unknown key
|
||||
When I call validate_key with "totally.unknown"
|
||||
@@ -118,55 +118,55 @@ Feature: ConfigService full coverage
|
||||
# ---------- validate_type ----------
|
||||
|
||||
Scenario: validate_type returns value unchanged when type already matches
|
||||
When I call validate_type with key "core.log_level" and value "INFO"
|
||||
When I call validate_type with key "core.log.level" and value "INFO"
|
||||
Then it should return "INFO" unchanged
|
||||
|
||||
Scenario: validate_type coerces a string to int
|
||||
When I call validate_type with key "core.server_port" and string value "9090"
|
||||
When I call validate_type with key "plan.concurrency" and string value "9090"
|
||||
Then it should return the integer 9090
|
||||
|
||||
Scenario: validate_type coerces a string to float
|
||||
When I call validate_type with key "provider.temperature" and string value "0.5"
|
||||
When I call validate_type with key "plan.budget.warn-threshold" and string value "0.5"
|
||||
Then it should return the float 0.5
|
||||
|
||||
Scenario: validate_type coerces non-string to str
|
||||
When I call validate_type with key "core.log_level" and integer value 123
|
||||
When I call validate_type with key "core.log.level" and integer value 123
|
||||
Then it should return the string "123"
|
||||
|
||||
Scenario: validate_type coerces "true" string to bool True
|
||||
When I call validate_type with key "core.debug_enabled" and string value "true"
|
||||
When I call validate_type with key "core.log.file-enabled" and string value "true"
|
||||
Then it should return boolean True
|
||||
|
||||
Scenario: validate_type coerces "1" string to bool True
|
||||
When I call validate_type with key "core.debug_enabled" and string value "1"
|
||||
When I call validate_type with key "core.log.file-enabled" and string value "1"
|
||||
Then it should return boolean True
|
||||
|
||||
Scenario: validate_type coerces "yes" string to bool True
|
||||
When I call validate_type with key "core.debug_enabled" and string value "yes"
|
||||
When I call validate_type with key "core.log.file-enabled" and string value "yes"
|
||||
Then it should return boolean True
|
||||
|
||||
Scenario: validate_type coerces "false" string to bool False
|
||||
When I call validate_type with key "core.debug_enabled" and string value "false"
|
||||
When I call validate_type with key "core.log.file-enabled" and string value "false"
|
||||
Then it should return boolean False
|
||||
|
||||
Scenario: validate_type coerces "0" string to bool False
|
||||
When I call validate_type with key "core.debug_enabled" and string value "0"
|
||||
When I call validate_type with key "core.log.file-enabled" and string value "0"
|
||||
Then it should return boolean False
|
||||
|
||||
Scenario: validate_type coerces "no" string to bool False
|
||||
When I call validate_type with key "core.debug_enabled" and string value "no"
|
||||
When I call validate_type with key "core.log.file-enabled" and string value "no"
|
||||
Then it should return boolean False
|
||||
|
||||
Scenario: validate_type raises TypeError for invalid bool string
|
||||
When I call validate_type with key "core.debug_enabled" and string value "maybe"
|
||||
When I call validate_type with key "core.log.file-enabled" and string value "maybe"
|
||||
Then a TypeError should be raised mentioning "Cannot convert"
|
||||
|
||||
Scenario: validate_type raises TypeError for non-coercible int value
|
||||
When I call validate_type with key "core.server_port" and string value "not_a_number"
|
||||
When I call validate_type with key "plan.concurrency" and string value "not_a_number"
|
||||
Then a TypeError should be raised mentioning "Type mismatch"
|
||||
|
||||
Scenario: validate_type raises TypeError for non-bool non-string to bool key
|
||||
When I call validate_type with key "core.debug_enabled" and a list value
|
||||
When I call validate_type with key "core.log.file-enabled" and a list value
|
||||
Then a TypeError should be raised mentioning "Type mismatch"
|
||||
|
||||
Scenario: validate_type raises ValueError for completely unknown key
|
||||
@@ -178,85 +178,85 @@ Feature: ConfigService full coverage
|
||||
Scenario: resolve returns default value when no overrides exist
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with empty config for resolve tests
|
||||
When I resolve key "core.log_level" with no overrides
|
||||
Then the resolved value should be "INFO" from source DEFAULT
|
||||
When I resolve key "core.log.level" with no overrides
|
||||
Then the resolved value should be "FATAL" from source DEFAULT
|
||||
|
||||
Scenario: resolve returns CLI flag value when provided
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with empty config for resolve tests
|
||||
When I resolve key "core.log_level" with cli_value "TRACE"
|
||||
When I resolve key "core.log.level" with cli_value "TRACE"
|
||||
Then the resolved value should be "TRACE" from source CLI_FLAG
|
||||
|
||||
Scenario: resolve returns environment variable when no CLI flag
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with empty config for resolve tests
|
||||
And the env var "CLEVERAGENTS_CORE_LOG_LEVEL" is injected with "WARNING"
|
||||
When I resolve key "core.log_level" with no overrides
|
||||
And the env var "CLEVERAGENTS_LOG_LEVEL" is injected with "WARNING"
|
||||
When I resolve key "core.log.level" with no overrides
|
||||
Then the resolved value should be "WARNING" from source ENV_VAR
|
||||
|
||||
Scenario: CLI flag takes precedence over environment variable
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with empty config for resolve tests
|
||||
And the env var "CLEVERAGENTS_CORE_LOG_LEVEL" is injected with "WARNING"
|
||||
When I resolve key "core.log_level" with cli_value "TRACE"
|
||||
And the env var "CLEVERAGENTS_LOG_LEVEL" is injected with "WARNING"
|
||||
When I resolve key "core.log.level" with cli_value "TRACE"
|
||||
Then the resolved value should be "TRACE" from source CLI_FLAG
|
||||
|
||||
Scenario: resolve returns project-scoped value when set
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"
|
||||
When I resolve key "core.log_level" with project_name "myproj"
|
||||
And a ConfigService with project-scoped config for key "core.automation-profile" value "PROJECT_DEBUG" under project "myproj"
|
||||
When I resolve key "core.automation-profile" with project_name "myproj"
|
||||
Then the resolved value should be "PROJECT_DEBUG" from source PROJECT
|
||||
|
||||
Scenario: resolve returns global config value when no higher overrides
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"
|
||||
When I resolve key "core.log_level" with no overrides
|
||||
And a ConfigService with global config key "core.log.level" set to "GLOBAL_WARN"
|
||||
When I resolve key "core.log.level" with no overrides
|
||||
Then the resolved value should be "GLOBAL_WARN" from source GLOBAL
|
||||
|
||||
Scenario: resolve populates chain when verbose is True
|
||||
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
|
||||
When I resolve key "core.log.level" with verbose True and no overrides
|
||||
Then the resolved chain should have 5 entries covering all levels
|
||||
|
||||
Scenario: resolve verbose chain includes env_name for ENV_VAR level
|
||||
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 verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
When I resolve key "core.log.level" with verbose True and no overrides
|
||||
Then the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_LOG_LEVEL"
|
||||
|
||||
Scenario: resolve verbose chain includes path for GLOBAL level
|
||||
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
|
||||
When I resolve key "core.log.level" with verbose True and no overrides
|
||||
Then the verbose chain GLOBAL entry should include the config file path
|
||||
|
||||
Scenario: resolve verbose chain shows project value when project-scoped wins
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"
|
||||
When I resolve key "core.log_level" with project_name "myproj" and verbose True
|
||||
And a ConfigService with project-scoped config for key "core.automation-profile" value "PROJECT_DEBUG" under project "myproj"
|
||||
When I resolve key "core.automation-profile" with project_name "myproj" and verbose True
|
||||
Then the resolved value should be "PROJECT_DEBUG" from source PROJECT
|
||||
And the verbose chain PROJECT entry should have value "PROJECT_DEBUG"
|
||||
|
||||
Scenario: resolve verbose chain shows global value when global config wins
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"
|
||||
When I resolve key "core.log_level" with verbose True and no overrides
|
||||
And a ConfigService with global config key "core.log.level" set to "GLOBAL_WARN"
|
||||
When I resolve key "core.log.level" with verbose True and no overrides
|
||||
Then the resolved value should be "GLOBAL_WARN" from source GLOBAL
|
||||
And the verbose chain GLOBAL entry should have value "GLOBAL_WARN"
|
||||
|
||||
Scenario: resolve skips project scope for non-scopable keys
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with project-scoped config for key "core.database_url" value "pg://proj" under project "myproj"
|
||||
When I resolve key "core.database_url" with project_name "myproj"
|
||||
Then the resolved value should not be "pg://proj"
|
||||
And a ConfigService with project-scoped config for key "core.data-dir" value "/proj/data" under project "myproj"
|
||||
When I resolve key "core.data-dir" with project_name "myproj"
|
||||
Then the resolved value should not be "/proj/data"
|
||||
And the resolved source should be DEFAULT or GLOBAL
|
||||
|
||||
# ---------- env_var_for_key ----------
|
||||
|
||||
Scenario: env_var_for_key returns the env var name for a valid key
|
||||
When I call env_var_for_key with "core.log_level"
|
||||
Then it should return "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
When I call env_var_for_key with "core.log.level"
|
||||
Then it should return "CLEVERAGENTS_LOG_LEVEL"
|
||||
|
||||
Scenario: env_var_for_key raises ValueError for an unknown key
|
||||
When I call env_var_for_key with "nonexistent.key"
|
||||
@@ -273,5 +273,5 @@ Feature: ConfigService full coverage
|
||||
Scenario: resolve_all applies CLI overrides to matching keys
|
||||
Given a temporary directory for config service
|
||||
And a ConfigService with empty config for resolve tests
|
||||
When I call resolve_all with cli_override "core.log_level" set to "FATAL"
|
||||
Then the resolve_all result for "core.log_level" should have value "FATAL" and source CLI_FLAG
|
||||
When I call resolve_all with cli_override "core.log.level" set to "FATAL"
|
||||
Then the resolve_all result for "core.log.level" should have value "FATAL" and source CLI_FLAG
|
||||
|
||||
@@ -471,6 +471,13 @@ def step_sn_set_previous(context: Context, expected: str) -> None:
|
||||
assert val == expected, f"Expected previous '{expected}', got {val!r}"
|
||||
|
||||
|
||||
@then('the safety-net set JSON should contain a "previous_value" field')
|
||||
def step_sn_set_has_previous(context: Context) -> None:
|
||||
assert "previous_value" in context._sn_set_json, (
|
||||
f"Expected 'previous_value' field in: {list(context._sn_set_json.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the safety-net set rich output should contain "{text}"')
|
||||
def step_sn_set_rich_contains(context: Context, text: str) -> None:
|
||||
result = context._sn_result
|
||||
@@ -488,6 +495,13 @@ def step_sn_cli_get(context: Context, key: str, fmt: str) -> None:
|
||||
context._sn_result = _runner.invoke(config_app, ["get", key, "--format", fmt])
|
||||
|
||||
|
||||
@when('the safety-net CLI gets key "{key}" with format "{fmt}" and verbose')
|
||||
def step_sn_cli_get_verbose(context: Context, key: str, fmt: str) -> None:
|
||||
context._sn_result = _runner.invoke(
|
||||
config_app, ["get", key, "--format", fmt, "--verbose"]
|
||||
)
|
||||
|
||||
|
||||
@then('the safety-net get rich output should contain "{text}"')
|
||||
def step_sn_get_rich_contains(context: Context, text: str) -> None:
|
||||
result = context._sn_result
|
||||
@@ -639,6 +653,30 @@ def step_sn_list_unmasked(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@then('the safety-net list JSON should contain masked "****" for key "{key}"')
|
||||
def step_sn_list_masked_by_key(context: Context, key: str) -> None:
|
||||
result = context._sn_result
|
||||
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
|
||||
parsed = json.loads(result.output.strip())
|
||||
entries = [e for e in parsed if e.get("key") == key]
|
||||
assert len(entries) > 0, f"No '{key}' entry found in list output"
|
||||
assert entries[0]["value"] == "****", (
|
||||
f"Expected masked ****, got {entries[0]['value']!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the safety-net list JSON should contain value "{value}" for key "{key}"')
|
||||
def step_sn_list_value_by_key(context: Context, value: str, key: str) -> None:
|
||||
result = context._sn_result
|
||||
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
|
||||
parsed = json.loads(result.output.strip())
|
||||
entries = [e for e in parsed if e.get("key") == key]
|
||||
assert len(entries) > 0, f"No '{key}' entry found in list output"
|
||||
assert entries[0]["value"] == value, (
|
||||
f"Expected '{value}', got {entries[0]['value']!r}"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_list - modified flag (L405-407)
|
||||
# ===================================================================
|
||||
|
||||
@@ -112,6 +112,11 @@ def step_run_config_get(context: Context, key: str) -> None:
|
||||
context.result = _runner.invoke(config_app, ["get", key])
|
||||
|
||||
|
||||
@when('I run config get "{key}" with verbose')
|
||||
def step_run_config_get_verbose(context: Context, key: str) -> None:
|
||||
context.result = _runner.invoke(config_app, ["get", key, "--verbose"])
|
||||
|
||||
|
||||
@when('I run config get key "{key}" formatted as "{fmt}"')
|
||||
def step_run_config_get_fmt(context: Context, key: str, fmt: str) -> None:
|
||||
context.result = _runner.invoke(config_app, ["get", key, "--format", fmt])
|
||||
@@ -195,12 +200,12 @@ def step_config_list_succeed(context: Context) -> None:
|
||||
|
||||
@then("the config list output should contain multiple settings")
|
||||
def step_config_list_multiple(context: Context) -> None:
|
||||
# The rich table should have many rows; just check for known keys
|
||||
assert "log_level" in context.result.output, (
|
||||
f"Expected 'log_level' in output: {context.result.output}"
|
||||
# The rich table should have many rows; check for known registry keys
|
||||
assert "core.log.level" in context.result.output, (
|
||||
f"Expected 'core.log.level' in output: {context.result.output}"
|
||||
)
|
||||
assert "server_port" in context.result.output, (
|
||||
f"Expected 'server_port' in output: {context.result.output}"
|
||||
assert "plan.concurrency" in context.result.output, (
|
||||
f"Expected 'plan.concurrency' in output: {context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@@ -234,7 +239,7 @@ def step_config_list_fail_regex(context: Context) -> None:
|
||||
def step_secrets_masked(context: Context) -> None:
|
||||
output = context.result.output
|
||||
# If any API key field is shown, it should be masked
|
||||
if "api_key" in output or "token" in output:
|
||||
if "api-key" in output or "token" in output:
|
||||
assert "****" in output, f"Expected masked values (****) in output: {output}"
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Step definitions for config_cli_uncovered_branches.feature.
|
||||
|
||||
Covers missed lines/branches in cleveragents/cli/commands/config.py:
|
||||
L81-85 _settings_defaults default_factory path
|
||||
L87 _settings_defaults both-None path
|
||||
L102-103 _validate_key empty key
|
||||
L145-147 _write_config_file existing-file merge
|
||||
L169-170 _resolve_source env-var path
|
||||
L261-262 config_set non-rich format
|
||||
L311-315 config_get non-rich + Path serialisation
|
||||
L422-423 config_list empty result
|
||||
_settings_defaults registry defaults path
|
||||
_validate_key empty key
|
||||
_write_config_file existing-file merge
|
||||
_resolve_source env-var path
|
||||
config_set non-rich format, --project flag, type coercion
|
||||
config_get non-rich + Path serialisation, --verbose flag
|
||||
config_list empty result, invalid regex, secret masking
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,13 +19,19 @@ import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import typer
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.application.services.config_service import (
|
||||
_REGISTRY,
|
||||
ConfigEntry,
|
||||
ConfigLevel,
|
||||
ResolvedValue,
|
||||
)
|
||||
from cleveragents.cli.commands import config as config_mod
|
||||
from cleveragents.cli.commands.config import (
|
||||
_resolve_source,
|
||||
@@ -75,74 +80,83 @@ def _stop_temp_config(context: Context) -> None:
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _settings_defaults - default_factory branch (L81-85)
|
||||
# _settings_defaults - returns defaults from registry
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a config cli branch mocked Settings with a default_factory field")
|
||||
def step_mock_settings_factory(context: Context) -> None:
|
||||
"""Create a mock Settings class whose model_fields include a factory field."""
|
||||
factory_field = MagicMock()
|
||||
factory_field.default = None # triggers elif
|
||||
factory_field.default_factory = lambda: ["from_factory"]
|
||||
|
||||
normal_field = MagicMock()
|
||||
normal_field.default = "normal_val"
|
||||
normal_field.default_factory = None
|
||||
|
||||
context._cfg_branch_mock_fields = {
|
||||
"factory_key": factory_field,
|
||||
"normal_key": normal_field,
|
||||
}
|
||||
|
||||
|
||||
@given("a config cli branch mocked Settings with a None-only field")
|
||||
def step_mock_settings_none(context: Context) -> None:
|
||||
"""Field where both .default and .default_factory are None (L87)."""
|
||||
none_field = MagicMock()
|
||||
none_field.default = None
|
||||
none_field.default_factory = None
|
||||
|
||||
context._cfg_branch_mock_fields = {"none_key": none_field}
|
||||
|
||||
|
||||
@when("I config cli branch call _settings_defaults")
|
||||
def step_call_settings_defaults(context: Context) -> None:
|
||||
mock_settings_cls = MagicMock()
|
||||
mock_settings_cls.model_fields = context._cfg_branch_mock_fields
|
||||
|
||||
# _settings_defaults() does `from cleveragents.config.settings import Settings`
|
||||
# so we must patch the canonical location that the local import resolves from.
|
||||
with patch(
|
||||
"cleveragents.config.settings.Settings",
|
||||
mock_settings_cls,
|
||||
):
|
||||
context._cfg_branch_defaults_real = _settings_defaults()
|
||||
context._cfg_branch_defaults_real = _settings_defaults()
|
||||
|
||||
|
||||
@then("the config cli branch defaults should contain the factory value")
|
||||
def step_defaults_factory_value(context: Context) -> None:
|
||||
@then("the config cli branch defaults should contain registry default values")
|
||||
def step_defaults_registry_values(context: Context) -> None:
|
||||
defaults = context._cfg_branch_defaults_real
|
||||
# factory_key should have the value produced by the lambda
|
||||
assert "factory_key" in defaults, f"factory_key missing: {defaults}"
|
||||
assert defaults["factory_key"] == ["from_factory"], (
|
||||
f"Expected ['from_factory'], got {defaults['factory_key']}"
|
||||
# core.log.level should have default "FATAL"
|
||||
assert "core.log.level" in defaults, f"core.log.level missing: {defaults.keys()}"
|
||||
assert defaults["core.log.level"] == "FATAL", (
|
||||
f"Expected 'FATAL', got {defaults['core.log.level']}"
|
||||
)
|
||||
# normal_key should have its literal default
|
||||
assert defaults["normal_key"] == "normal_val", (
|
||||
f"Expected 'normal_val', got {defaults['normal_key']}"
|
||||
# plan.concurrency should have default 4
|
||||
assert "plan.concurrency" in defaults, (
|
||||
f"plan.concurrency missing: {defaults.keys()}"
|
||||
)
|
||||
assert defaults["plan.concurrency"] == 4, (
|
||||
f"Expected 4, got {defaults['plan.concurrency']}"
|
||||
)
|
||||
# core.log.file-enabled should have default True
|
||||
assert "core.log.file-enabled" in defaults, (
|
||||
f"core.log.file-enabled missing: {defaults.keys()}"
|
||||
)
|
||||
assert defaults["core.log.file-enabled"] is True, (
|
||||
f"Expected True, got {defaults['core.log.file-enabled']}"
|
||||
)
|
||||
|
||||
|
||||
@then("the config cli branch defaults should contain None for the field")
|
||||
def step_defaults_none_value(context: Context) -> None:
|
||||
defaults = context._cfg_branch_defaults_real
|
||||
assert "none_key" in defaults, f"none_key missing: {defaults}"
|
||||
assert defaults["none_key"] is None, f"Expected None, got {defaults['none_key']}"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _validate_key - empty key (L102-103)
|
||||
# _settings_defaults - None default entry (mocked)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a config cli branch mocked registry entry with None default")
|
||||
def step_mock_registry_none_default(context: Context) -> None:
|
||||
"""Add a temporary registry entry with None as the default value."""
|
||||
context._cfg_branch_mock_key = "_test_none_default_key"
|
||||
context._cfg_branch_orig_entry = _REGISTRY.get(context._cfg_branch_mock_key)
|
||||
_REGISTRY[context._cfg_branch_mock_key] = ConfigEntry(
|
||||
key=context._cfg_branch_mock_key,
|
||||
python_type=str,
|
||||
default=None,
|
||||
env_var="CLEVERAGENTS_TEST_NONE",
|
||||
project_scopable=False,
|
||||
description="Test entry with None default",
|
||||
section="test",
|
||||
)
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(lambda: _cleanup_mock_registry(context))
|
||||
|
||||
|
||||
def _cleanup_mock_registry(context: Context) -> None:
|
||||
key = getattr(context, "_cfg_branch_mock_key", None)
|
||||
if key and key in _REGISTRY:
|
||||
orig = getattr(context, "_cfg_branch_orig_entry", None)
|
||||
if orig is not None:
|
||||
_REGISTRY[key] = orig
|
||||
else:
|
||||
del _REGISTRY[key]
|
||||
|
||||
|
||||
@then("the config cli branch defaults should contain None for the mocked key")
|
||||
def step_defaults_none_value(context: Context) -> None:
|
||||
defaults = context._cfg_branch_defaults_real
|
||||
key = context._cfg_branch_mock_key
|
||||
assert key in defaults, f"{key} missing: {list(defaults.keys())[:10]}"
|
||||
assert defaults[key] is None, f"Expected None, got {defaults[key]}"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _validate_key - empty key (L116-120)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -166,7 +180,7 @@ def step_validate_key_bad_param(context: Context) -> None:
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _write_config_file - existing file merge (L145-147)
|
||||
# _write_config_file - existing file merge
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -202,14 +216,18 @@ def step_config_file_both_keys(context: Context) -> None:
|
||||
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
|
||||
with open(config_path, "rb") as fh:
|
||||
data = tomllib.load(fh)
|
||||
assert "log_level" in data, f"log_level missing: {data}"
|
||||
assert "server_port" in data, f"server_port missing: {data}"
|
||||
assert data["log_level"] == "DEBUG", f"log_level: {data['log_level']}"
|
||||
assert data["server_port"] == 9090, f"server_port: {data['server_port']}"
|
||||
assert "core.log.level" in data, f"core.log.level missing: {data}"
|
||||
assert "plan.concurrency" in data, f"plan.concurrency missing: {data}"
|
||||
assert data["core.log.level"] == "DEBUG", (
|
||||
f"core.log.level: {data['core.log.level']}"
|
||||
)
|
||||
assert data["plan.concurrency"] == 4, (
|
||||
f"plan.concurrency: {data['plan.concurrency']}"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _resolve_source - env var path (L169-170)
|
||||
# _resolve_source - env var path
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -234,7 +252,7 @@ def step_source_equals(context: Context, expected: str) -> None:
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_set - non-rich format (L261-262)
|
||||
# config_set - non-rich format
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -264,36 +282,99 @@ def step_set_json_has_key(context: Context, key: str) -> None:
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_get - non-rich + Path serialisation (L311-315)
|
||||
# config_set - --project flag
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given('config cli branch settings_fields returns a Path value for "{key}"')
|
||||
def step_mock_settings_fields_path(context: Context, key: str) -> None:
|
||||
"""Patch helpers so config_get sees a Path value and a chain with Paths."""
|
||||
@when('I config cli branch run config set "{key}" "{value}" with project "{project}"')
|
||||
def step_run_config_set_project(
|
||||
context: Context, key: str, value: str, project: str
|
||||
) -> None:
|
||||
context._cfg_branch_result = _runner.invoke(
|
||||
config_app, ["set", key, value, "--project", project]
|
||||
)
|
||||
|
||||
|
||||
@then("the config cli branch set result for project should succeed")
|
||||
def step_set_project_succeed(context: Context) -> None:
|
||||
result = context._cfg_branch_result
|
||||
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
|
||||
|
||||
|
||||
@then('the config cli branch set result should contain scope "{scope}"')
|
||||
def step_set_result_scope(context: Context, scope: str) -> None:
|
||||
result = context._cfg_branch_result
|
||||
assert scope in result.output, f"Expected '{scope}' in output: {result.output}"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_get - --verbose flag
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@when('I config cli branch run config get "{key}" with verbose')
|
||||
def step_run_config_get_verbose(context: Context, key: str) -> None:
|
||||
context._cfg_branch_result = _runner.invoke(config_app, ["get", key, "--verbose"])
|
||||
|
||||
|
||||
@then("the config cli branch get verbose result should succeed")
|
||||
def step_get_verbose_succeed(context: Context) -> None:
|
||||
result = context._cfg_branch_result
|
||||
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
|
||||
|
||||
|
||||
@then("the config cli branch get verbose output should contain resolution chain")
|
||||
def step_get_verbose_chain(context: Context) -> None:
|
||||
result = context._cfg_branch_result
|
||||
output = result.output
|
||||
assert "Resolution chain" in output or "resolution_chain" in output, (
|
||||
f"Expected resolution chain in output: {output}"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_get - non-rich + Path serialisation
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given('config cli branch resolve returns a Path value for "{key}"')
|
||||
def step_mock_resolve_path(context: Context, key: str) -> None:
|
||||
"""Patch ConfigService.resolve so config_get sees a Path value."""
|
||||
path_val = Path("/mock/test/logs")
|
||||
|
||||
mock_fields = {key: path_val, "env": "development"}
|
||||
mock_chain = [
|
||||
{"source": "cli_flag", "value": None},
|
||||
{"source": "env_var", "value": None, "env_name": f"CLEVERAGENTS_{key.upper()}"},
|
||||
{"source": "config_file", "value": None, "path": "/mock/config.toml"},
|
||||
{"source": "default", "value": Path("/mock/default/logs")}, # Path in chain
|
||||
]
|
||||
|
||||
p1 = patch.object(config_mod, "_settings_fields", return_value=mock_fields)
|
||||
p2 = patch.object(config_mod, "_resolve_source", return_value="default")
|
||||
p3 = patch.object(config_mod, "_resolution_chain", return_value=mock_chain)
|
||||
p4 = patch.object(config_mod, "_validate_key", return_value=key)
|
||||
mock_resolved = ResolvedValue(
|
||||
key=key,
|
||||
value=path_val,
|
||||
source=ConfigLevel.DEFAULT,
|
||||
chain=[
|
||||
{"source": ConfigLevel.CLI_FLAG.value, "value": None},
|
||||
{
|
||||
"source": ConfigLevel.ENV_VAR.value,
|
||||
"value": None,
|
||||
"env_name": f"CLEVERAGENTS_{key.upper().replace('.', '_').replace('-', '_')}",
|
||||
},
|
||||
{"source": ConfigLevel.PROJECT.value, "value": None},
|
||||
{
|
||||
"source": ConfigLevel.GLOBAL.value,
|
||||
"value": None,
|
||||
"path": "/mock/config.toml",
|
||||
},
|
||||
{"source": ConfigLevel.DEFAULT.value, "value": Path("/mock/default/logs")},
|
||||
],
|
||||
)
|
||||
|
||||
# Patch _validate_key to accept the key, and ConfigService.resolve
|
||||
p1 = patch.object(config_mod, "_validate_key", return_value=key)
|
||||
p1.start()
|
||||
|
||||
from cleveragents.application.services.config_service import ConfigService
|
||||
|
||||
p2 = patch.object(ConfigService, "resolve", return_value=mock_resolved)
|
||||
p2.start()
|
||||
p3.start()
|
||||
p4.start()
|
||||
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.extend([p1.stop, p2.stop, p3.stop, p4.stop])
|
||||
context._cleanup_handlers.extend([p1.stop, p2.stop])
|
||||
|
||||
|
||||
@when('I config cli branch run config get "{key}" with format "{fmt}"')
|
||||
@@ -322,21 +403,45 @@ def step_get_json_value_string(context: Context) -> None:
|
||||
assert "/mock/test/logs" in data["value"], (
|
||||
f"Expected path string, got: {data['value']}"
|
||||
)
|
||||
# Resolution chain entry with Path should also be serialised
|
||||
chain = data.get("resolution_chain", [])
|
||||
default_entry = [e for e in chain if e["source"] == "default"]
|
||||
assert default_entry, "No 'default' entry in resolution chain"
|
||||
default_val = default_entry[0]["value"]
|
||||
assert isinstance(default_val, str), (
|
||||
f"Chain default value should be str, got {type(default_val)}: {default_val}"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_set - type coercion (bool, int, float)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@then("the config cli branch set JSON value should be bool false")
|
||||
def step_set_json_bool_false(context: Context) -> None:
|
||||
parsed = context._cfg_branch_set_json
|
||||
assert parsed["value"] is False, (
|
||||
f"Expected False, got {parsed['value']} (type {type(parsed['value'])})"
|
||||
)
|
||||
assert "/mock/default/logs" in default_val, (
|
||||
f"Expected default path string, got: {default_val}"
|
||||
|
||||
|
||||
@then("the config cli branch set JSON value should be int 8")
|
||||
def step_set_json_int_8(context: Context) -> None:
|
||||
parsed = context._cfg_branch_set_json
|
||||
assert parsed["value"] == 8, (
|
||||
f"Expected 8, got {parsed['value']} (type {type(parsed['value'])})"
|
||||
)
|
||||
assert isinstance(parsed["value"], int), (
|
||||
f"Expected int, got {type(parsed['value'])}"
|
||||
)
|
||||
|
||||
|
||||
@then("the config cli branch set JSON value should be float 0.5")
|
||||
def step_set_json_float_half(context: Context) -> None:
|
||||
parsed = context._cfg_branch_set_json
|
||||
assert parsed["value"] == 0.5, (
|
||||
f"Expected 0.5, got {parsed['value']} (type {type(parsed['value'])})"
|
||||
)
|
||||
assert isinstance(parsed["value"], float), (
|
||||
f"Expected float, got {type(parsed['value'])}"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_list - empty result (L422-423)
|
||||
# config_list - empty result
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@@ -352,3 +457,61 @@ def step_list_no_match(context: Context) -> None:
|
||||
assert "No configuration values match" in result.output, (
|
||||
f"Expected 'No configuration values match' in: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_list - invalid regex
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@then("the config cli branch list result should fail with regex error")
|
||||
def step_list_regex_error(context: Context) -> None:
|
||||
result = context._cfg_branch_result
|
||||
assert result.exit_code != 0, (
|
||||
f"Expected non-zero exit, got {result.exit_code}: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_get - unknown key
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@then("the config cli branch get result should fail with unknown key error")
|
||||
def step_get_unknown_key_error(context: Context) -> None:
|
||||
result = context._cfg_branch_result
|
||||
assert result.exit_code != 0, (
|
||||
f"Expected non-zero exit, got {result.exit_code}: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# config_list - JSON format + secret masking
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@when('I config cli branch run config list with format "{fmt}"')
|
||||
def step_run_config_list_format(context: Context, fmt: str) -> None:
|
||||
context._cfg_branch_result = _runner.invoke(config_app, ["list", "--format", fmt])
|
||||
|
||||
|
||||
@then("the config cli branch list result should be valid JSON")
|
||||
def step_list_result_json(context: Context) -> None:
|
||||
result = context._cfg_branch_result
|
||||
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
|
||||
parsed = json.loads(result.output.strip())
|
||||
assert isinstance(parsed, list), f"Expected list, got {type(parsed)}"
|
||||
context._cfg_branch_list_json = parsed
|
||||
|
||||
|
||||
@then("the config cli branch list JSON should mask api-key values")
|
||||
def step_list_json_masked(context: Context) -> None:
|
||||
items = context._cfg_branch_list_json
|
||||
# Find entries whose keys contain "api-key" or "token"
|
||||
secret_entries = [e for e in items if "api-key" in e["key"] or "token" in e["key"]]
|
||||
# Secret entries with non-None values should be masked
|
||||
for entry in secret_entries:
|
||||
if entry["value"] is not None:
|
||||
assert entry["value"] == "****", (
|
||||
f"Expected masked '****' for {entry['key']}, got: {entry['value']}"
|
||||
)
|
||||
|
||||
@@ -159,7 +159,7 @@ def step_verify_catalog_sections(context: Any) -> None:
|
||||
|
||||
@then("the registry should contain at least 20 keys")
|
||||
def step_verify_catalog_count(context: Any) -> None:
|
||||
# _build_catalog registers ~24 keys from the source
|
||||
# _build_catalog registers ~103 keys from the source
|
||||
assert len(_REGISTRY) >= 20, f"Expected >=20 keys, got {len(_REGISTRY)}"
|
||||
|
||||
|
||||
@@ -222,15 +222,15 @@ def step_verify_registry_copy(context: Any) -> None:
|
||||
assert context.result is not _REGISTRY
|
||||
|
||||
|
||||
@when('I call ConfigService.get_entry with "core.log_level"')
|
||||
@when('I call ConfigService.get_entry with "core.log.level"')
|
||||
def step_call_get_entry_known(context: Any) -> None:
|
||||
context.result = ConfigService.get_entry("core.log_level")
|
||||
context.result = ConfigService.get_entry("core.log.level")
|
||||
|
||||
|
||||
@then('it should return a ConfigEntry with key "core.log_level"')
|
||||
@then('it should return a ConfigEntry with key "core.log.level"')
|
||||
def step_verify_get_entry_known(context: Any) -> None:
|
||||
assert isinstance(context.result, ConfigEntry)
|
||||
assert context.result.key == "core.log_level"
|
||||
assert context.result.key == "core.log.level"
|
||||
|
||||
|
||||
@when('I call ConfigService.get_entry with "nonexistent.key"')
|
||||
@@ -248,11 +248,11 @@ def step_call_registered_keys(context: Any) -> None:
|
||||
context.result = ConfigService.registered_keys()
|
||||
|
||||
|
||||
@then('it should return a sorted list containing "core.log_level"')
|
||||
@then('it should return a sorted list containing "core.log.level"')
|
||||
def step_verify_registered_keys(context: Any) -> None:
|
||||
assert isinstance(context.result, list)
|
||||
assert context.result == sorted(context.result)
|
||||
assert "core.log_level" in context.result
|
||||
assert "core.log.level" in context.result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -274,11 +274,11 @@ def step_verify_read_empty(context: Any) -> None:
|
||||
assert data == {}
|
||||
|
||||
|
||||
@given('a TOML config file with key "core.log_level" set to "DEBUG"')
|
||||
@given('a TOML config file with key "core.log.level" set to "DEBUG"')
|
||||
def step_write_toml_log_level(context: Any) -> None:
|
||||
context.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
doc = tomlkit.document()
|
||||
doc["core.log_level"] = "DEBUG"
|
||||
doc["core.log.level"] = "DEBUG"
|
||||
with open(context.config_path, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
@@ -291,10 +291,10 @@ def step_service_existing_file(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@then('read_config should return a dict with "core.log_level" equal to "DEBUG"')
|
||||
@then('read_config should return a dict with "core.log.level" equal to "DEBUG"')
|
||||
def step_verify_read_debug(context: Any) -> None:
|
||||
data = context.svc.read_config()
|
||||
assert data.get("core.log_level") == "DEBUG"
|
||||
assert data.get("core.log.level") == "DEBUG"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -367,20 +367,20 @@ def step_verify_set_value(context: Any) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I call validate_key with "core.log_level"')
|
||||
@when('I call validate_key with "core.log.level"')
|
||||
def step_call_validate_key_known(context: Any) -> None:
|
||||
try:
|
||||
context.result = ConfigService.validate_key("core.log_level")
|
||||
context.result = ConfigService.validate_key("core.log.level")
|
||||
context.error = None
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then('it should return the ConfigEntry for "core.log_level" without error')
|
||||
@then('it should return the ConfigEntry for "core.log.level" without error')
|
||||
def step_verify_validate_key_ok(context: Any) -> None:
|
||||
assert context.error is None
|
||||
assert isinstance(context.result, ConfigEntry)
|
||||
assert context.result.key == "core.log_level"
|
||||
assert context.result.key == "core.log.level"
|
||||
|
||||
|
||||
@when('I call validate_key with "totally.unknown"')
|
||||
@@ -404,9 +404,9 @@ def step_verify_validate_key_error(context: Any) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.log_level" and value "INFO"')
|
||||
@when('I call validate_type with key "core.log.level" and value "INFO"')
|
||||
def step_validate_type_same(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.log_level", "INFO")
|
||||
context.result = ConfigService.validate_type("core.log.level", "INFO")
|
||||
|
||||
|
||||
@then('it should return "INFO" unchanged')
|
||||
@@ -415,9 +415,9 @@ def step_verify_type_same(context: Any) -> None:
|
||||
assert isinstance(context.result, str)
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.server_port" and string value "9090"')
|
||||
@when('I call validate_type with key "plan.concurrency" and string value "9090"')
|
||||
def step_validate_type_str_to_int(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.server_port", "9090")
|
||||
context.result = ConfigService.validate_type("plan.concurrency", "9090")
|
||||
|
||||
|
||||
@then("it should return the integer 9090")
|
||||
@@ -426,9 +426,11 @@ def step_verify_int_coercion(context: Any) -> None:
|
||||
assert isinstance(context.result, int)
|
||||
|
||||
|
||||
@when('I call validate_type with key "provider.temperature" and string value "0.5"')
|
||||
@when(
|
||||
'I call validate_type with key "plan.budget.warn-threshold" and string value "0.5"'
|
||||
)
|
||||
def step_validate_type_str_to_float(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("provider.temperature", "0.5")
|
||||
context.result = ConfigService.validate_type("plan.budget.warn-threshold", "0.5")
|
||||
|
||||
|
||||
@then("it should return the float 0.5")
|
||||
@@ -437,9 +439,9 @@ def step_verify_float_coercion(context: Any) -> None:
|
||||
assert isinstance(context.result, float)
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.log_level" and integer value 123')
|
||||
@when('I call validate_type with key "core.log.level" and integer value 123')
|
||||
def step_validate_type_int_to_str(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.log_level", 123)
|
||||
context.result = ConfigService.validate_type("core.log.level", 123)
|
||||
|
||||
|
||||
@then('it should return the string "123"')
|
||||
@@ -448,9 +450,9 @@ def step_verify_str_coercion(context: Any) -> None:
|
||||
assert isinstance(context.result, str)
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and string value "true"')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and string value "true"')
|
||||
def step_validate_type_bool_true(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", "true")
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", "true")
|
||||
|
||||
|
||||
@then("it should return boolean True")
|
||||
@@ -458,19 +460,19 @@ def step_verify_bool_true(context: Any) -> None:
|
||||
assert context.result is True
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and string value "1"')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and string value "1"')
|
||||
def step_validate_type_bool_one(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", "1")
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", "1")
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and string value "yes"')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and string value "yes"')
|
||||
def step_validate_type_bool_yes(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", "yes")
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", "yes")
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and string value "false"')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and string value "false"')
|
||||
def step_validate_type_bool_false(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", "false")
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", "false")
|
||||
|
||||
|
||||
@then("it should return boolean False")
|
||||
@@ -478,20 +480,20 @@ def step_verify_bool_false(context: Any) -> None:
|
||||
assert context.result is False
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and string value "0"')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and string value "0"')
|
||||
def step_validate_type_bool_zero(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", "0")
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", "0")
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and string value "no"')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and string value "no"')
|
||||
def step_validate_type_bool_no(context: Any) -> None:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", "no")
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", "no")
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and string value "maybe"')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and string value "maybe"')
|
||||
def step_validate_type_bool_invalid(context: Any) -> None:
|
||||
try:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", "maybe")
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", "maybe")
|
||||
context.error = None
|
||||
except TypeError as exc:
|
||||
context.error = exc
|
||||
@@ -505,11 +507,11 @@ def step_verify_bool_invalid_error(context: Any) -> None:
|
||||
|
||||
|
||||
@when(
|
||||
'I call validate_type with key "core.server_port" and string value "not_a_number"'
|
||||
'I call validate_type with key "plan.concurrency" and string value "not_a_number"'
|
||||
)
|
||||
def step_validate_type_int_invalid(context: Any) -> None:
|
||||
try:
|
||||
context.result = ConfigService.validate_type("core.server_port", "not_a_number")
|
||||
context.result = ConfigService.validate_type("plan.concurrency", "not_a_number")
|
||||
context.error = None
|
||||
except TypeError as exc:
|
||||
context.error = exc
|
||||
@@ -522,10 +524,10 @@ def step_verify_type_mismatch_error(context: Any) -> None:
|
||||
assert "Type mismatch" in str(context.error)
|
||||
|
||||
|
||||
@when('I call validate_type with key "core.debug_enabled" and a list value')
|
||||
@when('I call validate_type with key "core.log.file-enabled" and a list value')
|
||||
def step_validate_type_bool_list(context: Any) -> None:
|
||||
try:
|
||||
context.result = ConfigService.validate_type("core.debug_enabled", [1, 2, 3])
|
||||
context.result = ConfigService.validate_type("core.log.file-enabled", [1, 2, 3])
|
||||
context.error = None
|
||||
except TypeError as exc:
|
||||
context.error = exc
|
||||
@@ -561,20 +563,20 @@ def step_service_empty_for_resolve(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve key "core.log_level" with no overrides')
|
||||
@when('I resolve key "core.log.level" with no overrides')
|
||||
def step_resolve_no_overrides(context: Any) -> None:
|
||||
context.result = context.svc.resolve("core.log_level")
|
||||
context.result = context.svc.resolve("core.log.level")
|
||||
|
||||
|
||||
@then('the resolved value should be "INFO" from source DEFAULT')
|
||||
@then('the resolved value should be "FATAL" from source DEFAULT')
|
||||
def step_verify_resolve_default(context: Any) -> None:
|
||||
assert context.result.value == "INFO"
|
||||
assert context.result.value == "FATAL"
|
||||
assert context.result.source == ConfigLevel.DEFAULT
|
||||
|
||||
|
||||
@when('I resolve key "core.log_level" with cli_value "TRACE"')
|
||||
@when('I resolve key "core.log.level" with cli_value "TRACE"')
|
||||
def step_resolve_cli(context: Any) -> None:
|
||||
context.result = context.svc.resolve("core.log_level", cli_value="TRACE")
|
||||
context.result = context.svc.resolve("core.log.level", cli_value="TRACE")
|
||||
|
||||
|
||||
@then('the resolved value should be "TRACE" from source CLI_FLAG')
|
||||
@@ -602,14 +604,14 @@ def step_verify_resolve_env(context: Any) -> None:
|
||||
|
||||
|
||||
@given(
|
||||
'a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"'
|
||||
'a ConfigService with project-scoped config for key "core.automation-profile" value "PROJECT_DEBUG" under project "myproj"'
|
||||
)
|
||||
def step_service_project_scoped(context: Any) -> None:
|
||||
context.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
doc = tomlkit.document()
|
||||
project_table = tomlkit.table()
|
||||
myproj_table = tomlkit.table()
|
||||
myproj_table["core.log_level"] = "PROJECT_DEBUG"
|
||||
myproj_table["core.automation-profile"] = "PROJECT_DEBUG"
|
||||
project_table["myproj"] = myproj_table
|
||||
doc["project"] = project_table
|
||||
with open(context.config_path, "w") as fh:
|
||||
@@ -620,9 +622,11 @@ def step_service_project_scoped(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve key "core.log_level" with project_name "myproj"')
|
||||
@when('I resolve key "core.automation-profile" with project_name "myproj"')
|
||||
def step_resolve_project(context: Any) -> None:
|
||||
context.result = context.svc.resolve("core.log_level", project_name="myproj")
|
||||
context.result = context.svc.resolve(
|
||||
"core.automation-profile", project_name="myproj"
|
||||
)
|
||||
|
||||
|
||||
@then('the resolved value should be "PROJECT_DEBUG" from source PROJECT')
|
||||
@@ -631,11 +635,11 @@ def step_verify_resolve_project(context: Any) -> None:
|
||||
assert context.result.source == ConfigLevel.PROJECT
|
||||
|
||||
|
||||
@given('a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"')
|
||||
@given('a ConfigService with global config key "core.log.level" set to "GLOBAL_WARN"')
|
||||
def step_service_global_config(context: Any) -> None:
|
||||
context.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
doc = tomlkit.document()
|
||||
doc["core.log_level"] = "GLOBAL_WARN"
|
||||
doc["core.log.level"] = "GLOBAL_WARN"
|
||||
with open(context.config_path, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
context.svc = ConfigService(
|
||||
@@ -650,9 +654,9 @@ def step_verify_resolve_global(context: Any) -> None:
|
||||
assert context.result.source == ConfigLevel.GLOBAL
|
||||
|
||||
|
||||
@when('I resolve key "core.log_level" with verbose True and no overrides')
|
||||
@when('I resolve key "core.log.level" with verbose True and no overrides')
|
||||
def step_resolve_verbose(context: Any) -> None:
|
||||
context.result = context.svc.resolve("core.log_level", verbose=True)
|
||||
context.result = context.svc.resolve("core.log.level", verbose=True)
|
||||
|
||||
|
||||
@then("the resolved chain should have 5 entries covering all levels")
|
||||
@@ -670,12 +674,12 @@ def step_verify_verbose_chain_length(context: Any) -> None:
|
||||
|
||||
|
||||
@then(
|
||||
'the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_CORE_LOG_LEVEL"'
|
||||
'the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_LOG_LEVEL"'
|
||||
)
|
||||
def step_verify_verbose_env_name(context: Any) -> None:
|
||||
chain = context.result.chain
|
||||
env_entry = next(e for e in chain if e["source"] == ConfigLevel.ENV_VAR.value)
|
||||
assert env_entry["env_name"] == "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
assert env_entry["env_name"] == "CLEVERAGENTS_LOG_LEVEL"
|
||||
|
||||
|
||||
@then("the verbose chain GLOBAL entry should include the config file path")
|
||||
@@ -690,14 +694,14 @@ def step_verify_verbose_global_path(context: Any) -> None:
|
||||
|
||||
|
||||
@given(
|
||||
'a ConfigService with project-scoped config for key "core.database_url" value "pg://proj" under project "myproj"'
|
||||
'a ConfigService with project-scoped config for key "core.data-dir" value "/proj/data" under project "myproj"'
|
||||
)
|
||||
def step_service_project_scoped_nonscopable(context: Any) -> None:
|
||||
context.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
doc = tomlkit.document()
|
||||
project_table = tomlkit.table()
|
||||
myproj_table = tomlkit.table()
|
||||
myproj_table["core.database_url"] = "pg://proj"
|
||||
myproj_table["core.data-dir"] = "/proj/data"
|
||||
project_table["myproj"] = myproj_table
|
||||
doc["project"] = project_table
|
||||
with open(context.config_path, "w") as fh:
|
||||
@@ -708,10 +712,12 @@ def step_service_project_scoped_nonscopable(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve key "core.log_level" with project_name "myproj" and verbose True')
|
||||
@when(
|
||||
'I resolve key "core.automation-profile" with project_name "myproj" and verbose True'
|
||||
)
|
||||
def step_resolve_project_verbose(context: Any) -> None:
|
||||
context.result = context.svc.resolve(
|
||||
"core.log_level", project_name="myproj", verbose=True
|
||||
"core.automation-profile", project_name="myproj", verbose=True
|
||||
)
|
||||
|
||||
|
||||
@@ -729,14 +735,14 @@ def step_verify_verbose_global_value(context: Any) -> None:
|
||||
assert global_entry["value"] == "GLOBAL_WARN"
|
||||
|
||||
|
||||
@when('I resolve key "core.database_url" with project_name "myproj"')
|
||||
@when('I resolve key "core.data-dir" with project_name "myproj"')
|
||||
def step_resolve_nonscopable(context: Any) -> None:
|
||||
context.result = context.svc.resolve("core.database_url", project_name="myproj")
|
||||
context.result = context.svc.resolve("core.data-dir", project_name="myproj")
|
||||
|
||||
|
||||
@then('the resolved value should not be "pg://proj"')
|
||||
@then('the resolved value should not be "/proj/data"')
|
||||
def step_verify_not_project_value(context: Any) -> None:
|
||||
assert context.result.value != "pg://proj"
|
||||
assert context.result.value != "/proj/data"
|
||||
|
||||
|
||||
@then("the resolved source should be DEFAULT or GLOBAL")
|
||||
@@ -749,14 +755,14 @@ def step_verify_source_not_project(context: Any) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I call env_var_for_key with "core.log_level"')
|
||||
@when('I call env_var_for_key with "core.log.level"')
|
||||
def step_call_env_var_for_key_valid(context: Any) -> None:
|
||||
context.result = ConfigService.env_var_for_key("core.log_level")
|
||||
context.result = ConfigService.env_var_for_key("core.log.level")
|
||||
|
||||
|
||||
@then('it should return "CLEVERAGENTS_CORE_LOG_LEVEL"')
|
||||
@then('it should return "CLEVERAGENTS_LOG_LEVEL"')
|
||||
def step_verify_env_var_for_key(context: Any) -> None:
|
||||
assert context.result == "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
assert context.result == "CLEVERAGENTS_LOG_LEVEL"
|
||||
|
||||
|
||||
@when('I call env_var_for_key with "nonexistent.key"')
|
||||
@@ -789,15 +795,15 @@ def step_verify_resolve_all_keys(context: Any) -> None:
|
||||
assert isinstance(context.result[key], ResolvedValue)
|
||||
|
||||
|
||||
@when('I call resolve_all with cli_override "core.log_level" set to "FATAL"')
|
||||
@when('I call resolve_all with cli_override "core.log.level" set to "FATAL"')
|
||||
def step_call_resolve_all_with_overrides(context: Any) -> None:
|
||||
context.result = context.svc.resolve_all(cli_overrides={"core.log_level": "FATAL"})
|
||||
context.result = context.svc.resolve_all(cli_overrides={"core.log.level": "FATAL"})
|
||||
|
||||
|
||||
@then(
|
||||
'the resolve_all result for "core.log_level" should have value "FATAL" and source CLI_FLAG'
|
||||
'the resolve_all result for "core.log.level" should have value "FATAL" and source CLI_FLAG'
|
||||
)
|
||||
def step_verify_resolve_all_override(context: Any) -> None:
|
||||
rv = context.result["core.log_level"]
|
||||
rv = context.result["core.log.level"]
|
||||
assert rv.value == "FATAL"
|
||||
assert rv.source == ConfigLevel.CLI_FLAG
|
||||
|
||||
@@ -8,7 +8,15 @@ Suite Teardown Cleanup Test Environment
|
||||
${HELPER} ${CURDIR}/helper_config_resolution.py
|
||||
|
||||
*** Test Cases ***
|
||||
Config Resolution Returns Default Value
|
||||
Config Service Registers All Spec Keys
|
||||
[Documentation] Verify that the registry contains exactly 103 keys (102 spec + 1 skills)
|
||||
${result}= Run Process ${PYTHON} ${HELPER} registry-key-count cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-registry-key-count-ok
|
||||
|
||||
Config Resolution Returns Default
|
||||
[Documentation] Verify that resolving a key with no overrides returns the default
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-default cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
@@ -16,58 +24,66 @@ Config Resolution Returns Default Value
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-default-ok
|
||||
|
||||
Config Resolution Uses Global Config File
|
||||
[Documentation] Verify that a value set in the global TOML file overrides default
|
||||
Config Resolution With Global Config
|
||||
[Documentation] Verify that a value written to the TOML file overrides the default
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-global cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-global-ok
|
||||
|
||||
Config Resolution Uses Environment Variable
|
||||
[Documentation] Verify that env var overrides global config
|
||||
Config Resolution With Env Var
|
||||
[Documentation] Verify that an env var overrides the global config value
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-env cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-env-ok
|
||||
|
||||
Config Resolution Uses CLI Flag
|
||||
[Documentation] Verify that CLI flag has highest priority
|
||||
Config Resolution With CLI Override
|
||||
[Documentation] Verify that cli_value has the highest priority in the chain
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-cli cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-cli-ok
|
||||
|
||||
Config Resolution Rejects Unknown Key
|
||||
[Documentation] Verify that unknown keys raise ValueError
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-unknown-key cwd=${WORKSPACE}
|
||||
Config Resolution Project Scope
|
||||
[Documentation] Verify that project-scoped config is resolved via project_name
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-project cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-unknown-key-ok
|
||||
Should Contain ${result.stdout} config-resolution-project-ok
|
||||
|
||||
Config Resolution Performs Type Coercion
|
||||
[Documentation] Verify that string env vars are coerced to proper types
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-type-coercion cwd=${WORKSPACE}
|
||||
Config Resolution Verbose Chain
|
||||
[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}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-type-coercion-ok
|
||||
Should Contain ${result.stdout} config-resolution-verbose-chain-ok
|
||||
|
||||
Config Registry Contains Expected Keys
|
||||
[Documentation] Verify that the registry has the expected key catalog
|
||||
${result}= Run Process ${PYTHON} ${HELPER} registry-keys cwd=${WORKSPACE}
|
||||
Config Env Var For Provider Keys
|
||||
[Documentation] Verify provider.openai.api-key maps to OPENAI_API_KEY
|
||||
${result}= Run Process ${PYTHON} ${HELPER} env-var-provider cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-registry-ok
|
||||
Should Contain ${result.stdout} config-env-var-provider-ok
|
||||
|
||||
Config Env Var Naming Convention
|
||||
[Documentation] Verify CLEVERAGENTS_<SECTION>_<KEY> naming convention
|
||||
${result}= Run Process ${PYTHON} ${HELPER} env-var-convention cwd=${WORKSPACE}
|
||||
Config Validate Type Coercion
|
||||
[Documentation] Verify str to int, float, and bool coercion via validate_type
|
||||
${result}= Run Process ${PYTHON} ${HELPER} type-coercion cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-env-convention-ok
|
||||
Should Contain ${result.stdout} config-type-coercion-ok
|
||||
|
||||
Config List Command Integration
|
||||
[Documentation] Verify that ``agents config list`` outputs all registered keys
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cli-list-integration cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-cli-list-integration-ok
|
||||
|
||||
@@ -53,7 +53,7 @@ def _run_with_tmp(args: list[str]) -> Any:
|
||||
def config_list() -> None:
|
||||
"""Verify config list outputs settings."""
|
||||
result = _run_with_tmp(["list"])
|
||||
if result.exit_code == 0 and "log_level" in result.output:
|
||||
if result.exit_code == 0 and "core.log.level" in result.output:
|
||||
print("config-cli-list-ok")
|
||||
else:
|
||||
print(f"FAIL: list returned {result.exit_code}", file=sys.stderr)
|
||||
@@ -83,20 +83,22 @@ def config_set_get_roundtrip() -> None:
|
||||
patch.object(config_mod, "_CONFIG_DIR", tmpdir),
|
||||
patch.object(config_mod, "_CONFIG_PATH", tmppath),
|
||||
):
|
||||
set_result = runner.invoke(config_app, ["set", "log_level", "DEBUG"])
|
||||
set_result = runner.invoke(config_app, ["set", "core.log.level", "DEBUG"])
|
||||
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", "log_level", "--format", "json"])
|
||||
get_result = runner.invoke(
|
||||
config_app, ["get", "core.log.level", "--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 = json.loads(get_result.output)
|
||||
if data.get("source") == "config_file":
|
||||
if data.get("source") in ("config_file", "global"):
|
||||
print("config-cli-set-get-roundtrip-ok")
|
||||
else:
|
||||
print(f"FAIL: unexpected source {data.get('source')}", file=sys.stderr)
|
||||
@@ -133,7 +135,7 @@ def config_show_secrets() -> None:
|
||||
def config_list_filter() -> None:
|
||||
"""Verify config list with regex filter."""
|
||||
result = _run_with_tmp(["list", "log.*"])
|
||||
if result.exit_code == 0 and "log_level" in result.output:
|
||||
if result.exit_code == 0 and "core.log.level" in result.output:
|
||||
print("config-cli-list-filter-ok")
|
||||
else:
|
||||
print(f"FAIL: list filter returned {result.exit_code}", file=sys.stderr)
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# Ensure local source tree is importable
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
@@ -22,37 +23,62 @@ from cleveragents.application.services.config_service import ( # noqa: E402
|
||||
ConfigService,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_service() -> tuple[ConfigService, Path]:
|
||||
"""Create a ConfigService with a temporary directory."""
|
||||
"""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 registry_key_count() -> None:
|
||||
"""Verify that the registry contains exactly 103 keys."""
|
||||
registry = ConfigService.registry()
|
||||
count = len(registry)
|
||||
if count == 103:
|
||||
print("config-registry-key-count-ok")
|
||||
else:
|
||||
print(f"FAIL: expected 103 keys, got {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def resolve_default() -> None:
|
||||
"""Verify default resolution works."""
|
||||
"""Verify default resolution with no overrides."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
result = svc.resolve("core.log_level")
|
||||
if result.value == "INFO" and result.source == ConfigLevel.DEFAULT:
|
||||
result = svc.resolve("core.log.level")
|
||||
if result.value == "FATAL" and result.source == ConfigLevel.DEFAULT:
|
||||
print("config-resolution-default-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected INFO/default, got {result.value}/{result.source}",
|
||||
f"FAIL: expected FATAL/default, got {result.value}/{result.source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def resolve_global() -> None:
|
||||
"""Verify global config file overrides default."""
|
||||
"""Verify that a TOML file value overrides the default."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
svc.set_value("core.log_level", "DEBUG")
|
||||
result = svc.resolve("core.log_level")
|
||||
svc.set_value("core.log.level", "DEBUG")
|
||||
result = svc.resolve("core.log.level")
|
||||
if result.value == "DEBUG" and result.source == ConfigLevel.GLOBAL:
|
||||
print("config-resolution-global-ok")
|
||||
else:
|
||||
@@ -62,18 +88,19 @@ def resolve_global() -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def resolve_env() -> None:
|
||||
"""Verify env var overrides global config."""
|
||||
"""Verify that env var overrides the global config value."""
|
||||
svc, tmpdir = _make_service()
|
||||
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
env_key = "CLEVERAGENTS_LOG_LEVEL"
|
||||
old_val = os.environ.get(env_key)
|
||||
try:
|
||||
svc.set_value("core.log_level", "DEBUG")
|
||||
# Set a global value first so we can prove env wins
|
||||
svc.set_value("core.log.level", "DEBUG")
|
||||
os.environ[env_key] = "WARNING"
|
||||
result = svc.resolve("core.log_level")
|
||||
result = svc.resolve("core.log.level")
|
||||
if result.value == "WARNING" and result.source == ConfigLevel.ENV_VAR:
|
||||
print("config-resolution-env-ok")
|
||||
else:
|
||||
@@ -87,17 +114,19 @@ def resolve_env() -> None:
|
||||
os.environ.pop(env_key, None)
|
||||
else:
|
||||
os.environ[env_key] = old_val
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def resolve_cli() -> None:
|
||||
"""Verify CLI flag overrides env var."""
|
||||
"""Verify that cli_value has the highest priority."""
|
||||
svc, tmpdir = _make_service()
|
||||
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
env_key = "CLEVERAGENTS_LOG_LEVEL"
|
||||
old_val = os.environ.get(env_key)
|
||||
try:
|
||||
# Set all lower levels so we can prove CLI wins
|
||||
svc.set_value("core.log.level", "DEBUG")
|
||||
os.environ[env_key] = "WARNING"
|
||||
result = svc.resolve("core.log_level", cli_value="ERROR")
|
||||
result = svc.resolve("core.log.level", cli_value="ERROR")
|
||||
if result.value == "ERROR" and result.source == ConfigLevel.CLI_FLAG:
|
||||
print("config-resolution-cli-ok")
|
||||
else:
|
||||
@@ -111,84 +140,182 @@ def resolve_cli() -> None:
|
||||
os.environ.pop(env_key, None)
|
||||
else:
|
||||
os.environ[env_key] = old_val
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def resolve_unknown_key() -> None:
|
||||
"""Verify unknown key raises ValueError."""
|
||||
def resolve_project() -> None:
|
||||
"""Verify project-scoped config resolution via project_name."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
svc.resolve("bogus.nonexistent")
|
||||
print("FAIL: expected ValueError", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
print("config-resolution-unknown-key-ok")
|
||||
finally:
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
# Write project-scoped override into the TOML file
|
||||
import tomlkit
|
||||
|
||||
doc = tomlkit.document()
|
||||
project_table = tomlkit.table()
|
||||
myproj_table = tomlkit.table()
|
||||
myproj_table["core.automation-profile"] = "full-auto"
|
||||
project_table["myproject"] = myproj_table
|
||||
doc["project"] = project_table
|
||||
svc._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(svc._config_path, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
def resolve_type_coercion() -> None:
|
||||
"""Verify integer type coercion from env var."""
|
||||
svc, tmpdir = _make_service()
|
||||
env_key = "CLEVERAGENTS_CORE_SERVER_PORT"
|
||||
old_val = os.environ.get(env_key)
|
||||
try:
|
||||
os.environ[env_key] = "9090"
|
||||
result = svc.resolve("core.server_port")
|
||||
if result.value == 9090 and isinstance(result.value, int):
|
||||
print("config-resolution-type-coercion-ok")
|
||||
result = svc.resolve("core.automation-profile", project_name="myproject")
|
||||
if result.value == "full-auto" and result.source == ConfigLevel.PROJECT:
|
||||
print("config-resolution-project-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected 9090 (int), got {result.value} "
|
||||
f"({type(result.value).__name__})",
|
||||
f"FAIL: expected full-auto/project, got {result.value}/{result.source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if old_val is None:
|
||||
os.environ.pop(env_key, None)
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def resolve_verbose_chain() -> None:
|
||||
"""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 == 5:
|
||||
# Verify the sources in order
|
||||
expected_sources = [
|
||||
ConfigLevel.CLI_FLAG.value,
|
||||
ConfigLevel.ENV_VAR.value,
|
||||
ConfigLevel.PROJECT.value,
|
||||
ConfigLevel.GLOBAL.value,
|
||||
ConfigLevel.DEFAULT.value,
|
||||
]
|
||||
actual_sources = [entry["source"] for entry in result.chain]
|
||||
if actual_sources == expected_sources:
|
||||
print("config-resolution-verbose-chain-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: chain sources mismatch: {actual_sources}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
os.environ[env_key] = old_val
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
def registry_keys() -> None:
|
||||
"""Verify the registry contains expected keys."""
|
||||
registry = ConfigService.registry()
|
||||
expected = ["core.log_level", "plan.max_retries", "provider.temperature"]
|
||||
for key in expected:
|
||||
if key not in registry:
|
||||
print(f"FAIL: {key} not in registry", file=sys.stderr)
|
||||
print(
|
||||
f"FAIL: expected 5 chain entries, got {chain_len}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
print("config-resolution-registry-ok")
|
||||
finally:
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
def env_var_convention() -> None:
|
||||
"""Verify env var naming convention."""
|
||||
env_name = ConfigService.env_var_for_key("core.log_level")
|
||||
if env_name == "CLEVERAGENTS_CORE_LOG_LEVEL":
|
||||
print("config-resolution-env-convention-ok")
|
||||
def env_var_provider() -> None:
|
||||
"""Verify that provider.openai.api-key maps to OPENAI_API_KEY."""
|
||||
env_name = ConfigService.env_var_for_key("provider.openai.api-key")
|
||||
if env_name == "OPENAI_API_KEY":
|
||||
print("config-env-var-provider-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected CLEVERAGENTS_CORE_LOG_LEVEL, got {env_name}",
|
||||
f"FAIL: expected OPENAI_API_KEY, got {env_name}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def type_coercion() -> None:
|
||||
"""Verify str→int, str→float, and str→bool coercion."""
|
||||
errors: list[str] = []
|
||||
|
||||
# str → int
|
||||
result_int = ConfigService.validate_type("core.log.retention-days", "45")
|
||||
if result_int != 45 or not isinstance(result_int, int):
|
||||
errors.append(
|
||||
f"int coercion: expected 45 (int), got {result_int!r} "
|
||||
f"({type(result_int).__name__})"
|
||||
)
|
||||
|
||||
# str → float
|
||||
result_float = ConfigService.validate_type("plan.budget.per-plan", "3.14")
|
||||
if result_float != 3.14 or not isinstance(result_float, float):
|
||||
errors.append(
|
||||
f"float coercion: expected 3.14 (float), got {result_float!r} "
|
||||
f"({type(result_float).__name__})"
|
||||
)
|
||||
|
||||
# str → bool (true)
|
||||
result_bool_t = ConfigService.validate_type("core.log.file-enabled", "true")
|
||||
if result_bool_t is not True:
|
||||
errors.append(f"bool coercion (true): got {result_bool_t!r}")
|
||||
|
||||
# str → bool (false)
|
||||
result_bool_f = ConfigService.validate_type("core.log.file-enabled", "false")
|
||||
if result_bool_f is not False:
|
||||
errors.append(f"bool coercion (false): got {result_bool_f!r}")
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
print(f"FAIL: {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("config-type-coercion-ok")
|
||||
|
||||
|
||||
def cli_list_integration() -> None:
|
||||
"""Verify ``agents config list`` outputs all registered keys."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands import config as config_mod
|
||||
from cleveragents.cli.commands.config import app as config_app
|
||||
|
||||
runner = CliRunner()
|
||||
tmpdir = Path(tempfile.mkdtemp())
|
||||
tmppath = tmpdir / "config.toml"
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(config_mod, "_CONFIG_DIR", tmpdir),
|
||||
patch.object(config_mod, "_CONFIG_PATH", tmppath),
|
||||
):
|
||||
result = runner.invoke(config_app, ["list"])
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"FAIL: config list exited {result.exit_code}", file=sys.stderr)
|
||||
print(result.output, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
output = result.output
|
||||
|
||||
# Spot-check several representative keys from different sections
|
||||
missing: list[str] = []
|
||||
for key_fragment in ["log.level", "automation-profile", "openai.api-key"]:
|
||||
if key_fragment not in output:
|
||||
missing.append(key_fragment)
|
||||
|
||||
if missing:
|
||||
print(
|
||||
f"FAIL: missing key fragments in output: {missing}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print("config-cli-list-integration-ok")
|
||||
finally:
|
||||
_cleanup(tmpdir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMANDS: dict[str, Callable[[], None]] = {
|
||||
"registry-key-count": registry_key_count,
|
||||
"resolve-default": resolve_default,
|
||||
"resolve-global": resolve_global,
|
||||
"resolve-env": resolve_env,
|
||||
"resolve-cli": resolve_cli,
|
||||
"resolve-unknown-key": resolve_unknown_key,
|
||||
"resolve-type-coercion": resolve_type_coercion,
|
||||
"registry-keys": registry_keys,
|
||||
"env-var-convention": env_var_convention,
|
||||
"resolve-project": resolve_project,
|
||||
"resolve-verbose-chain": resolve_verbose_chain,
|
||||
"env-var-provider": env_var_provider,
|
||||
"type-coercion": type_coercion,
|
||||
"cli-list-integration": cli_list_integration,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,32 +9,34 @@ persisted in a TOML file at ``~/.cleveragents/config.toml``.
|
||||
|---------------------------------|-----------------------------------------|
|
||||
| ``agents config set <K> <V>`` | Set a configuration value |
|
||||
| ``agents config get <K>`` | Get a configuration value |
|
||||
| ``agents config list [REGEX]`` | List configuration values |
|
||||
| ``agents config list [PATTERN]``| List configuration values |
|
||||
|
||||
## Key Format
|
||||
|
||||
Keys correspond to ``Settings`` field names (e.g. ``log_level``,
|
||||
``server_port``). Dot-path aliases are accepted — underscores and dots
|
||||
are interchangeable (``log.level`` ≡ ``log_level``).
|
||||
Keys use the hierarchical dot-path names defined in the specification
|
||||
(e.g. ``core.log.level``, ``plan.budget.per-plan``). Resolution is
|
||||
delegated to :class:`ConfigService` which implements the five-level
|
||||
precedence chain.
|
||||
|
||||
Based on implementation_plan.md task A8.cli.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import fnmatch
|
||||
import re
|
||||
import tomllib
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, cast
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.application.services.config_service import (
|
||||
_REGISTRY,
|
||||
ConfigService,
|
||||
)
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
|
||||
app = typer.Typer(help="Manage configuration settings for CleverAgents.")
|
||||
@@ -59,58 +61,9 @@ _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _settings_fields() -> dict[str, Any]:
|
||||
"""Return a dict of all Settings field names to their current values."""
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
instance = Settings()
|
||||
fields: dict[str, Any] = {}
|
||||
for field_name in instance.model_fields:
|
||||
fields[field_name] = getattr(instance, field_name)
|
||||
return fields
|
||||
|
||||
|
||||
def _settings_defaults() -> dict[str, Any]:
|
||||
"""Return a dict of Settings field names to their default values."""
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
defaults: dict[str, Any] = {}
|
||||
for name, field_info in Settings.model_fields.items():
|
||||
if field_info.default is not None:
|
||||
defaults[name] = field_info.default
|
||||
elif field_info.default_factory is not None:
|
||||
factory: Callable[[], Any] = cast(
|
||||
Callable[[], Any], field_info.default_factory
|
||||
)
|
||||
defaults[name] = factory()
|
||||
else:
|
||||
defaults[name] = None
|
||||
return defaults
|
||||
|
||||
|
||||
def _normalize_key(key: str) -> str:
|
||||
"""Normalize a user-supplied key to the canonical Settings field name.
|
||||
|
||||
Dots are replaced with underscores so ``log.level`` becomes ``log_level``.
|
||||
"""
|
||||
return key.strip().replace(".", "_").replace("-", "_").lower()
|
||||
|
||||
|
||||
def _validate_key(key: str) -> str:
|
||||
"""Validate and normalise *key*, raising :class:`typer.BadParameter` on failure."""
|
||||
normalized = _normalize_key(key)
|
||||
if not normalized:
|
||||
raise typer.BadParameter("Key must not be empty.")
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
if normalized not in Settings.model_fields:
|
||||
valid_sample = ", ".join(sorted(Settings.model_fields)[:8])
|
||||
raise typer.BadParameter(
|
||||
f"Unknown configuration key: '{key}' "
|
||||
f"(normalized: '{normalized}'). "
|
||||
f"Valid keys include: {valid_sample} ..."
|
||||
)
|
||||
return normalized
|
||||
def _get_service() -> ConfigService:
|
||||
"""Return a ``ConfigService`` wired to the standard config paths."""
|
||||
return ConfigService(config_dir=_CONFIG_DIR, config_path=_CONFIG_PATH)
|
||||
|
||||
|
||||
def _is_secret_key(key: str) -> bool:
|
||||
@@ -123,90 +76,96 @@ def _mask_value(value: str) -> str:
|
||||
return "****"
|
||||
|
||||
|
||||
def _read_config_file() -> dict[str, Any]:
|
||||
"""Read the TOML config file and return its contents as a flat dict."""
|
||||
if not _CONFIG_PATH.exists():
|
||||
return {}
|
||||
with open(_CONFIG_PATH, "rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
# Legacy helpers retained for backward compatibility with tests that
|
||||
# patch or call them directly. They now delegate to ConfigService.
|
||||
|
||||
|
||||
def _write_config_file(data: dict[str, Any]) -> None:
|
||||
"""Write *data* to the TOML config file, creating dirs if needed.
|
||||
def _settings_fields() -> dict[str, Any]:
|
||||
"""Return all registered config keys mapped to their resolved values."""
|
||||
svc = _get_service()
|
||||
results: dict[str, Any] = {}
|
||||
for key in _REGISTRY:
|
||||
resolved = svc.resolve(key)
|
||||
results[key] = resolved.value
|
||||
return results
|
||||
|
||||
Uses ``tomlkit`` to preserve comments and formatting.
|
||||
|
||||
def _settings_defaults() -> dict[str, Any]:
|
||||
"""Return all registered config keys mapped to their default values."""
|
||||
return {key: entry.default for key, entry in _REGISTRY.items()}
|
||||
|
||||
|
||||
def _normalize_key(key: str) -> str:
|
||||
"""Pass-through normalisation — keys are matched as-is against the registry.
|
||||
|
||||
Backward compatibility: underscores are converted to dots so that
|
||||
``log_level`` resolves to ``core.log.level`` if there is a single
|
||||
match. If the key already exists in the registry, it is returned
|
||||
unchanged.
|
||||
"""
|
||||
import tomlkit
|
||||
stripped = key.strip()
|
||||
if stripped in _REGISTRY:
|
||||
return stripped
|
||||
# Try underscore-to-dot conversion
|
||||
dotted = stripped.replace("_", ".")
|
||||
if dotted in _REGISTRY:
|
||||
return dotted
|
||||
return stripped
|
||||
|
||||
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# If the file already exists, load it via tomlkit so we preserve
|
||||
# comments and ordering, then update the relevant keys.
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH) as fh:
|
||||
doc = tomlkit.load(fh)
|
||||
else:
|
||||
doc = tomlkit.document()
|
||||
|
||||
for key, value in data.items():
|
||||
doc[key] = value
|
||||
|
||||
with open(_CONFIG_PATH, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
def _validate_key(key: str) -> str:
|
||||
"""Validate and normalise *key*, raising :class:`typer.BadParameter` on failure."""
|
||||
normalized = _normalize_key(key)
|
||||
if not normalized:
|
||||
raise typer.BadParameter("Key must not be empty.")
|
||||
if normalized not in _REGISTRY:
|
||||
valid_sample = ", ".join(sorted(_REGISTRY)[:8])
|
||||
raise typer.BadParameter(
|
||||
f"Unknown configuration key: '{key}' "
|
||||
f"(normalized: '{normalized}'). "
|
||||
f"Valid keys include: {valid_sample} ..."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _env_var_for_key(key: str) -> str:
|
||||
"""Return the environment-variable name for a Settings key."""
|
||||
return f"CLEVERAGENTS_{key.upper()}"
|
||||
"""Return the environment-variable name for a registered key."""
|
||||
entry = _REGISTRY.get(key)
|
||||
if entry is not None:
|
||||
return entry.env_var
|
||||
return f"CLEVERAGENTS_{key.upper().replace('.', '_').replace('-', '_')}"
|
||||
|
||||
|
||||
def _resolve_source(key: str) -> str:
|
||||
"""Determine where the current value for *key* comes from.
|
||||
|
||||
Resolution order: env var → config file → default.
|
||||
"""
|
||||
env_name = _env_var_for_key(key)
|
||||
if os.environ.get(env_name):
|
||||
return "env"
|
||||
config_data = _read_config_file()
|
||||
if key in config_data:
|
||||
return "config_file"
|
||||
return "default"
|
||||
"""Determine which level supplied the winning value for *key*."""
|
||||
svc = _get_service()
|
||||
try:
|
||||
resolved = svc.resolve(key)
|
||||
return resolved.source.value
|
||||
except (ValueError, KeyError):
|
||||
return "default"
|
||||
|
||||
|
||||
def _resolution_chain(key: str) -> list[dict[str, Any]]:
|
||||
"""Build the resolution chain for *key*.
|
||||
"""Build the resolution chain for *key* via ConfigService."""
|
||||
svc = _get_service()
|
||||
try:
|
||||
resolved = svc.resolve(key, verbose=True)
|
||||
return resolved.chain
|
||||
except (ValueError, KeyError):
|
||||
return []
|
||||
|
||||
Returns a list of dicts with ``source`` and ``value`` entries
|
||||
ordered from highest to lowest priority.
|
||||
"""
|
||||
chain: list[dict[str, Any]] = []
|
||||
|
||||
# 1. CLI flag (not applicable at read time, placeholder)
|
||||
chain.append({"source": "cli_flag", "value": None})
|
||||
def _read_config_file() -> dict[str, Any]:
|
||||
"""Read the TOML config file and return its contents as a flat dict."""
|
||||
svc = _get_service()
|
||||
return svc.read_config()
|
||||
|
||||
# 2. Environment variable
|
||||
env_name = _env_var_for_key(key)
|
||||
env_val = os.environ.get(env_name)
|
||||
chain.append({"source": "env_var", "value": env_val, "env_name": env_name})
|
||||
|
||||
# 3. Config file
|
||||
config_data = _read_config_file()
|
||||
file_val = config_data.get(key)
|
||||
chain.append(
|
||||
{
|
||||
"source": "config_file",
|
||||
"value": file_val,
|
||||
"path": str(_CONFIG_PATH),
|
||||
}
|
||||
)
|
||||
|
||||
# 4. Default
|
||||
defaults = _settings_defaults()
|
||||
default_val = defaults.get(key)
|
||||
chain.append({"source": "default", "value": default_val})
|
||||
|
||||
return chain
|
||||
def _write_config_file(data: dict[str, Any]) -> None:
|
||||
"""Write *data* to the TOML config file, creating dirs if needed."""
|
||||
svc = _get_service()
|
||||
svc.write_config(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -218,6 +177,10 @@ def _resolution_chain(key: str) -> list[dict[str, Any]]:
|
||||
def config_set(
|
||||
key: Annotated[str, typer.Argument(help="Configuration key (dot-path)")],
|
||||
value: Annotated[str, typer.Argument(help="Value to set")],
|
||||
project: Annotated[
|
||||
str | None,
|
||||
typer.Option("--project", "-p", help="Project name for scoped override"),
|
||||
] = None,
|
||||
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
|
||||
) -> None:
|
||||
"""Set a configuration value.
|
||||
@@ -227,35 +190,48 @@ def config_set(
|
||||
|
||||
Examples::
|
||||
|
||||
agents config set log_level DEBUG
|
||||
agents config set server_port 9090
|
||||
agents config set core.log.level DEBUG
|
||||
agents config set plan.concurrency 8
|
||||
agents config set core.automation-profile manual --project local/prod
|
||||
"""
|
||||
normalized = _validate_key(key)
|
||||
svc = _get_service()
|
||||
entry = _REGISTRY[normalized]
|
||||
|
||||
# Coerce to the registered type
|
||||
coerced: Any = svc.validate_type(normalized, value)
|
||||
|
||||
# Read previous value
|
||||
config_data = _read_config_file()
|
||||
previous = config_data.get(normalized)
|
||||
config_data = svc.read_config()
|
||||
|
||||
# Coerce basic types
|
||||
coerced: Any = value
|
||||
if value.lower() in ("true", "false"):
|
||||
coerced = value.lower() == "true"
|
||||
if project is not None:
|
||||
# Project-scoped set
|
||||
if not entry.project_scopable:
|
||||
raise typer.BadParameter(f"Key '{normalized}' is not project-scopable.")
|
||||
project_section = config_data.get("project", {})
|
||||
if not isinstance(project_section, dict):
|
||||
project_section = {}
|
||||
proj_overrides = project_section.get(project, {})
|
||||
if not isinstance(proj_overrides, dict):
|
||||
proj_overrides = {}
|
||||
previous = proj_overrides.get(normalized)
|
||||
proj_overrides[normalized] = coerced
|
||||
project_section[project] = proj_overrides
|
||||
config_data["project"] = project_section
|
||||
svc.write_config(config_data)
|
||||
scope = f"project:{project}"
|
||||
else:
|
||||
try:
|
||||
coerced = int(value)
|
||||
except ValueError:
|
||||
with contextlib.suppress(ValueError):
|
||||
coerced = float(value)
|
||||
|
||||
config_data[normalized] = coerced
|
||||
_write_config_file(config_data)
|
||||
previous = config_data.get(normalized)
|
||||
config_data[normalized] = coerced
|
||||
svc.write_config(config_data)
|
||||
scope = "user"
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"key": normalized,
|
||||
"value": coerced,
|
||||
"previous_value": previous,
|
||||
"source": "config_file",
|
||||
"scope": "user",
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
@@ -269,7 +245,7 @@ def config_set(
|
||||
f"[bold]Value:[/bold] {coerced}\n"
|
||||
f"[bold]Previous:[/bold] {prev_display}\n"
|
||||
f"[bold]Source:[/bold] config_file\n"
|
||||
f"[bold]Scope:[/bold] user",
|
||||
f"[bold]Scope:[/bold] {scope}",
|
||||
title="Configuration Updated",
|
||||
expand=False,
|
||||
)
|
||||
@@ -279,38 +255,53 @@ def config_set(
|
||||
@app.command("get")
|
||||
def config_get(
|
||||
key: Annotated[str, typer.Argument(help="Configuration key (dot-path)")],
|
||||
verbose: Annotated[
|
||||
bool,
|
||||
typer.Option("--verbose", "-v", help="Show full resolution chain"),
|
||||
] = False,
|
||||
project: Annotated[
|
||||
str | None,
|
||||
typer.Option("--project", "-p", help="Project name for scoped resolution"),
|
||||
] = None,
|
||||
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
|
||||
) -> None:
|
||||
"""Get a configuration value with its resolution chain.
|
||||
|
||||
Shows the effective value and which source it was resolved from
|
||||
(CLI flag, environment variable, config file, or default).
|
||||
(CLI flag, environment variable, project config, global file, or
|
||||
default).
|
||||
|
||||
Use ``--verbose`` to display the full five-level resolution chain.
|
||||
|
||||
Examples::
|
||||
|
||||
agents config get log_level
|
||||
agents config get server_port --format json
|
||||
agents config get core.log.level
|
||||
agents config get plan.budget.per-plan --verbose
|
||||
agents config get core.automation-profile --project local/prod
|
||||
"""
|
||||
normalized = _validate_key(key)
|
||||
svc = _get_service()
|
||||
|
||||
fields = _settings_fields()
|
||||
current_value = fields[normalized]
|
||||
source = _resolve_source(normalized)
|
||||
chain = _resolution_chain(normalized)
|
||||
resolved = svc.resolve(
|
||||
normalized,
|
||||
project_name=project,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"key": normalized,
|
||||
"value": current_value,
|
||||
"source": source,
|
||||
"type": type(current_value).__name__ if current_value is not None else "None",
|
||||
"resolution_chain": chain,
|
||||
"value": resolved.value,
|
||||
"source": resolved.source.value,
|
||||
"type": type(resolved.value).__name__ if resolved.value is not None else "None",
|
||||
}
|
||||
if verbose:
|
||||
result["resolution_chain"] = resolved.chain
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
# Serialise Path objects to strings for JSON/YAML
|
||||
if hasattr(current_value, "__fspath__"):
|
||||
result["value"] = str(current_value)
|
||||
for entry in result["resolution_chain"]:
|
||||
if hasattr(result["value"], "__fspath__"):
|
||||
result["value"] = str(result["value"])
|
||||
for entry in result.get("resolution_chain", []):
|
||||
if hasattr(entry.get("value"), "__fspath__"):
|
||||
entry["value"] = str(entry["value"])
|
||||
typer.echo(format_output(result, fmt))
|
||||
@@ -319,27 +310,31 @@ def config_get(
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold]Key:[/bold] {normalized}\n"
|
||||
f"[bold]Value:[/bold] {current_value}\n"
|
||||
f"[bold]Source:[/bold] {source}\n"
|
||||
f"[bold]Type:[/bold] {type(current_value).__name__}",
|
||||
f"[bold]Value:[/bold] {resolved.value}\n"
|
||||
f"[bold]Source:[/bold] {resolved.source.value}\n"
|
||||
"[bold]Type:[/bold] "
|
||||
+ (type(resolved.value).__name__ if resolved.value is not None else "None"),
|
||||
title="Configuration Value",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print("\n[bold]Resolution chain[/bold] (highest → lowest priority):")
|
||||
for entry in chain:
|
||||
src = entry["source"]
|
||||
val = entry.get("value")
|
||||
marker = " [green]◀ active[/green]" if src == source and val is not None else ""
|
||||
display_val = str(val) if val is not None else "(not set)"
|
||||
console.print(f" {src:14s} → {display_val}{marker}")
|
||||
|
||||
if verbose and resolved.chain:
|
||||
console.print("\n[bold]Resolution chain[/bold] (highest → lowest priority):")
|
||||
for chain_entry in resolved.chain:
|
||||
src = chain_entry["source"]
|
||||
val = chain_entry.get("value")
|
||||
is_winner = src == resolved.source.value and val is not None
|
||||
marker = " [green]◀ active[/green]" if is_winner else ""
|
||||
display_val = str(val) if val is not None else "(not set)"
|
||||
console.print(f" {src:14s} → {display_val}{marker}")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def config_list(
|
||||
pattern: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Regex filter for key names"),
|
||||
typer.Argument(help="Glob or regex filter for key names (e.g. 'plan.*')"),
|
||||
] = None,
|
||||
filter_values: Annotated[
|
||||
str | None,
|
||||
@@ -349,18 +344,23 @@ def config_list(
|
||||
bool,
|
||||
typer.Option("--show-secrets", help="Show secret values unmasked"),
|
||||
] = False,
|
||||
project: Annotated[
|
||||
str | None,
|
||||
typer.Option("--project", "-p", help="Project name for scoped resolution"),
|
||||
] = None,
|
||||
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
|
||||
) -> None:
|
||||
"""List all configuration values.
|
||||
|
||||
Optionally filter by key name regex and/or value regex.
|
||||
Secret values (API keys, tokens, passwords) are masked by
|
||||
default; use ``--show-secrets`` to reveal them.
|
||||
Optionally filter by key glob pattern (e.g. ``plan.*``, ``context.pipeline.*``)
|
||||
and/or value regex. Secret values (API keys, tokens, passwords) are
|
||||
masked by default; use ``--show-secrets`` to reveal them.
|
||||
|
||||
Examples::
|
||||
|
||||
agents config list
|
||||
agents config list "log.*"
|
||||
agents config list "plan.*"
|
||||
agents config list "context.pipeline.*"
|
||||
agents config list --filter-values "DEBUG"
|
||||
agents config list --show-secrets
|
||||
"""
|
||||
@@ -382,25 +382,28 @@ def config_list(
|
||||
console.print(f"[red]Invalid value regex:[/red] {exc}")
|
||||
raise typer.Exit(code=1) from exc
|
||||
|
||||
fields = _settings_fields()
|
||||
svc = _get_service()
|
||||
all_resolved = svc.resolve_all(project_name=project)
|
||||
defaults = _settings_defaults()
|
||||
|
||||
settings_list: list[dict[str, Any]] = []
|
||||
for key in sorted(fields):
|
||||
# Key filter
|
||||
if key_re is not None and not key_re.search(key):
|
||||
for key in sorted(_REGISTRY):
|
||||
# Key filter: support both glob (fnmatch) and regex
|
||||
if (
|
||||
key_re is not None
|
||||
and not key_re.search(key)
|
||||
and (pattern is None or not fnmatch.fnmatch(key, pattern))
|
||||
):
|
||||
continue
|
||||
|
||||
raw_value = fields[key]
|
||||
resolved = all_resolved[key]
|
||||
raw_value = resolved.value
|
||||
str_value = str(raw_value) if raw_value is not None else ""
|
||||
|
||||
# Value filter
|
||||
if val_re is not None and not val_re.search(str_value):
|
||||
continue
|
||||
|
||||
# Determine source
|
||||
source = _resolve_source(key)
|
||||
|
||||
# Modified flag: value differs from default
|
||||
default_val = defaults.get(key)
|
||||
modified = raw_value != default_val
|
||||
@@ -414,7 +417,7 @@ def config_list(
|
||||
{
|
||||
"key": key,
|
||||
"value": display_value,
|
||||
"source": source,
|
||||
"source": resolved.source.value,
|
||||
"modified": modified,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user