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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-26 10:23 +0800
1"""Built-in Command Group (FE-13).
3Encapsulates visibility resolution and subcommand filtering for the
4reserved ``apcli`` group. Instantiated once by :func:`create_cli` and
5attached to the root Click group.
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``).
12See the FE-13 feature spec (``../apcore-cli/docs/features/builtin-group.md``)
13§4.2–4.7 for the authoritative semantics.
14"""
16from __future__ import annotations
18import logging
19import os
20import sys
21from typing import Any, Literal
23logger = logging.getLogger("apcore_cli.builtin_group")
25# ---------------------------------------------------------------------------
26# Types & constants
27# ---------------------------------------------------------------------------
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"]
34#: Resolved (non-sentinel) visibility modes.
35ResolvedApcliMode = Literal["all", "none", "include", "exclude"]
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"})
43_VALID_USER_MODES: frozenset[str] = frozenset({"all", "none", "include", "exclude"})
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)
69_ENV_VAR = "APCORE_CLI_APCLI"
71# Exit code for invalid CLI input (matches Click / errors.py convention).
72_EXIT_INVALID_CLI_INPUT = 2
75# ---------------------------------------------------------------------------
76# ApcliGroup
77# ---------------------------------------------------------------------------
80class ApcliGroup:
81 """Visibility configuration for the built-in ``apcli`` command group.
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 """
89 __slots__ = (
90 "_mode",
91 "_include",
92 "_exclude",
93 "_disable_env",
94 "_registry_injected",
95 "_from_cli_config",
96 )
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)
114 # ---- Factories ---------------------------------------------------------
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=...)``.
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)
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``.
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)
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.
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
177 # ---- Internal builder --------------------------------------------------
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__}")
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]
230 include = cls._normalize_list(config.get("include"), "include")
231 exclude = cls._normalize_list(config.get("exclude"), "exclude")
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
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 )
261 @staticmethod
262 def _normalize_list(raw: Any, label: str) -> list[str]:
263 """Normalize an include/exclude list.
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
297 # ---- Public predicates -------------------------------------------------
299 def resolve_visibility(self) -> ResolvedApcliMode:
300 """Return the effective visibility mode after applying tier precedence.
302 Never returns ``"auto"`` — the sentinel collapses via steps 3/4 below.
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]
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
320 # Tier 3 — yaml non-auto.
321 if self._mode != "auto":
322 return self._mode # type: ignore[return-value]
324 # Tier 4 — auto-detect.
325 return "none" if self._registry_injected else "all"
327 def is_subcommand_included(self, subcommand: str) -> bool:
328 """True if the subcommand passes the include/exclude filter.
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.")
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"
346 # ---- Env parser (Tier 2) ----------------------------------------------
348 @staticmethod
349 def _parse_env(raw: str | None) -> ResolvedApcliMode | None:
350 """Parse ``APCORE_CLI_APCLI``. Case-insensitive.
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
372__all__ = [
373 "APCLI_SUBCOMMAND_NAMES",
374 "ApcliGroup",
375 "ApcliMode",
376 "RESERVED_GROUP_NAMES",
377 "ResolvedApcliMode",
378]