diff --git a/CHANGELOG b/CHANGELOG index f16c46ec5..c24ae1285 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,13 +12,21 @@ - Fix issue #766 by removing documentation references to distutils. Thanks Russel Winder. +- Fix issue #1030: now byte-strings are escaped to produce item node ids + to make them always serializable. + Thanks Andy Freeland for the report and Bruno Oliveira for the PR. + +- Python 2: if unicode parametrized values are convertible to ascii, their + ascii representation is used for the node id. + - Fix issue #411: Add __eq__ method to assertion comparison example. Thanks Ben Webb. - Fix issue #653: deprecated_call can be used as context manager. -- fix issue 877: propperly handle assertion explanations with non-ascii repr +- fix issue 877: properly handle assertion explanations with non-ascii repr Thanks Mathieu Agopian for the report and Ronny Pfannschmidt for the PR. +- fix issue 1029: transform errors when writing cache values into pytest-warnings 2.8.0 ----------------------------- @@ -196,8 +204,6 @@ - fix issue714: add ability to apply indirect=True parameter on particular argnames. Thanks Elizaveta239. -- fix issue714: add ability to apply indirect=True parameter on particular argnames. - - fix issue890: changed extension of all documentation files from ``txt`` to ``rst``. Thanks to Abhijeet for the PR. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 7cb0d64dd..8e7055699 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -153,11 +153,11 @@ but here is a simple overview: $ cd pytest # now, to fix a bug create your own branch off "master": - $ git checkout master -b your-bugfix-branch-name + $ git checkout -b your-bugfix-branch-name master # or to instead add a feature create your own branch off "features": - $ git checkout features -b your-feature-branch-name + $ git checkout -b your-feature-branch-name features Given we have "major.minor.micro" version numbers, bugfixes will usually be released in micro releases whereas features will be released in diff --git a/_pytest/cacheprovider.py b/_pytest/cacheprovider.py index 977647fee..a1f728d9c 100755 --- a/_pytest/cacheprovider.py +++ b/_pytest/cacheprovider.py @@ -69,10 +69,22 @@ class Cache(object): like e. g. lists of dictionaries. """ path = self._getvaluepath(key) - path.dirpath().ensure_dir() - with path.open("w") as f: - self.trace("cache-write %s: %r" % (key, value,)) - json.dump(value, f, indent=2, sort_keys=True) + try: + path.dirpath().ensure_dir() + except (py.error.EEXIST, py.error.EACCES): + self.config.warn( + code='I9', message='could not create cache path %s' % (path,) + ) + return + try: + f = path.open('w') + except py.error.ENOTDIR: + self.config.warn( + code='I9', message='cache could not write path %s' % (path,)) + else: + with f: + self.trace("cache-write %s: %r" % (key, value,)) + json.dump(value, f, indent=2, sort_keys=True) class LFPlugin: @@ -174,8 +186,20 @@ def pytest_configure(config): @pytest.fixture def cache(request): + """ + Return a cache object that can persist state between testing sessions. + + cache.get(key, default) + cache.set(key, value) + + Keys must be strings not containing a "/" separator. Add a unique identifier + (such as plugin/app name) to avoid clashes with other cache users. + + Values can be any object handled by the json stdlib module. + """ return request.config.cache + def pytest_report_header(config): if config.option.verbose: relpath = py.path.local().bestrelpath(config.cache._cachedir) diff --git a/_pytest/python.py b/_pytest/python.py index 95388ffcb..6548cdbf5 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -32,6 +32,9 @@ exc_clear = getattr(sys, 'exc_clear', lambda: None) # The type of re.compile objects is not exposed in Python. REGEX_TYPE = type(re.compile('')) +_PY3 = sys.version_info > (3, 0) +_PY2 = not _PY3 + if hasattr(inspect, 'signature'): def _format_args(func): @@ -912,7 +915,7 @@ class Metafunc(FuncargnamesCompatAttr): :arg argvalues: The list of argvalues determines how often a test is invoked with different argument values. If only one - argname was specified argvalues is a list of simple values. If N + argname was specified argvalues is a list of values. If N argnames were specified, argvalues must be a list of N-tuples, where each tuple-element specifies a value for its respective argname. @@ -1037,6 +1040,35 @@ class Metafunc(FuncargnamesCompatAttr): self._calls.append(cs) +if _PY3: + def _escape_bytes(val): + """ + If val is pure ascii, returns it as a str(), otherwise escapes + into a sequence of escaped bytes: + b'\xc3\xb4\xc5\xd6' -> u'\\xc3\\xb4\\xc5\\xd6' + + note: + the obvious "v.decode('unicode-escape')" will return + valid utf-8 unicode if it finds them in the string, but we + want to return escaped bytes for any byte, even if they match + a utf-8 string. + """ + # source: http://goo.gl/bGsnwC + import codecs + encoded_bytes, _ = codecs.escape_encode(val) + return encoded_bytes.decode('ascii') +else: + def _escape_bytes(val): + """ + In py2 bytes and str are the same, so return it unchanged if it + is a full ascii string, otherwise escape it into its binary form. + """ + try: + return val.encode('ascii') + except UnicodeDecodeError: + return val.encode('string-escape') + + def _idval(val, argname, idx, idfn): if idfn: try: @@ -1046,7 +1078,9 @@ def _idval(val, argname, idx, idfn): except Exception: pass - if isinstance(val, (float, int, str, bool, NoneType)): + if isinstance(val, bytes): + return _escape_bytes(val) + elif isinstance(val, (float, int, str, bool, NoneType)): return str(val) elif isinstance(val, REGEX_TYPE): return val.pattern @@ -1054,6 +1088,14 @@ def _idval(val, argname, idx, idfn): return str(val) elif isclass(val) and hasattr(val, '__name__'): return val.__name__ + elif _PY2 and isinstance(val, unicode): + # special case for python 2: if a unicode string is + # convertible to ascii, return it as an str() object instead + try: + return str(val) + except UnicodeDecodeError: + # fallthrough + pass return str(argname)+str(idx) def _idvalset(idx, valset, argnames, idfn): diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 21bdc9fc9..ce4a88bc0 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -22,7 +22,7 @@ def pytest_addoption(parser): group._addoption('-r', action="store", dest="reportchars", default=None, metavar="chars", help="show extra test summary info as specified by chars (f)ailed, " - "(E)error, (s)skipped, (x)failed, (X)passed (w)warnings (a)all.") + "(E)error, (s)skipped, (x)failed, (X)passed (w)pytest-warnings (a)all.") group._addoption('-l', '--showlocals', action="store_true", dest="showlocals", default=False, help="show locals in tracebacks (disabled by default).") diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 04e4a4aeb..d0df62f81 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -129,11 +129,12 @@ class TestMetafunc: (object(), object())]) assert result == ["a0-1.0", "a1-b1"] # unicode mixing, issue250 - result = idmaker((py.builtin._totext("a"), "b"), [({}, '\xc3\xb4')]) - assert result == ['a0-\xc3\xb4'] + result = idmaker((py.builtin._totext("a"), "b"), [({}, b'\xc3\xb4')]) + assert result == ['a0-\\xc3\\xb4'] def test_idmaker_native_strings(self): from _pytest.python import idmaker + totext = py.builtin._totext result = idmaker(("a", "b"), [(1.0, -1.1), (2, -202), ("three", "three hundred"), @@ -143,7 +144,9 @@ class TestMetafunc: (str, int), (list("six"), [66, 66]), (set([7]), set("seven")), - (tuple("eight"), (8, -8, 8)) + (tuple("eight"), (8, -8, 8)), + (b'\xc3\xb4', b"name"), + (b'\xc3\xb4', totext("other")), ]) assert result == ["1.0--1.1", "2--202", @@ -154,7 +157,10 @@ class TestMetafunc: "str-int", "a7-b7", "a8-b8", - "a9-b9"] + "a9-b9", + "\\xc3\\xb4-name", + "\\xc3\\xb4-other", + ] def test_idmaker_enum(self): from _pytest.python import idmaker @@ -312,7 +318,6 @@ class TestMetafunc: "*uses no fixture 'y'*", ]) - @pytest.mark.xfail @pytest.mark.issue714 def test_parametrize_uses_no_fixture_error_indirect_true(self, testdir): testdir.makepyfile(""" @@ -333,7 +338,6 @@ class TestMetafunc: "*uses no fixture 'y'*", ]) - @pytest.mark.xfail @pytest.mark.issue714 def test_parametrize_indirect_uses_no_fixture_error_indirect_list(self, testdir): testdir.makepyfile(""" diff --git a/testing/test_cache.py b/testing/test_cache.py index 8eac4e8e0..20a6cf78a 100755 --- a/testing/test_cache.py +++ b/testing/test_cache.py @@ -1,3 +1,4 @@ +import sys import pytest import os import shutil @@ -25,6 +26,36 @@ class TestNewAPI: val = config.cache.get("key/name", -2) assert val == -2 + def test_cache_writefail_cachfile_silent(self, testdir): + testdir.makeini("[pytest]") + testdir.tmpdir.join('.cache').write('gone wrong') + config = testdir.parseconfigure() + cache = config.cache + cache.set('test/broken', []) + + @pytest.mark.skipif(sys.platform.startswith('win'), reason='no chmod on windows') + def test_cache_writefail_permissions(self, testdir): + testdir.makeini("[pytest]") + testdir.tmpdir.ensure_dir('.cache').chmod(0) + config = testdir.parseconfigure() + cache = config.cache + cache.set('test/broken', []) + + @pytest.mark.skipif(sys.platform.startswith('win'), reason='no chmod on windows') + def test_cache_failure_warns(self, testdir): + testdir.tmpdir.ensure_dir('.cache').chmod(0) + testdir.makepyfile(""" + def test_pass(): + pass + + """) + result = testdir.runpytest('-rw') + assert result.ret == 0 + result.stdout.fnmatch_lines([ + "*could not create cache path*", + "*1 pytest-warnings*", + ]) + def test_config_cache(self, testdir): testdir.makeconftest(""" def pytest_configure(config): @@ -238,6 +269,22 @@ class TestLastFailed: lastfailed = config.cache.get("cache/lastfailed", -1) assert not lastfailed + def test_non_serializable_parametrize(self, testdir): + """Test that failed parametrized tests with unmarshable parameters + don't break pytest-cache. + """ + testdir.makepyfile(r""" + import pytest + + @pytest.mark.parametrize('val', [ + b'\xac\x10\x02G', + ]) + def test_fail(val): + assert False + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines('*1 failed in*') + def test_lastfailed_collectfailure(self, testdir, monkeypatch): testdir.makepyfile(test_maybe="""