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

1"""Module Exposure Filtering (FE-12). 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import logging 

11import re 

12 

13import click 

14 

15logger = logging.getLogger("apcore_cli.exposure") 

16 

17 

18def _compile_pattern(pattern: str) -> re.Pattern[str]: 

19 """Compile a glob pattern to a regex. 

20 

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}$") 

36 

37 

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 

42 

43 

44class ExposureFilter: 

45 """Determines which modules are exposed as CLI commands. 

46 

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 """ 

52 

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] 

65 

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 

75 

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 

86 

87 @classmethod 

88 def from_config(cls, config: dict) -> ExposureFilter: 

89 """Create an ExposureFilter from a parsed config dict. 

90 

91 Expected structure:: 

92 

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

99 

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) 

104 

105 include = expose.get("include", []) 

106 if not isinstance(include, list): 

107 logger.warning("Invalid 'expose.include' (expected list), ignoring.") 

108 include = [] 

109 

110 exclude = expose.get("exclude", []) 

111 if not isinstance(exclude, list): 

112 logger.warning("Invalid 'expose.exclude' (expected list), ignoring.") 

113 exclude = [] 

114 

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

122 

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

129 

130 return cls(mode=mode, include=filtered_include, exclude=filtered_exclude)