Skip to content

Commit

Permalink
Changes default category to be heritable by default - meaning that su…
Browse files Browse the repository at this point in the history
…bclasses will inherit the parent class's default category.

Adds optional flag to disable heritability.
  • Loading branch information
anselor committed Sep 11, 2020
1 parent 6093e5e commit 872da20
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 5 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
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,
Expand Down Expand Up @@ -483,6 +483,8 @@ def register_command_set(self, cmdset: CommandSet) -> None:
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:
Expand All @@ -505,6 +507,9 @@ def register_command_set(self, cmdset: CommandSet) -> None:

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)
Expand Down
24 changes: 21 additions & 3 deletions cmd2/command_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""
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
Expand All @@ -17,22 +17,40 @@
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):
Expand Down
1 change: 1 addition & 0 deletions cmd2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

# 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'
Expand Down
5 changes: 4 additions & 1 deletion cmd2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
80 changes: 80 additions & 0 deletions examples/default_categories.py
Original file line number Diff line number Diff line change
@@ -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()
111 changes: 111 additions & 0 deletions tests_isolated/test_commandset/test_categories.py
Original file line number Diff line number Diff line change
@@ -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'
3 changes: 3 additions & 0 deletions tests_isolated/test_commandset/test_commandset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -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)

####################################################################################################################
Expand Down

0 comments on commit 872da20

Please sign in to comment.