diff options
author | Eric Lin <anselor@gmail.com> | 2021-03-16 12:25:34 -0400 |
---|---|---|
committer | Eric Lin <anselor@gmail.com> | 2021-03-18 14:16:03 -0400 |
commit | 0cd626ebbef273aa78c2d1154ebdd5f9055028cf (patch) | |
tree | 9a822b245312b3b515b64a69d772fab75fce8121 | |
parent | a649286b9468ebadbafeca1abf20a946351ceefe (diff) | |
download | cmd2-git-cmdset_settables.tar.gz |
Resolves comments from PRcmdset_settables
-rw-r--r-- | CHANGELOG.md | 10 | ||||
-rw-r--r-- | cmd2/ansi.py | 20 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 141 | ||||
-rw-r--r-- | cmd2/cmd2.py | 152 | ||||
-rw-r--r-- | cmd2/command_definition.py | 13 | ||||
-rw-r--r-- | cmd2/utils.py | 48 | ||||
-rwxr-xr-x | examples/cmd_as_argument.py | 2 | ||||
-rwxr-xr-x | examples/colors.py | 2 | ||||
-rwxr-xr-x | examples/decorator_example.py | 2 | ||||
-rwxr-xr-x | examples/environment.py | 6 | ||||
-rwxr-xr-x | examples/example.py | 2 | ||||
-rwxr-xr-x | examples/first_app.py | 2 | ||||
-rwxr-xr-x | examples/initialization.py | 2 | ||||
-rwxr-xr-x | examples/pirate.py | 2 | ||||
-rwxr-xr-x | examples/plumbum_colors.py | 6 | ||||
-rwxr-xr-x | setup.py | 1 | ||||
-rwxr-xr-x | tests/test_cmd2.py | 2 | ||||
-rwxr-xr-x | tests/test_completion.py | 8 | ||||
-rw-r--r-- | tests/test_transcript.py | 2 | ||||
-rw-r--r-- | tests_isolated/test_commandset/test_commandset.py | 88 |
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 @@ -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', + ) + ) |