diff options
-rw-r--r-- | .coveragerc | 7 | ||||
-rw-r--r-- | .travis.yml | 6 | ||||
-rw-r--r-- | CHANGES.rst | 8 | ||||
-rw-r--r-- | setup.py | 4 | ||||
-rw-r--r-- | src/zope/exceptions/exceptionformatter.py | 42 | ||||
-rw-r--r-- | src/zope/exceptions/log.py | 8 | ||||
-rw-r--r-- | src/zope/exceptions/tests/test_exceptionformatter.py | 189 | ||||
-rw-r--r-- | src/zope/exceptions/tests/test_log.py | 31 | ||||
-rw-r--r-- | tox.ini | 15 |
9 files changed, 191 insertions, 119 deletions
diff --git a/.coveragerc b/.coveragerc index af40312..3b86679 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,11 @@ [run] -source = src +source = zope.exceptions [report] +precision = 2 exclude_lines = pragma: no cover + if __name__ == '__main__': + raise NotImplementedError + self.fail + raise AssertionError diff --git a/.travis.yml b/.travis.yml index 19a4c83..830860d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,17 +2,17 @@ language: python sudo: false python: - 2.7 - - 3.3 - 3.4 - 3.5 - 3.6 - - pypy-5.4.1 + - pypy install: - pip install -U pip setuptools - pip install -U coverage coveralls - - pip install -U -e .[test] + - pip install -U -e .[test,docs] script: - coverage run -m zope.testrunner --test-path=src + - coverage run -a -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest notifications: email: false cache: pip diff --git a/CHANGES.rst b/CHANGES.rst index d919f5a..5d60d0f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,12 @@ - Add support for Python 3.6. +- Drop support for Python 3.3. + +- Fix handling of unicode supplemental traceback information on + Python 2. Now such values are always encoded to UTF-8; previously + the results were undefined and depended on system encodings and the + values themselves. See `issue 1 <https://github.com/zopefoundation/zope.exceptions/issues/1>`_. 4.1.0 (2017-04-12) ================== @@ -76,7 +82,7 @@ 4.0.1 (2012-08-20) ================== -- Fixed optional dependency code for `zope.security` to work under Python 3.3. +- Fixed optional dependency code for `'zope.security`` to work under Python 3.3. 4.0.0.1 (2012-05-16) @@ -61,7 +61,6 @@ setup(name='zope.exceptions', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', @@ -72,7 +71,7 @@ setup(name='zope.exceptions', 'Topic :: Internet :: WWW/HTTP', 'Framework :: Zope3', ], - url='http://cheeseshop.python.org/pypi/zope.exceptions', + url='https://github.com/zopefoundation/zope.exceptions', license='ZPL 2.1', packages=find_packages('src'), package_dir={'': 'src'}, @@ -89,7 +88,6 @@ setup(name='zope.exceptions', zip_safe=False, extras_require={ 'docs': ['Sphinx', 'repoze.sphinx.autointerface'], - 'testing': ['nose', 'coverage'], 'test': tests_require, }, ) diff --git a/src/zope/exceptions/exceptionformatter.py b/src/zope/exceptions/exceptionformatter.py index fabea9f..47e0f41 100644 --- a/src/zope/exceptions/exceptionformatter.py +++ b/src/zope/exceptions/exceptionformatter.py @@ -46,7 +46,12 @@ class TextExceptionFormatter(object): return limit def formatSupplementLine(self, line): - return ' - %s' % line + result = ' - %s' % line + if not isinstance(result, str): + # Must be an Python 2, and must be a unicode `line` + # and we upconverted the result to a unicode + result = result.encode('utf-8') + return result def formatSourceURL(self, url): return [self.formatSupplementLine(url)] @@ -110,13 +115,13 @@ class TextExceptionFormatter(object): co = f.f_code filename = co.co_filename name = co.co_name - locals = f.f_locals # XXX shadowing normal builtins deliberately? - globals = f.f_globals # XXX shadowing normal builtins deliberately? + f_locals = f.f_locals + f_globals = f.f_globals if self.with_filenames: s = ' File "%s", line %d' % (filename, lineno) else: - modname = globals.get('__name__', filename) + modname = f_globals.get('__name__', filename) s = ' Module %s, line %d' % (modname, lineno) s = s + ', in %s' % name @@ -130,13 +135,13 @@ class TextExceptionFormatter(object): result.append(" " + self.escape(line.strip())) # Output a traceback supplement, if any. - if '__traceback_supplement__' in locals: + if '__traceback_supplement__' in f_locals: # Use the supplement defined in the function. - tbs = locals['__traceback_supplement__'] - elif '__traceback_supplement__' in globals: + tbs = f_locals['__traceback_supplement__'] + elif '__traceback_supplement__' in f_globals: # Use the supplement defined in the module. # This is used by Scripts (Python). - tbs = globals['__traceback_supplement__'] + tbs = f_globals['__traceback_supplement__'] else: tbs = None if tbs is not None: @@ -151,7 +156,7 @@ class TextExceptionFormatter(object): # else just swallow the exception. try: - tbi = locals.get('__traceback_info__', None) + tbi = f_locals.get('__traceback_info__', None) if tbi is not None: result.append(self.formatTracebackInfo(tbi)) except: #pragma: no cover @@ -240,13 +245,23 @@ class HTMLExceptionFormatter(TextExceptionFormatter): line_sep = '<br />\r\n' def escape(self, s): + if not isinstance(s, str): + try: + s = str(s) + except UnicodeError: + if hasattr(s, 'encode'): + # We probably got a unicode string on + # Python 2. + s = s.encode('utf-8') + else: # pragma: no cover + raise return escape(s, quote=False) def getPrefix(self): return '<p>Traceback (most recent call last):</p>\r\n<ul>' def formatSupplementLine(self, line): - return '<b>%s</b>' % self.escape(str(line)) + return '<b>%s</b>' % self.escape(line) def formatSupplementInfo(self, info): info = self.escape(info) @@ -255,7 +270,7 @@ class HTMLExceptionFormatter(TextExceptionFormatter): return info def formatTracebackInfo(self, tbi): - s = self.escape(str(tbi)) + s = self.escape(tbi) s = s.replace('\n', self.line_sep) return '__traceback_info__: %s' % (s, ) @@ -275,6 +290,9 @@ def format_exception(t, v, tb, limit=None, as_html=False, Similar to 'traceback.format_exception', but adds supplemental information to the traceback and accepts two options, 'as_html' and 'with_filenames'. + + The result is a list of native strings; on Python 2 they are UTF-8 + encoded if need be. """ if as_html: fmt = HTMLExceptionFormatter(limit, with_filenames) @@ -291,7 +309,7 @@ def print_exception(t, v, tb, limit=None, file=None, as_html=False, information to the traceback and accepts two options, 'as_html' and 'with_filenames'. """ - if file is None: #pragma: no cover + if file is None: # pragma: no cover file = sys.stderr lines = format_exception(t, v, tb, limit, as_html, with_filenames) for line in lines: diff --git a/src/zope/exceptions/log.py b/src/zope/exceptions/log.py index 735429a..574ccd3 100644 --- a/src/zope/exceptions/log.py +++ b/src/zope/exceptions/log.py @@ -15,13 +15,11 @@ """ import logging -try: - from StringIO import StringIO -except ImportError: #pragma: no cover Python3 - from io import StringIO +import io from zope.exceptions.exceptionformatter import print_exception +Buffer = io.StringIO if bytes is not str else io.BytesIO class Formatter(logging.Formatter): @@ -30,7 +28,7 @@ class Formatter(logging.Formatter): Uses zope.exceptions.exceptionformatter to generate the traceback. """ - sio = StringIO() + sio = Buffer() print_exception(ei[0], ei[1], ei[2], file=sio, with_filenames=True) s = sio.getvalue() if s.endswith("\n"): diff --git a/src/zope/exceptions/tests/test_exceptionformatter.py b/src/zope/exceptions/tests/test_exceptionformatter.py index 5856583..9046950 100644 --- a/src/zope/exceptions/tests/test_exceptionformatter.py +++ b/src/zope/exceptions/tests/test_exceptionformatter.py @@ -14,7 +14,6 @@ """ExceptionFormatter tests. """ import unittest -import doctest import sys @@ -53,7 +52,6 @@ class TextExceptionFormatterTests(unittest.TestCase): self.assertEqual(fmt.getLimit(), 200) def test_getLimit_sys_has_limit(self): - import sys fmt = self._makeOne() with _Monkey(sys, tracebacklimit=15): self.assertEqual(fmt.getLimit(), 15) @@ -152,6 +150,17 @@ class TextExceptionFormatterTests(unittest.TestCase): self.assertEqual(fmt.formatTracebackInfo('XYZZY'), ' - __traceback_info__: XYZZY') + def test_formatTracebackInfo_unicode(self): + __traceback_info__ = u"Have a Snowman: \u2603" + fmt = self._makeOne() + + result = fmt.formatTracebackInfo(__traceback_info__) + expected = ' - __traceback_info__: Have a Snowman: ' + # utf-8 encoded on Python 2, unicode on Python 3 + expected += '\xe2\x98\x83' if bytes is str else u'\u2603' + self.assertIsInstance(result, str) + self.assertEqual(result, expected) + def test_formatLine_no_tb_no_f(self): fmt = self._makeOne() self.assertRaises(ValueError, fmt.formatLine, None, None) @@ -168,24 +177,26 @@ class TextExceptionFormatterTests(unittest.TestCase): tb.tb_frame = f = DummyFrame() lines = fmt.formatLine(tb).splitlines() self.assertEqual(len(lines), 1) - self.assertEqual(lines[0], - ' File "%s", line %d, in %s' - % (f.f_code.co_filename, - tb.tb_lineno, - f.f_code.co_name, - )) + self.assertEqual( + lines[0], + ' File "%s", line %d, in %s' + % (f.f_code.co_filename, + tb.tb_lineno, + f.f_code.co_name,) + ) def test_formatLine_w_f_bogus_linecache_w_filenames(self): fmt = self._makeOne(with_filenames=True) f = DummyFrame() lines = fmt.formatLine(f=f).splitlines() self.assertEqual(len(lines), 1) - self.assertEqual(lines[0], - ' File "%s", line %d, in %s' - % (f.f_code.co_filename, - f.f_lineno, - f.f_code.co_name, - )) + self.assertEqual( + lines[0], + ' File "%s", line %d, in %s' + % (f.f_code.co_filename, + f.f_lineno, + f.f_code.co_name,) + ) def test_formatLine_w_tb_bogus_linecache_wo_filenames(self): fmt = self._makeOne(with_filenames=False) @@ -194,25 +205,27 @@ class TextExceptionFormatterTests(unittest.TestCase): f.f_globals['__name__'] = 'dummy.filename' lines = fmt.formatLine(tb).splitlines() self.assertEqual(len(lines), 1) - self.assertEqual(lines[0], - ' Module dummy.filename, line %d, in %s' - % (tb.tb_lineno, - f.f_code.co_name, - )) + self.assertEqual( + lines[0], + ' Module dummy.filename, line %d, in %s' + % (tb.tb_lineno, + f.f_code.co_name,) + ) def test_formatLine_w_f_real_linecache_w_filenames(self): - import sys fmt = self._makeOne(with_filenames=True) - f = sys._getframe(); lineno = f.f_lineno + f = sys._getframe() + lineno = f.f_lineno result = fmt.formatLine(f=f) lines = result.splitlines() self.assertEqual(len(lines), 2) - self.assertEqual(lines[0], - ' File "%s", line %d, in %s' - % (f.f_code.co_filename, - lineno + 1, - f.f_code.co_name, - )) + self.assertEqual( + lines[0], + ' File "%s", line %d, in %s' + % (f.f_code.co_filename, + lineno + 1, + f.f_code.co_name,) + ) self.assertEqual(lines[1], ' result = fmt.formatLine(f=f)') @@ -257,7 +270,7 @@ class TextExceptionFormatterTests(unittest.TestCase): err = ValueError('testing') self.assertEqual(fmt.formatExceptionOnly(ValueError, err), ''.join( - traceback.format_exception_only(ValueError, err))) + traceback.format_exception_only(ValueError, err))) def test_formatLastLine(self): fmt = self._makeOne() @@ -272,7 +285,7 @@ class TextExceptionFormatterTests(unittest.TestCase): self.assertEqual(lines[0], 'Traceback (most recent call last):\n') self.assertEqual(lines[1], ''.join( - traceback.format_exception_only(ValueError, err))) + traceback.format_exception_only(ValueError, err))) def test_formatException_non_empty_tb_stack(self): import traceback @@ -287,7 +300,7 @@ class TextExceptionFormatterTests(unittest.TestCase): 'in dummy_function\n') self.assertEqual(lines[2], ''.join( - traceback.format_exception_only(ValueError, err))) + traceback.format_exception_only(ValueError, err))) def test_formatException_deep_tb_stack_with_limit(self): import traceback @@ -336,12 +349,12 @@ class TextExceptionFormatterTests(unittest.TestCase): 'in dummy_function\n') self.assertEqual(lines[4], ''.join( - traceback.format_exception_only(ValueError, err))) + traceback.format_exception_only(ValueError, err))) def test_extractStack_wo_frame(self): - import sys fmt = self._makeOne() - f = sys._getframe(); lineno = f.f_lineno + f = sys._getframe() + lineno = f.f_lineno lines = fmt.extractStack() # rather don't assert this here # self.assertEqual(len(lines), 10) @@ -351,9 +364,9 @@ class TextExceptionFormatterTests(unittest.TestCase): ' lines = fmt.extractStack()\n' % (lineno + 1)) def test_extractStack_wo_frame_w_limit(self): - import sys fmt = self._makeOne(limit=2) - f = sys._getframe(); lineno = f.f_lineno + f = sys._getframe() + lineno = f.f_lineno lines = fmt.extractStack() self.assertEqual(len(lines), 3) self.assertEqual(lines[-1], ' Module ' @@ -417,7 +430,7 @@ class TextExceptionFormatterTests(unittest.TestCase): def _makeTBs(self, count): prev = None - for i in range(count): + for _i in range(count): tb = DummyTB() tb.tb_lineno = 14 tb.tb_frame = DummyFrame() @@ -429,7 +442,7 @@ class TextExceptionFormatterTests(unittest.TestCase): def _makeFrames(self, count): prev = None - for i in range(count): + for _i in range(count): f = DummyFrame() f.f_lineno = 17 if prev is not None: @@ -467,7 +480,7 @@ class HTMLExceptionFormatterTests(unittest.TestCase): def test_escape_w_markup(self): fmt = self._makeOne() self.assertEqual(fmt.escape('<span>XXX & YYY<span>'), - '<span>XXX & YYY<span>') + '<span>XXX & YYY<span>') def test_getPrefix(self): fmt = self._makeOne() @@ -506,12 +519,13 @@ class HTMLExceptionFormatterTests(unittest.TestCase): tb = DummyTB() tb.tb_frame = f = DummyFrame() result = fmt.formatLine(tb) - self.assertEqual(result, - '<li> File "%s", line %d, in %s</li>' - % (f.f_code.co_filename, - tb.tb_lineno, - f.f_code.co_name, - )) + self.assertEqual( + result, + '<li> File "%s", line %d, in %s</li>' + % (f.f_code.co_filename, + tb.tb_lineno, + f.f_code.co_name,) + ) def test_formatLastLine(self): fmt = self._makeOne() @@ -521,7 +535,6 @@ class HTMLExceptionFormatterTests(unittest.TestCase): class Test_format_exception(unittest.TestCase): def _callFUT(self, as_html=False): - import sys from zope.exceptions.exceptionformatter import format_exception t, v, b = sys.exc_info() try: @@ -633,23 +646,33 @@ class Test_format_exception(unittest.TestCase): pass try: raise TypeError(C()) - except: + except TypeError: s = self._callFUT(True) - self.assertTrue(s.find('<') >= 0, s) - self.assertTrue(s.find('>') >= 0, s) + self.assertIn('<', s) + self.assertIn('>', s) def test_multiline_exception(self): try: exec('syntax error\n') - except Exception: + except SyntaxError: s = self._callFUT(False) lines = s.splitlines()[-3:] self.assertEqual(lines[0], ' syntax error') self.assertTrue(lines[1].endswith(' ^')) #PyPy has a shorter prefix self.assertEqual(lines[2], 'SyntaxError: invalid syntax') + def test_traceback_info_non_ascii(self): + __traceback_info__ = u"Have a Snowman: \u2603" + try: + raise TypeError() + except TypeError: + s = self._callFUT(True) + + self.assertIsInstance(s, str) + self.assertIn('Have a Snowman', s) + + def test_recursion_failure(self): - import sys from zope.exceptions.exceptionformatter import TextExceptionFormatter class FormatterException(Exception): @@ -676,16 +699,38 @@ class Test_format_exception(unittest.TestCase): self.assertTrue('FormatterException: Formatter failed' in s.splitlines()[-1]) + def test_format_exception_as_html(self): + # Test for format_exception (as_html=True) + from zope.exceptions.exceptionformatter import format_exception + from textwrap import dedent + import re + try: + exec('import') + except SyntaxError: + result = ''.join(format_exception(*sys.exc_info(), as_html=True)) + expected = dedent("""\ + <p>Traceback (most recent call last):</p> + <ul> + <li> Module zope.exceptions.tests.test_exceptionformatter, line ABC, in test_format_exception_as_html<br /> + exec('import')</li> + </ul><p> File "<string>", line 1<br /> + import<br /> + ^<br /> + SyntaxError: invalid syntax<br /> + </p>""") + # HTML formatter uses Windows line endings for some reason. + result = result.replace('\r\n', '\n') + result = re.sub(r'line \d\d\d,', 'line ABC,', result) + self.maxDiff = None + self.assertEqual(expected, result) + class Test_print_exception(unittest.TestCase): def _callFUT(self, as_html=False): - try: - from StringIO import StringIO - except ImportError: - from io import StringIO - buf = StringIO() - import sys + import io + buf = io.StringIO() if bytes is not str else io.BytesIO() + from zope.exceptions.exceptionformatter import print_exception t, v, b = sys.exc_info() try: @@ -718,7 +763,6 @@ class Test_print_exception(unittest.TestCase): class Test_extract_stack(unittest.TestCase): def _callFUT(self, as_html=False): - import sys from zope.exceptions.exceptionformatter import extract_stack f = sys.exc_info()[2].tb_frame try: @@ -752,7 +796,7 @@ class Test_extract_stack(unittest.TestCase): def test_traceback_info_html(self): try: - __traceback_info__ = "Adam & Eve" + __traceback_info__ = u"Adam & Eve" raise ExceptionForTesting except ExceptionForTesting: s = self._callFUT(True) @@ -761,7 +805,7 @@ class Test_extract_stack(unittest.TestCase): def test_traceback_supplement_text(self): try: __traceback_supplement__ = (TestingTracebackSupplement, - "You're one in a million") + u"You're one in a million") raise ExceptionForTesting except ExceptionForTesting: s = self._callFUT(False) @@ -803,7 +847,7 @@ class Test_extract_stack(unittest.TestCase): self.assertTrue(s.find('test_noinput') >= 0) -class ExceptionForTesting (Exception): +class ExceptionForTesting(Exception): pass @@ -860,30 +904,7 @@ class _Monkey(object): delattr(self.module, key) -def doctest_format_exception_as_html(): - """Test for format_exception (as_html=True) - - >>> from zope.exceptions.exceptionformatter import format_exception - >>> try: - ... exec('import 2 + 2') - ... except: - ... print(''.join(format_exception(*sys.exc_info(), as_html=True))) - <p>Traceback (most recent call last):</p> - <ul> - <li> Module zope.exceptions.tests.test_exceptionformatter, line 2, in <module><br /> - exec('import 2 + 2')</li> - </ul><p> File "<string>", line 1<br /> - import 2 + 2<br /> - ^<br /> - SyntaxError: invalid syntax<br /> - </p> - - """ def test_suite(): - return unittest.TestSuite([ - unittest.defaultTestLoader.loadTestsFromName(__name__), - doctest.DocTestSuite( - optionflags=doctest.NORMALIZE_WHITESPACE), - ]) + return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/src/zope/exceptions/tests/test_log.py b/src/zope/exceptions/tests/test_log.py index d63385a..9e01eea 100644 --- a/src/zope/exceptions/tests/test_log.py +++ b/src/zope/exceptions/tests/test_log.py @@ -28,7 +28,6 @@ class FormatterTests(unittest.TestCase): def test_simple_exception(self): import traceback tb = DummyTB() - tb.tb_frame = DummyFrame() exc = ValueError('testing') fmt = self._makeOne() result = fmt.formatException((ValueError, exc, tb)) @@ -38,14 +37,40 @@ class FormatterTests(unittest.TestCase): self.assertEqual(lines[1], ' File "dummy/filename.py", line 14, ' 'in dummy_function') self.assertEqual(lines[2], - traceback.format_exception_only( - ValueError, exc)[0][:-1]) #trailing \n + traceback.format_exception_only( + ValueError, exc)[0][:-1]) #trailing \n + + def test_unicode_traceback_info(self): + import traceback + __traceback_info__ = u"Have a Snowman: \u2603" + tb = DummyTB() + tb.tb_frame.f_locals['__traceback_info__'] = __traceback_info__ + exc = ValueError('testing') + fmt = self._makeOne() + result = fmt.formatException((ValueError, exc, tb)) + self.assertIsInstance(result, str) + lines = result.splitlines() + self.assertEqual(len(lines), 4) + self.assertEqual(lines[0], 'Traceback (most recent call last):') + self.assertEqual(lines[1], ' File "dummy/filename.py", line 14, ' + 'in dummy_function') + expected = ' - __traceback_info__: Have a Snowman: ' + # utf-8 encoded on Python 2, unicode on Python 3 + expected += '\xe2\x98\x83' if bytes is str else u'\u2603' + + self.assertEqual(lines[2], + expected) + self.assertEqual(lines[3], + traceback.format_exception_only( + ValueError, exc)[0][:-1]) #trailing \n class DummyTB(object): tb_lineno = 14 tb_next = None + def __init__(self): + self.tb_frame = DummyFrame() class DummyFrame(object): f_lineno = 137 @@ -1,22 +1,25 @@ [tox] envlist = - py27,py33,py34,py35,py36,pypy,pypy3,coverage,docs + py27,py34,py35,py36,pypy,pypy3,coverage,docs [testenv] commands = zope-testrunner --test-path=src [] + sphinx-build -b doctest -d {envdir}/.cache/doctrees docs {envdir}/.cache/doctest deps = - .[test] + .[test,docs] [testenv:coverage] usedevelop = true basepython = python2.7 commands = - nosetests --with-xunit --with-xcoverage + coverage run -m zope.testrunner --test-path=src [] + coverage run -a -m sphinx -b doctest -d {envdir}/.cache/doctrees docs {envdir}/.cache/doctest + coverage report --fail-under=100 deps = - .[test,testing] - nosexcover + {[testenv]deps} + coverage [testenv:docs] basepython = @@ -24,5 +27,3 @@ basepython = commands = sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest -deps = - .[test,docs] |