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

Add mutually exclusive group #422

Merged
merged 12 commits into from
Aug 16, 2024
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
Version UNRELEASED
-------------

**Development**
- Add support for mutually exclusive groups on argument parser. [PR #422](https://github.com/codemagic-ci-cd/cli-tools/pull/422)


Version 0.53.3
-------------

Expand Down
69 changes: 64 additions & 5 deletions doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from typing import NamedTuple
from typing import Optional
from typing import Sequence
from typing import Tuple

from mdutils.mdutils import MdUtils
from mdutils.tools.Table import Table
Expand All @@ -25,6 +26,7 @@

from codemagic import cli
from codemagic import tools
from codemagic.cli import MutuallyExclusiveGroup


class SerializedArgument(NamedTuple):
Expand All @@ -38,6 +40,7 @@ class SerializedArgument(NamedTuple):
nargs: bool
choices: str
store_boolean: bool
mutually_exclusive_group: Optional[MutuallyExclusiveGroup] = None


class Action(NamedTuple):
Expand Down Expand Up @@ -69,6 +72,7 @@ def __init__(self, raw_arguments: Sequence[cli.Argument]):
self.required_args: List[SerializedArgument] = []
self.optional_args: List[SerializedArgument] = []
self.custom_args: Dict[str, List[SerializedArgument]] = defaultdict(list)
self.mutually_exclusive_group_args: List[SerializedArgument] = []

@classmethod
def _replace_quotes(cls, description: str) -> str:
Expand All @@ -95,22 +99,30 @@ def _serialize_argument(self, arg) -> SerializedArgument:
description = self._replace_quotes(description)

kwargs = self._proccess_kwargs(getattr(arg._value_, "argparse_kwargs"))
mutually_exclusive_group = (
arg._value_.mutually_exclusive_group if arg._value_.mutually_exclusive_group else None
)
priitlatt marked this conversation as resolved.
Show resolved Hide resolved
required = mutually_exclusive_group.required if mutually_exclusive_group else kwargs.required

return SerializedArgument(
key=arg._value_.key,
description=description,
flags=", ".join(getattr(arg._value_, "flags", "")),
name="" if arg_type and arg_type.__name__ == "bool" else arg._name_,
required=kwargs.required,
required=required,
argument_group_name=arg._value_.argument_group_name,
default=kwargs.default,
nargs=kwargs.nargs,
choices=kwargs.choices,
store_boolean=kwargs.store_boolean,
mutually_exclusive_group=mutually_exclusive_group,
)

def serialize(self) -> ArgumentsSerializer:
for argument in map(self._serialize_argument, self.raw_arguments):
if argument.required:
if argument.mutually_exclusive_group:
self.mutually_exclusive_group_args.append(argument)
elif argument.required:
self.required_args.append(argument)
elif argument.argument_group_name:
self.custom_args[argument.argument_group_name].append(argument)
Expand Down Expand Up @@ -167,6 +179,9 @@ def __init__(self, tool, main_dir: str):
self.tool_optional_args = class_args_serializer.optional_args
self.tool_required_args = class_args_serializer.required_args
self.tool_options = self._serialize_default_options(self.tool)
self.tool_serialized_mutually_exclusive_groups = self._serialize_mutually_exclusive_groups(
class_args_serializer.mutually_exclusive_group_args,
)
self.tool_serialized_actions = self._serialize_actions(self.tool)
self.tool_serialized_action_groups = self._serialize_action_groups(self.tool)

Expand All @@ -180,7 +195,13 @@ def generate(self):
self._write_action_group_page(group)

def _write_tool_command_arguments_and_options(self, writer):
writer.write_arguments(f"command `{self.tool_command}`", self.tool_optional_args, self.tool_required_args, {})
writer.write_arguments(
f"command `{self.tool_command}`",
self.tool_optional_args,
self.tool_required_args,
{},
self.tool_serialized_mutually_exclusive_groups,
)
writer.write_options(self.tool_options)

def _write_tool_page(self):
Expand Down Expand Up @@ -220,6 +241,7 @@ def _write_action_page(self, action: Action, action_group: Optional[ActionGroup]
action.optional_args,
action.required_args,
action.custom_args,
{},
)
self._write_tool_command_arguments_and_options(writer)
writer.ensure_empty_line_at_end()
Expand All @@ -236,6 +258,17 @@ def _serialize_action_group(group) -> ActionGroup:

return list(map(_serialize_action_group, tool.list_class_action_groups()))

@classmethod
def _serialize_mutually_exclusive_groups(
cls,
mutually_exclusive_group_args: List[SerializedArgument],
) -> Dict[str, list[SerializedArgument]]:
serialized_mutually_exclusive_groups = defaultdict(list)
for arg in mutually_exclusive_group_args:
if arg.mutually_exclusive_group:
serialized_mutually_exclusive_groups[arg.mutually_exclusive_group.name].append(arg)
return serialized_mutually_exclusive_groups

@classmethod
def _serialize_actions(cls, tool: cli.CliApp, action_group=None) -> List[Action]:
def _serialize_action(action: cli.argument.ActionCallable) -> Action:
Expand Down Expand Up @@ -310,11 +343,28 @@ def _get_opt_common_flags(self) -> str:
def _get_tool_arguments_and_flags(self) -> Iterable[str]:
return map(
self._get_formatted_flag,
[*self.doc_generator.tool_required_args, *self.doc_generator.tool_optional_args],
[
*self.doc_generator.tool_required_args,
*self.doc_generator.tool_serialized_mutually_exclusive_groups.items(),
*self.doc_generator.tool_optional_args,
],
)

def _get_formatted_flag(self, arg_or_group: SerializedArgument | Tuple[str, SerializedArgument]) -> str:
if isinstance(arg_or_group, SerializedArgument):
return self._get_formatted_flag_for_arg(arg_or_group)
flags = ""
for group_name, args in self.doc_generator.tool_serialized_mutually_exclusive_groups.items():
group_flags_list = [self._get_formatted_flag_text(arg) for arg in args]
group_flags_str = " | ".join(group_flags_list)
if args[0].required:
flags += f"({group_flags_str}) "
else:
flags += f"[{group_flags_str}] "
return flags

@classmethod
def _get_formatted_flag(cls, arg: SerializedArgument) -> str:
def _get_formatted_flag_text(cls, arg: SerializedArgument) -> str:
flag = f'{arg.flags.split(",")[0]}'
if arg.store_boolean:
pass
Expand All @@ -324,6 +374,11 @@ def _get_formatted_flag(cls, arg: SerializedArgument) -> str:
flag = f"{flag} STREAM"
elif arg.name:
flag = f"{flag} {arg.name}"
return flag

@classmethod
def _get_formatted_flag_for_arg(cls, arg: SerializedArgument) -> str:
priitlatt marked this conversation as resolved.
Show resolved Hide resolved
flag = cls._get_formatted_flag_text(arg)
return flag if arg.required else f"[{flag}]"


Expand Down Expand Up @@ -397,11 +452,15 @@ def write_arguments(
optional: List[SerializedArgument],
required: List[SerializedArgument],
custom: Dict[str, List[SerializedArgument]],
mutually_exclusive_groups: Dict[str, List[SerializedArgument]],
):
self._write_arguments(self.file, f"Required arguments for {obj}", required)
self._write_arguments(self.file, f"Optional arguments for {obj}", optional)
for group_name, custom_arguments in custom.items():
self._write_arguments(self.file, f"Optional arguments to {group_name}", custom_arguments)
for group_name, args in mutually_exclusive_groups.items():
prefix = "Required" if args[0].mutually_exclusive_group.required else "Optional"
self._write_arguments(self.file, f"{prefix} mutually exclusive arguments for {obj}", args)

def write_options(self, options: List[SerializedArgument]):
self._write_arguments(self.file, "Common options", options)
Expand Down
1 change: 1 addition & 0 deletions src/codemagic/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .argument import ArgumentProperties
from .argument import CommonArgumentTypes
from .argument import EnvironmentArgumentValue
from .argument import MutuallyExclusiveGroup
from .argument import TypedCliArgument
from .cli_app import CliApp
from .cli_app import CliAppException
Expand Down
1 change: 1 addition & 0 deletions src/codemagic/cli/argument/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .argument_formatter import ArgumentFormatter
from .argument_parser_builder import ArgumentParserBuilder
from .argument_properties import ArgumentProperties
from .argument_properties import MutuallyExclusiveGroup
from .common_argument_types import CommonArgumentTypes
from .typed_cli_argument import EnvironmentArgumentValue
from .typed_cli_argument import TypedCliArgument
61 changes: 52 additions & 9 deletions src/codemagic/cli/argument/argument_parser_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
from typing import TYPE_CHECKING
from typing import Dict
from typing import Tuple
from typing import Type

from codemagic.cli.cli_help_formatter import CliHelpFormatter
Expand All @@ -14,6 +15,7 @@
from argparse import _ArgumentGroup as ArgumentGroup
from argparse import _SubParsersAction as SubParsersAction

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

Expand Down Expand Up @@ -112,7 +114,7 @@ def set_default_cli_options(cls, cli_options_parser):
verbose=False,
)

