"""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