Overhaul exception reporting.

unittest2 recently added the ability to show local variables in
tracebacks as https://github.com/testing-cabal/testtools/issues/111
requested for us. Reusing that requires some refactoring of our code,
in particular where we were reimplementing bits of the traceback
module. Now we can just hard-depend on traceback2 and linecache2 which
are brought in by unittest2 1.0.0.

Change-Id: Ieb3268029d26b48ed4fcd25ed644bd339f6aa3fb
This commit is contained in:
Robert Collins 2015-03-09 22:29:29 +13:00
parent edf585e6d5
commit 55278e0e4a
16 changed files with 182 additions and 574 deletions

15
NEWS
View File

@ -7,6 +7,21 @@ Changes and improvements to testtools_, grouped by release.
NEXT NEXT
~~~~ ~~~~
Improvements
------------
* ``testtools.run`` now accepts ``--locals`` to show local variables
in tracebacks, which can be a significant aid in debugging. In doing
so we've removed the code reimplementing linecache and traceback by
using the new traceback2 and linecache2 packages.
(Robert Collins, github #111)
Changes
-------
* ``testtools`` now depends on ``unittest2`` 1.0.0 which brings in a dependency
on ``traceback2`` and via it ``linecache2``. (Robert Collins)
1.5.0 1.5.0
~~~~~ ~~~~~

View File

@ -92,6 +92,13 @@ cause ``testtools.RunTest`` to fail the test case after the test has finished.
This is useful when you want to cause a test to fail, but don't want to This is useful when you want to cause a test to fail, but don't want to
prevent the remainder of the test code from being executed. prevent the remainder of the test code from being executed.
Exception formatting
--------------------
Testtools ``TestCase`` instances format their own exceptions. The attribute
``__testtools_tb_locals__`` controls whether to include local variables in the
formatted exceptions.
Test placeholders Test placeholders
================= =================

View File

@ -72,10 +72,12 @@ installed or have Python 2.7 or later, and then run::
$ python -m testtools.run discover packagecontainingtests $ python -m testtools.run discover packagecontainingtests
For more information see the Python 2.7 unittest documentation, or:: For more information see the Python unittest documentation, and::
python -m testtools.run --help python -m testtools.run --help
which describes the options available to ``testtools.run``.
As your testing needs grow and evolve, you will probably want to use a more As your testing needs grow and evolve, you will probably want to use a more
sophisticated test runner. There are many of these for Python, and almost all sophisticated test runner. There are many of these for Python, and almost all
of them will happily run testtools tests. In particular: of them will happily run testtools tests. In particular:

View File

@ -63,7 +63,8 @@ deps = [
# 'mimeparse' has not been uploaded by the maintainer with Python3 compat # 'mimeparse' has not been uploaded by the maintainer with Python3 compat
# but someone kindly uploaded a fixed version as 'python-mimeparse'. # but someone kindly uploaded a fixed version as 'python-mimeparse'.
'python-mimeparse', 'python-mimeparse',
'unittest2>=0.8.0', 'unittest2>=1.0.0',
'traceback2',
] ]

View File

