Skip to content

Commit 3f737b4

Browse files
authored
Merge pull request #462 from python-cmd2/plugin_functions
Add methods to register hooks, for better plugin support
2 parents 9df22b9 + cd9eee2 commit 3f737b4

File tree

15 files changed

+1593
-122
lines changed

15 files changed

+1593
-122
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
## 0.9.4 (TBD, 2018)
2+
* Bug Fixes
3+
* Fixed bug where ``preparse`` wasn't getting called
4+
* Enhancements
5+
* Improved implementation of lifecycle hooks to to support a plugin
6+
framework, see ``docs/hooks.rst`` for details.
7+
* New dependency on ``attrs`` third party module
8+
* Deprecations
9+
* Deprecated the following hook methods, see ``hooks.rst`` for full details:
10+
* ``cmd2.Cmd.preparse()`` - equivilent functionality available
11+
via ``cmd2.Cmd.register_postparsing_hook()``
12+
* ``cmd2.Cmd.postparsing_precmd()`` - equivilent functionality available
13+
via ``cmd2.Cmd.register_postparsing_hook()``
14+
* ``cmd2.Cmd.postparsing_postcmd()`` - equivilent functionality available
15+
via ``cmd2.Cmd.register_postcmd_hook()``
16+
117
## 0.9.3 (July 12, 2018)
218
* Bug Fixes
319
* Fixed bug when StatementParser ``__init__()`` was called with ``terminators`` equal to ``None``

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ how to do it.
398398

399399
7. Creating the PR causes our continuous integration (CI) systems to automatically run all of the
400400
unit tests on all supported OSes and all supported versions of Python. You should watch your PR
401-
to make sure that all unit tests pass on Both TravisCI (Linux) and AppVeyor (Windows).
401+
to make sure that all unit tests pass on TravisCI (Linux), AppVeyor (Windows), and VSTS (macOS).
402402

