Coverage for src/mafw/tools/click_extensions.py: 98%

132 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-09-01 19:49 +0000

1# Copyright 2025–2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Reusable Click group classes and shell completion tools for MAFw command-line interfaces. 

6 

7This module centralizes the command abbreviation behavior used by the 

8``mafw`` executable and the development tools so nested command groups can 

9inherit the same resolution policy without repeating the ``cls=...`` 

10configuration. It also provides common helper functions for shell completion 

11installation and management across all MAFw CLI tools. 

12 

13.. versionadded:: 2.2 

14 

15Authors 

16------- 

17Bulgheroni Antonio <antonio.bulgheroni@ec.europa.eu> 

18""" 

19 

20from __future__ import annotations 

21 

22import collections.abc as cabc 

23import os 

24import pathlib 

25import re 

26import sys 

27import warnings 

28from typing import TYPE_CHECKING, Any 

29 

30import click 

31 

32from mafw.tools.shell_tools import CONSOLE, run_stdout 

33 

34if TYPE_CHECKING: 

35 pass 

36 

37 

38class DeprecatedOption(click.Option): 

39 """A Click Option subclass that emits a DeprecationWarning when explicitly used. 

40 

41 Use this class as ``cls=DeprecatedOption`` in a ``@click.option`` decorator to 

42 mark an option as deprecated. The warning is only emitted when the user 

43 explicitly provides the option on the command line; default-value resolution 

44 does **not** trigger the warning. The resolved value is passed through to the 

45 command callback unchanged. 

46 

47 :param deprecated_message: Warning text emitted when the option is explicitly 

48 provided on the command line. 

49 :type deprecated_message: str 

50 

51 .. versionadded:: 2.2 

52 """ 

53 

54 def __init__(self, *args: Any, deprecated_message: str = '', **kwargs: Any) -> None: 

55 self.deprecated_message = deprecated_message 

56 super().__init__(*args, **kwargs) 

57 if self.help: 57 ↛ exitline 57 didn't return from function '__init__' because the condition on line 57 was always true

58 self.help = f'(DEPRECATED) {self.help}' 

59 

60 def consume_value( 

61 self, ctx: click.Context, opts: cabc.Mapping[str, click.Parameter] 

62 ) -> tuple[Any, click.core.ParameterSource]: 

63 """Intercept value consumption to detect explicit CLI usage. 

64 

65 :param ctx: Current Click context. 

66 :type ctx: click.Context 

67 :param opts: Parsed option tokens from the command line. 

68 :type opts: Mapping[str, click.Parameter] 

69 :return: Tuple of (value, source) as returned by the parent implementation. 

70 :rtype: tuple[Any, click.core.ParameterSource] 

71 """ 

72 value, source = super().consume_value(ctx, opts) 

73 if source == click.core.ParameterSource.COMMANDLINE: 

74 warnings.warn(self.deprecated_message, DeprecationWarning, stacklevel=1) 

75 return value, source 

76 

77 

78class AbbreviateGroup(click.Group): 

79 """Click group that resolves unique command prefixes and catches DevtoolsError.""" 

80 

81 group_class: type[click.Group] = click.Group 

82 

83 def invoke(self, ctx: click.Context) -> Any: 

84 """Invoke the group, catching DevtoolsError and converting to ClickException.""" 

85 from mafw.devtools import DevtoolsError 

86 

87 try: 

88 return super().invoke(ctx) 

89 except DevtoolsError as exc: 

90 raise click.ClickException(str(exc)) from exc 

91 

92 def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: 

93 """Return a command by exact name or a unique abbreviation.""" 

94 # let's give it a try to the cmd_name. if the user provided the full command, 

95 # then we might be lucky and get it working right away, otherwise we will have to try 

96 # to get the command using the abbreviations. 

97 rv = super().get_command(ctx, cmd_name) 

98 if rv is not None: 

99 return rv 

100 

101 matches = [name for name in self.list_commands(ctx) if name.startswith(cmd_name)] 

102 if not matches: 

103 return None 

104 if len(matches) == 1: 

105 return click.Group.get_command(self, ctx, matches[0]) 

106 ctx.fail(f'Too many matches: {", ".join(sorted(matches))}') 

107 

108 def resolve_command( 

109 self, ctx: click.Context, args: list[str] 

110 ) -> tuple[str | None, click.Command | None, list[str]]: 

111 """Return the canonical command name for abbreviated commands.""" 

112 _, cmd, args = super().resolve_command(ctx, args) 

113 if TYPE_CHECKING: 

114 assert isinstance(cmd, click.Command) 

115 return cmd.name if cmd is not None else None, cmd, args 

116 

117 

118AbbreviateGroup.group_class = AbbreviateGroup 

119 

120 

121COMPLETION_SHELLS: dict[str, str] = { 

122 'bash': 'bash_source', 

123 'zsh': 'zsh_source', 

124 'fish': 'fish_source', 

125 'powershell': 'powershell_source', 

126} 

127 

128 

129def check_ci_completion_guard() -> None: 

130 """ 

