# Copyright 2025–2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Reusable Click group classes for MAFw command-line interfaces.
This module centralizes the command abbreviation behavior used by the
``mafw`` executable and the development tools so nested command groups can
inherit the same resolution policy without repeating the ``cls=...``
configuration.
.. versionadded:: 2.2
Authors
-------
Bulgheroni Antonio <antonio.bulgheroni@ec.europa.eu>
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any
import click
from click.exceptions import ClickException
if TYPE_CHECKING:
pass
[docs]
class AbbreviateGroup(click.Group):
"""Click group that resolves unique command prefixes."""
group_class: type[click.Group] = click.Group
[docs]
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
"""Return a command by exact name or a unique abbreviation."""
# let's give it a try to the cmd_name. if the user provided the full command,
# then we might be lucky and get it working right away, otherwise we will have to try
# to get the command using the abbreviations.
rv = super().get_command(ctx, cmd_name)
if rv is not None:
return rv
matches = [name for name in self.list_commands(ctx) if name.startswith(cmd_name)]
if not matches:
return None
if len(matches) == 1:
return click.Group.get_command(self, ctx, matches[0])
ctx.fail(f'Too many matches: {", ".join(sorted(matches))}')
[docs]
def resolve_command(
self, ctx: click.Context, args: list[str]
) -> tuple[str | None, click.Command | None, list[str]]:
"""Return the canonical command name for abbreviated commands."""
_, cmd, args = super().resolve_command(ctx, args)
if TYPE_CHECKING:
assert isinstance(cmd, click.Command)
return cmd.name if cmd is not None else None, cmd, args
AbbreviateGroup.group_class = AbbreviateGroup
[docs]
class MAFwGroup(AbbreviateGroup):
"""Click group with abbreviation and MAFw-specific exit handling."""
group_class: type[click.Group] = AbbreviateGroup
[docs]
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
"""Parse arguments and record the selected root command before invoke."""
rv = super().parse_args(ctx, args)
if (
ctx.parent is None
): # this is assuring that we store only the first command corresponding to the root context
ctx.meta['_mafw_invoked_subcommand'] = self._detect_command_name(args)
return rv
[docs]
def _detect_command_name(self, args: list[str]) -> str | None:
"""Return the first subcommand token while skipping root option values."""
# This is probably an overkill. After the default parsing the first element in args should be the first
# (abbreviated or fully typed) command. All global options will be removed from args.
# so potentially we could return self.get_command(ctx, args[0]),
# I will leave it like this because it might be useful in the future.
option_params = {opt for param in self.params for opt in getattr(param, 'opts', [])}
expects_value = {
opt for param in self.params if not getattr(param, 'is_flag', False) for opt in getattr(param, 'opts', [])
}
skip_next = False
for token in args:
if skip_next:
skip_next = False
continue
if token in option_params:
if token in expects_value:
skip_next = True
continue
if token.startswith('-'):
continue
selected = self.get_command(click.Context(self), token)
return selected.name if selected is not None else token
return None
[docs]
def invoke(self, ctx: click.Context) -> Any:
"""Invoke the command and normalize Click exceptions to MAFw exits."""
from mafw.scripts.mafw_exe import print_banner
print_banner(ctx)
try:
return super().invoke(ctx)
except ClickException as exc:
exc.show()
sys.exit(exc.exit_code)
except (SystemExit, click.exceptions.Exit):
raise
except Exception:
from mafw.scripts.mafw_exe import ReturnValue
sys.exit(ReturnValue.Error)
[docs]
def main(self, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
"""Run the CLI and convert returned statuses to process exit codes."""
from mafw.scripts.mafw_exe import ReturnValue
try:
rv = super().main(*args, standalone_mode=False, **kwargs) # type: ignore[call-overload]
if isinstance(rv, (ReturnValue, int)):
sys.exit(rv)
sys.exit(ReturnValue.OK)
except ClickException as exc:
exc.show()
sys.exit(exc.exit_code)
except SystemExit:
raise
except Exception:
sys.exit(ReturnValue.Error)