Skip to content

Commit cab32f7

Browse files
committed
rework exception docstring parser
1 parent 43ccce7 commit cab32f7

File tree

4 files changed

+197
-94
lines changed

4 files changed

+197
-94
lines changed

slycot/exceptions.py

Lines changed: 143 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
MA 02110-1301, USA.
1919
"""
2020

21+
import re
22+
2123

2224
class SlycotError(RuntimeError):
2325
"""Slycot exception base class"""
@@ -36,64 +38,153 @@ class SlycotParameterError(SlycotError, ValueError):
3638

3739
pass
3840

41+
3942
class SlycotArithmeticError(SlycotError, ArithmeticError):
4043
"""A Slycot computation failed"""
4144

4245
pass
4346

44-
def filter_docstring_exceptions(docstring):
45-
"""Check a docstring to find exception descriptions"""
46-
47-
# check-count the message indices
48-
index = 0
49-
exdict = {}
50-
msg = []
51-
for l in docstring.split('\n'):
52-
l = l.strip()
53-
if l[:10] == ":e.info = " and l[-1] == ":":
54-
try:
55-
idx = int(l[10:-1])
56-
if msg:
57-
exdict[index] = '\n'.join(msg)
58-
msg = []
59-
index = idx
60-
except ValueError:
61-
if msg:
62-
exdict[index] = '\n'.join(msg)
63-
msg = []
64-
index = 0
65-
elif not l:
66-
if msg:
67-
exdict[index] = '\n'.join(msg)
68-
msg = []
69-
index = 0
70-
elif index:
71-
msg.append(l.strip())
72-
if msg:
73-
exdict[index] = '\n'.join(msg)
74-
return exdict
75-
76-
def raise_if_slycot_error(info, arg_list, docstring=None):
77-
"""Raise exceptions if slycot info returned is non-zero
78-
79-
For negative info, the argument as indicated in arg_list was erroneous
80-
81-
For positive info, the matching exception text is recovered from
82-
the docstring, which may in many cases simply be the python interface
83-
routine docstring
47+
48+
def raise_if_slycot_error(info, arg_list=None, docstring=None, checkvars={}):
49+
"""Raise exceptions if slycot info returned is non-zero.
50+
51+
Parameters
52+
----------
53+
info: int
54+
The parameter INFO returned by the SLICOT subroutine
55+
arg_list: list of str, optional
56+
A list of arguments (possibly hidden by the wrapper) of the SLICOT
57+
subroutine
58+
docstring: str, optional
59+
The docstring of the Slycot function
60+
checkvars: dict, optional
61+
dict of variables for evaluation of <infospec> and formatting the
62+
exception message
63+
64+
Notes
65+
-----
66+
If the numpydoc compliant docstring has a "Raises" section with one or
67+
multiple definition terms ``SlycotError : e`` or a subclass of it,
68+
the matching exception text is used.
69+
70+
The definition body must contain a reST compliant field list with
71+
':<infospec>:' as field name, where <infospec> specifies the valid values
72+
for `e.ìnfo` in a python parseable expression using the variables provided
73+
in `checkvars`. A single " = " is treated as " == ".
74+
75+
The body of the field list contains the exception message and can contain
76+
replacement fields in format string syntax using the variables in
77+
`checkvars`.
78+
79+
For negative info, the argument as indicated in arg_list was erroneous and
80+
a generic SlycotParameterError is raised if no custom text was defined in
81+
the docstring or no docstring is provided.
82+
83+
Example
84+
-------
85+
>>> def fun(info):
86+
... \"""Example function
87+
...
88+
... Raises
89+
... ------
90+
... SlycotArithmeticError : e
91+
... :e.info = 1: Info is 1
92+
... :e.info > 1 and e.info < n:
93+
... Info is {e.info}, which is between 1 and {n}
94+
... :n <= e.info < m:
95+
... {e.info} is between {n} and {m:10.2g}!
96+
... \"""
97+
... n, m = 4, 1.2e2
98+
... raise_if_slycot_error(info,
99+
... arg_list=["a", "b", "c"],
100+
... docstring=fun.__doc__,
101+
... checkvars=locals())
102+
...
103+
>>> fun(0)
104+
>>> fun(-1)
105+
SlycotParameterError: The following argument had an illegal value: a
106+
>>> fun(1)
107+
SlycotArithmeticError: Info is 1
108+
>>> fun(2)
109+
SlycotArithmeticError: Info is 2, which is between 1 and 4
110+
>>> fun(5)
111+
SlycotArithmeticError: 4 is between 4 and 1.2e+02!
84112
"""
113+
if docstring:
114+
slycot_error_map = {"SlycotError": SlycotError,
115+
"SlycotParameterError": SlycotParameterError,
116+
"SlycotArithmeticError": SlycotArithmeticError}
85117

