Skip to content

Commit 5f76f95

Browse files
authored
Merge pull request #980 from python-cmd2/move_module_loading
Fixed AttributeError when loading CommandSet
2 parents 5dd2d03 + 27b1093 commit 5f76f95

File tree

14 files changed

+336
-52
lines changed

14 files changed

+336
-52
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 1.3.4 (TBD)
2+
* Bug Fixes
3+
* Fixed `AttributeError` when `CommandSet` that uses `as_subcommand_to` decorator is loaded during
4+
`cmd2.Cmd.__init__()`.
5+
16
## 1.3.3 (August 13, 2020)
27
* Breaking changes
38
* CommandSet command functions (do_, complete_, help_) will no longer have the cmd2 app

cmd2/cmd2.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -259,22 +259,6 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
259259
multiline_commands=multiline_commands,
260260
shortcuts=shortcuts)
261261

262-
# Load modular commands
263-
self._installed_command_sets = [] # type: List[CommandSet]
264-
self._cmd_to_command_sets = {} # type: Dict[str, CommandSet]
265-
if command_sets:
266-
for command_set in command_sets:
267-
self.register_command_set(command_set)
268-
269-
if auto_load_commands:
270-
self._autoload_commands()
271-
272-
# Verify commands don't have invalid names (like starting with a shortcut)
273-
for cur_cmd in self.get_all_commands():
274-
valid, errmsg = self.statement_parser.is_valid_command(cur_cmd)
275-
if not valid:
276-
raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg))
277-
278262
# Stores results from the last command run to enable usage of results in a Python script or interactive console
279263
# Built-in commands don't make use of this. It is purely there for user-defined commands and convenience.
280264
self.last_result = None
@@ -412,6 +396,28 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
412396
# If False, then complete() will sort the matches using self.default_sort_key before they are displayed.
413397
self.matches_sorted = False
414398

399+
############################################################################################################
400+
# The following code block loads CommandSets, verifies command names, and registers subcommands.
401+
# This block should appear after all attributes have been created since the registration code
402+
# depends on them and it's possible a module's on_register() method may need to access some.
403+
############################################################################################################
404+
# Load modular commands
405+
self._installed_command_sets = [] # type: List[CommandSet]
406+
self._cmd_to_command_sets = {} # type: Dict[str, CommandSet]
407+
if command_sets:
408+
for command_set in command_sets:
409+
self.register_command_set(command_set)
410+
411+
if auto_load_commands:
412+
self._autoload_commands()
413+
414+
# Verify commands don't have invalid names (like starting with a shortcut)
415+
for cur_cmd in self.get_all_commands():
416+
valid, errmsg = self.statement_parser.is_valid_command(cur_cmd)
417+
if not valid:
418+
raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg))
419+
420+
# Add functions decorated to be subcommands
415421
self._register_subcommands(self)
416422

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

632638
# iterate through all matching methods
633639
for method_name, method in methods:
634-
subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME)
640+
subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) # type: str
635641
full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) # type: str
636642
subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
637643

644+
subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
645+
if not subcommand_valid:
646+
raise CommandSetRegistrationError('Subcommand {} is not valid: {}'.format(str(subcommand_name), errmsg))
647+
638648
command_tokens = full_command_name.split()
639649
command_name = command_tokens[0]
640650
subcommand_names = command_tokens[1:]

