Skip to content

Commit 77e2a0f

Browse files
authored
Merge pull request #906 from python-cmd2/parsing_exception
Parsing exception
2 parents 59739aa + a4160cf commit 77e2a0f

File tree

7 files changed

+81
-21
lines changed

7 files changed

+81
-21
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.0.1 (TBD, 2020)
2+
* Bug Fixes
3+
* Fixed issue where postcmd hooks were running after an `argparse` exception in a command.
4+
15
## 1.0.0 (March 1, 2020)
26
* Enhancements
37
* The documentation at [cmd2.rftd.io](https://cmd2.readthedocs.io) received a major overhaul

cmd2/cmd2.py

+13-11
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
from .argparse_custom import CompletionItem, DEFAULT_ARGUMENT_PARSER
5151
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
5252
from .decorators import with_argparser
53-
from .exceptions import EmbeddedConsoleExit, EmptyStatement
53+
from .exceptions import Cmd2ArgparseError, Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement
5454
from .history import History, HistoryItem
5555
from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split
5656
from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt, rl_warning
@@ -1599,12 +1599,10 @@ def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True, py_bridge
15991599
stop = False
16001600
try:
16011601
statement = self._input_line_to_statement(line)
1602-
except EmptyStatement:
1602+
except (EmptyStatement, Cmd2ShlexError) as ex:
1603+
if isinstance(ex, Cmd2ShlexError):
1604+
self.perror("Invalid syntax: {}".format(ex))
16031605
return self._run_cmdfinalization_hooks(stop, None)
1604-
except ValueError as ex:
1605-
# If shlex.split failed on syntax, let user know what's going on
1606-
self.pexcept("Invalid syntax: {}".format(ex))
1607-
return stop
16081606

16091607
# now that we have a statement, run it with all the hooks
16101608
try:
@@ -1684,8 +1682,8 @@ def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True, py_bridge
16841682
# Stop saving command's stdout before command finalization hooks run
16851683
self.stdout.pause_storage = True
16861684

1687-
except EmptyStatement:
1688-
# don't do anything, but do allow command finalization hooks to run
1685+
except (Cmd2ArgparseError, EmptyStatement):
1686+
# Don't do anything, but do allow command finalization hooks to run
16891687
pass
16901688
except Exception as ex:
16911689
self.pexcept(ex)
@@ -1744,6 +1742,8 @@ def _complete_statement(self, line: str) -> Statement:
17441742
17451743
:param line: the line being parsed
17461744
:return: the completed Statement
1745+
:raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
1746+
EmptyStatement when the resulting Statement is blank
17471747
"""
17481748
while True:
17491749
try:
@@ -1755,7 +1755,7 @@ def _complete_statement(self, line: str) -> Statement:
17551755
# it's not a multiline command, but we parsed it ok
17561756
# so we are done
17571757
break
1758-
except ValueError:
1758+
except Cmd2ShlexError:
17591759
# we have unclosed quotation marks, lets parse only the command
17601760
# and see if it's a multiline
17611761
statement = self.statement_parser.parse_command_only(line)
@@ -1792,7 +1792,7 @@ def _complete_statement(self, line: str) -> Statement:
17921792
self._at_continuation_prompt = False
17931793

17941794
if not statement.command:
1795-
raise EmptyStatement()
1795+
raise EmptyStatement
17961796
return statement
17971797

17981798
def _input_line_to_statement(self, line: str) -> Statement:
@@ -1801,6 +1801,8 @@ def _input_line_to_statement(self, line: str) -> Statement:
18011801
18021802
:param line: the line being parsed
18031803
:return: parsed command line as a Statement
1804+
:raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
1805+
EmptyStatement when the resulting Statement is blank
18041806
"""
18051807
used_macros = []
18061808
orig_line = None
@@ -1819,7 +1821,7 @@ def _input_line_to_statement(self, line: str) -> Statement:
18191821
used_macros.append(statement.command)
18201822
line = self._resolve_macro(statement)
18211823
if line is None:
1822-
raise EmptyStatement()
1824+
raise EmptyStatement
18231825
else:
18241826
break
18251827

cmd2/decorators.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Callable, List, Optional, Union
55

66
from . import constants
7+
from .exceptions import Cmd2ArgparseError
78
from .parsing import Statement
89

910

@@ -144,7 +145,7 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str]):
144145
try:
145146
args, unknown = parser.parse_known_args(parsed_arglist, namespace)
146147
except SystemExit:
147-
return
148+
raise Cmd2ArgparseError
148149
else:
149150
setattr(args, '__statement__', statement)
150151
return func(cmd2_app, args, unknown)
@@ -216,7 +217,7 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str]):
216217
try:
217218
args = parser.parse_args(parsed_arglist, namespace)
218219
except SystemExit:
219-
return
220+
raise Cmd2ArgparseError
220221
else:
221222
setattr(args, '__statement__', statement)
222223
return func(cmd2_app, args)

cmd2/exceptions.py

+14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22
"""Custom exceptions for cmd2. These are NOT part of the public API and are intended for internal use only."""
33

44

