Skip to content

Commit 4501ab4

Browse files
committed
Prevent crashing from UnicodeDecodeError
On Python 2, `sys.stdout` and `print` can normally handle any combination of `str` and `unicode` objects. However, `StringIO.StringIO` can only safely handle one or the other. If the program writes both a `unicode` string, and a non-ASCII `str` string, then the `getvalue()` method will fail with `UnicodeDecodeError` [1]. In nose, that causes the script to suddenly abort, with the cryptic `UnicodeDecodeError`. This fix catches `UnicodeError` when trying to get the captured output, and will replace the captured output with a warning message. Fixes #816 [1] <https://github.com/python/cpython/blob/2.7/Lib/StringIO.py#L258>
1 parent 7c26ad1 commit 4501ab4

File tree

4 files changed

+76
-6
lines changed

4 files changed

+76
-6
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ Erik Rose
2626
Sascha Peilicke
2727
Andre Caron
2828
Joscha Feth
29+
Jordan Moldow

CHANGELOG

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
- Add option to suppress printing of coverage report
44
Patch by Eric Larson.
5+
- Fix #816: UnicodeDecodeError on failing tests with output which contains
6+
`unicode`s and non-ascii `str`s.
7+
Patch by Jordan Moldow
58

69
1.3.7
710

nose/plugins/capture.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import logging
1313
import os
1414
import sys
15+
import traceback
1516
from nose.plugins.base import Plugin
1617
from nose.pyversion import exc_to_unicode, force_unicode
1718
from nose.util import ln
@@ -71,26 +72,56 @@ def beforeTest(self, test):
7172
def formatError(self, test, err):
7273
"""Add captured output to error report.
7374
"""
74-
test.capturedOutput = output = self.buffer
75+
test.capturedOutput = output = ''
76+
output_exc_info = None
77+
try:
78+
test.capturedOutput = output = self.buffer
79+
except UnicodeError:
80+
# python2's StringIO.StringIO [1] class has this warning:
81+
#
82+
# The StringIO object can accept either Unicode or 8-bit strings,
83+
# but mixing the two may take some care. If both are used, 8-bit
84+
# strings that cannot be interpreted as 7-bit ASCII (that use the
85+
# 8th bit) will cause a UnicodeError to be raised when getvalue()
86+
# is called.
87+
#
88+
# This exception handler is a protection against issue #816 [2].
89+
# Capturing the exception info allows us to display it back to the
90+
# user.
91+
#
92+
# [1] <https://github.com/python/cpython/blob/2.7/Lib/StringIO.py#L258>
93+
# [2] <https://github.com/nose-devs/nose/issues/816>
94+
output_exc_info = sys.exc_info()
7595
self._buf = None
76-
if not output:
96+
if (not output) and (not output_exc_info):
7797
# Don't return None as that will prevent other
7898
# formatters from formatting and remove earlier formatters
7999
# formats, instead return the err we got
80100
return err
81101
ec, ev, tb = err
82-
return (ec, self.addCaptureToErr(ev, output), tb)
102+
return (ec, self.addCaptureToErr(ev, output, output_exc_info=output_exc_info), tb)
83103

84104
def formatFailure(self, test, err):
85105
"""Add captured output to failure report.
86106
"""
87107
return self.formatError(test, err)
88108

89-
def addCaptureToErr(self, ev, output):
109+
def addCaptureToErr(self, ev, output, output_exc_info=None):
110+
# If given, output_exc_info should be a 3-tuple from sys.exc_info(),
111+
# from an exception raised while trying to get the captured output.
90112
ev = exc_to_unicode(ev)
91113
output = force_unicode(output)
92-
return u'\n'.join([ev, ln(u'>> begin captured stdout <<'),
93-
output, ln(u'>> end captured stdout <<')])
114+
error_text = [ev, ln(u'>> begin captured stdout <<'),
115+
output, ln(u'>> end captured stdout <<')]
116+
if output_exc_info:
117+
error_text.extend([u'OUTPUT ERROR: Could not get captured output.',
118+
# <https://github.com/python/cpython/blob/2.7/Lib/StringIO.py#L258>
119+
# <https://github.com/nose-devs/nose/issues/816>
120+
u"The test might've printed both 'unicode' strings and non-ASCII 8-bit 'str' strings.",
121+
ln(u'>> begin captured stdout exception traceback <<'),
122+
u''.join(traceback.format_exception(*output_exc_info)),
123+
ln(u'>> end captured stdout exception traceback <<')])
124+
return u'\n'.join(error_text)
94125

95126
def start(self):
96127
self.stdout.append(sys.stdout)

unit_tests/test_capture_plugin.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
from optparse import OptionParser
55
from nose.config import Config
66
from nose.plugins.capture import Capture
7+
from nose.pyversion import force_unicode
8+
9+
if sys.version_info[0] == 2:
10+
py2 = True
11+
else:
12+
py2 = False
713

814
class TestCapturePlugin(unittest.TestCase):
915

@@ -62,6 +68,35 @@ def test_captures_nonascii_stdout(self):
6268
c.end()
6369
self.assertEqual(c.buffer, "test 日本\n")
6470

71+
def test_does_not_crash_with_mixed_unicode_and_nonascii_str(self):
72+
class Dummy:
73+
pass
74+
d = Dummy()
75+
c = Capture()
76+
c.start()
77+
printed_nonascii_str = force_unicode("test 日本").encode('utf-8')
78+
printed_unicode = force_unicode("Hello")
79+
print printed_nonascii_str
80+
print printed_unicode
81+
try:
82+
raise Exception("boom")
83+
except:
84+
err = sys.exc_info()
85+
formatted = c.formatError(d, err)
86+
_, fev, _ = formatted
87+
88+
if py2:
89+
for string in [force_unicode(printed_nonascii_str, encoding='utf-8'), printed_unicode]:
90+
assert string not in fev, "Output unexpectedly found in error message"
91+
assert d.capturedOutput == '', "capturedOutput unexpectedly non-empty"
92+
assert "OUTPUT ERROR" in fev
93+
assert "captured stdout exception traceback" in fev
94+
assert "UnicodeDecodeError" in fev
95+
else:
96+
for string in [repr(printed_nonascii_str), printed_unicode]:
97+
assert string in fev, "Output not found in error message"
98+
assert string in d.capturedOutput, "Output not attached to test"
99+
65100
def test_format_error(self):
66101
class Dummy:
67102
pass

0 commit comments

Comments
 (0)