86-
if info < 0:
118+
docline = iter(docstring.splitlines())
119+
info_eval = False
120+
try:
121+
while "Raises" not in next(docline):
122+
continue
123+
124+
section_indent = next(docline).index("-")
125+
126+
slycot_error = None
127+
for l in docline:
128+
print(l)
129+
# ignore blank lines
130+
if not l.strip():
131+
continue
132+
133+
134+
# reached end of Raises section without match
135+
if not l[:section_indent].isspace():
136+
return None
137+
138+
# Exception Type
139+
ematch = re.match(
140+
r'(\s*)(Slycot(Parameter|Arithmetic)?Error) : e', l)
141+
if ematch:
142+
error_indent = len(ematch[1])
143+
slycot_error = ematch[2]
144+
145+
# new infospec
146+
if slycot_error:
147+
imatch = re.match(
148+
r'(\s{' + str(error_indent + 1) + r',}):(.*):\s*(.*)',
149+
l)
150+
if imatch:
151+
infospec_indent = len(imatch[1])
152+
infospec = imatch[2]
153+
# Don't handle the standard case unless we have i
154+
if infospec == "e.info = -i":
155+
if 'i' not in checkvars.keys():
156+
continue
157+
infospec_ = infospec.replace(" = ", " == ")
158+
checkvars['e'] = SlycotError("", info)
159+
try:
160+
info_eval = eval(infospec_, checkvars)
161+
except NameError:
162+
raise RuntimeError("Unknown variable in infospec: "
163+
+ infospec)
164+
except SyntaxError:
165+
raise RuntimeError("Invalid infospec: "
166+
+ infospec)
167+
if info_eval:
168+
message = imatch[3].strip() + '\n'
169+
mmatch = re.match(
170+
r'(\s{' + str(infospec_indent+1) + r',})(.*)',
171+
next(docline))
172+
if not mmatch:
173+
break # docstring
174+
body_indent = len(mmatch[1])
175+
message += mmatch[2] + '\n'
176+
for l in docline:
177+
if l and not l[:body_indent].isspace():
178+
break # message body
179+
message += l[body_indent:] + '\n'
180+
break # docstring
181+
except StopIteration:
182+
pass
183+
if info_eval and message:
184+
fmessage = '\n' + message.format(**checkvars).strip()
185+
raise slycot_error_map[slycot_error](fmessage, info)
186+
187+
if info < 0 and arg_list:
87188
message = ("The following argument had an illegal value: {}"
88-
"".format(arg_list[-info-1]))
189+
"".format(arg_list[-info-1]))
89190
raise SlycotParameterError(message, info)
90-
elif info > 0 and docstring:
91-
# process the docstring for the error message
92-
messages = filter_docstring_exceptions(docstring)
93-
try:
94-
raise SlycotParameterError(messages[info], info)
95-
except IndexError:
96-
raise SlycotParameterError(
97-
"Slycot returned an unhandled error code {}".format(info),
98-
info)
99-

slycot/synthesis.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2490,7 +2490,7 @@ def sg03bd(n,m,A,E,Q,Z,B,dico,fact='N',trans='N',ldwork=None):
24902490
eigenvalues of the matrix pencil A - lambda * E.
24912491
24922492
Raises
2493-
------
2493+
------
24942494
24952495
SlycotParameterError : e
24962496
:e.info = -i: the i-th argument had an illegal value;
@@ -2507,7 +2507,7 @@ def sg03bd(n,m,A,E,Q,Z,B,dico,fact='N',trans='N',ldwork=None):
25072507
:e.info = 3:
25082508
fact = 'F' and there is a 2-by-2 block on the main
25092509
diagonal of the pencil A_s - lambda * E_s whose
2510-
igenvalues are not conjugate complex;
2510+
eigenvalues are not conjugate complex;
25112511
:e.info = 4:
25122512
fact = 'N' and the pencil A - lambda * E cannot be
25132513
reduced to generalized Schur form: LAPACK routine

slycot/tests/test_sb.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
# sb* synthesis tests
33

44
from slycot import synthesis
5+
from slycot.exceptions import raise_if_slycot_error, SlycotError, \
6+
SlycotParameterError, SlycotArithmeticError
7+
58
from numpy import array, eye, zeros
6-
from numpy.testing import assert_allclose
7-
from slycot.exceptions import filter_docstring_exceptions
9+
from numpy.testing import assert_allclose, assert_raises
10+
import pytest
11+
812