5+
class Cmd2ArgparseError(Exception):
6+
"""
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.
10+
"""
11+
pass
12+
13+
14+
class Cmd2ShlexError(Exception):
15+
"""Raised when shlex fails to parse a command line string in StatementParser"""
16+
pass
17+
18+
519
class EmbeddedConsoleExit(SystemExit):
620
"""Custom exception class for use with the py command."""
721
pass

cmd2/parsing.py

+7-5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from . import constants
1212
from . import utils
13+
from .exceptions import Cmd2ShlexError
1314

1415

1516
def shlex_split(str_to_split: str) -> List[str]:
@@ -330,7 +331,7 @@ def tokenize(self, line: str) -> List[str]:
330331
331332
:param line: the command line being lexed
332333
:return: A list of tokens
333-
:raises ValueError: if there are unclosed quotation marks
334+
:raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
334335
"""
335336

336337
# expand shortcuts and aliases
@@ -341,7 +342,10 @@ def tokenize(self, line: str) -> List[str]:
341342
return []
342343

343344
# split on whitespace
344-
tokens = shlex_split(line)
345+
try:
346+
tokens = shlex_split(line)
347+
except ValueError as ex:
348+
raise Cmd2ShlexError(ex)
345349

346350
# custom lexing
347351
tokens = self.split_on_punctuation(tokens)
@@ -355,7 +359,7 @@ def parse(self, line: str) -> Statement:
355359
356360
:param line: the command line being parsed
357361
:return: a new :class:`~cmd2.Statement` object
358-
:raises ValueError: if there are unclosed quotation marks
362+
:raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
359363
"""
360364

361365
# handle the special case/hardcoded terminator of a blank line
@@ -518,8 +522,6 @@ def parse_command_only(self, rawinput: str) -> Statement:
518522
:param rawinput: the command line as entered by the user
519523
:return: a new :class:`~cmd2.Statement` object
520524
"""
521-
line = rawinput
522-
523525
# expand shortcuts and aliases
524526
line = self._expand(rawinput)
525527

tests/test_parsing.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def test_tokenize(parser, line, tokens):
9696
assert tokens_to_test == tokens
9797

9898
def test_tokenize_unclosed_quotes(parser):
99-
with pytest.raises(ValueError):
99+
with pytest.raises(exceptions.Cmd2ShlexError):
100100
_ = parser.tokenize('command with "unclosed quotes')
101101

102102
@pytest.mark.parametrize('tokens,command,args', [
@@ -583,7 +583,7 @@ def test_parse_redirect_to_unicode_filename(parser):
583583
assert statement.output_to == 'café'
584584

585585
def test_parse_unclosed_quotes(parser):
586-
with pytest.raises(ValueError):
586+
with pytest.raises(exceptions.Cmd2ShlexError):
587587
_ = parser.tokenize("command with 'unclosed quotes")
588588

589589
def test_empty_statement_raises_exception():

tests/test_plugin.py

+38-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
Test plugin infrastructure and hooks.
55
"""
6+
import argparse
67
import sys
78

89
import pytest
@@ -14,7 +15,7 @@
1415
from unittest import mock
1516

1617
import cmd2
17-
from cmd2 import exceptions, plugin
18+
from cmd2 import exceptions, plugin, Cmd2ArgumentParser, with_argparser
1819

1920

2021
class Plugin:
@@ -254,6 +255,14 @@ def do_say(self, statement):
254255
"""Repeat back the arguments"""
255256
self.poutput(statement)
256257

258+
parser = Cmd2ArgumentParser(description="Test parser")
259+
parser.add_argument("my_arg", help="some help text")
260+
261+
@with_argparser(parser)
262+
def do_argparse_cmd(self, namespace: argparse.Namespace):
263+
"""Repeat back the arguments"""
264+
self.poutput(namespace.__statement__)
265+
257266
###
258267
#
259268
# test pre and postloop hooks
@@ -836,3 +845,31 @@ def test_cmdfinalization_hook_exception(capsys):
836845
assert out == 'hello\n'
837846
assert err
838847
assert app.called_cmdfinalization == 1
848+
849+
850+
def test_cmd2_argparse_exception(capsys):
851+
"""
852+
Verify Cmd2ArgparseErrors raised after calling a command prevent postcmd events from
853+
running but do not affect cmdfinalization events
854+
"""
855+
app = PluggedApp()
856+
app.register_postcmd_hook(app.postcmd_hook)
857+
app.register_cmdfinalization_hook(app.cmdfinalization_hook)
858+
859+
# First generate no exception and make sure postcmd_hook, postcmd, and cmdfinalization_hook run
860+
app.onecmd_plus_hooks('argparse_cmd arg_val')
861+
out, err = capsys.readouterr()
862+
assert out == 'arg_val\n'
863+
assert not err
864+
assert app.called_postcmd == 2
865+
assert app.called_cmdfinalization == 1
866+
867+
app.reset_counters()
868+
869+
# Next cause an argparse exception and verify no postcmd stuff runs but cmdfinalization_hook still does
870+
app.onecmd_plus_hooks('argparse_cmd')
871+
out, err = capsys.readouterr()
872+
assert not out
873+
assert "Error: the following arguments are required: my_arg" in err
874+
assert app.called_postcmd == 0
875+
assert app.called_cmdfinalization == 1

0 commit comments

Comments
 (0)