Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overhaul exception reporting. #129

Merged
merged 1 commit into from
Mar 9, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 15 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ Changes and improvements to testtools_, grouped by release.
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
~~~~~

Expand Down
7 changes: 7 additions & 0 deletions doc/for-framework-folk.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
=================

Expand Down
4 changes: 3 additions & 1 deletion doc/for-test-authors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@ installed or have Python 2.7 or later, and then run::

$ 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

which describes the options available to ``testtools.run``.

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
of them will happily run testtools tests. In particular:
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ def get_long_description():
# 'mimeparse' has not been uploaded by the maintainer with Python3 compat
# but someone kindly uploaded a fixed version as 'python-mimeparse'.
'python-mimeparse',
'unittest2>=0.8.0',
'unittest2>=1.0.0',
'traceback2',
]


Expand Down
167 changes: 3 additions & 164 deletions testtools/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@

import codecs
import io
import linecache
import locale
import os
import re
import sys
import traceback
import unicodedata

from extras import try_imports
from extras import try_import, try_imports

BytesIO = try_imports(['StringIO.StringIO', 'io.BytesIO'])
StringIO = try_imports(['StringIO.StringIO', 'io.StringIO'])
# To let setup.py work, make this a conditional import.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does that mean exactly? What does setup.py do if it is not present? Do we have to do this for all of our external dependencies?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, unless we alter setup.py to not require importing testtools. Which I'd like to do but is a separate conceptual change. Note that it was already broken before this patch - my other changes don't introduce the damage.

linecache = try_import('linecache2')

try:
from testtools import _compat2x as _compat
Expand Down Expand Up @@ -209,61 +210,6 @@ def unicode_output_stream(stream):
except AttributeError:
pass
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():
"""Return the encoding we expect messages from the OS to be encoded in"""
if os.name == "nt":
Expand All @@ -276,110 +222,3 @@ def _get_exception_encoding():
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)