@ -18,7 +18,6 @@ __all__ = [
import codecs import codecs
import io import io
import linecache
import locale import locale
import os import os
import re import re
@ -26,10 +25,12 @@ import sys
import traceback import traceback
import unicodedata import unicodedata
from extras import try_imports from extras import try_import, try_imports
BytesIO = try_imports(['StringIO.StringIO', 'io.BytesIO']) BytesIO = try_imports(['StringIO.StringIO', 'io.BytesIO'])
StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) StringIO = try_imports(['StringIO.StringIO', 'io.StringIO'])
# To let setup.py work, make this a conditional import.
linecache = try_import('linecache2')
try: try:
from testtools import _compat2x as _compat from testtools import _compat2x as _compat
@ -209,61 +210,6 @@ def unicode_output_stream(stream):
except AttributeError: except AttributeError:
pass pass
return writer(stream, "replace") return writer(stream, "replace")
# The default source encoding is actually "iso-8859-1" until Python 2.5 but
# using non-ascii causes a deprecation warning in 2.4 and it's cleaner to
# treat all versions the same way
_default_source_encoding = "ascii"
# Pattern specified in <http://www.python.org/dev/peps/pep-0263/>
_cookie_search=re.compile("coding[:=]\s*([-\w.]+)").search
def _detect_encoding(lines):
"""Get the encoding of a Python source file from a list of lines as bytes
This function does less than tokenize.detect_encoding added in Python 3 as
it does not attempt to raise a SyntaxError when the interpreter would, it
just wants the encoding of a source file Python has already compiled and
determined is valid.
"""
if not lines:
return _default_source_encoding
if lines[0].startswith("\xef\xbb\xbf"):
# Source starting with UTF-8 BOM is either UTF-8 or a SyntaxError
return "utf-8"
# Only the first two lines of the source file are examined
magic = _cookie_search("".join(lines[:2]))
if magic is None:
return _default_source_encoding
encoding = magic.group(1)
try:
codecs.lookup(encoding)
except LookupError:
# Some codecs raise something other than LookupError if they don't
# support the given error handler, but not the text ones that could
# actually be used for Python source code
return _default_source_encoding
return encoding
class _EncodingTuple(tuple):
"""A tuple type that can have an encoding attribute smuggled on"""
def _get_source_encoding(filename):
"""Detect, cache and return the encoding of Python source at filename"""
try:
return linecache.cache[filename].encoding
except (AttributeError, KeyError):
encoding = _detect_encoding(linecache.getlines(filename))
if filename in linecache.cache:
newtuple = _EncodingTuple(linecache.cache[filename])
newtuple.encoding = encoding
linecache.cache[filename] = newtuple
return encoding
def _get_exception_encoding(): def _get_exception_encoding():
"""Return the encoding we expect messages from the OS to be encoded in""" """Return the encoding we expect messages from the OS to be encoded in"""
if os.name == "nt": if os.name == "nt":
@ -276,110 +222,3 @@ def _get_exception_encoding():
return locale.getlocale(locale.LC_MESSAGES)[1] or "ascii" return locale.getlocale(locale.LC_MESSAGES)[1] or "ascii"
def _exception_to_text(evalue):
"""Try hard to get a sensible text value out of an exception instance"""
try:
return unicode(evalue)
except KeyboardInterrupt:
raise
except:
# Apparently this is what traceback._some_str does. Sigh - RBC 20100623
pass
try:
return str(evalue).decode(_get_exception_encoding(), "replace")
except KeyboardInterrupt:
raise
except:
# Apparently this is what traceback._some_str does. Sigh - RBC 20100623
pass
# Okay, out of ideas, let higher level handle it
return None
def _format_stack_list(stack_lines):
"""Format 'stack_lines' and return a list of unicode strings.
:param stack_lines: A list of filename, lineno, name, and line variables,
probably obtained by calling traceback.extract_tb or
traceback.extract_stack.
"""
fs_enc = sys.getfilesystemencoding()
extracted_list = []
for filename, lineno, name, line in stack_lines:
extracted_list.append((
filename.decode(fs_enc, "replace"),
lineno,
name.decode("ascii", "replace"),
line and line.decode(
_get_source_encoding(filename), "replace")))
return traceback.format_list(extracted_list)
def _format_exception_only(eclass, evalue):
"""Format the excption part of a traceback.
:param eclass: The type of the exception being formatted.
:param evalue: The exception instance.
:returns: A list of unicode strings.
"""
list = []
if evalue is None:
# Is a (deprecated) string exception
list.append((eclass + "\n").decode("ascii", "replace"))
return list
if isinstance(evalue, SyntaxError):
# Avoid duplicating the special formatting for SyntaxError here,
# instead create a new instance with unicode filename and line
# Potentially gives duff spacing, but that's a pre-existing issue
try:
msg, (filename, lineno, offset, line) = evalue
except (TypeError, ValueError):
pass # Strange exception instance, fall through to generic code
else:
# Errors during parsing give the line from buffer encoded as
# latin-1 or utf-8 or the encoding of the file depending on the
# coding and whether the patch for issue #1031213 is applied, so
# give up on trying to decode it and just read the file again
if line:
bytestr = linecache.getline(filename, lineno)
if bytestr:
if lineno == 1 and bytestr.startswith("\xef\xbb\xbf"):
bytestr = bytestr[3:]
line = bytestr.decode(
_get_source_encoding(filename), "replace")
del linecache.cache[filename]
else:
line = line.decode("ascii", "replace")
if filename:
fs_enc = sys.getfilesystemencoding()
filename = filename.decode(fs_enc, "replace")
evalue = eclass(msg, (filename, lineno, offset, line))
list.extend(traceback.format_exception_only(eclass, evalue))
return list
sclass = eclass.__name__
svalue = _exception_to_text(evalue)
if svalue:
list.append("%s: %s\n" % (sclass, svalue))
elif svalue is None:
# GZ 2010-05-24: Not a great fallback message, but keep for the moment
list.append(_u("%s: <unprintable %s object>\n" % (sclass, sclass)))
else:
list.append(_u("%s\n" % sclass))
return list
_TB_HEADER = _u('Traceback (most recent call last):\n')
def _format_exc_info(eclass, evalue, tb, limit=None):
"""Format a stack trace and the exception information as unicode
Compatibility function for Python 2 which ensures each component of a
traceback is correctly decoded according to its origins.
Based on traceback.format_exception and related functions.
"""
return [_TB_HEADER] \
+ _format_stack_list(traceback.extract_tb(tb, limit)) \
+ _format_exception_only(eclass, evalue)

View File

