3434import collections
3535from colorama import Fore
3636import glob
37+ import inspect
3738import os
3839import platform
3940import re
4041import shlex
4142import 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
4445from . import constants
4546from . import utils
47+ from . import plugin
4648from .argparse_completer import AutoCompleter , ACArgumentParser
4749from .clipboard import can_clip , get_paste_buffer , write_to_paste_buffer
4850from .parsing import StatementParser , Statement
@@ -114,7 +116,7 @@ def __subclasshook__(cls, C):
114116except 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
31293327class History (list ):
31303328 """ A list of HistoryItems that knows how to respond to user requests. """
0 commit comments