def _get_custom_argument_group(self, group_name) -> ArgumentGroup:
def _get_custom_argument_group(self, group_name: str) -> ArgumentGroup:
try:
argument_group = self._custom_arguments_groups[group_name]
except KeyError:
Expand All @@ -123,16 +125,50 @@ def _get_custom_argument_group(self, group_name) -> ArgumentGroup:
self._custom_arguments_groups[group_name] = argument_group
return argument_group

def _get_argument_group(self, argument) -> ArgumentGroup:
if argument.argument_group_name is None:
if argument.is_required():
argument_group = self._required_arguments
else:
argument_group = self._optional_arguments
def _get_mutually_exclusive_group_texts(self, group: MutuallyExclusiveGroup) -> Tuple[str, str]:
if group.required:
title_prefix, description_prefix, description_verb = "Required", "Exactly", "must"
else:
title_prefix, description_prefix, description_verb = "Optional", "Only", "can"

title = (
f"{title_prefix} mutually exclusive arguments "
f"for command {self._cli_action.action_name} to {Colors.BOLD(group.name)}"
)
description = f"{description_prefix} one of those options {description_verb} be selected"
return title, description

def _get_mutually_exclusive_group(self, group: MutuallyExclusiveGroup) -> ArgumentGroup:
try:
mutually_exclusive_argument_group = self._custom_arguments_groups[group.name]
except KeyError:
title, description = self._get_mutually_exclusive_group_texts(group)
argument_group = self._action_parser.add_argument_group(Colors.UNDERLINE(title), description)
mutually_exclusive_argument_group = argument_group.add_mutually_exclusive_group(required=group.required)
self._custom_arguments_groups[group.name] = mutually_exclusive_argument_group
return mutually_exclusive_argument_group

