Coverage for src / apcore_cli / exposure.py: 96%
68 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"""Module Exposure Filtering (FE-12).
3Provides declarative control over which discovered modules are exposed
4as CLI commands. Supports three modes: all, include (whitelist), and
5exclude (blacklist) with glob-pattern matching on module IDs.
6"""
8from __future__ import annotations
10import logging
11import re
13import click
15logger = logging.getLogger("apcore_cli.exposure")
18def _compile_pattern(pattern: str) -> re.Pattern[str]:
19 """Compile a glob pattern to a regex.
21 - ``*`` matches a single dotted segment (no dots).
22 - ``**`` matches across segments (any characters including dots).
23 - Literal text is matched exactly.
24 """
25 # Replace ** first with a sentinel to avoid confusion with single *
26 sentinel = "\x00GLOB\x00"
27 escaped = pattern.replace("**", sentinel)
28 # Escape regex-special chars except our sentinel and remaining *
29 parts = escaped.split("*")
30 parts = [p.replace(sentinel, "**") for p in parts]
31 # Now rebuild: join single-star positions with [^.]* and handle **
32 regex = "[^.]*".join(re.escape(p) for p in parts)
33 # Restore ** → .+ (must match at least one char across segments)
34 regex = regex.replace(re.escape("**"), ".+")
35 return re.compile(f"^{regex}$")
38def _glob_match(module_id: str, pattern: str) -> bool:
39 """Test whether a module_id matches a glob pattern."""
40 compiled = _compile_pattern(pattern)
41 return compiled.match(module_id) is not None
44class ExposureFilter:
45 """Determines which modules are exposed as CLI commands.
47 Filtering modes:
48 - ``all``: every discovered module becomes a CLI command (default).
49 - ``include``: only modules matching at least one include pattern are exposed.
50 - ``exclude``: all modules are exposed except those matching any exclude pattern.
51 """
53 def __init__(
54 self,
55 mode: str = "all",
56 include: list[str] | None = None,
57 exclude: list[str] | None = None,
58 ) -> None:
59 self._mode = mode
60 self._include_patterns = list(dict.fromkeys(include or []))
61 self._exclude_patterns = list(dict.fromkeys(exclude or []))
62 # Pre-compile regexes
63 self._compiled_include = [_compile_pattern(p) for p in self._include_patterns]
64 self._compiled_exclude = [_compile_pattern(p) for p in self._exclude_patterns]
66 def is_exposed(self, module_id: str) -> bool:
67 """Return True if the module should be exposed as a CLI command."""
68 if self._mode == "all":
69 return True
70 if self._mode == "include":
71 return any(rx.match(module_id) for rx in self._compiled_include)
72 if self._mode == "exclude":
73 return not any(rx.match(module_id) for rx in self._compiled_exclude)
74 return False
76 def filter_modules(self, module_ids: list[str]) -> tuple[list[str], list[str]]:
77 """Partition module_ids into (exposed, hidden) lists."""
78 exposed: list[str] = []
79 hidden: list[str] = []
80 for mid in module_ids:
81 if self.is_exposed(mid):
82 exposed.append(mid)
83 else:
84 hidden.append(mid)
85 return exposed, hidden
87 @classmethod
88 def from_config(cls, config: dict) -> ExposureFilter:
89 """Create an ExposureFilter from a parsed config dict.
91 Expected structure::
93 {"expose": {"mode": "include", "include": ["admin.*"]}}
94 """
95 expose = config.get("expose", {})
96 if not isinstance(expose, dict):
97 logger.warning("Invalid 'expose' config (expected dict), using mode: all.")
98 return cls()
100 mode = expose.get("mode", "all")
101 if mode not in ("all", "include", "exclude"):
102 msg = f"Invalid expose mode: '{mode}'. Must be one of: all, include, exclude."
103 raise click.BadParameter(msg)
105 include = expose.get("include", [])
106 if not isinstance(include, list):
107 logger.warning("Invalid 'expose.include' (expected list), ignoring.")
108 include = []
110 exclude = expose.get("exclude", [])
111 if not isinstance(exclude, list):
112 logger.warning("Invalid 'expose.exclude' (expected list), ignoring.")
113 exclude = []
115 # Filter empty strings
116 filtered_include: list[str] = []
117 for p in include:
118 if not p:
119 logger.warning("Empty pattern in expose.include, skipping.")
120 else:
121 filtered_include.append(str(p))
123 filtered_exclude: list[str] = []
124 for p in exclude:
125 if not p:
126 logger.warning("Empty pattern in expose.exclude, skipping.")
127 else:
128 filtered_exclude.append(str(p))
130 return cls(mode=mode, include=filtered_include, exclude=filtered_exclude)