131 Check if the current environment is a CI environment. 

132 

133 If the ``CI`` environment variable is set, the function prints an 

134 informational message and exits the process with code 0. 

135 """ 

136 if os.environ.get('CI'): 

137 CONSOLE.print('This command is not compatible with the CI environment.') 

138 sys.exit(0) 

139 

140 

141def completion_shell_from_env(shell_path: str | None) -> str: 

142 """ 

143 Resolve a completion shell from ``$SHELL``. 

144 

145 The resolver supports the shells handled by Click completion generation: 

146 ``bash``, ``zsh``, ``fish``, and ``powershell`` (including ``pwsh``). 

147 

148 :param shell_path: The raw shell path as exposed by the environment. 

149 :type shell_path: str | None 

150 :return: The normalized shell name. 

151 :rtype: str 

152 :raises click.ClickException: If the shell cannot be determined or is unsupported. 

153 """ 

154 if not shell_path: 

155 raise click.ClickException( 

156 'Unable to infer a shell from $SHELL. Supported shells: bash, fish, powershell, zsh.' 

157 ) 

158 # PureWindowsPath handles both forward-slash (POSIX) and backslash (Windows) 

159 # paths on all platforms, unlike PurePath which is platform-dependent. 

160 shell = pathlib.PureWindowsPath(shell_path).stem 

161 # Map known PowerShell binary names to the canonical 'powershell' key 

162 shell_aliases: dict[str, str] = {'pwsh': 'powershell', 'powershell': 'powershell'} 

163 shell = shell_aliases.get(shell, shell) 

164 if shell not in COMPLETION_SHELLS: 

165 supported = ', '.join(sorted(COMPLETION_SHELLS)) 

166 raise click.ClickException(f'Unsupported shell "{shell}". Supported shells: {supported}.') 

167 return shell 

168 

169 

170def resolve_completion_shell(shell: str) -> str: 

171 """ 

172 Normalize the requested completion shell. 

173 

174 :param shell: Shell selector from the CLI. 

175 :type shell: str 

176 :return: The resolved supported shell name. 

177 :rtype: str 

178 :raises click.ClickException: If the shell is unsupported. 

179 """ 

180 if shell == 'auto': 

181 return completion_shell_from_env(os.environ.get('SHELL')) 

182 if shell not in COMPLETION_SHELLS: 

183 supported = ', '.join(['auto', *sorted(COMPLETION_SHELLS)]) 

184 raise click.ClickException(f'Unsupported shell "{shell}". Supported shells: {supported}.') 

185 return shell 

186 

187 

188def _virtualenv_root() -> pathlib.Path: 

189 """ 

190 Return the active virtual environment root. 

191 

192 :return: Active virtual environment path. 

193 :rtype: pathlib.Path 

194 :raises click.ClickException: If ``VIRTUAL_ENV`` is missing. 

195 """ 

196 virtual_env = os.environ.get('VIRTUAL_ENV') 

197 if not virtual_env: 

198 raise click.ClickException('VIRTUAL_ENV is not set. Activate a virtual environment first.') 

199 return pathlib.Path(virtual_env) 

200 

201 

202def completion_script_path(tool_name: str, shell: str) -> pathlib.Path: 

203 """ 

