Skip to content

Commit dcafd4e

Browse files
committed
Fixed issue where subcommand usage text could contain a subcommand alias instead of the actual name
1 parent 0bc9217 commit dcafd4e

File tree

3 files changed

+74
-15
lines changed

3 files changed

+74
-15
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
## 1.0.3 (TBD, 2020)
2+
* Bug Fixes
3+
* Fixed issue where subcommand usage text could contain a subcommand alias instead of the actual name
24
* Enhancements
35
* Made `ipy` consistent with `py` in the following ways
46
* `ipy` returns whether any of the commands run in it returned True to stop command loop

cmd2/decorators.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str):
8585
"""
8686
Recursively set prog attribute of a parser and all of its subparsers so that the root command
8787
is a command name and not sys.argv[0].
88+
8889
:param parser: the parser being edited
8990
:param prog: value for the current parsers prog attribute
9091
"""
@@ -94,11 +95,25 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str):
9495
# Set the prog value for the parser's subcommands
9596
for action in parser._actions:
9697
if isinstance(action, argparse._SubParsersAction):
97-
98-
# Set the prog value for each subcommand
99-
for sub_cmd, sub_cmd_parser in action.choices.items():
100-
sub_cmd_prog = parser.prog + ' ' + sub_cmd
101-
_set_parser_prog(sub_cmd_parser, sub_cmd_prog)
98+
# The keys of action.choices are subcommand names as well as subcommand aliases. The aliases point to the
99+
# same parser as the actual subcommand. We want to avoid placing an alias into a parser's prog value.
100+
# Unfortunately there is nothing about an action.choices entry which tells us it's an alias. In most cases
101+
# we can filter out the aliases by checking the contents of action._choices_actions. This list only contains
102+
# help information and names for the subcommands and not aliases. However, subcommands without help text
103+
# won't show up in that list. Since dictionaries are ordered in Python 3.6 and above and argparse inserts the
104+
# subcommand name into choices dictionary before aliases, we should be OK assuming the first time we see a
105+
# parser, the dictionary key is a subcommand and not alias.
106+
processed_parsers = []
107+
108+
# Set the prog value for each subcommand's parser
109+
for subcmd_name, subcmd_parser in action.choices.items():
110+
# Check if we've already edited this parser
111+
if subcmd_parser in processed_parsers:
112+
continue
113+
114+
subcmd_prog = parser.prog + ' ' + subcmd_name
115+
_set_parser_prog(subcmd_parser, subcmd_prog)
116+
processed_parsers.append(subcmd_parser)
102117

103118
# We can break since argparse only allows 1 group of subcommands per level
104119
break

tests/test_argparse.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,14 @@ def base_bar(self, args):
233233
"""bar subcommand of base command"""
234234
self.poutput('((%s))' % args.z)
235235

236+
def base_helpless(self, args):
237+
"""helpless subcommand of base command"""
238+
self.poutput('((%s))' % args.z)
239+
236240
# create the top-level parser for the base command
237241
base_parser = argparse.ArgumentParser()
238-
base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
242+
base_subparsers = base_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
243+
base_subparsers.required = True
239244

240245
# create the parser for the "foo" subcommand
241246
parser_foo = base_subparsers.add_parser('foo', help='foo help')
@@ -244,20 +249,24 @@ def base_bar(self, args):
244249
parser_foo.set_defaults(func=base_foo)
245250

246251
# create the parser for the "bar" subcommand
247-
parser_bar = base_subparsers.add_parser('bar', help='bar help')
252+
parser_bar = base_subparsers.add_parser('bar', help='bar help', aliases=['bar_1', 'bar_2'])
253+
parser_bar.add_argument('z', help='string')
254+
parser_bar.set_defaults(func=base_bar)
255+
256+
# create the parser for the "helpless" subcommand
257+
# This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which
258+
# use an approach which relies on action._choices_actions list. See comment in that function for more
259+
# details.
260+
parser_bar = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2'])
248261
parser_bar.add_argument('z', help='string')
249262
parser_bar.set_defaults(func=base_bar)
250263

251264
@cmd2.with_argparser(base_parser)
252265
def do_base(self, args):
253266
"""Base command help"""
254-
func = getattr(args, 'func', None)
255-
if func is not None:
256-
# Call whatever subcommand function was selected
257-
func(self, args)
258-
else:
259-
# No subcommand was provided, so call help
260-
self.do_help('base')
267+
# Call whatever subcommand function was selected
268+
func = getattr(args, 'func')
269+
func(self, args)
261270

262271
@pytest.fixture
263272
def subcommand_app():
@@ -277,7 +286,7 @@ def test_subcommand_bar(subcommand_app):
277286
def test_subcommand_invalid(subcommand_app):
278287
out, err = run_cmd(subcommand_app, 'base baz')
279288
assert err[0].startswith('usage: base')
280-
assert err[1].startswith("base: error: invalid choice: 'baz'")
289+
assert err[1].startswith("base: error: argument SUBCOMMAND: invalid choice: 'baz'")
281290

282291
def test_subcommand_base_help(subcommand_app):
283292
out, err = run_cmd(subcommand_app, 'help base')
@@ -286,11 +295,44 @@ def test_subcommand_base_help(subcommand_app):
286295
assert out[2] == 'Base command help'
287296

288297
def test_subcommand_help(subcommand_app):
298+
# foo has no aliases
289299
out, err = run_cmd(subcommand_app, 'help base foo')
290300
assert out[0].startswith('usage: base foo')
291301
assert out[1] == ''
292302
assert out[2] == 'positional arguments:'
293303

304+
# bar has aliases (usage should never show alias name)
305+
out, err = run_cmd(subcommand_app, 'help base bar')
306+
assert out[0].startswith('usage: base bar')
307+
assert out[1] == ''
308+
assert out[2] == 'positional arguments:'
309+
310+
out, err = run_cmd(subcommand_app, 'help base bar_1')
311+
assert out[0].startswith('usage: base bar')
312+
assert out[1] == ''
313+
assert out[2] == 'positional arguments:'
314+
315+
out, err = run_cmd(subcommand_app, 'help base bar_2')
316+
assert out[0].startswith('usage: base bar')
317+
assert out[1] == ''
318+
assert out[2] == 'positional arguments:'
319+
320+
# helpless has aliases and no help text (usage should never show alias name)
321+
out, err = run_cmd(subcommand_app, 'help base helpless')
322+
assert out[0].startswith('usage: base helpless')
323+
assert out[1] == ''
324+
assert out[2] == 'positional arguments:'
325+
326+
out, err = run_cmd(subcommand_app, 'help base helpless_1')
327+
assert out[0].startswith('usage: base helpless')
328+
assert out[1] == ''
329+
assert out[2] == 'positional arguments:'
330+
331+
out, err = run_cmd(subcommand_app, 'help base helpless_2')
332+
assert out[0].startswith('usage: base helpless')
333+
assert out[1] == ''
334+
assert out[2] == 'positional arguments:'
335+
294336

295337
def test_subcommand_invalid_help(subcommand_app):
296338
out, err = run_cmd(subcommand_app, 'help base baz')

0 commit comments

Comments
 (0)