7b3fcaf466
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 19s
CI / helm (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 3m19s
CI / quality (pull_request) Successful in 3m49s
CI / typecheck (pull_request) Successful in 3m57s
CI / integration_tests (pull_request) Successful in 3m57s
CI / security (pull_request) Successful in 4m6s
CI / unit_tests (pull_request) Successful in 7m19s
CI / docker (pull_request) Successful in 1m18s
CI / coverage (pull_request) Successful in 11m53s
CI / e2e_tests (pull_request) Successful in 21m25s
CI / status-check (pull_request) Successful in 1s
CI / build (push) Successful in 18s
CI / helm (push) Successful in 22s
CI / lint (push) Successful in 3m16s
CI / quality (push) Successful in 3m41s
CI / integration_tests (push) Successful in 3m48s
CI / typecheck (push) Successful in 3m54s
CI / unit_tests (push) Successful in 3m54s
CI / security (push) Successful in 4m4s
CI / docker (push) Successful in 1m19s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Successful in 14m58s
CI / coverage (push) Successful in 11m55s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 28m23s
CI / benchmark-regression (pull_request) Successful in 54m47s
Implement three-scope configuration resolution (local > project > global) with deep merge. Add ConfigScope enum, project-root discovery, and config.local.toml file support. Changes: - Add LOCAL to ConfigLevel enum and new ConfigScope enum (GLOBAL, PROJECT, LOCAL) - Implement discover_project_root() walking up from CWD for cleveragents.toml/.cleveragents - Implement config.local.toml file loading in ConfigService - Implement three-scope deep merge via _deep_merge() helper - Add read_project_config(), read_local_config(), read_merged_config() methods - Add write_scoped_config() for writing to any scope file - Update set_value() to accept optional scope parameter - Update resolve() with six-level precedence chain (CLI > env > local > project > global > default) - Update CLI config set with --scope flag (global/project/local) - Add config.local.toml to .gitignore and DEFAULT_IGNORE_PATTERNS - Add Behave BDD scenarios for three-scope resolution, deep merge, and project-root discovery ISSUES CLOSED: #937
832 lines
29 KiB
Python
832 lines
29 KiB
Python
"""Behave steps for complete coverage of config_service.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import tomlkit
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.application.services.config_service import (
|
|
_REGISTRY,
|
|
ConfigEntry,
|
|
ConfigLevel,
|
|
ConfigService,
|
|
ResolvedValue,
|
|
_env_name,
|
|
_register,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ConfigLevel enum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("ConfigLevel should have values CLI_FLAG ENV_VAR LOCAL PROJECT GLOBAL DEFAULT")
|
|
def step_config_level_enum(context: Any) -> None:
|
|
assert ConfigLevel.CLI_FLAG.value == "cli_flag"
|
|
assert ConfigLevel.ENV_VAR.value == "env_var"
|
|
assert ConfigLevel.LOCAL.value == "local"
|
|
assert ConfigLevel.PROJECT.value == "project"
|
|
assert ConfigLevel.GLOBAL.value == "global"
|
|
assert ConfigLevel.DEFAULT.value == "default"
|
|
assert len(ConfigLevel) == 6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ConfigEntry dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a ConfigEntry with key "test.key" and type str')
|
|
def step_create_config_entry(context: Any) -> None:
|
|
context.entry = ConfigEntry(
|
|
key="test.key",
|
|
python_type=str,
|
|
default="default_val",
|
|
env_var="CLEVERAGENTS_TEST_KEY",
|
|
project_scopable=True,
|
|
description="A test key.",
|
|
section="test",
|
|
)
|
|
|
|
|
|
@then("the ConfigEntry fields should match the provided values")
|
|
def step_verify_config_entry(context: Any) -> None:
|
|
e = context.entry
|
|
assert e.key == "test.key"
|
|
assert e.python_type is str
|
|
assert e.default == "default_val"
|
|
assert e.env_var == "CLEVERAGENTS_TEST_KEY"
|
|
assert e.project_scopable is True
|
|
assert e.description == "A test key."
|
|
assert e.section == "test"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ResolvedValue dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a ResolvedValue with key "test.key" and source DEFAULT')
|
|
def step_create_resolved_value(context: Any) -> None:
|
|
context.resolved = ResolvedValue(
|
|
key="test.key",
|
|
value="default_val",
|
|
source=ConfigLevel.DEFAULT,
|
|
)
|
|
|
|
|
|
@then("the ResolvedValue fields should match including an empty chain by default")
|
|
def step_verify_resolved_value(context: Any) -> None:
|
|
rv = context.resolved
|
|
assert rv.key == "test.key"
|
|
assert rv.value == "default_val"
|
|
assert rv.source == ConfigLevel.DEFAULT
|
|
assert rv.chain == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _env_name helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I call _env_name with section "core" and key "log_level"')
|
|
def step_call_env_name(context: Any) -> None:
|
|
context.env_name_result = _env_name("core", "log_level")
|
|
|
|
|
|
@then('the env name should be "CLEVERAGENTS_CORE_LOG_LEVEL"')
|
|
def step_verify_env_name(context: Any) -> None:
|
|
assert context.env_name_result == "CLEVERAGENTS_CORE_LOG_LEVEL"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _register
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I register a custom key "custom.test_key" with type int and default 42')
|
|
def step_register_custom_key(context: Any) -> None:
|
|
_register("custom", "test_key", int, 42, description="Custom test key.")
|
|
|
|
|
|
@then('the registry should contain "custom.test_key" with default 42')
|
|
def step_verify_custom_key(context: Any) -> None:
|
|
entry = _REGISTRY.get("custom.test_key")
|
|
assert entry is not None
|
|
assert entry.default == 42
|
|
assert entry.python_type is int
|
|
assert entry.env_var == "CLEVERAGENTS_CUSTOM_TEST_KEY"
|
|
|
|
|
|
@when('I register a key "custom.explicit_env" with explicit env_var "MY_CUSTOM_VAR"')
|
|
def step_register_explicit_env(context: Any) -> None:
|
|
_register(
|
|
"custom",
|
|
"explicit_env",
|
|
str,
|
|
"",
|
|
env_var="MY_CUSTOM_VAR",
|
|
description="Explicit env var.",
|
|
)
|
|
|
|
|
|
@then('the entry "custom.explicit_env" should have env_var "MY_CUSTOM_VAR"')
|
|
def step_verify_explicit_env(context: Any) -> None:
|
|
entry = _REGISTRY.get("custom.explicit_env")
|
|
assert entry is not None
|
|
assert entry.env_var == "MY_CUSTOM_VAR"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_catalog
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then(
|
|
"the registry should contain keys from sections core plan provider sandbox context index"
|
|
)
|
|
def step_verify_catalog_sections(context: Any) -> None:
|
|
sections_found: set[str] = set()
|
|
for entry in _REGISTRY.values():
|
|
sections_found.add(entry.section)
|
|
for expected in ("core", "plan", "provider", "sandbox", "context", "index"):
|
|
assert expected in sections_found, f"Section '{expected}' not found in registry"
|
|
|
|
|
|
@then("the registry should contain at least 20 keys")
|
|
def step_verify_catalog_count(context: Any) -> None:
|
|
# _build_catalog registers ~105 keys from the source
|
|
assert len(_REGISTRY) >= 20, f"Expected >=20 keys, got {len(_REGISTRY)}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ConfigService instantiation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a temporary directory for config service")
|
|
def step_temp_dir(context: Any) -> None:
|
|
context.temp_dir = Path(tempfile.mkdtemp())
|
|
context.config_dir = context.temp_dir / "config"
|
|
context.config_path = context.config_dir / "config.toml"
|
|
context.svc = None
|
|
context.result = None
|
|
context.error = None
|
|
|
|
|
|
@when("I create a ConfigService with custom paths")
|
|
def step_create_service_custom(context: Any) -> None:
|
|
context.config_dir.mkdir(parents=True, exist_ok=True)
|
|
context.svc = ConfigService(
|
|
config_dir=context.config_dir,
|
|
config_path=context.config_path,
|
|
)
|
|
|
|
|
|
@then("the service should use the custom config directory and path")
|
|
def step_verify_custom_paths(context: Any) -> None:
|
|
assert context.svc._config_dir == context.config_dir
|
|
assert context.svc._config_path == context.config_path
|
|
|
|
|
|
@when("I create a ConfigService with default paths")
|
|
def step_create_service_default(context: Any) -> None:
|
|
context.svc = ConfigService()
|
|
|
|
|
|
@then('the service config path should end with ".cleveragents/config.toml"')
|
|
def step_verify_default_path(context: Any) -> None:
|
|
path_str = str(context.svc._config_path)
|
|
assert path_str.endswith(".cleveragents/config.toml"), f"Got: {path_str}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# registry(), get_entry(), registered_keys()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I call ConfigService.registry")
|
|
def step_call_registry(context: Any) -> None:
|
|
context.result = ConfigService.registry()
|
|
|
|
|
|
@then("it should return a dict with the same keys as the internal registry")
|
|
def step_verify_registry_copy(context: Any) -> None:
|
|
assert isinstance(context.result, dict)
|
|
assert set(context.result.keys()) == set(_REGISTRY.keys())
|
|
# Verify it's a copy, not the same object
|
|
assert context.result is not _REGISTRY
|
|
|
|
|
|
@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")
|
|
|
|
|
|
@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"
|
|
|
|
|
|
@when('I call ConfigService.get_entry with "nonexistent.key"')
|
|
def step_call_get_entry_unknown(context: Any) -> None:
|
|
context.result = ConfigService.get_entry("nonexistent.key")
|
|
|
|
|
|
@then("it should return None")
|
|
def step_verify_get_entry_none(context: Any) -> None:
|
|
assert context.result is None
|
|
|
|
|
|
@when("I call ConfigService.registered_keys")
|
|
def step_call_registered_keys(context: Any) -> None:
|
|
context.result = ConfigService.registered_keys()
|
|
|
|
|
|
@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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# read_config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a ConfigService pointing to a missing config file")
|
|
def step_service_missing_file(context: Any) -> None:
|
|
context.svc = ConfigService(
|
|
config_dir=context.config_dir,
|
|
config_path=context.config_dir / "nonexistent.toml",
|
|
)
|
|
|
|
|
|
@then("read_config should return an empty dict")
|
|
def step_verify_read_empty(context: Any) -> None:
|
|
data = context.svc.read_config()
|
|
assert data == {}
|
|
|
|
|
|
@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"
|
|
with open(context.config_path, "w") as fh:
|
|
tomlkit.dump(doc, fh)
|
|
|
|
|
|
@when("I create a ConfigService pointing to that TOML file")
|
|
def step_service_existing_file(context: Any) -> None:
|
|
context.svc = ConfigService(
|
|
config_dir=context.config_dir,
|
|
config_path=context.config_path,
|
|
)
|
|
|
|
|
|
@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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# write_config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a ConfigService with a nested non-existent config directory")
|
|
def step_service_nested_dir(context: Any) -> None:
|
|
nested = context.temp_dir / "a" / "b" / "c"
|
|
context.config_dir = nested
|
|
context.config_path = nested / "config.toml"
|
|
context.svc = ConfigService(
|
|
config_dir=nested,
|
|
config_path=context.config_path,
|
|
)
|
|
|
|
|
|
@when('I call write_config with key "greeting" value "hello"')
|
|
def step_call_write_config(context: Any) -> None:
|
|
context.svc.write_config({"greeting": "hello"})
|
|
|
|
|
|
@then('the TOML file should exist and contain key "greeting" with value "hello"')
|
|
def step_verify_written_toml(context: Any) -> None:
|
|
assert context.config_path.exists()
|
|
data = context.svc.read_config()
|
|
assert data["greeting"] == "hello"
|
|
|
|
|
|
@given('a TOML config file with key "existing" set to "value1"')
|
|
def step_write_toml_existing(context: Any) -> None:
|
|
context.config_dir.mkdir(parents=True, exist_ok=True)
|
|
doc = tomlkit.document()
|
|
doc["existing"] = "value1"
|
|
with open(context.config_path, "w") as fh:
|
|
tomlkit.dump(doc, fh)
|
|
|
|
|
|
@when('I call write_config with key "new_key" value "value2"')
|
|
def step_call_write_config_merge(context: Any) -> None:
|
|
context.svc.write_config({"new_key": "value2"})
|
|
|
|
|
|
@then('read_config should return a dict containing both "existing" and "new_key"')
|
|
def step_verify_merged_toml(context: Any) -> None:
|
|
data = context.svc.read_config()
|
|
assert data["existing"] == "value1"
|
|
assert data["new_key"] == "value2"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# set_value
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I call set_value with key "mykey" and value "myval"')
|
|
def step_call_set_value(context: Any) -> None:
|
|
context.svc.set_value("mykey", "myval")
|
|
|
|
|
|
@then('read_config should return a dict with "mykey" equal to "myval"')
|
|
def step_verify_set_value(context: Any) -> None:
|
|
data = context.svc.read_config()
|
|
assert data["mykey"] == "myval"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@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"
|
|
|
|
|
|
@when('I call validate_key with "totally.unknown"')
|
|
def step_call_validate_key_unknown(context: Any) -> None:
|
|
try:
|
|
context.result = ConfigService.validate_key("totally.unknown")
|
|
context.error = None
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('a ValueError should be raised mentioning "{text}"')
|
|
def step_then_generic_valueerror_raised_mentioning(context: Any, text: str) -> None:
|
|
"""Shared @then step: check for a ValueError stored in common context attrs."""
|
|
err = None
|
|
for attr in ("error", "lsp_error", "caught_error", "ontology_error"):
|
|
err = getattr(context, attr, None)
|
|
if err is not None:
|
|
break
|
|
assert err is not None, "Expected a ValueError but none was raised"
|
|
assert isinstance(err, ValueError), f"Expected ValueError, got {type(err).__name__}"
|
|
assert text in str(err), f"Expected '{text}' in error message: {err}"
|
|
|
|
|
|
@then('a ValidationError should be raised mentioning "{text}"')
|
|
def step_then_generic_validation_error_raised_mentioning(
|
|
context: Any, text: str
|
|
) -> None:
|
|
"""Shared @then step: check for a ValidationError stored in common context attrs."""
|
|
err = None
|
|
for attr in ("error", "lsp_error", "caught_error", "ontology_error"):
|
|
err = getattr(context, attr, None)
|
|
if err is not None:
|
|
break
|
|
assert err is not None, "Expected a ValidationError but none was raised"
|
|
assert text in str(err), f"Expected '{text}' in error message: {err}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_type
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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")
|
|
|
|
|
|
@then('it should return "INFO" unchanged')
|
|
def step_verify_type_same(context: Any) -> None:
|
|
assert context.result == "INFO"
|
|
assert isinstance(context.result, str)
|
|
|
|
|
|
@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("plan.concurrency", "9090")
|
|
|
|
|
|
@then("it should return the integer 9090")
|
|
def step_verify_int_coercion(context: Any) -> None:
|
|
assert context.result == 9090
|
|
assert isinstance(context.result, int)
|
|
|
|
|
|
@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("plan.budget.warn-threshold", "0.5")
|
|
|
|
|
|
@then("it should return the float 0.5")
|
|
def step_verify_float_coercion(context: Any) -> None:
|
|
assert context.result == 0.5
|
|
assert isinstance(context.result, float)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@then('it should return the string "123"')
|
|
def step_verify_str_coercion(context: Any) -> None:
|
|
assert context.result == "123"
|
|
assert isinstance(context.result, str)
|
|
|
|
|
|
@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.log.file-enabled", "true")
|
|
|
|
|
|
@then("it should return boolean True")
|
|
def step_verify_bool_true(context: Any) -> None:
|
|
assert context.result is True
|
|
|
|
|
|
@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.log.file-enabled", "1")
|
|
|
|
|
|
@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.log.file-enabled", "yes")
|
|
|
|
|
|
@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.log.file-enabled", "false")
|
|
|
|
|
|
@then("it should return boolean False")
|
|
def step_verify_bool_false(context: Any) -> None:
|
|
assert context.result is False
|
|
|
|
|
|
@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.log.file-enabled", "0")
|
|
|
|
|
|
@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.log.file-enabled", "no")
|
|
|
|
|
|
@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.log.file-enabled", "maybe")
|
|
context.error = None
|
|
except TypeError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('a TypeError should be raised mentioning "Cannot convert"')
|
|
def step_verify_bool_invalid_error(context: Any) -> None:
|
|
assert context.error is not None
|
|
assert isinstance(context.error, TypeError)
|
|
assert "Cannot convert" in str(context.error)
|
|
|
|
|
|
@when(
|
|
'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("plan.concurrency", "not_a_number")
|
|
context.error = None
|
|
except TypeError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('a TypeError should be raised mentioning "Type mismatch"')
|
|
def step_verify_type_mismatch_error(context: Any) -> None:
|
|
assert context.error is not None
|
|
assert isinstance(context.error, TypeError)
|
|
assert "Type mismatch" in str(context.error)
|
|
|
|
|
|
@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.log.file-enabled", [1, 2, 3])
|
|
context.error = None
|
|
except TypeError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I call validate_type with unknown key "fake.unknown" and value "x"')
|
|
def step_validate_type_unknown_key(context: Any) -> None:
|
|
try:
|
|
context.result = ConfigService.validate_type("fake.unknown", "x")
|
|
context.error = None
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('the raised ValueError should mention "Cannot validate type for unknown key"')
|
|
def step_verify_validate_type_unknown_key_error(context: Any) -> None:
|
|
assert context.error is not None
|
|
assert isinstance(context.error, ValueError)
|
|
assert "Cannot validate type for unknown key" in str(context.error)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve - six-level chain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ConfigService with empty config for resolve tests")
|
|
def step_service_empty_for_resolve(context: Any) -> None:
|
|
context.config_dir.mkdir(parents=True, exist_ok=True)
|
|
context.svc = ConfigService(
|
|
config_dir=context.config_dir,
|
|
config_path=context.config_path,
|
|
)
|
|
|
|
|
|
@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")
|
|
|
|
|
|
@then('the resolved value should be "FATAL" from source DEFAULT')
|
|
def step_verify_resolve_default(context: Any) -> None:
|
|
assert context.result.value == "FATAL"
|
|
assert context.result.source == ConfigLevel.DEFAULT
|
|
|
|
|
|
@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")
|
|
|
|
|
|
@then('the resolved value should be "TRACE" from source CLI_FLAG')
|
|
def step_verify_resolve_cli(context: Any) -> None:
|
|
assert context.result.value == "TRACE"
|
|
assert context.result.source == ConfigLevel.CLI_FLAG
|
|
# Clean up env patch if present (from CLI-over-env scenario)
|
|
if hasattr(context, "_env_patch"):
|
|
context._env_patch.stop()
|
|
|
|
|
|
@given('the env var "{name}" is injected with "{value}"')
|
|
def step_inject_env_var(context: Any, name: str, value: str) -> None:
|
|
context._env_patch = patch.dict("os.environ", {name: value})
|
|
context._env_patch.start()
|
|
|
|
|
|
@then('the resolved value should be "WARNING" from source ENV_VAR')
|
|
def step_verify_resolve_env(context: Any) -> None:
|
|
assert context.result.value == "WARNING"
|
|
assert context.result.source == ConfigLevel.ENV_VAR
|
|
# Clean up the patch
|
|
if hasattr(context, "_env_patch"):
|
|
context._env_patch.stop()
|
|
|
|
|
|
@given(
|
|
'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.automation-profile"] = "PROJECT_DEBUG"
|
|
project_table["myproj"] = myproj_table
|
|
doc["project"] = project_table
|
|
with open(context.config_path, "w") as fh:
|
|
tomlkit.dump(doc, fh)
|
|
context.svc = ConfigService(
|
|
config_dir=context.config_dir,
|
|
config_path=context.config_path,
|
|
)
|
|
|
|
|
|
@when('I resolve key "core.automation-profile" with project_name "myproj"')
|
|
def step_resolve_project(context: Any) -> None:
|
|
context.result = context.svc.resolve(
|
|
"core.automation-profile", project_name="myproj"
|
|
)
|
|
|
|
|
|
@then('the resolved value should be "PROJECT_DEBUG" from source PROJECT')
|
|
def step_verify_resolve_project(context: Any) -> None:
|
|
assert context.result.value == "PROJECT_DEBUG"
|
|
assert context.result.source == ConfigLevel.PROJECT
|
|
|
|
|
|
@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"
|
|
with open(context.config_path, "w") as fh:
|
|
tomlkit.dump(doc, fh)
|
|
context.svc = ConfigService(
|
|
config_dir=context.config_dir,
|
|
config_path=context.config_path,
|
|
)
|
|
|
|
|
|
@then('the resolved value should be "GLOBAL_WARN" from source GLOBAL')
|
|
def step_verify_resolve_global(context: Any) -> None:
|
|
assert context.result.value == "GLOBAL_WARN"
|
|
assert context.result.source == ConfigLevel.GLOBAL
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@then("the resolved chain should have 6 entries covering all levels")
|
|
def step_verify_verbose_chain_length(context: Any) -> None:
|
|
chain = context.result.chain
|
|
assert len(chain) == 6, f"Expected 6 chain entries, got {len(chain)}"
|
|
sources = [entry["source"] for entry in chain]
|
|
assert sources == [
|
|
ConfigLevel.CLI_FLAG.value,
|
|
ConfigLevel.ENV_VAR.value,
|
|
ConfigLevel.LOCAL.value,
|
|
ConfigLevel.PROJECT.value,
|
|
ConfigLevel.GLOBAL.value,
|
|
ConfigLevel.DEFAULT.value,
|
|
]
|
|
|
|
|
|
@then(
|
|
'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_LOG_LEVEL"
|
|
|
|
|
|
@then("the verbose chain GLOBAL entry should include the config file path")
|
|
def step_verify_verbose_global_path(context: Any) -> None:
|
|
chain = context.result.chain
|
|
global_entry = next(e for e in chain if e["source"] == ConfigLevel.GLOBAL.value)
|
|
assert "path" in global_entry
|
|
assert str(context.config_path) in global_entry["path"]
|
|
|
|
|
|
# -- project scope skip for non-scopable keys --
|
|
|
|
|
|
@given(
|
|
'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.data-dir"] = "/proj/data"
|
|
project_table["myproj"] = myproj_table
|
|
doc["project"] = project_table
|
|
with open(context.config_path, "w") as fh:
|
|
tomlkit.dump(doc, fh)
|
|
context.svc = ConfigService(
|
|
config_dir=context.config_dir,
|
|
config_path=context.config_path,
|
|
)
|
|
|
|
|
|
@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.automation-profile", project_name="myproj", verbose=True
|
|
)
|
|
|
|
|
|
@then('the verbose chain PROJECT entry should have value "PROJECT_DEBUG"')
|
|
def step_verify_verbose_project_value(context: Any) -> None:
|
|
chain = context.result.chain
|
|
proj_entry = next(e for e in chain if e["source"] == ConfigLevel.PROJECT.value)
|
|
assert proj_entry["value"] == "PROJECT_DEBUG"
|
|
|
|
|
|
@then('the verbose chain GLOBAL entry should have value "GLOBAL_WARN"')
|
|
def step_verify_verbose_global_value(context: Any) -> None:
|
|
chain = context.result.chain
|
|
global_entry = next(e for e in chain if e["source"] == ConfigLevel.GLOBAL.value)
|
|
assert global_entry["value"] == "GLOBAL_WARN"
|
|
|
|
|
|
@when('I resolve key "core.data-dir" with project_name "myproj"')
|
|
def step_resolve_nonscopable(context: Any) -> None:
|
|
context.result = context.svc.resolve("core.data-dir", project_name="myproj")
|
|
|
|
|
|
@then('the resolved value should not be "/proj/data"')
|
|
def step_verify_not_project_value(context: Any) -> None:
|
|
assert context.result.value != "/proj/data"
|
|
|
|
|
|
@then("the resolved source should be DEFAULT or GLOBAL")
|
|
def step_verify_source_not_project(context: Any) -> None:
|
|
assert context.result.source in (ConfigLevel.DEFAULT, ConfigLevel.GLOBAL)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# env_var_for_key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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")
|
|
|
|
|
|
@then('it should return "CLEVERAGENTS_LOG_LEVEL"')
|
|
def step_verify_env_var_for_key(context: Any) -> None:
|
|
assert context.result == "CLEVERAGENTS_LOG_LEVEL"
|
|
|
|
|
|
@when('I call env_var_for_key with "nonexistent.key"')
|
|
def step_call_env_var_for_key_unknown(context: Any) -> None:
|
|
try:
|
|
context.result = ConfigService.env_var_for_key("nonexistent.key")
|
|
context.error = None
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
# The "a ValueError should be raised mentioning ..." @then step is defined
|
|
# above (step_then_generic_valueerror_raised_mentioning) and reused here.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_all
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I call resolve_all with no overrides")
|
|
def step_call_resolve_all(context: Any) -> None:
|
|
context.result = context.svc.resolve_all()
|
|
|
|
|
|
@then("the result should contain an entry for every registered key")
|
|
def step_verify_resolve_all_keys(context: Any) -> None:
|
|
for key in _REGISTRY:
|
|
assert key in context.result, f"Missing resolved key: {key}"
|
|
assert isinstance(context.result[key], ResolvedValue)
|
|
|
|
|
|
@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"})
|
|
|
|
|
|
@then(
|
|
'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"]
|
|
assert rv.value == "FATAL"
|
|
assert rv.source == ConfigLevel.CLI_FLAG
|