Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add interface to declare aliases for legacy actions #187

Merged
merged 12 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/codemagic/cli/argument/action_callable.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ class ActionCallable:
arguments: Sequence[Argument]
is_cli_action: bool
action_options: Dict[str, Any]
deprecated_alias: Optional[str]
__name__: str
__call__: Callable
98 changes: 70 additions & 28 deletions src/codemagic/cli/argument/argument_parser_builder.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import argparse
from typing import TYPE_CHECKING
from typing import Dict
Expand All @@ -9,44 +11,81 @@
from .argument_formatter import ArgumentFormatter

if TYPE_CHECKING:
from argparse import _ArgumentGroup as ArgumentGroup
from argparse import _SubParsersAction as SubParsersAction

from codemagic.cli.argument import ActionCallable
from codemagic.cli.cli_app import CliApp


class ArgumentParserBuilder:
def __init__(
self,
cli_app: Type['CliApp'],
cli_action: 'ActionCallable',
parent_parser: argparse._SubParsersAction,
self,
cli_app: Type[CliApp],
cli_action: ActionCallable,
parent_parser: SubParsersAction,
for_deprecated_alias: bool = False,
):
self._cli_app = cli_app
self._cli_action = cli_action
self._action_parser = parent_parser.add_parser(
cli_action.action_name,
formatter_class=CliHelpFormatter,
help=cli_action.__doc__,
description=Colors.BOLD(cli_action.__doc__),
)
self._action_parser = self._create_action_parser(parent_parser, for_deprecated_alias)
self._required_arguments = self._action_parser.add_argument_group(
Colors.UNDERLINE(f'Required arguments for command {Colors.BOLD(cli_action.action_name)}'))
Colors.UNDERLINE(f'Required arguments for command {Colors.BOLD(cli_action.action_name)}'),
)
self._optional_arguments = self._action_parser.add_argument_group(
Colors.UNDERLINE(f'Optional arguments for command {Colors.BOLD(cli_action.action_name)}'))
self._custom_arguments_groups: Dict[str, argparse._ArgumentGroup] = {}
Colors.UNDERLINE(f'Optional arguments for command {Colors.BOLD(cli_action.action_name)}'),
)
self._custom_arguments_groups: Dict[str, ArgumentGroup] = {}

def _create_action_parser(self, parent_parser: SubParsersAction, for_deprecated_alias: bool):
if for_deprecated_alias:
if self._cli_action.deprecated_alias is None:
raise RuntimeError(f'Deprecated alias requested for {self._cli_action.action_name} without alias')
deprecation_message = Colors.YELLOW(Colors.BOLD(f'Deprecated alias for `{self._full_action_name}`.'))
return parent_parser.add_parser(
self._cli_action.deprecated_alias,
formatter_class=CliHelpFormatter,
description=f'{deprecation_message} {Colors.BOLD(self._cli_action.__doc__)}',
)
else:
return parent_parser.add_parser(
self._cli_action.action_name,
formatter_class=CliHelpFormatter,
description=Colors.BOLD(self._cli_action.__doc__),
help=self._cli_action.__doc__,
)

@property
def _full_action_name(self) -> str:
executable = self._cli_app.get_executable_name()
if self._cli_action.action_group:
return f'{executable} {self._cli_action.action_group.name} {self._cli_action.action_name}'
else:
return f'{executable} {self._cli_action.action_name}'

@classmethod
def set_default_cli_options(cls, cli_options_parser):
options_group = cli_options_parser.add_argument_group(Colors.UNDERLINE('Options'))
options_group.add_argument('--log-stream', type=str, default='stderr', choices=['stderr', 'stdout'],
help=f'Log output stream. {ArgumentFormatter.format_default_value("stderr")}')
options_group.add_argument('--no-color', dest='no_color', action='store_true',
help='Do not use ANSI colors to format terminal output')
options_group.add_argument('--version', dest='show_version', action='store_true',
help='Show tool version and exit')
options_group.add_argument('-s', '--silent', dest='enable_logging', action='store_false',
help='Disable log output for commands')
options_group.add_argument('-v', '--verbose', dest='verbose', action='store_true',
help='Enable verbose logging for commands')
options_group.add_argument(
'--log-stream', type=str, default='stderr', choices=['stderr', 'stdout'],
help=f'Log output stream. {ArgumentFormatter.format_default_value("stderr")}',
)
options_group.add_argument(
'--no-color', dest='no_color', action='store_true',
help='Do not use ANSI colors to format terminal output',
)
options_group.add_argument(
'--version', dest='show_version', action='store_true',
help='Show tool version and exit',
)
options_group.add_argument(
'-s', '--silent', dest='enable_logging', action='store_false',
help='Disable log output for commands',
)
options_group.add_argument(
'-v', '--verbose', dest='verbose', action='store_true',
help='Enable verbose logging for commands',
)

