diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ab4d2cf..368a487e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,10 @@ prompt is displayed. before calling it like the previous functions did. - Removed `Cmd.default_to_shell`. - Removed `Cmd.ruler` since `cmd2` no longer uses it. + - All parsers used with `cmd2` commands must be an instance of `Cmd2ArgumentParser` or a child + class of it. + - Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is + now a public member of `Cmd2ArgumentParser`. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index fabb2b9ee..0d32a2a32 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -22,18 +22,11 @@ cast, ) -from rich.text import Text - -from .constants import INFINITY -from .rich_utils import Cmd2SimpleTable - -if TYPE_CHECKING: # pragma: no cover - from .cmd2 import Cmd - from rich.table import Column +from rich.text import Text from .argparse_custom import ( - ChoicesCallable, + Cmd2ArgumentParser, generate_range_error, ) from .command_definition import CommandSet @@ -42,14 +35,25 @@ Completions, all_display_numeric, ) +from .constants import INFINITY from .exceptions import CompletionError +from .rich_utils import Cmd2SimpleTable +from .types import ( + ChoicesProviderUnbound, + CmdOrSet, + CompleterUnbound, +) + +if TYPE_CHECKING: # pragma: no cover + from .cmd2 import Cmd + # Name of the choice/completer function argument that, if present, will be passed a dictionary of # command line tokens up through the token being completed mapped to their argparse destination name. ARG_TOKENS = 'arg_tokens' -def _build_hint(parser: argparse.ArgumentParser, arg_action: argparse.Action) -> str: +def _build_hint(parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> str: """Build completion hint for a given argument.""" # Check if hinting is disabled for this argument suppress_hint = arg_action.get_suppress_tab_hint() # type: ignore[attr-defined] @@ -64,12 +68,12 @@ def _build_hint(parser: argparse.ArgumentParser, arg_action: argparse.Action) -> return formatter.format_help() -def _single_prefix_char(token: str, parser: argparse.ArgumentParser) -> bool: +def _single_prefix_char(token: str, parser: Cmd2ArgumentParser) -> bool: """Is a token just a single flag prefix character.""" return len(token) == 1 and token[0] in parser.prefix_chars -def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: +def _looks_like_flag(token: str, parser: Cmd2ArgumentParser) -> bool: """Determine if a token looks like a flag. Unless an argument has nargs set to argparse.REMAINDER, then anything that looks like a flag @@ -140,12 +144,12 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None: class _NoResultsError(CompletionError): - def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: + def __init__(self, parser: Cmd2ArgumentParser, arg_action: argparse.Action) -> None: """CompletionError which occurs when there are no results. If hinting is allowed on this argument, then its hint text will display. - :param parser: ArgumentParser instance which owns the action being completed + :param parser: Cmd2ArgumentParser instance which owns the action being completed :param arg_action: action being completed. """ # Set apply_style to False because we don't want hints to look like errors @@ -157,14 +161,14 @@ class ArgparseCompleter: def __init__( self, - parser: argparse.ArgumentParser, + parser: Cmd2ArgumentParser, cmd2_app: 'Cmd', *, parent_tokens: Mapping[str, MutableSequence[str]] | None = None, ) -> None: """Create an ArgparseCompleter. - :param parser: ArgumentParser instance + :param parser: Cmd2ArgumentParser instance :param cmd2_app: reference to the Cmd2 application that owns this ArgparseCompleter :param parent_tokens: optional Mapping of parent parsers' arg names to their tokens This is only used by ArgparseCompleter when recursing on subcommand parsers @@ -187,7 +191,7 @@ def __init__( self._positional_actions: list[argparse.Action] = [] # This will be set if self._parser has subcommands - self._subcommand_action: argparse._SubParsersAction[argparse.ArgumentParser] | None = None + self._subcommand_action: argparse._SubParsersAction[Cmd2ArgumentParser] | None = None # Start digging through the argparse structures. # _actions is the top level container of parameter definitions @@ -707,33 +711,32 @@ def print_help(self, tokens: Sequence[str], file: IO[str] | None = None) -> None return self._parser.print_help(file=file) - def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | ChoicesCallable | None: - """Extract choices from action or return the choices_callable.""" - if arg_state.action.choices is not None: - # If choices are subcommands, then get their help text to populate display_meta. - if isinstance(arg_state.action, argparse._SubParsersAction): - parser_help = {} - for action in arg_state.action._choices_actions: - if action.dest in arg_state.action.choices: - subparser = arg_state.action.choices[action.dest] - parser_help[subparser] = action.help or '' - - return [ - CompletionItem(name, display_meta=parser_help.get(subparser, '')) - for name, subparser in arg_state.action.choices.items() - ] - - # Standard choices + def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]: + """Convert choices from action to list of CompletionItems.""" + if arg_state.action.choices is None: + return [] + + # If choices are subcommands, then get their help text to populate display_meta. + if isinstance(arg_state.action, argparse._SubParsersAction): + parser_help = {} + for action in arg_state.action._choices_actions: + if action.dest in arg_state.action.choices: + subparser = arg_state.action.choices[action.dest] + parser_help[subparser] = action.help or '' + return [ - choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices + CompletionItem(name, display_meta=parser_help.get(subparser, '')) + for name, subparser in arg_state.action.choices.items() ] - choices_callable: ChoicesCallable | None = arg_state.action.get_choices_callable() # type: ignore[attr-defined] - return choices_callable + # Standard choices + return [ + choice if isinstance(choice, CompletionItem) else CompletionItem(choice) for choice in arg_state.action.choices + ] def _prepare_callable_params( self, - choices_callable: ChoicesCallable, + to_call: ChoicesProviderUnbound[CmdOrSet] | CompleterUnbound[CmdOrSet], arg_state: _ArgumentState, text: str, consumed_arg_values: dict[str, list[str]], @@ -744,14 +747,14 @@ def _prepare_callable_params( kwargs: dict[str, Any] = {} # Resolve the 'self' instance for the method - self_arg = self._cmd2_app._resolve_func_self(choices_callable.to_call, cmd_set) + self_arg = self._cmd2_app._resolve_func_self(to_call, cmd_set) if self_arg is None: - raise CompletionError("Could not find CommandSet instance matching defining type for completer") + raise CompletionError("Could not find CommandSet instance matching defining type") args.append(self_arg) # Check if the function expects 'arg_tokens' - to_call_params = inspect.signature(choices_callable.to_call).parameters + to_call_params = inspect.signature(to_call).parameters if ARG_TOKENS in to_call_params: arg_tokens = {**self._parent_tokens, **consumed_arg_values} arg_tokens.setdefault(arg_state.action.dest, []).append(text) @@ -775,26 +778,33 @@ def _complete_arg( :return: a Completions object :raises CompletionError: if the completer or choices function this calls raises one """ - raw_choices = self._get_raw_choices(arg_state) - if not raw_choices: - return Completions() - - # Check if the argument uses a completer function - if isinstance(raw_choices, ChoicesCallable) and raw_choices.is_completer: - args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) + # Check if the argument uses a completer + completer = arg_state.action.get_completer() # type: ignore[attr-defined] + if completer is not None: + args, kwargs = self._prepare_callable_params( + completer, + arg_state, + text, + consumed_arg_values, + cmd_set, + ) args.extend([text, line, begidx, endidx]) - completions = raw_choices.completer(*args, **kwargs) + completions: Completions = completer(*args, **kwargs) - # Otherwise it uses a choices list or choices provider function + # Otherwise it uses a choices provider or choices list else: - all_choices: list[CompletionItem] = [] - - if isinstance(raw_choices, ChoicesCallable): - args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) - choices_func = raw_choices.choices_provider - all_choices = list(choices_func(*args, **kwargs)) + choices_provider = arg_state.action.get_choices_provider() # type: ignore[attr-defined] + if choices_provider is not None: + args, kwargs = self._prepare_callable_params( + choices_provider, + arg_state, + text, + consumed_arg_values, + cmd_set, + ) + all_choices = list(choices_provider(*args, **kwargs)) else: - all_choices = raw_choices + all_choices = self._choices_to_items(arg_state) # Filter used values and run basic completion used_values = consumed_arg_values.get(arg_state.action.dest, []) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 68f970cfa..5711ffb68 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -2,16 +2,9 @@ It also defines a parser class called Cmd2ArgumentParser which improves error and help output over normal argparse. All cmd2 code uses this parser and it is -recommended that developers of cmd2-based apps either use it or write their own -parser that inherits from it. This will give a consistent look-and-feel between -the help/error output of built-in cmd2 commands and the app-specific commands. -If you wish to override the parser used by cmd2's built-in commands, see -custom_parser.py example. - -Since the new capabilities are added by patching at the argparse API level, -they are available whether or not Cmd2ArgumentParser is used. However, the help -and error output of Cmd2ArgumentParser is customized to notate nargs ranges -whereas any other parser class won't be as explicit in their output. +required that developers of cmd2-based apps either use it or write their own +parser that inherits from it. If you wish to override the parser used by cmd2's +built-in commands, see custom_parser.py example. **Added capabilities** @@ -32,7 +25,7 @@ **Completion** cmd2 uses its ArgparseCompleter class to enable argparse-based completion -on all commands that use the @with_argparse wrappers. Out of the box you get +on all commands that use the @with_argparser decorator. Out of the box you get completion of commands, subcommands, and flag names, as well as instructive hints about the current argument that print when tab is pressed. In addition, you can add completion for each argument's values using parameters passed @@ -215,37 +208,20 @@ def get_choices(self) -> Choices: exceeds this number, then a completion table won't be displayed. -**Patched argparse functions** - -``argparse._ActionsContainer.add_argument`` - adds arguments related to tab -completion and enables nargs range parsing. See _add_argument_wrapper for -more details on these arguments. - -``argparse.ArgumentParser._get_nargs_pattern`` - adds support for nargs ranges. -See ``_get_nargs_pattern_wrapper`` for more details. - -``argparse.ArgumentParser._match_argument`` - adds support for nargs ranges. -See ``_match_argument_wrapper`` for more details. +**Custom Argument Parameters** -**Added accessor methods** +``argparse._ActionsContainer.add_argument`` has been patched to support several +custom parameters used for tab completion and nargs range parsing. These +parameters are registered using ``register_argparse_argument_parameter()``. +See ``_ActionsContainer_add_argument`` for more details on these parameters. -cmd2 has patched ``argparse.Action`` to include the following accessor methods -for cases in which you need to manually access the cmd2-specific attributes. +Registering a parameter whitelists it for use in ``add_argument()`` and +automatically adds getter and setter accessor methods to the ``argparse.Action`` +class. For any registered parameter named ````, the following methods are +available on the resulting ``Action`` object to access its underlying attribute: -- ``argparse.Action.get_choices_callable()`` - See ``action_get_choices_callable`` for more details. -- ``argparse.Action.set_choices_provider()`` - See ``_action_set_choices_provider`` for more details. -- ``argparse.Action.set_completer()`` - See ``_action_set_completer`` for more details. -- ``argparse.Action.get_table_columns()`` - See ``_action_get_table_columns`` for more details. -- ``argparse.Action.set_table_columns()`` - See ``_action_set_table_columns`` for more details. -- ``argparse.Action.get_nargs_range()`` - See ``_action_get_nargs_range`` for more details. -- ``argparse.Action.set_nargs_range()`` - See ``_action_set_nargs_range`` for more details. -- ``argparse.Action.get_suppress_tab_hint()`` - See ``_action_get_suppress_tab_hint`` for more details. -- ``argparse.Action.set_suppress_tab_hint()`` - See ``_action_set_suppress_tab_hint`` for more details. - -cmd2 has patched ``argparse.ArgumentParser`` to include the following accessor methods - -- ``argparse.ArgumentParser.get_ap_completer_type()`` - See ``_ArgumentParser_get_ap_completer_type`` for more details. -- ``argparse.Action.set_ap_completer_type()`` - See ``_ArgumentParser_set_ap_completer_type`` for more details. +- ``action.get_()`` +- ``action.set_(value)`` **Subcommand Manipulation** @@ -376,322 +352,101 @@ def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None: req_args.append(action.dest) -class ChoicesCallable: - """Enables using a callable as the choices provider for an argparse argument. - - While argparse has the built-in choices attribute, it is limited to an iterable. - """ - - def __init__( - self, - is_completer: bool, - to_call: ChoicesProviderUnbound[CmdOrSet] | CompleterUnbound[CmdOrSet], - ) -> None: - """Initialize the ChoiceCallable instance. - - :param is_completer: True if to_call is a completion routine which expects - the args: text, line, begidx, endidx - :param to_call: the callable object that will be called to provide choices for the argument. - """ - self.is_completer = is_completer - self.to_call = to_call - - @property - def choices_provider(self) -> ChoicesProviderUnbound[CmdOrSet]: - """Retrieve the internal choices_provider function.""" - if self.is_completer: - raise AttributeError("This instance is configured as a completer, not a choices_provider") - return cast(ChoicesProviderUnbound[CmdOrSet], self.to_call) - - @property - def completer(self) -> CompleterUnbound[CmdOrSet]: - """Retrieve the internal completer function.""" - if not self.is_completer: - raise AttributeError("This instance is configured as a choices_provider, not a completer") - return cast(CompleterUnbound[CmdOrSet], self.to_call) - - ############################################################################################################ -# The following are names of custom argparse Action attributes added by cmd2 +# Allow developers to add custom action attributes ############################################################################################################ -# ChoicesCallable object that specifies the function to be called which provides choices to the argument -ATTR_CHOICES_CALLABLE = 'choices_callable' +# This set should only be edited by calling register_argparse_argument_parameter(). +# Do not manually add or remove items. +_CUSTOM_ACTION_ATTRIBS: set[str] = set() -# Completion table columns -ATTR_TABLE_COLUMNS = 'table_columns' -# A tuple specifying nargs as a range (min, max) -ATTR_NARGS_RANGE = 'nargs_range' +def register_argparse_argument_parameter( + param_name: str, + *, + validator: Callable[[argparse.Action, Any], Any] | None = None, +) -> None: + """Register a custom parameter for argparse.Action and add accessors to the Action class. -# Pressing tab normally displays the help text for the argument if no choices are available -# Setting this attribute to True will suppress these hints -ATTR_SUPPRESS_TAB_HINT = 'suppress_tab_hint' + :param param_name: Name of the parameter. This must be a valid Python identifier. + :param validator: Optional function to validate and/or transform the parameter value. + It accepts the Action instance and the value as arguments. + :raises ValueError: if the parameter name is invalid + :raises KeyError: if the new parameter collides with any existing attributes + """ + if not param_name.isidentifier(): + raise ValueError(f"Invalid parameter name '{param_name}': must be a valid Python identifier") + if param_name in _CUSTOM_ACTION_ATTRIBS: + raise KeyError(f"Custom parameter '{param_name}' is already registered") -############################################################################################################ -# Patch argparse.Action with accessors for choice_callable attribute -############################################################################################################ -def _action_get_choices_callable(self: argparse.Action) -> ChoicesCallable | None: - """Get the choices_callable attribute of an argparse Action. + # Ensure we don't hijack standard argparse.Action attributes or existing methods + if hasattr(argparse.Action, param_name): + raise KeyError(f"'{param_name}' conflicts with an existing attribute on argparse.Action") - This function is added by cmd2 as a method called ``get_choices_callable()`` to ``argparse.Action`` class. + # Check if accessors already exist (e.g., from manual patching or previous registration) + getter_name = f'get_{param_name}' + setter_name = f'set_{param_name}' + if hasattr(argparse.Action, getter_name) or hasattr(argparse.Action, setter_name): + raise KeyError(f"Accessor methods for '{param_name}' already exist on argparse.Action") - To call: ``action.get_choices_callable()`` + # Check for the prefixed internal attribute name collision (e.g., _cmd2_) + attr_name = constants.cmd2_attr_name(param_name) + if hasattr(argparse.Action, attr_name): + raise KeyError(f"The internal attribute '{attr_name}' already exists on argparse.Action") - :param self: argparse Action being queried - :return: A ChoicesCallable instance or None if attribute does not exist - """ - return cast(ChoicesCallable | None, getattr(self, ATTR_CHOICES_CALLABLE, None)) + def _action_get_custom_parameter(self: argparse.Action) -> Any: + """Get the custom attribute of an argparse Action.""" + return getattr(self, attr_name, None) + setattr(argparse.Action, getter_name, _action_get_custom_parameter) -setattr(argparse.Action, 'get_choices_callable', _action_get_choices_callable) + def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: + """Set the custom attribute of an argparse Action.""" + if validator is not None: + value = validator(self, value) + setattr(self, attr_name, value) -def _action_set_choices_callable(self: argparse.Action, choices_callable: ChoicesCallable) -> None: - """Set the choices_callable attribute of an argparse Action. + setattr(argparse.Action, setter_name, _action_set_custom_parameter) - This function is added by cmd2 as a method called ``_set_choices_callable()`` to ``argparse.Action`` class. + _CUSTOM_ACTION_ATTRIBS.add(param_name) - Call this using the convenience wrappers ``set_choices_provider()`` and ``set_completer()`` instead. - :param self: action being edited - :param choices_callable: the ChoicesCallable instance to use - :raises TypeError: if used on incompatible action type - """ - # Verify consistent use of parameters +def _validate_completion_callable(self: argparse.Action, value: Any) -> Any: + """Validate choices_provider and completer values for potential conflicts.""" + if value is None: + return None + if self.choices is not None: err_msg = "None of the following parameters can be used alongside a choices parameter:\nchoices_provider, completer" - raise (TypeError(err_msg)) + raise ValueError(err_msg) if self.nargs == 0: err_msg = ( "None of the following parameters can be used on an action that takes no arguments:\nchoices_provider, completer" ) - raise (TypeError(err_msg)) - - setattr(self, ATTR_CHOICES_CALLABLE, choices_callable) - - -setattr(argparse.Action, '_set_choices_callable', _action_set_choices_callable) - - -def _action_set_choices_provider( - self: argparse.Action, - choices_provider: ChoicesProviderUnbound[CmdOrSet], -) -> None: - """Set choices_provider of an argparse Action. - - This function is added by cmd2 as a method called ``set_choices_callable()`` to ``argparse.Action`` class. - - To call: ``action.set_choices_provider(choices_provider)`` - - :param self: action being edited - :param choices_provider: the choices_provider instance to use - :raises TypeError: if used on incompatible action type - """ - self._set_choices_callable(ChoicesCallable(is_completer=False, to_call=choices_provider)) # type: ignore[attr-defined] - - -setattr(argparse.Action, 'set_choices_provider', _action_set_choices_provider) - - -def _action_set_completer( - self: argparse.Action, - completer: CompleterUnbound[CmdOrSet], -) -> None: - """Set completer of an argparse Action. - - This function is added by cmd2 as a method called ``set_completer()`` to ``argparse.Action`` class. - - To call: ``action.set_completer(completer)`` - - :param self: action being edited - :param completer: the completer instance to use - :raises TypeError: if used on incompatible action type - """ - self._set_choices_callable(ChoicesCallable(is_completer=True, to_call=completer)) # type: ignore[attr-defined] - - -setattr(argparse.Action, 'set_completer', _action_set_completer) - - -############################################################################################################ -# Patch argparse.Action with accessors for table_columns attribute -############################################################################################################ -def _action_get_table_columns(self: argparse.Action) -> Sequence[str | Column] | None: - """Get the table_columns attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_table_columns()`` to ``argparse.Action`` class. - - To call: ``action.get_table_columns()`` - - :param self: argparse Action being queried - :return: The value of table_columns or None if attribute does not exist - """ - return cast(Sequence[str | Column] | None, getattr(self, ATTR_TABLE_COLUMNS, None)) - - -setattr(argparse.Action, 'get_table_columns', _action_get_table_columns) - - -def _action_set_table_columns(self: argparse.Action, table_columns: Sequence[str | Column] | None) -> None: - """Set the table_columns attribute of an argparse Action. - - This function is added by cmd2 as a method called ``set_table_columns()`` to ``argparse.Action`` class. - - To call: ``action.set_table_columns(table_columns)`` - - :param self: argparse Action being updated - :param table_columns: value being assigned - """ - setattr(self, ATTR_TABLE_COLUMNS, table_columns) - - -setattr(argparse.Action, 'set_table_columns', _action_set_table_columns) - - -############################################################################################################ -# Patch argparse.Action with accessors for nargs_range attribute -############################################################################################################ -def _action_get_nargs_range(self: argparse.Action) -> tuple[int, int | float] | None: - """Get the nargs_range attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_nargs_range()`` to ``argparse.Action`` class. - - To call: ``action.get_nargs_range()`` - - :param self: argparse Action being queried - :return: The value of nargs_range or None if attribute does not exist - """ - return cast(tuple[int, int | float] | None, getattr(self, ATTR_NARGS_RANGE, None)) - - -setattr(argparse.Action, 'get_nargs_range', _action_get_nargs_range) - - -def _action_set_nargs_range(self: argparse.Action, nargs_range: tuple[int, int | float] | None) -> None: - """Set the nargs_range attribute of an argparse Action. - - This function is added by cmd2 as a method called ``set_nargs_range()`` to ``argparse.Action`` class. - - To call: ``action.set_nargs_range(nargs_range)`` - - :param self: argparse Action being updated - :param nargs_range: value being assigned - """ - setattr(self, ATTR_NARGS_RANGE, nargs_range) - - -setattr(argparse.Action, 'set_nargs_range', _action_set_nargs_range) - - -############################################################################################################ -# Patch argparse.Action with accessors for suppress_tab_hint attribute -############################################################################################################ -def _action_get_suppress_tab_hint(self: argparse.Action) -> bool: - """Get the suppress_tab_hint attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_suppress_tab_hint()`` to ``argparse.Action`` class. - - To call: ``action.get_suppress_tab_hint()`` - - :param self: argparse Action being queried - :return: The value of suppress_tab_hint or False if attribute does not exist - """ - return cast(bool, getattr(self, ATTR_SUPPRESS_TAB_HINT, False)) - - -setattr(argparse.Action, 'get_suppress_tab_hint', _action_get_suppress_tab_hint) - - -def _action_set_suppress_tab_hint(self: argparse.Action, suppress_tab_hint: bool) -> None: - """Set the suppress_tab_hint attribute of an argparse Action. - - This function is added by cmd2 as a method called ``set_suppress_tab_hint()`` to ``argparse.Action`` class. - - To call: ``action.set_suppress_tab_hint(suppress_tab_hint)`` - - :param self: argparse Action being updated - :param suppress_tab_hint: value being assigned - """ - setattr(self, ATTR_SUPPRESS_TAB_HINT, suppress_tab_hint) + raise ValueError(err_msg) + return value -setattr(argparse.Action, 'set_suppress_tab_hint', _action_set_suppress_tab_hint) +# Add new attributes to argparse.Action. +# See _ActionsContainer_add_argument() for details on these attributes. +register_argparse_argument_parameter('choices_provider', validator=_validate_completion_callable) +register_argparse_argument_parameter('completer', validator=_validate_completion_callable) +register_argparse_argument_parameter('table_columns') +register_argparse_argument_parameter('nargs_range') +register_argparse_argument_parameter('suppress_tab_hint') ############################################################################################################ -# Allow developers to add custom action attributes +# Patch _ActionsContainer.add_argument to support more arguments ############################################################################################################ -CUSTOM_ACTION_ATTRIBS: set[str] = set() -_CUSTOM_ATTRIB_PFX = '_attr_' - - -def register_argparse_argument_parameter(param_name: str, param_type: type[Any] | None) -> None: - """Register a custom argparse argument parameter. - - The registered name will then be a recognized keyword parameter to the parser's `add_argument()` function. - - An accessor functions will be added to the parameter's Action object in the form of: ``get_{param_name}()`` - and ``set_{param_name}(value)``. - - :param param_name: Name of the parameter to add. - :param param_type: Type of the parameter to add. - """ - attr_name = f'{_CUSTOM_ATTRIB_PFX}{param_name}' - if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name): - raise KeyError(f'Custom parameter {param_name} already exists') - if not re.search('^[A-Za-z_][A-Za-z0-9_]*$', param_name): - raise KeyError(f'Invalid parameter name {param_name} - cannot be used as a python identifier') - - getter_name = f'get_{param_name}' - - def _action_get_custom_parameter(self: argparse.Action) -> Any: - """Get the custom attribute of an argparse Action. - - This function is added by cmd2 as a method called ``get_()`` to ``argparse.Action`` class. - - To call: ``action.get_()`` - - :param self: argparse Action being queried - :return: The value of the custom attribute or None if attribute does not exist - """ - return getattr(self, attr_name, None) - - setattr(argparse.Action, getter_name, _action_get_custom_parameter) - - setter_name = f'set_{param_name}' - - def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None: - """Set the custom attribute of an argparse Action. - - This function is added by cmd2 as a method called ``set_()`` to ``argparse.Action`` class. - - To call: ``action.set_()`` - - :param self: argparse Action being updated - :param value: value being assigned - """ - if param_type and not isinstance(value, param_type): - raise TypeError(f'{param_name} must be of type {param_type}, got: {value} ({type(value)})') - setattr(self, attr_name, value) - - setattr(argparse.Action, setter_name, _action_set_custom_parameter) - - CUSTOM_ACTION_ATTRIBS.add(param_name) - - -############################################################################################################ -# Patch _ActionsContainer.add_argument with our wrapper to support more arguments -############################################################################################################ - - -# Save original _ActionsContainer.add_argument so we can call it in our wrapper +# Save original _ActionsContainer.add_argument so we can call it in our patch orig_actions_container_add_argument = argparse._ActionsContainer.add_argument -def _add_argument_wrapper( +def _ActionsContainer_add_argument( # noqa: N802 self: argparse._ActionsContainer, *args: Any, nargs: int | str | tuple[int] | tuple[int, int] | tuple[int, float] | None = None, @@ -701,7 +456,7 @@ def _add_argument_wrapper( table_columns: Sequence[str | Column] | None = None, **kwargs: Any, ) -> argparse.Action: - """Wrap ActionsContainer.add_argument() which supports more settings used by cmd2. + """Patch _ActionsContainer.add_argument() to support cmd2-specific settings. # Args from original function :param self: instance of the _ActionsContainer being added to @@ -732,12 +487,8 @@ def _add_argument_wrapper( :raises ValueError: on incorrect parameter usage """ # Verify consistent use of arguments - choices_callables = [choices_provider, completer] - num_params_set = len(choices_callables) - choices_callables.count(None) - - if num_params_set > 1: - err_msg = "Only one of the following parameters may be used at a time:\nchoices_provider, completer" - raise (ValueError(err_msg)) + if choices_provider is not None and completer is not None: + raise ValueError("Only one of the following parameters may be used at a time:\nchoices_provider, completer") # Pre-process special ranged nargs nargs_range = None @@ -793,24 +544,21 @@ def _add_argument_wrapper( kwargs['nargs'] = nargs_adjusted # Extract registered custom keyword arguments - custom_attribs = {keyword: value for keyword, value in kwargs.items() if keyword in CUSTOM_ACTION_ATTRIBS} + custom_attribs = {keyword: value for keyword, value in kwargs.items() if keyword in _CUSTOM_ACTION_ATTRIBS} for keyword in custom_attribs: del kwargs[keyword] # Create the argument using the original add_argument function new_arg = orig_actions_container_add_argument(self, *args, **kwargs) - # Set the custom attributes + # Set the cmd2-specific attributes new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined] - - if choices_provider: - new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined] - elif completer: - new_arg.set_completer(completer) # type: ignore[attr-defined] - + new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined] + new_arg.set_completer(completer) # type: ignore[attr-defined] new_arg.set_suppress_tab_hint(suppress_tab_hint) # type: ignore[attr-defined] new_arg.set_table_columns(table_columns) # type: ignore[attr-defined] + # Set other registered custom attributes for keyword, value in custom_attribs.items(): attr_setter = getattr(new_arg, f'set_{keyword}', None) if attr_setter is not None: @@ -819,129 +567,8 @@ def _add_argument_wrapper( return new_arg -# Overwrite _ActionsContainer.add_argument with our wrapper -setattr(argparse._ActionsContainer, 'add_argument', _add_argument_wrapper) - -############################################################################################################ -# Patch ArgumentParser._get_nargs_pattern with our wrapper to support nargs ranges -############################################################################################################ - -# Save original ArgumentParser._get_nargs_pattern so we can call it in our wrapper -orig_argument_parser_get_nargs_pattern = argparse.ArgumentParser._get_nargs_pattern - - -def _get_nargs_pattern_wrapper(self: argparse.ArgumentParser, action: argparse.Action) -> str: - # Wrapper around ArgumentParser._get_nargs_pattern behavior to support nargs ranges - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] - if nargs_range: - range_max = '' if nargs_range[1] == constants.INFINITY else nargs_range[1] - nargs_pattern = f'(-*A{{{nargs_range[0]},{range_max}}}-*)' - - # if this is an optional action, -- is not allowed - if action.option_strings: - nargs_pattern = nargs_pattern.replace('-*', '') - nargs_pattern = nargs_pattern.replace('-', '') - return nargs_pattern - - return orig_argument_parser_get_nargs_pattern(self, action) - - -# Overwrite ArgumentParser._get_nargs_pattern with our wrapper -setattr(argparse.ArgumentParser, '_get_nargs_pattern', _get_nargs_pattern_wrapper) - - -############################################################################################################ -# Patch ArgumentParser._match_argument with our wrapper to support nargs ranges -############################################################################################################ -orig_argument_parser_match_argument = argparse.ArgumentParser._match_argument - - -def _match_argument_wrapper(self: argparse.ArgumentParser, action: argparse.Action, arg_strings_pattern: str) -> int: - # Wrapper around ArgumentParser._match_argument behavior to support nargs ranges - nargs_pattern = self._get_nargs_pattern(action) - match = re.match(nargs_pattern, arg_strings_pattern) - - # raise an exception if we weren't able to find a match - if match is None: - nargs_range = action.get_nargs_range() # type: ignore[attr-defined] - if nargs_range is not None: - raise ArgumentError(action, generate_range_error(nargs_range[0], nargs_range[1])) - - return orig_argument_parser_match_argument(self, action, arg_strings_pattern) - - -# Overwrite ArgumentParser._match_argument with our wrapper -setattr(argparse.ArgumentParser, '_match_argument', _match_argument_wrapper) - - -############################################################################################################ -# Patch argparse.ArgumentParser with accessors for ap_completer_type attribute -############################################################################################################ - -# An ArgumentParser attribute which specifies a subclass of ArgparseCompleter for custom completion behavior on a -# given parser. If this is None or not present, then cmd2 will use argparse_completer.DEFAULT_AP_COMPLETER when tab -# completing a parser's arguments -ATTR_AP_COMPLETER_TYPE = 'ap_completer_type' - - -def _ArgumentParser_get_ap_completer_type(self: argparse.ArgumentParser) -> type['ArgparseCompleter'] | None: # noqa: N802 - """Get the ap_completer_type attribute of an argparse ArgumentParser. - - This function is added by cmd2 as a method called ``get_ap_completer_type()`` to ``argparse.ArgumentParser`` class. - - To call: ``parser.get_ap_completer_type()`` - - :param self: ArgumentParser being queried - :return: An ArgparseCompleter-based class or None if attribute does not exist - """ - return cast(type['ArgparseCompleter'] | None, getattr(self, ATTR_AP_COMPLETER_TYPE, None)) - - -setattr(argparse.ArgumentParser, 'get_ap_completer_type', _ArgumentParser_get_ap_completer_type) - - -def _ArgumentParser_set_ap_completer_type(self: argparse.ArgumentParser, ap_completer_type: type['ArgparseCompleter']) -> None: # noqa: N802 - """Set the ap_completer_type attribute of an argparse ArgumentParser. - - This function is added by cmd2 as a method called ``set_ap_completer_type()`` to ``argparse.ArgumentParser`` class. - - To call: ``parser.set_ap_completer_type(ap_completer_type)`` - - :param self: ArgumentParser being edited - :param ap_completer_type: the custom ArgparseCompleter-based class to use when completing arguments for this parser - """ - setattr(self, ATTR_AP_COMPLETER_TYPE, ap_completer_type) - - -setattr(argparse.ArgumentParser, 'set_ap_completer_type', _ArgumentParser_set_ap_completer_type) - - -############################################################################################################ -# Patch ArgumentParser._check_value to support CompletionItems as choices -############################################################################################################ -def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse.Action, value: Any) -> None: # noqa: N802 - """Check_value that supports CompletionItems as choices (Custom override of ArgumentParser._check_value). - - When displaying choices, use CompletionItem.value instead of the CompletionItem instance. - - :param self: ArgumentParser instance - :param action: the action being populated - :param value: value from command line already run through conversion function by argparse - """ - # Import gettext like argparse does - from gettext import ( - gettext as _, - ) - - if action.choices is not None and value not in action.choices: - # If any choice is a CompletionItem, then display its value property. - choices = [c.value if isinstance(c, CompletionItem) else c for c in action.choices] - args = {'value': value, 'choices': ', '.join(map(repr, choices))} - msg = _('invalid choice: %(value)r (choose from %(choices)s)') - raise ArgumentError(action, msg % args) - - -setattr(argparse.ArgumentParser, '_check_value', _ArgumentParser_check_value) +# Overwrite _ActionsContainer.add_argument with our patch +setattr(argparse._ActionsContainer, 'add_argument', _ActionsContainer_add_argument) ############################################################################################################ @@ -1285,7 +912,7 @@ def __init__( self.description: RenderableType | None # type: ignore[assignment] self.epilog: RenderableType | None # type: ignore[assignment] - self.set_ap_completer_type(ap_completer_type) # type: ignore[attr-defined] + self.ap_completer_type = ap_completer_type def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore[type-arg] """Add a subcommand parser. @@ -1332,6 +959,55 @@ def create_text_group(self, title: str, text: RenderableType) -> TextGroup: """Create a TextGroup using this parser's formatter creator.""" return TextGroup(title, text, self._get_formatter) + def _get_nargs_pattern(self, action: argparse.Action) -> str: + """Override to support nargs ranges.""" + nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + if nargs_range: + range_max = '' if nargs_range[1] == constants.INFINITY else nargs_range[1] + nargs_pattern = f'(-*A{{{nargs_range[0]},{range_max}}}-*)' + + # if this is an optional action, -- is not allowed + if action.option_strings: + nargs_pattern = nargs_pattern.replace('-*', '') + nargs_pattern = nargs_pattern.replace('-', '') + return nargs_pattern + + return super()._get_nargs_pattern(action) + + def _match_argument(self, action: argparse.Action, arg_strings_pattern: str) -> int: + """Override to support nargs ranges.""" + nargs_pattern = self._get_nargs_pattern(action) + match = re.match(nargs_pattern, arg_strings_pattern) + + # raise an exception if we weren't able to find a match + if match is None: + nargs_range = action.get_nargs_range() # type: ignore[attr-defined] + if nargs_range is not None: + raise ArgumentError(action, generate_range_error(nargs_range[0], nargs_range[1])) + + return super()._match_argument(action, arg_strings_pattern) + + def _check_value(self, action: argparse.Action, value: Any) -> None: + """Override that supports CompletionItems as choices. + + When displaying choices, use CompletionItem.value instead of the CompletionItem instance. + + :param self: ArgumentParser instance + :param action: the action being populated + :param value: value from command line already run through conversion function by argparse + """ + # Import gettext like argparse does + from gettext import ( + gettext as _, + ) + + if action.choices is not None and value not in action.choices: + # If any choice is a CompletionItem, then display its value property. + choices = [c.value if isinstance(c, CompletionItem) else c for c in action.choices] + args = {'value': value, 'choices': ', '.join(map(repr, choices))} + msg = _('invalid choice: %(value)r (choose from %(choices)s)') + raise ArgumentError(action, msg % args) + class Cmd2AttributeWrapper: """Wraps a cmd2-specific attribute added to an argparse Namespace. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 786417814..9181f01e1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -62,7 +62,6 @@ TYPE_CHECKING, Any, TextIO, - TypeAlias, TypeVar, Union, cast, @@ -206,8 +205,8 @@ def __init__(self, msg: str = '') -> None: ) if TYPE_CHECKING: # pragma: no cover - StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser] - ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser] + StaticArgParseBuilder = staticmethod[[], Cmd2ArgumentParser] + ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], Cmd2ArgumentParser] from prompt_toolkit.buffer import Buffer else: StaticArgParseBuilder = staticmethod @@ -237,7 +236,7 @@ def __init__(self, cmd: 'Cmd') -> None: # Keyed by the fully qualified method names. This is more reliable than # the methods themselves, since wrapping a method will change its address. - self._parsers: dict[str, argparse.ArgumentParser] = {} + self._parsers: dict[str, Cmd2ArgumentParser] = {} @staticmethod def _fully_qualified_name(command_method: CommandFunc) -> str: @@ -256,7 +255,7 @@ def __contains__(self, command_method: CommandFunc) -> bool: parser = self.get(command_method) return bool(parser) - def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None: + def get(self, command_method: CommandFunc) -> Cmd2ArgumentParser | None: """Return a given method's parser or None if the method is not argparse-based. If the parser does not yet exist, it will be created. @@ -889,32 +888,38 @@ def register_command_set(self, cmdset: CommandSet) -> None: def _build_parser( self, parent: CmdOrSet, - parser_builder: argparse.ArgumentParser - | Callable[[], argparse.ArgumentParser] - | StaticArgParseBuilder - | ClassArgParseBuilder, + parser_builder: Cmd2ArgumentParser | Callable[[], Cmd2ArgumentParser] | StaticArgParseBuilder | ClassArgParseBuilder, prog: str, - ) -> argparse.ArgumentParser: + ) -> Cmd2ArgumentParser: """Build argument parser for a command/subcommand. :param parent: object which owns the command using the parser. When parser_builder is a classmethod, this function passes parent's class to it. - :param parser_builder: means used to build the parser + :param parser_builder: an existing Cmd2ArgumentParser instance or a factory + (callable, staticmethod, or classmethod) that returns one. :param prog: prog value to set in new parser :return: new parser - :raises TypeError: if parser_builder is invalid type + :raises TypeError: if parser_builder is an invalid type or if the factory fails + to return a Cmd2ArgumentParser """ - if isinstance(parser_builder, staticmethod): - parser = parser_builder.__func__() - elif isinstance(parser_builder, classmethod): - parser = parser_builder.__func__(parent.__class__) - elif callable(parser_builder): - parser = parser_builder() - elif isinstance(parser_builder, argparse.ArgumentParser): + if isinstance(parser_builder, Cmd2ArgumentParser): parser = copy.deepcopy(parser_builder) else: - raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") + # Try to build the parser with a factory + if isinstance(parser_builder, staticmethod): + parser = parser_builder.__func__() + elif isinstance(parser_builder, classmethod): + parser = parser_builder.__func__(parent.__class__) + elif callable(parser_builder): + parser = parser_builder() + else: + raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") + + # Verify the factory returned the required type + if not isinstance(parser, Cmd2ArgumentParser): + builder_name = getattr(parser_builder, "__name__", str(parser_builder)) # type: ignore[unreachable] + raise TypeError(f"The parser returned by '{builder_name}' must be a Cmd2ArgumentParser or a subclass of it") argparse_custom.set_parser_prog(parser, prog) @@ -1021,7 +1026,7 @@ def unregister_command_set(self, cmdset: CommandSet) -> None: self._installed_command_sets.remove(cmdset) def _check_uninstallable(self, cmdset: CommandSet) -> None: - def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None: + def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: cmdset_id = id(cmdset) for action in parser._actions: if isinstance(action, argparse._SubParsersAction): @@ -1098,9 +1103,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) - def find_subcommand( - action: argparse.ArgumentParser, subcmd_names: MutableSequence[str] - ) -> argparse.ArgumentParser: + def find_subcommand(action: Cmd2ArgumentParser, subcmd_names: MutableSequence[str]) -> Cmd2ArgumentParser: if not subcmd_names: return action cur_subcmd = subcmd_names.pop(0) @@ -2349,19 +2352,16 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com return compfunc(text, line, begidx, endidx) @staticmethod - def _determine_ap_completer_type(parser: argparse.ArgumentParser) -> type[argparse_completer.ArgparseCompleter]: + def _determine_ap_completer_type(parser: Cmd2ArgumentParser) -> type[argparse_completer.ArgparseCompleter]: """Determine what type of ArgparseCompleter to use on a given parser. If the parser does not have one set, then use argparse_completer.DEFAULT_AP_COMPLETER. :param parser: the parser to examine :return: type of ArgparseCompleter """ - APCompleterType: TypeAlias = type[argparse_completer.ArgparseCompleter] | None - completer_type: APCompleterType = parser.get_ap_completer_type() # type: ignore[attr-defined] - - if completer_type is None: - completer_type = argparse_completer.DEFAULT_AP_COMPLETER - return completer_type + if parser.ap_completer_type is None: + return argparse_completer.DEFAULT_AP_COMPLETER + return parser.ap_completer_type def _perform_completion( self, text: str, line: str, begidx: int, endidx: int, custom_settings: utils.CustomCompletionSettings | None = None @@ -2725,8 +2725,8 @@ def get_help_topics(self) -> list[str]: def sigint_handler( self, - signum: int, # noqa: ARG002, - frame: FrameType | None, # noqa: ARG002, + signum: int, # noqa: ARG002 + frame: FrameType | None, # noqa: ARG002 ) -> None: """Signal handler for SIGINTs which typically come from Ctrl-C events. @@ -3455,7 +3455,7 @@ def _resolve_completer( choices: Iterable[Any] | None = None, choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, completer: CompleterUnbound[CmdOrSet] | None = None, - parser: argparse.ArgumentParser | None = None, + parser: Cmd2ArgumentParser | None = None, ) -> Completer: """Determine the appropriate completer based on provided arguments.""" if not any((parser, choices, choices_provider, completer)): @@ -3487,7 +3487,7 @@ def read_input( choices: Iterable[Any] | None = None, choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, completer: CompleterUnbound[CmdOrSet] | None = None, - parser: argparse.ArgumentParser | None = None, + parser: Cmd2ArgumentParser | None = None, ) -> str: """Read a line of input with optional completion and history. diff --git a/cmd2/constants.py b/cmd2/constants.py index 75c60662c..91497d86b 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -31,23 +31,36 @@ # All command completer functions start with this COMPLETER_FUNC_PREFIX = 'complete_' +# Prefix for private attributes injected by cmd2 +CMD2_ATTR_PREFIX = '_cmd2_' + + +def cmd2_attr_name(name: str) -> str: + """Build an attribute name with the cmd2 prefix. + + :param name: the name of the attribute + :return: the prefixed attribute name + """ + return f'{CMD2_ATTR_PREFIX}{name}' + + # The custom help category a command belongs to -CMD_ATTR_HELP_CATEGORY = 'help_category' -CLASS_ATTR_DEFAULT_HELP_CATEGORY = 'cmd2_default_help_category' +CMD_ATTR_HELP_CATEGORY = cmd2_attr_name('help_category') +CLASS_ATTR_DEFAULT_HELP_CATEGORY = cmd2_attr_name('default_help_category') # The argparse parser for the command -CMD_ATTR_ARGPARSER = 'argparser' +CMD_ATTR_ARGPARSER = cmd2_attr_name('argparser') # Whether or not tokens are unquoted before sending to argparse -CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' +CMD_ATTR_PRESERVE_QUOTES = cmd2_attr_name('preserve_quotes') # subcommand attributes for the base command name and the subcommand name -SUBCMD_ATTR_COMMAND = 'parent_command' -SUBCMD_ATTR_NAME = 'subcommand_name' -SUBCMD_ATTR_ADD_PARSER_KWARGS = 'subcommand_add_parser_kwargs' +SUBCMD_ATTR_COMMAND = cmd2_attr_name('parent_command') +SUBCMD_ATTR_NAME = cmd2_attr_name('subcommand_name') +SUBCMD_ATTR_ADD_PARSER_KWARGS = cmd2_attr_name('subcommand_add_parser_kwargs') -# arpparse attribute uniquely identifying the command set instance -PARSER_ATTR_COMMANDSET_ID = 'command_set_id' +# argparse attribute uniquely identifying the command set instance +PARSER_ATTR_COMMANDSET_ID = cmd2_attr_name('command_set_id') # custom attributes added to argparse Namespaces -NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__' +NS_ATTR_SUBCMD_HANDLER = cmd2_attr_name('subcmd_handler') diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 5054d91f6..c2c8b32c0 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -13,7 +13,10 @@ ) from . import constants -from .argparse_custom import Cmd2AttributeWrapper +from .argparse_custom import ( + Cmd2ArgumentParser, + Cmd2AttributeWrapper, +) from .command_definition import ( CommandFunc, CommandSet, @@ -184,19 +187,19 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: return arg_decorator -#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input #: and optionally return a boolean ArgparseCommandFuncOptionalBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool | None] ArgparseCommandFuncWithUnknownArgsOptionalBoolReturn: TypeAlias = Callable[ [CmdOrSet, argparse.Namespace, list[str]], bool | None ] -#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input #: and return a boolean ArgparseCommandFuncBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], bool] ArgparseCommandFuncWithUnknownArgsBoolReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], bool] -#: Function signatures for command functions that use an argparse.ArgumentParser to process user input +#: Function signatures for command functions that use a Cmd2ArgumentParser to process user input #: and return nothing ArgparseCommandFuncNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace], None] ArgparseCommandFuncWithUnknownArgsNoneReturn: TypeAlias = Callable[[CmdOrSet, argparse.Namespace, list[str]], None] @@ -213,17 +216,17 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: def with_argparser( - parser: argparse.ArgumentParser # existing parser - | Callable[[], argparse.ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClass], argparse.ArgumentParser], # Cmd or CommandSet classmethod + parser: Cmd2ArgumentParser # existing parser + | Callable[[], Cmd2ArgumentParser] # function or staticmethod + | Callable[[CmdOrSetClass], Cmd2ArgumentParser], # Cmd or CommandSet classmethod *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, ) -> Callable[[ArgparseCommandFunc[CmdOrSet]], RawCommandFuncOptionalBoolReturn[CmdOrSet]]: - """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of argparse.ArgumentParser. + """Decorate a ``do_*`` method to populate its ``args`` argument with the given instance of Cmd2ArgumentParser. - :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this command + :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this command :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that affects parsing. @@ -347,9 +350,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: def as_subcommand_to( command: str, subcommand: str, - parser: argparse.ArgumentParser # existing parser - | Callable[[], argparse.ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClass], argparse.ArgumentParser], # Cmd or CommandSet classmethod + parser: Cmd2ArgumentParser # existing parser + | Callable[[], Cmd2ArgumentParser] # function or staticmethod + | Callable[[CmdOrSetClass], Cmd2ArgumentParser], # Cmd or CommandSet classmethod *, help: str | None = None, # noqa: A002 aliases: Sequence[str] | None = None, @@ -359,7 +362,7 @@ def as_subcommand_to( :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name - :param parser: instance of ArgumentParser or a callable that returns an ArgumentParser for this subcommand + :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this subcommand :param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to. This is passed as the help argument to subparsers.add_parser(). :param aliases: Alternative names for this subcommand. This is passed as the alias argument to diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 7b07185d2..58efba27e 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -494,12 +494,12 @@ def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]: # Text.from_ansi() monkey patch ################################################################################### -# Save original Text.from_ansi() so we can call it in our wrapper +# Save original Text.from_ansi() so we can call it in our patch _orig_text_from_ansi = Text.from_ansi @classmethod # type: ignore[misc] -def _from_ansi_wrapper(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: ARG001 +def _Text_from_ansi(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: N802, ARG001 r"""Wrap Text.from_ansi() to fix its trailing newline bug. This wrapper handles an issue where Text.from_ansi() removes the @@ -539,4 +539,4 @@ def _from_ansi_has_newline_bug() -> bool: # Only apply the monkey patch if the bug is present if _from_ansi_has_newline_bug(): - Text.from_ansi = _from_ansi_wrapper # type: ignore[assignment] + Text.from_ansi = _Text_from_ansi # type: ignore[assignment] diff --git a/cmd2/utils.py b/cmd2/utils.py index dae8ae2ea..5c1f871d3 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -1,6 +1,5 @@ """Shared utility functions.""" -import argparse import contextlib import functools import glob @@ -36,6 +35,7 @@ if TYPE_CHECKING: # pragma: no cover PopenTextIO = subprocess.Popen[str] + from .argparse_custom import Cmd2ArgumentParser else: PopenTextIO = subprocess.Popen @@ -734,7 +734,7 @@ def get_defining_class(meth: Callable[..., Any]) -> type[Any] | None: class CustomCompletionSettings: """Used by cmd2.Cmd.complete() to complete strings other than command arguments.""" - def __init__(self, parser: argparse.ArgumentParser, *, preserve_quotes: bool = False) -> None: + def __init__(self, parser: 'Cmd2ArgumentParser', *, preserve_quotes: bool = False) -> None: """CustomCompletionSettings initializer. :param parser: arg parser defining format of string being completed diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index 00a9b94c6..8f9b3ccb4 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -6,7 +6,7 @@ following for you: 1. Parsing input and quoted strings in a manner similar to how POSIX shells do it 1. Parse the resulting argument list using an instance of - [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser) + [Cmd2ArgumentParser](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser) that you provide 1. Passes the resulting [argparse.Namespace](https://docs.python.org/3/library/argparse.html#argparse.Namespace) object @@ -39,9 +39,9 @@ command which might have its own argument parsing. The [@with_argparser][cmd2.with_argparser] decorator can accept the following for its first argument: -1. An existing instance of `argparse.ArgumentParser` -2. A function or static method which returns an instance of `argparse.ArgumentParser` -3. Cmd or CommandSet class method which returns an instance of `argparse.ArgumentParser` +1. An existing instance of `Cmd2ArgumentParser` +2. A function or static method which returns an instance of `Cmd2ArgumentParser` +3. Cmd or CommandSet class method which returns an instance of `Cmd2ArgumentParser` In all cases the `@with_argparser` decorator creates a deep copy of the parser instance which it stores internally. A consequence is that parsers don't need to be unique across commands. @@ -55,11 +55,11 @@ stores internally. A consequence is that parsers don't need to be unique across ## Argument Parsing For each command in the `cmd2.Cmd` subclass which requires argument parsing, create an instance of -`argparse.ArgumentParser()` which can parse the input appropriately for the command (or provide a +`Cmd2ArgumentParser` which can parse the input appropriately for the command (or provide a function/method that returns such a parser). Then decorate the command method with the `@with_argparser` decorator, passing the argument parser as the first parameter to the decorator. This changes the second argument of the command method, which will contain the results of -`ArgumentParser.parse_args()`. +`Cmd2ArgumentParser.parse_args()`. Here's what it looks like: @@ -97,7 +97,7 @@ def do_speak(self, opts): By default, `cmd2` uses the docstring of the command method when a user asks for help on the command. When you use the `@with_argparser` decorator, the docstring for the `do_*` method is used -to set the description for the `argparse.ArgumentParser`. +to set the description for the `Cmd2ArgumentParser`. !!! tip "description and epilog fields are rich objects" @@ -135,8 +135,8 @@ optional arguments: -h, --help show this help message and exit ``` -If you would prefer, you can set the `description` while instantiating the `argparse.ArgumentParser` -and leave the docstring on your method blank: +If you would prefer, you can set the `description` while instantiating the `Cmd2ArgumentParser` and +leave the docstring on your method blank: ```py from cmd2 import Cmd2ArgumentParser, with_argparser diff --git a/docs/features/completion.md b/docs/features/completion.md index d58d0cef5..868099025 100644 --- a/docs/features/completion.md +++ b/docs/features/completion.md @@ -77,7 +77,7 @@ When using `cmd2`'s [@with_argparser][cmd2.with_argparser] decorator, `cmd2` pro completion of flag names. Tab completion of argument values can be configured by using one of three parameters to -[argparse.ArgumentParser.add_argument](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument) +`Cmd2ArgumentParser.add_argument()`. - `choices` - `choices_provider` diff --git a/docs/migrating/next_steps.md b/docs/migrating/next_steps.md index cff4913c5..886f06010 100644 --- a/docs/migrating/next_steps.md +++ b/docs/migrating/next_steps.md @@ -9,13 +9,13 @@ leveraging other `cmd2` features. The three ideas here will get you started. Bro For all but the simplest of commands, it's probably easier to use [argparse](https://docs.python.org/3/library/argparse.html) to parse user input than to do it manually yourself for each command. `cmd2` provides a `@with_argparser()` decorator which associates -an `ArgumentParser` object with one of your commands. Using this method will: +a `Cmd2ArgumentParser` object with one of your commands. Using this method will: 1. Pass your command a [Namespace](https://docs.python.org/3/library/argparse.html#argparse.Namespace) containing the arguments instead of a string of text 2. Properly handle quoted string input from your users -3. Create a help message for you based on the `ArgumentParser` +3. Create a help message for you based on the `Cmd2ArgumentParser` 4. Give you a big head start adding [Tab Completion](../features/completion.md) to your application 5. Make it much easier to implement subcommands (i.e. `git` has a bunch of subcommands such as `git pull`, `git diff`, etc) diff --git a/tests/conftest.py b/tests/conftest.py index d47c1b5de..3b68e36c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Cmd2 unit/functional testing""" -import argparse import sys from collections.abc import Callable from contextlib import redirect_stderr @@ -118,19 +117,6 @@ def cmd_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] -def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: - if not subcmd_names: - return action - cur_subcmd = subcmd_names.pop(0) - for sub_action in action._actions: - if isinstance(sub_action, argparse._SubParsersAction): - for choice_name, choice in sub_action.choices.items(): - if choice_name == cur_subcmd: - return find_subcommand(choice, subcmd_names) - break - raise ValueError(f"Could not find subcommand '{subcmd_names}'") - - if TYPE_CHECKING: _Base = cmd2.Cmd else: diff --git a/tests/test_argparse.py b/tests/test_argparse.py index c2cfb7778..d1fed524d 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -247,10 +247,56 @@ def test_preservelist(argparse_app) -> None: def test_invalid_parser_builder(argparse_app): parser_builder = None - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="Invalid type for parser_builder"): argparse_app._build_parser(argparse_app, parser_builder, "fake_prog") +def test_invalid_parser_return_type(argparse_app): + def bad_builder(): + return argparse.ArgumentParser() + + with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + argparse_app._build_parser(argparse_app, bad_builder, "fake_prog") + + +def test_invalid_parser_return_type_staticmethod(argparse_app): + def bad_builder(): + return argparse.ArgumentParser() + + sm = staticmethod(bad_builder) + + with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + argparse_app._build_parser(argparse_app, sm, "fake_prog") + + +def test_invalid_parser_return_type_classmethod(argparse_app): + def bad_builder(cls): + return argparse.ArgumentParser() + + cm = classmethod(bad_builder) + + with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + argparse_app._build_parser(argparse_app, cm, "fake_prog") + + +def test_invalid_parser_return_type_nameless_object(argparse_app): + # A class that is callable but has no __name__ attribute + class NamelessBuilder: + def __call__(self): + return argparse.ArgumentParser() + + builder = NamelessBuilder() + + # Verify __name__ is actually missing + assert not hasattr(builder, '__name__') + + # The error message should now contain the string representation of the object + expected_msg = f"The parser returned by '{builder}' must be a Cmd2ArgumentParser" + + with pytest.raises(TypeError, match=expected_msg): + argparse_app._build_parser(argparse_app, builder, "fake_prog") + + def _build_has_subcmd_parser() -> cmd2.Cmd2ArgumentParser: has_subcmds_parser = cmd2.Cmd2ArgumentParser(description="Tests as_subcmd_to decorator") has_subcmds_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index a7e1b3a1b..c94479f91 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1266,20 +1266,20 @@ def test_validate_table_data_valid() -> None: # Custom ArgparseCompleter-based class class CustomCompleter(argparse_completer.ArgparseCompleter): - def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: list[str]) -> list[str]: + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, used_flags: set[str]) -> Completions: """Override so flags with 'complete_when_ready' set to True will complete only when app is ready""" - # Find flags which should not be completed and place them in matched_flags + # Find flags which should not be completed and place them in used_flags for flag in self._flags: action = self._flag_to_action[flag] app: CustomCompleterApp = cast(CustomCompleterApp, self._cmd2_app) - if action.get_complete_when_ready() is True and not app.is_ready: - matched_flags.append(flag) + if action.get_complete_when_ready() and not app.is_ready: + used_flags.append(flag) - return super()._complete_flags(text, line, begidx, endidx, matched_flags) + return super()._complete_flags(text, line, begidx, endidx, used_flags) # Add a custom argparse action attribute -argparse_custom.register_argparse_argument_parameter('complete_when_ready', bool) +argparse_custom.register_argparse_argument_parameter('complete_when_ready') # App used to test custom ArgparseCompleter types and custom argparse attributes @@ -1421,8 +1421,10 @@ def test_add_parser_custom_completer() -> None: parser = Cmd2ArgumentParser() subparsers = parser.add_subparsers() - no_custom_completer_parser = subparsers.add_parser(name="no_custom_completer") - assert no_custom_completer_parser.get_ap_completer_type() is None # type: ignore[attr-defined] + no_custom_completer_parser: Cmd2ArgumentParser = subparsers.add_parser(name="no_custom_completer") + assert no_custom_completer_parser.ap_completer_type is None - custom_completer_parser = subparsers.add_parser(name="custom_completer", ap_completer_type=CustomCompleter) - assert custom_completer_parser.get_ap_completer_type() is CustomCompleter # type: ignore[attr-defined] + custom_completer_parser: Cmd2ArgumentParser = subparsers.add_parser( + name="custom_completer", ap_completer_type=CustomCompleter + ) + assert custom_completer_parser.ap_completer_type is CustomCompleter diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index f5967ee90..95f5527c7 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -9,13 +9,14 @@ from cmd2 import ( Choices, Cmd2ArgumentParser, + argparse_custom, constants, ) from cmd2.argparse_custom import ( - ChoicesCallable, Cmd2HelpFormatter, Cmd2RichArgparseConsole, generate_range_error, + register_argparse_argument_parameter, ) from .conftest import run_cmd @@ -55,7 +56,7 @@ def fake_func() -> None: ({'choices_provider': fake_func, 'completer': fake_func}, False), ], ) -def test_apcustom_choices_callable_count(kwargs, is_valid) -> None: +def test_apcustom_completion_callable_count(kwargs, is_valid) -> None: parser = Cmd2ArgumentParser() if is_valid: parser.add_argument('name', **kwargs) @@ -66,32 +67,21 @@ def test_apcustom_choices_callable_count(kwargs, is_valid) -> None: @pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})]) -def test_apcustom_no_choices_callables_alongside_choices(kwargs) -> None: +def test_apcustom_no_completion_callable_alongside_choices(kwargs) -> None: parser = Cmd2ArgumentParser() - with pytest.raises(TypeError) as excinfo: + + expected_err = "None of the following parameters can be used alongside a choices parameter" + with pytest.raises(ValueError, match=expected_err): parser.add_argument('name', choices=['my', 'choices', 'list'], **kwargs) - assert 'None of the following parameters can be used alongside a choices parameter' in str(excinfo.value) @pytest.mark.parametrize('kwargs', [({'choices_provider': fake_func}), ({'completer': fake_func})]) -def test_apcustom_no_choices_callables_when_nargs_is_0(kwargs) -> None: +def test_apcustom_no_completion_callable_when_nargs_is_0(kwargs) -> None: parser = Cmd2ArgumentParser() - with pytest.raises(TypeError) as excinfo: - parser.add_argument('--name', action='store_true', **kwargs) - assert 'None of the following parameters can be used on an action that takes no arguments' in str(excinfo.value) - -def test_apcustom_choices_callables_wrong_property() -> None: - """Test using the wrong property when retrieving the to_call value from a ChoicesCallable.""" - choices_callable = ChoicesCallable(is_completer=True, to_call=fake_func) - with pytest.raises(AttributeError) as excinfo: - _ = choices_callable.choices_provider - assert 'This instance is configured as a completer' in str(excinfo.value) - - choices_callable = ChoicesCallable(is_completer=False, to_call=fake_func) - with pytest.raises(AttributeError) as excinfo: - _ = choices_callable.completer - assert 'This instance is configured as a choices_provider' in str(excinfo.value) + expected_err = "None of the following parameters can be used on an action that takes no arguments" + with pytest.raises(ValueError, match=expected_err): + parser.add_argument('--name', action='store_true', **kwargs) def test_apcustom_usage() -> None: @@ -206,19 +196,19 @@ def test_apcustom_narg_tuple_zero_base() -> None: parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(0,)) assert arg.nargs == argparse.ZERO_OR_MORE - assert arg.nargs_range is None + assert arg.get_nargs_range() is None assert "[arg ...]" in parser.format_help() parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(0, 1)) assert arg.nargs == argparse.OPTIONAL - assert arg.nargs_range is None + assert arg.get_nargs_range() is None assert "[arg]" in parser.format_help() parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(0, 3)) assert arg.nargs == argparse.ZERO_OR_MORE - assert arg.nargs_range == (0, 3) + assert arg.get_nargs_range() == (0, 3) assert "arg{0..3}" in parser.format_help() @@ -226,13 +216,13 @@ def test_apcustom_narg_tuple_one_base() -> None: parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(1,)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range is None + assert arg.get_nargs_range() is None assert "arg [arg ...]" in parser.format_help() parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(1, 5)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range == (1, 5) + assert arg.get_nargs_range() == (1, 5) assert "arg{1..5}" in parser.format_help() @@ -241,13 +231,13 @@ def test_apcustom_narg_tuple_other_ranges() -> None: parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(2,)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range == (2, constants.INFINITY) + assert arg.get_nargs_range() == (2, constants.INFINITY) # Test finite range parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(2, 5)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range == (2, 5) + assert arg.get_nargs_range() == (2, 5) def test_apcustom_print_message(capsys) -> None: @@ -308,6 +298,50 @@ def test_cmd2_attribute_wrapper() -> None: assert wrapper.get() == new_val +def test_register_argparse_argument_parameter() -> None: + # Test successful registration + param_name = "test_unique_param" + register_argparse_argument_parameter(param_name) + + assert param_name in argparse_custom._CUSTOM_ACTION_ATTRIBS + assert hasattr(argparse.Action, f'get_{param_name}') + assert hasattr(argparse.Action, f'set_{param_name}') + + # Test duplicate registration + expected_err = "already registered" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter(param_name) + + # Test invalid identifier + expected_err = "must be a valid Python identifier" + with pytest.raises(ValueError, match=expected_err): + register_argparse_argument_parameter("invalid name") + + # Test collision with standard argparse.Action attribute + expected_err = "conflicts with an existing attribute on argparse.Action" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("format_usage") + + # Test collision with existing accessor methods + try: + argparse.Action.get_colliding_param = lambda self: None + expected_err = "Accessor methods for 'colliding_param' already exist on argparse.Action" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("colliding_param") + finally: + delattr(argparse.Action, 'get_colliding_param') + + # Test collision with internal attribute + try: + attr_name = constants.cmd2_attr_name("internal_collision") + setattr(argparse.Action, attr_name, None) + expected_err = f"The internal attribute '{attr_name}' already exists on argparse.Action" + with pytest.raises(KeyError, match=expected_err): + register_argparse_argument_parameter("internal_collision") + finally: + delattr(argparse.Action, attr_name) + + def test_parser_attachment() -> None: # Attach a parser as a subcommand root_parser = Cmd2ArgumentParser(description="root command") diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 0f1e79566..d0998f30a 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2151,7 +2151,7 @@ def test_resolve_completer_with_choices_provider(base_app: cmd2.Cmd) -> None: assert settings is not None action = settings.parser._actions[-1] - assert action.get_choices_callable().choices_provider == mock_provider + assert action.get_choices_provider() == mock_provider assert not settings.preserve_quotes @@ -2168,7 +2168,7 @@ def test_resolve_completer_with_completer(base_app: cmd2.Cmd) -> None: assert settings is not None action = settings.parser._actions[-1] - assert action.get_choices_callable().completer == mock_completer + assert action.get_completer() == mock_completer assert not settings.preserve_quotes diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index a3e8f9d34..948ce5564 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -115,10 +115,10 @@ def test_set_theme() -> None: assert ru.APP_THEME.styles[rich_style_key] == theme[rich_style_key] -def test_from_ansi_wrapper() -> None: +def test_from_ansi_patch() -> None: # Check if we are still patching Text.from_ansi(). If this check fails, then Rich - # has fixed the bug. Therefore, we can remove this test function and ru._from_ansi_wrapper. - assert Text.from_ansi.__func__ is ru._from_ansi_wrapper.__func__ # type: ignore[attr-defined] + # has fixed the bug. Therefore, we can remove this test function and ru._Text_from_ansi. + assert Text.from_ansi.__func__ is ru._Text_from_ansi.__func__ # type: ignore[attr-defined] # Line breaks recognized by str.splitlines(). # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines