Skip to content

Commit efadff3

Browse files
committed
Merge branch 'master' into doc_additions
2 parents 74857c3 + 1176c0c commit efadff3

File tree

9 files changed

+314
-248
lines changed

9 files changed

+314
-248
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
* Fixed a bug when running a cmd2 application on Linux without Gtk libraries installed
55
* Enhancements
66
* No longer treating empty text scripts as an error condition
7+
* Allow dynamically extending a `cmd2.Cmd` object instance with a `do_xxx` method at runtime
8+
* Choices/Completer functions can now be passed a dictionary that maps command-line tokens to their
9+
argparse argument. This is helpful when one argument determines what is tab completed for another argument.
10+
If these functions have an argument called `arg_tokens`, then AutoCompleter will automatically pass this
11+
dictionary to them.
712

813
## 0.9.16 (August 7, 2019)
914
* Bug Fixes

cmd2/argparse_completer.py

Lines changed: 132 additions & 126 deletions
Large diffs are not rendered by default.

cmd2/argparse_custom.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class called Cmd2ArgumentParser which improves error and help output over normal
4444
generated when the user hits tab.
4545
4646
Example:
47-
def my_choices_function):
47+
def my_choices_function():
4848
...
4949
return my_generated_list
5050
@@ -102,6 +102,20 @@ def my_completer_function(text, line, begidx, endidx):
102102
set_completer_function(action, func)
103103
set_completer_method(action, method)
104104
105+
There are times when what's being tab completed is determined by a previous argument on the command line.
106+
In theses cases, Autocompleter can pass a dictionary that maps the command line tokens up through the one
107+
being completed to their argparse argument name. To receive this dictionary, your choices/completer function
108+
should have an argument called arg_tokens.
109+
110+
Example:
111+
def my_choices_method(self, arg_tokens)
112+
def my_completer_method(self, text, line, begidx, endidx, arg_tokens)
113+
114+
All values of the arg_tokens dictionary are lists, even if a particular argument expects only 1 token. Since
115+
AutoCompleter is for tab completion, it does not convert the tokens to their actual argument types or validate
116+
their values. All tokens are stored in the dictionary as the raw strings provided on the command line. It is up to
117+
the developer to determine if the user entered the correct argument type (e.g. int) and validate their values.
118+
105119
CompletionItem Class:
106120
This class was added to help in cases where uninformative data is being tab completed. For instance,
107121
tab completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems
@@ -138,7 +152,7 @@ def my_completer_function(text, line, begidx, endidx):
138152
To use CompletionItems, just return them from your choices or completer functions.
139153
140154
To avoid printing a ton of information to the screen at once when a user presses tab, there is
141-
a maximum threshold for the number of CompletionItems that will be shown. It's value is defined
155+
a maximum threshold for the number of CompletionItems that will be shown. Its value is defined
142156
in cmd2.Cmd.max_completion_items. It defaults to 50, but can be changed. If the number of completion
143157
suggestions exceeds this number, they will be displayed in the typical columnized format and will
144158
not include the description value of the CompletionItems.
@@ -159,10 +173,9 @@ def my_completer_function(text, line, begidx, endidx):
159173
import argparse
160174
import re
161175
import sys
162-
163176
# noinspection PyUnresolvedReferences,PyProtectedMember
164177
from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _
165-
from typing import Any, Callable, Iterable, List, Optional, Tuple, Union
178+
from typing import Callable, Optional, Tuple, Union
166179

167180
from .ansi import ansi_aware_write, style_error
168181

