diff options
-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 |