diff options
-rw-r--r-- | cmd2/cmd2.py | 201 | ||||
-rw-r--r-- | cmd2/utils.py | 61 | ||||
-rw-r--r-- | tests/conftest.py | 10 | ||||
-rwxr-xr-x | tests/test_cmd2.py | 33 | ||||
-rw-r--r-- | tests/test_transcript.py | 4 | ||||
-rw-r--r-- | tests/transcripts/regex_set.txt | 4 |
6 files changed, 181 insertions, 132 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 34435ed0..52971ddd 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -47,7 +47,7 @@ from . import ansi from . import constants from . import plugin from . import utils -from .argparse_custom import CompletionItem, DEFAULT_ARGUMENT_PARSER +from .argparse_custom import CompletionError, CompletionItem, DEFAULT_ARGUMENT_PARSER from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .decorators import with_argparser from .history import History, HistoryItem @@ -198,22 +198,9 @@ class Cmd(cmd.Cmd): # not include the description value of the CompletionItems. self.max_completion_items = 50 - # To make an attribute settable with the "do_set" command, add it to this ... - self.settable = \ - { - # allow_style is a special case in which it's an application-wide setting defined in ansi.py - 'allow_style': ('Allow ANSI text style sequences in output ' - '(valid values: {}, {}, {})'.format(ansi.STYLE_TERMINAL, - ansi.STYLE_ALWAYS, - ansi.STYLE_NEVER)), - 'debug': 'Show full error stack on error', - 'echo': 'Echo command issued into output', - 'editor': 'Program used by ``edit``', - 'feedback_to_output': 'Include nonessentials in `|`, `>` results', - 'max_completion_items': 'Maximum number of CompletionItems to display during tab completion', - 'quiet': "Don't print nonessential feedback", - 'timing': 'Report execution times' - } + # A dictionary mapping settable names to their Settable instance + self.settables = dict() + self.build_settables() # Use as prompt for multiline commands on the 2nd+ line of input self.continuation_prompt = '> ' @@ -393,6 +380,31 @@ class Cmd(cmd.Cmd): # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. self.matches_sorted = False + def add_settable(self, settable: utils.Settable) -> None: + """ + Convenience method to add a settable parameter to self.settables + :param settable: Settable object being added + """ + self.settables[settable.name] = settable + + def build_settables(self): + """Populates self.add_settable with parameters that can be edited via the set command""" + self.add_settable(utils.Settable('allow_style', str, + 'Allow ANSI text style sequences in output (valid values: ' + '{}, {}, {})'.format(ansi.STYLE_TERMINAL, + ansi.STYLE_ALWAYS, + ansi.STYLE_NEVER), + choices=[ansi.STYLE_TERMINAL, ansi.STYLE_ALWAYS, ansi.STYLE_NEVER])) + + self.add_settable(utils.Settable('debug', bool, "Show full error stack on error")) + self.add_settable(utils.Settable('echo', bool, "Echo command issued into output")) + self.add_settable(utils.Settable('editor', str, "Program used by `edit`")) + self.add_settable(utils.Settable('feedback_to_output', bool, "Include nonessentials in '|', '>' results")) + self.add_settable(utils.Settable('max_completion_items', int, + "Maximum number of CompletionItems to display during tab completion")) + self.add_settable(utils.Settable('quiet', bool, "Don't print nonessential feedback")) + self.add_settable(utils.Settable('timing', bool, "Report execution times")) + # ----- Methods related to presenting output to the user ----- @property @@ -411,8 +423,9 @@ class Cmd(cmd.Cmd): elif new_val == ansi.STYLE_NEVER.lower(): ansi.allow_style = ansi.STYLE_NEVER else: - self.perror('Invalid value: {} (valid values: {}, {}, {})'.format(new_val, ansi.STYLE_TERMINAL, - ansi.STYLE_ALWAYS, ansi.STYLE_NEVER)) + raise ValueError('Invalid value: {} (valid values: {}, {}, {})'.format(new_val, ansi.STYLE_TERMINAL, + ansi.STYLE_ALWAYS, + ansi.STYLE_NEVER)) def _completion_supported(self) -> bool: """Return whether tab completion is supported""" @@ -497,7 +510,7 @@ class Cmd(cmd.Cmd): if apply_style: final_msg = ansi.style_error(final_msg) - if not self.debug and 'debug' in self.settable: + if not self.debug and 'debug' in self.settables: warning = "\nTo enable full traceback, run the following command: 'set debug true'" final_msg += ansi.style_warning(warning) @@ -1451,7 +1464,7 @@ class Cmd(cmd.Cmd): def _get_settable_completion_items(self) -> List[CompletionItem]: """Return list of current settable names and descriptions as CompletionItems""" - return [CompletionItem(cur_key, self.settable[cur_key]) for cur_key in self.settable] + return [CompletionItem(cur_key, self.settables[cur_key].description) for cur_key in self.settables] def _get_commands_aliases_and_macros_for_completion(self) -> List[str]: """Return a list of visible commands, aliases, and macros for tab completion""" @@ -2262,7 +2275,6 @@ class Cmd(cmd.Cmd): alias_subparsers.required = True # alias -> create - alias_create_help = "create or overwrite an alias" alias_create_description = "Create or overwrite an alias" alias_create_epilog = ("Notes:\n" @@ -2277,7 +2289,7 @@ class Cmd(cmd.Cmd): " alias create show_log !cat \"log file.txt\"\n" " alias create save_results print_results \">\" out.txt\n") - alias_create_parser = alias_subparsers.add_parser('create', help=alias_create_help, + alias_create_parser = alias_subparsers.add_parser('create', help=alias_create_description.lower(), description=alias_create_description, epilog=alias_create_epilog) alias_create_parser.add_argument('name', help='name of this alias') @@ -2594,8 +2606,7 @@ class Cmd(cmd.Cmd): super().do_help(args.command) def _help_menu(self, verbose: bool = False) -> None: - """Show a list of commands which help can be displayed for. - """ + """Show a list of commands which help can be displayed for""" # Get a sorted list of help topics help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) @@ -2798,93 +2809,95 @@ class Cmd(cmd.Cmd): self.poutput("{!r} isn't a valid choice. Pick a number between 1 and {}:".format( response, len(fulloptions))) - def _get_read_only_settings(self) -> str: - """Return a summary report of read-only settings which the user cannot modify at runtime. - - :return: The report string - """ - read_only_settings = """ - Commands may be terminated with: {} - Output redirection and pipes allowed: {}""" - return read_only_settings.format(str(self.statement_parser.terminators), self.allow_redirection) - - def _show(self, args: argparse.Namespace, parameter: str = '') -> None: - """Shows current settings of parameters. + def complete_set_value(self, text: str, line: str, begidx: int, endidx: int, + arg_tokens: Dict[str, List[str]]) -> List[str]: + """Completes the value argument of set""" + param = arg_tokens['param'][0] + try: + settable = self.settables[param] + except KeyError: + raise CompletionError(param + " is not a settable parameter") + + # Create a parser based on this settable + settable_parser = DEFAULT_ARGUMENT_PARSER() + settable_parser.add_argument(settable.name, help=settable.description, + choices=settable.choices, + choices_function=settable.choices_function, + choices_method=settable.choices_method, + completer_function=settable.completer_function, + completer_method=settable.completer_method) - :param args: argparse parsed arguments from the set command - :param parameter: optional search parameter - """ - param = utils.norm_fold(parameter.strip()) - result = {} - maxlen = 0 - - for p in self.settable: - if (not param) or p.startswith(param): - result[p] = '{}: {}'.format(p, str(getattr(self, p))) - maxlen = max(maxlen, len(result[p])) - - if result: - for p in sorted(result, key=self.default_sort_key): - if args.long: - self.poutput('{} # {}'.format(result[p].ljust(maxlen), self.settable[p])) - else: - self.poutput(result[p]) + from .argparse_completer import AutoCompleter + completer = AutoCompleter(settable_parser, self) - # If user has requested to see all settings, also show read-only settings - if args.all: - self.poutput('\nRead only settings:{}'.format(self._get_read_only_settings())) - else: - self.perror("Parameter '{}' not supported (type 'set' for list of parameters).".format(param)) + tokens = [param] + arg_tokens['value'] + return completer.complete_command(tokens, text, line, begidx, endidx) set_description = ("Set a settable parameter or show current settings of parameters\n" - "\n" - "Accepts abbreviated parameter names so long as there is no ambiguity.\n" "Call without arguments for a list of settable parameters with their values.") - set_parser = DEFAULT_ARGUMENT_PARSER(description=set_description) - set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well') set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter') set_parser.add_argument('param', nargs=argparse.OPTIONAL, help='parameter to set or view', choices_method=_get_settable_completion_items, descriptive_header='Description') - set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='the new value for settable') + + # Suppress tab-completion hints for this field. The completer method is going to create an + # AutoCompleter based on the actual parameter being completed and we only want that hint printing. + set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='the new value for settable', + completer_method=complete_set_value, suppress_tab_hint=True) @with_argparser(set_parser) def do_set(self, args: argparse.Namespace) -> None: """Set a settable parameter or show current settings of parameters""" + if not self.settables: + self.poutput("There are no settable parameters") - # Check if param was passed in - if not args.param: - return self._show(args) - param = utils.norm_fold(args.param.strip()) - - # Check if value was passed in - if not args.value: - return self._show(args, param) - value = args.value - - # Check if param points to just one settable - if param not in self.settable: - hits = [p for p in self.settable if p.startswith(param)] - if len(hits) == 1: - param = hits[0] - else: - return self._show(args, param) + if args.param: + try: + settable = self.settables[args.param] + except KeyError: + self.perror("Parameter '{}' not supported (type 'set' for list of parameters).".format(args.param)) + return - # Update the settable's value - orig_value = getattr(self, param) - setattr(self, param, utils.cast(orig_value, value)) + if args.value: + # Try to update the settable's value + try: + orig_value = getattr(self, args.param) + new_value = settable.val_type(args.value) + setattr(self, args.param, new_value) + # noinspection PyBroadException + except Exception as e: + err_msg = "Error setting {}: {}".format(args.param, e) + self.perror(err_msg) + return - # In cases where a Python property is used to validate and update a settable's value, its value will not - # change if the passed in one is invalid. Therefore we should read its actual value back and not assume. - new_value = getattr(self, param) + self.poutput('{} - was: {!r}\nnow: {!r}'.format(args.param, orig_value, new_value)) - self.poutput('{} - was: {}\nnow: {}'.format(param, orig_value, new_value)) + # Check if we need to call an onchange callback + if orig_value != new_value and settable.onchange_cb: + settable.onchange_cb(args.param, orig_value, new_value) + return - # See if we need to call a change hook for this settable - if orig_value != new_value: - onchange_hook = getattr(self, '_onchange_{}'.format(param), None) - if onchange_hook is not None: - onchange_hook(old=orig_value, new=new_value) # pylint: disable=not-callable + # Show one settable + to_show = [args.param] + else: + # Show all settables + to_show = list(self.settables.keys()) + + # Build the result strings + max_len = 0 + results = dict() + for param in to_show: + results[param] = '{}: {!r}'.format(param, getattr(self, param)) + max_len = max(max_len, ansi.style_aware_wcswidth(results[param])) + + # Display the results + for param in sorted(results, key=self.default_sort_key): + result_str = results[param] + if args.long: + self.poutput('{} # {}'.format(utils.align_left(result_str, width=max_len), + self.settables[param].description)) + else: + self.poutput(result_str) shell_parser = DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") shell_parser.add_argument('command', help='the command to run', completer_method=shell_cmd_complete) diff --git a/cmd2/utils.py b/cmd2/utils.py index 42248884..a23826c3 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -2,6 +2,7 @@ """Shared utility functions""" import collections +import collections.abc as collections_abc import glob import os import re @@ -9,9 +10,8 @@ import subprocess import sys import threading import unicodedata -import collections.abc as collections_abc from enum import Enum -from typing import Any, Iterable, List, Optional, TextIO, Union +from typing import Any, Callable, Iterable, List, Optional, TextIO, Union from . import constants @@ -57,6 +57,61 @@ def strip_quotes(arg: str) -> str: return arg +def str_to_bool(val: str) -> bool: + """ + Converts a string to a boolean based on its value + :param val: string being converted + :return: boolean value expressed in the string + :raises: ValueError if the string does not contain a value corresponding to a boolean value + """ + if val.lower() == "true": + return True + elif val.lower() == "false": + return False + raise ValueError("must be true or false") + + +class Settable: + """Used to configure a cmd2 instance member to be settable via the set command in the CLI""" + def __init__(self, name: str, val_type: Callable, description: str, *, + choices: Iterable = None, + choices_function: Optional[Callable] = None, + choices_method: Optional[Callable] = None, + completer_function: Optional[Callable] = None, + completer_method: Optional[Callable] = None, + onchange_cb: Callable[[str, Any, Any], Any] = None): + """ + Settable Initializer + + :param name: name of the instance attribute being made settable + :param val_type: type or callable used to cast the string value from the command line + setting this to bool provides tab completion for true/false and validation using str_to_bool + :param description: string describing this setting + + The following optional settings provide tab completion for a parameter's values. They correspond to the + same settings in argparse-based tab completion. A maximum of one of these should be provided. + + :param choices: iterable of accepted values + :param choices_function: function that provides choices for this argument + :param choices_method: cmd2-app method that provides choices for this argument + :param completer_function: tab-completion function that provides choices for this argument + :param completer_method: cmd2-app tab-completion method that provides choices for this argument + """ + if val_type == bool: + val_type = str_to_bool + choices = ['true', 'false'] + + self.name = name + self.val_type = val_type + self.description = description + self.choices = choices + self.choices_function = choices_function + self.choices_method = choices_method + self.completer_function = completer_function + self.completer_method = completer_method + self.onchange_cb = onchange_cb + + def namedtuple_with_defaults(typename: str, field_names: Union[str, List[str]], default_values: collections_abc.Iterable = ()): """ @@ -372,7 +427,7 @@ class StdSim(object): def __init__(self, inner_stream, echo: bool = False, encoding: str = 'utf-8', errors: str = 'replace') -> None: """ - Initializer + StdSim Initializer :param inner_stream: the wrapped stream. Should be a TextIO or StdSim instance. :param echo: if True, then all input will be echoed to inner_stream :param encoding: codec for encoding/decoding strings (defaults to utf-8) diff --git a/tests/conftest.py b/tests/conftest.py index b8abc4a5..51345881 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -88,10 +88,10 @@ SHORTCUTS_TXT = """Shortcuts for other commands: """ # Output from the show command with default settings -SHOW_TXT = """allow_style: Terminal +SHOW_TXT = """allow_style: 'Terminal' debug: False echo: False -editor: vim +editor: 'vim' feedback_to_output: False max_completion_items: 50 quiet: False @@ -99,11 +99,11 @@ timing: False """ SHOW_LONG = """ -allow_style: Terminal # Allow ANSI text style sequences in output (valid values: Terminal, Always, Never) +allow_style: 'Terminal' # Allow ANSI text style sequences in output (valid values: Terminal, Always, Never) debug: False # Show full error stack on error echo: False # Echo command issued into output -editor: vim # Program used by ``edit`` -feedback_to_output: False # Include nonessentials in `|`, `>` results +editor: 'vim' # Program used by `edit` +feedback_to_output: False # Include nonessentials in '|', '>' results max_completion_items: 50 # Maximum number of CompletionItems to display during tab completion quiet: False # Don't print nonessential feedback timing: False # Report execution times diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index a3dbe1be..8b14949c 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -79,7 +79,7 @@ def test_base_argparse_help(base_app): def test_base_invalid_option(base_app): out, err = run_cmd(base_app, 'set -z') - assert err[0] == 'Usage: set [-h] [-a] [-l] [param] [value]' + assert err[0] == 'Usage: set [-h] [-l] [param] [value]' assert 'Error: unrecognized arguments: -z' in err[1] def test_base_shortcuts(base_app): @@ -108,16 +108,6 @@ def test_base_show_long(base_app): assert out == expected -def test_base_show_readonly(base_app): - base_app.editor = 'vim' - out, err = run_cmd(base_app, 'set -a') - expected = normalize(SHOW_TXT + '\nRead only settings:' + """ - Commands may be terminated with: {} - Output redirection and pipes allowed: {} -""".format(base_app.statement_parser.terminators, base_app.allow_redirection)) - assert out == expected - - def test_cast(): # Boolean assert utils.cast(True, True) == True @@ -175,16 +165,6 @@ Parameter 'qqq' not supported (type 'set' for list of parameters). """) assert err == expected -def test_set_quiet(base_app): - out, err = run_cmd(base_app, 'set quie True') - expected = normalize(""" -quiet - was: False -now: True -""") - assert out == expected - - out, err = run_cmd(base_app, 'set quiet') - assert out == ['quiet: True'] @pytest.mark.parametrize('new_val, is_valid, expected', [ (ansi.STYLE_NEVER, False, ansi.STYLE_NEVER), @@ -214,10 +194,11 @@ def test_set_allow_style(base_app, new_val, is_valid, expected): class OnChangeHookApp(cmd2.Cmd): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.add_settable(utils.Settable('quiet', bool, "my description", onchange_cb=self._onchange_quiet)) - def _onchange_quiet(self, old, new) -> None: + def _onchange_quiet(self, name, old, new) -> None: """Runs when quiet is changed via set command""" - self.poutput("You changed quiet") + self.poutput("You changed " + name) @pytest.fixture def onchange_app(): @@ -671,7 +652,7 @@ now: True def test_debug_not_settable(base_app): # Set debug to False and make it unsettable base_app.debug = False - del base_app.settable['debug'] + del base_app.settables['debug'] # Cause an exception out, err = run_cmd(base_app, 'bad "quote') @@ -1583,8 +1564,8 @@ def test_get_macro_completion_items(base_app): def test_get_settable_completion_items(base_app): results = base_app._get_settable_completion_items() for cur_res in results: - assert cur_res in base_app.settable - assert cur_res.description == base_app.settable[cur_res] + assert cur_res in base_app.settables + assert cur_res.description == base_app.settables[cur_res].description def test_alias_no_subcommand(base_app): out, err = run_cmd(base_app, 'alias') diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 5739ad8e..64c95b30 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -16,7 +16,7 @@ import pytest import cmd2 from .conftest import run_cmd, verify_help_text from cmd2 import transcript -from cmd2.utils import StdSim +from cmd2.utils import StdSim, Settable class CmdLineApp(cmd2.Cmd): @@ -31,7 +31,7 @@ class CmdLineApp(cmd2.Cmd): super().__init__(*args, multiline_commands=['orate'], **kwargs) # Make maxrepeats settable at runtime - self.settable['maxrepeats'] = 'Max number of `--repeat`s allowed' + self.add_settable(Settable('maxrepeats', int, 'Max number of `--repeat`s allowed')) self.intro = 'This is an intro banner ...' diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index 5bf9add3..5004adc5 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -4,10 +4,10 @@ # Regexes on prompts just make the trailing space obvious (Cmd) set -allow_style: /(Terminal|Always|Never)/ +allow_style: /'(Terminal|Always|Never)'/ debug: False echo: False -editor: /.*/ +editor: /'.*'/ feedback_to_output: False max_completion_items: 50 maxrepeats: 3 |