@ -17,15 +17,13 @@ import inspect
import json import json
import os import os
import sys import sys
import traceback
from extras import try_import from extras import try_import
# To let setup.py work, make this a conditional import.
traceback = try_import('traceback2')
from testtools.compat import ( from testtools.compat import (
_b, _b,
_format_exception_only,
_format_stack_list,
_TB_HEADER,
_u, _u,
istext, istext,
str_is_unicode, str_is_unicode,
@ -163,61 +161,52 @@ class StackLinesContent(Content):
def _stack_lines_to_unicode(self, stack_lines): def _stack_lines_to_unicode(self, stack_lines):
"""Converts a list of pre-processed stack lines into a unicode string. """Converts a list of pre-processed stack lines into a unicode string.
""" """
msg_lines = traceback.format_list(stack_lines)
# testtools customization. When str is unicode (e.g. IronPython, return _u('').join(msg_lines)
# Python 3), traceback.format_exception returns unicode. For Python 2,
# it returns bytes. We need to guarantee unicode.
if str_is_unicode:
format_stack_lines = traceback.format_list
else:
format_stack_lines = _format_stack_list
msg_lines = format_stack_lines(stack_lines)
return ''.join(msg_lines)
def TracebackContent(err, test): class TracebackContent(Content):
"""Content object for tracebacks. """Content object for tracebacks.
This adapts an exc_info tuple to the 'Content' interface. This adapts an exc_info tuple to the 'Content' interface.
'text/x-traceback;language=python' is used for the mime type, in order to 'text/x-traceback;language=python' is used for the mime type, in order to
provide room for other languages to format their tracebacks differently. provide room for other languages to format their tracebacks differently.
""" """
if err is None:
raise ValueError("err may not be None")
exctype, value, tb = err def __init__(self, err, test, capture_locals=False):
# Skip test runner traceback levels """Create a TracebackContent for ``err``.
if StackLinesContent.HIDE_INTERNAL_STACK:
while tb and '__unittest' in tb.tb_frame.f_globals:
tb = tb.tb_next
# testtools customization. When str is unicode (e.g. IronPython, :param err: An exc_info error tuple.
# Python 3), traceback.format_exception_only returns unicode. For Python 2, :param test: A test object used to obtain failureException.
# it returns bytes. We need to guarantee unicode. :param capture_locals: If true, show locals in the traceback.
if str_is_unicode: """
format_exception_only = traceback.format_exception_only if err is None:
else: raise ValueError("err may not be None")
format_exception_only = _format_exception_only
limit = None exctype, value, tb = err
# Disabled due to https://bugs.launchpad.net/testtools/+bug/1188420 # Skip test runner traceback levels
if (False if StackLinesContent.HIDE_INTERNAL_STACK:
and StackLinesContent.HIDE_INTERNAL_STACK while tb and '__unittest' in tb.tb_frame.f_globals:
and test.failureException tb = tb.tb_next
and isinstance(value, test.failureException)):
# Skip assert*() traceback levels
limit = 0
while tb and not self._is_relevant_tb_level(tb):
limit += 1
tb = tb.tb_next
prefix = _TB_HEADER limit = None
stack_lines = traceback.extract_tb(tb, limit) # Disabled due to https://bugs.launchpad.net/testtools/+bug/1188420
postfix = ''.join(format_exception_only(exctype, value)) if (False
and StackLinesContent.HIDE_INTERNAL_STACK
and test.failureException
and isinstance(value, test.failureException)):
# Skip assert*() traceback levels
limit = 0
while tb and not self._is_relevant_tb_level(tb):
limit += 1
tb = tb.tb_next
return StackLinesContent(stack_lines, prefix, postfix) stack_lines = list(traceback.TracebackException(exctype, value, tb,
limit=limit, capture_locals=capture_locals).format())
content_type = ContentType('text', 'x-traceback',
{"language": "python", "charset": "utf8"})
super(TracebackContent, self).__init__(
content_type, lambda: [x.encode('utf8') for x in stack_lines])
def StacktraceContent(prefix_content="", postfix_content=""): def StacktraceContent(prefix_content="", postfix_content=""):
@ -232,22 +221,20 @@ def StacktraceContent(prefix_content="", postfix_content=""):
:param prefix_content: A unicode string to add before the stack lines. :param prefix_content: A unicode string to add before the stack lines.
:param postfix_content: A unicode string to add after the stack lines. :param postfix_content: A unicode string to add after the stack lines.
""" """
stack = inspect.stack()[1:] stack = traceback.walk_stack(None)
def filter_stack(stack):
if StackLinesContent.HIDE_INTERNAL_STACK: # Discard the filter_stack frame.
limit = 1 next(stack)
while limit < len(stack) and '__unittest' not in stack[limit][0].f_globals: # Discard the StacktraceContent frame.
limit += 1 next(stack)
else: for f, f_lineno in stack:
limit = -1 if StackLinesContent.HIDE_INTERNAL_STACK:
if '__unittest' in f.f_globals:
frames_only = [line[0] for line in stack[:limit]] return
processed_stack = [ ] yield f, f_lineno
for frame in reversed(frames_only): extract = traceback.StackSummary.extract(filter_stack(stack))
filename, line, function, context, _ = inspect.getframeinfo(frame) extract.reverse()
context = ''.join(context) return StackLinesContent(extract, prefix_content, postfix_content)
processed_stack.append((filename, line, function, context))
return StackLinesContent(processed_stack, prefix_content, postfix_content)
def json_content(json_data): def json_content(json_data):

View File

@ -11,10 +11,11 @@ For instance, to run the testtools test suite.
import argparse import argparse
from functools import partial from functools import partial
import os.path import os.path
import unittest2 as unittest
import sys import sys
from extras import safe_hasattr from extras import safe_hasattr, try_imports
# To let setup.py work, make this a conditional import.
unittest = try_imports(['unittest2', 'unittest'])
from testtools import TextTestResult, testcase from testtools import TextTestResult, testcase
from testtools.compat import classtypes, istext, unicode_output_stream from testtools.compat import classtypes, istext, unicode_output_stream
@ -67,18 +68,20 @@ class TestToolsTestRunner(object):
""" A thunk object to support unittest.TestProgram.""" """ A thunk object to support unittest.TestProgram."""
def __init__(self, verbosity=None, failfast=None, buffer=None, def __init__(self, verbosity=None, failfast=None, buffer=None,
stdout=None): stdout=None, tb_locals=False, **kwargs):
"""Create a TestToolsTestRunner. """Create a TestToolsTestRunner.
:param verbosity: Ignored. :param verbosity: Ignored.
:param failfast: Stop running tests at the first failure. :param failfast: Stop running tests at the first failure.
:param buffer: Ignored. :param buffer: Ignored.
:param stdout: Stream to use for stdout. :param stdout: Stream to use for stdout.
:param tb_locals: If True include local variables in tracebacks.
""" """
self.failfast = failfast self.failfast = failfast
if stdout is None: if stdout is None:
stdout = sys.stdout stdout = sys.stdout
self.stdout = stdout self.stdout = stdout
self.tb_locals = tb_locals
def list(self, test, loader): def list(self, test, loader):
"""List the tests that would be run if test() was run.""" """List the tests that would be run if test() was run."""
@ -94,7 +97,8 @@ class TestToolsTestRunner(object):
def run(self, test): def run(self, test):
"Run the given test case or test suite." "Run the given test case or test suite."
result = TextTestResult( result = TextTestResult(
unicode_output_stream(self.stdout), failfast=self.failfast) unicode_output_stream(self.stdout), failfast=self.failfast,
tb_locals=self.tb_locals)
result.startTestRun() result.startTestRun()
try: try:
return test.run(result) return test.run(result)
@ -127,7 +131,7 @@ class TestProgram(unittest.TestProgram):
def __init__(self, module=__name__, defaultTest=None, argv=None, def __init__(self, module=__name__, defaultTest=None, argv=None,
testRunner=None, testLoader=defaultTestLoader, testRunner=None, testLoader=defaultTestLoader,
exit=True, verbosity=1, failfast=None, catchbreak=None, exit=True, verbosity=1, failfast=None, catchbreak=None,
buffer=None, stdout=None): buffer=None, stdout=None, tb_locals=False):
if module == __name__: if module == __name__:
self.module = None self.module = None
elif istext(module): elif istext(module):
@ -147,6 +151,7 @@ class TestProgram(unittest.TestProgram):
self.catchbreak = catchbreak self.catchbreak = catchbreak
self.verbosity = verbosity self.verbosity = verbosity
self.buffer = buffer self.buffer = buffer
self.tb_locals = tb_locals
self.defaultTest = defaultTest self.defaultTest = defaultTest
# XXX: Local edit (see http://bugs.python.org/issue22860) # XXX: Local edit (see http://bugs.python.org/issue22860)
self.listtests = False self.listtests = False
@ -219,10 +224,18 @@ class TestProgram(unittest.TestProgram):
if self.testRunner is None: if self.testRunner is None:
self.testRunner = TestToolsTestRunner self.testRunner = TestToolsTestRunner
try: try:
testRunner = self.testRunner(verbosity=self.verbosity, try:
failfast=self.failfast, testRunner = self.testRunner(verbosity=self.verbosity,
buffer=self.buffer, failfast=self.failfast,
stdout=self.stdout) buffer=self.buffer,
stdout=self.stdout,
tb_locals=self.tb_locals)
except TypeError:
# didn't accept the tb_locals parameter
testRunner = self.testRunner(verbosity=self.verbosity,
failfast=self.failfast,
buffer=self.buffer,
stdout=self.stdout)
except TypeError: except TypeError:
# didn't accept the verbosity, buffer, failfast or stdout arguments # didn't accept the verbosity, buffer, failfast or stdout arguments
# Try with the prior contract # Try with the prior contract

View File

@ -103,6 +103,8 @@ class RunTest(object):
self.result = result self.result = result
try: try:
self._exceptions = [] self._exceptions = []
self.case.__testtools_tb_locals__ = getattr(
result, 'tb_locals', False)
self._run_core() self._run_core()
if self._exceptions: if self._exceptions:
# One or more caught exceptions, now trigger the test's # One or more caught exceptions, now trigger the test's

View File

@ -24,8 +24,10 @@ import types
from extras import ( from extras import (
safe_hasattr, safe_hasattr,
try_import, try_import,
try_imports,
) )
import unittest2 as unittest # To let setup.py work, make this a conditional import.
unittest = try_imports(['unittest2', 'unittest'])
from testtools import ( from testtools import (
content, content,
@ -585,7 +587,9 @@ class TestCase(unittest.TestCase):
tb_label = '%s-%d' % (tb_label, tb_id) tb_label = '%s-%d' % (tb_label, tb_id)
if tb_label not in self.getDetails(): if tb_label not in self.getDetails():
break break
self.addDetail(tb_label, content.TracebackContent(exc_info, self)) self.addDetail(tb_label, content.TracebackContent(
exc_info, self, capture_locals=getattr(
self, '__testtools_tb_locals__', False)))
@staticmethod @staticmethod
def _report_unexpected_success(self, result, err): def _report_unexpected_success(self, result, err):

View File

@ -80,12 +80,13 @@ class TestResult(unittest.TestResult):
:ivar skip_reasons: A dict of skip-reasons -> list of tests. See addSkip. :ivar skip_reasons: A dict of skip-reasons -> list of tests. See addSkip.
""" """
def __init__(self, failfast=False): def __init__(self, failfast=False, tb_locals=False):
# startTestRun resets all attributes, and older clients don't know to # startTestRun resets all attributes, and older clients don't know to
# call startTestRun, so it is called once here. # call startTestRun, so it is called once here.
# Because subclasses may reasonably not expect this, we call the # Because subclasses may reasonably not expect this, we call the
# specific version we want to run. # specific version we want to run.
self.failfast = failfast self.failfast = failfast
self.tb_locals = tb_locals
TestResult.startTestRun(self) TestResult.startTestRun(self)
def addExpectedFailure(self, test, err=None, details=None): def addExpectedFailure(self, test, err=None, details=None):
@ -174,7 +175,8 @@ class TestResult(unittest.TestResult):
def _err_details_to_string(self, test, err=None, details=None): def _err_details_to_string(self, test, err=None, details=None):
"""Convert an error in exc_info form or a contents dict to a string.""" """Convert an error in exc_info form or a contents dict to a string."""
if err is not None: if err is not None:
return TracebackContent(err, test).as_text() return TracebackContent(
err, test, capture_locals=self.tb_locals).as_text()
return _details_to_str(details, special='traceback') return _details_to_str(details, special='traceback')
def _exc_info_to_unicode(self, err, test): def _exc_info_to_unicode(self, err, test):
@ -201,8 +203,9 @@ class TestResult(unittest.TestResult):
pristine condition ready for use in another test run. Note that this pristine condition ready for use in another test run. Note that this
is different from Python 2.7's startTestRun, which does nothing. is different from Python 2.7's startTestRun, which does nothing.
""" """
# failfast is reset by the super __init__, so stash it. # failfast and tb_locals are reset by the super __init__, so save them.
failfast = self.failfast failfast = self.failfast
tb_locals = self.tb_locals
super(TestResult, self).__init__() super(TestResult, self).__init__()
self.skip_reasons = {} self.skip_reasons = {}
self.__now = None self.__now = None
@ -212,6 +215,8 @@ class TestResult(unittest.TestResult):
self.unexpectedSuccesses = [] self.unexpectedSuccesses = []
self.failfast = failfast self.failfast = failfast
# -- End: As per python 2.7 -- # -- End: As per python 2.7 --
# -- Python 3.5
self.tb_locals = tb_locals
def stopTestRun(self): def stopTestRun(self):
"""Called after a test run completes """Called after a test run completes
@ -875,9 +880,10 @@ class MultiTestResult(TestResult):
class TextTestResult(TestResult): class TextTestResult(TestResult):
"""A TestResult which outputs activity to a text stream.""" """A TestResult which outputs activity to a text stream."""
def __init__(self, stream, failfast=False): def __init__(self, stream, failfast=False, tb_locals=False):
"""Construct a TextTestResult writing to stream.""" """Construct a TextTestResult writing to stream."""
super(TextTestResult, self).__init__(failfast=failfast) super(TextTestResult, self).__init__(
failfast=failfast, tb_locals=tb_locals)
self.stream = stream self.stream = stream
self.sep1 = '=' * 70 + '\n' self.sep1 = '=' * 70 + '\n'
self.sep2 = '-' * 70 + '\n' self.sep2 = '-' * 70 + '\n'
@ -1642,7 +1648,8 @@ class TestByTestResult(TestResult):
def _err_to_details(self, test, err, details): def _err_to_details(self, test, err, details):
if details: if details:
return details return details
return {'traceback': TracebackContent(err, test)} return {'traceback': TracebackContent(
err, test, capture_locals=self.tb_locals)}
def addSuccess(self, test, details=None): def addSuccess(self, test, details=None):
super(TestByTestResult, self).addSuccess(test) super(TestByTestResult, self).addSuccess(test)

View File

@ -3,7 +3,7 @@
"""Tests for miscellaneous compatibility functions""" """Tests for miscellaneous compatibility functions"""
import io import io
import linecache import linecache2 as linecache
import os import os
import sys import sys
import tempfile import tempfile
@ -13,11 +13,6 @@ import testtools
from testtools.compat import ( from testtools.compat import (
_b, _b,
_detect_encoding,
_format_exc_info,
_format_exception_only,
_format_stack_list,
_get_source_encoding,
_u, _u,
reraise, reraise,
str_is_unicode, str_is_unicode,
@ -34,161 +29,6 @@ from testtools.matchers import (
) )
class TestDetectEncoding(testtools.TestCase):
"""Test detection of Python source encodings"""
def _check_encoding(self, expected, lines, possibly_invalid=False):
"""Check lines are valid Python and encoding is as expected"""
if not possibly_invalid:
compile(_b("".join(lines)), "<str>", "exec")
encoding = _detect_encoding(lines)
self.assertEqual(expected, encoding,
"Encoding %r expected but got %r from lines %r" %
(expected, encoding, lines))
def test_examples_from_pep(self):
"""Check the examples given in PEP 263 all work as specified
See 'Examples' section of <http://www.python.org/dev/peps/pep-0263/>
"""
# With interpreter binary and using Emacs style file encoding comment:
self._check_encoding("latin-1", (
"#!/usr/bin/python\n",
"# -*- coding: latin-1 -*-\n",
"import os, sys\n"))
self._check_encoding("iso-8859-15", (
"#!/usr/bin/python\n",
"# -*- coding: iso-8859-15 -*-\n",
"import os, sys\n"))
self._check_encoding("ascii", (
"#!/usr/bin/python\n",
"# -*- coding: ascii -*-\n",
"import os, sys\n"))
# Without interpreter line, using plain text:
self._check_encoding("utf-8", (
"# This Python file uses the following encoding: utf-8\n",
"import os, sys\n"))
# Text editors might have different ways of defining the file's
# encoding, e.g.
self._check_encoding("latin-1", (
"#!/usr/local/bin/python\n",
"# coding: latin-1\n",
"import os, sys\n"))
# Without encoding comment, Python's parser will assume ASCII text:
self._check_encoding("ascii", (
"#!/usr/local/bin/python\n",
"import os, sys\n"))
# Encoding comments which don't work:
# Missing "coding:" prefix:
self._check_encoding("ascii", (
"#!/usr/local/bin/python\n",
"# latin-1\n",
"import os, sys\n"))
# Encoding comment not on line 1 or 2:
self._check_encoding("ascii", (
"#!/usr/local/bin/python\n",
"#\n",
"# -*- coding: latin-1 -*-\n",
"import os, sys\n"))
# Unsupported encoding:
self._check_encoding("ascii", (
"#!/usr/local/bin/python\n",
"# -*- coding: utf-42 -*-\n",
"import os, sys\n"),
possibly_invalid=True)
def test_bom(self):
"""Test the UTF-8 BOM counts as an encoding declaration"""
self._check_encoding("utf-8", (
"\xef\xbb\xbfimport sys\n",
))
self._check_encoding("utf-8", (
"\xef\xbb\xbf# File encoding: utf-8\n",
))
self._check_encoding("utf-8", (
'\xef\xbb\xbf"""Module docstring\n',
'\xef\xbb\xbfThat should just be a ZWNB"""\n'))
self._check_encoding("latin-1", (
'"""Is this coding: latin-1 or coding: utf-8 instead?\n',
'\xef\xbb\xbfThose should be latin-1 bytes"""\n'))
self._check_encoding("utf-8", (
"\xef\xbb\xbf# Is the coding: utf-8 or coding: euc-jp instead?\n",
'"""Module docstring say \xe2\x98\x86"""\n'),
possibly_invalid=True)
def test_multiple_coding_comments(self):
"""Test only the first of multiple coding declarations counts"""
self._check_encoding("iso-8859-1", (
"# Is the coding: iso-8859-1\n",
"# Or is it coding: iso-8859-2\n"),
possibly_invalid=True)
self._check_encoding("iso-8859-1", (
"#!/usr/bin/python\n",
"# Is the coding: iso-8859-1\n",
"# Or is it coding: iso-8859-2\n"))
self._check_encoding("iso-8859-1", (
"# Is the coding: iso-8859-1 or coding: iso-8859-2\n",
"# Or coding: iso-8859-3 or coding: iso-8859-4\n"),
possibly_invalid=True)
self._check_encoding("iso-8859-2", (
"# Is the coding iso-8859-1 or coding: iso-8859-2\n",
"# Spot the missing colon above\n"))
class TestGetSourceEncoding(testtools.TestCase):
"""Test reading and caching the encodings of source files"""
def setUp(self):
testtools.TestCase.setUp(self)
dir = tempfile.mkdtemp()
self.addCleanup(os.rmdir, dir)
self.filename = os.path.join(dir, self.id().rsplit(".", 1)[1] + ".py")
self._written = False
def put_source(self, text):
f = open(self.filename, "w")
try:
f.write(text)
finally:
f.close()
if not self._written:
self._written = True
self.addCleanup(os.remove, self.filename)
self.addCleanup(linecache.cache.pop, self.filename, None)
def test_nonexistant_file_as_ascii(self):
"""When file can't be found, the encoding should default to ascii"""
self.assertEquals("ascii", _get_source_encoding(self.filename))
def test_encoding_is_cached(self):
"""The encoding should stay the same if the cache isn't invalidated"""
self.put_source(
"# coding: iso-8859-13\n"
"import os\n")
self.assertEquals("iso-8859-13", _get_source_encoding(self.filename))
self.put_source(
"# coding: rot-13\n"
"vzcbeg bf\n")
self.assertEquals("iso-8859-13", _get_source_encoding(self.filename))
def test_traceback_rechecks_encoding(self):
"""A traceback function checks the cache and resets the encoding"""
self.put_source(
"# coding: iso-8859-8\n"
"import os\n")
self.assertEquals("iso-8859-8", _get_source_encoding(self.filename))
self.put_source(
"# coding: utf-8\n"
"import os\n")
try:
exec (compile("raise RuntimeError\n", self.filename, "exec"))
except RuntimeError:
traceback.extract_tb(sys.exc_info()[2])
else:
self.fail("RuntimeError not raised")
self.assertEquals("utf-8", _get_source_encoding(self.filename))
class _FakeOutputStream(object): class _FakeOutputStream(object):
"""A simple file-like object for testing""" """A simple file-like object for testing"""
@ -453,151 +293,6 @@ class TestReraise(testtools.TestCase):
self.assertRaises(CustomException, reraise, *_exc_info) self.assertRaises(CustomException, reraise, *_exc_info)
class Python2CompatibilityTests(testtools.TestCase):
def setUp(self):
super(Python2CompatibilityTests, self).setUp()
if sys.version[0] >= '3':
self.skip("These tests are only applicable to python 2.")
class TestExceptionFormatting(Python2CompatibilityTests):
"""Test the _format_exception_only function."""
def _assert_exception_format(self, eclass, evalue, expected):
actual = _format_exception_only(eclass, evalue)
self.assertThat(actual, Equals(expected))
self.assertThat(''.join(actual), IsInstance(unicode))
def test_supports_string_exception(self):
self._assert_exception_format(
"String_Exception",
None,
[_u("String_Exception\n")]
)
def test_supports_regular_exception(self):
self._assert_exception_format(
RuntimeError,
RuntimeError("Something went wrong"),
[_u("RuntimeError: Something went wrong\n")]
)
def test_supports_unprintable_exceptions(self):
"""Verify support for exception classes that raise an exception when
__unicode__ or __str__ is called.
"""
class UnprintableException(Exception):
def __str__(self):
raise Exception()
def __unicode__(self):
raise Exception()
self._assert_exception_format(
UnprintableException,
UnprintableException("Foo"),
[_u("UnprintableException: <unprintable UnprintableException object>\n")]
)
def test_supports_exceptions_with_no_string_value(self):
class NoStringException(Exception):
def __str__(self):
return ""
def __unicode__(self):
return _u("")
self._assert_exception_format(
NoStringException,
NoStringException("Foo"),
[_u("NoStringException\n")]
)
def test_supports_strange_syntax_error(self):
"""Test support for syntax errors with unusual number of arguments"""
self._assert_exception_format(
SyntaxError,
SyntaxError("Message"),
[_u("SyntaxError: Message\n")]
)
def test_supports_syntax_error(self):
self._assert_exception_format(
SyntaxError,
SyntaxError(
"Some Syntax Message",
(
"/path/to/file",
12,
2,
"This is the line of code",
)
),
[
_u(' File "/path/to/file", line 12\n'),
_u(' This is the line of code\n'),
_u(' ^\n'),
_u('SyntaxError: Some Syntax Message\n'),
]
)
class StackListFormattingTests(Python2CompatibilityTests):
"""Test the _format_stack_list function."""
def _assert_stack_format(self, stack_lines, expected_output):
actual = _format_stack_list(stack_lines)
self.assertThat(actual, Equals([expected_output]))
def test_single_complete_stack_line(self):
stack_lines = [(
'/path/to/filename',
12,
'func_name',
'some_code()',
)]
expected = \
_u(' File "/path/to/filename", line 12, in func_name\n' \
' some_code()\n')
self._assert_stack_format(stack_lines, expected)
def test_single_stack_line_no_code(self):
stack_lines = [(
'/path/to/filename',
12,
'func_name',
None
)]
expected = _u(' File "/path/to/filename", line 12, in func_name\n')
self._assert_stack_format(stack_lines, expected)
class FormatExceptionInfoTests(Python2CompatibilityTests):
def test_individual_functions_called(self):
self.patch(
testtools.compat,
'_format_stack_list',
lambda stack_list: [_u("format stack list called\n")]
)
self.patch(
testtools.compat,
'_format_exception_only',
lambda etype, evalue: [_u("format exception only called\n")]
)
result = _format_exc_info(None, None, None)
expected = [
_u("Traceback (most recent call last):\n"),
_u("format stack list called\n"),
_u("format exception only called\n"),
]
self.assertThat(expected, Equals(result))
def test_suite(): def test_suite():
from unittest import TestLoader from unittest import TestLoader
return TestLoader().loadTestsFromName(__name__) return TestLoader().loadTestsFromName(__name__)