403403
8. If any unit tests fail, you should look at the details and fix the failures. You can then push
404404
the fix to the same branch in your fork and the PR will automatically get updated and the CI system

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ cmd2: a tool for building interactive command line apps
33
[![Latest Version](https://img.shields.io/pypi/v/cmd2.svg?style=flat-square&label=latest%20stable%20version)](https://pypi.python.org/pypi/cmd2/)
44
[![Build status](https://img.shields.io/travis/python-cmd2/cmd2.svg?style=flat-square&label=unix%20build)](https://travis-ci.org/python-cmd2/cmd2)
55
[![Appveyor build status](https://img.shields.io/appveyor/ci/FedericoCeratto/cmd2.svg?style=flat-square&label=windows%20build)](https://ci.appveyor.com/project/FedericoCeratto/cmd2)
6+
[![VSTS Build status](https://python-cmd2.visualstudio.com/cmd2/_apis/build/status/cmd2-Python%20package-CI?branch=master)](https://python-cmd2.visualstudio.com/cmd2/_build/latest?definitionId=1&branch=master)
67
[![codecov](https://codecov.io/gh/python-cmd2/cmd2/branch/master/graph/badge.svg)](https://codecov.io/gh/python-cmd2/cmd2)
78
[![Documentation Status](https://readthedocs.org/projects/cmd2/badge/?version=latest)](http://cmd2.readthedocs.io/en/latest/?badge=latest)
89

@@ -57,10 +58,12 @@ pip install -U cmd2
5758
```
5859

5960
cmd2 works with Python 3.4+ on Windows, macOS, and Linux. It is pure Python code with
60-
the only 3rd-party dependencies being on [colorama](https://github.com/tartley/colorama), and [pyperclip](https://github.com/asweigart/pyperclip).
61+
the only 3rd-party dependencies being on [attrs](https://github.com/python-attrs/attrs),
62+
[colorama](https://github.com/tartley/colorama), and [pyperclip](https://github.com/asweigart/pyperclip).
6163
Windows has an additional dependency on [pyreadline](https://pypi.python.org/pypi/pyreadline). Non-Windows platforms
6264
have an additional dependency on [wcwidth](https://pypi.python.org/pypi/wcwidth). Finally, Python
63-
3.4 has an additional dependency on [contextlib2](https://pypi.python.org/pypi/contextlib2).
65+
3.4 has additional dependencies on [contextlib2](https://pypi.python.org/pypi/contextlib2) and the
66+
[typing](https://pypi.org/project/typing/) backport.
6467

6568
For information on other installation options, see
6669
[Installation Instructions](https://cmd2.readthedocs.io/en/latest/install.html) in the cmd2

cmd2/cmd2.py

Lines changed: 211 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,17 @@
3434
import collections
3535
from colorama import Fore
3636
import glob
37+
import inspect
3738
import os
3839
import platform
3940
import re
4041
import shlex
4142
import sys
42-
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
43+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union
4344

4445
from . import constants
4546
from . import utils
47+
from . import plugin
4648
from .argparse_completer import AutoCompleter, ACArgumentParser
4749
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
4850
from .parsing import StatementParser, Statement
@@ -114,7 +116,7 @@ def __subclasshook__(cls, C):
114116
except ImportError: # pragma: no cover
115117
ipython_available = False
116118

117-
__version__ = '0.9.3'
119+
__version__ = '0.9.4'
118120

119121

120122
# optional attribute, when tagged on a function, allows cmd2 to categorize commands
@@ -369,6 +371,10 @@ def __init__(self, completekey: str='tab', stdin=None, stdout=None, persistent_h
369371
except AttributeError:
370372
pass
371373

374+
# initialize plugin system
375+
# needs to be done before we call __init__(0)
376+
self._initialize_plugin_system()
377+
372378
# Call super class constructor
373379
super().__init__(completekey=completekey, stdin=stdin, stdout=stdout)
374380

@@ -597,12 +603,13 @@ def ppaged(self, msg: str, end: str='\n', chop: bool=False) -> None:
597603
598604
:param msg: message to print to current stdout - anything convertible to a str with '{}'.format() is OK
599605
:param end: string appended after the end of the message if not already present, default a newline
600-
:param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped
606+
:param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped
601607
- truncated text is still accessible by scrolling with the right & left arrow keys
602608
- chopping is ideal for displaying wide tabular data as is done in utilities like pgcli
603609
False -> causes lines longer than the screen width to wrap to the next line
604610
- wrapping is ideal when you want to avoid users having to use horizontal scrolling
605-
WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
611+
612+
WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
606613
"""
607614
import subprocess
608615
if msg is not None and msg != '':
@@ -1635,10 +1642,20 @@ def precmd(self, statement: Statement) -> Statement:
16351642

16361643
# noinspection PyMethodMayBeStatic
16371644
def preparse(self, raw: str) -> str:
1638-
"""Hook method executed just before the command line is interpreted, but after the input prompt is generated.
1645+
"""Hook method executed before user input is parsed.
1646+
1647+
WARNING: If it's a multiline command, `preparse()` may not get all the
1648+
user input. _complete_statement() really does two things: a) parse the
1649+
user input, and b) accept more input in case it's a multiline command
1650+
the passed string doesn't have a terminator. `preparse()` is currently
1651+
called before we know whether it's a multiline command, and before we
1652+
know whether the user input includes a termination character.
1653+
1654+
If you want a reliable pre parsing hook method, register a postparsing
1655+
hook, modify the user input, and then reparse it.
16391656
1640-
:param raw: raw command line input
1641-
:return: potentially modified raw command line input
1657+
:param raw: raw command line input :return: potentially modified raw
1658+
command line input
16421659
"""
16431660
return raw
16441661

@@ -1699,36 +1716,89 @@ def onecmd_plus_hooks(self, line: str) -> bool:
16991716
:return: True if cmdloop() should exit, False otherwise
17001717
"""
17011718
import datetime
1719+
17021720
stop = False
17031721
try:
17041722
statement = self._complete_statement(line)
1705-
(stop, statement) = self.postparsing_precmd(statement)
1723+
except EmptyStatement:
1724+
return self._run_cmdfinalization_hooks(stop, None)
1725+
except ValueError as ex:
1726+
# If shlex.split failed on syntax, let user know whats going on
1727+
self.perror("Invalid syntax: {}".format(ex), traceback_war=False)
1728+
return stop
1729+
1730+
# now that we have a statement, run it with all the hooks
1731+
try:
1732+
# call the postparsing hooks
1733+
data = plugin.PostparsingData(False, statement)
1734+
for func in self._postparsing_hooks:
1735+
data = func(data)
1736+
if data.stop:
1737+
break
1738+
# postparsing_precmd is deprecated
1739+
if not data.stop:
1740+
(data.stop, data.statement) = self.postparsing_precmd(data.statement)
1741+
# unpack the data object
1742+
statement = data.statement
1743+
stop = data.stop
17061744
if stop:
1707-
return self.postparsing_postcmd(stop)
1745+
# we should not run the command, but
1746+
# we need to run the finalization hooks
1747+
raise EmptyStatement
17081748

17091749
try:
17101750
if self.allow_redirection:
17111751
self._redirect_output(statement)
17121752
timestart = datetime.datetime.now()
17131753
if self._in_py:
17141754
self._last_result = None
1755+
1756+
# precommand hooks
1757+
data = plugin.PrecommandData(statement)
1758+
for func in self._precmd_hooks:
1759+
data = func(data)
1760+
statement = data.statement
1761+
# call precmd() for compatibility with cmd.Cmd
17151762
statement = self.precmd(statement)
1763+
1764+
# go run the command function
17161765
stop = self.onecmd(statement)
1766+
1767+
# postcommand hooks
1768+
data = plugin.PostcommandData(stop, statement)
1769+
for func in self._postcmd_hooks:
1770+
data = func(data)
1771+
# retrieve the final value of stop, ignoring any statement modification from the hooks
1772+
stop = data.stop
1773+
# call postcmd() for compatibility with cmd.Cmd
17171774
stop = self.postcmd(stop, statement)
1775+
17181776
if self.timing:
17191777
self.pfeedback('Elapsed: %s' % str(datetime.datetime.now() - timestart))
17201778
finally:
17211779
if self.allow_redirection and self.redirecting:
17221780
self._restore_output(statement)
17231781
except EmptyStatement:
1782+
# don't do anything, but do allow command finalization hooks to run
17241783
pass
1725-
except ValueError as ex:
1726-
# If shlex.split failed on syntax, let user know whats going on
1727-
self.perror("Invalid syntax: {}".format(ex), traceback_war=False)
17281784
except Exception as ex:
17291785
self.perror(ex)
17301786
finally:
1787+
return self._run_cmdfinalization_hooks(stop, statement)
1788+
1789+
def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool:
1790+
"""Run the command finalization hooks"""
1791+
try:
1792+
data = plugin.CommandFinalizationData(stop, statement)
1793+
for func in self._cmdfinalization_hooks:
1794+
data = func(data)
1795+
# retrieve the final value of stop, ignoring any
1796+
# modifications to the statement
1797+
stop = data.stop
1798+
# postparsing_postcmd is deprecated
17311799
return self.postparsing_postcmd(stop)
1800+
except Exception as ex:
1801+
self.perror(ex)
17321802

17331803
def runcmds_plus_hooks(self, cmds: List[str]) -> bool:
17341804
"""Convenience method to run multiple commands by onecmd_plus_hooks.
@@ -1780,7 +1850,7 @@ def _complete_statement(self, line: str) -> Statement:
17801850
pipe runs out. We can't refactor it because we need to retain
17811851
backwards compatibility with the standard library version of cmd.
17821852
"""
1783-
statement = self.statement_parser.parse(line)
1853+
statement = self.statement_parser.parse(self.preparse(line))
17841854
while statement.multiline_command and not statement.terminator:
17851855
if not self.quit_on_sigint:
17861856
try:
@@ -3105,6 +3175,8 @@ def cmdloop(self, intro: Optional[str]=None) -> None:
31053175
self.cmdqueue.extend(callargs)
31063176

31073177
# Always run the preloop first
3178+
for func in self._preloop_hooks:
3179+
func()
31083180
self.preloop()
31093181

31103182
# If transcript-based regression testing was requested, then do that instead of the main loop
@@ -3123,8 +3195,134 @@ def cmdloop(self, intro: Optional[str]=None) -> None:
31233195
self._cmdloop()
31243196

31253197
# Run the postloop() no matter what
3198+
for func in self._postloop_hooks:
3199+
func()
31263200
self.postloop()
31273201

3202+
###
3203+
#
3204+
# plugin related functions
3205+
#
3206+
###
3207+
def _initialize_plugin_system(self):
3208+
"""Initialize the plugin system"""
3209+
self._preloop_hooks = []
3210+
self._postloop_hooks = []
3211+
self._postparsing_hooks = []
3212+
self._precmd_hooks = []
3213+
self._postcmd_hooks = []
3214+
self._cmdfinalization_hooks = []
3215+
3216+
@classmethod
3217+
def _validate_callable_param_count(cls, func: Callable, count: int):
3218+
"""Ensure a function has the given number of parameters."""
3219+
signature = inspect.signature(func)
3220+
# validate that the callable has the right number of parameters
3221+
nparam = len(signature.parameters)
3222+
if nparam != count:
3223+
raise TypeError('{} has {} positional arguments, expected {}'.format(
3224+
func.__name__,
3225+
nparam,
3226+
count,
3227+
))
3228+
3229+
@classmethod
3230+
def _validate_prepostloop_callable(cls, func: Callable):
3231+
"""Check parameter and return types for preloop and postloop hooks."""
3232+
cls._validate_callable_param_count(func, 0)
3233+
# make sure there is no return notation
3234+
signature = inspect.signature(func)
3235+
if signature.return_annotation is not None:
3236+
raise TypeError("{} must declare return a return type of 'None'".format(
3237+
func.__name__,
3238+
))
3239+
3240+
def register_preloop_hook(self, func: Callable):
3241+
"""Register a function to be called at the beginning of the command loop."""
3242+
self._validate_prepostloop_callable(func)
3243+
self._preloop_hooks.append(func)
3244+
3245+
def register_postloop_hook(self, func: Callable):
3246+
"""Register a function to be called at the end of the command loop."""
3247+
self._validate_prepostloop_callable(func)
3248+
self._postloop_hooks.append(func)
3249+
3250+
@classmethod
3251+
def _validate_postparsing_callable(cls, func: Callable):
3252+
"""Check parameter and return types for postparsing hooks"""
3253+
cls._validate_callable_param_count(func, 1)
3254+
signature = inspect.signature(func)
3255+
_, param = list(signature.parameters.items())[0]
3256+
if param.annotation != plugin.PostparsingData:
3257+
raise TypeError("{} must have one parameter declared with type 'cmd2.plugin.PostparsingData'".format(
3258+
func.__name__
3259+
))
3260+
if signature.return_annotation != plugin.PostparsingData:
3261+
raise TypeError("{} must declare return a return type of 'cmd2.plugin.PostparsingData'".format(
3262+
func.__name__
3263+
))
3264+
3265+
def register_postparsing_hook(self, func: Callable):
3266+
"""Register a function to be called after parsing user input but before running the command"""
3267+
self._validate_postparsing_callable(func)
3268+
self._postparsing_hooks.append(func)
3269+
3270+
@classmethod
3271+
def _validate_prepostcmd_hook(cls, func: Callable, data_type: Type):
3272+
"""Check parameter and return types for pre and post command hooks."""
3273+
signature = inspect.signature(func)
3274+
# validate that the callable has the right number of parameters
3275+
cls._validate_callable_param_count(func, 1)
3276+
# validate the parameter has the right annotation
3277+
paramname = list(signature.parameters.keys())[0]
3278+
param = signature.parameters[paramname]
3279+
if param.annotation != data_type:
3280+
raise TypeError('argument 1 of {} has incompatible type {}, expected {}'.format(
3281+
func.__name__,
3282+
param.annotation,
3283+
data_type,
3284+
))
3285+
# validate the return value has the right annotation
3286+
if signature.return_annotation == signature.empty:
3287+
raise TypeError('{} does not have a declared return type, expected {}'.format(
3288+
func.__name__,
3289+
data_type,
3290+
))
3291+
if signature.return_annotation != data_type:
3292+
raise TypeError('{} has incompatible return type {}, expected {}'.format(
3293+
func.__name__,
3294+
signature.return_annotation,
3295+
data_type,
3296+
))
3297+
3298+
def register_precmd_hook(self, func: Callable):
3299+
"""Register a hook to be called before the command function."""
3300+
self._validate_prepostcmd_hook(func, plugin.PrecommandData)
3301+
self._precmd_hooks.append(func)
3302+
3303+
def register_postcmd_hook(self, func: Callable):
3304+
"""Register a hook to be called after the command function."""
3305+
self._validate_prepostcmd_hook(func, plugin.PostcommandData)
3306+
self._postcmd_hooks.append(func)
3307+
3308+
@classmethod
3309+
def _validate_cmdfinalization_callable(cls, func: Callable):
3310+
"""Check parameter and return types for command finalization hooks."""
3311+
cls._validate_callable_param_count(func, 1)
3312+
signature = inspect.signature(func)
3313+
_, param = list(signature.parameters.items())[0]
3314+
if param.annotation != plugin.CommandFinalizationData:
3315+
raise TypeError("{} must have one parameter declared with type "
3316+
"'cmd2.plugin.CommandFinalizationData'".format(func.__name__))
3317+
if signature.return_annotation != plugin.CommandFinalizationData:
3318+
raise TypeError("{} must declare return a return type of "
3319+
"'cmd2.plugin.CommandFinalizationData'".format(func.__name__))
3320+
3321+
def register_cmdfinalization_hook(self, func: Callable):
3322+
"""Register a hook to be called after a command is completed, whether it completes successfully or not."""
3323+
self._validate_cmdfinalization_callable(func)
3324+
self._cmdfinalization_hooks.append(func)
3325+
31283326

31293327
class History(list):
31303328
""" A list of HistoryItems that knows how to respond to user requests. """

cmd2/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@
1515

1616
# Regular expression to match ANSI escape codes
1717
ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m')
18+
19+
LINE_FEED = '\n'

0 commit comments

Comments
 (0)