options_group.set_defaults(
enable_logging=True,
Expand All @@ -55,17 +94,18 @@ def set_default_cli_options(cls, cli_options_parser):
verbose=False,
)

def _get_custom_argument_group(self, group_name) -> argparse._ArgumentGroup:
def _get_custom_argument_group(self, group_name) -> ArgumentGroup:
try:
argument_group = self._custom_arguments_groups[group_name]
except KeyError:
group_description = Colors.UNDERLINE(
f'Optional arguments for command {self._cli_action.action_name} to {Colors.BOLD(group_name)}')
f'Optional arguments for command {self._cli_action.action_name} to {Colors.BOLD(group_name)}',
)
argument_group = self._action_parser.add_argument_group(group_description)
self._custom_arguments_groups[group_name] = argument_group
return argument_group

def _get_argument_group(self, argument) -> argparse._ArgumentGroup:
def _get_argument_group(self, argument) -> ArgumentGroup:
if argument.argument_group_name is None:
if argument.is_required():
argument_group = self._required_arguments
Expand All @@ -78,9 +118,11 @@ def _get_argument_group(self, argument) -> argparse._ArgumentGroup:
def _setup_cli_app_options(self):
executable = self._action_parser.prog.split()[0]
tool_required_arguments = self._action_parser.add_argument_group(
Colors.UNDERLINE(f'Required arguments for {Colors.BOLD(executable)}'))
Colors.UNDERLINE(f'Required arguments for {Colors.BOLD(executable)}'),
)
tool_optional_arguments = self._action_parser.add_argument_group(
Colors.UNDERLINE(f'Optional arguments for {Colors.BOLD(executable)}'))
Colors.UNDERLINE(f'Optional arguments for {Colors.BOLD(executable)}'),
)
for argument in self._cli_app.CLASS_ARGUMENTS:
argument_group = tool_required_arguments if argument.is_required() else tool_optional_arguments
argument.register(argument_group)
Expand Down
44 changes: 41 additions & 3 deletions src/codemagic/cli/cli_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ def _get_invoked_cli_action(self, args: argparse.Namespace) -> ActionCallable:
action_key = subcommand

cli_actions = {ac.action_name: ac for ac in self.iter_cli_actions(action_group)}
return cli_actions[action_key]
try:
return cli_actions[action_key]
except KeyError:
deprecated_cli_actions = {ac.deprecated_alias: ac for ac in self.iter_deprecated_cli_actions()}
return deprecated_cli_actions[action_key]

def _invoke_action(self, args: argparse.Namespace):
cli_action = self._get_invoked_cli_action(args)
Expand Down Expand Up @@ -280,6 +284,11 @@ def iter_cli_actions(self, action_group: Optional[ActionGroup] = None) -> Iterab
for class_action in self.iter_class_cli_actions(action_group=action_group):
yield getattr(self, class_action.__name__)

def iter_deprecated_cli_actions(self) -> Iterable[ActionCallable]:
for class_action in self.iter_class_cli_actions(include_all=True):
if class_action.deprecated_alias:
yield getattr(self, class_action.__name__)

