From eb0669e6a8b8cc7ad2bde5b9cb838bc747a6eba2 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 10 Sep 2020 12:16:42 -0400 Subject: Updated URL and documentation in preparation for cmd2-ext-test 0.2.0 release --- plugins/ext_test/CHANGELOG.md | 12 ++++++++---- plugins/ext_test/setup.py | 8 +++++--- 2 files changed, 13 insertions(+), 7 deletions(-) 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..7bc29b5b 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 -- cgit v1.2.1 From 6093e5e9c1b6366c67323f090d21696e867b6625 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 11 Sep 2020 09:25:39 -0400 Subject: Fixed classifier spacing --- plugins/ext_test/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ext_test/setup.py b/plugins/ext_test/setup.py index 7bc29b5b..3384527c 100644 --- a/plugins/ext_test/setup.py +++ b/plugins/ext_test/setup.py @@ -48,8 +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', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', ], # dependencies for development and testing -- cgit v1.2.1 From 872da20feba57f42dde204da01dc48c4c87e1b54 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 10 Sep 2020 09:15:05 -0400 Subject: 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. --- CHANGELOG.md | 2 + cmd2/cmd2.py | 7 +- cmd2/command_definition.py | 24 ++++- cmd2/constants.py | 1 + cmd2/utils.py | 5 +- examples/default_categories.py | 80 ++++++++++++++++ tests_isolated/test_commandset/test_categories.py | 111 ++++++++++++++++++++++ tests_isolated/test_commandset/test_commandset.py | 3 + 8 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 examples/default_categories.py create mode 100644 tests_isolated/test_commandset/test_categories.py 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) #################################################################################################################### -- cgit v1.2.1 From 92b8a38d66d255027a0440c45582d319f2694aab Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 8 Sep 2020 16:01:04 -0400 Subject: Minor type hinting fixes. --- cmd2/utils.py | 21 ++++++++++++++------- tests/test_utils.py | 7 +++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/cmd2/utils.py b/cmd2/utils.py index d396fb6a..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` @@ -1044,7 +1051,7 @@ def categorize(func: Union[Callable, Iterable[Callable]], category: str) -> None 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/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) -- cgit v1.2.1