Skip to content

Commit 8d9405a

Browse files
authored
Merge pull request #937 from python-cmd2/exceptions
Exception handling
2 parents d4653e6 + 9b91116 commit 8d9405a

File tree

10 files changed

+178
-45
lines changed

10 files changed

+178
-45
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
## 1.0.3 (TBD, 2020)
1+
## 1.1.0 (TBD, 2020)
22
* Bug Fixes
33
* Fixed issue where subcommand usage text could contain a subcommand alias instead of the actual name
44
* Enhancements
@@ -14,6 +14,15 @@
1414
documentation for an overview.
1515
* See [table_creation.py](https://github.com/python-cmd2/cmd2/blob/master/examples/table_creation.py)
1616
for an example.
17+
* Added the following exceptions to the public API
18+
* `SkipPostcommandHooks` - Custom exception class for when a command has a failure bad enough to skip
19+
post command hooks, but not bad enough to print the exception to the user.
20+
* `Cmd2ArgparseError` - A `SkipPostcommandHooks` exception for when a command fails to parse its arguments.
21+
Normally argparse raises a `SystemExit` exception in these cases. To avoid stopping the command
22+
loop, catch the `SystemExit` and raise this instead. If you still need to run post command hooks
23+
after parsing fails, just return instead of raising an exception.
24+
* Added explicit handling of `SystemExit`. If a command raises this exception, the command loop will be
25+
gracefully stopped.
1726

1827
## 1.0.2 (April 06, 2020)
1928
* Bug Fixes

cmd2/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .cmd2 import Cmd
2626
from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS
2727
from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category
28+
from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks
2829
from .parsing import Statement
2930
from .py_bridge import CommandResult
3031
from .utils import categorize, CompletionError, Settable

cmd2/cmd2.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem
4747
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
4848
from .decorators import with_argparser
49-
from .exceptions import Cmd2ArgparseError, Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError
49+
from .exceptions import Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, SkipPostcommandHooks
5050
from .history import History, HistoryItem
5151
from .parsing import Macro, MacroArg, Statement, StatementParser, shlex_split
5252
from .rl_utils import RlType, rl_get_point, rl_make_safe_prompt, rl_set_prompt, rl_type, rl_warning, vt100_support
@@ -1587,9 +1587,9 @@ def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True,
15871587
15881588
:param line: command line to run
15891589
:param add_to_history: If True, then add this command to history. Defaults to True.
1590-
:param raise_keyboard_interrupt: if True, then KeyboardInterrupt exceptions will be raised. This is used when
1591-
running commands in a loop to be able to stop the whole loop and not just
1592-
the current command. Defaults to False.
1590+
:param raise_keyboard_interrupt: if True, then KeyboardInterrupt exceptions will be raised if stop isn't already
1591+
True. This is used when running commands in a loop to be able to stop the whole
1592+
loop and not just the current command. Defaults to False.
15931593
:param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning
15941594
of an app() call from Python. It is used to enable/disable the storage of the
15951595
command's stdout.
@@ -1667,26 +1667,35 @@ def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True,
16671667
if py_bridge_call:
16681668
# Stop saving command's stdout before command finalization hooks run
16691669
self.stdout.pause_storage = True
1670-
except KeyboardInterrupt as ex:
1671-
if raise_keyboard_interrupt:
1672-
raise ex
1673-
except (Cmd2ArgparseError, EmptyStatement):
1670+
except (SkipPostcommandHooks, EmptyStatement):
16741671
# Don't do anything, but do allow command finalization hooks to run
16751672
pass
16761673
except Cmd2ShlexError as ex:
16771674
self.perror("Invalid syntax: {}".format(ex))
16781675
except RedirectionError as ex:
16791676
self.perror(ex)
1677+
except KeyboardInterrupt as ex:
1678+
if raise_keyboard_interrupt and not stop:
1679+
raise ex
1680+
except SystemExit:
1681+
stop = True
16801682
except Exception as ex:
16811683
self.pexcept(ex)
16821684
finally:
1683-
stop = self._run_cmdfinalization_hooks(stop, statement)
1685+
try:
1686+
stop = self._run_cmdfinalization_hooks(stop, statement)
1687+
except KeyboardInterrupt as ex:
1688+
if raise_keyboard_interrupt and not stop:
1689+
raise ex
1690+
except SystemExit:
1691+
stop = True
1692+
except Exception as ex:
1693+
self.pexcept(ex)
16841694

16851695
return stop
16861696

16871697
def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool:
16881698
"""Run the command finalization hooks"""
1689-
16901699
with self.sigint_protection:
16911700
if not sys.platform.startswith('win') and self.stdin.isatty():
16921701
# Before the next command runs, fix any terminal problems like those
@@ -1695,15 +1704,12 @@ def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement])
16951704
proc = subprocess.Popen(['stty', 'sane'])
16961705
proc.communicate()
16971706

