From a11c366f19fb45db7e4baf9bc0c9f1c7e3a3b931 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 25 Jun 2025 10:49:24 -0400 Subject: [PATCH 1/3] Refactored Cmd._build_parser() to accept a prog value. Restored call to _set_parser_prog() in with_argparser() to preserve backward compatibility with cmd2 2.0 family. --- cmd2/cmd2.py | 54 +++++++++++++++++++++++++--------------------- cmd2/decorators.py | 6 ++++++ 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 4ddda77a..6a57d1f5 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -254,17 +254,11 @@ def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]: command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :] parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None) - parent = self._cmd.find_commandset_for_command(command) or self._cmd - parser = self._cmd._build_parser(parent, parser_builder) - if parser is None: + if parser_builder is None: return None - # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command - from .decorators import ( - _set_parser_prog, - ) - - _set_parser_prog(parser, command) + parent = self._cmd.find_commandset_for_command(command) or self._cmd + parser = self._cmd._build_parser(parent, parser_builder, command) # If the description has not been set, then use the method docstring if one exists if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__: @@ -758,24 +752,39 @@ def register_command_set(self, cmdset: CommandSet) -> None: def _build_parser( self, parent: CommandParent, - parser_builder: Optional[ - Union[ - argparse.ArgumentParser, - Callable[[], argparse.ArgumentParser], - StaticArgParseBuilder, - ClassArgParseBuilder, - ] + parser_builder: Union[ + argparse.ArgumentParser, + Callable[[], argparse.ArgumentParser], + StaticArgParseBuilder, + ClassArgParseBuilder, ], - ) -> Optional[argparse.ArgumentParser]: - parser: Optional[argparse.ArgumentParser] = None + prog: str, + ) -> argparse.ArgumentParser: + """Build argument parser for a command/subcommand. + + :param parent: CommandParent object which owns the parser + :param parser_builder: method used to build the parser + :param prog: prog value to set in new parser + :return: new parser + :raises TypeError: if parser_builder is invalid type + """ if isinstance(parser_builder, staticmethod): parser = parser_builder.__func__() elif isinstance(parser_builder, classmethod): - parser = parser_builder.__func__(parent if not None else self) # type: ignore[arg-type] + parser = parser_builder.__func__(parent.__class__) elif callable(parser_builder): parser = parser_builder() elif isinstance(parser_builder, argparse.ArgumentParser): parser = copy.deepcopy(parser_builder) + else: + raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") + + from .decorators import ( + _set_parser_prog, + ) + + _set_parser_prog(parser, prog) + return parser def _install_command_function(self, command_func_name: str, command_method: CommandFunc, context: str = '') -> None: @@ -963,12 +972,7 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> target_parser = find_subcommand(command_parser, subcommand_names) - subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder)) - from .decorators import ( - _set_parser_prog, - ) - - _set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}') + subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{command_name} {subcommand_name}') if subcmd_parser.description is None and method.__doc__: subcmd_parser.description = strip_doc_annotations(method.__doc__) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 61742ad3..df75ce3b 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -393,6 +393,12 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] + if isinstance(parser, argparse.ArgumentParser): + # Set parser's prog value for backward compatibility within the cmd2 2.0 family. + # This will be removed in cmd2 3.0 since we never reference this parser's prog value. + # We only set prog on the deep copy of this parser created in Cmd._build_parser(). + _set_parser_prog(parser, command_name) + # Set some custom attributes for this command setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) From 50be31c75902647dc3ca43bfe69062dae866dd77 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 26 Jun 2025 01:37:49 -0400 Subject: [PATCH 2/3] Added additional test for Cmd._build_parser(). --- CHANGELOG.md | 6 ++++++ cmd2/decorators.py | 6 ++++-- tests/test_argparse.py | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23abd334..6ad5f868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ ## 2.6.2 (TBD, 2025) - Enhancements + - Added explicit support for free-threaded versions of Python, starting with version 3.14 +- Bug Fixes + - Restored code to set a parser's `prog` value in the `with_argparser` decorator. This is to + preserve backward compatibility in the `cmd2` 2.0 family. This functionality will be removed + in `cmd2` 3.0.0. + ## 2.6.1 (June 8, 2025) - Bug Fixes diff --git a/cmd2/decorators.py b/cmd2/decorators.py index df75ce3b..21870cdc 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -395,8 +395,10 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> Optional[bool]: if isinstance(parser, argparse.ArgumentParser): # Set parser's prog value for backward compatibility within the cmd2 2.0 family. - # This will be removed in cmd2 3.0 since we never reference this parser's prog value. - # We only set prog on the deep copy of this parser created in Cmd._build_parser(). + # This will be removed in cmd2 3.0 since we never reference this parser object's prog value. + # Since it's possible for the same parser object to be passed into multiple with_argparser() + # calls, we only set prog on the deep copies of this parser based on the specific do_xxxx + # instance method they are associated with. _set_parser_prog(parser, command_name) # Set some custom attributes for this command diff --git a/tests/test_argparse.py b/tests/test_argparse.py index e03edb37..ff387ecc 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -248,6 +248,12 @@ def test_preservelist(argparse_app) -> None: assert out[0] == "['foo', '\"bar baz\"']" +def test_invalid_parser_builder(argparse_app): + parser_builder = None + with pytest.raises(TypeError): + argparse_app._build_parser(argparse_app, parser_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) From a72c98d18f5a9cb54b79987cd9a145a310323b9e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 26 Jun 2025 11:57:50 -0400 Subject: [PATCH 3/3] Updated comment --- cmd2/cmd2.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 6a57d1f5..de2ae157 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -762,8 +762,10 @@ def _build_parser( ) -> argparse.ArgumentParser: """Build argument parser for a command/subcommand. - :param parent: CommandParent object which owns the parser - :param parser_builder: method used to build the parser + :param parent: CommandParent object which owns the command using the parser. + This function assumes that parent is where parser_builder + is defined when parser_builder is a classmethod. + :param parser_builder: means used to build the parser :param prog: prog value to set in new parser :return: new parser :raises TypeError: if parser_builder is invalid type