From 624a78d9f8214d21346b7791d3e2f2a57cb26688 Mon Sep 17 00:00:00 2001 From: Jelmer Vernooij Date: Thu, 8 Dec 2011 21:21:59 +0100 Subject: [PATCH] testtools: Update to new upstream revision. --- lib/testtools/NEWS | 41 +++ lib/testtools/doc/for-test-authors.rst | 137 ++++++++- lib/testtools/doc/hacking.rst | 2 +- lib/testtools/setup.py | 5 +- lib/testtools/testtools/compat.py | 5 +- lib/testtools/testtools/content.py | 6 +- lib/testtools/testtools/matchers.py | 253 ++++++++++++++++- lib/testtools/testtools/testcase.py | 10 +- lib/testtools/testtools/testresult/real.py | 57 ++-- .../testtools/tests/test_matchers.py | 260 +++++++++++++++++- .../testtools/tests/test_testresult.py | 20 ++ 11 files changed, 750 insertions(+), 46 deletions(-) diff --git a/lib/testtools/NEWS b/lib/testtools/NEWS index c56bdf7470..2795bae51c 100644 --- a/lib/testtools/NEWS +++ b/lib/testtools/NEWS @@ -9,8 +9,49 @@ NEXT Changes ------- +* ``MatchesAll`` now takes an ``first_only`` keyword argument that changes how + mismatches are displayed. If you were previously passing matchers to + ``MatchesAll`` with keyword arguments, then this change might affect your + test results. (Jonathan Lange) + +Improvements +------------ + +* Actually hide all of the testtools stack for assertion failures. The + previous release promised clean stack, but now we actually provide it. + (Jonathan Lange, #854769) + * Failed equality assertions now line up. (Jonathan Lange, #879339) +* ``MatchesAll`` and ``MatchesListwise`` both take a ``first_only`` keyword + argument. If True, they will report only on the first mismatch they find, + and not continue looking for other possible mismatches. + (Jonathan Lange) + +* New matchers: + + * ``DirContains`` matches the contents of a directory. + (Jonathan Lange, James Westby) + + * ``DirExists`` matches if a directory exists. + (Jonathan Lange, James Westby) + + * ``FileContains`` matches the contents of a file. + (Jonathan Lange, James Westby) + + * ``FileExists`` matches if a file exists. + (Jonathan Lange, James Westby) + + * ``HasPermissions`` matches the permissions of a file. (Jonathan Lange) + + * ``MatchesPredicate`` matches if a predicate is true. (Jonathan Lange) + + * ``PathExists`` matches if a path exists. (Jonathan Lange, James Westby) + + * ``SamePath`` matches if two paths are the same. (Jonathan Lange) + + * ``TarballContains`` matches the contents of a tarball. (Jonathan Lange) + 0.9.12 ~~~~~~ diff --git a/lib/testtools/doc/for-test-authors.rst b/lib/testtools/doc/for-test-authors.rst index 04c4be6b0d..febbb84151 100644 --- a/lib/testtools/doc/for-test-authors.rst +++ b/lib/testtools/doc/for-test-authors.rst @@ -445,6 +445,110 @@ be able to do, if you think about it:: self.assertThat('foo', MatchesRegex('fo+')) +File- and path-related matchers +------------------------------- + +testtools also has a number of matchers to help with asserting things about +the state of the filesystem. + +PathExists +~~~~~~~~~~ + +Matches if a path exists:: + + self.assertThat('/', PathExists()) + + +DirExists +~~~~~~~~~ + +Matches if a path exists and it refers to a directory:: + + # This will pass on most Linux systems. + self.assertThat('/home/', DirExists()) + # This will not + self.assertThat('/home/jml/some-file.txt', DirExists()) + + +FileExists +~~~~~~~~~~ + +Matches if a path exists and it refers to a file (as opposed to a directory):: + + # This will pass on most Linux systems. + self.assertThat('/bin/true', FileExists()) + # This will not. + self.assertThat('/home/', FileExists()) + + +DirContains +~~~~~~~~~~~ + +Matches if the given directory contains the specified files and directories. +Say we have a directory ``foo`` that has the files ``a``, ``b`` and ``c``, +then:: + + self.assertThat('foo', DirContains(['a', 'b', 'c'])) + +will match, but:: + + self.assertThat('foo', DirContains(['a', 'b'])) + +will not. + +The matcher sorts both the input and the list of names we get back from the +filesystem. + +You can use this in a more advanced way, and match the sorted directory +listing against an arbitrary matcher:: + + self.assertThat('foo', DirContains(matcher=Contains('a'))) + + +FileContains +~~~~~~~~~~~~ + +Matches if the given file has the specified contents. Say there's a file +called ``greetings.txt`` with the contents, ``Hello World!``:: + + self.assertThat('greetings.txt', FileContains("Hello World!")) + +will match. + +You can also use this in a more advanced way, and match the contents of the +file against an arbitrary matcher:: + + self.assertThat('greetings.txt', FileContains(matcher=Contains('!'))) + + +HasPermissions +~~~~~~~~~~~~~~ + +Used for asserting that a file or directory has certain permissions. Uses +octal-mode permissions for both input and matching. For example:: + + self.assertThat('/tmp', HasPermissions('1777')) + self.assertThat('id_rsa', HasPermissions('0600')) + +This is probably more useful on UNIX systems than on Windows systems. + + +SamePath +~~~~~~~~ + +Matches if two paths actually refer to the same thing. The paths don't have +to exist, but if they do exist, ``SamePath`` will resolve any symlinks.:: + + self.assertThat('somefile', SamePath('childdir/../somefile')) + + +TarballContains +~~~~~~~~~~~~~~~ + +Matches the contents of a tarball. In many ways, much like ``DirContains``, +but instead of matching on ``os.listdir`` matches on ``TarFile.getnames``. + + Combining matchers ------------------ @@ -550,7 +654,11 @@ more information in error messages is a big help. The second reason is that it is sometimes useful to give a name to a set of matchers. ``has_und_at_both_ends`` is a bit contrived, of course, but it is -clear. +clear. The ``FileExists`` and ``DirExists`` matchers included in testtools +are perhaps better real examples. + +If you want only the first mismatch to be reported, pass ``first_only=True`` +as a keyword parameter to ``MatchesAll``. MatchesAny @@ -595,6 +703,9 @@ For example:: This is useful for writing custom, domain-specific matchers. +If you want only the first mismatch to be reported, pass ``first_only=True`` +to ``MatchesListwise``. + MatchesSetwise ~~~~~~~~~~~~~~ @@ -645,6 +756,30 @@ object must equal each attribute of the example object. For example:: is exactly equivalent to ``matcher`` in the previous example. +MatchesPredicate +~~~~~~~~~~~~~~~~ + +Sometimes, all you want to do is create a matcher that matches if a given +function returns True, and mismatches if it returns False. + +For example, you might have an ``is_prime`` function and want to make a +matcher based on it:: + + def test_prime_numbers(self): + IsPrime = MatchesPredicate(is_prime, '%s is not prime.') + self.assertThat(7, IsPrime) + self.assertThat(1983, IsPrime) + # This will fail. + self.assertThat(42, IsPrime) + +Which will produce the error message:: + + Traceback (most recent call last): + File "...", line ..., in test_prime_numbers + self.assertThat(42, IsPrime) + MismatchError: 42 is not prime. + + Raises ~~~~~~ diff --git a/lib/testtools/doc/hacking.rst b/lib/testtools/doc/hacking.rst index b9f5ff22c6..fa67887abd 100644 --- a/lib/testtools/doc/hacking.rst +++ b/lib/testtools/doc/hacking.rst @@ -147,7 +147,7 @@ Release tasks .. _PEP 8: http://www.python.org/dev/peps/pep-0008/ .. _unittest: http://docs.python.org/library/unittest.html -.. _~testtools-dev: https://launchpad.net/~testtools-dev +.. _~testtools-committers: https://launchpad.net/~testtools-committers .. _MIT license: http://www.opensource.org/licenses/mit-license.php .. _Sphinx: http://sphinx.pocoo.org/ .. _restructuredtext: http://docutils.sourceforge.net/rst.html diff --git a/lib/testtools/setup.py b/lib/testtools/setup.py index d07c8f2935..0fabb06693 100755 --- a/lib/testtools/setup.py +++ b/lib/testtools/setup.py @@ -45,7 +45,10 @@ def get_version(): return pkg_info_version revno = get_revno() if revno is None: - return "snapshot" + # Apparently if we just say "snapshot" then distribute won't accept it + # as satisfying versioned dependencies. This is a problem for the + # daily build version. + return "snapshot-%s" % (version,) if phase == 'alpha': # No idea what the next version will be return 'next-r%s' % revno diff --git a/lib/testtools/testtools/compat.py b/lib/testtools/testtools/compat.py index b7e23c8fec..2547b88d59 100644 --- a/lib/testtools/testtools/compat.py +++ b/lib/testtools/testtools/compat.py @@ -128,7 +128,7 @@ else: def _slow_escape(text): - """Escape unicode `text` leaving printable characters unmodified + """Escape unicode ``text`` leaving printable characters unmodified The behaviour emulates the Python 3 implementation of repr, see unicode_repr in unicodeobject.c and isprintable definition. @@ -158,7 +158,8 @@ def _slow_escape(text): def text_repr(text, multiline=None): - """Rich repr for `text` returning unicode, triple quoted if `multiline`""" + """Rich repr for ``text`` returning unicode, triple quoted if ``multiline``. + """ is_py3k = sys.version_info > (3, 0) nl = _isbytes(text) and bytes((0xA,)) or "\n" if multiline is None: diff --git a/lib/testtools/testtools/content.py b/lib/testtools/testtools/content.py index 2c6ed9f586..5da818adb6 100644 --- a/lib/testtools/testtools/content.py +++ b/lib/testtools/testtools/content.py @@ -148,7 +148,7 @@ def content_from_file(path, content_type=None, chunk_size=DEFAULT_CHUNK_SIZE, :param content_type: The type of content. If not specified, defaults to UTF8-encoded text/plain. :param chunk_size: The size of chunks to read from the file. - Defaults to `DEFAULT_CHUNK_SIZE`. + Defaults to ``DEFAULT_CHUNK_SIZE``. :param buffer_now: If True, read the file from disk now and keep it in memory. Otherwise, only read when the content is serialized. """ @@ -177,7 +177,7 @@ def content_from_stream(stream, content_type=None, :param content_type: The type of content. If not specified, defaults to UTF8-encoded text/plain. :param chunk_size: The size of chunks to read from the file. - Defaults to `DEFAULT_CHUNK_SIZE`. + Defaults to ``DEFAULT_CHUNK_SIZE``. :param buffer_now: If True, reads from the stream right now. Otherwise, only reads when the content is serialized. Defaults to False. """ @@ -208,7 +208,7 @@ def attach_file(detailed, path, name=None, content_type=None, chunk_size=DEFAULT_CHUNK_SIZE, buffer_now=True): """Attach a file to this test as a detail. - This is a convenience method wrapping around `addDetail`. + This is a convenience method wrapping around ``addDetail``. Note that unless 'read_now' is explicitly passed in as True, the file *must* exist when the test result is called with the results of this diff --git a/lib/testtools/testtools/matchers.py b/lib/testtools/testtools/matchers.py index 4725265f98..3279306650 100644 --- a/lib/testtools/testtools/matchers.py +++ b/lib/testtools/testtools/matchers.py @@ -16,10 +16,14 @@ __all__ = [ 'AllMatch', 'Annotate', 'Contains', + 'DirExists', 'DocTestMatches', 'EndsWith', 'Equals', + 'FileContains', + 'FileExists', 'GreaterThan', + 'HasPermissions', 'Is', 'IsInstance', 'KeysEqual', @@ -28,21 +32,27 @@ __all__ = [ 'MatchesAny', 'MatchesException', 'MatchesListwise', + 'MatchesPredicate', 'MatchesRegex', 'MatchesSetwise', 'MatchesStructure', 'NotEquals', 'Not', + 'PathExists', 'Raises', 'raises', + 'SamePath', 'StartsWith', + 'TarballContains', ] import doctest import operator from pprint import pformat import re +import os import sys +import tarfile import types from testtools.compat import ( @@ -205,25 +215,25 @@ class _NonManglingOutputChecker(doctest.OutputChecker): """Doctest checker that works with unicode rather than mangling strings This is needed because current Python versions have tried to fix string - encoding related problems, but regressed the default behaviour with unicode - inputs in the process. + encoding related problems, but regressed the default behaviour with + unicode inputs in the process. - In Python 2.6 and 2.7 `OutputChecker.output_difference` is was changed to - return a bytestring encoded as per `sys.stdout.encoding`, or utf-8 if that - can't be determined. Worse, that encoding process happens in the innocent - looking `_indent` global function. Because the `DocTestMismatch.describe` - result may well not be destined for printing to stdout, this is no good - for us. To get a unicode return as before, the method is monkey patched if - `doctest._encoding` exists. + In Python 2.6 and 2.7 ``OutputChecker.output_difference`` is was changed + to return a bytestring encoded as per ``sys.stdout.encoding``, or utf-8 if + that can't be determined. Worse, that encoding process happens in the + innocent looking `_indent` global function. Because the + `DocTestMismatch.describe` result may well not be destined for printing to + stdout, this is no good for us. To get a unicode return as before, the + method is monkey patched if ``doctest._encoding`` exists. Python 3 has a different problem. For some reason both inputs are encoded to ascii with 'backslashreplace', making an escaped string matches its - unescaped form. Overriding the offending `OutputChecker._toAscii` method + unescaped form. Overriding the offending ``OutputChecker._toAscii`` method is sufficient to revert this. """ def _toAscii(self, s): - """Return `s` unchanged rather than mangling it to ascii""" + """Return ``s`` unchanged rather than mangling it to ascii""" return s # Only do this overriding hackery if doctest has a broken _input function @@ -232,7 +242,7 @@ class _NonManglingOutputChecker(doctest.OutputChecker): __f = doctest.OutputChecker.output_difference.im_func __g = dict(__f.func_globals) def _indent(s, indent=4, _pattern=re.compile("^(?!$)", re.MULTILINE)): - """Prepend non-empty lines in `s` with `indent` number of spaces""" + """Prepend non-empty lines in ``s`` with ``indent`` number of spaces""" return _pattern.sub(indent*" ", s) __g["_indent"] = _indent output_difference = __F(__f.func_code, __g, "output_difference") @@ -385,6 +395,39 @@ class _BinaryMismatch(Mismatch): return "%s %s %s" % (left, self._mismatch_string, right) +class MatchesPredicate(Matcher): + """Match if a given function returns True. + + It is reasonably common to want to make a very simple matcher based on a + function that you already have that returns True or False given a single + argument (i.e. a predicate function). This matcher makes it very easy to + do so. e.g.:: + + IsEven = MatchesPredicate(lambda x: x % 2 == 0, '%s is not even') + self.assertThat(4, IsEven) + """ + + def __init__(self, predicate, message): + """Create a ``MatchesPredicate`` matcher. + + :param predicate: A function that takes a single argument and returns + a value that will be interpreted as a boolean. + :param message: A message to describe a mismatch. It will be formatted + with '%' and be given whatever was passed to ``match()``. Thus, it + needs to contain exactly one thing like '%s', '%d' or '%f'. + """ + self.predicate = predicate + self.message = message + + def __str__(self): + return '%s(%r, %r)' % ( + self.__class__.__name__, self.predicate, self.message) + + def match(self, x): + if not self.predicate(x): + return Mismatch(self.message % x) + + class Equals(_BinaryComparison): """Matches if the items are equal.""" @@ -483,8 +526,16 @@ class MatchesAny(object): class MatchesAll(object): """Matches if all of the matchers it is created with match.""" - def __init__(self, *matchers): + def __init__(self, *matchers, **options): + """Construct a MatchesAll matcher. + + Just list the component matchers as arguments in the ``*args`` + style. If you want only the first mismatch to be reported, past in + first_only=True as a keyword argument. By default, all mismatches are + reported. + """ self.matchers = matchers + self.first_only = options.get('first_only', False) def __str__(self): return 'MatchesAll(%s)' % ', '.join(map(str, self.matchers)) @@ -494,6 +545,8 @@ class MatchesAll(object): for matcher in self.matchers: mismatch = matcher.match(matchee) if mismatch is not None: + if self.first_only: + return mismatch results.append(mismatch) if results: return MismatchesAll(results) @@ -784,10 +837,20 @@ class MatchesListwise(object): 1 != 2 2 != 1 ] + >>> matcher = MatchesListwise([Equals(1), Equals(2)], first_only=True) + >>> print (matcher.match([3, 4]).describe()) + 1 != 3 """ - def __init__(self, matchers): + def __init__(self, matchers, first_only=False): + """Construct a MatchesListwise matcher. + + :param matchers: A list of matcher that the matched values must match. + :param first_only: If True, then only report the first mismatch, + otherwise report all of them. Defaults to False. + """ self.matchers = matchers + self.first_only = first_only def match(self, values): mismatches = [] @@ -798,6 +861,8 @@ class MatchesListwise(object): for matcher, value in zip(self.matchers, values): mismatch = matcher.match(value) if mismatch: + if self.first_only: + return mismatch mismatches.append(mismatch) if mismatches: return MismatchesAll(mismatches) @@ -1054,6 +1119,166 @@ class AllMatch(object): return MismatchesAll(mismatches) +def PathExists(): + """Matches if the given path exists. + + Use like this:: + + assertThat('/some/path', PathExists()) + """ + return MatchesPredicate(os.path.exists, "%s does not exist.") + + +def DirExists(): + """Matches if the path exists and is a directory.""" + return MatchesAll( + PathExists(), + MatchesPredicate(os.path.isdir, "%s is not a directory."), + first_only=True) + + +def FileExists(): + """Matches if the given path exists and is a file.""" + return MatchesAll( + PathExists(), + MatchesPredicate(os.path.isfile, "%s is not a file."), + first_only=True) + + +class DirContains(Matcher): + """Matches if the given directory contains files with the given names. + + That is, is the directory listing exactly equal to the given files? + """ + + def __init__(self, filenames=None, matcher=None): + """Construct a ``DirContains`` matcher. + + Can be used in a basic mode where the whole directory listing is + matched against an expected directory listing (by passing + ``filenames``). Can also be used in a more advanced way where the + whole directory listing is matched against an arbitrary matcher (by + passing ``matcher`` instead). + + :param filenames: If specified, match the sorted directory listing + against this list of filenames, sorted. + :param matcher: If specified, match the sorted directory listing + against this matcher. + """ + if filenames == matcher == None: + raise AssertionError( + "Must provide one of `filenames` or `matcher`.") + if None not in (filenames, matcher): + raise AssertionError( + "Must provide either `filenames` or `matcher`, not both.") + if filenames is None: + self.matcher = matcher + else: + self.matcher = Equals(sorted(filenames)) + + def match(self, path): + mismatch = DirExists().match(path) + if mismatch is not None: + return mismatch + return self.matcher.match(sorted(os.listdir(path))) + + +class FileContains(Matcher): + """Matches if the given file has the specified contents.""" + + def __init__(self, contents=None, matcher=None): + """Construct a ``FileContains`` matcher. + + Can be used in a basic mode where the file contents are compared for + equality against the expected file contents (by passing ``contents``). + Can also be used in a more advanced way where the file contents are + matched against an arbitrary matcher (by passing ``matcher`` instead). + + :param contents: If specified, match the contents of the file with + these contents. + :param matcher: If specified, match the contents of the file against + this matcher. + """ + if contents == matcher == None: + raise AssertionError( + "Must provide one of `contents` or `matcher`.") + if None not in (contents, matcher): + raise AssertionError( + "Must provide either `contents` or `matcher`, not both.") + if matcher is None: + self.matcher = Equals(contents) + else: + self.matcher = matcher + + def match(self, path): + mismatch = PathExists().match(path) + if mismatch is not None: + return mismatch + f = open(path) + try: + actual_contents = f.read() + return self.matcher.match(actual_contents) + finally: + f.close() + + def __str__(self): + return "File at path exists and contains %s" % self.contents + + +class TarballContains(Matcher): + """Matches if the given tarball contains the given paths. + + Uses TarFile.getnames() to get the paths out of the tarball. + """ + + def __init__(self, paths): + super(TarballContains, self).__init__() + self.paths = paths + + def match(self, tarball_path): + tarball = tarfile.open(tarball_path) + try: + return Equals(sorted(self.paths)).match(sorted(tarball.getnames())) + finally: + tarball.close() + + +class SamePath(Matcher): + """Matches if two paths are the same. + + That is, the paths are equal, or they point to the same file but in + different ways. The paths do not have to exist. + """ + + def __init__(self, path): + super(SamePath, self).__init__() + self.path = path + + def match(self, other_path): + f = lambda x: os.path.abspath(os.path.realpath(x)) + return Equals(f(self.path)).match(f(other_path)) + + +class HasPermissions(Matcher): + """Matches if a file has the given permissions. + + Permissions are specified and matched as a four-digit octal string. + """ + + def __init__(self, octal_permissions): + """Construct a HasPermissions matcher. + + :param octal_permissions: A four digit octal string, representing the + intended access permissions. e.g. '0775' for rwxrwxr-x. + """ + super(HasPermissions, self).__init__() + self.octal_permissions = octal_permissions + + def match(self, filename): + permissions = oct(os.stat(filename).st_mode)[-4:] + return Equals(self.octal_permissions).match(permissions) + + # Signal that this is part of the testing framework, and that code from this # should not normally appear in tracebacks. __unittest = True diff --git a/lib/testtools/testtools/testcase.py b/lib/testtools/testtools/testcase.py index ee5e296cd4..07278be0e4 100644 --- a/lib/testtools/testtools/testcase.py +++ b/lib/testtools/testtools/testcase.py @@ -113,13 +113,13 @@ def run_test_with(test_runner, **kwargs): def _copy_content(content_object): """Make a copy of the given content object. - The content within `content_object` is iterated and saved. This is useful - when the source of the content is volatile, a log file in a temporary - directory for example. + The content within ``content_object`` is iterated and saved. This is + useful when the source of the content is volatile, a log file in a + temporary directory for example. :param content_object: A `content.Content` instance. :return: A `content.Content` instance with the same mime-type as - `content_object` and a non-volatile copy of its content. + ``content_object`` and a non-volatile copy of its content. """ content_bytes = list(content_object.iter_bytes()) content_callback = lambda: content_bytes @@ -127,7 +127,7 @@ def _copy_content(content_object): def gather_details(source_dict, target_dict): - """Merge the details from `source_dict` into `target_dict`. + """Merge the details from ``source_dict`` into ``target_dict``. :param source_dict: A dictionary of details will be gathered. :param target_dict: A dictionary into which details will be gathered. diff --git a/lib/testtools/testtools/testresult/real.py b/lib/testtools/testtools/testresult/real.py index aec6edb032..a627f0900e 100644 --- a/lib/testtools/testtools/testresult/real.py +++ b/lib/testtools/testtools/testresult/real.py @@ -12,6 +12,7 @@ __all__ = [ import datetime import sys +import traceback import unittest from testtools.compat import all, _format_exc_info, str_is_unicode, _u @@ -35,6 +36,9 @@ class UTC(datetime.tzinfo): utc = UTC() +STDOUT_LINE = '\nStdout:\n%s' +STDERR_LINE = '\nStderr:\n%s' + class TestResult(unittest.TestResult): """Subclass of unittest.TestResult extending the protocol for flexability. @@ -137,22 +141,43 @@ class TestResult(unittest.TestResult): """ return not (self.errors or self.failures or self.unexpectedSuccesses) - if str_is_unicode: - # Python 3 and IronPython strings are unicode, use parent class method - _exc_info_to_unicode = unittest.TestResult._exc_info_to_string - else: - # For Python 2, need to decode components of traceback according to - # their source, so can't use traceback.format_exception - # Here follows a little deep magic to copy the existing method and - # replace the formatter with one that returns unicode instead - from types import FunctionType as __F, ModuleType as __M - __f = unittest.TestResult._exc_info_to_string.im_func - __g = dict(__f.func_globals) - __m = __M("__fake_traceback") - __m.format_exception = _format_exc_info - __g["traceback"] = __m - _exc_info_to_unicode = __F(__f.func_code, __g, "_exc_info_to_unicode") - del __F, __M, __f, __g, __m + def _exc_info_to_unicode(self, err, test): + """Converts a sys.exc_info()-style tuple of values into a string. + + Copied from Python 2.7's unittest.TestResult._exc_info_to_string. + """ + exctype, value, tb = err + # Skip test runner traceback levels + while tb and self._is_relevant_tb_level(tb): + tb = tb.tb_next + + # testtools customization. When str is unicode (e.g. IronPython, + # Python 3), traceback.format_exception returns unicode. For Python 2, + # it returns bytes. We need to guarantee unicode. + if str_is_unicode: + format_exception = traceback.format_exception + else: + format_exception = _format_exc_info + + if test.failureException and isinstance(value, test.failureException): + # Skip assert*() traceback levels + length = self._count_relevant_tb_levels(tb) + msgLines = format_exception(exctype, value, tb, length) + else: + msgLines = format_exception(exctype, value, tb) + + if getattr(self, 'buffer', None): + output = sys.stdout.getvalue() + error = sys.stderr.getvalue() + if output: + if not output.endswith('\n'): + output += '\n' + msgLines.append(STDOUT_LINE % output) + if error: + if not error.endswith('\n'): + error += '\n' + msgLines.append(STDERR_LINE % error) + return ''.join(msgLines) 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.""" diff --git a/lib/testtools/testtools/tests/test_matchers.py b/lib/testtools/testtools/tests/test_matchers.py index f327a34b05..24ec684738 100644 --- a/lib/testtools/testtools/tests/test_matchers.py +++ b/lib/testtools/testtools/tests/test_matchers.py @@ -4,10 +4,15 @@ import doctest import re +import os +import shutil import sys +import tarfile +import tempfile from testtools import ( Matcher, # check that Matcher is exposed at the top level for docs. + skipIf, TestCase, ) from testtools.compat import ( @@ -24,11 +29,16 @@ from testtools.matchers import ( AnnotatedMismatch, _BinaryMismatch, Contains, - Equals, + DirContains, + DirExists, DocTestMatches, DoesNotEndWith, DoesNotStartWith, EndsWith, + Equals, + FileContains, + FileExists, + HasPermissions, KeysEqual, Is, IsInstance, @@ -38,6 +48,7 @@ from testtools.matchers import ( MatchesAll, MatchesException, MatchesListwise, + MatchesPredicate, MatchesRegex, MatchesSetwise, MatchesStructure, @@ -46,9 +57,12 @@ from testtools.matchers import ( MismatchError, Not, NotEquals, + PathExists, Raises, raises, + SamePath, StartsWith, + TarballContains, ) from testtools.tests.helpers import FullStackRunTest @@ -533,10 +547,14 @@ class TestMatchesAllInterface(TestCase, TestMatchersInterface): ("MatchesAll(NotEquals(1), NotEquals(2))", MatchesAll(NotEquals(1), NotEquals(2)))] - describe_examples = [("""Differences: [ + describe_examples = [ + ("""Differences: [ 1 == 1 ]""", - 1, MatchesAll(NotEquals(1), NotEquals(2)))] + 1, MatchesAll(NotEquals(1), NotEquals(2))), + ("1 == 1", 1, + MatchesAll(NotEquals(2), NotEquals(1), Equals(3), first_only=True)), + ] class TestKeysEqual(TestCase, TestMatchersInterface): @@ -1066,6 +1084,242 @@ class TestAllMatch(TestCase, TestMatchersInterface): ] +class PathHelpers(object): + + def mkdtemp(self): + directory = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, directory) + return directory + + def create_file(self, filename, contents=''): + fp = open(filename, 'w') + try: + fp.write(contents) + finally: + fp.close() + + def touch(self, filename): + return self.create_file(filename) + + +class TestPathExists(TestCase, PathHelpers): + + def test_exists(self): + tempdir = self.mkdtemp() + self.assertThat(tempdir, PathExists()) + + def test_not_exists(self): + doesntexist = os.path.join(self.mkdtemp(), 'doesntexist') + mismatch = PathExists().match(doesntexist) + self.assertThat( + "%s does not exist." % doesntexist, Equals(mismatch.describe())) + + +class TestDirExists(TestCase, PathHelpers): + + def test_exists(self): + tempdir = self.mkdtemp() + self.assertThat(tempdir, DirExists()) + + def test_not_exists(self): + doesntexist = os.path.join(self.mkdtemp(), 'doesntexist') + mismatch = DirExists().match(doesntexist) + self.assertThat( + PathExists().match(doesntexist).describe(), + Equals(mismatch.describe())) + + def test_not_a_directory(self): + filename = os.path.join(self.mkdtemp(), 'foo') + self.touch(filename) + mismatch = DirExists().match(filename) + self.assertThat( + "%s is not a directory." % filename, Equals(mismatch.describe())) + + +class TestFileExists(TestCase, PathHelpers): + + def test_exists(self): + tempdir = self.mkdtemp() + filename = os.path.join(tempdir, 'filename') + self.touch(filename) + self.assertThat(filename, FileExists()) + + def test_not_exists(self): + doesntexist = os.path.join(self.mkdtemp(), 'doesntexist') + mismatch = FileExists().match(doesntexist) + self.assertThat( + PathExists().match(doesntexist).describe(), + Equals(mismatch.describe())) + + def test_not_a_file(self): + tempdir = self.mkdtemp() + mismatch = FileExists().match(tempdir) + self.assertThat( + "%s is not a file." % tempdir, Equals(mismatch.describe())) + + +class TestDirContains(TestCase, PathHelpers): + + def test_empty(self): + tempdir = self.mkdtemp() + self.assertThat(tempdir, DirContains([])) + + def test_not_exists(self): + doesntexist = os.path.join(self.mkdtemp(), 'doesntexist') + mismatch = DirContains([]).match(doesntexist) + self.assertThat( + PathExists().match(doesntexist).describe(), + Equals(mismatch.describe())) + + def test_contains_files(self): + tempdir = self.mkdtemp() + self.touch(os.path.join(tempdir, 'foo')) + self.touch(os.path.join(tempdir, 'bar')) + self.assertThat(tempdir, DirContains(['bar', 'foo'])) + + def test_matcher(self): + tempdir = self.mkdtemp() + self.touch(os.path.join(tempdir, 'foo')) + self.touch(os.path.join(tempdir, 'bar')) + self.assertThat(tempdir, DirContains(matcher=Contains('bar'))) + + def test_neither_specified(self): + self.assertRaises(AssertionError, DirContains) + + def test_both_specified(self): + self.assertRaises( + AssertionError, DirContains, filenames=[], matcher=Contains('a')) + + def test_does_not_contain_files(self): + tempdir = self.mkdtemp() + self.touch(os.path.join(tempdir, 'foo')) + mismatch = DirContains(['bar', 'foo']).match(tempdir) + self.assertThat( + Equals(['bar', 'foo']).match(['foo']).describe(), + Equals(mismatch.describe())) + + +class TestFileContains(TestCase, PathHelpers): + + def test_not_exists(self): + doesntexist = os.path.join(self.mkdtemp(), 'doesntexist') + mismatch = FileContains('').match(doesntexist) + self.assertThat( + PathExists().match(doesntexist).describe(), + Equals(mismatch.describe())) + + def test_contains(self): + tempdir = self.mkdtemp() + filename = os.path.join(tempdir, 'foo') + self.create_file(filename, 'Hello World!') + self.assertThat(filename, FileContains('Hello World!')) + + def test_matcher(self): + tempdir = self.mkdtemp() + filename = os.path.join(tempdir, 'foo') + self.create_file(filename, 'Hello World!') + self.assertThat( + filename, FileContains(matcher=DocTestMatches('Hello World!'))) + + def test_neither_specified(self): + self.assertRaises(AssertionError, FileContains) + + def test_both_specified(self): + self.assertRaises( + AssertionError, FileContains, contents=[], matcher=Contains('a')) + + def test_does_not_contain(self): + tempdir = self.mkdtemp() + filename = os.path.join(tempdir, 'foo') + self.create_file(filename, 'Goodbye Cruel World!') + mismatch = FileContains('Hello World!').match(filename) + self.assertThat( + Equals('Hello World!').match('Goodbye Cruel World!').describe(), + Equals(mismatch.describe())) + + +def is_even(x): + return x % 2 == 0 + + +class TestMatchesPredicate(TestCase, TestMatchersInterface): + + matches_matcher = MatchesPredicate(is_even, "%s is not even") + matches_matches = [2, 4, 6, 8] + matches_mismatches = [3, 5, 7, 9] + + str_examples = [ + ("MatchesPredicate(%r, %r)" % (is_even, "%s is not even"), + MatchesPredicate(is_even, "%s is not even")), + ] + + describe_examples = [ + ('7 is not even', 7, MatchesPredicate(is_even, "%s is not even")), + ] + + +class TestTarballContains(TestCase, PathHelpers): + + def test_match(self): + tempdir = self.mkdtemp() + in_temp_dir = lambda x: os.path.join(tempdir, x) + self.touch(in_temp_dir('a')) + self.touch(in_temp_dir('b')) + tarball = tarfile.open(in_temp_dir('foo.tar.gz'), 'w') + tarball.add(in_temp_dir('a'), 'a') + tarball.add(in_temp_dir('b'), 'b') + tarball.close() + self.assertThat( + in_temp_dir('foo.tar.gz'), TarballContains(['b', 'a'])) + + def test_mismatch(self): + tempdir = self.mkdtemp() + in_temp_dir = lambda x: os.path.join(tempdir, x) + self.touch(in_temp_dir('a')) + self.touch(in_temp_dir('b')) + tarball = tarfile.open(in_temp_dir('foo.tar.gz'), 'w') + tarball.add(in_temp_dir('a'), 'a') + tarball.add(in_temp_dir('b'), 'b') + tarball.close() + mismatch = TarballContains(['d', 'c']).match(in_temp_dir('foo.tar.gz')) + self.assertEqual( + mismatch.describe(), + Equals(['c', 'd']).match(['a', 'b']).describe()) + + +class TestSamePath(TestCase, PathHelpers): + + def test_same_string(self): + self.assertThat('foo', SamePath('foo')) + + def test_relative_and_absolute(self): + path = 'foo' + abspath = os.path.abspath(path) + self.assertThat(path, SamePath(abspath)) + self.assertThat(abspath, SamePath(path)) + + def test_real_path(self): + symlink = getattr(os, 'symlink', None) + skipIf(symlink is None, "No symlink support") + tempdir = self.mkdtemp() + source = os.path.join(tempdir, 'source') + self.touch(source) + target = os.path.join(tempdir, 'target') + symlink(source, target) + self.assertThat(source, SamePath(target)) + self.assertThat(target, SamePath(source)) + + +class TestHasPermissions(TestCase, PathHelpers): + + def test_match(self): + tempdir = self.mkdtemp() + filename = os.path.join(tempdir, 'filename') + self.touch(filename) + permissions = oct(os.stat(filename).st_mode)[-4:] + self.assertThat(filename, HasPermissions(permissions)) + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_testresult.py b/lib/testtools/testtools/tests/test_testresult.py index 69d2d6e2de..364fe51158 100644 --- a/lib/testtools/testtools/tests/test_testresult.py +++ b/lib/testtools/testtools/tests/test_testresult.py @@ -74,6 +74,13 @@ def make_failing_test(): return Test("failed") +def make_mismatching_test(): + class Test(TestCase): + def mismatch(self): + self.assertEqual(1, 2) + return Test("mismatch") + + def make_unexpectedly_successful_test(): class Test(TestCase): def succeeded(self): @@ -416,6 +423,19 @@ class TestTestResult(TestCase): 'ZeroDivisionError: ...\n', doctest.ELLIPSIS)) + def test_traceback_formatting_with_stack_hidden_mismatch(self): + result = self.makeResult() + test = make_mismatching_test() + run_with_stack_hidden(True, test.run, result) + self.assertThat( + result.failures[0][1], + DocTestMatches( + 'Traceback (most recent call last):\n' + ' File "...testtools...tests...test_testresult.py", line ..., in mismatch\n' + ' self.assertEqual(1, 2)\n' + '...MismatchError: 1 != 2\n', + doctest.ELLIPSIS)) + class TestMultiTestResult(TestCase): """Tests for 'MultiTestResult'.""" -- 2.34.1