forked from HAL9000/cleveragents-core
355 lines
13 KiB
Python
355 lines
13 KiB
Python
"""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
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import typer
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands import config as config_mod
|
|
from cleveragents.cli.commands.config import (
|
|
_resolve_source,
|
|
_settings_defaults,
|
|
_validate_key,
|
|
_write_config_file,
|
|
)
|
|
from cleveragents.cli.commands.config import (
|
|
app as config_app,
|
|
)
|
|
|
|
_runner = CliRunner()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers - isolated temp dir patching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _patch_temp_config(context: Context) -> None:
|
|
"""Redirect _CONFIG_DIR / _CONFIG_PATH to a fresh temp directory."""
|
|
if not hasattr(context, "_cfg_branch_tmpdir"):
|
|
context._cfg_branch_tmpdir = tempfile.mkdtemp(prefix="cfg_branch_")
|
|
tmp = Path(context._cfg_branch_tmpdir)
|
|
context._cfg_branch_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
|
|
context._cfg_branch_path_patch = patch.object(
|
|
config_mod, "_CONFIG_PATH", tmp / "config.toml"
|
|
)
|
|
context._cfg_branch_dir_patch.start()
|
|
context._cfg_branch_path_patch.start()
|
|
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers: list[Any] = []
|
|
context._cleanup_handlers.append(lambda: _stop_temp_config(context))
|
|
|
|
|
|
def _stop_temp_config(context: Context) -> None:
|
|
for attr in ("_cfg_branch_dir_patch", "_cfg_branch_path_patch"):
|
|
patcher = getattr(context, attr, None)
|
|
if patcher is not None:
|
|
with contextlib.suppress(RuntimeError):
|
|
patcher.stop()
|
|
tmpdir = getattr(context, "_cfg_branch_tmpdir", None)
|
|
if tmpdir and os.path.isdir(tmpdir):
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
# ===================================================================
|
|
# _settings_defaults - default_factory branch (L81-85)
|
|
# ===================================================================
|
|
|
|
|
|
@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()
|
|
|
|
|
|
@then("the config cli branch defaults should contain the factory value")
|
|
def step_defaults_factory_value(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']}"
|
|
)
|
|
# normal_key should have its literal default
|
|
assert defaults["normal_key"] == "normal_val", (
|
|
f"Expected 'normal_val', got {defaults['normal_key']}"
|
|
)
|
|
|
|
|
|
@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)
|
|
# ===================================================================
|
|
|
|
|
|
@when("I config cli branch call _validate_key with an empty key")
|
|
def step_call_validate_key_empty(context: Context) -> None:
|
|
context._cfg_branch_validate_exc = None
|
|
try:
|
|
_validate_key("")
|
|
except typer.BadParameter as exc:
|
|
context._cfg_branch_validate_exc = exc
|
|
|
|
|
|
@then("the config cli branch call should raise BadParameter")
|
|
def step_validate_key_bad_param(context: Context) -> None:
|
|
assert context._cfg_branch_validate_exc is not None, (
|
|
"Expected typer.BadParameter but no exception was raised"
|
|
)
|
|
assert "empty" in str(context._cfg_branch_validate_exc).lower(), (
|
|
f"Expected 'empty' in message: {context._cfg_branch_validate_exc}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# _write_config_file - existing file merge (L145-147)
|
|
# ===================================================================
|
|
|
|
|
|
@given("a config cli branch temp config directory")
|
|
def step_temp_config_dir(context: Context) -> None:
|
|
_patch_temp_config(context)
|
|
|
|
|
|
@given('a config cli branch existing config file with key "{key}" set to "{value}"')
|
|
def step_existing_config_file(context: Context, key: str, value: str) -> None:
|
|
import tomlkit
|
|
|
|
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
doc = tomlkit.document()
|
|
doc[key] = value
|
|
with open(config_path, "w") as fh:
|
|
tomlkit.dump(doc, fh)
|
|
|
|
|
|
@when('I config cli branch write config with key "{key}" set to "{value}"')
|
|
def step_write_config_merge(context: Context, key: str, value: str) -> None:
|
|
coerced: Any = value
|
|
with contextlib.suppress(ValueError):
|
|
coerced = int(value)
|
|
_write_config_file({key: coerced})
|
|
|
|
|
|
@then("the config cli branch config file should contain both keys")
|
|
def step_config_file_both_keys(context: Context) -> None:
|
|
import tomllib
|
|
|
|
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']}"
|
|
|
|
|
|
# ===================================================================
|
|
# _resolve_source - env var path (L169-170)
|
|
# ===================================================================
|
|
|
|
|
|
@given('the config cli branch env var "{var}" is set to "{value}"')
|
|
def step_set_env_var(context: Context, var: str, value: str) -> None:
|
|
os.environ[var] = value
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(lambda: os.environ.pop(var, None))
|
|
|
|
|
|
@when('I config cli branch call _resolve_source for "{key}"')
|
|
def step_call_resolve_source(context: Context, key: str) -> None:
|
|
context._cfg_branch_source = _resolve_source(key)
|
|
|
|
|
|
@then('the config cli branch source should be "{expected}"')
|
|
def step_source_equals(context: Context, expected: str) -> None:
|
|
assert context._cfg_branch_source == expected, (
|
|
f"Expected '{expected}', got '{context._cfg_branch_source}'"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# config_set - non-rich format (L261-262)
|
|
# ===================================================================
|
|
|
|
|
|
@when('I config cli branch run config set "{key}" "{value}" with format "{fmt}"')
|
|
def step_run_config_set_format(
|
|
context: Context, key: str, value: str, fmt: str
|
|
) -> None:
|
|
context._cfg_branch_result = _runner.invoke(
|
|
config_app, ["set", key, value, "--format", fmt]
|
|
)
|
|
|
|
|
|
@then("the config cli branch set result should be valid JSON")
|
|
def step_set_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, dict), f"Expected dict, got {type(parsed)}"
|
|
context._cfg_branch_set_json = parsed
|
|
|
|
|
|
@then('the config cli branch set JSON should contain key "{key}"')
|
|
def step_set_json_has_key(context: Context, key: str) -> None:
|
|
assert key in context._cfg_branch_set_json, (
|
|
f"Key '{key}' not in {list(context._cfg_branch_set_json.keys())}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# config_get - non-rich + Path serialisation (L311-315)
|
|
# ===================================================================
|
|
|
|
|
|
@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."""
|
|
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)
|
|
|
|
p1.start()
|
|
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])
|
|
|
|
|
|
@when('I config cli branch run config get "{key}" with format "{fmt}"')
|
|
def step_run_config_get_format(context: Context, key: str, fmt: str) -> None:
|
|
context._cfg_branch_result = _runner.invoke(
|
|
config_app, ["get", key, "--format", fmt]
|
|
)
|
|
|
|
|
|
@then("the config cli branch get result should be valid JSON")
|
|
def step_get_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, dict), f"Expected dict, got {type(parsed)}"
|
|
context._cfg_branch_get_json = parsed
|
|
|
|
|
|
@then("the config cli branch get JSON value should be a string not a Path")
|
|
def step_get_json_value_string(context: Context) -> None:
|
|
data = context._cfg_branch_get_json
|
|
# The top-level "value" should be a string (serialised from Path)
|
|
assert isinstance(data["value"], str), (
|
|
f"Expected str, got {type(data['value'])}: {data['value']}"
|
|
)
|
|
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}"
|
|
)
|
|
assert "/mock/default/logs" in default_val, (
|
|
f"Expected default path string, got: {default_val}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# config_list - empty result (L422-423)
|
|
# ===================================================================
|
|
|
|
|
|
@when('I config cli branch run config list with pattern "{pattern}"')
|
|
def step_run_config_list_pattern(context: Context, pattern: str) -> None:
|
|
context._cfg_branch_result = _runner.invoke(config_app, ["list", pattern])
|
|
|
|
|
|
@then("the config cli branch list output should say no values match")
|
|
def step_list_no_match(context: Context) -> None:
|
|
result = context._cfg_branch_result
|
|
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
|
|
assert "No configuration values match" in result.output, (
|
|
f"Expected 'No configuration values match' in: {result.output}"
|
|
)
|