1698-
try:
1699-
data = plugin.CommandFinalizationData(stop, statement)
1700-
for func in self._cmdfinalization_hooks:
1701-
data = func(data)
1702-
# retrieve the final value of stop, ignoring any
1703-
# modifications to the statement
1704-
return data.stop
1705-
except Exception as ex:
1706-
self.pexcept(ex)
1707+
data = plugin.CommandFinalizationData(stop, statement)
1708+
for func in self._cmdfinalization_hooks:
1709+
data = func(data)
1710+
# retrieve the final value of stop, ignoring any
1711+
# modifications to the statement
1712+
return data.stop
17071713

17081714
def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_history: bool = True,
17091715
stop_on_keyboard_interrupt: bool = True) -> bool:
@@ -3894,7 +3900,7 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
38943900
38953901
IMPORTANT: This function will not print an alert unless it can acquire self.terminal_lock to ensure
38963902
a prompt is onscreen. Therefore it is best to acquire the lock before calling this function
3897-
to guarantee the alert prints.
3903+
to guarantee the alert prints and to avoid raising a RuntimeError.
38983904
38993905
:param alert_msg: the message to display to the user
39003906
:param new_prompt: if you also want to change the prompt that is displayed, then include it here
@@ -3956,7 +3962,7 @@ def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
39563962
39573963
IMPORTANT: This function will not update the prompt unless it can acquire self.terminal_lock to ensure
39583964
a prompt is onscreen. Therefore it is best to acquire the lock before calling this function
3959-
to guarantee the prompt changes.
3965+
to guarantee the prompt changes and to avoid raising a RuntimeError.
39603966
39613967
If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
39623968
not change. However self.prompt will still be updated and display immediately after the multiline
@@ -3971,9 +3977,9 @@ def set_window_title(self, title: str) -> None: # pragma: no cover
39713977
39723978
Raises a `RuntimeError` if called while another thread holds `terminal_lock`.
39733979
3974-
IMPORTANT: This function will not set the title unless it can acquire self.terminal_lock to avoid
3975-
writing to stderr while a command is running. Therefore it is best to acquire the lock
3976-
before calling this function to guarantee the title changes.
3980+
IMPORTANT: This function will not set the title unless it can acquire self.terminal_lock to avoid writing
3981+
to stderr while a command is running. Therefore it is best to acquire the lock before calling
3982+
this function to guarantee the title changes and to avoid raising a RuntimeError.
39773983
39783984
:param title: the new window title
39793985
"""

cmd2/exceptions.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
11
# coding=utf-8
2-
"""Custom exceptions for cmd2. These are NOT part of the public API and are intended for internal use only."""
2+
"""Custom exceptions for cmd2"""
33

44

5-
class Cmd2ArgparseError(Exception):
5+
############################################################################################################
6+
# The following exceptions are part of the public API
7+
############################################################################################################
8+
9+
class SkipPostcommandHooks(Exception):
610
"""
7-
Custom exception class for when a command has an error parsing its arguments.
8-
This can be raised by argparse decorators or the command functions themselves.
9-
The main use of this exception is to tell cmd2 not to run Postcommand hooks.
11+
Custom exception class for when a command has a failure bad enough to skip post command
12+
hooks, but not bad enough to print the exception to the user.
1013
"""
1114
pass
1215

1316

17+
class Cmd2ArgparseError(SkipPostcommandHooks):
18+
"""
19+
A ``SkipPostcommandHooks`` exception for when a command fails to parse its arguments.
20+
Normally argparse raises a SystemExit exception in these cases. To avoid stopping the command
21+
loop, catch the SystemExit and raise this instead. If you still need to run post command hooks
22+
after parsing fails, just return instead of raising an exception.
23+
"""
24+
pass
25+
26+
27+
############################################################################################################
28+
# The following exceptions are NOT part of the public API and are intended for internal use only.
29+
############################################################################################################
30+
1431
class Cmd2ShlexError(Exception):
1532
"""Raised when shlex fails to parse a command line string in StatementParser"""
1633
pass

docs/api/exceptions.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
cmd2.exceptions
2+
===============
3+
4+
Custom cmd2 exceptions
5+
6+
7+
.. autoclass:: cmd2.exceptions.SkipPostcommandHooks
8+
:members:
9+
10+
.. autoclass:: cmd2.exceptions.Cmd2ArgparseError
11+
:members:

docs/api/index.rst

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,39 @@ This documentation is for ``cmd2`` version |version|.
1919
:hidden:
2020

2121
cmd
22-
decorators
23-
parsing
22+
ansi
2423
argparse_completer
2524
argparse_custom
26-
ansi
27-
utils
25+
constants
26+
decorators
27+
exceptions
2828
history
29+
parsing
2930
plugin
3031
py_bridge
3132
table_creator
32-
constants
33+
utils
3334

3435
**Modules**
3536

3637
- :ref:`api/cmd:cmd2.Cmd` - functions and attributes of the main
3738
class in this library
38-
- :ref:`api/decorators:cmd2.decorators` - decorators for ``cmd2``
39-
commands
40-
- :ref:`api/parsing:cmd2.parsing` - classes for parsing and storing
41-
user input
39+
- :ref:`api/ansi:cmd2.ansi` - convenience classes and functions for generating
40+
ANSI escape sequences to style text in the terminal
4241
- :ref:`api/argparse_completer:cmd2.argparse_completer` - classes for
4342
``argparse``-based tab completion
4443
- :ref:`api/argparse_custom:cmd2.argparse_custom` - classes and functions
4544
for extending ``argparse``
46-
- :ref:`api/ansi:cmd2.ansi` - convenience classes and functions for generating
47-
ANSI escape sequences to style text in the terminal
48-
- :ref:`api/utils:cmd2.utils` - various utility classes and functions
45+
- :ref:`api/constants:cmd2.constants` - just like it says on the tin
46+
- :ref:`api/decorators:cmd2.decorators` - decorators for ``cmd2``
47+
commands
48+
- :ref:`api/exceptions:cmd2.exceptions` - custom ``cmd2`` exceptions
4949
- :ref:`api/history:cmd2.history` - classes for storing the history
5050
of previously entered commands
51+
- :ref:`api/parsing:cmd2.parsing` - classes for parsing and storing
52+
user input
5153
- :ref:`api/plugin:cmd2.plugin` - data classes for hook methods
5254
- :ref:`api/py_bridge:cmd2.py_bridge` - classes for bridging calls from the
5355
embedded python environment to the host app
5456
- :ref:`api/table_creator:cmd2.table_creator` - table creation module
55-
- :ref:`api/constants:cmd2.constants` - just like it says on the tin
57+
- :ref:`api/utils:cmd2.utils` - various utility classes and functions

docs/features/commands.rst

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ it should stop prompting for user input and cleanly exit. ``cmd2`` already
129129
includes a ``quit`` command, but if you wanted to make another one called
130130
``finis`` you could::
131131

132-
def do_finis(self, line):
132+
def do_finish(self, line):
133133
"""Exit the application"""
134134
return True
135135

@@ -186,6 +186,19 @@ catch it and display it for you. The `debug` :ref:`setting
186186
name and message. If `debug` is `true`, ``cmd2`` will display a traceback, and
187187
then display the exception name and message.
188188

189+
There are a few exceptions which commands can raise that do not print as
190+
described above:
191+
192+
- :attr:`cmd2.exceptions.SkipPostcommandHooks` - all postcommand hooks are
193+
skipped and no exception prints
194+
- :attr:`cmd2.exceptions.Cmd2ArgparseError` - behaves like
195+
``SkipPostcommandHooks``
196+
- ``SystemExit`` - ``stop`` will be set to ``True`` in an attempt to stop the
197+
command loop
198+
- ``KeyboardInterrupt`` - raised if running in a text script and ``stop`` isn't
199+
already True to stop the script
200+
201+
All other ``BaseExceptions`` are not caught by ``cmd2`` and will be raised
189202

190203
Disabling or Hiding Commands
191204
----------------------------

docs/features/hooks.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ blindly returns ``False``, a prior hook's requst to exit the application will
291291
not be honored. It's best to return the value you were passed unless you have a
292292
compelling reason to do otherwise.
293293

294+
To purposefully and silently skip postcommand hooks, commands can raise any of
295+
of the following exceptions.
296+
297+
- :attr:`cmd2.exceptions.SkipPostcommandHooks`
298+
- :attr:`cmd2.exceptions.Cmd2ArgparseError`
299+
294300

295301
Command Finalization Hooks
296302
--------------------------

tests/test_cmd2.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,17 @@ def hook(self: cmd2.Cmd, data: plugin.CommandFinalizationData) -> plugin.Command
466466

467467
assert "WE ARE IN SCRIPT" in out[-1]
468468

469+
def test_system_exit_in_command(base_app, capsys):
470+
"""Test raising SystemExit from a command"""
471+
import types
472+
473+
def do_system_exit(self, _):
474+
raise SystemExit
475+
setattr(base_app, 'do_system_exit', types.MethodType(do_system_exit, base_app))
476+
477+
stop = base_app.onecmd_plus_hooks('system_exit')
478+
assert stop
479+
469480
def test_output_redirection(base_app):
470481
fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt')
471482
os.close(fd)

tests/test_plugin.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,24 @@ def cmdfinalization_hook_exception(self, data: cmd2.plugin.CommandFinalizationDa
222222
self.called_cmdfinalization += 1
223223
raise ValueError
224224

225+
def cmdfinalization_hook_system_exit(self, data: cmd2.plugin.CommandFinalizationData) -> \
226+
cmd2.plugin.CommandFinalizationData:
227+
"""A command finalization hook which raises a SystemExit"""
228+
self.called_cmdfinalization += 1
229+
raise SystemExit
230+
231+
def cmdfinalization_hook_keyboard_interrupt(self, data: cmd2.plugin.CommandFinalizationData) -> \
232+
cmd2.plugin.CommandFinalizationData:
233+
"""A command finalization hook which raises a KeyboardInterrupt"""
234+
self.called_cmdfinalization += 1
235+
raise KeyboardInterrupt
236+
225237
def cmdfinalization_hook_not_enough_parameters(self) -> plugin.CommandFinalizationData:
226238
"""A command finalization hook with no parameters."""
227239
pass
228240

229-
def cmdfinalization_hook_too_many_parameters(self, one: plugin.CommandFinalizationData, two: str) -> plugin.CommandFinalizationData:
241+
def cmdfinalization_hook_too_many_parameters(self, one: plugin.CommandFinalizationData, two: str) -> \
242+
plugin.CommandFinalizationData:
230243
"""A command finalization hook with too many parameters."""
231244
return one
232245

@@ -256,6 +269,10 @@ def do_say(self, statement):
256269
"""Repeat back the arguments"""
257270
self.poutput(statement)
258271

272+
def do_skip_postcmd_hooks(self, _):
273+
self.poutput("In do_skip_postcmd_hooks")
274+
raise exceptions.SkipPostcommandHooks
275+
259276
parser = Cmd2ArgumentParser(description="Test parser")
260277
parser.add_argument("my_arg", help="some help text")
261278

@@ -847,6 +864,46 @@ def test_cmdfinalization_hook_exception(capsys):
847864
assert err
848865
assert app.called_cmdfinalization == 1
849866

867+
def test_cmdfinalization_hook_system_exit(capsys):
868+
app = PluggedApp()
869+
app.register_cmdfinalization_hook(app.cmdfinalization_hook_system_exit)
870+
stop = app.onecmd_plus_hooks('say hello')
871+
assert stop
872+
assert app.called_cmdfinalization == 1
873+
874+
def test_cmdfinalization_hook_keyboard_interrupt(capsys):
875+
app = PluggedApp()
876+
app.register_cmdfinalization_hook(app.cmdfinalization_hook_keyboard_interrupt)
877+
878+
# First make sure KeyboardInterrupt isn't raised unless told to
879+
stop = app.onecmd_plus_hooks('say hello', raise_keyboard_interrupt=False)
880+
assert not stop
881+
assert app.called_cmdfinalization == 1
882+
883+
# Now enable raising the KeyboardInterrupt
884+
app.reset_counters()
885+
with pytest.raises(KeyboardInterrupt):
886+
stop = app.onecmd_plus_hooks('say hello', raise_keyboard_interrupt=True)
887+
assert not stop
888+
assert app.called_cmdfinalization == 1
889+
890+
# Now make sure KeyboardInterrupt isn't raised if stop is already True
891+
app.reset_counters()
892+
stop = app.onecmd_plus_hooks('quit', raise_keyboard_interrupt=True)
893+
assert stop
894+
assert app.called_cmdfinalization == 1
895+
896+
def test_skip_postcmd_hooks(capsys):
897+
app = PluggedApp()
898+
app.register_postcmd_hook(app.postcmd_hook)
899+
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
900+
901+
# Cause a SkipPostcommandHooks exception and verify no postcmd stuff runs but cmdfinalization_hook still does
902+
app.onecmd_plus_hooks('skip_postcmd_hooks')
903+
out, err = capsys.readouterr()
904+
assert "In do_skip_postcmd_hooks" in out
905+
assert app.called_postcmd == 0
906+
assert app.called_cmdfinalization == 1
850907

851908
def test_cmd2_argparse_exception(capsys):
852909
"""

0 commit comments

Comments
 (0)