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

1"""Configuration resolver with 4-tier precedence (FE-07).""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import os 

7from typing import Any 

8 

9import yaml 

10 

11logger = logging.getLogger("apcore_cli.config") 

12 

13 

14class ConfigResolver: 

15 """Resolves configuration values using 4-tier precedence: 

16 CLI flag > Environment variable > Config file > Default. 

17 """ 

18 

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

27 

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`. 

48 

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() 

57 

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 

70 

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 

76 

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] 

84 

85 # Tier 4: Default 

86 return self.DEFAULTS.get(key) 

87 

88 def resolve_object(self, key: str) -> Any: 

89 """Return the subtree rooted at ``key`` from the on-disk config file. 

90 

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. 

94 

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 

108 

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 

122 

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 

129 

130 return self._flatten_dict(config) 

131 

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