913
def test_sb02mt():
1014
"""Test if sb02mt is callable
@@ -98,18 +102,15 @@ def test_sb10jd():
98102
assert_allclose(C_r, Cexp, atol=1e-5)
99103
assert_allclose(D_r, Dexp, atol=1e-5)
100104

101-
def test_exceptionstrings():
102-
assert(len(filter_docstring_exceptions(synthesis.sb01bd.__doc__)) == 4)
103-
assert(len(filter_docstring_exceptions(synthesis.sb02md.__doc__)) == 5)
104-
assert(len(filter_docstring_exceptions(synthesis.sb02od.__doc__)) == 6)
105-
assert(len(filter_docstring_exceptions(synthesis.sb03md.__doc__)) == 0)
106-
assert(len(filter_docstring_exceptions(synthesis.sb03od.__doc__)) == 6)
107-
assert(len(filter_docstring_exceptions(synthesis.sb04md.__doc__)) == 0)
108-
assert(len(filter_docstring_exceptions(synthesis.sb04qd.__doc__)) == 0)
109-
assert(len(filter_docstring_exceptions(synthesis.sb10ad.__doc__)) == 12)
110-
assert(len(filter_docstring_exceptions(synthesis.sb10dd.__doc__)) == 9)
111-
assert(len(filter_docstring_exceptions(synthesis.sb10hd.__doc__)) == 5)
112-
assert(len(filter_docstring_exceptions(synthesis.sb10jd.__doc__)) == 0)
113-
assert(len(filter_docstring_exceptions(synthesis.sg03ad.__doc__)) == 4)
114-
assert(len(filter_docstring_exceptions(synthesis.sg02ad.__doc__)) == 7)
115-
assert(len(filter_docstring_exceptions(synthesis.sg03bd.__doc__)) == 7)
105+
106+
@pytest.mark.parametrize(
107+
'fun, info, exception, checkvars',
108+
[(synthesis.sb01bd, -1, SlycotParameterError, {}),
109+
(synthesis.sb01bd, 1, SlycotArithmeticError, {}),
110+
(synthesis.sb01bd, 2, SlycotArithmeticError, {}), ])
111+
def test_sb_exceptionstrings(fun, info, exception, checkvars):
112+
assert_raises(exception, raise_if_slycot_error, info, arg_list=["a", "b"],
113+
docstring=fun.__doc__, checkvars=checkvars)
114+
115+
116+

slycot/tests/test_tb05ad.py

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# ===================================================
22
# tb05ad tests
3-
import unittest
3+
44
from slycot import transform
5+
from slycot.exceptions import SlycotArithmeticError, SlycotParameterError
6+
57
import numpy as np
6-
from slycot.exceptions import SlycotError
78

8-
from numpy.testing import assert_raises, assert_almost_equal
9+
import unittest
10+
from numpy.testing import assert_almost_equal
911

1012

1113
# set the random seed so we can get consistent results.
@@ -143,36 +145,45 @@ def check_tb05ad_errors(self, sys):
143145
jomega = 10*1j
144146
# test error handling
145147
# wrong size A
146-
assert_raises(ValueError, transform.tb05ad, n+1, m, p,
147-
jomega, sys['A'], sys['B'], sys['C'], job='NH')
148+
with self.assertRaises(SlycotParameterError) as cm:
149+
transform.tb05ad(
150+
n+1, m, p, jomega, sys['A'], sys['B'], sys['C'], job='NH')
151+
assert cm.exception.info == -7
148152
# wrong size B
149-
assert_raises(ValueError, transform.tb05ad, n, m+1, p,
150-
jomega, sys['A'], sys['B'], sys['C'], job='NH')
153+
with self.assertRaises(SlycotParameterError) as cm:
154+
transform.tb05ad(
155+
n, m+1, p, jomega, sys['A'], sys['B'], sys['C'], job='NH')
156+
assert cm.exception.info == -9
151157
# wrong size C
152-
assert_raises(ValueError, transform.tb05ad, n, m, p+1,
153-
jomega, sys['A'], sys['B'], sys['C'], job='NH')
158+
with self.assertRaises(SlycotParameterError) as cm:
159+
transform.tb05ad(
160+
n, m, p+1, jomega, sys['A'], sys['B'], sys['C'], job='NH')
161+
assert cm.exception.info == -11
154162
# unrecognized job
155-
assert_raises(ValueError, transform.tb05ad, n, m, p, jomega,
156-
sys['A'], sys['B'], sys['C'], job='a')
163+
with self.assertRaises(SlycotParameterError) as cm:
164+
transform.tb05ad(
165+
n, m, p, jomega, sys['A'], sys['B'], sys['C'], job='a')
166+
assert cm.exception.info == -1
157167

158168
def test_tb05ad_resonance(self):
159-
'''
160-
Actually test one of the exception messages. For many routines these are
161-
parsed from the docstring, tests both the info index and the message
162-
'''
169+
""" Test tb05ad resonance failure.
170+
171+
Actually test one of the exception messages. For many routines these
172+
are parsed from the docstring, tests both the info index and the
173+
message
174+
"""
163175
A = np.array([ [0, -1], [1, 0] ])
164176
B = np.array([ [1],[0] ])
165177
C = np.array([ [0, 1 ]])
166178
jomega = 1j
167-
from numpy.linalg import eig
168-
print( eig(A))
169-
try:
179+
with self.assertRaises(
180+
SlycotArithmeticError,
181+
msg="\n"
182+
"Either FREQ is too near to an eigenvalue of A, or RCOND\n"
183+
"is less than the machine precision EPS.") as cm:
170184
transform.tb05ad(2, 1, 1, jomega, A, B, C, job='NH')
171-
except SlycotError as e:
172-
assert(str(e) == \
173-
"""Either FREQ is too near to an eigenvalue of A, or RCOND
174-
is less than the machine precision EPS.""")
175-
assert(e.info == 2)
185+
assert cm.exception.info == 2
186+
176187

177188
if __name__ == "__main__":
178189
unittest.main()

0 commit comments

Comments
 (0)