summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md4
-rw-r--r--cmd2/cmd2.py7
-rw-r--r--cmd2/command_definition.py24
-rw-r--r--cmd2/constants.py1
-rw-r--r--cmd2/utils.py26
-rw-r--r--examples/default_categories.py80
-rw-r--r--plugins/ext_test/CHANGELOG.md12
-rw-r--r--plugins/ext_test/setup.py8
-rw-r--r--tests/test_utils.py7
-rw-r--r--tests_isolated/test_commandset/test_categories.py111
-rw-r--r--tests_isolated/test_commandset/test_commandset.py3
11 files changed, 263 insertions, 20 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f29e43b7..d1f33931 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,12 @@
-## 1.4.0 (TBD, 2020)
+## 1.3.10 (September 17, 2020)
* Enhancements
* Added user-settable option called `always_show_hint`. If True, then tab completion hints will always
display even when tab completion suggestions print. Arguments whose help or hint text is suppressed will
not display hints even when this setting is True.
* argparse tab completion now groups flag names which run the same action. Optional flags are wrapped
in brackets like it is done in argparse usage text.
+ * default category decorators are now heritable by default and will propagate the category down the
+ class hierarchy until overridden. There's a new optional flag to set heritable to false.
* Added `--silent` flag to `alias/macro create`. If used, then no confirmation message will be printed
when aliases and macros are created or overwritten.
* Added `--with_silent` flag to `alias/macro list`. Use this option when saving to a startup script
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index badae1d8..9b561889 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -46,7 +46,7 @@ from . import ansi, constants, plugin, utils
from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
from .command_definition import CommandSet
-from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX
+from .constants import CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX
from .decorators import with_argparser, as_subcommand_to
from .exceptions import (
CommandSetRegistrationError,
@@ -483,6 +483,8 @@ class Cmd(cmd.Cmd):
predicate=lambda meth: isinstance(meth, Callable)
and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX))
+ default_category = getattr(cmdset, CLASS_ATTR_DEFAULT_HELP_CATEGORY, None)
+
installed_attributes = []
try:
for method_name, method in methods:
@@ -505,6 +507,9 @@ class Cmd(cmd.Cmd):
self._cmd_to_command_sets[command] = cmdset
+ if default_category and not hasattr(method, constants.CMD_ATTR_HELP_CATEGORY):
+ utils.categorize(method, default_category)
+
self._installed_command_sets.append(cmdset)
self._register_subcommands(cmdset)
diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py
index 64adaada..3f05792c 100644
--- a/cmd2/command_definition.py
+++ b/cmd2/command_definition.py
@@ -4,7 +4,7 @@ Supports the definition of commands in separate classes to be composed into cmd2
"""
from typing import Optional, Type
-from .constants import COMMAND_FUNC_PREFIX
+from .constants import CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX
from .exceptions import CommandSetRegistrationError
# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues
@@ -17,22 +17,40 @@ except ImportError: # pragma: no cover
pass
-def with_default_category(category: str):
+def with_default_category(category: str, *, heritable: bool = True):
"""
Decorator that applies a category to all ``do_*`` command methods in a class that do not already
have a category specified.
+ CommandSets that are decorated by this with `heritable` set to True (default) will set a class attribute that is
+ inherited by all subclasses unless overridden. All commands of this CommandSet and all subclasses of this CommandSet
+ that do not declare an explicit category will be placed in this category. Subclasses may use this decorator to
+ override the default category.
+
+ If `heritable` is set to False, then only the commands declared locally to this CommandSet will be placed in the
+ specified category. Dynamically created commands, and commands declared in sub-classes will not receive this
+ category.
+
:param category: category to put all uncategorized commands in
+ :param heritable: Flag whether this default category should apply to sub-classes. Defaults to True
:return: decorator function
"""
def decorate_class(cls: Type[CommandSet]):
+ if heritable:
+ setattr(cls, CLASS_ATTR_DEFAULT_HELP_CATEGORY, category)
+
from .constants import CMD_ATTR_HELP_CATEGORY
import inspect
from .decorators import with_category
+ # get members of the class that meet the following criteria:
+ # 1. Must be a function
+ # 2. Must start with COMMAND_FUNC_PREFIX (do_)
+ # 3. Must be a member of the class being decorated and not one inherited from a parent declaration
methods = inspect.getmembers(
cls,
- predicate=lambda meth: inspect.isfunction(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX))
+ predicate=lambda meth: inspect.isfunction(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)
+ and meth in inspect.getmro(cls)[0].__dict__.values())
category_decorator = with_category(category)
for method in methods:
if not hasattr(method[1], CMD_ATTR_HELP_CATEGORY):
diff --git a/cmd2/constants.py b/cmd2/constants.py
index 037a7cab..552c1a74 100644
--- a/cmd2/constants.py
+++ b/cmd2/constants.py
@@ -39,6 +39,7 @@ COMPLETER_FUNC_PREFIX = 'complete_'
# The custom help category a command belongs to
CMD_ATTR_HELP_CATEGORY = 'help_category'
+CLASS_ATTR_DEFAULT_HELP_CATEGORY = 'cmd2_default_help_category'
# The argparse parser for the command
CMD_ATTR_ARGPARSER = 'argparser'
diff --git a/cmd2/utils.py b/cmd2/utils.py
index a2b1c854..b6dadf1c 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -11,9 +11,10 @@ import re
import subprocess
import sys
import threading
+
import unicodedata
from enum import Enum
-from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union
+from typing import Any, Callable, Dict, IO, Iterable, List, Optional, TextIO, Type, Union
from . import constants
@@ -470,10 +471,15 @@ class StdSim:
"""Get the internal contents as bytes"""
return bytes(self.buffer.byte_buf)
- def read(self) -> str:
+ def read(self, size: Optional[int] = -1) -> str:
"""Read from the internal contents as a str and then clear them out"""
- result = self.getvalue()
- self.clear()
+ if size is None or size == -1:
+ result = self.getvalue()
+ self.clear()
+ else:
+ result = self.buffer.byte_buf[:size].decode(encoding=self.encoding, errors=self.errors)
+ self.buffer.byte_buf = self.buffer.byte_buf[size:]
+
return result
def readbytes(self) -> bytes:
@@ -668,7 +674,7 @@ class ContextFlag:
class RedirectionSavedState:
"""Created by each command to store information required to restore state after redirection"""
- def __init__(self, self_stdout: Union[StdSim, TextIO], sys_stdout: Union[StdSim, TextIO],
+ def __init__(self, self_stdout: Union[StdSim, IO[str]], sys_stdout: Union[StdSim, IO[str]],
pipe_proc_reader: Optional[ProcReader], saved_redirecting: bool) -> None:
"""
RedirectionSavedState initializer
@@ -1025,11 +1031,12 @@ def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None
:Example:
+ >>> import cmd2
>>> class MyApp(cmd2.Cmd):
>>> def do_echo(self, arglist):
>>> self.poutput(' '.join(arglist)
>>>
- >>> utils.categorize(do_echo, "Text Processing")
+ >>> cmd2.utils.categorize(do_echo, "Text Processing")
For an alternative approach to categorizing commands using a decorator, see
:func:`~cmd2.decorators.with_category`
@@ -1038,10 +1045,13 @@ def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None
for item in func:
setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
else:
- setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
+ if inspect.ismethod(func):
+ setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category)
+ else:
+ setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
-def get_defining_class(meth):
+def get_defining_class(meth) -> Type:
"""
Attempts to resolve the class that defined a method.
diff --git a/examples/default_categories.py b/examples/default_categories.py
new file mode 100644
index 00000000..19699513
--- /dev/null
+++ b/examples/default_categories.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+# coding=utf-8
+"""
+Simple example demonstrating basic CommandSet usage.
+"""
+
+import cmd2
+from cmd2 import CommandSet, with_default_category
+
+
+@with_default_category('Default Category')
+class MyBaseCommandSet(CommandSet):
+ """Defines a default category for all sub-class CommandSets"""
+ pass
+
+
+class ChildInheritsParentCategories(MyBaseCommandSet):
+ """
+ This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'
+ """
+ def do_hello(self, _: cmd2.Statement):
+ self._cmd.poutput('Hello')
+
+ def do_world(self, _: cmd2.Statement):
+ self._cmd.poutput('World')
+
+
+@with_default_category('Non-Heritable Category', heritable=False)
+class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet):
+ """
+ This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this
+ CommandSet will not inherit this category and will, instead, inherit 'Default Category'
+ """
+ def do_goodbye(self, _: cmd2.Statement):
+ self._cmd.poutput('Goodbye')
+
+
+class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable):
+ """
+ This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined
+ by the grandparent class.
+ """
+ def do_aloha(self, _: cmd2.Statement):
+ self._cmd.poutput('Aloha')
+
+
+@with_default_category('Heritable Category')
+class ChildOverridesParentCategories(MyBaseCommandSet):
+ """
+ This subclass is decorated with a default category that is heritable. This overrides the parent class's default
+ category declaration.
+ """
+ def do_bonjour(self, _: cmd2.Statement):
+ self._cmd.poutput('Bonjour')
+
+
+class GrandchildInheritsHeritable(ChildOverridesParentCategories):
+ """
+ This subclass's parent declares a default category that overrides its parent. As a result, commands in this
+ CommandSet will be categorized under 'Heritable Category'
+ """
+ def do_monde(self, _: cmd2.Statement):
+ self._cmd.poutput('Monde')
+
+
+class ExampleApp(cmd2.Cmd):
+ """
+ Example to demonstrate heritable default categories
+ """
+
+ def __init__(self):
+ super(ExampleApp, self).__init__()
+
+ def do_something(self, arg):
+ self.poutput('this is the something command')
+
+
+if __name__ == '__main__':
+ app = ExampleApp()
+ app.cmdloop()
diff --git a/plugins/ext_test/CHANGELOG.md b/plugins/ext_test/CHANGELOG.md
index c2880b64..031d4227 100644
--- a/plugins/ext_test/CHANGELOG.md
+++ b/plugins/ext_test/CHANGELOG.md
@@ -4,11 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
-## 0.2.0 (TBD)
-- Relocated inside of cmd2 project
-- Fixes to match cmd2 API changes
+## 0.2.0 (2020-09-11)
+* Updated documentation to reflect new home inside of main cmd2 repo.
+* Updated python version requirements to match cmd2
-## 0.1.0 (2020-03-09)
+## 0.1.2 (2020-08-03)
+* Bug Fixes
+ * Applied fix to match change in cmd2 APIs
+
+## 0.1.1 (2020-03-09)
### Added
- Initial contribution
diff --git a/plugins/ext_test/setup.py b/plugins/ext_test/setup.py
index d48e9871..3384527c 100644
--- a/plugins/ext_test/setup.py
+++ b/plugins/ext_test/setup.py
@@ -28,17 +28,17 @@ setuptools.setup(
author='Eric Lin',
author_email='anselor@gmail.com',
- url='https://github.com/python-cmd2/cmd2-ext-test',
+ url='https://github.com/python-cmd2/cmd2/tree/master/plugins/ext_test',
license='MIT',
packages=['cmd2_ext_test'],
- python_requires='>=3.4',
+ python_requires='>=3.5',
install_requires=['cmd2 >= 0.9.4, <=2'],
setup_requires=['setuptools_scm >= 3.0'],
classifiers=[
- 'Development Status :: 4 - Beta',
+ 'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Operating System :: OS Independent',
'Topic :: Software Development :: Libraries :: Python Modules',
@@ -48,6 +48,8 @@ setuptools.setup(
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.9',
],
# dependencies for development and testing
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 27bf4743..2c94466c 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -154,6 +154,13 @@ def test_stdsim_read(stdout_sim):
assert stdout_sim.read() == my_str
assert stdout_sim.getvalue() == ''
+ stdout_sim.write(my_str)
+
+ assert stdout_sim.getvalue() == my_str
+ assert stdout_sim.read(2) == my_str[:2]
+ assert stdout_sim.getvalue() == my_str[2:]
+
+
def test_stdsim_read_bytes(stdout_sim):
b_str = b'Hello World'
stdout_sim.buffer.write(b_str)
diff --git a/tests_isolated/test_commandset/test_categories.py b/tests_isolated/test_commandset/test_categories.py
new file mode 100644
index 00000000..c266e0d4
--- /dev/null
+++ b/tests_isolated/test_commandset/test_categories.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+# coding=utf-8
+"""
+Simple example demonstrating basic CommandSet usage.
+"""
+from typing import Any
+
+import cmd2
+from cmd2 import CommandSet, with_default_category
+
+
+@with_default_category('Default Category')
+class MyBaseCommandSet(CommandSet):
+ """Defines a default category for all sub-class CommandSets"""
+ def __init__(self, _: Any):
+ super(MyBaseCommandSet, self).__init__()
+
+
+class ChildInheritsParentCategories(MyBaseCommandSet):
+ """
+ This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'
+ """
+ def do_hello(self, _: cmd2.Statement):
+ self._cmd.poutput('Hello')
+
+ def do_world(self, _: cmd2.Statement):
+ self._cmd.poutput('World')
+
+
+@with_default_category('Non-Heritable Category', heritable=False)
+class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet):
+ """
+ This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this
+ CommandSet will not inherit this category and will, instead, inherit 'Default Category'
+ """
+ def do_goodbye(self, _: cmd2.Statement):
+ self._cmd.poutput('Goodbye')
+
+
+class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable):
+ """
+ This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined
+ by the grandparent class.
+ """
+ def do_aloha(self, _: cmd2.Statement):
+ self._cmd.poutput('Aloha')
+
+
+@with_default_category('Heritable Category')
+class ChildOverridesParentCategories(MyBaseCommandSet):
+ """
+ This subclass is decorated with a default category that is heritable. This overrides the parent class's default
+ category declaration.
+ """
+ def do_bonjour(self, _: cmd2.Statement):
+ self._cmd.poutput('Bonjour')
+
+
+class GrandchildInheritsHeritable(ChildOverridesParentCategories):
+ """
+ This subclass's parent declares a default category that overrides its parent. As a result, commands in this
+ CommandSet will be categorized under 'Heritable Category'
+ """
+ def do_monde(self, _: cmd2.Statement):
+ self._cmd.poutput('Monde')
+
+
+class ExampleApp(cmd2.Cmd):
+ """
+ Example to demonstrate heritable default categories
+ """
+
+ def __init__(self):
+ super(ExampleApp, self).__init__(auto_load_commands=False)
+
+ def do_something(self, arg):
+ self.poutput('this is the something command')
+
+
+def test_heritable_categories():
+ app = ExampleApp()
+
+ base_cs = MyBaseCommandSet(0)
+ assert getattr(base_cs, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
+
+ child1 = ChildInheritsParentCategories(1)
+ assert getattr(child1, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
+ app.register_command_set(child1)
+ assert getattr(app.cmd_func('hello').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category'
+ app.unregister_command_set(child1)
+
+ child_nonheritable = ChildOverridesParentCategoriesNonHeritable(2)
+ assert getattr(child_nonheritable, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) != 'Non-Heritable Category'
+ app.register_command_set(child_nonheritable)
+ assert getattr(app.cmd_func('goodbye').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Non-Heritable Category'
+ app.unregister_command_set(child_nonheritable)
+
+ grandchild1 = GrandchildInheritsGrandparentCategory(3)
+ assert getattr(grandchild1, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
+ app.register_command_set(grandchild1)
+ assert getattr(app.cmd_func('aloha').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category'
+ app.unregister_command_set(grandchild1)
+
+ child_overrides = ChildOverridesParentCategories(4)
+ assert getattr(child_overrides, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category'
+ app.register_command_set(child_overrides)
+ assert getattr(app.cmd_func('bonjour').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Heritable Category'
+ app.unregister_command_set(child_overrides)
+
+ grandchild2 = GrandchildInheritsHeritable(5)
+ assert getattr(grandchild2, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category'
diff --git a/tests_isolated/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py
index d7f2523e..21cce8bf 100644
--- a/tests_isolated/test_commandset/test_commandset.py
+++ b/tests_isolated/test_commandset/test_commandset.py
@@ -685,6 +685,7 @@ def test_static_subcommands(static_subcommands_app):
complete_states_expected_self = None
+@cmd2.with_default_category('With Completer')
class WithCompleterCommandSet(cmd2.CommandSet):
states = ['alabama', 'alaska', 'arizona', 'arkansas', 'california', 'colorado', 'connecticut', 'delaware']
@@ -752,6 +753,8 @@ def test_cross_commandset_completer(command_sets_manual):
assert first_match == 'alabama'
assert command_sets_manual.completion_matches == WithCompleterCommandSet.states
+ assert getattr(command_sets_manual.cmd_func('case1').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY) == 'With Completer'
+
command_sets_manual.unregister_command_set(case1_set)
####################################################################################################################