Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 1.3.4 (TBD)
* Bug Fixes
* Fixed `AttributeError` when `CommandSet` that uses `as_subcommand_to` decorator is loaded during
`cmd2.Cmd.__init__()`.

## 1.3.3 (August 13, 2020)
* Breaking changes
* CommandSet command functions (do_, complete_, help_) will no longer have the cmd2 app
Expand Down
44 changes: 27 additions & 17 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,22 +259,6 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
multiline_commands=multiline_commands,
shortcuts=shortcuts)

# Load modular commands
self._installed_command_sets = [] # type: List[CommandSet]
self._cmd_to_command_sets = {} # type: Dict[str, CommandSet]
if command_sets:
for command_set in command_sets:
self.register_command_set(command_set)

if auto_load_commands:
self._autoload_commands()

# Verify commands don't have invalid names (like starting with a shortcut)
for cur_cmd in self.get_all_commands():
valid, errmsg = self.statement_parser.is_valid_command(cur_cmd)
if not valid:
raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg))

# Stores results from the last command run to enable usage of results in a Python script or interactive console
# Built-in commands don't make use of this. It is purely there for user-defined commands and convenience.
self.last_result = None
Expand Down Expand Up @@ -412,6 +396,28 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
# If False, then complete() will sort the matches using self.default_sort_key before they are displayed.
self.matches_sorted = False

############################################################################################################
# The following code block loads CommandSets, verifies command names, and registers subcommands.
# This block should appear after all attributes have been created since the registration code
# depends on them and it's possible a module's on_register() method may need to access some.
############################################################################################################
# Load modular commands
self._installed_command_sets = [] # type: List[CommandSet]
self._cmd_to_command_sets = {} # type: Dict[str, CommandSet]
if command_sets:
for command_set in command_sets:
self.register_command_set(command_set)

if auto_load_commands:
self._autoload_commands()

# Verify commands don't have invalid names (like starting with a shortcut)
for cur_cmd in self.get_all_commands():
valid, errmsg = self.statement_parser.is_valid_command(cur_cmd)
if not valid:
raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg))

# Add functions decorated to be subcommands
self._register_subcommands(self)

def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]:
Expand Down Expand Up @@ -631,10 +637,14 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:

# iterate through all matching methods
for method_name, method in methods:
subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME)
subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) # type: str
full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) # type: str
subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)

subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
if not subcommand_valid:
raise CommandSetRegistrationError('Subcommand {} is not valid: {}'.format(str(subcommand_name), errmsg))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding a better error message


