Skip to content

Commit f086960

Browse files
authored
Merge pull request #365 from python-cmd2/bugfix/364
Backport bugfix related to help completion on commands using with_arg…
2 parents 09b22c5 + 19a72b6 commit f086960

File tree

4 files changed

+239
-52
lines changed

4 files changed

+239
-52
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## 0.8.6 (TBD)
22
* Bug Fixes
3-
* TBD
3+
* Commands using the @with_argparser_and_unknown_args were not correctly recognized when tab completing help
44

55
## 0.8.5 (April 15, 2018)
66
* Bug Fixes

cmd2.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,8 +420,18 @@ def cmd_wrapper(instance, cmdline):
420420

421421
# If there are subcommands, store their names in a list to support tab-completion of subcommand names
422422
if argparser._subparsers is not None:
423-
subcommand_names = argparser._subparsers._group_actions[0]._name_parser_map.keys()
424-
cmd_wrapper.__dict__['subcommand_names'] = subcommand_names
423+
# Key is subcommand name and value is completer function
424+
subcommands = collections.OrderedDict()
425+
426+
# Get all subcommands and check if they have completer functions
427+
for name, parser in argparser._subparsers._group_actions[0]._name_parser_map.items():
428+
if 'completer' in parser._defaults:
429+
completer = parser._defaults['completer']
430+
else:
431+
completer = None
432+
subcommands[name] = completer
433+
434+
cmd_wrapper.__dict__['subcommands'] = subcommands
425435

426436
return cmd_wrapper
427437

tests/conftest.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,24 @@
88
import sys
99

1010
from pytest import fixture
11+
try:
12+
from unittest import mock
13+
except ImportError:
14+
import mock
1115

1216
import cmd2
1317

18+
# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
19+
try:
20+
import gnureadline as readline
21+
except ImportError:
22+
# Try to import readline, but allow failure for convenience in Windows unit testing
23+
# Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
24+
try:
25+
# noinspection PyUnresolvedReferences
26+
import readline
27+
except ImportError:
28+
pass
1429

