# coding:utf-8
from __future__ import absolute_import
import inspect
import os
import sys
import traceback
from os import path
from pkg_resources import iter_entry_points
try:
from abc import ABCMeta, abstractmethod
except ImportError:
ABCMeta = type
abstractmethod = lambda x: x
from attest import statistics, utils
from attest.hook import (ExpressionEvaluator,
TestFailure,
COMPILES_AST,
AssertImportHook)
__all__ = (
'TestResult',
'AbstractReporter',
'PlainReporter',
'FancyReporter',
'auto_reporter',
'XmlReporter',
'QuickFixReporter',
'get_reporter_by_name',
'get_all_reporters',
)
[docs]class TestResult(object):
"""Container for result data from running a test.
.. versionadded:: 0.4
"""
def __init__(self, **kwargs):
for key, value in kwargs.iteritems():
setattr(self, key, value)
full_tracebacks = False
debugger = False
#: The test callable.
test = None
#: The exception instance, if the test failed.
error = None
#: The :func:`~sys.exc_info` of the exception, if the test failed.
exc_info = None
#: A list of lines the test printed on the standard output.
stdout = None
#: A list of lines the test printed on the standard error.
stderr = None
def debug(self):
if self.debugger:
import pdb
tb = self.exc_info[2]
pdb.post_mortem(tb)
@property
[docs] def test_name(self):
"""A representative name for the test, similar to its import path.
"""
parts = []
if self.test.__module__ != '__main__':
parts.append(self.test.__module__)
if hasattr(self.test, 'im_class'):
parts.append(self.test.im_class.__name__)
parts.append(self.test.__name__)
return '.'.join(parts)
@property
[docs] def raw_traceback(self):
"""Like :func:`traceback.extract_tb` with uninteresting entries
removed.
.. versionadded:: 0.5
"""
tb = traceback.extract_tb(self.exc_info[2])
if self.full_tracebacks:
return tb
if not COMPILES_AST and AssertImportHook.enabled:
newtb = []
for filename, lineno, funcname, text in tb:
newtb.append((filename, 0, funcname, None))
tb = newtb
clean = []
thisfile = path.abspath(path.dirname(__file__))
for item in tb:
failfile = path.abspath(path.dirname(item[0]))
if failfile != thisfile:
clean.append(item)
return clean
@property
[docs] def traceback(self):
"""The traceback for the exception, if the test failed, cleaned up.
"""
clean = self.raw_traceback
lines = ['Traceback (most recent call last):\n']
lines += traceback.format_list(clean)
msg = str(self.error)
lines += traceback.format_exception_only(self.exc_info[0], msg)
return ''.join(lines)[:-1]
@property
def assertion(self):
if isinstance(self.error, TestFailure):
expressions = str(self.error.value)
return '\n'.join('assert %s' % expr
for expr in expressions.splitlines())
def _test_loader_factory(reporter):
class Loader(object):
def loadTestsFromNames(self, names, module=None):
from .collectors import Tests
Tests(names).run(reporter)
raise SystemExit
return Loader()
[docs]class AbstractReporter(object):
"""Optional base for reporters, serves as documentation and improves
errors for incomplete reporters.
"""
__metaclass__ = ABCMeta
@classmethod
[docs] def test_loader(cls):
"""Creates a basic unittest test loader using this reporter. This
can be used to run tests via distribute, for example::
setup(
test_loader='attest:FancyReporter.test_loader',
test_suite='tests.collection',
)
Now, ``python setup.py -q test`` is equivalent to::
from attest import FancyReporter
from tests import collection
collection.run(FancyReporter)
If you want to run the tests as a normal unittest suite,
try :meth:`~attest.collectors.Tests.test_suite` instead::
setup(
test_suite='tests.collection.test_suite'
)
.. versionadded:: 0.5
"""
return _test_loader_factory(cls)
@abstractmethod
[docs] def begin(self, tests):
"""Called when a test run has begun.
:param tests: The list of test functions we will be running.
"""
raise NotImplementedError
@abstractmethod
[docs] def success(self, result):
"""Called when a test succeeds.
:param result: Result data for the succeeding test.
:type result: :class:`TestResult`
.. versionchanged:: 0.4
Parameters changed to `result`.
"""
raise NotImplementedError
@abstractmethod
[docs] def failure(self, result):
"""Called when a test fails.
:param result: Result data for the failing test.
:type result: :class:`TestResult`
.. versionchanged:: 0.4
Parameters changed to `result`.
"""
raise NotImplementedError
@abstractmethod
[docs] def finished(self):
"""Called when all tests have run."""
raise NotImplementedError
[docs]class PlainReporter(AbstractReporter):
"""Plain text ASCII output for humans."""
def begin(self, tests):
self.total = len(tests)
self.failures = []
def success(self, result):
sys.stdout.write('.')
sys.stdout.flush()
def failure(self, result):
if isinstance(result.error, AssertionError):
sys.stdout.write('F')
else:
sys.stdout.write('E')
sys.stdout.flush()
self.failures.append(result)
def finished(self):
print
print
width, _ = utils.get_terminal_size()
for result in self.failures:
print result.test_name
if result.test.__doc__:
print inspect.getdoc(result.test)
print '-' * width
if result.stdout:
print '->', '\n'.join(result.stdout)
if result.stderr:
print 'E:', '\n'.join(result.stderr)
print result.traceback
print
result.debug()
print 'Failures: %s/%s (%s assertions)' % (len(self.failures),
self.total,
statistics.assertions)
if self.failures:
raise SystemExit(1)
[docs]class FancyReporter(AbstractReporter):
"""Heavily uses ANSI escape codes for fancy output to 256-color
terminals. Progress of running the tests is indicated by a progressbar
and failures are shown with syntax highlighted tracebacks.
:param style:
`Pygments`_ style for tracebacks.
:param verbose:
Report on tests regardless of failure.
:param colorscheme:
If `style` is *light* or *dark*, maps token names to color names.
.. admonition:: Styles
Available styles can be listed with ``pygmentize -L styles``. The
special values ``'light'`` and ``'dark'`` (referring to the terminal's
background) use the 16 system colors rather than assuming a 256-color
terminal.
Defaults to *light* or the environment variable
:envvar:`ATTEST_PYGMENTS_STYLE`.
.. versionchanged:: 0.6 Added the 16-color styles *light* and *dark*
and the complementary `colorscheme` option
.. _Pygments: http://pygments.org/
"""
def __init__(self, style=None, verbose=False, colorscheme=None):
import progressbar, pygments
self.style = style
self.verbose = verbose
self.colorscheme = colorscheme
if style is None:
self.style = os.environ.get('ATTEST_PYGMENTS_STYLE', 'light')
def begin(self, tests):
from progressbar import ProgressBar, Percentage, ETA, SimpleProgress
widgets = ['[', Percentage(), '] ', SimpleProgress(), ' ', ETA()]
self.counter = 0
self.progress = ProgressBar(maxval=len(tests), widgets=widgets)
self.progress.start()
self.passes = []
self.failures = []
def success(self, result):
self.counter += 1
self.progress.update(self.counter)
self.passes.append(result)
def failure(self, result):
self.counter += 1
self.progress.update(self.counter)
self.failures.append(result)
def finished(self):
from pygments.console import colorize
from pygments import highlight
from pygments.lexers import PythonTracebackLexer, PythonLexer
if self.style in ('light', 'dark'):
from pygments.formatters import TerminalFormatter
formatter = TerminalFormatter(bg=self.style)
if self.colorscheme is not None:
from pygments.token import string_to_tokentype
for token, value in self.colorscheme.iteritems():
token = string_to_tokentype(token.capitalize())
formatter.colorscheme[token] = (value, value)
else:
from pygments.formatters import Terminal256Formatter
formatter = Terminal256Formatter(style=self.style)
self.progress.finish()
print
width, _ = utils.get_terminal_size()
def show(result):
print colorize('bold', result.test_name)
if result.test.__doc__:
print inspect.getdoc(result.test)
print colorize('faint', '─' * width)
for line in result.stdout:
print colorize('bold', '→'),
print line
for line in result.stderr:
print colorize('red', '→'),
print line
if self.verbose:
for result in self.passes:
if result.stdout or result.stderr:
show(result)
print
for result in self.failures:
show(result)
print highlight(result.traceback,
PythonTracebackLexer(),
formatter)
if result.assertion is not None:
print highlight(result.assertion, PythonLexer(), formatter)
result.debug()
if self.failures:
failed = colorize('red', str(len(self.failures)))
else:
failed = len(self.failures)
print 'Failures: %s/%s (%s assertions)' % (failed, self.counter,
statistics.assertions)
if self.failures:
raise SystemExit(1)
[docs]def auto_reporter(**opts):
"""Select a reporter based on the target output and installed
dependencies.
This is the default reporter.
:param opts: Passed to :class:`FancyReporter` if it is used.
:rtype:
:class:`FancyReporter` if output is a terminal and the progressbar
and pygments packages are installed, otherwise a
:class:`PlainReporter`.
.. versionchanged:: 0.5
A `test_loader` function attribute similar to
:meth:`AbstractReporter.test_loader`.
"""
if sys.stdout.isatty():
try:
return FancyReporter(**opts)
except ImportError:
pass
return PlainReporter()
auto_reporter.test_loader = lambda: _test_loader_factory(auto_reporter)
[docs]class XmlReporter(AbstractReporter):
"""Report the result of a testrun in an XML format. Not compatible with
JUnit or XUnit.
"""
def __init__(self):
self.escape = __import__('cgi').escape
def begin(self, tests):
print '<?xml version="1.0" encoding="UTF-8"?>'
print '<testreport tests="%d">' % len(tests)
def success(self, result):
print ' <pass name="%s"/>' % result.test_name
def failure(self, result):
if isinstance(result.error, AssertionError):
tag = 'fail'
else:
tag = 'error'
print ' <%s name="%s" type="%s">' % (tag, result.test_name,
result.exc_info[0].__name__)
print self.escape('\n'.join(' ' * 4 + line
for line in
result.traceback.splitlines()),
quote=True)
print ' </%s>' % tag
def finished(self):
print '</testreport>'
[docs]class QuickFixReporter(AbstractReporter):
"""Report failures in a format that's understood by Vim's quickfix
feature.
Write a Makefile that runs your tests with this reporter and
then from Vim you can do ``:mak``. If there's failures, Vim will jump
to the first one by opening the offending file and positioning the
cursor at the relevant line; you can jump between failures with ``:cn``
and ``:cp``. For more information try `:help quickfix
<http://vimdoc.sourceforge.net/htmldoc/quickfix.html>`_.
Example Makefile (remember to indent with tabs not spaces)::
test:
@python runtests.py -rquickfix
.. versionadded:: 0.5
"""
failed = False
def begin(self, tests):
pass
def success(self, result):
pass
def failure(self, result):
self.failed = True
fn, lineno = result.raw_traceback[-1][:2]
type, msg = result.exc_info[0].__name__, str(result.exc_info[1])
if msg:
msg = ': ' + msg
print "%s:%s: %s%s" % (fn, lineno, type, msg)
def finished(self):
if self.failed:
raise SystemExit(1)
[docs]def get_reporter_by_name(name, default='auto'):
"""Get an :class:`AbstractReporter` by name, falling back on a default.
Reporters are registered via setuptools entry points, in the
``'attest.reporters'`` group. A third-party reporter can thus register
itself using this in its :file:`setup.py`::
setup(
entry_points = {
'attest.reporters': [
'name = import.path.to:callable'
]
}
)
Names for the built in reporters:
* ``'fancy'`` — :class:`FancyReporter`
* ``'plain'`` — :class:`PlainReporter`
* ``'quickfix'`` — :class:`QuickFixReporter`
* ``'xml'`` — :class:`XmlReporter`
* ``'auto'`` — :func:`auto_reporter`
:param name: One of the above strings.
:param default:
The fallback reporter if no reporter has the supplied name,
defaulting to ``'auto'``.
:raises KeyError:
If neither the name or the default is a valid name of a reporter.
:rtype: Callable returning an instance of an :class:`AbstractReporter`.
.. versionchanged:: 0.4
Reporters are registered via setuptools entry points.
"""
reporter = None
if name is not None:
reporter = list(iter_entry_points('attest.reporters', name))
if not reporter:
reporter = list(iter_entry_points('attest.reporters', default))
if not reporter:
raise KeyError
return reporter[0].load(require=False)
[docs]def get_all_reporters():
"""Iterable yielding the names of all registered reporters.
.. testsetup::
from attest import get_all_reporters
>>> list(get_all_reporters())
['xml', 'plain', 'quickfix', 'fancy', 'auto']
.. versionadded:: 0.4
"""
for ep in iter_entry_points('attest.reporters'):
yield ep.name