summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEric Lin <anselor@gmail.com>2021-03-16 12:25:34 -0400
committerEric Lin <anselor@gmail.com>2021-03-18 14:16:03 -0400
commit0cd626ebbef273aa78c2d1154ebdd5f9055028cf (patch)
tree9a822b245312b3b515b64a69d772fab75fce8121
parenta649286b9468ebadbafeca1abf20a946351ceefe (diff)
downloadcmd2-git-cmdset_settables.tar.gz
Resolves comments from PRcmdset_settables
-rw-r--r--CHANGELOG.md10
-rw-r--r--cmd2/ansi.py20
-rw-r--r--cmd2/argparse_custom.py141
-rw-r--r--cmd2/cmd2.py152
-rw-r--r--cmd2/command_definition.py13
-rw-r--r--cmd2/utils.py48
-rwxr-xr-xexamples/cmd_as_argument.py2
-rwxr-xr-xexamples/colors.py2
-rwxr-xr-xexamples/decorator_example.py2
-rwxr-xr-xexamples/environment.py6
-rwxr-xr-xexamples/example.py2
-rwxr-xr-xexamples/first_app.py2
-rwxr-xr-xexamples/initialization.py2
-rwxr-xr-xexamples/pirate.py2
-rwxr-xr-xexamples/plumbum_colors.py6
-rwxr-xr-xsetup.py1
-rwxr-xr-xtests/test_cmd2.py2
-rwxr-xr-xtests/test_completion.py8
-rw-r--r--tests/test_transcript.py2
-rw-r--r--tests_isolated/test_commandset/test_commandset.py88
20 files changed, 385 insertions, 126 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05648a17..9f1739c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,9 @@
* Removed `with_argparser_and_unknown_args` since it was deprecated in 1.3.0.
* Replaced `cmd2.Cmd.completion_header` with `cmd2.Cmd.formatted_completions`. See Enhancements
for description of this new class member.
+ * Settables now have new initialization parameters. It is now a required parameter to supply the reference to the
+ object that holds the settable attribute. `cmd2.Cmd.settables` is no longer a public dict attribute - it is now a
+ property that aggregates all Settables across all registered CommandSets.
* Enhancements
* Added support for custom tab completion and up-arrow input history to `cmd2.Cmd2.read_input`.
See [read_input.py](https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py)
@@ -25,7 +28,12 @@
* Added `cmd2.exceptions.PassThroughException` to raise unhandled command exceptions instead of printing them.
* Added support for ANSI styles and newlines in tab completion results using `cmd2.Cmd.formatted_completions`.
`cmd2` provides this capability automatically if you return argparse completion matches as `CompletionItems`.
-
+ * Settables enhancements:
+ * Settables may be optionally scoped to a CommandSet. Settables added to CommandSets will appear when a
+ CommandSet is registered and disappear when a CommandSet is unregistered. Optionally, scoped Settables
+ may have a prepended prefix.
+ * Settables now allow changes to be applied to any arbitrary object attribute. It no longer needs to match an
+ attribute added to the cmd2 instance itself.
## 1.5.0 (January 31, 2021)
* Bug Fixes
* Fixed bug where setting `always_show_hint=True` did not show a hint when completing `Settables`
diff --git a/cmd2/ansi.py b/cmd2/ansi.py
index 741d3b8b..5b086c45 100644
--- a/cmd2/ansi.py
+++ b/cmd2/ansi.py
@@ -230,7 +230,7 @@ def style_aware_write(fileobj: IO[str], msg: str) -> None:
fileobj.write(msg)
-def fg_lookup(fg_name: Union[str, fg]) -> Fore:
+def fg_lookup(fg_name: Union[str, fg]) -> str:
"""
Look up ANSI escape codes based on foreground color name.
@@ -239,16 +239,16 @@ def fg_lookup(fg_name: Union[str, fg]) -> Fore:
:raises: ValueError: if the color cannot be found
"""
if isinstance(fg_name, fg):
- return fg_name.value
+ return str(fg_name.value)
try:
ansi_escape = fg[fg_name.lower()].value
except KeyError:
raise ValueError('Foreground color {!r} does not exist; must be one of: {}'.format(fg_name, fg.colors()))
- return ansi_escape
+ return str(ansi_escape)
-def bg_lookup(bg_name: Union[str, bg]) -> Back:
+def bg_lookup(bg_name: Union[str, bg]) -> str:
"""
Look up ANSI escape codes based on background color name.
@@ -257,13 +257,13 @@ def bg_lookup(bg_name: Union[str, bg]) -> Back:
:raises: ValueError: if the color cannot be found
"""
if isinstance(bg_name, bg):
- return bg_name.value
+ return str(bg_name.value)
try:
ansi_escape = bg[bg_name.lower()].value
except KeyError:
raise ValueError('Background color {!r} does not exist; must be one of: {}'.format(bg_name, bg.colors()))
- return ansi_escape
+ return str(ansi_escape)
# noinspection PyShadowingNames
@@ -292,13 +292,13 @@ def style(
:return: the stylized string
"""
# List of strings that add style
- additions = []
+ additions: List[str] = []
# List of strings that remove style
- removals = []
+ removals: List[str] = []
# Convert the text object into a string if it isn't already one
- text = "{}".format(text)
+ text_formatted = "{}".format(text)
# Process the style settings
if fg:
@@ -322,7 +322,7 @@ def style(
removals.append(UNDERLINE_DISABLE)
# Combine the ANSI style sequences with the text
- return cast(str, "".join(additions) + text + "".join(removals))
+ return "".join(additions) + text_formatted + "".join(removals)
# Default styles for printing strings of various types.
diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py
index a9879d31..96449a45 100644
--- a/cmd2/argparse_custom.py
+++ b/cmd2/argparse_custom.py
@@ -203,12 +203,15 @@ from gettext import (
gettext,
)
from typing import (
+ IO,
Any,
Callable,
+ Dict,
Iterable,
List,
NoReturn,
Optional,
+ Sequence,
Tuple,
Type,
Union,
@@ -220,6 +223,15 @@ from . import (
constants,
)
+try:
+ from typing import (
+ Protocol,
+ )
+except ImportError:
+ from typing_extensions import ( # type: ignore[misc]
+ Protocol,
+ )
+
############################################################################################################
# The following are names of custom argparse argument attributes added by cmd2
############################################################################################################
@@ -286,6 +298,59 @@ class CompletionItem(str):
############################################################################################################
# Class and functions related to ChoicesCallable
############################################################################################################
+
+
+class ChoicesProviderFunc(Protocol):
+ """
+ Function that returns a list of choices in support of tab completion
+ """
+
+ def __call__(self) -> List[str]:
+ ... # pragma: no cover
+
+
+class ChoicesProviderFuncWithTokens(Protocol):
+ """
+ Function that returns a list of choices in support of tab completion and accepts a dictionary of prior arguments.
+ """
+
+ def __call__(self, *, arg_tokens: Dict[str, List[str]]) -> List[str]:
+ ... # pragma: no cover
+
+
+class CompleterFunc(Protocol):
+ """
+ Function to support tab completion with the provided state of the user prompt
+ """
+
+ def __call__(
+ self,
+ text: str,
+ line: str,
+ begidx: int,
+ endidx: int,
+ ) -> List[str]:
+ ... # pragma: no cover
+
+
+class CompleterFuncWithTokens(Protocol):
+ """
+ Function to support tab completion with the provided state of the user prompt and accepts a dictionary of prior
+ arguments.
+ """
+
+ def __call__(
+ self,
+ text: str,
+ line: str,
+ begidx: int,
+ endidx: int,
+ *,
+ arg_tokens: Dict[str, List[str]],
+ ) -> List[str]:
+ ... # pragma: no cover
+
+
class ChoicesCallable:
"""
Enables using a callable as the choices provider for an argparse argument.
@@ -295,7 +360,7 @@ class ChoicesCallable:
def __init__(
self,
is_completer: bool,
- to_call: Union[Callable[[], List[str]], Callable[[str, str, int, int], List[str]]],
+ to_call: Union[CompleterFunc, CompleterFuncWithTokens, ChoicesProviderFunc, ChoicesProviderFuncWithTokens],
) -> None:
"""
Initializer
@@ -328,12 +393,18 @@ def _set_choices_callable(action: argparse.Action, choices_callable: ChoicesCall
setattr(action, ATTR_CHOICES_CALLABLE, choices_callable)
-def set_choices_provider(action: argparse.Action, choices_provider: Callable[[], List[str]]) -> None:
+def set_choices_provider(
+ action: argparse.Action,
+ choices_provider: Union[ChoicesProviderFunc, ChoicesProviderFuncWithTokens],
+) -> None:
"""Set choices_provider on an argparse action"""
_set_choices_callable(action, ChoicesCallable(is_completer=False, to_call=choices_provider))
-def set_completer(action: argparse.Action, completer: Callable[[str, str, int, int], List[str]]) -> None:
+def set_completer(
+ action: argparse.Action,
+ completer: Union[CompleterFunc, CompleterFuncWithTokens],
+) -> None:
"""Set completer on an argparse action"""
_set_choices_callable(action, ChoicesCallable(is_completer=True, to_call=completer))
@@ -351,11 +422,11 @@ def _add_argument_wrapper(
self: argparse._ActionsContainer,
*args: Any,
nargs: Union[int, str, Tuple[int], Tuple[int, int], Tuple[int, float], None] = None,
- choices_provider: Optional[Callable[[], List[str]]] = None,
- completer: Optional[Callable[[str, str, int, int], List[str]]] = None,
+ choices_provider: Optional[Union[ChoicesProviderFunc, ChoicesProviderFuncWithTokens]] = None,
+ completer: Optional[Union[CompleterFunc, CompleterFuncWithTokens]] = None,
suppress_tab_hint: bool = False,
descriptive_header: Optional[str] = None,
- **kwargs: Any
+ **kwargs: Any,
) -> argparse.Action:
"""
Wrapper around _ActionsContainer.add_argument() which supports more settings used by cmd2
@@ -646,9 +717,9 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
# helper for wrapping lines
# noinspection PyMissingOrEmptyDocstring,PyShadowingNames
- def get_lines(parts: List[str], indent: str, prefix: Optional[str] = None):
- lines = []
- line = []
+ def get_lines(parts: List[str], indent: str, prefix: Optional[str] = None) -> List[str]:
+ lines: List[str] = []
+ line: List[str] = []
if prefix is not None:
line_len = len(prefix) - 1
else:
@@ -703,14 +774,14 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
# prefix with 'Usage:'
return '%s%s\n\n' % (prefix, usage)
- def _format_action_invocation(self, action) -> str:
+ def _format_action_invocation(self, action: argparse.Action) -> str:
if not action.option_strings:
default = self._get_default_metavar_for_positional(action)
(metavar,) = self._metavar_formatter(action, default)(1)
return metavar
else:
- parts = []
+ parts: List[str] = []
# if the Optional doesn't take a value, format is:
# -s, --long
@@ -729,7 +800,11 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
# End cmd2 customization
# noinspection PyMethodMayBeStatic
- def _determine_metavar(self, action, default_metavar) -> Union[str, Tuple]:
+ def _determine_metavar(
+ self,
+ action: argparse.Action,
+ default_metavar: Union[str, Tuple[str, ...]],
+ ) -> Union[str, Tuple[str, ...]]:
"""Custom method to determine what to use as the metavar value of an action"""
if action.metavar is not None:
result = action.metavar
@@ -742,11 +817,15 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
result = default_metavar
return result
- def _metavar_formatter(self, action, default_metavar) -> Callable:
+ def _metavar_formatter(
+ self,
+ action: argparse.Action,
+ default_metavar: Union[str, Tuple[str, ...]],
+ ) -> Callable[[int], Tuple[str, ...]]:
metavar = self._determine_metavar(action, default_metavar)
# noinspection PyMissingOrEmptyDocstring
- def format(tuple_size):
+ def format(tuple_size: int) -> Tuple[str, ...]:
if isinstance(metavar, tuple):
return metavar
else:
@@ -755,7 +834,7 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
return format
# noinspection PyProtectedMember
- def _format_args(self, action, default_metavar) -> str:
+ def _format_args(self, action: argparse.Action, default_metavar: Union[str, Tuple[str, ...]]) -> str:
"""Customized to handle ranged nargs and make other output less verbose"""
metavar = self._determine_metavar(action, default_metavar)
metavar_formatter = self._metavar_formatter(action, default_metavar)
@@ -780,7 +859,7 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
elif isinstance(action.nargs, int) and action.nargs > 1:
return '{}{{{}}}'.format('%s' % metavar_formatter(1), action.nargs)
- return super()._format_args(action, default_metavar)
+ return super()._format_args(action, default_metavar) # type: ignore[arg-type]
# noinspection PyCompatibility
@@ -789,18 +868,18 @@ class Cmd2ArgumentParser(argparse.ArgumentParser):
def __init__(
self,
- prog=None,
- usage=None,
- description=None,
- epilog=None,
- parents=None,
- formatter_class=Cmd2HelpFormatter,
- prefix_chars='-',
- fromfile_prefix_chars=None,
- argument_default=None,
- conflict_handler='error',
- add_help=True,
- allow_abbrev=True,
+ prog: Optional[str] = None,
+ usage: Optional[str] = None,
+ description: Optional[str] = None,
+ epilog: Optional[str] = None,
+ parents: Sequence[argparse.ArgumentParser] = [],
+ formatter_class: Type[argparse.HelpFormatter] = Cmd2HelpFormatter,
+ prefix_chars: str = '-',
+ fromfile_prefix_chars: Optional[str] = None,
+ argument_default: Optional[str] = None,
+ conflict_handler: str = 'error',
+ add_help: bool = True,
+ allow_abbrev: bool = True,
) -> None:
super(Cmd2ArgumentParser, self).__init__(
prog=prog,
@@ -817,7 +896,7 @@ class Cmd2ArgumentParser(argparse.ArgumentParser):
allow_abbrev=allow_abbrev,
)
- def add_subparsers(self, **kwargs):
+ def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction:
"""
Custom override. Sets a default title if one was not given.
@@ -895,7 +974,7 @@ class Cmd2ArgumentParser(argparse.ArgumentParser):
# determine help from format above
return formatter.format_help() + '\n'
- def _print_message(self, message, file=None):
+ def _print_message(self, message: str, file: Optional[IO[str]] = None) -> None:
# Override _print_message to use style_aware_write() since we use ANSI escape characters to support color
if message:
if file is None:
@@ -923,7 +1002,7 @@ class Cmd2AttributeWrapper:
# The default ArgumentParser class for a cmd2 app
-DEFAULT_ARGUMENT_PARSER = Cmd2ArgumentParser
+DEFAULT_ARGUMENT_PARSER: Type[argparse.ArgumentParser] = Cmd2ArgumentParser
def set_default_argument_parser(parser: Type[argparse.ArgumentParser]) -> None:
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 73e319b5..088a1c7e 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -48,6 +48,12 @@ from collections import (
from contextlib import (
redirect_stdout,
)
+from pathlib import (
+ Path,
+)
+from types import (
+ ModuleType,
+)
from typing import (
Any,
Callable,
@@ -57,9 +63,11 @@ from typing import (
Mapping,
Optional,
Set,
+ TextIO,
Tuple,
Type,
Union,
+ cast,
)
from . import (
@@ -159,7 +167,7 @@ else:
ipython_available = True
try:
# noinspection PyUnresolvedReferences,PyPackageRequirements
- from IPython import (
+ from IPython import ( # type: ignore[import]
embed,
)
except ImportError: # pragma: no cover
@@ -169,7 +177,7 @@ except ImportError: # pragma: no cover
class _SavedReadlineSettings:
"""readline settings that are backed up when switching between readline environments"""
- def __init__(self):
+ def __init__(self) -> None:
self.completer = None
self.delims = ''
self.basic_quotes = None
@@ -178,12 +186,12 @@ class _SavedReadlineSettings:
class _SavedCmd2Env:
"""cmd2 environment settings that are backed up when entering an interactive Python shell"""
- def __init__(self):
+ def __init__(self) -> None:
self.readline_settings = _SavedReadlineSettings()
- self.readline_module = None
- self.history = []
- self.sys_stdout = None
- self.sys_stdin = None
+ self.readline_module: Optional[ModuleType] = None
+ self.history: List[str] = []
+ self.sys_stdout: Optional[TextIO] = None
+ self.sys_stdin: Optional[TextIO] = None
# Contains data about a disabled command which is used to restore its original functions when the command is enabled
@@ -212,12 +220,12 @@ class Cmd(cmd.Cmd):
def __init__(
self,
completekey: str = 'tab',
- stdin=None,
- stdout=None,
+ stdin: Optional[TextIO] = None,
+ stdout: Optional[TextIO] = None,
*,
- persistent_history_file: str = '',
+ persistent_history_file: Path = '',
persistent_history_length: int = 1000,
- startup_script: str = '',
+ startup_script: Path = '',
silent_startup_script: bool = False,
use_ipython: bool = False,
allow_cli_args: bool = True,
@@ -310,7 +318,7 @@ class Cmd(cmd.Cmd):
# A dictionary mapping settable names to their Settable instance
self._settables: Dict[str, Settable] = dict()
- self.always_prefix_settables: bool = False
+ self._always_prefix_settables: bool = False
# CommandSet containers
self._installed_command_sets: Set[CommandSet] = set()
@@ -338,13 +346,13 @@ class Cmd(cmd.Cmd):
self.macros: Dict[str, Macro] = dict()
# Keeps track of typed command history in the Python shell
- self._py_history = []
+ self._py_history: List[str] = []
# The name by which Python environments refer to the PyBridge to call app commands
self.py_bridge_name = 'app'
# Defines app-specific variables/functions available in Python shells and pyscripts
- self.py_locals = dict()
+ self.py_locals: Dict[str, Any] = dict()
# True if running inside a Python script or interactive console, False otherwise
self._in_py = False
@@ -482,7 +490,7 @@ class Cmd(cmd.Cmd):
self.formatted_completions = ''
# Used by complete() for readline tab completion
- self.completion_matches = []
+ self.completion_matches: List[str] = []
# Use this list if you need to display tab completion suggestions that are different than the actual text
# of the matches. For instance, if you are completing strings that contain a common delimiter and you only
@@ -490,7 +498,7 @@ class Cmd(cmd.Cmd):
# still must be returned from your completer function. For an example, look at path_complete() which
# uses this to show only the basename of paths as the suggestions. delimiter_complete() also populates
# this list. These are ignored if self.formatted_completions is populated.
- self.display_matches = []
+ self.display_matches: List[str] = []
# Used by functions like path_complete() and delimiter_complete() to properly
# quote matches that are completed in a delimited fashion
@@ -552,7 +560,7 @@ class Cmd(cmd.Cmd):
all_commandset_defs = CommandSet.__subclasses__()
existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets]
- def load_commandset_by_type(commandset_types: List[Type]) -> None:
+ def load_commandset_by_type(commandset_types: List[Type[CommandSet]]) -> None:
for cmdset_type in commandset_types:
# check if the type has sub-classes. We will only auto-load leaf class types.
subclasses = cmdset_type.__subclasses__()
@@ -580,19 +588,24 @@ class Cmd(cmd.Cmd):
if type(cmdset) in existing_commandset_types:
raise CommandSetRegistrationError('CommandSet ' + type(cmdset).__name__ + ' is already installed')
+ all_settables = self.settables
if self.always_prefix_settables:
- if len(cmdset.settable_prefix.strip()) == 0:
+ if not cmdset.settable_prefix.strip():
raise CommandSetRegistrationError('CommandSet settable prefix must not be empty')
+ for key in cmdset.settables.keys():
+ prefixed_name = f'{cmdset.settable_prefix}.{key}'
+ if prefixed_name in all_settables:
+ raise CommandSetRegistrationError(f'Duplicate settable: {key}')
+
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')
+ raise CommandSetRegistrationError(f'Duplicate settable {key} is already registered')
cmdset.on_register(self)
methods = inspect.getmembers(
cmdset,
- predicate=lambda meth: isinstance(meth, Callable)
+ predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
and hasattr(meth, '__name__')
and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
)
@@ -639,7 +652,7 @@ class Cmd(cmd.Cmd):
cmdset.on_unregistered()
raise
- def _install_command_function(self, command: str, command_wrapper: Callable, context=''):
+ def _install_command_function(self, command: str, command_wrapper: Callable, context: str = '') -> None:
cmd_func_name = COMMAND_FUNC_PREFIX + command
# Make sure command function doesn't share name with existing attribute
@@ -663,23 +676,24 @@ class Cmd(cmd.Cmd):
setattr(self, cmd_func_name, command_wrapper)
- def _install_completer_function(self, cmd_name: str, cmd_completer: Callable):
+ def _install_completer_function(self, cmd_name: str, cmd_completer: Callable) -> None:
completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name
if hasattr(self, completer_func_name):
raise CommandSetRegistrationError('Attribute already exists: {}'.format(completer_func_name))
setattr(self, completer_func_name, cmd_completer)
- def _install_help_function(self, cmd_name: str, cmd_help: Callable):
+ def _install_help_function(self, cmd_name: str, cmd_help: Callable) -> None:
help_func_name = HELP_FUNC_PREFIX + cmd_name
if hasattr(self, help_func_name):
raise CommandSetRegistrationError('Attribute already exists: {}'.format(help_func_name))
setattr(self, help_func_name, cmd_help)
- def unregister_command_set(self, cmdset: CommandSet):
+ def unregister_command_set(self, cmdset: CommandSet) -> None:
"""
Uninstalls a CommandSet and unloads all associated commands
+
:param cmdset: CommandSet to uninstall
"""
if cmdset in self._installed_command_sets:
@@ -715,7 +729,7 @@ class Cmd(cmd.Cmd):
cmdset.on_unregistered()
self._installed_command_sets.remove(cmdset)
- def _check_uninstallable(self, cmdset: CommandSet):
+ def _check_uninstallable(self, cmdset: CommandSet) -> None:
methods = inspect.getmembers(
cmdset,
predicate=lambda meth: isinstance(meth, Callable)
@@ -732,9 +746,9 @@ class Cmd(cmd.Cmd):
else:
command_func = self.cmd_func(command_name)
- command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
+ command_parser = cast(argparse.ArgumentParser, getattr(command_func, constants.CMD_ATTR_ARGPARSER, None))
- def check_parser_uninstallable(parser):
+ def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
for subparser in action.choices.values():
@@ -903,7 +917,38 @@ class Cmd(cmd.Cmd):
break
@property
+ def always_prefix_settables(self) -> bool:
+ """
+ Flags whether CommandSet settable values should always be prefixed
+
+ :return: True if CommandSet settable values will always be prefixed. False if not.
+ """
+ return self._always_prefix_settables
+
+ @always_prefix_settables.setter
+ def always_prefix_settables(self, new_value: bool) -> None:
+ """
+ Set whether CommandSet settable values should always be prefixed.
+
+ :param new_value: True if CommandSet settable values should always be prefixed. False if not.
+ :raises ValueError: If a registered CommandSet does not have a defined prefix
+ """
+ if not self._always_prefix_settables and new_value:
+ for cmd_set in self._installed_command_sets:
+ if not cmd_set.settable_prefix:
+ raise ValueError(
+ f'Cannot force settable prefixes. CommandSet {cmd_set.__class__.__name__} does '
+ f'not have a settable prefix defined.'
+ )
+ self._always_prefix_settables = new_value
+
+ @property
def settables(self) -> Mapping[str, Settable]:
+ """
+ Get all available user-settable attributes. This includes settables defined in installed CommandSets
+
+ :return: Mapping from attribute-name to Settable of all user-settable attributes from
+ """
all_settables = dict(self._settables)
for cmd_set in self._installed_command_sets:
cmdset_settables = cmd_set.settables
@@ -916,15 +961,13 @@ class Cmd(cmd.Cmd):
def add_settable(self, settable: Settable) -> None:
"""
- Convenience method to add a settable parameter to ``self.settables``
+ Add a settable parameter to ``self.settables``
:param settable: Settable object being added
"""
if not self.always_prefix_settables:
if settable.name in self.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:
@@ -939,7 +982,7 @@ class Cmd(cmd.Cmd):
except KeyError:
raise KeyError(name + " is not a settable parameter")
- def build_settables(self):
+ def build_settables(self) -> None:
"""Create the dictionary of user-settable parameters"""
self.add_settable(
Settable(
@@ -947,22 +990,28 @@ class Cmd(cmd.Cmd):
str,
'Allow ANSI text style sequences in output (valid values: '
'{}, {}, {})'.format(ansi.STYLE_TERMINAL, ansi.STYLE_ALWAYS, ansi.STYLE_NEVER),
+ self,
choices=[ansi.STYLE_TERMINAL, ansi.STYLE_ALWAYS, ansi.STYLE_NEVER],
)
)
self.add_settable(
- Settable('always_show_hint', bool, 'Display tab completion hint even when completion suggestions print')
+ Settable(
+ 'always_show_hint',
+ bool,
+ 'Display tab completion hint even when completion suggestions print',
+ self,
+ )
)
- self.add_settable(Settable('debug', bool, "Show full traceback on exception"))
- self.add_settable(Settable('echo', bool, "Echo command issued into output"))
- self.add_settable(Settable('editor', str, "Program used by 'edit'"))
- self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|', '>' results"))
+ self.add_settable(Settable('debug', bool, "Show full traceback on exception", self))
+ self.add_settable(Settable('echo', bool, "Echo command issued into output", self))
+ self.add_settable(Settable('editor', str, "Program used by 'edit'", self))
+ self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|', '>' results", self))
self.add_settable(
- Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion")
+ Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self)
)
- self.add_settable(Settable('quiet', bool, "Don't print nonessential feedback"))
- self.add_settable(Settable('timing', bool, "Report execution times"))
+ self.add_settable(Settable('quiet', bool, "Don't print nonessential feedback", self))
+ self.add_settable(Settable('timing', bool, "Report execution times", self))
# ----- Methods related to presenting output to the user -----
@@ -984,7 +1033,7 @@ class Cmd(cmd.Cmd):
def _completion_supported(self) -> bool:
"""Return whether tab completion is supported"""
- return self.use_rawinput and self.completekey and rl_type != RlType.NONE
+ return self.use_rawinput and bool(self.completekey) and rl_type != RlType.NONE
@property
def visible_prompt(self) -> str:
@@ -1231,7 +1280,14 @@ class Cmd(cmd.Cmd):
return tokens, raw_tokens
# noinspection PyMethodMayBeStatic, PyUnusedLocal
- def basic_complete(self, text: str, line: str, begidx: int, endidx: int, match_against: Iterable) -> List[str]:
+ def basic_complete(
+ self,
+ text: str,
+ line: str,
+ begidx: int,
+ endidx: int,
+ match_against: Iterable[str],
+ ) -> List[str]:
"""
Basic tab completion function that matches against a list of strings without considering line contents
or cursor position. The args required by this function are defined in the header of Python's cmd.py.
@@ -1246,7 +1302,13 @@ class Cmd(cmd.Cmd):
return [cur_match for cur_match in match_against if cur_match.startswith(text)]
def delimiter_complete(
- self, text: str, line: str, begidx: int, endidx: int, match_against: Iterable, delimiter: str
+ self,
+ text: str,
+ line: str,
+ begidx: int,
+ endidx: int,
+ match_against: Iterable[str],
+ delimiter: str,
) -> List[str]:
"""
Performs tab completion against a list but each match is split on a delimiter and only
@@ -3743,8 +3805,6 @@ 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
@@ -4333,7 +4393,7 @@ class Cmd(cmd.Cmd):
history = self.history.span(':', args.all)
return history
- def _initialize_history(self, hist_file):
+ def _initialize_history(self, hist_file: str) -> None:
"""Initialize history using history related attributes
This function can determine whether history is saved in the prior text-based
diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py
index a63e0efc..9e7238d0 100644
--- a/cmd2/command_definition.py
+++ b/cmd2/command_definition.py
@@ -148,11 +148,14 @@ class CommandSet(object):
: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
+ if self._cmd:
+ if 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}')
+ else:
+ prefixed_name = f'{self._settable_prefix}.{settable.name}'
+ if prefixed_name in self._cmd.settables.keys() and settable.name not in self._settables.keys():
+ raise KeyError(f'Duplicate settable: {settable.name}')
self._settables[settable.name] = settable
def remove_settable(self, name: str) -> None:
diff --git a/cmd2/utils.py b/cmd2/utils.py
index 717d73b4..2787c079 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -35,11 +35,20 @@ from typing import (
from . import (
constants,
)
+from .argparse_custom import (
+ ChoicesProviderFunc,
+ ChoicesProviderFuncWithTokens,
+ CompleterFunc,
+ CompleterFuncWithTokens,
+)
if TYPE_CHECKING: # pragma: no cover
import cmd2 # noqa: F401
+_T = TypeVar('_T')
+
+
def is_quoted(arg: str) -> bool:
"""
Checks if a string is quoted
@@ -104,13 +113,13 @@ class Settable:
name: str,
val_type: Union[Type[Any], Callable[[Any], Any]],
description: str,
+ settable_object: object,
*,
- settable_object: Optional[object] = None,
settable_attrib_name: Optional[str] = None,
- onchange_cb: Optional[Callable[[str, Any, Any], Any]] = None,
+ onchange_cb: Optional[Callable[[str, _T, _T], Any]] = None,
choices: Optional[Iterable[Any]] = None,
- choices_provider: Optional[Callable[[], List[str]]] = None,
- completer: Optional[Callable[[str, str, int, int], List[str]]] = None
+ choices_provider: Optional[Union[ChoicesProviderFunc, ChoicesProviderFuncWithTokens]] = None,
+ completer: Optional[Union[CompleterFunc, CompleterFuncWithTokens]] = None,
):
"""
Settable Initializer
@@ -120,9 +129,9 @@ 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 description: string describing this setting
: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)
@@ -213,9 +222,6 @@ def is_text_file(file_path: str) -> bool:
return valid_text_file
-_T = TypeVar('_T')
-
-
def remove_duplicates(list_to_prune: List[_T]) -> List[_T]:
"""Removes duplicates from a list while preserving order of the items.
@@ -447,9 +453,17 @@ class StdSim:
Stores contents in internal buffer and optionally echos to the inner stream it is simulating.
"""
- def __init__(self, inner_stream: TextIO, *, echo: bool = False, encoding: str = 'utf-8', errors: str = 'replace') -> None:
+ def __init__(
+ self,
+ inner_stream: Union[TextIO, 'StdSim'],
+ *,
+ echo: bool = False,
+ encoding: str = 'utf-8',
+ errors: str = 'replace',
+ ) -> None:
"""
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)
@@ -463,7 +477,11 @@ class StdSim:
self.buffer = ByteBuf(self)
def write(self, s: str) -> None:
- """Add str to internal bytes buffer and if echo is True, echo contents to inner stream"""
+ """
+ Add str to internal bytes buffer and if echo is True, echo contents to inner stream
+
+ :param s: String to write to the stream
+ """
if not isinstance(s, str):
raise TypeError('write() argument must be str, not {}'.format(type(s)))
@@ -481,7 +499,11 @@ class StdSim:
return bytes(self.buffer.byte_buf)
def read(self, size: Optional[int] = -1) -> str:
- """Read from the internal contents as a str and then clear them out"""
+ """
+ Read from the internal contents as a str and then clear them out
+
+ :param size: Number of bytes to read from the stream
+ """
if size is None or size == -1:
result = self.getvalue()
self.clear()
@@ -726,7 +748,7 @@ def align_text(
fill_char: str = ' ',
width: Optional[int] = None,
tab_width: int = 4,
- truncate: bool = False
+ truncate: bool = False,
) -> str:
"""
Align text for display within a given width. Supports characters with display widths greater than 1.
@@ -1060,7 +1082,7 @@ def categorize(func: Union[Callable[..., Any], Iterable[Callable[..., Any]]], ca
for item in func:
setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
else:
- if inspect.ismethod(func) and hasattr(func, '__func__'):
+ if inspect.ismethod(func):
setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category) # type: ignore[attr-defined]
else:
setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
diff --git a/examples/cmd_as_argument.py b/examples/cmd_as_argument.py
index 951c54b9..33ad699b 100755
--- a/examples/cmd_as_argument.py
+++ b/examples/cmd_as_argument.py
@@ -36,7 +36,7 @@ class CmdLineApp(cmd2.Cmd):
self.self_in_py = True
self.maxrepeats = 3
# Make maxrepeats settable at runtime
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command'))
+ self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
speak_parser = argparse.ArgumentParser()
speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
diff --git a/examples/colors.py b/examples/colors.py
index abfb8955..2cdd047d 100755
--- a/examples/colors.py
+++ b/examples/colors.py
@@ -49,7 +49,7 @@ class CmdLineApp(cmd2.Cmd):
self.maxrepeats = 3
# Make maxrepeats settable at runtime
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command'))
+ self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
# Should ANSI color output be allowed
self.allow_style = ansi.STYLE_TERMINAL
diff --git a/examples/decorator_example.py b/examples/decorator_example.py
index 09193926..9497bcc0 100755
--- a/examples/decorator_example.py
+++ b/examples/decorator_example.py
@@ -31,7 +31,7 @@ class CmdLineApp(cmd2.Cmd):
self.maxrepeats = 3
# Make maxrepeats settable at runtime
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command'))
+ self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
# Example of args set from the command-line (but they aren't being used here)
self._ip = ip_addr
diff --git a/examples/environment.py b/examples/environment.py
index 5f5d927b..151c55af 100755
--- a/examples/environment.py
+++ b/examples/environment.py
@@ -13,8 +13,10 @@ class EnvironmentApp(cmd2.Cmd):
super().__init__()
self.degrees_c = 22
self.sunny = False
- self.add_settable(cmd2.Settable('degrees_c', int, 'Temperature in Celsius', onchange_cb=self._onchange_degrees_c))
- self.add_settable(cmd2.Settable('sunny', bool, 'Is it sunny outside?'))
+ self.add_settable(
+ cmd2.Settable('degrees_c', int, 'Temperature in Celsius', self, onchange_cb=self._onchange_degrees_c)
+ )
+ self.add_settable(cmd2.Settable('sunny', bool, 'Is it sunny outside?', self))
def do_sunbathe(self, arg):
"""Attempt to sunbathe."""
diff --git a/examples/example.py b/examples/example.py
index ffa8d3bf..a3d4e90b 100755
--- a/examples/example.py
+++ b/examples/example.py
@@ -32,7 +32,7 @@ class CmdLineApp(cmd2.Cmd):
# Make maxrepeats settable at runtime
self.maxrepeats = 3
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command'))
+ self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
speak_parser = argparse.ArgumentParser()
speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
diff --git a/examples/first_app.py b/examples/first_app.py
index 0b088491..b6335e25 100755
--- a/examples/first_app.py
+++ b/examples/first_app.py
@@ -27,7 +27,7 @@ class FirstApp(cmd2.Cmd):
# Make maxrepeats settable at runtime
self.maxrepeats = 3
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command'))
+ self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
speak_parser = argparse.ArgumentParser()
speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
diff --git a/examples/initialization.py b/examples/initialization.py
index 293d8075..54bef6d8 100755
--- a/examples/initialization.py
+++ b/examples/initialization.py
@@ -51,7 +51,7 @@ class BasicApp(cmd2.Cmd):
# Make echo_fg settable at runtime
self.add_settable(
- cmd2.Settable('foreground_color', str, 'Foreground color to use with echo command', choices=fg.colors())
+ cmd2.Settable('foreground_color', str, 'Foreground color to use with echo command', self, choices=fg.colors())
)
@cmd2.with_category(CUSTOM_CATEGORY)
diff --git a/examples/pirate.py b/examples/pirate.py
index 7b92b6f0..f91b99d7 100755
--- a/examples/pirate.py
+++ b/examples/pirate.py
@@ -28,7 +28,7 @@ class Pirate(cmd2.Cmd):
self.songcolor = 'blue'
# Make songcolor settable at runtime
- self.add_settable(cmd2.Settable('songcolor', str, 'Color to ``sing``', choices=cmd2.ansi.fg.colors()))
+ self.add_settable(cmd2.Settable('songcolor', str, 'Color to ``sing``', self, choices=cmd2.ansi.fg.colors()))
# prompts and defaults
self.gold = 0
diff --git a/examples/plumbum_colors.py b/examples/plumbum_colors.py
index a7cb7e88..16326569 100755
--- a/examples/plumbum_colors.py
+++ b/examples/plumbum_colors.py
@@ -62,11 +62,11 @@ class BgColors(ansi.ColorBase):
purple = bg.Purple
-def get_fg(name: str):
+def get_fg(name: str) -> str:
return str(FgColors[name])
-def get_bg(name: str):
+def get_bg(name: str) -> str:
return str(BgColors[name])
@@ -83,7 +83,7 @@ class CmdLineApp(cmd2.Cmd):
self.maxrepeats = 3
# Make maxrepeats settable at runtime
- self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command'))
+ self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self))
# Should ANSI color output be allowed
self.allow_style = ansi.STYLE_TERMINAL
diff --git a/setup.py b/setup.py
index c9a97077..3289dbd1 100755
--- a/setup.py
+++ b/setup.py
@@ -47,6 +47,7 @@ INSTALL_REQUIRES = [
'colorama >= 0.3.7',
'importlib_metadata>=1.6.0;python_version<"3.8"',
'pyperclip >= 1.6',
+ 'typing_extensions; python_version<"3.8"',
'wcwidth >= 0.1.7',
]
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index f3c44bd4..91815d50 100755
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -214,7 +214,7 @@ 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))
+ self.add_settable(utils.Settable('quiet', bool, "my description", self, onchange_cb=self._onchange_quiet))
def _onchange_quiet(self, name, old, new) -> None:
"""Runs when quiet is changed via set command"""
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 0635bb48..cde77b93 100755
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -67,7 +67,13 @@ class CompletionsExample(cmd2.Cmd):
cmd2.Cmd.__init__(self, multiline_commands=['test_multiline'])
self.foo = 'bar'
self.add_settable(
- utils.Settable('foo', str, description="a settable param", completer=CompletionsExample.complete_foo_val)
+ utils.Settable(
+ 'foo',
+ str,
+ description="a settable param",
+ settable_object=self,
+ completer=CompletionsExample.complete_foo_val,
+ )
)
def do_test_basic(self, args):
diff --git a/tests/test_transcript.py b/tests/test_transcript.py
index 48c6a792..ccb28740 100644
--- a/tests/test_transcript.py
+++ b/tests/test_transcript.py
@@ -42,7 +42,7 @@ class CmdLineApp(cmd2.Cmd):
super().__init__(*args, multiline_commands=['orate'], **kwargs)
# Make maxrepeats settable at runtime
- self.add_settable(Settable('maxrepeats', int, 'Max number of `--repeat`s allowed'))
+ self.add_settable(Settable('maxrepeats', int, 'Max number of `--repeat`s allowed', self))
self.intro = 'This is an intro banner ...'
diff --git a/tests_isolated/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py
index 5bb68b08..2e7d837f 100644
--- a/tests_isolated/test_commandset/test_commandset.py
+++ b/tests_isolated/test_commandset/test_commandset.py
@@ -927,6 +927,45 @@ def test_commandset_settables():
self._arbitrary = Arbitrary()
self._settable_prefix = 'addon'
+ self.my_int = 11
+
+ self.add_settable(
+ Settable(
+ 'arbitrary_value',
+ int,
+ 'Some settable value',
+ settable_object=self._arbitrary,
+ settable_attrib_name='some_value',
+ )
+ )
+
+ # Declare a CommandSet with an empty settable prefix
+ class WithSettablesNoPrefix(CommandSetBase):
+ def __init__(self):
+ super(WithSettablesNoPrefix, self).__init__()
+
+ self._arbitrary = Arbitrary()
+ self._settable_prefix = ''
+ self.my_int = 11
+
+ self.add_settable(
+ Settable(
+ 'another_value',
+ float,
+ 'Some settable value',
+ settable_object=self._arbitrary,
+ settable_attrib_name='some_value',
+ )
+ )
+
+ # Declare a commandset with duplicate settable name
+ class WithSettablesB(CommandSetBase):
+ def __init__(self):
+ super(WithSettablesB, self).__init__()
+
+ self._arbitrary = Arbitrary()
+ self._settable_prefix = 'some'
+ self.my_int = 11
self.add_settable(
Settable(
@@ -941,11 +980,14 @@ def test_commandset_settables():
# create the command set and cmd2
cmdset = WithSettablesA()
arbitrary2 = Arbitrary()
- app = cmd2.Cmd(command_sets=[cmdset])
- app.add_settable(Settable('always_prefix_settables', bool, 'Prefix settables'))
+ app = cmd2.Cmd(command_sets=[cmdset], auto_load_commands=False)
+ setattr(app, 'str_value', '')
+ app.add_settable(Settable('always_prefix_settables', bool, 'Prefix settables', app))
+ app._settables['str_value'] = Settable('str_value', str, 'String value', app)
assert 'arbitrary_value' in app.settables.keys()
assert 'always_prefix_settables' in app.settables.keys()
+ assert 'str_value' in app.settables.keys()
# verify the settable shows up
out, err = run_cmd(app, 'set')
@@ -965,7 +1007,7 @@ now: 10
# can't add to cmd2 now because commandset already has this settable
with pytest.raises(KeyError):
- app.add_settable(Settable('arbitrary_value', int, 'This should fail'))
+ app.add_settable(Settable('arbitrary_value', int, 'This should fail', app))
cmdset.add_settable(
Settable('arbitrary_value', int, 'Replaced settable', settable_object=arbitrary2, settable_attrib_name='some_value')
@@ -973,12 +1015,17 @@ now: 10
# Can't add a settable to the commandset that already exists in cmd2
with pytest.raises(KeyError):
- cmdset.add_settable(Settable('always_prefix_settables', int, 'This should also fail'))
+ cmdset.add_settable(Settable('always_prefix_settables', int, 'This should also fail', cmdset))
# Can't remove a settable from the CommandSet if it is elsewhere and not in the CommandSet
with pytest.raises(KeyError):
cmdset.remove_settable('always_prefix_settables')
+ # verify registering a commandset with duplicate settable names fails
+ cmdset_dupname = WithSettablesB()
+ with pytest.raises(CommandSetRegistrationError):
+ app.register_command_set(cmdset_dupname)
+
# unregister the CommandSet and verify the settable is now gone
app.unregister_command_set(cmdset)
out, err = run_cmd(app, 'set')
@@ -989,12 +1036,24 @@ Parameter 'arbitrary_value' not supported (type 'set' for list of parameters).
"""
assert err == normalize(expected)
+ # Add a commandset with no prefix
+ cmdset_nopfx = WithSettablesNoPrefix()
+ app.register_command_set(cmdset_nopfx)
+
+ with pytest.raises(ValueError):
+ app.always_prefix_settables = True
+
+ app.unregister_command_set(cmdset_nopfx)
+
# turn on prefixes and add the commandset back
app.always_prefix_settables = True
+
+ with pytest.raises(CommandSetRegistrationError):
+ app.register_command_set(cmdset_nopfx)
+
app.register_command_set(cmdset)
# Verify the settable is back with the defined prefix.
-
assert 'addon.arbitrary_value' in app.settables.keys()
# rename the prefix and verify that the prefix changes everywhere
@@ -1006,3 +1065,22 @@ Parameter 'arbitrary_value' not supported (type 'set' for list of parameters).
assert 'some.arbitrary_value: 5' in out
out, err = run_cmd(app, 'set some.arbitrary_value')
assert out == ['some.arbitrary_value: 5']
+
+ # verify registering a commandset with duplicate prefix and settable names fails
+ with pytest.raises(CommandSetRegistrationError):
+ app.register_command_set(cmdset_dupname)
+
+ cmdset_dupname.remove_settable('arbitrary_value')
+
+ app.register_command_set(cmdset_dupname)
+
+ with pytest.raises(KeyError):
+ cmdset_dupname.add_settable(
+ Settable(
+ 'arbitrary_value',
+ int,
+ 'Some settable value',
+ settable_object=cmdset_dupname._arbitrary,
+ settable_attrib_name='some_value',
+ )
+ )