Skip to content

Commit b72edb0

Browse files
authored
Merge pull request #781 from python-cmd2/CompletionError
CompletionError class
2 parents 27a0adb + 88db3b4 commit b72edb0

File tree

6 files changed

+126
-35
lines changed

6 files changed

+126
-35
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
argparse argument. This is helpful when one argument determines what is tab completed for another argument.
1010
If these functions have an argument called `arg_tokens`, then AutoCompleter will automatically pass this
1111
dictionary to them.
12+
* Added CompletionError class that can be raised during argparse-based tab completion and printed to the user
1213

1314
## 0.9.16 (August 7, 2019)
1415
* Bug Fixes

cmd2/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
pass
1212

1313
from .ansi import style
14-
from .argparse_custom import Cmd2ArgumentParser, CompletionItem
14+
from .argparse_custom import Cmd2ArgumentParser, CompletionError, CompletionItem
1515
from .cmd2 import Cmd, Statement, EmptyStatement, categorize
1616
from .cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category
1717
from .constants import DEFAULT_SHORTCUTS

cmd2/argparse_completer.py

Lines changed: 62 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
from . import cmd2
1616
from . import utils
1717
from .ansi import ansi_safe_wcswidth, style_error
18+
from .argparse_custom import ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error
1819
from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE
19-
from .argparse_custom import ChoicesCallable, CompletionItem, ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error
20+
from .argparse_custom import ChoicesCallable, CompletionError, CompletionItem
2021
from .rl_utils import rl_force_redisplay
2122

2223
# If no descriptive header is supplied, then this will be used instead
@@ -319,8 +320,12 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
319320

320321
# Check if we are completing a flag's argument
321322
if flag_arg_state is not None:
322-
completion_results = self._complete_for_arg(flag_arg_state.action, text, line,
323-
begidx, endidx, consumed_arg_values)
323+
try:
324+
completion_results = self._complete_for_arg(flag_arg_state.action, text, line,
325+
begidx, endidx, consumed_arg_values)
326+
except CompletionError as ex:
327+
self._print_completion_error(flag_arg_state.action, ex)
328+
return []
324329

325330
# If we have results, then return them
326331
if completion_results:
@@ -341,8 +346,12 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
341346
action = self._positional_actions[pos_index]
342347
pos_arg_state = AutoCompleter._ArgumentState(action)
343348

344-
completion_results = self._complete_for_arg(pos_arg_state.action, text, line,
345-
begidx, endidx, consumed_arg_values)
349+
try:
350+
completion_results = self._complete_for_arg(pos_arg_state.action, text, line,
351+
begidx, endidx, consumed_arg_values)
352+
except CompletionError as ex:
353+
self._print_completion_error(pos_arg_state.action, ex)
354+
return []
346355

347356
# If we have results, then return them
348357
if completion_results:
@@ -456,7 +465,11 @@ def format_help(self, tokens: List[str]) -> str:
456465
def _complete_for_arg(self, arg_action: argparse.Action,
457466
text: str, line: str, begidx: int, endidx: int,
458467
consumed_arg_values: Dict[str, List[str]]) -> List[str]:
459-
"""Tab completion routine for an argparse argument"""
468+
"""
469+
Tab completion routine for an argparse argument
470+
:return: list of completions
471+
:raises CompletionError if the completer or choices function this calls raises one
472+
"""
460473
# Check if the arg provides choices to the user
461474
if arg_action.choices is not None:
462475
arg_choices = arg_action.choices
@@ -520,53 +533,72 @@ def _complete_for_arg(self, arg_action: argparse.Action,
520533
return self._format_completions(arg_action, results)
521534

522535
@staticmethod
523-
def _print_arg_hint(arg_action: argparse.Action) -> None:
524-
"""Print argument hint to the terminal when tab completion results in no results"""
525-
526-
# Check if hinting is disabled
527-
suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False)
528-
if suppress_hint or arg_action.help == argparse.SUPPRESS or arg_action.dest == argparse.SUPPRESS:
529-
return
530-
536+
def _format_message_prefix(arg_action: argparse.Action) -> str:
537+
"""Format the arg prefix text that appears before messages printed to the user"""
531538
# Check if this is a flag
532539
if arg_action.option_strings:
533540
flags = ', '.join(arg_action.option_strings)
534541
param = ' ' + str(arg_action.dest).upper()
535-
prefix = '{}{}'.format(flags, param)
542+
return '{}{}'.format(flags, param)
536543

