summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEric Lin <anselor@gmail.com>2020-09-10 09:15:05 -0400
committeranselor <anselor@gmail.com>2020-09-11 13:50:45 -0400
commit872da20feba57f42dde204da01dc48c4c87e1b54 (patch)
tree37f7812aae5eddac9d8d37ead8b22828378e7497
parent6093e5e9c1b6366c67323f090d21696e867b6625 (diff)
downloadcmd2-git-872da20feba57f42dde204da01dc48c4c87e1b54.tar.gz
Changes default category to be heritable by default - meaning that subclasses will inherit the parent class's default category.
Adds optional flag to disable heritability.
-rw-r--r--CHANGELOG.md2
-rw-r--r--cmd2/cmd2.py7
-rw-r--r--cmd2/command_definition.py24
-rw-r--r--cmd2/constants.py1
-rw-r--r--cmd2/utils.py5
-rw-r--r--examples/default_categories.py80
-rw-r--r--tests_isolated/test_commandset/test_categories.py111
-rw-r--r--tests_isolated/test_commandset/test_commandset.py3
8 files changed, 228 insertions, 5 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df8fae73..c7938be1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
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.
* Bug Fixes
* Fixed issue where flag names weren't always sorted correctly in argparse tab completion
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 3e9ea9bc..97378bef 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..d396fb6a 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -1038,7 +1038,10 @@ 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):
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/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)
####################################################################################################################