View File

@ -278,24 +278,20 @@ class TestStacktraceContent(TestCase):
content = StacktraceContent() content = StacktraceContent()
content_type = ContentType("text", "x-traceback", content_type = ContentType("text", "x-traceback",
{"language": "python", "charset": "utf8"}) {"language": "python", "charset": "utf8"})
self.assertEqual(content_type, content.content_type) self.assertEqual(content_type, content.content_type)
def test_prefix_is_used(self): def test_prefix_is_used(self):
prefix = self.getUniqueString() prefix = self.getUniqueString()
actual = StacktraceContent(prefix_content=prefix).as_text() actual = StacktraceContent(prefix_content=prefix).as_text()
self.assertTrue(actual.startswith(prefix)) self.assertTrue(actual.startswith(prefix))
def test_postfix_is_used(self): def test_postfix_is_used(self):
postfix = self.getUniqueString() postfix = self.getUniqueString()
actual = StacktraceContent(postfix_content=postfix).as_text() actual = StacktraceContent(postfix_content=postfix).as_text()
self.assertTrue(actual.endswith(postfix)) self.assertTrue(actual.endswith(postfix))
def test_top_frame_is_skipped_when_no_stack_is_specified(self): def test_top_frame_is_skipped_when_no_stack_is_specified(self):
actual = StacktraceContent().as_text() actual = StacktraceContent().as_text()
self.assertTrue('testtools/content.py' not in actual) self.assertTrue('testtools/content.py' not in actual)