537544
# Otherwise this is a positional
538545
else:
539-
prefix = '{}'.format(str(arg_action.dest).upper())
546+
return '{}'.format(str(arg_action.dest).upper())
547+
548+
@staticmethod
549+
def _print_message(msg: str) -> None:
550+
"""Print a message instead of tab completions and redraw the prompt and input line"""
551+
print(msg)
552+
rl_force_redisplay()
553+
554+
def _print_arg_hint(self, arg_action: argparse.Action) -> None:
555+
"""
556+
Print argument hint to the terminal when tab completion results in no results
557+
:param arg_action: action being tab completed
558+
"""
559+
# Check if hinting is disabled
560+
suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False)
561+
if suppress_hint or arg_action.help == argparse.SUPPRESS or arg_action.dest == argparse.SUPPRESS:
562+
return
540563

564+
prefix = self._format_message_prefix(arg_action)
541565
prefix = ' {0: <{width}} '.format(prefix, width=20)
542566
pref_len = len(prefix)
543567

544568
help_text = '' if arg_action.help is None else arg_action.help
545569
help_lines = help_text.splitlines()
546570

547571
if len(help_lines) == 1:
548-
print('\nHint:\n{}{}\n'.format(prefix, help_lines[0]))
572+
self._print_message('\nHint:\n{}{}\n'.format(prefix, help_lines[0]))
549573
else:
550574
out_str = '\n{}'.format(prefix)
551575
out_str += '\n{0: <{width}}'.format('', width=pref_len).join(help_lines)
552-
print('\nHint:' + out_str + '\n')
576+
self._print_message('\nHint:' + out_str + '\n')
553577

554-
# Redraw prompt and input line
555-
rl_force_redisplay()
556-
557-
@staticmethod
558-
def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None:
559-
"""Print an error during tab completion when the user has not finished the current flag"""
560-
flags = ', '.join(flag_arg_state.action.option_strings)
561-
param = ' ' + str(flag_arg_state.action.dest).upper()
562-
prefix = '{}{}'.format(flags, param)
578+
def _print_unfinished_flag_error(self, flag_arg_state: _ArgumentState) -> None:
579+
"""
580+
Print an error during tab completion when the user has not finished the current flag
581+
:param flag_arg_state: information about the unfinished flag action
582+
"""
583+
prefix = self._format_message_prefix(flag_arg_state.action)
563584

564585
out_str = "\nError:\n"
565586
out_str += ' {0: <{width}} '.format(prefix, width=20)
566587
out_str += generate_range_error(flag_arg_state.min, flag_arg_state.max)
567588

568589
out_str += ' ({} entered)'.format(flag_arg_state.count)
569-
print(style_error('{}\n'.format(out_str)))
590+
self._print_message(style_error('{}\n'.format(out_str)))
570591

571-
# Redraw prompt and input line
572-
rl_force_redisplay()
592+
def _print_completion_error(self, arg_action: argparse.Action, completion_error: CompletionError) -> None:
593+
"""
594+
Print a CompletionError to the user
595+
:param arg_action: action being tab completed
596+
:param completion_error: error that occurred
597+
"""
598+
prefix = self._format_message_prefix(arg_action)
599+
600+
out_str = "\nError:\n"
601+
out_str += ' {0: <{width}} '.format(prefix, width=20)
602+
out_str += str(completion_error)
603+
604+
self._print_message(style_error('{}\n'.format(out_str)))

cmd2/argparse_custom.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def my_completer_function(text, line, begidx, endidx):
9494
as dynamic. Therefore it is up to the developer to validate if the user has typed an acceptable value for these
9595
arguments.
9696
97-
The following functions exist in cases where you may want to manually add choice providing function/methods to
97+
The following functions exist in cases where you may want to manually a add choice-providing function/method to
9898
an existing argparse action. For instance, in __init__() of a custom action class.
9999
100100
set_choices_function(action, func)
@@ -116,6 +116,13 @@ def my_completer_method(self, text, line, begidx, endidx, arg_tokens)
116116
their values. All tokens are stored in the dictionary as the raw strings provided on the command line. It is up to
117117
the developer to determine if the user entered the correct argument type (e.g. int) and validate their values.
118118
119+
CompletionError Class:
120+
Raised during tab-completion operations to report any sort of error you want printed by the AutoCompleter
121+
122+
Example use cases
123+
- Reading a database to retrieve a tab completion data set failed
124+
- A previous command line argument that determines the data set being completed is invalid
125+
119126
CompletionItem Class:
120127
This class was added to help in cases where uninformative data is being tab completed. For instance,
121128
tab completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems
@@ -221,6 +228,17 @@ def generate_range_error(range_min: int, range_max: Union[int, float]) -> str:
221228
return err_str
222229