204 Build the completion script path inside the active virtual environment. 

205 

206 :param tool_name: The name of the tool (e.g., 'mafw', 'multiversion-doc', 'release-mgt'). 

207 :type tool_name: str 

208 :param shell: Resolved shell name. 

209 :type shell: str 

210 :return: Target completion script path. 

211 :rtype: pathlib.Path 

212 """ 

213 suffix = {'bash': '.bash', 'zsh': '.zsh', 'fish': '.fish', 'powershell': '.ps1'}[shell] 

214 return _virtualenv_root() / 'share' / 'mafw' / f'{tool_name}_completion{suffix}' 

215 

216 

217def is_script_already_installed(tool_name: str, shell: str) -> bool: 

218 """ 

219 Check if the completion script for the tool is already installed. 

220 

221 :param tool_name: The name of the tool. 

222 :type tool_name: str 

223 :param shell: Resolved shell name. 

224 :type shell: str 

225 :return: True if the completion script exists, False otherwise. 

226 :rtype: bool 

227 """ 

228 return completion_script_path(tool_name, shell).exists() 

229 

230 

231def _activation_script_path(shell: str) -> pathlib.Path: 

232 """ 

233 Build the activation script path for a shell. 

234 

235 :param shell: Resolved shell name. 

236 :type shell: str 

237 :return: Target activation script path. 

238 :rtype: pathlib.Path 

239 """ 

240 if shell == 'powershell': 

241 if sys.platform == 'win32': 

242 return _virtualenv_root() / 'Scripts' / 'Activate.ps1' 

243 return _virtualenv_root() / 'bin' / 'Activate.ps1' 

244 if shell == 'fish': 

245 return _virtualenv_root() / 'bin' / 'activate.fish' 

246 return _virtualenv_root() / 'bin' / 'activate' 

247 

248 

249def completion_source_script(tool_name: str, shell: str) -> str: 

250 """ 

251 Generate the Click completion source script for the requested shell. 

252 

253 :param tool_name: The name of the tool. 

254 :type tool_name: str 

255 :param shell: Resolved shell name. 

256 :type shell: str 

257 :return: Completion script content. 

258 :rtype: str 

259 """ 

260 completion_env = COMPLETION_SHELLS[shell] 

261 # Use underscores and uppercase for the environment variable as Click expects 

262 env_var = f'_{tool_name.upper().replace("-", "_")}_COMPLETE' 

263 return run_stdout([tool_name], env={env_var: completion_env}, quiet=True) 

264 

265 

266def _completion_marker_block(shell: str) -> str: 

267 """ 

268 Build the activation block appended to the environment activation script. 

269 

270 This block executes all files in `$VIRTUAL_ENV/share/mafw/*_completion.<ext>`. 

271 

272 :param shell: Resolved shell name. 

273 :type shell: str 

274 :return: Marker block to append to the activation file. 

275 :rtype: str 

276 """ 

277 if shell == 'powershell': 

278 return ( 

279 '# >>> MAFw completion >>>\n' 

280 'foreach ($file in Get-ChildItem "$env:VIRTUAL_ENV/share/mafw/*_completion.ps1"' 

281 ' -ErrorAction SilentlyContinue) {\n' 

282 ' if (Test-Path $file) {\n' 

283 ' . $file\n' 

284 ' }\n' 

285 '}\n' 

286 '# <<< MAFw completion <<<\n' 

287 ) 

288 if shell == 'fish': 

289 return ( 

290 '# >>> MAFw completion >>>\n' 

291 'for file in "$VIRTUAL_ENV/share/mafw/"*_completion.fish\n' 

292 ' if test -f "$file"\n' 

293 ' source "$file"\n' 

294 ' end\n' 

295 'end\n' 

296 '# <<< MAFw completion <<<\n' 

297 ) 

298 return ( 

299 '# >>> MAFw completion >>>\n' 

300 'case "$SHELL" in\n' 

301 ' *zsh*) ext="zsh" ;;\n' 

302 ' *) ext="bash" ;;\n' 

303 'esac\n' 

304 'for file in "$VIRTUAL_ENV/share/mafw/"*_completion."$ext"; do\n' 

305 ' if [ -f "$file" ]; then\n' 

306 ' . "$file"\n' 

307 ' fi\n' 

308 'done\n' 

309 '# <<< MAFw completion <<<\n' 

310 ) 

311 

312 

313def _strip_completion_marker_block(content: str) -> str: 

314 """ 

