Skip to content

Commit 872da20

Browse files
committed
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.
1 parent 6093e5e commit 872da20

File tree

8 files changed

+228
-5
lines changed

8 files changed

+228
-5
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
not display hints even when this setting is True.
66
* argparse tab completion now groups flag names which run the same action. Optional flags are wrapped
77
in brackets like it is done in argparse usage text.
8+
* default category decorators are now heritable by default and will propagate the category down the
9+
class hierarchy until overridden. There's a new optional flag to set heritable to false.
810
* Bug Fixes
911
* Fixed issue where flag names weren't always sorted correctly in argparse tab completion
1012

cmd2/cmd2.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem
4747
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
4848
from .command_definition import CommandSet
49-
from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX
49+
from .constants import CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX
5050
from .decorators import with_argparser, as_subcommand_to
5151
from .exceptions import (
5252
CommandSetRegistrationError,
@@ -483,6 +483,8 @@ def register_command_set(self, cmdset: CommandSet) -> None:
483483
predicate=lambda meth: isinstance(meth, Callable)
484484
and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX))
485485

486+
default_category = getattr(cmdset, CLASS_ATTR_DEFAULT_HELP_CATEGORY, None)
487+
486488
installed_attributes = []
487489
try:
488490
for method_name, method in methods:
@@ -505,6 +507,9 @@ def register_command_set(self, cmdset: CommandSet) -> None:
505507

506508
self._cmd_to_command_sets[command] = cmdset
507509

510+
if default_category and not hasattr(method, constants.CMD_ATTR_HELP_CATEGORY):
511+
utils.categorize(method, default_category)
512+
508513
self._installed_command_sets.append(cmdset)
509514

510515
self._register_subcommands(cmdset)

cmd2/command_definition.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55
from typing import Optional, Type
66

7-
from .constants import COMMAND_FUNC_PREFIX
7+
from .constants import CLASS_ATTR_DEFAULT_HELP_CATEGORY, COMMAND_FUNC_PREFIX
88
from .exceptions import CommandSetRegistrationError
99

1010
# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues
@@ -17,22 +17,40 @@
1717
pass
1818

1919

20-
def with_default_category(category: str):
20+
def with_default_category(category: str, *, heritable: bool = True):
2121
"""
2222
Decorator that applies a category to all ``do_*`` command methods in a class that do not already
2323
have a category specified.
2424
25+
CommandSets that are decorated by this with `heritable` set to True (default) will set a class attribute that is
26+
inherited by all subclasses unless overridden. All commands of this CommandSet and all subclasses of this CommandSet
27+
that do not declare an explicit category will be placed in this category. Subclasses may use this decorator to
28+
override the default category.
29+
30+
If `heritable` is set to False, then only the commands declared locally to this CommandSet will be placed in the
31+
specified category. Dynamically created commands, and commands declared in sub-classes will not receive this
32+
category.
33+
2534
:param category: category to put all uncategorized commands in
35+
:param heritable: Flag whether this default category should apply to sub-classes. Defaults to True
2636
:return: decorator function
2737
"""
2838

2939
def decorate_class(cls: Type[CommandSet]):
40+
if heritable:
41+
setattr(cls, CLASS_ATTR_DEFAULT_HELP_CATEGORY, category)
42+
3043
from .constants import CMD_ATTR_HELP_CATEGORY
3144
import inspect
3245
from .decorators import with_category
46+
# get members of the class that meet the following criteria:
47+
# 1. Must be a function
48+
# 2. Must start with COMMAND_FUNC_PREFIX (do_)
49+
# 3. Must be a member of the class being decorated and not one inherited from a parent declaration
3350
methods = inspect.getmembers(
3451
cls,
35-
predicate=lambda meth: inspect.isfunction(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX))
52+
predicate=lambda meth: inspect.isfunction(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)
53+
and meth in inspect.getmro(cls)[0].__dict__.values())
3654
category_decorator = with_category(category)
3755
for method in methods:
3856
if not hasattr(method[1], CMD_ATTR_HELP_CATEGORY):

cmd2/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
# The custom help category a command belongs to
4141
CMD_ATTR_HELP_CATEGORY = 'help_category'
42+
CLASS_ATTR_DEFAULT_HELP_CATEGORY = 'cmd2_default_help_category'
4243