223230

231+
class CompletionError(Exception):
232+
"""
233+
Raised during tab-completion operations to report any sort of error you want printed by the AutoCompleter
234+
235+
Example use cases
236+
- Reading a database to retrieve a tab completion data set failed
237+
- A previous command line argument that determines the data set being completed is invalid
238+
"""
239+
pass
240+
241+
224242
class CompletionItem(str):
225243
"""
226244
Completion item with descriptive text attached

cmd2/cmd2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ class Cmd(cmd.Cmd):
341341

342342
def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
343343
persistent_history_file: str = '', persistent_history_length: int = 1000,
344-
startup_script: Optional[str] = None, use_ipython: bool = False,
344+
startup_script: str = '', use_ipython: bool = False,
345345
allow_cli_args: bool = True, transcript_files: Optional[List[str]] = None,
346346
allow_redirection: bool = True, multiline_commands: Optional[List[str]] = None,
347347
terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None) -> None:
@@ -499,7 +499,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
499499
self._startup_commands = []
500500

501501
# If a startup script is provided, then execute it in the startup commands
502-
if startup_script is not None:
502+
if startup_script:
503503
startup_script = os.path.abspath(os.path.expanduser(startup_script))
504504
if os.path.exists(startup_script) and os.path.getsize(startup_script) > 0:
505505
self._startup_commands.append("run_script '{}'".format(startup_script))

tests/test_argparse_completer.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010

1111
import cmd2
12-
from cmd2 import with_argparser, Cmd2ArgumentParser, CompletionItem
12+
from cmd2 import with_argparser, Cmd2ArgumentParser, CompletionError, CompletionItem
1313
from cmd2.utils import StdSim, basic_complete
1414
from .conftest import run_cmd, complete_tester
1515

@@ -210,6 +210,27 @@ def do_nargs(self, args: argparse.Namespace) -> None:
210210
def do_hint(self, args: argparse.Namespace) -> None:
211211
pass
212212

213+
############################################################################################################
214+
# Begin code related to CompletionError
215+
############################################################################################################
216+
def completer_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
217+
"""Raises CompletionError"""
218+
raise CompletionError('completer broke something')
219+
220+
def choice_raise_error(self) -> List[str]:
221+
"""Raises CompletionError"""
222+
raise CompletionError('choice broke something')
223+
224+
comp_error_parser = Cmd2ArgumentParser()
225+
comp_error_parser.add_argument('completer', help='positional arg',
226+
completer_method=completer_raise_error)
227+
comp_error_parser.add_argument('--choice', help='flag arg',
228+
choices_method=choice_raise_error)
229+
230+
@with_argparser(comp_error_parser)
231+
def do_raise_completion_error(self, args: argparse.Namespace) -> None:
232+
pass
233+
213234
############################################################################################################
214235
# Begin code related to receiving arg_tokens
215236
############################################################################################################
@@ -723,6 +744,25 @@ def test_autocomp_hint_no_help_text(ac_app, capsys):
723744
'''
724745

725746

747+
@pytest.mark.parametrize('args, text', [
748+
# Exercise a flag arg and choices function that raises a CompletionError
749+
('--choice ', 'choice'),
750+
751+
# Exercise a positional arg and completer that raises a CompletionError
752+
('', 'completer')
753+
])
754+
def test_completion_error(ac_app, capsys, args, text):
755+
line = 'raise_completion_error {} {}'.format(args, text)
756+
endidx = len(line)
757+
begidx = endidx - len(text)
758+
759+
first_match = complete_tester(text, line, begidx, endidx, ac_app)
760+
out, err = capsys.readouterr()
761+
762+
assert first_match is None
763+
assert "{} broke something".format(text) in out
764+
765+
726766
@pytest.mark.parametrize('command_and_args, completions', [
727767
# Exercise a choices function that receives arg_tokens dictionary
728768
('arg_tokens choice subcmd', ['choice', 'subcmd']),

0 commit comments

Comments
 (0)