command_tokens = full_command_name.split()
command_name = command_tokens[0]
subcommand_names = command_tokens[1:]
Expand Down
19 changes: 12 additions & 7 deletions cmd2/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,15 @@ def __init__(self,
expr = r'\A\s*(\S*?)({})'.format(second_group)
self._command_pattern = re.compile(expr)

def is_valid_command(self, word: str) -> Tuple[bool, str]:
def is_valid_command(self, word: str, *, is_subcommand: bool = False) -> Tuple[bool, str]:
"""Determine whether a word is a valid name for a command.

Commands can not include redirection characters, whitespace,
or termination characters. They also cannot start with a
shortcut.

:param word: the word to check as a command
:param is_subcommand: Flag whether this command name is a subcommand name
:return: a tuple of a boolean and an error string

If word is not a valid command, return ``False`` and an error string
Expand All @@ -297,18 +298,22 @@ def is_valid_command(self, word: str) -> Tuple[bool, str]:
"""
valid = False

if not isinstance(word, str):
return False, 'must be a string. Received {} instead'.format(str(type(word)))

if not word:
return False, 'cannot be an empty string'

if word.startswith(constants.COMMENT_CHAR):
return False, 'cannot start with the comment character'

for (shortcut, _) in self.shortcuts:
if word.startswith(shortcut):
# Build an error string with all shortcuts listed
errmsg = 'cannot start with a shortcut: '
errmsg += ', '.join(shortcut for (shortcut, _) in self.shortcuts)
return False, errmsg
if not is_subcommand:
for (shortcut, _) in self.shortcuts:
if word.startswith(shortcut):
# Build an error string with all shortcuts listed
errmsg = 'cannot start with a shortcut: '
errmsg += ', '.join(shortcut for (shortcut, _) in self.shortcuts)
return False, errmsg

errmsg = 'cannot contain: whitespace, quotes, '
errchars = []
Expand Down
2 changes: 2 additions & 0 deletions docs/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ This documentation is for ``cmd2`` version |version|.
py_bridge
table_creator
utils
plugin_external_test

**Modules**

Expand All @@ -56,3 +57,4 @@ This documentation is for ``cmd2`` version |version|.
embedded python environment to the host app
- :ref:`api/table_creator:cmd2.table_creator` - table creation module
- :ref:`api/utils:cmd2.utils` - various utility classes and functions
- :ref:`api/plugin_external_test:cmd2_ext_test` - External test plugin
9 changes: 9 additions & 0 deletions docs/api/plugin_external_test.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
cmd2_ext_test
=============

External Test Plugin


.. autoclass:: cmd2_ext_test.ExternalTestMixin
:members:

2 changes: 2 additions & 0 deletions docs/features/builtin_commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ quit

This command exits the ``cmd2`` application.

.. _feature-builtin-commands-run-pyscript:

run_pyscript
~~~~~~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions docs/features/scripting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ session.
(Cmd) command # this is not a comment


.. _scripting-python-scripts:

Python Scripts
--------------

Expand Down
9 changes: 9 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ Plugins
plugins/index


Testing
=======

.. toctree::
:maxdepth: 2

testing


API Reference
=============

Expand Down
18 changes: 10 additions & 8 deletions docs/plugins/external_test.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ Overview
~~~~~~~~

.. _cmd2_external_test_plugin:
https://github.com/python-cmd2/cmd2-ext-test/
https://github.com/python-cmd2/cmd2/tree/cmdset_settables/plugins/ext_test

The cmd2_external_test_plugin_ supports testing of a cmd2 application by exposing access cmd2 commands with the same
context as from within a cmd2 pyscript. This allows for verification of an application's support for pyscripts and
enables the cmd2 application to be tested as part of a larger system integration test.
The `External Test Plugin <cmd2_external_test_plugin_>`_ supports testing of a cmd2 application by exposing access cmd2
commands with the same context as from within a cmd2 :ref:`Python Scripts <scripting-python-scripts>`. This interface
captures ``stdout``, ``stderr``, as well as any application-specific data returned by the command. This also allows
for verification of an application's support for :ref:`Python Scripts <scripting-python-scripts>` and enables the cmd2
application to be tested as part of a larger system integration test.


Example cmd2 Application
Expand Down Expand Up @@ -59,11 +61,11 @@ In your test, define a fixture for your cmd2 application
Writing Tests
~~~~~~~~~~~~~

Now write your tests that validate your application using the `app_cmd` function to access
the cmd2 application's commands. This allows invocation of the application's commands in the
Now write your tests that validate your application using the :meth:`~cmd2_ext_test.ExternalTestMixin.app_cmd()`
function to access the cmd2 application's commands. This allows invocation of the application's commands in the
same format as a user would type. The results from calling a command matches what is returned
from running an python script with cmd2's pyscript command, which provides stdout, stderr, and
the command's result data.
from running an python script with cmd2's :ref:`feature-builtin-commands-run-pyscript` command, which provides
``stdout``, ``stderr``, and the command's result data.

.. code-block:: python

Expand Down
46 changes: 46 additions & 0 deletions docs/testing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Testing
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this section on testing!

=======

.. toctree::
:maxdepth: 1

Overview
~~~~~~~~

This covers special considerations when writing unit tests for a cmd2 application.


Testing Commands
~~~~~~~~~~~~~~~~

The :doc:`External Test Plugin <plugins/external_test>` provides a mixin class with an :meth:`` function that
allows external calls to application commands. The :meth:`~cmd2_ext_test.ExternalTestMixin.app_cmd()` function captures
and returns stdout, stderr, and the command-specific result data.


Mocking
~~~~~~~

.. _python_mock_autospeccing:
https://docs.python.org/3/library/unittest.mock.html#autospeccing
.. _python_mock_patch:
https://docs.python.org/3/library/unittest.mock.html#patch

If you need to mock anything in your cmd2 application, and most specifically in sub-classes of :class:`~cmd2.Cmd` or
:class:`~cmd2.command_definition.CommandSet`, you must use `Autospeccing <python_mock_autospeccing_>`_,
`spec=True <python_mock_patch_>`_, or whatever equivalant is provided in the mocking library you're using.

In order to automatically load functions as commands cmd2 performs a number of reflection calls to look up attributes
of classes defined in your cmd2 application. Many mocking libraries will automatically create mock objects to match any
attribute being requested, regardless of whether they're present in the object being mocked. This behavior can
incorrectly instruct cmd2 to treat a function or attribute as something it needs to recognize and process. To prevent
this, you should always mock with `Autospeccing <python_mock_autospeccing_>`_ or `spec=True <python_mock_patch_>`_
enabled.

Example of spec=True
====================
.. code-block:: python
def test_mocked_methods():
with mock.patch.object(MockMethodApp, 'foo', spec=True):
cli = MockMethodApp()
1 change: 1 addition & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def docs(session):
session.install('sphinx',
'sphinx-rtd-theme',
'.',
'plugins/ext_test',
)
session.chdir('docs')
tmpdir = session.create_tmp()
Expand Down
Loading