diff options
author | Eric Lin <anselor@gmail.com> | 2020-08-19 14:01:50 -0400 |
---|---|---|
committer | anselor <anselor@gmail.com> | 2021-03-18 18:26:20 -0400 |
commit | 486734e85988d0d0160147b0b44a37759c833e8a (patch) | |
tree | 3b0ef809806d86d781e869771540465cbe089e20 | |
parent | a0cb0e37878a03aa197ba502857afabb0ffad171 (diff) | |
download | cmd2-git-486734e85988d0d0160147b0b44a37759c833e8a.tar.gz |
Each CommandSet's settables are defined separately. cmd2.Cmd searches all registered CommandSets for settables.
Settables can now set any attribute on any object passed to it. The name the user sees may be set to a different value
than what the actual attribute is.
Cmd2 will now aggregate all settables on the cmd2.Cmd instance with each installed CommandSet.
-rw-r--r-- | cmd2/cmd2.py | 59 | ||||
-rw-r--r-- | cmd2/command_definition.py | 40 | ||||
-rw-r--r-- | cmd2/utils.py | 35 | ||||
-rwxr-xr-x | tests/test_cmd2.py | 7 |
4 files changed, 116 insertions, 25 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index cccfa882..902cb314 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -308,9 +308,14 @@ class Cmd(cmd.Cmd): self.max_completion_items = 50 # A dictionary mapping settable names to their Settable instance - self.settables: Dict[str, Settable] = dict() + self._settables: Dict[str, Settable] = dict() + self.always_prefix_settables: bool = False self.build_settables() + # CommandSet containers + self._installed_command_sets: List[CommandSet] = [] + self._cmd_to_command_sets: Dict[str, CommandSet] = {} + # Use as prompt for multiline commands on the 2nd+ line of input self.continuation_prompt = '> ' @@ -500,8 +505,6 @@ class Cmd(cmd.Cmd): # depends on them and it's possible a module's on_register() method may need to access some. ############################################################################################################ # Load modular commands - self._installed_command_sets: List[CommandSet] = [] - self._cmd_to_command_sets: Dict[str, CommandSet] = {} if command_sets: for command_set in command_sets: self.register_command_set(command_set) @@ -575,6 +578,15 @@ class Cmd(cmd.Cmd): if type(cmdset) in existing_commandset_types: raise CommandSetRegistrationError('CommandSet ' + type(cmdset).__name__ + ' is already installed') + if self.always_prefix_settables: + if len(cmdset.settable_prefix.strip()) == 0: + raise CommandSetRegistrationError('CommandSet settable prefix must not be empty') + else: + all_settables = self.settables + for key in cmdset.settables.keys(): + if key in all_settables: + raise KeyError(f'Duplicate settable {key} is already registered') + cmdset.on_register(self) methods = inspect.getmembers( cmdset, @@ -888,13 +900,27 @@ class Cmd(cmd.Cmd): action.remove_parser(subcommand_name) break + @property + def settables(self) -> Mapping[str, Settable]: + all_settables = dict(self._settables) + for cmd_set in self._installed_command_sets: + cmdset_settables = cmd_set.settables + for settable_name, settable in cmdset_settables.items(): + if self.always_prefix_settables: + all_settables[f'{cmd_set.settable_prefix}.{settable_name}'] = settable + else: + all_settables[settable_name] = settable + return all_settables + def add_settable(self, settable: Settable) -> None: """ Convenience method to add a settable parameter to ``self.settables`` :param settable: Settable object being added """ - self.settables[settable.name] = settable + if settable.settable_obj is None: + settable.settable_obj = self + self._settables[settable.name] = settable def remove_settable(self, name: str) -> None: """ @@ -904,7 +930,7 @@ class Cmd(cmd.Cmd): :raises: KeyError if the Settable matches this name """ try: - del self.settables[name] + del self._settables[name] except KeyError: raise KeyError(name + " is not a settable parameter") @@ -3712,29 +3738,23 @@ class Cmd(cmd.Cmd): if args.param: try: settable = self.settables[args.param] + if settable.settable_obj is None: + settable.settable_obj = self except KeyError: self.perror("Parameter '{}' not supported (type 'set' for list of parameters).".format(args.param)) return if args.value: - args.value = utils.strip_quotes(args.value) - # Try to update the settable's value try: - orig_value = getattr(self, args.param) - setattr(self, args.param, settable.val_type(args.value)) - new_value = getattr(self, args.param) + orig_value = settable.get_value() + new_value = settable.set_value(utils.strip_quotes(args.value)) # noinspection PyBroadException except Exception as e: err_msg = "Error setting {}: {}".format(args.param, e) self.perror(err_msg) - return - - self.poutput('{} - was: {!r}\nnow: {!r}'.format(args.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) + else: + self.poutput('{} - was: {!r}\nnow: {!r}'.format(args.param, orig_value, new_value)) return # Show one settable @@ -3747,7 +3767,8 @@ class Cmd(cmd.Cmd): max_len = 0 results = dict() for param in to_show: - results[param] = '{}: {!r}'.format(param, getattr(self, param)) + settable = self.settables[param] + results[param] = '{}: {!r}'.format(param, settable.get_value()) max_len = max(max_len, ansi.style_aware_wcswidth(results[param])) # Display the results @@ -5131,7 +5152,7 @@ class Cmd(cmd.Cmd): :return: """ # figure out what class the command support function was defined in - func_class = get_defining_class(cmd_support_func) + func_class = get_defining_class(cmd_support_func) # type: Optional[Type] # Was there a defining class identified? If so, is it a sub-class of CommandSet? if func_class is not None and issubclass(func_class, CommandSet): diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 654043c8..a63e0efc 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -3,6 +3,8 @@ Supports the definition of commands in separate classes to be composed into cmd2.Cmd """ from typing import ( + Dict, + Mapping, Optional, Type, ) @@ -14,6 +16,9 @@ from .constants import ( from .exceptions import ( CommandSetRegistrationError, ) +from .utils import ( + Settable, +) # Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues try: # pragma: no cover @@ -90,6 +95,8 @@ class CommandSet(object): def __init__(self): self._cmd: Optional[cmd2.Cmd] = None + self._settables: Dict[str, Settable] = {} + self._settable_prefix = self.__class__.__name__ def on_register(self, cmd) -> None: """ @@ -126,3 +133,36 @@ class CommandSet(object): Subclasses can override this to perform remaining cleanup steps. """ self._cmd = None + + @property + def settable_prefix(self) -> str: + return self._settable_prefix + + @property + def settables(self) -> Mapping[str, Settable]: + return self._settables + + def add_settable(self, settable: Settable) -> None: + """ + Convenience method to add a settable parameter to the CommandSet + + :param settable: Settable object being added + """ + if self._cmd and not self._cmd.always_prefix_settables: + if settable.name in self._cmd.settables.keys() and settable.name not in self._settables.keys(): + raise KeyError(f'Duplicate settable: {settable.name}') + if settable.settable_obj is None: + settable.settable_obj = self + self._settables[settable.name] = settable + + def remove_settable(self, name: str) -> None: + """ + Convenience method for removing a settable parameter from the CommandSet + + :param name: name of the settable being removed + :raises: KeyError if the Settable matches this name + """ + try: + del self._settables[name] + except KeyError: + raise KeyError(name + " is not a settable parameter") diff --git a/cmd2/utils.py b/cmd2/utils.py index c9577e82..1008cb86 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -28,13 +28,15 @@ from typing import ( Optional, TextIO, Type, + TYPE_CHECKING, Union, ) from . import ( constants, ) - +if TYPE_CHECKING: # pragma: no cover + import cmd2 def is_quoted(arg: str) -> bool: """ @@ -93,7 +95,7 @@ def str_to_bool(val: str) -> bool: class Settable: - """Used to configure a cmd2 instance member to be settable via the set command in the CLI""" + """Used to configure an attribute to be settable via the set command in the CLI""" def __init__( self, @@ -101,6 +103,8 @@ class Settable: val_type: Callable, description: str, *, + settable_object: Optional[object] = None, + settable_attrib_name: Optional[str] = None, onchange_cb: Callable[[str, Any, Any], Any] = None, choices: Iterable = None, choices_provider: Optional[Callable] = None, @@ -114,6 +118,8 @@ class Settable: even validate its value. Setting this to bool provides tab completion for true/false and validation using str_to_bool(). The val_type function should raise an exception if it fails. This exception will be caught and printed by Cmd.do_set(). + :param settable_object: Object to configure with the set command + :param settable_attrib_name: Attribute name to be modified. Defaults to `name` if not specified. :param description: string describing this setting :param onchange_cb: optional function or method to call when the value of this settable is altered by the set command. (e.g. onchange_cb=self.debug_changed) @@ -137,11 +143,36 @@ class Settable: self.name = name self.val_type = val_type self.description = description + self.settable_obj = settable_object + self.settable_attrib_name = settable_attrib_name if settable_attrib_name is not None else name self.onchange_cb = onchange_cb self.choices = choices self.choices_provider = choices_provider self.completer = completer + def get_value(self) -> Any: + """ + Get the value of the settable attribute + :return: + """ + return getattr(self.settable_obj, self.settable_attrib_name) + + def set_value(self, value: Any) -> Any: + """ + Set the settable attribute on the specified destination object + :param value: New value to set + :return: New value that the attribute was set to + """ + # Try to update the settable's value + orig_value = self.get_value() + setattr(self.settable_obj, self.settable_attrib_name, self.val_type(value)) + new_value = getattr(self.settable_obj, self.settable_attrib_name) + + # Check if we need to call an onchange callback + if orig_value != new_value and self.onchange_cb: + self.onchange_cb(self.name, orig_value, new_value) + return new_value + def namedtuple_with_defaults(typename: str, field_names: Union[str, List[str]], default_values: collections_abc.Iterable = ()): """ diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index b4b13945..cd03e66e 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -176,7 +176,7 @@ Parameter 'qqq' not supported (type 'set' for list of parameters). def test_set_no_settables(base_app): - base_app.settables = {} + base_app._settables.clear() out, err = run_cmd(base_app, 'set quiet True') expected = normalize("There are no settable parameters") assert err == expected @@ -229,11 +229,10 @@ def onchange_app(): def test_set_onchange_hook(onchange_app): out, err = run_cmd(onchange_app, 'set quiet True') - expected = normalize( - """ + expected = normalize(""" +You changed quiet quiet - was: False now: True -You changed quiet """ ) assert out == expected |