@@ -272,24 +285,22 @@ def _set_choices_callable(action: argparse.Action, choices_callable: ChoicesCall
272285
setattr(action, ATTR_CHOICES_CALLABLE, choices_callable)
273286

274287

275-
def set_choices_function(action: argparse.Action, choices_function: Callable[[], Iterable[Any]]) -> None:
288+
def set_choices_function(action: argparse.Action, choices_function: Callable) -> None:
276289
"""Set choices_function on an argparse action"""
277290
_set_choices_callable(action, ChoicesCallable(is_method=False, is_completer=False, to_call=choices_function))
278291

279292

280-
def set_choices_method(action: argparse.Action, choices_method: Callable[[Any], Iterable[Any]]) -> None:
293+
def set_choices_method(action: argparse.Action, choices_method: Callable) -> None:
281294
"""Set choices_method on an argparse action"""
282295
_set_choices_callable(action, ChoicesCallable(is_method=True, is_completer=False, to_call=choices_method))
283296

284297

285-
def set_completer_function(action: argparse.Action,
286-
completer_function: Callable[[str, str, int, int], List[str]]) -> None:
298+
def set_completer_function(action: argparse.Action, completer_function: Callable) -> None:
287299
"""Set completer_function on an argparse action"""
288300
_set_choices_callable(action, ChoicesCallable(is_method=False, is_completer=True, to_call=completer_function))
289301

290302

291-
def set_completer_method(action: argparse.Action,
292-
completer_method: Callable[[Any, str, str, int, int], List[str]]) -> None:
303+
def set_completer_method(action: argparse.Action, completer_method: Callable) -> None:
293304
"""Set completer_method on an argparse action"""
294305
_set_choices_callable(action, ChoicesCallable(is_method=True, is_completer=True, to_call=completer_method))
295306

@@ -305,10 +316,10 @@ def set_completer_method(action: argparse.Action,
305316

306317
def _add_argument_wrapper(self, *args,
307318
nargs: Union[int, str, Tuple[int], Tuple[int, int], None] = None,
308-
choices_function: Optional[Callable[[], Iterable[Any]]] = None,
309-
choices_method: Optional[Callable[[Any], Iterable[Any]]] = None,
310-
completer_function: Optional[Callable[[str, str, int, int], List[str]]] = None,
311-
completer_method: Optional[Callable[[Any, str, str, int, int], List[str]]] = None,
319+
choices_function: Optional[Callable] = None,
320+
choices_method: Optional[Callable] = None,
321+
completer_function: Optional[Callable] = None,
322+
completer_method: Optional[Callable] = None,
312323
suppress_tab_hint: bool = False,
313324
descriptive_header: Optional[str] = None,
314325
**kwargs) -> argparse.Action:

cmd2/cmd2.py

Lines changed: 49 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@
119119
# The argparse parser for the command
120120
CMD_ATTR_ARGPARSER = 'argparser'
121121

122+
# Whether or not tokens are unquoted before sending to argparse
123+
CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes'
124+
122125

123126
def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None:
124127
"""Categorize a function.
@@ -225,8 +228,9 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str]):
225228
# Set the command's help text as argparser.description (which can be None)
226229
cmd_wrapper.__doc__ = argparser.description
227230

228-
# Mark this function as having an argparse ArgumentParser
231+
# Set some custom attributes for this command
229232
setattr(cmd_wrapper, CMD_ATTR_ARGPARSER, argparser)
233+
setattr(cmd_wrapper, CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
230234

231235
return cmd_wrapper
232236

@@ -283,8 +287,9 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str]):
283287
# Set the command's help text as argparser.description (which can be None)
284288
cmd_wrapper.__doc__ = argparser.description
285289

286-
# Mark this function as having an argparse ArgumentParser
290+
# Set some custom attributes for this command
287291
setattr(cmd_wrapper, CMD_ATTR_ARGPARSER, argparser)
292+
setattr(cmd_wrapper, CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
288293

289294
return cmd_wrapper
290295

@@ -1431,7 +1436,8 @@ def _completion_for_command(self, text: str, line: str, begidx: int,
14311436
if func is not None and argparser is not None:
14321437
import functools
14331438
compfunc = functools.partial(self._autocomplete_default,
1434-
argparser=argparser)
1439+
argparser=argparser,
1440+
preserve_quotes=getattr(func, CMD_ATTR_PRESERVE_QUOTES))
14351441
else:
14361442
compfunc = self.completedefault
14371443

@@ -1588,13 +1594,21 @@ def complete(self, text: str, state: int) -> Optional[str]:
15881594
self.pexcept(e)
15891595
return None
15901596

1591-
def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int,
1592-
argparser: argparse.ArgumentParser) -> List[str]:
1597+
def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, *,
1598+
argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]:
15931599
"""Default completion function for argparse commands"""
15941600
from .argparse_completer import AutoCompleter
15951601
completer = AutoCompleter(argparser, self)
1596-
tokens, _ = self.tokens_for_completion(line, begidx, endidx)
1597-
return completer.complete_command(tokens, text, line, begidx, endidx)
1602+
tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
1603+
1604+
# To have tab-completion parsing match command line parsing behavior,
1605+
# use preserve_quotes to determine if we parse the quoted or unquoted tokens.
1606+
tokens_to_parse = raw_tokens if preserve_quotes else tokens
1607+
return completer.complete_command(tokens_to_parse, text, line, begidx, endidx)
1608+
1609+
def get_names(self):
1610+
"""Return an alphabetized list of names comprising the attributes of the cmd2 class instance."""
1611+
return dir(self)
15981612

15991613
def get_all_commands(self) -> List[str]:
16001614
"""Return a list of all commands"""
@@ -2384,7 +2398,8 @@ def _alias_list(self, args: argparse.Namespace) -> None:
23842398
alias_parser = Cmd2ArgumentParser(description=alias_description, epilog=alias_epilog, prog='alias')
23852399

23862400
# Add subcommands to alias
2387-
alias_subparsers = alias_parser.add_subparsers()
2401+
alias_subparsers = alias_parser.add_subparsers(dest='subcommand')
2402+
alias_subparsers.required = True
23882403

23892404
# alias -> create
23902405
alias_create_help = "create or overwrite an alias"
@@ -2439,13 +2454,9 @@ def _alias_list(self, args: argparse.Namespace) -> None:
24392454
@with_argparser(alias_parser, preserve_quotes=True)
24402455
def do_alias(self, args: argparse.Namespace) -> None:
24412456
"""Manage aliases"""
2442-
func = getattr(args, 'func', None)
2443-
if func is not None:
2444-
# Call whatever subcommand function was selected
2445-
func(self, args)
2446-
else:
2447-
# noinspection PyTypeChecker
2448-
self.do_help('alias')
2457+
# Call whatever subcommand function was selected
2458+
func = getattr(args, 'func')
2459+
func(self, args)
24492460

24502461
# ----- Macro subcommand functions -----
24512462

@@ -2564,7 +2575,8 @@ def _macro_list(self, args: argparse.Namespace) -> None:
25642575
macro_parser = Cmd2ArgumentParser(description=macro_description, epilog=macro_epilog, prog='macro')
25652576

25662577
# Add subcommands to macro
2567-
macro_subparsers = macro_parser.add_subparsers()
2578+
macro_subparsers = macro_parser.add_subparsers(dest='subcommand')
2579+
macro_subparsers.required = True
25682580

25692581
# macro -> create
25702582
macro_create_help = "create or overwrite a macro"
@@ -2641,13 +2653,9 @@ def _macro_list(self, args: argparse.Namespace) -> None:
26412653
@with_argparser(macro_parser, preserve_quotes=True)
26422654
def do_macro(self, args: argparse.Namespace) -> None:
26432655
"""Manage macros"""
2644-
func = getattr(args, 'func', None)
2645-
if func is not None:
2646-
# Call whatever subcommand function was selected
2647-
func(self, args)
2648-
else:
2649-
# noinspection PyTypeChecker
2650-
self.do_help('macro')
2656+
# Call whatever subcommand function was selected
2657+
func = getattr(args, 'func')
2658+
func(self, args)
26512659

26522660
def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
26532661
"""Completes the command argument of help"""
@@ -2658,49 +2666,34 @@ def complete_help_command(self, text: str, line: str, begidx: int, endidx: int)
26582666
strs_to_match = list(topics | visible_commands)
26592667
return utils.basic_complete(text, line, begidx, endidx, strs_to_match)
26602668

2661-
def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
2662-
"""Completes the subcommand argument of help"""
2663-
2664-
# Get all tokens through the one being completed
2665-
tokens, _ = self.tokens_for_completion(line, begidx, endidx)
2666-
2667-
if not tokens:
2668-
return []
2669-
2670-
# Must have at least 3 args for 'help command subcommand'
2671-
if len(tokens) < 3:
2672-
return []
2669+
def complete_help_subcommands(self, text: str, line: str, begidx: int, endidx: int,
2670+
arg_tokens: Dict[str, List[str]]) -> List[str]:
2671+
"""Completes the subcommands argument of help"""
26732672

2674-
# Find where the command is by skipping past any flags
2675-
cmd_index = 1
2676-
for cur_token in tokens[cmd_index:]:
2677-
if not cur_token.startswith('-'):
2678-
break
2679-
cmd_index += 1
2680-
2681-
if cmd_index >= len(tokens):
2673+
# Make sure we have a command whose subcommands we will complete
2674+
command = arg_tokens['command'][0]
2675+
if not command:
26822676
return []
26832677

2684-
command = tokens[cmd_index]
2685-
matches = []
2686-
26872678
# Check if this command uses argparse
26882679
func = self.cmd_func(command)
26892680
argparser = getattr(func, CMD_ATTR_ARGPARSER, None)
2681+
if func is None or argparser is None:
2682+
return []
26902683

2691-
if func is not None and argparser is not None:
2692-
from .argparse_completer import AutoCompleter
2693-
completer = AutoCompleter(argparser, self)
2694-
matches = completer.complete_command_help(tokens[cmd_index:], text, line, begidx, endidx)
2684+
# Combine the command and its subcommand tokens for the AutoCompleter
2685+
tokens = [command] + arg_tokens['subcommands']
26952686

2696-
return matches
2687+
from .argparse_completer import AutoCompleter
2688+
completer = AutoCompleter(argparser, self)
2689+
return completer.complete_subcommand_help(tokens, text, line, begidx, endidx)
26972690

26982691
help_parser = Cmd2ArgumentParser(description="List available commands or provide "
26992692
"detailed help for a specific command")
27002693
help_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to retrieve help for",
27012694
completer_method=complete_help_command)
2702-
help_parser.add_argument('subcommand', nargs=argparse.REMAINDER, help="subcommand to retrieve help for",
2703-
completer_method=complete_help_subcommand)
2695+
help_parser.add_argument('subcommands', nargs=argparse.REMAINDER, help="subcommand(s) to retrieve help for",
2696+
completer_method=complete_help_subcommands)
27042697
help_parser.add_argument('-v', '--verbose', action='store_true',
27052698
help="print a list of all commands with descriptions of each")
27062699

@@ -2724,7 +2717,7 @@ def do_help(self, args: argparse.Namespace) -> None:
27242717
if func is not None and argparser is not None:
27252718
from .argparse_completer import AutoCompleter
27262719
completer = AutoCompleter(argparser, self)
2727-
tokens = [args.command] + args.subcommand
2720+
tokens = [args.command] + args.subcommands
27282721

27292722
# Set end to blank so the help output matches how it looks when "command -h" is used
27302723
self.poutput(completer.format_help(tokens), end='')
@@ -2959,14 +2952,11 @@ def enable_completion():
29592952
choice = int(response)
29602953
if choice < 1:
29612954
raise IndexError
2962-
result = fulloptions[choice - 1][0]
2963-
break
2955+
return fulloptions[choice - 1][0]
29642956
except (ValueError, IndexError):
29652957
self.poutput("{!r} isn't a valid choice. Pick a number between 1 and {}:".format(
29662958
response, len(fulloptions)))
29672959

2968-
return result
2969-
29702960
def _get_read_only_settings(self) -> str:
29712961
"""Return a summary report of read-only settings which the user cannot modify at runtime.
29722962
@@ -4121,8 +4111,7 @@ def disable_category(self, category: str, message_to_print: str) -> None:
41214111
if getattr(func, CMD_ATTR_HELP_CATEGORY, None) == category:
41224112
self.disable_command(cmd_name, message_to_print)
41234113

4124-
# noinspection PyUnusedLocal
4125-
def _report_disabled_command_usage(self, *args, message_to_print: str, **kwargs) -> None:
4114+
def _report_disabled_command_usage(self, *_args, message_to_print: str, **_kwargs) -> None:
41264115
"""
41274116
Report when a disabled command has been run or had help called on it
41284117
:param args: not used

examples/dynamic_commands.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
# coding=utf-8
3+
"""A simple example demonstrating how do_* commands can be created in a loop.
4+
"""
5+
import functools
6+
import cmd2
7+
COMMAND_LIST = ['foo', 'bar', 'baz']
8+
9+
10+
class CommandsInLoop(cmd2.Cmd):
11+
"""Example of dynamically adding do_* commands."""
12+
def __init__(self):
13+
super().__init__(use_ipython=True)
14+
15+
def send_text(self, args: cmd2.Statement, *, text: str):
16+
"""Simulate sending text to a server and printing the response."""
17+
self.poutput(text.capitalize())
18+
19+
def text_help(self, *, text: str):
20+
"""Deal with printing help for the dynamically added commands."""
21+
self.poutput("Simulate sending {!r} to a server and printing the response".format(text))
22+
23+
24+
for command in COMMAND_LIST:
25+
setattr(CommandsInLoop, 'do_{}'.format(command), functools.partialmethod(CommandsInLoop.send_text, text=command))
26+
setattr(CommandsInLoop, 'help_{}'.format(command), functools.partialmethod(CommandsInLoop.text_help, text=command))
27+
28+
29+
if __name__ == '__main__':
30+
app = CommandsInLoop()
31+
app.cmdloop()

0 commit comments

Comments
 (0)