cmd2/parsing.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,15 @@ def __init__(self,
277277
expr = r'\A\s*(\S*?)({})'.format(second_group)
278278
self._command_pattern = re.compile(expr)
279279

280-
def is_valid_command(self, word: str) -> Tuple[bool, str]:
280+
def is_valid_command(self, word: str, *, is_subcommand: bool = False) -> Tuple[bool, str]:
281281
"""Determine whether a word is a valid name for a command.
282282
283283
Commands can not include redirection characters, whitespace,
284284
or termination characters. They also cannot start with a
285285
shortcut.
286286
287287
:param word: the word to check as a command
288+
:param is_subcommand: Flag whether this command name is a subcommand name
288289
:return: a tuple of a boolean and an error string
289290
290291
If word is not a valid command, return ``False`` and an error string
@@ -297,18 +298,22 @@ def is_valid_command(self, word: str) -> Tuple[bool, str]:
297298
"""
298299
valid = False
299300

301+
if not isinstance(word, str):
302+
return False, 'must be a string. Received {} instead'.format(str(type(word)))
303+
300304
if not word:
301305
return False, 'cannot be an empty string'
302306

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

306-
for (shortcut, _) in self.shortcuts:
307-
if word.startswith(shortcut):
308-
# Build an error string with all shortcuts listed
309-
errmsg = 'cannot start with a shortcut: '
310-
errmsg += ', '.join(shortcut for (shortcut, _) in self.shortcuts)
311-
return False, errmsg
310+
if not is_subcommand:
311+
for (shortcut, _) in self.shortcuts:
312+
if word.startswith(shortcut):
313+
# Build an error string with all shortcuts listed
314+
errmsg = 'cannot start with a shortcut: '
315+
errmsg += ', '.join(shortcut for (shortcut, _) in self.shortcuts)
316+
return False, errmsg
312317

313318
errmsg = 'cannot contain: whitespace, quotes, '
314319
errchars = []

docs/api/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ This documentation is for ``cmd2`` version |version|.
3232
py_bridge
3333
table_creator
3434
utils
35+
plugin_external_test
3536

3637
**Modules**
3738

@@ -56,3 +57,4 @@ This documentation is for ``cmd2`` version |version|.
5657
embedded python environment to the host app
5758
- :ref:`api/table_creator:cmd2.table_creator` - table creation module
5859
- :ref:`api/utils:cmd2.utils` - various utility classes and functions
60+
- :ref:`api/plugin_external_test:cmd2_ext_test` - External test plugin

docs/api/plugin_external_test.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
cmd2_ext_test
2+
=============
3+
4+
External Test Plugin
5+
6+
7+
.. autoclass:: cmd2_ext_test.ExternalTestMixin
8+
:members:
9+

docs/features/builtin_commands.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ quit
7070

7171
This command exits the ``cmd2`` application.
7272

73+
.. _feature-builtin-commands-run-pyscript:
74+
7375
run_pyscript
7476
~~~~~~~~~~~~
7577

docs/features/scripting.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ session.
6161
(Cmd) command # this is not a comment
6262

6363

64+
.. _scripting-python-scripts:
65+
6466
Python Scripts
6567
--------------
6668

docs/index.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ Plugins
7676
plugins/index
7777

7878

79+
Testing
80+
=======
81+
82+
.. toctree::
83+
:maxdepth: 2
84+
85+
testing
86+
87+
7988
API Reference
8089
=============
8190

docs/plugins/external_test.rst

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ Overview
55
~~~~~~~~
66

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

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

1416

1517
Example cmd2 Application
@@ -59,11 +61,11 @@ In your test, define a fixture for your cmd2 application
5961
Writing Tests
6062
~~~~~~~~~~~~~
6163

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

6870
.. code-block:: python
6971

docs/testing.rst

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
Testing
2+
=======
3+
4+
.. toctree::
5+
:maxdepth: 1
6+
7+
Overview
8+
~~~~~~~~
9+
10+
This covers special considerations when writing unit tests for a cmd2 application.
11+
12+
13+
Testing Commands
14+
~~~~~~~~~~~~~~~~
15+
16+
The :doc:`External Test Plugin <plugins/external_test>` provides a mixin class with an :meth:`` function that
17+
allows external calls to application commands. The :meth:`~cmd2_ext_test.ExternalTestMixin.app_cmd()` function captures
18+
and returns stdout, stderr, and the command-specific result data.
19+
20+
21+
Mocking
22+
~~~~~~~
23+
24+
.. _python_mock_autospeccing:
25+
https://docs.python.org/3/library/unittest.mock.html#autospeccing
26+
.. _python_mock_patch:
27+
https://docs.python.org/3/library/unittest.mock.html#patch
28+
29+
If you need to mock anything in your cmd2 application, and most specifically in sub-classes of :class:`~cmd2.Cmd` or
30+
:class:`~cmd2.command_definition.CommandSet`, you must use `Autospeccing <python_mock_autospeccing_>`_,
31+
`spec=True <python_mock_patch_>`_, or whatever equivalant is provided in the mocking library you're using.
32+
33+
In order to automatically load functions as commands cmd2 performs a number of reflection calls to look up attributes
34+
of classes defined in your cmd2 application. Many mocking libraries will automatically create mock objects to match any
35+
attribute being requested, regardless of whether they're present in the object being mocked. This behavior can
36+
incorrectly instruct cmd2 to treat a function or attribute as something it needs to recognize and process. To prevent
37+
this, you should always mock with `Autospeccing <python_mock_autospeccing_>`_ or `spec=True <python_mock_patch_>`_
38+
enabled.
39+
40+
Example of spec=True
41+
====================
42+
.. code-block:: python
43+
44+
def test_mocked_methods():
45+
with mock.patch.object(MockMethodApp, 'foo', spec=True):
46+
cli = MockMethodApp()

0 commit comments

Comments
 (0)