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
« 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.
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.
13.. versionadded:: 2.2
15Authors
16-------
17Bulgheroni Antonio <antonio.bulgheroni@ec.europa.eu>
18"""
20from __future__ import annotations
22import collections.abc as cabc
23import os
24import pathlib
25import re
26import sys
27import warnings
28from typing import TYPE_CHECKING, Any
30import click
32from mafw.tools.shell_tools import CONSOLE, run_stdout
34if TYPE_CHECKING:
35 pass
38class DeprecatedOption(click.Option):
39 """A Click Option subclass that emits a DeprecationWarning when explicitly used.
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.
47 :param deprecated_message: Warning text emitted when the option is explicitly
48 provided on the command line.
49 :type deprecated_message: str
51 .. versionadded:: 2.2
52 """
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}'
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.
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
78class AbbreviateGroup(click.Group):
79 """Click group that resolves unique command prefixes and catches DevtoolsError."""
81 group_class: type[click.Group] = click.Group
83 def invoke(self, ctx: click.Context) -> Any:
84 """Invoke the group, catching DevtoolsError and converting to ClickException."""
85 from mafw.devtools import DevtoolsError
87 try:
88 return super().invoke(ctx)
89 except DevtoolsError as exc:
90 raise click.ClickException(str(exc)) from exc
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
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))}')
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
118AbbreviateGroup.group_class = AbbreviateGroup
121COMPLETION_SHELLS: dict[str, str] = {
122 'bash': 'bash_source',
123 'zsh': 'zsh_source',
124 'fish': 'fish_source',
125 'powershell': 'powershell_source',
126}
129def check_ci_completion_guard() -> None:
130 """
131 Check if the current environment is a CI environment.
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)
141def completion_shell_from_env(shell_path: str | None) -> str:
142 """
143 Resolve a completion shell from ``$SHELL``.
145 The resolver supports the shells handled by Click completion generation:
146 ``bash``, ``zsh``, ``fish``, and ``powershell`` (including ``pwsh``).
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
170def resolve_completion_shell(shell: str) -> str:
171 """
172 Normalize the requested completion shell.
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
188def _virtualenv_root() -> pathlib.Path:
189 """
190 Return the active virtual environment root.
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)
202def completion_script_path(tool_name: str, shell: str) -> pathlib.Path:
203 """
204 Build the completion script path inside the active virtual environment.
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}'
217def is_script_already_installed(tool_name: str, shell: str) -> bool:
218 """
219 Check if the completion script for the tool is already installed.
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()
231def _activation_script_path(shell: str) -> pathlib.Path:
232 """
233 Build the activation script path for a shell.
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'
249def completion_source_script(tool_name: str, shell: str) -> str:
250 """
251 Generate the Click completion source script for the requested shell.
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)
266def _completion_marker_block(shell: str) -> str:
267 """
268 Build the activation block appended to the environment activation script.
270 This block executes all files in `$VIRTUAL_ENV/share/mafw/*_completion.<ext>`.
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 )
313def _strip_completion_marker_block(content: str) -> str:
314 """
315 Remove the completion block delimited by the MAFw markers.
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)
326def uninstall_completion_files(tool_name: str, shell: str | None = None) -> None:
327 """
328 Remove installed completion files and activation hooks.
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'
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)]
344 for path in targets:
345 if path.exists():
346 path.unlink()
348 # Check if any other completion files remain
349 remaining = list(share_dir.glob('*_completion.*')) if share_dir.exists() else []
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')
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')
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.
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)
388 script_path.parent.mkdir(parents=True, exist_ok=True)
389 activation_path = _activation_script_path(shell)
391 script_text = completion_source_script(tool_name, shell)
392 script_path.write_text(script_text, encoding='utf-8')
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')
401 return script_path