Coverage for src / apcore_cli / config.py: 100%
59 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-26 10:23 +0800
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-26 10:23 +0800
1"""Configuration resolver with 4-tier precedence (FE-07)."""
3from __future__ import annotations
5import logging
6import os
7from typing import Any
9import yaml
11logger = logging.getLogger("apcore_cli.config")
14class ConfigResolver:
15 """Resolves configuration values using 4-tier precedence:
16 CLI flag > Environment variable > Config file > Default.
17 """
19 # Namespace key -> legacy key mapping for backward compatibility
20 _NAMESPACE_TO_LEGACY: dict[str, str] = {
21 "apcore-cli.stdin_buffer_limit": "cli.stdin_buffer_limit",
22 "apcore-cli.auto_approve": "cli.auto_approve",
23 "apcore-cli.help_text_max_length": "cli.help_text_max_length",
24 "apcore-cli.logging_level": "logging.level",
25 }
26 _LEGACY_TO_NAMESPACE: dict[str, str] = {v: k for k, v in _NAMESPACE_TO_LEGACY.items()}
28 DEFAULTS: dict[str, Any] = {
29 "extensions.root": "./extensions",
30 "logging.level": "WARNING",
31 "cli.help_text_max_length": 1000,
32 # FE-11 (v0.6.0)
33 "cli.approval_timeout": 60,
34 "cli.strategy": "standard",
35 "cli.group_depth": 1,
36 # Exposure filtering (FE-12)
37 "expose.mode": "all",
38 "expose.include": [],
39 "expose.exclude": [],
40 }
41 # Audit D9 (config cleanup): the unused entries `sandbox.enabled`,
42 # `cli.stdin_buffer_limit`, `cli.auto_approve`, and the four
43 # `apcore-cli.*` namespace aliases were removed in v0.6.x. None of
44 # them were ever read by `resolve()` at runtime — sandbox/auto-approve
45 # come from CLI flags, the stdin buffer is hard-coded, and the
46 # namespace aliases are registered separately via the Config Bus
47 # `register_namespace()` call in `apcore_cli/__init__.py`.
49 def __init__(
50 self,
51 cli_flags: dict[str, Any] | None = None,
52 config_path: str = "apcore.yaml",
53 ) -> None:
54 self._cli_flags = cli_flags or {}
55 self._config_path = config_path
56 self._config_file: dict[str, Any] | None = self._load_config_file()
58 def resolve(
59 self,
60 key: str,
61 cli_flag: str | None = None,
62 env_var: str | None = None,
63 ) -> Any:
64 """Resolve a configuration value using 4-tier precedence."""
65 # Tier 1: CLI flag
66 if cli_flag is not None and cli_flag in self._cli_flags:
67 value = self._cli_flags[cli_flag]
68 if value is not None:
69 return value
71 # Tier 2: Environment variable
72 if env_var is not None:
73 env_value = os.environ.get(env_var)
74 if env_value is not None and env_value != "":
75 return env_value
77 # Tier 3: Config file (try both namespace and legacy keys)
78 if self._config_file is not None:
79 if key in self._config_file:
80 return self._config_file[key]
81 alt_key = self._NAMESPACE_TO_LEGACY.get(key) or self._LEGACY_TO_NAMESPACE.get(key)
82 if alt_key and alt_key in self._config_file:
83 return self._config_file[alt_key]
85 # Tier 4: Default
86 return self.DEFAULTS.get(key)
88 def resolve_object(self, key: str) -> Any:
89 """Return the subtree rooted at ``key`` from the on-disk config file.
91 Unlike :meth:`resolve`, this is for non-leaf keys that may evaluate to
92 a scalar, a bool, ``None``, or a nested mapping — e.g. the FE-13
93 ``apcli:`` block which can be ``true`` / ``false`` / ``null`` / object.
95 Lookup semantics:
96 - Scalar / bool leaf at ``<key>``: returns the value as-is.
97 - Flattened subtree under ``<key>.*``: reconstructed into a
98 one-level dict whose keys drop the ``<key>.`` prefix.
99 - Key absent everywhere: returns ``None``.
100 """
101 if self._config_file is None:
102 return None
103 if key in self._config_file:
104 return self._config_file[key]
105 prefix = f"{key}."
106 subtree = {k[len(prefix) :]: v for k, v in self._config_file.items() if k.startswith(prefix)}
107 return subtree or None
109 def _load_config_file(self) -> dict[str, Any] | None:
110 """Load and flatten a YAML config file."""
111 try:
112 with open(self._config_path) as f:
113 config = yaml.safe_load(f)
114 except FileNotFoundError:
115 return None
116 except yaml.YAMLError:
117 logger.warning(
118 "Configuration file '%s' is malformed, using defaults.",
119 self._config_path,
120 )
121 return None
123 if not isinstance(config, dict):
124 logger.warning(
125 "Configuration file '%s' is malformed, using defaults.",
126 self._config_path,
127 )
128 return None
130 return self._flatten_dict(config)
132 def _flatten_dict(self, d: dict, prefix: str = "") -> dict[str, Any]:
133 """Flatten nested dict to dot-notation keys."""
134 result: dict[str, Any] = {}
135 for key, value in d.items():
136 full_key = f"{prefix}.{key}" if prefix else key
137 if isinstance(value, dict):
138 result.update(self._flatten_dict(value, full_key))
139 else:
140 result[full_key] = value
141 return result