def _get_argument_group(self, argument) -> ArgumentGroup:
if argument.argument_group_name:
argument_group = self._get_custom_argument_group(argument.argument_group_name)
elif argument.mutually_exclusive_group:
argument_group = self._get_mutually_exclusive_group(argument.mutually_exclusive_group)
elif argument.is_required():
argument_group = self._required_arguments
else:
argument_group = self._optional_arguments

return argument_group

def _setup_cli_app_mutually_exclusive_groups(self) -> Dict[str, ArgumentGroup]:
mutually_exclusive_groups = {}
for argument in self._cli_app.CLASS_ARGUMENTS:
if argument.mutually_exclusive_group:
mutually_exclusive_group = argument.mutually_exclusive_group
group_name = mutually_exclusive_group.name
mutually_exclusive_groups[group_name] = self._get_mutually_exclusive_group(mutually_exclusive_group)
return mutually_exclusive_groups

def _setup_cli_app_options(self):
executable = self._action_parser.prog.split()[0]
tool_required_arguments = self._action_parser.add_argument_group(
Expand All @@ -141,9 +177,16 @@ def _setup_cli_app_options(self):
tool_optional_arguments = self._action_parser.add_argument_group(
Colors.UNDERLINE(f"Optional arguments for {Colors.BOLD(executable)}"),
)
tool_mutually_exclusive_groups = self._setup_cli_app_mutually_exclusive_groups()

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)
if argument.mutually_exclusive_group:
mutually_exclusive_group = tool_mutually_exclusive_groups[argument.mutually_exclusive_group.name]
argument.register(mutually_exclusive_group)
elif argument.is_required():
argument.register(tool_required_arguments)
else:
argument.register(tool_optional_arguments)

def build(self) -> argparse.ArgumentParser:
self.set_default_cli_options(self._action_parser)
Expand Down
6 changes: 6 additions & 0 deletions src/codemagic/cli/argument/argument_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
from typing import Union


class MutuallyExclusiveGroup(NamedTuple):
fran-tirapu marked this conversation as resolved.
Show resolved Hide resolved
name: str
required: bool


class ArgumentProperties(NamedTuple):
key: str
description: str
type: Union[Type, Callable[[str], Any]] = str
flags: Tuple[str, ...] = tuple()
argparse_kwargs: Optional[Dict[str, Any]] = None
argument_group_name: Optional[str] = None
mutually_exclusive_group: Optional[MutuallyExclusiveGroup] = None

@classmethod
def duplicate(cls, template: Union[Tuple, ArgumentProperties], **overwrites) -> ArgumentProperties:
Expand Down
Loading