Skip to content

Commit 486734e

Browse files
committed
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.
1 parent a0cb0e3 commit 486734e

File tree

4 files changed

+116
-25
lines changed

4 files changed

+116
-25
lines changed

cmd2/cmd2.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,14 @@ def __init__(
308308
self.max_completion_items = 50
309309

310310
# A dictionary mapping settable names to their Settable instance
311-
self.settables: Dict[str, Settable] = dict()
311+
self._settables: Dict[str, Settable] = dict()
312+
self.always_prefix_settables: bool = False
312313
self.build_settables()
313314

315+
# CommandSet containers
316+
self._installed_command_sets: List[CommandSet] = []
317+
self._cmd_to_command_sets: Dict[str, CommandSet] = {}
318+
314319
# Use as prompt for multiline commands on the 2nd+ line of input
315320
self.continuation_prompt = '> '
316321

@@ -500,8 +505,6 @@ def __init__(
500505
# depends on them and it's possible a module's on_register() method may need to access some.
501506
############################################################################################################
502507
# Load modular commands
503-
self._installed_command_sets: List[CommandSet] = []
504-
self._cmd_to_command_sets: Dict[str, CommandSet] = {}
505508
if command_sets:
506509
for command_set in command_sets:
507510
self.register_command_set(command_set)
@@ -575,6 +578,15 @@ def register_command_set(self, cmdset: CommandSet) -> None:
575578
if type(cmdset) in existing_commandset_types:
576579
raise CommandSetRegistrationError('CommandSet ' + type(cmdset).__name__ + ' is already installed')
577580

581+
if self.always_prefix_settables:
582+
if len(cmdset.settable_prefix.strip()) == 0:
583+
raise CommandSetRegistrationError('CommandSet settable prefix must not be empty')
584+
else:
585+
all_settables = self.settables
586+
for key in cmdset.settables.keys():
587+
if key in all_settables:
588+
raise KeyError(f'Duplicate settable {key} is already registered')
589+
578590
cmdset.on_register(self)
579591
methods = inspect.getmembers(
580592
cmdset,
@@ -888,13 +900,27 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
888900
action.remove_parser(subcommand_name)
889901
break
890902

903+
@property
904+
def settables(self) -> Mapping[str, Settable]:
905+
all_settables = dict(self._settables)
906+
for cmd_set in self._installed_command_sets:
907+
cmdset_settables = cmd_set.settables
908+
for settable_name, settable in cmdset_settables.items():
909+
if self.always_prefix_settables:
910+
all_settables[f'{cmd_set.settable_prefix}.{settable_name}'] = settable
911+
else:
912+
all_settables[settable_name] = settable
913+
return all_settables
914+
891915
def add_settable(self, settable: Settable) -> None:
892916
"""
893917
Convenience method to add a settable parameter to ``self.settables``
894918
895919
:param settable: Settable object being added
896920
"""
897-
self.settables[settable.name] = settable
921+
if settable.settable_obj is None:
922+
settable.settable_obj = self
923+
self._settables[settable.name] = settable
898924

899925
def remove_settable(self, name: str) -> None:
900926
"""
@@ -904,7 +930,7 @@ def remove_settable(self, name: str) -> None:
904930
:raises: KeyError if the Settable matches this name
905931
"""
906932
try:
907-
del self.settables[name]
933+
del self._settables[name]
908934
except KeyError:
909935
raise KeyError(name + " is not a settable parameter")
910936

@@ -3712,29 +3738,23 @@ def do_set(self, args: argparse.Namespace) -> None:
37123738
if args.param:
37133739
try:
37143740
settable = self.settables[args.param]
3741+
if settable.settable_obj is None:
3742+
settable.settable_obj = self
37153743
except KeyError:
37163744
self.perror("Parameter '{}' not supported (type 'set' for list of parameters).".format(args.param))
37173745
return
37183746

37193747
if args.value:
3720-
args.value = utils.strip_quotes(args.value)
3721-
37223748
# Try to update the settable's value
37233749
try:
3724-
orig_value = getattr(self, args.param)
3725-
setattr(self, args.param, settable.val_type(args.value))
3726-
new_value = getattr(self, args.param)
3750+
orig_value = settable.get_value()
3751+
new_value = settable.set_value(utils.strip_quotes(args.value))
37273752
# noinspection PyBroadException
37283753
except Exception as e:
37293754
err_msg = "Error setting {}: {}".format(args.param, e)
37303755
self.perror(err_msg)
3731-
return
3732-
3733-
self.poutput('{} - was: {!r}\nnow: {!r}'.format(args.param, orig_value, new_value))
3734-
3735-
# Check if we need to call an onchange callback
3736-
if orig_value != new_value and settable.onchange_cb:
3737-
settable.onchange_cb(args.param, orig_value, new_value)
3756+
else:
3757+
self.poutput('{} - was: {!r}\nnow: {!r}'.format(args.param, orig_value, new_value))
37383758
return
37393759

37403760
# Show one settable
@@ -3747,7 +3767,8 @@ def do_set(self, args: argparse.Namespace) -> None:
37473767
max_len = 0
37483768
results = dict()
37493769
for param in to_show:
3750-
results[param] = '{}: {!r}'.format(param, getattr(self, param))
3770+
settable = self.settables[param]
3771+
results[param] = '{}: {!r}'.format(param, settable.get_value())
37513772
max_len = max(max_len, ansi.style_aware_wcswidth(results[param]))
37523773

37533774
# Display the results
@@ -5131,7 +5152,7 @@ def _resolve_func_self(self, cmd_support_func: Callable, cmd_self: Union[Command
51315152
:return:
51325153
"""
51335154
# figure out what class the command support function was defined in
5134-
func_class = get_defining_class(cmd_support_func)
5155+
func_class = get_defining_class(cmd_support_func) # type: Optional[Type]
51355156

51365157
# Was there a defining class identified? If so, is it a sub-class of CommandSet?
51375158
if func_class is not None and issubclass(func_class, CommandSet):

cmd2/command_definition.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
Supports the definition of commands in separate classes to be composed into cmd2.Cmd
44
"""
55
from typing import (
6+
Dict,
7+
Mapping,
68
Optional,
79
Type,
810
)
@@ -14,6 +16,9 @@
1416
from .exceptions import (
1517
CommandSetRegistrationError,
1618
)
19+
from .utils import (
20+
Settable,
21+
)
1722

1823
# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues
1924
try: # pragma: no cover
@@ -90,6 +95,8 @@ class CommandSet(object):
9095

9196
def __init__(self):
9297
self._cmd: Optional[cmd2.Cmd] = None
98+
self._settables: Dict[str, Settable] = {}
99+
self._settable_prefix = self.__class__.__name__
93100

94101
def on_register(self, cmd) -> None:
95102
"""
@@ -126,3 +133,36 @@ def on_unregistered(self) -> None:
126133
Subclasses can override this to perform remaining cleanup steps.
127134
"""
128135
self._cmd = None
136+
137+
@property
138+
def settable_prefix(self) -> str:
139+
return self._settable_prefix
140+
141+
@property
142+
def settables(self) -> Mapping[str, Settable]:
143+
return self._settables
144+
145+
def add_settable(self, settable: Settable) -> None:
146+
"""
147+
Convenience method to add a settable parameter to the CommandSet
148+
149+
:param settable: Settable object being added
150+
"""
151+
if self._cmd and not self._cmd.always_prefix_settables:
152+
if settable.name in self._cmd.settables.keys() and settable.name not in self._settables.keys():
153+
raise KeyError(f'Duplicate settable: {settable.name}')
154+
if settable.settable_obj is None:
155+
settable.settable_obj = self
156+
self._settables[settable.name] = settable
157+
158+
def remove_settable(self, name: str) -> None:
159+
"""
160+
Convenience method for removing a settable parameter from the CommandSet
161+
162+
:param name: name of the settable being removed
163+
:raises: KeyError if the Settable matches this name
164+
"""
165+
try:
166+
del self._settables[name]
167+
except KeyError:
168+
raise KeyError(name + " is not a settable parameter")

cmd2/utils.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@
2828
Optional,
2929
TextIO,
3030
Type,
31+
TYPE_CHECKING,
3132
Union,
3233
)
3334

3435
from . import (
3536
constants,
3637
)
37-
38+
if TYPE_CHECKING: # pragma: no cover
39+
import cmd2
3840

3941
def is_quoted(arg: str) -> bool:
4042
"""
@@ -93,14 +95,16 @@ def str_to_bool(val: str) -> bool:
9395

9496

9597
class Settable:
96-
"""Used to configure a cmd2 instance member to be settable via the set command in the CLI"""
98+
"""Used to configure an attribute to be settable via the set command in the CLI"""
9799

98100
def __init__(
99101
self,
100102
name: str,
101103
val_type: Callable,
102104
description: str,
103105
*,
106+
settable_object: Optional[object] = None,
107+
settable_attrib_name: Optional[str] = None,
104108
onchange_cb: Callable[[str, Any, Any], Any] = None,
105109
choices: Iterable = None,
106110
choices_provider: Optional[Callable] = None,
@@ -114,6 +118,8 @@ def __init__(
114118
even validate its value. Setting this to bool provides tab completion for true/false and
115119
validation using str_to_bool(). The val_type function should raise an exception if it fails.
116120
This exception will be caught and printed by Cmd.do_set().
121+
:param settable_object: Object to configure with the set command
122+
:param settable_attrib_name: Attribute name to be modified. Defaults to `name` if not specified.
117123
:param description: string describing this setting
118124
:param onchange_cb: optional function or method to call when the value of this settable is altered
119125
by the set command. (e.g. onchange_cb=self.debug_changed)
@@ -137,11 +143,36 @@ def __init__(
137143
self.name = name
138144
self.val_type = val_type
139145
self.description = description
146+
self.settable_obj = settable_object
147+
self.settable_attrib_name = settable_attrib_name if settable_attrib_name is not None else name
140148
self.onchange_cb = onchange_cb
141149
self.choices = choices
142150
self.choices_provider = choices_provider
143151
self.completer = completer
144152

153+
def get_value(self) -> Any:
154+
"""
155+
Get the value of the settable attribute
156+
:return:
157+
"""
158+
return getattr(self.settable_obj, self.settable_attrib_name)
159+
160+
def set_value(self, value: Any) -> Any:
161+
"""
162+
Set the settable attribute on the specified destination object
163+
:param value: New value to set
164+
:return: New value that the attribute was set to
165+
"""
166+
# Try to update the settable's value
167+
orig_value = self.get_value()
168+
setattr(self.settable_obj, self.settable_attrib_name, self.val_type(value))
169+
new_value = getattr(self.settable_obj, self.settable_attrib_name)
170+
171+
# Check if we need to call an onchange callback
172+
if orig_value != new_value and self.onchange_cb:
173+
self.onchange_cb(self.name, orig_value, new_value)
174+
return new_value
175+
145176

146177
def namedtuple_with_defaults(typename: str, field_names: Union[str, List[str]], default_values: collections_abc.Iterable = ()):
147178
"""

tests/test_cmd2.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def test_set_not_supported(base_app):
176176

177177

178178
def test_set_no_settables(base_app):
179-
base_app.settables = {}
179+
base_app._settables.clear()
180180
out, err = run_cmd(base_app, 'set quiet True')
181181
expected = normalize("There are no settable parameters")
182182
assert err == expected
@@ -229,11 +229,10 @@ def onchange_app():
229229

230230
def test_set_onchange_hook(onchange_app):
231231
out, err = run_cmd(onchange_app, 'set quiet True')
232-
expected = normalize(
233-
"""
232+
expected = normalize("""
233+
You changed quiet
234234
quiet - was: False
235235
now: True
236-
You changed quiet
237236
"""
238237
)
239238
assert out == expected

0 commit comments

Comments
 (0)