Files
temp/features/steps/config_cli_uncovered_branches_steps.py
T

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