Skip to content

Commit 55e7d6b

Browse files
committed
Fixed issue where argument parsers for overridden commands were not being created.
1 parent 91897f4 commit 55e7d6b

File tree

9 files changed

+382
-96
lines changed

9 files changed

+382
-96
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.5.7 (TBD)
2+
* Bug Fixes
3+
* Fixed issue where argument parsers for overridden commands were not being created.
4+
15
## 2.5.6 (November 14, 2024)
26
* Bug Fixes
37
* Fixed type hint for `with_default_category` decorator which caused type checkers to mistype

cmd2/cmd2.py

Lines changed: 153 additions & 84 deletions
Large diffs are not rendered by default.

cmd2/decorators.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]:
346346
statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
347347
command_name, statement_arg, preserve_quotes
348348
)
349-
arg_parser = cmd2_app._command_parsers.get(command_name, None)
349+
350+
# Pass cmd_wrapper instead of func, since it contains the parser info.
351+
arg_parser = cmd2_app._command_parsers.get(cmd_wrapper)
350352
if arg_parser is None:
351353
# This shouldn't be possible to reach
352354
raise ValueError(f'No argument parser found for {command_name}') # pragma: no cover

examples/hello_cmd2.py

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,143 @@
44
This is intended to be a completely bare-bones cmd2 application suitable for rapid testing and debugging.
55
"""
66

7-
from cmd2 import (
8-
cmd2,
9-
)
7+
import argparse
8+
from typing import List
9+
10+
import cmd2
11+
12+
# class CommandSetBase(cmd2.CommandSet):
13+
# pass
14+
15+
# class WithCommandSets(cmd2.Cmd):
16+
# """Class for testing custom help_* methods which override docstring help."""
17+
18+
# def __init__(self, *args, **kwargs):
19+
# super(WithCommandSets, self).__init__(*args, **kwargs)
20+
21+
# @cmd2.with_default_category('Fruits')
22+
# class CommandSetA(CommandSetBase):
23+
# def on_register(self, cmd) -> None:
24+
# super().on_register(cmd)
25+
# print("in on_register now")
26+
27+
# def on_registered(self) -> None:
28+
# super().on_registered()
29+
# print("in on_registered now")
30+
31+
# def on_unregister(self) -> None:
32+
# super().on_unregister()
33+
# print("in on_unregister now")
34+
35+
# def on_unregistered(self) -> None:
36+
# super().on_unregistered()
37+
# print("in on_unregistered now")
38+
39+
# def do_apple(self, statement: cmd2.Statement):
40+
# self._cmd.poutput('Apple!')
41+
42+
# def do_banana(self, statement: cmd2.Statement):
43+
# """Banana Command"""
44+
# self._cmd.poutput('Banana!!')
45+
46+
# cranberry_parser = cmd2.Cmd2ArgumentParser()
47+
# cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce'])
48+
49+
# @cmd2.with_argparser(cranberry_parser, with_unknown_args=True)
50+
# def do_cranberry(self, ns: argparse.Namespace, unknown: List[str]):
51+
# self._cmd.poutput('Cranberry {}!!'.format(ns.arg1))
52+
# if unknown and len(unknown):
53+
# self._cmd.poutput('Unknown: ' + ', '.join(['{}'] * len(unknown)).format(*unknown))
54+
# self._cmd.last_result = {'arg1': ns.arg1, 'unknown': unknown}
55+
56+
# def help_cranberry(self):
57+
# self._cmd.stdout.write('This command does diddly squat...\n')
58+
59+
# @cmd2.with_argument_list
60+
# @cmd2.with_category('Also Alone')
61+
# def do_durian(self, args: List[str]):
62+
# """Durian Command"""
63+
# self._cmd.poutput('{} Arguments: '.format(len(args)))
64+
# self._cmd.poutput(', '.join(['{}'] * len(args)).format(*args))
65+
# self._cmd.last_result = {'args': args}
66+
67+
# def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
68+
# return self._cmd.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting'])
69+
70+
# elderberry_parser = cmd2.Cmd2ArgumentParser()
71+
# elderberry_parser.add_argument('arg1')
72+
73+
# @cmd2.with_category('Alone')
74+
# @cmd2.with_argparser(elderberry_parser)
75+
# def do_elderberry(self, ns: argparse.Namespace):
76+
# self._cmd.poutput('Elderberry {}!!'.format(ns.arg1))
77+
# self._cmd.last_result = {'arg1': ns.arg1}
78+
79+
# # Test that CommandSet with as_subcommand_to decorator successfully loads
80+
# # during `cmd2.Cmd.__init__()`.
81+
# main_parser = cmd2.Cmd2ArgumentParser(description="Main Command")
82+
# main_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
83+
84+
# @cmd2.with_category('Alone')
85+
# @cmd2.with_argparser(main_parser)
86+
# def do_main(self, args: argparse.Namespace) -> None:
87+
# # Call handler for whatever subcommand was selected
88+
# handler = args.cmd2_handler.get()
89+
# handler(args)
90+
91+
# # main -> sub
92+
# subcmd_parser = cmd2.Cmd2ArgumentParser(description="Sub Command")
93+
94+
# @cmd2.as_subcommand_to('main', 'sub', subcmd_parser, help="sub command")
95+
# def subcmd_func(self, args: argparse.Namespace) -> None:
96+
# self._cmd.poutput("Subcommand Ran")
97+
98+
# class MySet(cmd2.CommandSet):
99+
# p = cmd2.Cmd2ArgumentParser(description="This is help")
100+
# p.add_argument("command")
101+
# p.add_argument("foo")
102+
103+
# @cmd2.with_argparser(p)
104+
# def do_foo(self, args: argparse.Namespace) -> None:
105+
# print(f"foo is {args.foo}")
106+
# self._cmd.do_help(args.command)
107+
# print(self._cmd.do_foo)
108+
109+
# do_test = cmd2.Cmd.do_alias
110+
111+
# def do_enable(self, _):
112+
# self._cmd.enable_command("foo")
113+
114+
# def do_disable(self, _):
115+
# self._cmd.disable_command("foo", "That's a disabled command.")
116+
117+
# def on_registered(self) -> None:
118+
# super().on_registered()
119+
120+
# def do_unregister(self, _) -> None:
121+
# self._cmd.unregister_command_set(self)
122+
123+
124+
# class SynonymCommandSet(CommandSetA):
125+
126+
# do_synonym = CommandSetA.do_cranberry
127+
128+
# def do_unregister(self, _) -> None:
129+
# self._cmd.unregister_command_set(self)
130+
131+
class App(cmd2.Cmd):
132+
do_synonym = cmd2.Cmd.do_alias
133+
134+
def do_disable(self, _):
135+
self.disable_command("synonym", "This is disabled.")
10136

11137
if __name__ == '__main__':
12138
import sys
13139

14140
# If run as the main application, simply start a bare-bones cmd2 application with only built-in functionality.
15141
# Enable commands to support interactive Python and IPython shells.
16-
app = cmd2.Cmd(include_py=True, include_ipy=True, persistent_history_file='cmd2_history.dat')
142+
app = App(include_py=True, include_ipy=True, persistent_history_file='cmd2_history.dat')
17143
app.self_in_py = True # Enable access to "self" within the py command
18144
app.debug = True # Show traceback if/when an exception occurs
145+
print(app.cmd_func("synonym"))
19146
sys.exit(app.cmdloop())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ dev-dependencies = [
328328
"pytest",
329329
"pytest-cov",
330330
"pytest-mock",
331+
"ruff",
331332
"sphinx",
332333
"sphinx-autobuild",
333334
"sphinx-rtd-theme",

tests/test_argparse.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import cmd2
1515

1616
from .conftest import (
17-
find_subcommand,
1817
run_cmd,
1918
)
2019

@@ -402,8 +401,7 @@ def test_add_another_subcommand(subcommand_app):
402401
This tests makes sure _set_parser_prog() sets _prog_prefix on every _SubParsersAction so that all future calls
403402
to add_parser() write the correct prog value to the parser being added.
404403
"""
405-
base_parser = subcommand_app._command_parsers.get('base')
406-
find_subcommand(subcommand_app._command_parsers.get('base'), [])
404+
base_parser = subcommand_app._command_parsers.get(subcommand_app.do_base)
407405
for sub_action in base_parser._actions:
408406
if isinstance(sub_action, argparse._SubParsersAction):
409407
new_parser = sub_action.add_parser('new_sub', help='stuff')

