Coverage for src / apcore_cli / builtin_group.py: 93%

118 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-26 10:23 +0800

1"""Built-in Command Group (FE-13). 

2 

3Encapsulates visibility resolution and subcommand filtering for the 

4reserved ``apcli`` group. Instantiated once by :func:`create_cli` and 

5attached to the root Click group. 

6 

7Shape mirrors :class:`apcore_cli.exposure.ExposureFilter`: private 

8initializer, named classmethod factories (``from_cli_config`` for Tier 1, 

9``from_yaml`` for Tier 3), and a small set of predicate methods 

10(``resolve_visibility``, ``is_subcommand_included``, ``is_group_visible``). 

11 

12See the FE-13 feature spec (``../apcore-cli/docs/features/builtin-group.md``) 

13§4.2–4.7 for the authoritative semantics. 

14""" 

15 

16from __future__ import annotations 

17 

18import logging 

19import os 

20import sys 

21from typing import Any, Literal 

22 

23logger = logging.getLogger("apcore_cli.builtin_group") 

24 

25# --------------------------------------------------------------------------- 

26# Types & constants 

27# --------------------------------------------------------------------------- 

28 

29#: Resolved visibility modes. ``"auto"`` is an internal sentinel — it is never 

30#: returned from :meth:`ApcliGroup.resolve_visibility` and is rejected when 

31#: supplied via user config (CliConfig or ``apcore.yaml``). 

32ApcliMode = Literal["auto", "all", "none", "include", "exclude"] 

33 

34#: Resolved (non-sentinel) visibility modes. 

35ResolvedApcliMode = Literal["all", "none", "include", "exclude"] 

36 

37#: Set of group names reserved by apcore-cli; enforced by 

38#: :class:`apcore_cli.cli.GroupedModuleGroup` when building the registry-driven 

39#: command surface. Business modules whose alias/top-level/group name collides 

40#: with a reserved entry are rejected at import time with exit code 2. 

41RESERVED_GROUP_NAMES: frozenset[str] = frozenset({"apcli"}) 

42 

43_VALID_USER_MODES: frozenset[str] = frozenset({"all", "none", "include", "exclude"}) 

44 

45#: Canonical set of apcli subcommand names. Declarative mirror of the 

46#: registrar table in :func:`apcore_cli.factory._register_apcli_subcommands`. 

47#: Used by :meth:`ApcliGroup._normalize_list` to warn on unknown entries in 

48#: include/exclude lists (spec §7 error table / T-APCLI-25). 

49#: 

50#: Keep in sync with the factory TABLE if subcommands are added or removed. 

51APCLI_SUBCOMMAND_NAMES: frozenset[str] = frozenset( 

52 { 

53 "list", 

54 "describe", 

55 "exec", 

56 "validate", 

57 "init", 

58 "health", 

59 "usage", 

60 "enable", 

61 "disable", 

62 "reload", 

63 "config", 

64 "completion", 

65 "describe-pipeline", 

66 } 

67) 

68 

69_ENV_VAR = "APCORE_CLI_APCLI" 

70 

71# Exit code for invalid CLI input (matches Click / errors.py convention). 

72_EXIT_INVALID_CLI_INPUT = 2 

73 

74 

75# --------------------------------------------------------------------------- 

76# ApcliGroup 

77# --------------------------------------------------------------------------- 

78 

79 

80class ApcliGroup: 

81 """Visibility configuration for the built-in ``apcli`` command group. 

82 

83 Instantiated via :meth:`ApcliGroup.from_cli_config` (Tier 1) or 

84 :meth:`ApcliGroup.from_yaml` (Tier 3). The default ``__init__`` is 

85 considered private — callers should prefer the classmethod factories so 

86 the Tier-1-vs-Tier-3 precedence flag is set correctly. 

87 """ 

88 

89 __slots__ = ( 

90 "_mode", 

91 "_include", 

92 "_exclude", 

93 "_disable_env", 

94 "_registry_injected", 

95 "_from_cli_config", 

96 ) 