4344
# The argparse parser for the command
4445
CMD_ATTR_ARGPARSER = 'argparser'

cmd2/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,10 @@ def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None
10381038
for item in func:
10391039
setattr(item, constants.CMD_ATTR_HELP_CATEGORY, category)
10401040
else:
1041-
setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
1041+
if inspect.ismethod(func):
1042+
setattr(func.__func__, constants.CMD_ATTR_HELP_CATEGORY, category)
1043+
else:
1044+
setattr(func, constants.CMD_ATTR_HELP_CATEGORY, category)
10421045

10431046

10441047
def get_defining_class(meth):

examples/default_categories.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env python3
2+
# coding=utf-8
3+
"""
4+
Simple example demonstrating basic CommandSet usage.
5+
"""
6+
7+
import cmd2
8+
from cmd2 import CommandSet, with_default_category
9+
10+
11+
@with_default_category('Default Category')
12+
class MyBaseCommandSet(CommandSet):
13+
"""Defines a default category for all sub-class CommandSets"""
14+
pass
15+
16+
17+
class ChildInheritsParentCategories(MyBaseCommandSet):
18+
"""
19+
This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'
20+
"""
21+
def do_hello(self, _: cmd2.Statement):
22+
self._cmd.poutput('Hello')
23+
24+
def do_world(self, _: cmd2.Statement):
25+
self._cmd.poutput('World')
26+
27+
28+
@with_default_category('Non-Heritable Category', heritable=False)
29+
class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet):
30+
"""
31+
This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this
32+
CommandSet will not inherit this category and will, instead, inherit 'Default Category'
33+
"""
34+
def do_goodbye(self, _: cmd2.Statement):
35+
self._cmd.poutput('Goodbye')
36+
37+
38+
class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable):
39+
"""
40+
This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined
41+
by the grandparent class.
42+
"""
43+
def do_aloha(self, _: cmd2.Statement):
44+
self._cmd.poutput('Aloha')
45+
46+
47+
@with_default_category('Heritable Category')
48+
class ChildOverridesParentCategories(MyBaseCommandSet):
49+
"""
50+
This subclass is decorated with a default category that is heritable. This overrides the parent class's default
51+
category declaration.
52+
"""
53+
def do_bonjour(self, _: cmd2.Statement):
54+
self._cmd.poutput('Bonjour')
55+
56+
57+
class GrandchildInheritsHeritable(ChildOverridesParentCategories):
58+
"""
59+
This subclass's parent declares a default category that overrides its parent. As a result, commands in this
60+
CommandSet will be categorized under 'Heritable Category'
61+
"""
62+
def do_monde(self, _: cmd2.Statement):
63+
self._cmd.poutput('Monde')
64+
65+
66+
class ExampleApp(cmd2.Cmd):
67+
"""
68+
Example to demonstrate heritable default categories
69+
"""
70+
71+
def __init__(self):
72+
super(ExampleApp, self).__init__()
73+
74+
def do_something(self, arg):
75+
self.poutput('this is the something command')
76+
77+
78+
if __name__ == '__main__':
79+
app = ExampleApp()
80+
app.cmdloop()
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env python3
2+
# coding=utf-8
3+
"""
4+
Simple example demonstrating basic CommandSet usage.
5+
"""
6+
from typing import Any
7+
8+
import cmd2
9+
from cmd2 import CommandSet, with_default_category
10+
11+
12+
@with_default_category('Default Category')
13+
class MyBaseCommandSet(CommandSet):
14+
"""Defines a default category for all sub-class CommandSets"""
15+
def __init__(self, _: Any):
16+
super(MyBaseCommandSet, self).__init__()
17+
18+
19+
class ChildInheritsParentCategories(MyBaseCommandSet):
20+
"""
21+
This subclass doesn't declare any categories so all commands here are also categorized under 'Default Category'
22+
"""
23+
def do_hello(self, _: cmd2.Statement):
24+
self._cmd.poutput('Hello')
25+
26+
def do_world(self, _: cmd2.Statement):
27+
self._cmd.poutput('World')
28+
29+
30+
@with_default_category('Non-Heritable Category', heritable=False)
31+
class ChildOverridesParentCategoriesNonHeritable(MyBaseCommandSet):
32+
"""
33+
This subclass overrides the 'Default Category' from the parent, but in a non-heritable fashion. Sub-classes of this
34+
CommandSet will not inherit this category and will, instead, inherit 'Default Category'
35+
"""
36+
def do_goodbye(self, _: cmd2.Statement):
37+
self._cmd.poutput('Goodbye')
38+
39+
40+
class GrandchildInheritsGrandparentCategory(ChildOverridesParentCategoriesNonHeritable):
41+
"""
42+
This subclass's parent class declared its default category non-heritable. Instead, it inherits the category defined
43+
by the grandparent class.
44+
"""
45+
def do_aloha(self, _: cmd2.Statement):
46+
self._cmd.poutput('Aloha')
47+
48+
49+
@with_default_category('Heritable Category')
50+
class ChildOverridesParentCategories(MyBaseCommandSet):
51+
"""
52+
This subclass is decorated with a default category that is heritable. This overrides the parent class's default
53+
category declaration.
54+
"""
55+
def do_bonjour(self, _: cmd2.Statement):
56+
self._cmd.poutput('Bonjour')
57+
58+
59+
class GrandchildInheritsHeritable(ChildOverridesParentCategories):
60+
"""
61+
This subclass's parent declares a default category that overrides its parent. As a result, commands in this
62+
CommandSet will be categorized under 'Heritable Category'
63+
"""
64+
def do_monde(self, _: cmd2.Statement):
65+
self._cmd.poutput('Monde')
66+
67+
68+
class ExampleApp(cmd2.Cmd):
69+
"""
70+
Example to demonstrate heritable default categories
71+
"""
72+
73+
def __init__(self):
74+
super(ExampleApp, self).__init__(auto_load_commands=False)
75+
76+
def do_something(self, arg):
77+
self.poutput('this is the something command')
78+
79+
80+
def test_heritable_categories():
81+
app = ExampleApp()
82+
83+
base_cs = MyBaseCommandSet(0)
84+
assert getattr(base_cs, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
85+
86+
child1 = ChildInheritsParentCategories(1)
87+
assert getattr(child1, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
88+
app.register_command_set(child1)
89+
assert getattr(app.cmd_func('hello').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category'
90+
app.unregister_command_set(child1)
91+
92+
child_nonheritable = ChildOverridesParentCategoriesNonHeritable(2)
93+
assert getattr(child_nonheritable, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) != 'Non-Heritable Category'
94+
app.register_command_set(child_nonheritable)
95+
assert getattr(app.cmd_func('goodbye').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Non-Heritable Category'
96+
app.unregister_command_set(child_nonheritable)
97+
98+
grandchild1 = GrandchildInheritsGrandparentCategory(3)
99+
assert getattr(grandchild1, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Default Category'
100+
app.register_command_set(grandchild1)
101+
assert getattr(app.cmd_func('aloha').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Default Category'
102+
app.unregister_command_set(grandchild1)
103+
104+
child_overrides = ChildOverridesParentCategories(4)
105+
assert getattr(child_overrides, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category'
106+
app.register_command_set(child_overrides)
107+
assert getattr(app.cmd_func('bonjour').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY, None) == 'Heritable Category'
108+
app.unregister_command_set(child_overrides)
109+
110+
grandchild2 = GrandchildInheritsHeritable(5)
111+
assert getattr(grandchild2, cmd2.constants.CLASS_ATTR_DEFAULT_HELP_CATEGORY, None) == 'Heritable Category'

tests_isolated/test_commandset/test_commandset.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,7 @@ def test_static_subcommands(static_subcommands_app):
685685
complete_states_expected_self = None
686686

687687

688+
@cmd2.with_default_category('With Completer')
688689
class WithCompleterCommandSet(cmd2.CommandSet):
689690
states = ['alabama', 'alaska', 'arizona', 'arkansas', 'california', 'colorado', 'connecticut', 'delaware']
690691

@@ -752,6 +753,8 @@ def test_cross_commandset_completer(command_sets_manual):
752753
assert first_match == 'alabama'
753754
assert command_sets_manual.completion_matches == WithCompleterCommandSet.states
754755

756+
assert getattr(command_sets_manual.cmd_func('case1').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY) == 'With Completer'
757+
755758
command_sets_manual.unregister_command_set(case1_set)
756759

757760
####################################################################################################################

0 commit comments

Comments
 (0)