From 507c309fce6722ebfebb172ea745fe8804dec69f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 5 Feb 2026 01:42:56 -0500 Subject: [PATCH] Fixed incompatibilities with Python 3.14.3. (#1571) * Removed most overridden functions for custom argparse help formatting due to incompatibilities with newer versions. * Updated _macro_list to use a method to build its parser. * No longer storing Cmd/CommandSet instance in subcommand parsers. Using id(instance) instead. * Fixed issue deep copying Cmd2ArgumentParser in Python 3.14.3. --- CHANGELOG.md | 10 ++ cmd2/argparse_custom.py | 266 +++-------------------------- cmd2/cmd2.py | 49 +++--- cmd2/constants.py | 4 +- tests/test_argparse_custom.py | 7 - tests/test_cmd2.py | 2 +- tests/transcripts/from_cmdloop.txt | 10 +- 7 files changed, 73 insertions(+), 275 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c1882ac..1cadabee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,16 @@ shell, and the option for a persistent bottom bar that can display realtime stat - **max_column_completion_results**: (int) the maximum number of completion results to display in a single column +## 3.2.0 (February 5, 2026) + +- Bug Fixes + - Fixed incompatibilities with Python 3.14.3. + +- Potentially Breaking Changes + - To avoid future incompatibilities with argparse, we removed most of our overridden help + functions. This should not break an application, but it could affect unit tests which parse + help text. + ## 3.1.3 (February 3, 2026) - Bug Fixes diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 3afec8d0f..c74388b0c 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -261,11 +261,9 @@ def get_items(self) -> list[CompletionItems]: from argparse import ArgumentError from collections.abc import ( Callable, - Iterable, Iterator, Sequence, ) -from gettext import gettext from typing import ( TYPE_CHECKING, Any, @@ -1126,177 +1124,22 @@ def __init__( **kwargs: Any, ) -> None: """Initialize Cmd2HelpFormatter.""" - if console is None: - console = Cmd2RichArgparseConsole() - super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) - def _format_usage( - self, - usage: str | None, - actions: Iterable[argparse.Action], - groups: Iterable[argparse._ArgumentGroup], - prefix: str | None = None, - ) -> str: - if prefix is None: - prefix = gettext('Usage: ') - - # if usage is specified, use that - if usage is not None: - usage %= {"prog": self._prog} - - # if no optionals or positionals are available, usage is just prog - elif not actions: - usage = f'{self._prog}' - - # if optionals and positionals are available, calculate usage - else: - prog = f'{self._prog}' - - # split optionals from positionals - optionals = [] - positionals = [] - # Begin cmd2 customization (separates required and optional, applies to all changes in this function) - required_options = [] - for action in actions: - if action.option_strings: - if action.required: - required_options.append(action) - else: - optionals.append(action) - else: - positionals.append(action) - # End cmd2 customization - - # build full usage string - format_actions = self._format_actions_usage - action_usage = format_actions(required_options + optionals + positionals, groups) # type: ignore[arg-type] - usage = ' '.join([s for s in [prog, action_usage] if s]) - - # wrap the usage parts if it's too long - text_width = self._width - self._current_indent - if len(prefix) + len(usage) > text_width: - # Begin cmd2 customization - - # break usage into wrappable parts - part_regexp = r'\(.*?\)+|\[.*?\]+|\S+' - req_usage = format_actions(required_options, groups) # type: ignore[arg-type] - opt_usage = format_actions(optionals, groups) # type: ignore[arg-type] - pos_usage = format_actions(positionals, groups) # type: ignore[arg-type] - req_parts = re.findall(part_regexp, req_usage) - opt_parts = re.findall(part_regexp, opt_usage) - pos_parts = re.findall(part_regexp, pos_usage) - - # End cmd2 customization - - # helper for wrapping lines - def get_lines(parts: list[str], indent: str, prefix: str | None = None) -> list[str]: - lines: list[str] = [] - line: list[str] = [] - line_len = len(prefix) - 1 if prefix is not None else len(indent) - 1 - for part in parts: - if line_len + 1 + len(part) > text_width and line: - lines.append(indent + ' '.join(line)) - line = [] - line_len = len(indent) - 1 - line.append(part) - line_len += len(part) + 1 - if line: - lines.append(indent + ' '.join(line)) - if prefix is not None: - lines[0] = lines[0][len(indent) :] - return lines - - # if prog is short, follow it with optionals or positionals - if len(prefix) + len(prog) <= 0.75 * text_width: - indent = ' ' * (len(prefix) + len(prog) + 1) - # Begin cmd2 customization - if req_parts: - lines = get_lines([prog, *req_parts], indent, prefix) - lines.extend(get_lines(opt_parts, indent)) - lines.extend(get_lines(pos_parts, indent)) - elif opt_parts: - lines = get_lines([prog, *opt_parts], indent, prefix) - lines.extend(get_lines(pos_parts, indent)) - elif pos_parts: - lines = get_lines([prog, *pos_parts], indent, prefix) - else: - lines = [prog] - # End cmd2 customization - - # if prog is long, put it on its own line - else: - indent = ' ' * len(prefix) - # Begin cmd2 customization - parts = req_parts + opt_parts + pos_parts - lines = get_lines(parts, indent) - if len(lines) > 1: - lines = [] - lines.extend(get_lines(req_parts, indent)) - lines.extend(get_lines(opt_parts, indent)) - lines.extend(get_lines(pos_parts, indent)) - # End cmd2 customization - lines = [prog, *lines] - - # join lines into usage - usage = '\n'.join(lines) - - # prefix with 'Usage:' - return f'{prefix}{usage}\n\n' - - def _format_action_invocation(self, action: argparse.Action) -> str: - if not action.option_strings: - default = self._get_default_metavar_for_positional(action) - (metavar,) = self._metavar_formatter(action, default)(1) - return metavar - - parts: list[str] = [] - - # if the Optional doesn't take a value, format is: - # -s, --long - if action.nargs == 0: - parts.extend(action.option_strings) - return ', '.join(parts) - - # Begin cmd2 customization (less verbose) - # if the Optional takes a value, format is: - # -s, --long ARGS - default = self._get_default_metavar_for_optional(action) - args_string = self._format_args(action, default) - - return ', '.join(action.option_strings) + ' ' + args_string - # End cmd2 customization - - def _determine_metavar( - self, - action: argparse.Action, - default_metavar: str, - ) -> str | tuple[str, ...]: - """Determine what to use as the metavar value of an action.""" - if action.metavar is not None: - result = action.metavar - elif action.choices is not None: - choice_strs = [str(choice) for choice in action.choices] - # Begin cmd2 customization (added space after comma) - result = f'{", ".join(choice_strs)}' - # End cmd2 customization - else: - result = default_metavar - return result + # Recast to assist type checkers + self._console: Cmd2RichArgparseConsole | None - def _metavar_formatter( - self, - action: argparse.Action, - default_metavar: str, - ) -> Callable[[int], tuple[str, ...]]: - metavar = self._determine_metavar(action, default_metavar) - - def format_tuple(tuple_size: int) -> tuple[str, ...]: - if isinstance(metavar, tuple): - return metavar - return (metavar,) * tuple_size + @property # type: ignore[override] + def console(self) -> Cmd2RichArgparseConsole: + """Return our console instance.""" + if self._console is None: + self._console = Cmd2RichArgparseConsole() + return self._console - return format_tuple + @console.setter + def console(self, console: Cmd2RichArgparseConsole) -> None: + """Set our console instance.""" + self._console = console def _build_nargs_range_str(self, nargs_range: tuple[int, int | float]) -> str: """Generate nargs range string for help text.""" @@ -1314,13 +1157,12 @@ def _format_args(self, action: argparse.Action, default_metavar: str) -> str: All formats in this function need to be handled by _rich_metavar_parts(). """ - metavar = self._determine_metavar(action, default_metavar) - metavar_formatter = self._metavar_formatter(action, default_metavar) + get_metavar = self._metavar_formatter(action, default_metavar) # Handle nargs specified as a range nargs_range = action.get_nargs_range() # type: ignore[attr-defined] if nargs_range is not None: - arg_str = '%s' % metavar_formatter(1) # noqa: UP031 + arg_str = '%s' % get_metavar(1) # noqa: UP031 range_str = self._build_nargs_range_str(nargs_range) return f"{arg_str}{range_str}" @@ -1329,8 +1171,8 @@ def _format_args(self, action: argparse.Action, default_metavar: str) -> str: # To make this less verbose, format it like: 'command arg{5}'. # Do not customize the output when metavar is a tuple of strings. Allow argparse's # formatter to handle that instead. - if isinstance(metavar, str) and isinstance(action.nargs, int) and action.nargs > 1: - arg_str = '%s' % metavar_formatter(1) # noqa: UP031 + if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1: + arg_str = '%s' % get_metavar(1) # noqa: UP031 return f"{arg_str}{{{action.nargs}}}" # Fallback to parent for all other cases @@ -1342,19 +1184,18 @@ def _rich_metavar_parts( default_metavar: str, ) -> Iterator[tuple[str, bool]]: """Override to handle all cmd2-specific formatting in _format_args().""" - metavar = self._determine_metavar(action, default_metavar) - metavar_formatter = self._metavar_formatter(action, default_metavar) + get_metavar = self._metavar_formatter(action, default_metavar) # Handle nargs specified as a range nargs_range = action.get_nargs_range() # type: ignore[attr-defined] if nargs_range is not None: - yield "%s" % metavar_formatter(1), True # noqa: UP031 + yield "%s" % get_metavar(1), True # noqa: UP031 yield self._build_nargs_range_str(nargs_range), False return # Handle specific integer nargs (e.g., nargs=5 -> arg{5}) - if isinstance(metavar, str) and isinstance(action.nargs, int) and action.nargs > 1: - yield "%s" % metavar_formatter(1), True # noqa: UP031 + if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1: + yield "%s" % get_metavar(1), True # noqa: UP031 yield f"{{{action.nargs}}}", False return @@ -1490,15 +1331,15 @@ def __init__( ) # Recast to assist type checkers since these can be Rich renderables in a Cmd2HelpFormatter. - self.description: RenderableType | None = self.description # type: ignore[assignment] - self.epilog: RenderableType | None = self.epilog # type: ignore[assignment] + 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] def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore[type-arg] """Add a subcommand parser. - Set a default title if one was not given.f + Set a default title if one was not given. :param kwargs: additional keyword arguments :return: argparse Subparser Action @@ -1509,10 +1350,7 @@ def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: return super().add_subparsers(**kwargs) def error(self, message: str) -> NoReturn: - """Print a usage message, including the message, to sys.stderr and terminates the program with a status code of 2. - - Custom override that applies custom formatting to the error message. - """ + """Override that applies custom formatting to the error message.""" lines = message.split('\n') formatted_message = '' for linum, line in enumerate(lines): @@ -1532,62 +1370,12 @@ def error(self, message: str) -> NoReturn: self.exit(2, f'{formatted_message}\n') def _get_formatter(self) -> Cmd2HelpFormatter: - """Override _get_formatter with customizations for Cmd2HelpFormatter.""" + """Override with customizations for Cmd2HelpFormatter.""" return cast(Cmd2HelpFormatter, super()._get_formatter()) def format_help(self) -> str: - """Return a string containing a help message, including the program usage and information about the arguments. - - Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters. - """ - formatter = self._get_formatter() - - # usage - formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups) - - # description - formatter.add_text(self.description) - - # Begin cmd2 customization (separate required and optional arguments) - - # positionals, optionals and user-defined groups - for action_group in self._action_groups: - default_options_group = action_group.title == 'options' - - if default_options_group: - # check if the arguments are required, group accordingly - req_args = [] - opt_args = [] - for action in action_group._group_actions: - if action.required: - req_args.append(action) - else: - opt_args.append(action) - - # separately display required arguments - formatter.start_section('required arguments') - formatter.add_text(action_group.description) - formatter.add_arguments(req_args) - formatter.end_section() - - # now display truly optional arguments - formatter.start_section('optional arguments') - formatter.add_text(action_group.description) - formatter.add_arguments(opt_args) - formatter.end_section() - else: - formatter.start_section(action_group.title) - formatter.add_text(action_group.description) - formatter.add_arguments(action_group._group_actions) - formatter.end_section() - - # End cmd2 customization - - # epilog - formatter.add_text(self.epilog) - - # determine help from format above - return formatter.format_help() + '\n' + """Override to add a newline.""" + return super().format_help() + '\n' def create_text_group(self, title: str, text: RenderableType) -> TextGroup: """Create a TextGroup using this parser's formatter creator.""" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0cd4844bd..0897767ed 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -992,11 +992,12 @@ def unregister_command_set(self, cmdset: CommandSet) -> None: def _check_uninstallable(self, cmdset: CommandSet) -> None: def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None: + cmdset_id = id(cmdset) for action in parser._actions: if isinstance(action, argparse._SubParsersAction): for subparser in action.choices.values(): - attached_cmdset = getattr(subparser, constants.PARSER_ATTR_COMMANDSET, None) - if attached_cmdset is not None and attached_cmdset is not cmdset: + attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_COMMANDSET_ID, None) + if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id: raise CommandSetRegistrationError( 'Cannot uninstall CommandSet when another CommandSet depends on it' ) @@ -1091,7 +1092,7 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> subcmd_parser.set_defaults(**defaults) # Set what instance the handler is bound to - setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) + setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET_ID, id(cmdset)) # Find the argparse action that handles subcommands for action in target_parser._actions: @@ -3994,25 +3995,31 @@ def _macro_delete(self, args: argparse.Namespace) -> None: self.perror(f"Macro '{cur_name}' does not exist") # macro -> list - macro_list_help = "list macros" - macro_list_description = Text.assemble( - "List specified macros in a reusable form that can be saved to a startup script to preserve macros across sessions.", - "\n\n", - "Without arguments, all macros will be listed.", - ) - - macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description) - macro_list_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='macro(s) to list', - choices_provider=_get_macro_completion_items, - descriptive_headers=["Value"], - ) - - @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help) + @classmethod + def _build_macro_list_parser(cls) -> Cmd2ArgumentParser: + macro_list_description = Text.assemble( + ( + "List specified macros in a reusable form that can be saved to a startup script " + "to preserve macros across sessions." + ), + "\n\n", + "Without arguments, all macros will be listed.", + ) + + macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description) + macro_list_parser.add_argument( + 'names', + nargs=argparse.ZERO_OR_MORE, + help='macro(s) to list', + choices_provider=cls._get_macro_completion_items, + descriptive_headers=["Value"], + ) + + return macro_list_parser + + @as_subcommand_to('macro', 'list', _build_macro_list_parser, help="list macros") def _macro_list(self, args: argparse.Namespace) -> None: - """List some or all macros as 'macro create' commands.""" + """List macros.""" self.last_result = {} # dict[macro_name, macro_value] tokens_to_quote = constants.REDIRECTION_TOKENS diff --git a/cmd2/constants.py b/cmd2/constants.py index 5d3351ebb..1ecd19374 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -47,8 +47,8 @@ SUBCMD_ATTR_NAME = 'subcommand_name' SUBCMD_ATTR_ADD_PARSER_KWARGS = 'subcommand_add_parser_kwargs' -# arpparse attribute linking to command set instance -PARSER_ATTR_COMMANDSET = 'command_set' +# arpparse attribute uniquely identifying the command set instance +PARSER_ATTR_COMMANDSET_ID = 'command_set_id' # custom attributes added to argparse Namespaces NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__' diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 3889be147..5096d60d7 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -271,13 +271,6 @@ def test_generate_range_error() -> None: assert err_str == "expected 0 to 2 arguments" -def test_apcustom_required_options() -> None: - # Make sure a 'required arguments' section shows when a flag is marked required - parser = Cmd2ArgumentParser() - parser.add_argument('--required_flag', required=True) - assert 'Required Arguments' in parser.format_help() - - def test_apcustom_metavar_tuple() -> None: # Test the case when a tuple metavar is used with nargs an integer > 1 parser = Cmd2ArgumentParser() diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index f42add634..bde06e33d 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1722,7 +1722,7 @@ def test_help_with_no_docstring(capsys) -> None: out == """Usage: greet [-h] [-s] -Optional Arguments: +Options: -h, --help show this help message and exit -s, --shout N00B EMULATION MODE diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index da5363831..613a46d35 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -6,11 +6,11 @@ Usage: speak [-h] [-p] [-s] [-r REPEAT]/ */ Repeats what you tell me to./ */ -Optional Arguments:/ */ - -h, --help show this help message and exit/ */ - -p, --piglatin atinLay/ */ - -s, --shout N00B EMULATION MODE/ */ - -r, --repeat REPEAT output [n] times/ */ +Options:/ */ + -h, --help/ */show this help message and exit/ */ + -p, --piglatin/ */atinLay/ */ + -s, --shout/ */N00B EMULATION MODE/ */ + -r, --repeat REPEAT/ */output [n] times/ */ (Cmd) say goodnight, Gracie goodnight, Gracie