97 

98 def __init__( 

99 self, 

100 mode: ApcliMode = "auto", 

101 include: list[str] | None = None, 

102 exclude: list[str] | None = None, 

103 disable_env: bool = False, 

104 registry_injected: bool = False, 

105 from_cli_config: bool = False, 

106 ) -> None: 

107 self._mode: ApcliMode = mode 

108 self._include: list[str] = list(include) if include else [] 

109 self._exclude: list[str] = list(exclude) if exclude else [] 

110 self._disable_env: bool = bool(disable_env) 

111 self._registry_injected: bool = bool(registry_injected) 

112 self._from_cli_config: bool = bool(from_cli_config) 

113 

114 # ---- Factories --------------------------------------------------------- 

115 

116 @classmethod 

117 def from_cli_config( 

118 cls, 

119 config: bool | dict[str, Any] | None, 

120 *, 

121 registry_injected: bool, 

122 ) -> ApcliGroup: 

123 """Tier 1 constructor — value came from ``create_cli(apcli=...)``. 

124 

125 A non-auto mode from this tier wins outright over the env var and 

126 ``apcore.yaml``. Boolean / dict / ``None`` are all accepted; other 

127 types raise :class:`TypeError` (the factory catches and exits 2). 

128 """ 

129 return cls._build(config, registry_injected=registry_injected, from_cli_config=True) 

130 

131 @classmethod 

132 def from_yaml( 

133 cls, 

134 config: Any, 

135 *, 

136 registry_injected: bool, 

137 ) -> ApcliGroup: 

138 """Tier 3 constructor — value came from ``apcore.yaml``. 

139 

140 Env var (Tier 2) may override the yaml-supplied mode when 

141 ``disable_env`` is not set. On invalid input, logs a WARNING and 

142 falls back to auto-detect rather than raising. 

143 """ 

144 # Coerce yaml-loaded values. Anything that isn't bool/dict/None 

145 # becomes "auto" with a warning — yaml is often user-edited, so we 

146 # log but do not crash for non-actionable shapes. 

147 if config is not None and not isinstance(config, bool | dict): 

148 logger.warning( 

149 "apcore.yaml 'apcli:' must be a bool, mapping, or null; got %s. Using auto-detect.", 

150 type(config).__name__, 

151 ) 

152 config = None 

153 return cls._build(config, registry_injected=registry_injected, from_cli_config=False) 

154 

155 @classmethod 

156 def try_from_yaml( 

157 cls, 

158 config: Any, 

159 *, 

160 registry_injected: bool, 

161 ) -> tuple[ApcliGroup, None] | tuple[None, str]: 

162 """Non-panicking Tier 3 constructor. Returns ``(instance, None)`` on 

163 success or ``(None, error_message)`` on validation failure. 

164 

165 Use this in programmatic contexts where raising/exiting is unwanted. 

166 ``from_yaml()`` is the lenient shim that logs + falls back; 

167 ``try_from_yaml()`` surfaces the error so callers can decide. 

168 """ 

169 if config is not None and not isinstance(config, bool | dict): 

170 return None, (f"apcore.yaml 'apcli:' must be a bool, mapping, or null; got {type(config).__name__}") 

171 if isinstance(config, dict): 

172 raw_mode = config.get("mode") 

173 if raw_mode is not None and (not isinstance(raw_mode, str) or raw_mode not in _VALID_USER_MODES): 

174 return None, f"Invalid apcli mode: '{raw_mode}'. Must be one of: all, none, include, exclude." 

175 return cls._build(config, registry_injected=registry_injected, from_cli_config=False), None 

176 

177 # ---- Internal builder -------------------------------------------------- 

178 

179 @classmethod 

180 def _build( 

181 cls, 

182 config: bool | dict[str, Any] | None, 

183 *, 

184 registry_injected: bool, 

185 from_cli_config: bool, 

186 ) -> ApcliGroup: 

187 if config is True: 

188 return cls( 

189 mode="all", 

190 disable_env=False, 

191 registry_injected=registry_injected, 

192 from_cli_config=from_cli_config, 

193 ) 

194 if config is False: 

195 return cls( 

196 mode="none", 

197 disable_env=False, 

198 registry_injected=registry_injected, 

199 from_cli_config=from_cli_config, 

200 ) 

201 if config is None: 

202 # Auto-detect (internal sentinel; never returned from resolve). 

203 return cls( 

204 mode="auto", 

205 disable_env=False, 

206 registry_injected=registry_injected, 

207 from_cli_config=from_cli_config, 

208 ) 

209 if not isinstance(config, dict): 

210 # create_cli() ultimately raises TypeError for programmatic 

211 # callers; the factory wrapper prints the error and exits 2. 

212 raise TypeError(f"apcli: expected bool, dict, ApcliGroup, or None; got {type(config).__name__}") 

213 

214 # Mode validation — rejects "auto" (internal sentinel) and unknowns. 

215 raw_mode = config.get("mode") 

216 if raw_mode is None: 

217 mode: ApcliMode = "auto" 

218 elif not isinstance(raw_mode, str): 

219 sys.stderr.write( 

220 f"Error: apcli.mode must be a string; got {type(raw_mode).__name__}. " 

221 "Expected one of all|none|include|exclude.\n" 

222 ) 

223 sys.exit(_EXIT_INVALID_CLI_INPUT) 

224 elif raw_mode not in _VALID_USER_MODES: 

225 sys.stderr.write(f"Error: Invalid apcli mode: '{raw_mode}'. Must be one of: all, none, include, exclude.\n") 

226 sys.exit(_EXIT_INVALID_CLI_INPUT) 

227 else: 

228 mode = raw_mode # type: ignore[assignment] 

229 

230 include = cls._normalize_list(config.get("include"), "include") 

231 exclude = cls._normalize_list(config.get("exclude"), "exclude") 

232 

233 # disable_env — accept both snake_case (apcore.yaml + Python) and 

234 # camelCase (JS/TS object literals passed through dict() coercion in 

235 # cross-language bridge tests). Must be boolean; warn + treat as 

236 # false otherwise. Per spec §4.2: "Non-boolean value: log WARNING, 

237 # treat as false." 

238 raw_disable = config.get("disable_env") 

239 if raw_disable is None: 

240 raw_disable = config.get("disableEnv") 

241 if raw_disable is None: 

242 disable_env = False 

243 elif isinstance(raw_disable, bool): 

244 disable_env = raw_disable 

245 else: 

246 logger.warning( 

247 "apcli.disable_env must be boolean; got %s. Treating as false.", 

248 type(raw_disable).__name__, 

249 ) 

250 disable_env = False 

251 

252 return cls( 

253 mode=mode, 

254 include=include, 

255 exclude=exclude, 

256 disable_env=disable_env, 

257 registry_injected=registry_injected, 

258 from_cli_config=from_cli_config, 

259 ) 

260 

261 @staticmethod 

262 def _normalize_list(raw: Any, label: str) -> list[str]: 

263 """Normalize an include/exclude list. 

264 

265 Unknown-but-well-formed entries emit a WARNING (spec §7 error table, 

266 T-APCLI-25) but are retained in the returned list for forward 

267 compatibility — if apcore-cli later adds a subcommand named ``foo``, 

268 existing configs continue to work. At runtime, unknown names simply 

269 never match any registered subcommand. 

270 """ 

271 if raw is None: 

272 return [] 

273 if not isinstance(raw, list): 

274 logger.warning( 

275 "apcli.%s must be a list; got %s. Ignoring.", 

276 label, 

277 type(raw).__name__, 

278 ) 

279 return [] 

280 out: list[str] = [] 

281 for entry in raw: 

