[TIP] assertRaisesRegex with exceptions from inside a generator
Andrew Dalke
dalke at dalkescientific.com
Sun Jan 8 08:15:57 PST 2017
Hi all,
I tracked down an odd unit-test failure as part of my migration from Python 2.7 to 3.5+. The unittest assertRaisesRegex() does a traceback.clear_frames() on the traceback. If the exception comes from inside a still active generator then it has the effect of shutting down the generator.
I can't tell if this is misunderstanding on my part in how things are supposed to work, a bug in the unittest module, or some other bug in Python.
Before filing a Python bug, I want to eliminate the possibility of my misunderstanding. Note that all of the following was tested under Python 3.5.
My code uses a generator as a consumer coroutine, where I send data to the generator for output processing. Here's a sketch of how the generator looks:
def generator():
result = None
while 1:
process_list = yield result
for item in process_list:
process(item)
result = ... intermediate result, like the number of records processed ...
(This is an unusual approach. The actual code is more complex with a lot of cumulative state information which must persist across multiple calls. There was a non-trivial performance penalty for saving/restoring through instance variables instead of using the STORE_FAST/LOAD_FAST by being in a generator's local scope.)
If there is an error then I want to raise an exception. I also want the consumer to be resumable, so the user can send more data to that generator.
Here's what the working code looks like:
##### Code under test
class MyException(Exception):
pass
# The public interface is "open_writer()", which returns a Writer().
def open_writer():
return Writer(write_gen())
class Writer:
def __init__(self, gen):
self.gen = gen
def write_objects(self, objs):
total_len, err = self.gen.send(objs)
# If the generator indicates there was an error then re-raise it
if err is not None:
raise err
return total_len
# The generator is a back-end implementation detail.
def write_gen():
gen = _write_gen()
next(gen) # prime the generator so it's ready to receive
return gen
def _write_gen():
total_len = 0 # accumulate the length of all of the objects
result = "started"
# Receive a request to process more objects.
while 1:
objs = yield result
try:
for obj in objs:
if obj == "a":
raise MyException("'a' is not allowed")
total_len += len(obj)
except Exception as err:
# An error occurred. Forward it to the Writer() instance.
result = None, err
else:
# No error. Return the current total length.
result = total_len, None
##### End of code under test
Here's a simple test for the code:
##### test the code manually. This works as expected
def simple_test():
w = open_writer()
try:
w.write_objects(["this", "is", "a"])
except MyException as err:
print("got expected exception:", err)
else:
raise AssertionError("should have raised exception")
total_len = w.write_objects(["test"])
assert total_len == 10, total_len
print("simple_test is done.")
simple_test()
##### end of manual test code
The output of this is exactly as expected.
##### Output of the manual test code.
got expected exception: 'a' is not allowed
simple_test is done.
#####
My problem came because the real test code is in unittest and I use the assertRaisesRegex to test the exception. That causes the generator to stop working, and not be resumable.
##### test the code using unittest
import unittest
class SimpleText(unittest.TestCase):
def test_using_assert_raises(self):
w = open_writer()
with self.assertRaisesRegex(MyException, "'a' is not allowed"):
w.write_objects(["this", "is", "a"])
total_len = w.write_objects(["test"])
self.assertEqual(total_len, 10)
try:
unittest.main()
except SystemExit:
pass
##### End of the unittest-based test.
This gives me an unexpected StopIteration error.
##### Output from the unittest-based test:
E
======================================================================
ERROR: test_using_assert_raises (__main__.SimpleText)
----------------------------------------------------------------------
Traceback (most recent call last):
File "fail2.py", line 69, in test_using_assert_raises
total_len = w.write_objects(["test"])
File "fail2.py", line 13, in write_objects
total_len, err = self.gen.send(objs)
StopIteration
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)
##### End of the output from unittest.
I spent about 5-6 hours trying to figure out if I didn't understand Python 3's chained exceptions. Eventually I tracked it down to unittest.py::_AssertRaisesContext.__exit__'s use of traceback.clear_frames(tb), at
https://hg.python.org/cpython/file/tip/Lib/unittest/case.py#l201
Here's my reproducible which shows how that function causes the behavior I see:
##### Test how clear_frames() affects a traceback from an active generator.
import traceback
class ClearFrames:
def __init__(self, clear_frames):
self.clear_frames = clear_frames
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
assert exc_type is MyException, exc_type
assert "is not allowed" in str(exc_value), str(exc_value)
if self.clear_frames:
traceback.clear_frames(tb) # This is the only difference between the tests.
return True
# This is essentially the same test case as before, but structured using
# a context manager that either does or does not clear the traceback frames.
def clear_test(clear_frames):
w = open_writer()
with ClearFrames(clear_frames):
w.write_objects(["this", "is", "a"])
try:
total_len = w.write_objects(["test"])
except StopIteration:
print("...caught StopIteration")
else:
assert total_len == 10, total_len
print("...returned a length")
print("\nDo not clear frames")
clear_test(False)
print("\nClear frames")
clear_test(True)
##### End of ClearFrames() context manager test code.
This test code results in:
##### Output from the ClearFrames() context manager test:
Do not clear frames
...returned a length
Clear frames
...caught StopIteration
##### End of ClearFrames() test output.
It looks like traceback.clear_frames(tb) is the culprit. ("Clears the local variables of all the stack frames in a traceback tb by calling the clear() method of each frame object.") I assume it expects that the frame will never be used again, which isn't the case for an exception from a generator.
My work-around is to not use assertRaisesRegex for my unit tests for resumable consumer generators. I only have a dozen or so of these so it's easy to re-write.
Is there a better solution? I can think of several:
- is there a way I can sanitize the exception in my code so users can see the traceback stack but not have clear_frames() destroy things?
- should unittest2 not clear_frames() for a frame from a running generator?
- should assertRaisesRegex take an optional parameter to not clear the frames?
- should traceback.clear_frames() only clear up to a running generator?
- Perhaps there's a missing function which would be more appropriate than clear_frames() for this use case?
Cheers,
Andrew
dalke at dalkescientific.com
More information about the testing-in-python
mailing list