tests/test_cmd2.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,12 @@ def do_multiline_docstr(self, arg):
12101210
"""
12111211
pass
12121212

1213+
parser_cmd_parser = cmd2.Cmd2ArgumentParser(description="This is the description.")
1214+
@cmd2.with_argparser(parser_cmd_parser)
1215+
def do_parser_cmd(self, args):
1216+
"""This is the docstring."""
1217+
pass
1218+
12131219

12141220
@pytest.fixture
12151221
def help_app():
@@ -1249,6 +1255,11 @@ def test_help_multiline_docstring(help_app):
12491255
assert help_app.last_result is True
12501256

12511257

1258+
def test_help_verbose_uses_parser_description(help_app: HelpApp):
1259+
out, err = run_cmd(help_app, 'help --verbose')
1260+
verify_help_text(help_app, out, verbose_strings=[help_app.parser_cmd_parser.description])
1261+
1262+
12521263
class HelpCategoriesApp(cmd2.Cmd):
12531264
"""Class for testing custom help_* methods which override docstring help."""
12541265

@@ -3016,3 +3027,26 @@ def test_columnize_too_wide(outsim_app):
30163027

30173028
expected = "\n".join(str_list) + "\n"
30183029
assert outsim_app.stdout.getvalue() == expected
3030+
3031+
3032+
def test_command_parser_retrieval(outsim_app: cmd2.Cmd):
3033+
# Pass something that isn't a method
3034+
not_a_method = "just a string"
3035+
assert outsim_app._command_parsers.get(not_a_method) is None
3036+
3037+
# Pass a non-command method
3038+
assert outsim_app._command_parsers.get(outsim_app.__init__) is None
3039+
3040+
3041+
def test_command_synonym_parser():
3042+
# Make sure a command synonym returns the same parser as what it aliases
3043+
class SynonymApp(cmd2.cmd2.Cmd):
3044+
do_synonym = cmd2.cmd2.Cmd.do_help
3045+
3046+
app = SynonymApp()
3047+
3048+
synonym_parser = app._command_parsers.get(app.do_synonym)
3049+
help_parser = app._command_parsers.get(app.do_help)
3050+
3051+
assert synonym_parser is not None
3052+
assert synonym_parser is help_parser

tests/transcripts/from_cmdloop.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# so you can see where they are.
33

44
(Cmd) help say
5-
Usage: say [-h] [-p] [-s] [-r REPEAT]/ */
5+
Usage: speak [-h] [-p] [-s] [-r REPEAT]/ */
66

77
Repeats what you tell me to./ */
88

tests_isolated/test_commandset/test_commandset.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,49 @@ def test_autoload_commands(command_sets_app):
149149
assert 'Command Set B' not in cmds_cats
150150

151151

152+
def test_command_synonyms():
153+
"""Test the use of command synonyms in CommandSets"""
154+
class SynonymCommandSet(cmd2.CommandSet):
155+
def __init__(self, arg1):
156+
super().__init__()
157+
self._arg1 = arg1
158+
159+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser(description="Native Command"))
160+
def do_builtin(self, _):
161+
pass
162+
163+
# Create a synonym to a command inside of this CommandSet
164+
do_builtin_synonym = do_builtin
165+
166+
# Create a synonym to a command outside of this CommandSet
167+
do_help_synonym = cmd2.Cmd.do_help
168+
169+
cs = SynonymCommandSet("foo")
170+
app = WithCommandSets(command_sets=[cs])
171+
172+
# Make sure the synonyms have the same parser as what they alias
173+
builtin_parser = app._command_parsers.get(app.do_builtin)
174+
builtin_synonym_parser = app._command_parsers.get(app.do_builtin_synonym)
175+
assert builtin_parser is not None
176+
assert builtin_parser is builtin_synonym_parser
177+
178+
help_parser = app._command_parsers.get(cmd2.Cmd.do_help)
179+
help_synonym_parser = app._command_parsers.get(app.do_help_synonym)
180+
assert help_parser is not None
181+
assert help_parser is help_synonym_parser
182+
183+
# Unregister the CommandSet and make sure built-in command and synonyms are gone
184+
app.unregister_command_set(cs)
185+
assert not hasattr(app, "do_builtin")
186+
assert not hasattr(app, "do_builtin_synonym")
187+
assert not hasattr(app, "do_help_synonym")
188+
189+
# Make sure the help command still exists and works
190+
assert hasattr(app, "do_help")
191+
out, err = run_cmd(app, 'help')
192+
assert app.doc_header in out
193+
194+
152195
def test_custom_construct_commandsets():
153196
command_set_b = CommandSetB('foo')
154197

@@ -291,7 +334,7 @@ def test_load_commandset_errors(command_sets_manual, capsys):
291334
cmd_set = CommandSetA()
292335

293336
# create a conflicting command before installing CommandSet to verify rollback behavior
294-
command_sets_manual._install_command_function('durian', cmd_set.do_durian)
337+
command_sets_manual._install_command_function('do_durian', cmd_set.do_durian)
295338
with pytest.raises(CommandSetRegistrationError):
296339
command_sets_manual.register_command_set(cmd_set)
297340

@@ -316,13 +359,21 @@ def test_load_commandset_errors(command_sets_manual, capsys):
316359
assert "Deleting alias 'banana'" in err
317360
assert "Deleting macro 'apple'" in err
318361

362+
# verify command functions which don't start with "do_" raise an exception
363+
with pytest.raises(CommandSetRegistrationError):
364+
command_sets_manual._install_command_function('new_cmd', cmd_set.do_banana)
365+
366+
# verify methods which don't start with "do_" raise an exception
367+
with pytest.raises(CommandSetRegistrationError):
368+
command_sets_manual._install_command_function('do_new_cmd', cmd_set.on_register)
369+
319370
# verify duplicate commands are detected
320371
with pytest.raises(CommandSetRegistrationError):
321-
command_sets_manual._install_command_function('banana', cmd_set.do_banana)
372+
command_sets_manual._install_command_function('do_banana', cmd_set.do_banana)
322373

323374
# verify bad command names are detected
324375
with pytest.raises(CommandSetRegistrationError):
325-
command_sets_manual._install_command_function('bad command', cmd_set.do_banana)
376+
command_sets_manual._install_command_function('do_bad command', cmd_set.do_banana)
326377

327378
# verify error conflict with existing completer function
328379
with pytest.raises(CommandSetRegistrationError):

0 commit comments

Comments
 (0)