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/cmd2.py b/cmd2/cmd2.py index 4ddda77a..de2ae157 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,41 @@ 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 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 + """ 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 +974,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..21870cdc 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -393,6 +393,14 @@ 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 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 setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) 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)