1530
# Help text for base cmd2.Cmd application
1631
BASE_HELP = """Documented commands (type help <topic>):
@@ -141,3 +156,38 @@ def base_app():
141156
c = cmd2.Cmd()
142157
c.stdout = StdOut()
143158
return c
159+
160+
161+
def complete_tester(text, line, begidx, endidx, app):
162+
"""
163+
This is a convenience function to test cmd2.complete() since
164+
in a unit test environment there is no actual console readline
165+
is monitoring. Therefore we use mock to provide readline data
166+
to complete().
167+
168+
:param text: str - the string prefix we are attempting to match
169+
:param line: str - the current input line with leading whitespace removed
170+
:param begidx: int - the beginning index of the prefix text
171+
:param endidx: int - the ending index of the prefix text
172+
:param app: the cmd2 app that will run completions
173+
:return: The first matched string or None if there are no matches
174+
Matches are stored in app.completion_matches
175+
These matches also have been sorted by complete()
176+
"""
177+
def get_line():
178+
return line
179+
180+
def get_begidx():
181+
return begidx
182+
183+
def get_endidx():
184+
return endidx
185+
186+
first_match = None
187+
with mock.patch.object(readline, 'get_line_buffer', get_line):
188+
with mock.patch.object(readline, 'get_begidx', get_begidx):
189+
with mock.patch.object(readline, 'get_endidx', get_endidx):
190+
# Run the readline tab-completion function with readline mocks in place
191+
first_match = app.complete(text, 0)
192+
193+
return first_match

tests/test_completion.py

Lines changed: 176 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,8 @@
1313
import sys
1414

1515
import cmd2
16-
import mock
1716
import pytest
18-
19-
# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
20-
try:
21-
import gnureadline as readline
22-
except ImportError:
23-
# Try to import readline, but allow failure for convenience in Windows unit testing
24-
# Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows
25-
try:
26-
# noinspection PyUnresolvedReferences
27-
import readline
28-
except ImportError:
29-
pass
30-
17+
from conftest import complete_tester
3118

3219
# List of strings used with completion functions
3320
food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato']
@@ -87,41 +74,6 @@ def cmd2_app():
8774
return c
8875

8976

90-
def complete_tester(text, line, begidx, endidx, app):
91-
"""
92-
This is a convenience function to test cmd2.complete() since
93-
in a unit test environment there is no actual console readline
94-
is monitoring. Therefore we use mock to provide readline data
95-
to complete().
96-
97-
:param text: str - the string prefix we are attempting to match
98-
:param line: str - the current input line with leading whitespace removed
99-
:param begidx: int - the beginning index of the prefix text
100-
:param endidx: int - the ending index of the prefix text
101-
:param app: the cmd2 app that will run completions
102-
:return: The first matched string or None if there are no matches
103-
Matches are stored in app.completion_matches
104-
These matches also have been sorted by complete()
105-
"""
106-
def get_line():
107-
return line
108-
109-
def get_begidx():
110-
return begidx
111-
112-
def get_endidx():
113-
return endidx
114-
115-
first_match = None
116-
with mock.patch.object(readline, 'get_line_buffer', get_line):
117-
with mock.patch.object(readline, 'get_begidx', get_begidx):
118-
with mock.patch.object(readline, 'get_endidx', get_endidx):
119-
# Run the readline tab-completion function with readline mocks in place
120-
first_match = app.complete(text, 0)
121-
122-
return first_match
123-
124-
12577
def test_cmd2_command_completion_single(cmd2_app):
12678
text = 'he'
12779
line = text
@@ -911,6 +863,7 @@ def test_subcommand_tab_completion(sc_app):
911863
# It is at end of line, so extra space is present
912864
assert first_match is not None and sc_app.completion_matches == ['Football ']
913865

866+
914867
def test_subcommand_tab_completion_with_no_completer(sc_app):
915868
# This tests what happens when a subcommand has no completer
916869
# In this case, the foo subcommand has no completer defined
@@ -922,6 +875,7 @@ def test_subcommand_tab_completion_with_no_completer(sc_app):
922875
first_match = complete_tester(text, line, begidx, endidx, sc_app)
923876
assert first_match is None
924877

878+
925879
def test_subcommand_tab_completion_space_in_text(sc_app):
926880
text = 'B'
927881
line = 'base sport "Space {}'.format(text)
@@ -934,6 +888,179 @@ def test_subcommand_tab_completion_space_in_text(sc_app):
934888
sc_app.completion_matches == ['Ball" '] and \
935889
sc_app.display_matches == ['Space Ball']
936890

891+
####################################################
892+
893+
894+
class SubcommandsWithUnknownExample(cmd2.Cmd):
895+
"""
896+
Example cmd2 application where we a base command which has a couple subcommands
897+
and the "sport" subcommand has tab completion enabled.
898+
"""
899+
900+
def __init__(self):
901+
cmd2.Cmd.__init__(self)
902+
903+
# subcommand functions for the base command
904+
def base_foo(self, args):
905+
"""foo subcommand of base command"""
906+
self.poutput(args.x * args.y)
907+
908+
def base_bar(self, args):
909+
"""bar subcommand of base command"""
910+
self.poutput('((%s))' % args.z)
911+
912+
def base_sport(self, args):
913+
"""sport subcommand of base command"""
914+
self.poutput('Sport is {}'.format(args.sport))
915+
916+
# noinspection PyUnusedLocal
917+
def complete_base_sport(self, text, line, begidx, endidx):
918+
""" Adds tab completion to base sport subcommand """
919+
index_dict = {1: sport_item_strs}
920+
return self.index_based_complete(text, line, begidx, endidx, index_dict)
921+
922+
# create the top-level parser for the base command
923+
base_parser = argparse.ArgumentParser(prog='base')
924+
base_subparsers = base_parser.add_subparsers(title='subcommands', help='subcommand help')
925+
926+
# create the parser for the "foo" subcommand
927+
parser_foo = base_subparsers.add_parser('foo', help='foo help')
928+
parser_foo.add_argument('-x', type=int, default=1, help='integer')
929+
parser_foo.add_argument('y', type=float, help='float')
930+
parser_foo.set_defaults(func=base_foo)
931+
932+
# create the parser for the "bar" subcommand
933+
parser_bar = base_subparsers.add_parser('bar', help='bar help')
934+
parser_bar.add_argument('z', help='string')
935+
parser_bar.set_defaults(func=base_bar)
936+
937+
# create the parser for the "sport" subcommand
938+
parser_sport = base_subparsers.add_parser('sport', help='sport help')
939+
parser_sport.add_argument('sport', help='Enter name of a sport')
940+
941+
# Set both a function and tab completer for the "sport" subcommand
942+
parser_sport.set_defaults(func=base_sport, completer=complete_base_sport)
943+
944+
@cmd2.with_argparser_and_unknown_args(base_parser)
945+
def do_base(self, args):
946+
"""Base command help"""
947+
func = getattr(args, 'func', None)
948+
if func is not None:
949+
# Call whatever subcommand function was selected
950+
func(self, args)
951+
else:
952+
# No subcommand was provided, so call help
953+
self.do_help('base')
954+
955+
# Enable tab completion of base to make sure the subcommands' completers get called.
956+
complete_base = cmd2.Cmd.cmd_with_subs_completer
957+
958+
959+
@pytest.fixture
960+
def scu_app():
961+
"""Declare test fixture for with_argparser_and_unknown_args"""
962+
app = SubcommandsWithUnknownExample()
963+
return app
964+
965+
966+
def test_cmd2_subcmd_with_unknown_completion_single_end(scu_app):
967+
text = 'f'
968+
line = 'base {}'.format(text)
969+
endidx = len(line)
970+
begidx = endidx - len(text)
971+
972+
first_match = complete_tester(text, line, begidx, endidx, scu_app)
973+
974+
# It is at end of line, so extra space is present
975+
assert first_match is not None and scu_app.completion_matches == ['foo ']
976+
977+
978+
def test_cmd2_subcmd_with_unknown_completion_multiple(scu_app):
979+
text = ''
980+
line = 'base {}'.format(text)
981+
endidx = len(line)
982+
begidx = endidx - len(text)
983+
984+
first_match = complete_tester(text, line, begidx, endidx, scu_app)
985+
assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport']
986+
987+
988+
def test_cmd2_subcmd_with_unknown_completion_nomatch(scu_app):
989+
text = 'z'
990+
line = 'base {}'.format(text)
991+
endidx = len(line)
992+
begidx = endidx - len(text)
993+
994+
first_match = complete_tester(text, line, begidx, endidx, scu_app)
995+
assert first_match is None
996+
997+
998+
def test_cmd2_help_subcommand_completion_single(scu_app):
999+
text = 'base'
1000+
line = 'help {}'.format(text)
1001+
endidx = len(line)
1002+
begidx = endidx - len(text)
1003+
assert scu_app.complete_help(text, line, begidx, endidx) == ['base']
1004+
1005+
1006+
def test_cmd2_help_subcommand_completion_multiple(scu_app):
1007+
text = ''
1008+
line = 'help base {}'.format(text)
1009+
endidx = len(line)
1010+
begidx = endidx - len(text)
1011+
1012+
matches = sorted(scu_app.complete_help(text, line, begidx, endidx))
1013+
assert matches == ['bar', 'foo', 'sport']
1014+
1015+
1016+
def test_cmd2_help_subcommand_completion_nomatch(scu_app):
1017+
text = 'z'
1018+
line = 'help base {}'.format(text)
1019+
endidx = len(line)
1020+
begidx = endidx - len(text)
1021+
assert scu_app.complete_help(text, line, begidx, endidx) == []
1022+
1023+
1024+
def test_subcommand_tab_completion(scu_app):
1025+
# This makes sure the correct completer for the sport subcommand is called
1026+
text = 'Foot'
1027+
line = 'base sport {}'.format(text)
1028+
endidx = len(line)
1029+
begidx = endidx - len(text)
1030+
1031+
first_match = complete_tester(text, line, begidx, endidx, scu_app)
1032+
1033+
# It is at end of line, so extra space is present
1034+
assert first_match is not None and scu_app.completion_matches == ['Football ']
1035+
1036+
1037+
def test_subcommand_tab_completion_with_no_completer(scu_app):
1038+
# This tests what happens when a subcommand has no completer
1039+
# In this case, the foo subcommand has no completer defined
1040+
text = 'Foot'
1041+
line = 'base foo {}'.format(text)
1042+
endidx = len(line)
1043+
begidx = endidx - len(text)
1044+
1045+
first_match = complete_tester(text, line, begidx, endidx, scu_app)
1046+
assert first_match is None
1047+
1048+
1049+
def test_subcommand_tab_completion_space_in_text(scu_app):
1050+
text = 'B'
1051+
line = 'base sport "Space {}'.format(text)
1052+
endidx = len(line)
1053+
begidx = endidx - len(text)
1054+
1055+
first_match = complete_tester(text, line, begidx, endidx, scu_app)
1056+
1057+
assert first_match is not None and \
1058+
scu_app.completion_matches == ['Ball" '] and \
1059+
scu_app.display_matches == ['Space Ball']
1060+
1061+
####################################################
1062+
1063+
9371064
class SecondLevel(cmd2.Cmd):
9381065
"""To be used as a second level command class. """
9391066

0 commit comments

Comments
 (0)