@classmethod
def _setup_logging(cls, cli_args: argparse.Namespace):
log.initialize_logging(
Expand Down Expand Up @@ -326,6 +335,11 @@ def _setup_cli_options(cls) -> argparse.ArgumentParser:
group_parsers = cls._add_action_group(action_group, action_parsers)
for group_action in cls.iter_class_cli_actions(action_group):
ArgumentParserBuilder(cls, group_action, group_parsers).build()

if group_action.deprecated_alias:
ArgumentParserBuilder(cls, group_action, action_parsers, for_deprecated_alias=True).build()
CliHelpFormatter.suppress_deprecated_action(group_action.deprecated_alias)

else:
main_action: ActionCallable = action_or_group
ArgumentParserBuilder(cls, main_action, action_parsers).build()
Expand Down Expand Up @@ -396,13 +410,17 @@ def action(
*arguments: Argument,
action_group: Optional[ActionGroup] = None,
action_options: Optional[Dict[str, Any]] = None,
deprecated_alias: Optional[str] = None,
) -> Callable[[_Fn], _Fn]:
"""
Decorator to mark that the method is usable from CLI
:param action_name: Name of the CLI parameter
:param arguments: CLI arguments that are required for this method to work
:param action_group: CLI argument group under which this action belongs
:param action_options: Meta information about the action to check whether some conditions are met
:param deprecated_alias: Deprecated name of the action for backwards compatibility.
The action is registered on the root arguments parser with this name
without explicit documentation.
"""

# Ensure that each argument is used exactly once
Expand All @@ -421,17 +439,37 @@ def decorator(func):
func.action_name = action_name
func.arguments = function_cli_arguments
func.is_cli_action = True
func.deprecated_alias = deprecated_alias
func.action_options = action_options or {}

@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
def wrapper(self: CliApp, *args, **kwargs):
if deprecated_alias and deprecated_alias in sys.argv:
_notify_deprecated_action_usage(self, action_name, action_group, deprecated_alias)
return func(self, *args, **kwargs)

return wrapper

return decorator


def _notify_deprecated_action_usage(
cli_app: CliApp,
action_name: str,
action_group: Optional[ActionGroup],
deprecated_alias: str,
):
executable = cli_app.get_executable_name()
name_parts = (executable, action_group.name if action_group else None, action_name)
full_action_name = ' '.join(p for p in name_parts if p)
deprecated_action_name = f'{executable} {deprecated_alias}'
deprecation_message = (
f'Using `{deprecated_action_name}` is deprecated and replaced by equivalent action `{full_action_name}`.\n'
f'Use `{full_action_name}` instead as `{deprecated_action_name}` is subject for removal in future releases.\n'
)
cli_app.echo(Colors.apply(deprecation_message, Colors.YELLOW, Colors.BOLD))


_CliApp = TypeVar('_CliApp', bound=Type[CliApp])


Expand Down
30 changes: 27 additions & 3 deletions src/codemagic/cli/cli_help_formatter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import argparse
import re
import shutil
import sys
from typing import Set

from .colors import Colors


class CliHelpFormatter(argparse.HelpFormatter):
_deprecated_actions: Set[str] = set()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Fix help width
Expand All @@ -14,14 +18,28 @@ def __init__(self, *args, **kwargs):
else:
self._width = sys.maxsize

@classmethod
def suppress_deprecated_action(cls, action_name: str):
cls._deprecated_actions.add(action_name)

def _exclude_deprecated_actions(self, message: str) -> str:
for deprecated_action in self._deprecated_actions:
message = re.sub(f'[^ ]{deprecated_action},?', '', message)
return message

def _format_args(self, *args, **kwargs):
# Set custom color for arguments
fmt = super()._format_args(*args, **kwargs)
return Colors.CYAN(fmt)

def _format_action_invocation(self, action):
# Color optional arguments as blue and mandatory as green
# Omit suppressed actions from help output and color
# optional arguments as blue and mandatory as green
fmt = super()._format_action_invocation(action)

if action.dest == 'action':
fmt = self._exclude_deprecated_actions(fmt)

parts = fmt.split(', ')
color = Colors.BRIGHT_BLUE if action.option_strings else Colors.GREEN
return ', '.join(map(color, parts))
Expand All @@ -32,8 +50,10 @@ def _format_action(self, action):
# To cope with colored starts, the width of the actions is custom-calculated.

# determine the required width and the entry label
help_position = min(self._action_max_length + 2,
self._max_help_position)
help_position = min(
self._action_max_length + 2,
self._max_help_position,
)
help_width = max(self._width - help_position, 11)
action_width = help_position - self._current_indent - 2
action_header = self._format_action_invocation(action)
Expand Down Expand Up @@ -76,3 +96,7 @@ def _format_action(self, action):

# return a single string
return self._join_parts(parts)

def _format_usage(self, *args, **kwargs) -> str:
usage = super()._format_usage(*args, **kwargs)
return self._exclude_deprecated_actions(usage)