summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd2/cmd2.py201
-rw-r--r--cmd2/utils.py61
-rw-r--r--tests/conftest.py10
-rwxr-xr-xtests/test_cmd2.py33
-rw-r--r--tests/test_transcript.py4
-rw-r--r--tests/transcripts/regex_set.txt4
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