282 if isinstance(entry, str) and entry: 

283 if entry not in APCLI_SUBCOMMAND_NAMES: 

284 logger.warning( 

285 "Unknown apcli subcommand '%s' in %s list — ignoring.", 

286 entry, 

287 label, 

288 ) 

289 out.append(entry) 

290 else: 

291 logger.warning( 

292 "apcli.%s contains non-string entry; skipping.", 

293 label, 

294 ) 

295 return out 

296 

297 # ---- Public predicates ------------------------------------------------- 

298 

299 def resolve_visibility(self) -> ResolvedApcliMode: 

300 """Return the effective visibility mode after applying tier precedence. 

301 

302 Never returns ``"auto"`` — the sentinel collapses via steps 3/4 below. 

303 

304 Tier order (spec §4.4): 

305 1. ``CliConfig`` non-auto wins outright. 

306 2. ``APCORE_CLI_APCLI`` env var (unless sealed by ``disable_env``). 

307 3. ``apcore.yaml`` non-auto. 

308 4. Auto-detect from ``registry_injected``. 

309 """ 

310 # Tier 1 — CliConfig non-auto. 

311 if self._from_cli_config and self._mode != "auto": 

312 return self._mode # type: ignore[return-value] 

313 

314 # Tier 2 — env var (unless sealed). 

315 if not self._disable_env: 

316 env_mode = self._parse_env(os.environ.get(_ENV_VAR)) 

317 if env_mode is not None: 

318 return env_mode 

319 

320 # Tier 3 — yaml non-auto. 

321 if self._mode != "auto": 

322 return self._mode # type: ignore[return-value] 

323 

324 # Tier 4 — auto-detect. 

325 return "none" if self._registry_injected else "all" 

326 

327 def is_subcommand_included(self, subcommand: str) -> bool: 

328 """True if the subcommand passes the include/exclude filter. 

329 

330 Only meaningful when :meth:`resolve_visibility` returns 

331 ``"include"`` or ``"exclude"``. Raises ``AssertionError`` otherwise 

332 — dispatchers under ``"all"`` / ``"none"`` MUST bypass this method 

333 and register unconditionally (spec §4.6). 

334 """ 

335 mode = self.resolve_visibility() 

336 if mode == "include": 

337 return subcommand in self._include 

338 if mode == "exclude": 

339 return subcommand not in self._exclude 

340 raise AssertionError(f"is_subcommand_included called under mode='{mode}'; caller should bypass.") 

341 

342 def is_group_visible(self) -> bool: 

343 """True if the ``apcli`` group itself should appear in root ``--help``.""" 

344 return self.resolve_visibility() != "none" 

345 

346 # ---- Env parser (Tier 2) ---------------------------------------------- 

347 

348 @staticmethod 

349 def _parse_env(raw: str | None) -> ResolvedApcliMode | None: 

350 """Parse ``APCORE_CLI_APCLI``. Case-insensitive. 

351 

352 - ``show`` / ``1`` / ``true`` → ``"all"`` 

353 - ``hide`` / ``0`` / ``false`` → ``"none"`` 

354 - Empty / unset → ``None`` 

355 - Anything else → warn and return ``None`` 

356 """ 

357 if raw is None or raw == "": 

358 return None 

359 normalized = raw.lower() 

360 if normalized in ("show", "1", "true"): 

361 return "all" 

362 if normalized in ("hide", "0", "false"): 

363 return "none" 

364 logger.warning( 

365 "Unknown %s value '%s', ignoring. Expected: show, hide, 1, 0, true, false.", 

366 _ENV_VAR, 

367 raw, 

368 ) 

369 return None 

370 

371 

372__all__ = [ 

373 "APCLI_SUBCOMMAND_NAMES", 

374 "ApcliGroup", 

375 "ApcliMode", 

376 "RESERVED_GROUP_NAMES", 

377 "ResolvedApcliMode", 

378]