315 Remove the completion block delimited by the MAFw markers. 

316 

317 :param content: Activation script content. 

318 :type content: str 

319 :return: Content without the MAFw completion block. 

320 :rtype: str 

321 """ 

322 pattern = re.compile(r'\n?# >>> MAFw completion >>>\n.*?\n# <<< MAFw completion <<<\n?', re.S) 

323 return pattern.sub('\n', content) 

324 

325 

326def uninstall_completion_files(tool_name: str, shell: str | None = None) -> None: 

327 """ 

328 Remove installed completion files and activation hooks. 

329 

330 :param tool_name: The name of the tool. 

331 :type tool_name: str 

332 :param shell: Optional shell selector. When omitted, all completion files for the tool are removed. 

333 :type shell: str | None 

334 """ 

335 virtual_env = _virtualenv_root() 

336 share_dir = virtual_env / 'share' / 'mafw' 

337 

338 if share_dir.exists(): 

339 if shell is None: 

340 targets = list(share_dir.glob(f'{tool_name}_completion.*')) 

341 else: 

342 targets = [completion_script_path(tool_name, shell)] 

343 

344 for path in targets: 

345 if path.exists(): 

346 path.unlink() 

347 

348 # Check if any other completion files remain 

349 remaining = list(share_dir.glob('*_completion.*')) if share_dir.exists() else [] 

350 

351 if not remaining: 

352 activation_scripts = [ 

353 virtual_env / 'bin' / 'activate', 

354 virtual_env / 'bin' / 'activate.fish', 

355 ] 

356 if sys.platform == 'win32': 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 activation_scripts.append(virtual_env / 'Scripts' / 'Activate.ps1') 

358 else: 

359 activation_scripts.append(virtual_env / 'bin' / 'Activate.ps1') 

360 

361 for activation_path in activation_scripts: 

362 if not activation_path.exists(): 

363 continue 

364 content = activation_path.read_text(encoding='utf-8') 

365 updated = _strip_completion_marker_block(content) 

366 if updated != content: 

367 activation_path.write_text(updated.lstrip('\n'), encoding='utf-8') 

368 

369 

370def install_completion(tool_name: str, shell: str, force: bool, script_path: pathlib.Path) -> pathlib.Path: 

371 """ 

372 Install Click completion for the requested shell. 

373 

374 :param tool_name: The name of the tool. 

375 :type tool_name: str 

376 :param shell: Resolved shell name. 

377 :type shell: str 

378 :param force: Reinstall even if completion is already loaded. 

379 :type force: bool 

380 :param script_path: The target path for the completion script. 

381 :type script_path: pathlib.Path 

382 :return: Installed completion script path. 

383 :rtype: pathlib.Path 

384 """ 

385 if force: 

386 uninstall_completion_files(tool_name, shell) 

387 

388 script_path.parent.mkdir(parents=True, exist_ok=True) 

389 activation_path = _activation_script_path(shell) 

390 

391 script_text = completion_source_script(tool_name, shell) 

392 script_path.write_text(script_text, encoding='utf-8') 

393 

394 activation_path.parent.mkdir(parents=True, exist_ok=True) 

395 activation_content = activation_path.read_text(encoding='utf-8') if activation_path.exists() else '' 

396 # Add marker block if not present 

397 if '# >>> MAFw completion >>>' not in activation_content: 

398 activation_content = activation_content.rstrip('\n') + '\n\n' + _completion_marker_block(shell) 

399 activation_path.write_text(activation_content, encoding='utf-8') 

400 

401 return script_path