View File

@ -307,6 +307,18 @@ testtools.resourceexample.TestFoo.test_foo
self.assertThat( self.assertThat(
stdout.getDetails()['stdout'].as_text(), Contains('Ran 1 test')) stdout.getDetails()['stdout'].as_text(), Contains('Ran 1 test'))
def test_run_locals(self):
stdout = self.useFixture(fixtures.StringStream('stdout'))
class Failing(TestCase):
def test_a(self):
a = 1
self.fail('a')
runner = run.TestToolsTestRunner(tb_locals=True, stdout=stdout.stream)
runner.run(Failing('test_a'))
self.assertThat(
stdout.getDetails()['stdout'].as_text(), Contains('a = 1'))
def test_stdout_honoured(self): def test_stdout_honoured(self):
self.useFixture(SampleTestFixture()) self.useFixture(SampleTestFixture())
tests = [] tests = []

View File

@ -95,6 +95,7 @@ from testtools.testresult.real import (
def make_erroring_test(): def make_erroring_test():
class Test(TestCase): class Test(TestCase):
def error(self): def error(self):
a = 1
1/0 1/0
return Test("error") return Test("error")
@ -1163,6 +1164,32 @@ class TestTestResult(TestCase):
self.assertEqual( self.assertEqual(
TracebackContent(exc_info, test).as_text(), text_traceback) TracebackContent(exc_info, test).as_text(), text_traceback)
def test_traceback_with_locals(self):
result = self.makeResult()
result.tb_locals = True
test = make_erroring_test()
test.run(result)
self.assertThat(
result.errors[0][1],
DocTestMatches(
'Traceback (most recent call last):\n'
' File "...testtools...runtest.py", line ..., in _run_user\n'
' return fn(*args, **kwargs)\n'
' args = ...\n'
' fn = ...\n'
' kwargs = ...\n'
' self = ...\n'
' File "...testtools...testcase.py", line ..., in _run_test_method\n'
' return self._get_test_method()()\n'
' result = ...\n'
' self = ...\n'
' File "...testtools...tests...test_testresult.py", line ..., in error\n'
' 1/0\n'
' a = 1\n'
' self = ...\n'
'ZeroDivisionError: ...\n',
doctest.ELLIPSIS | doctest.REPORT_UDIFF))
class TestMultiTestResult(TestCase): class TestMultiTestResult(TestCase):
"""Tests for 'MultiTestResult'.""" """Tests for 'MultiTestResult'."""
@ -2445,8 +2472,7 @@ class TestNonAsciiResults(TestCase):
if str_is_unicode: if str_is_unicode:
output_text = example_text output_text = example_text
else: else:
output_text = example_text.encode("shift_jis").decode( output_text = "b%r" % example_text.encode("shift_jis")
_get_exception_encoding(), "replace")
self.assertIn(self._as_output("AssertionError: %s" % output_text), self.assertIn(self._as_output("AssertionError: %s" % output_text),
textoutput) textoutput)
@ -2515,19 +2541,6 @@ class TestNonAsciiResults(TestCase):
textoutput = self._test_external_case("raise SyntaxError(3, 2, 1)") textoutput = self._test_external_case("raise SyntaxError(3, 2, 1)")
self.assertIn(self._as_output("\nSyntaxError: "), textoutput) self.assertIn(self._as_output("\nSyntaxError: "), textoutput)
def test_syntax_error_import_binary(self):
"""Importing a binary file shouldn't break SyntaxError formatting"""
self._setup_external_case("import bad")
f = open(os.path.join(self.dir, "bad.py"), "wb")
try:
f.write(_b("x\x9c\xcb*\xcd\xcb\x06\x00\x04R\x01\xb9"))
finally:
f.close()
textoutput = self._run_external_case()
matches_error = MatchesAny(
Contains('\nTypeError: '), Contains('\nSyntaxError: '))
self.assertThat(textoutput, matches_error)
def test_syntax_error_line_iso_8859_1(self): def test_syntax_error_line_iso_8859_1(self):
"""Syntax error on a latin-1 line shows the line decoded""" """Syntax error on a latin-1 line shows the line decoded"""
text, raw = self._get_sample_text("iso-8859-1") text, raw = self._get_sample_text("iso-8859-1")

View File

@ -179,16 +179,31 @@ class TestConcurrentStreamTestSuiteRun(TestCase):
suite.run(result) suite.run(result)
events = result._events events = result._events
# Check the traceback loosely. # Check the traceback loosely.
self.assertThat(events[1][6].decode('utf8'), DocTestMatches("""\ self.assertEqual(events[1][6].decode('utf8'),
Traceback (most recent call last): "Traceback (most recent call last):\n")
self.assertThat(events[2][6].decode('utf8'), DocTestMatches("""\
File "...testtools/testsuite.py", line ..., in _run_test File "...testtools/testsuite.py", line ..., in _run_test
test.run(process_result) test.run(process_result)
""", doctest.ELLIPSIS))
self.assertThat(events[3][6].decode('utf8'), DocTestMatches("""\
TypeError: run() takes ...1 ...argument...2...given... TypeError: run() takes ...1 ...argument...2...given...
""", doctest.ELLIPSIS)) """, doctest.ELLIPSIS))
events = [event[0:10] + (None,) for event in events] events = [event[0:10] + (None,) for event in events]
events[1] = events[1][:6] + (None,) + events[1][7:] events[1] = events[1][:6] + (None,) + events[1][7:]
events[2] = events[2][:6] + (None,) + events[2][7:]
events[3] = events[3][:6] + (None,) + events[3][7:]
self.assertEqual([ self.assertEqual([
('status', "broken-runner-'0'", 'inprogress', None, True, None, None, False, None, _u('0'), None), ('status', "broken-runner-'0'", 'inprogress', None, True, None, None, False, None, _u('0'), None),
('status', "broken-runner-'0'", None, None, True, 'traceback', None,
False,
'text/x-traceback; charset="utf8"; language="python"',
'0',
None),
('status', "broken-runner-'0'", None, None, True, 'traceback', None,
False,
'text/x-traceback; charset="utf8"; language="python"',
'0',
None),
('status', "broken-runner-'0'", None, None, True, 'traceback', None, ('status', "broken-runner-'0'", None, None, True, 'traceback', None,
True, True,
'text/x-traceback; charset="utf8"; language="python"', 'text/x-traceback; charset="utf8"; language="python"',

View File

@ -14,10 +14,10 @@ __all__ = [
import sys import sys
import threading import threading
import unittest import unittest
import unittest2
from extras import safe_hasattr, try_imports from extras import safe_hasattr, try_imports
# This is just to let setup.py work, as testtools is imported in setup.py.
unittest2 = try_imports(['unittest2', 'unittest'])
Queue = try_imports(['Queue.Queue', 'queue.Queue']) Queue = try_imports(['Queue.Queue', 'queue.Queue'])
import testtools import testtools