From f8d31d24001e0afb519af0eafeb8848d663c21e8 Mon Sep 17 00:00:00 2001 From: Christopher Dignam Date: Wed, 12 Dec 2018 15:20:24 -0500 Subject: [PATCH 01/18] Bugfix: monkeypatch.delattr handles class descriptors Correct monkeypatch.delattr to match the correct behavior of monkeypatch.setattr when changing class descriptors --- AUTHORS | 1 + changelog/4536.bugfix.rst | 1 + src/_pytest/monkeypatch.py | 8 +++++++- testing/test_monkeypatch.py | 27 +++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 changelog/4536.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 1316f7b8f..e227a7ed8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -48,6 +48,7 @@ Christian Boelsen Christian Theunert Christian Tismer Christopher Gilling +Christopher Dignam CrazyMerlyn Cyrus Maden Dhiren Serai diff --git a/changelog/4536.bugfix.rst b/changelog/4536.bugfix.rst new file mode 100644 index 000000000..0a88ee755 --- /dev/null +++ b/changelog/4536.bugfix.rst @@ -0,0 +1 @@ +monkeypatch.delattr handles class descriptors like staticmethod/classmethod diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 2c81de177..3ef05769b 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -181,6 +181,8 @@ class MonkeyPatch(object): attribute is missing. """ __tracebackhide__ = True + import inspect + if name is notset: if not isinstance(target, six.string_types): raise TypeError( @@ -194,7 +196,11 @@ class MonkeyPatch(object): if raising: raise AttributeError(name) else: - self._setattr.append((target, name, getattr(target, name, notset))) + oldval = getattr(target, name, notset) + # avoid class descriptors like staticmethod/classmethod + if inspect.isclass(target): + oldval = target.__dict__.get(name, notset) + self._setattr.append((target, name, oldval)) delattr(target, name) def setitem(self, dic, name, value): diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index ebc233fbf..572c31357 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -391,6 +391,33 @@ def test_issue156_undo_staticmethod(Sample): assert Sample.hello() +def test_undo_class_descriptors_delattr(): + class SampleParent(object): + @classmethod + def hello(_cls): + pass + + @staticmethod + def world(): + pass + + class SampleChild(SampleParent): + pass + + monkeypatch = MonkeyPatch() + + original_hello = SampleChild.hello + original_world = SampleChild.world + monkeypatch.delattr(SampleParent, "hello") + monkeypatch.delattr(SampleParent, "world") + assert getattr(SampleParent, "hello", None) is None + assert getattr(SampleParent, "world", None) is None + + monkeypatch.undo() + assert original_hello == SampleChild.hello + assert original_world == SampleChild.world + + def test_issue1338_name_resolving(): pytest.importorskip("requests") monkeypatch = MonkeyPatch() From 7a600ea3ebbcb9d0de909209f7cba2ebea58d32e Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 12 Dec 2018 23:28:47 +0100 Subject: [PATCH 02/18] Improve changelog --- changelog/4536.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/4536.bugfix.rst b/changelog/4536.bugfix.rst index 0a88ee755..0ec84a62b 100644 --- a/changelog/4536.bugfix.rst +++ b/changelog/4536.bugfix.rst @@ -1 +1 @@ -monkeypatch.delattr handles class descriptors like staticmethod/classmethod +``monkeypatch.delattr`` handles class descriptors like ``staticmethod``/``classmethod``. From 9b3be870dc5330673eed8804db978ea789f5fdb6 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 12 Dec 2018 23:29:43 +0100 Subject: [PATCH 03/18] Improve comment --- src/_pytest/monkeypatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 3ef05769b..46d9718da 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -197,7 +197,7 @@ class MonkeyPatch(object): raise AttributeError(name) else: oldval = getattr(target, name, notset) - # avoid class descriptors like staticmethod/classmethod + # Avoid class descriptors like staticmethod/classmethod. if inspect.isclass(target): oldval = target.__dict__.get(name, notset) self._setattr.append((target, name, oldval)) From e2a9aaf24b0e3bdd1f4e429ba7c032f1f03bb585 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 14 Jan 2019 21:50:10 -0200 Subject: [PATCH 04/18] Add docs page about plans for dropping py27 and py34 Fix #4635 --- doc/en/contents.rst | 1 + doc/en/py27-py34-deprecation.rst | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 doc/en/py27-py34-deprecation.rst diff --git a/doc/en/contents.rst b/doc/en/contents.rst index 9883eaa64..6bf97b453 100644 --- a/doc/en/contents.rst +++ b/doc/en/contents.rst @@ -41,6 +41,7 @@ Full pytest documentation backwards-compatibility deprecations + py27-py34-deprecation historical-notes license contributing diff --git a/doc/en/py27-py34-deprecation.rst b/doc/en/py27-py34-deprecation.rst new file mode 100644 index 000000000..97d9abf83 --- /dev/null +++ b/doc/en/py27-py34-deprecation.rst @@ -0,0 +1,22 @@ +Python 2.7 and 3.4 support plan +=============================== + +Python 2.7 EOL is fast approaching, with +upstream support `ending in 2020 `__. +Python 3.4's last release is scheduled for +`March 2019 `__. pytest is one of +the participating projects of the https://python3statement.org. + +We plan to drop support for Python 2.7 and 3.4 at the same time with the release of **pytest 5.0**, +scheduled to be released by **mid-2019**. Thanks to the `python_requires `__ ``setuptools`` option, +Python 2.7 and Python 3.4 users using a modern ``pip`` version +will install the last compatible pytest ``4.X`` version automatically even if ``5.0`` or later +are available on PyPI. + +During the period **from mid-2019 and 2020**, the pytest core team plans to make +bug-fix releases of the pytest ``4.X`` series by back-porting patches to the ``4.x-maintenance`` +branch. + +**After 2020**, the core team will no longer actively back port-patches, but the ``4.x-maintenance`` +branch will continue to exist so the community itself can contribute patches. The +core team will be happy to accept those patches and make new ``4.X`` releases **until mid-2020**. From 04bd147d46033511401d3b06f4fcc8b49205fb9a Mon Sep 17 00:00:00 2001 From: Adam Uhlir Date: Fri, 18 Jan 2019 12:54:00 -0800 Subject: [PATCH 05/18] Fixes #4653 - tmp_path provides real path --- AUTHORS | 1 + changelog/4653.bugfix.rst | 1 + src/_pytest/tmpdir.py | 2 +- testing/test_tmpdir.py | 16 ++++++++++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 changelog/4653.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 68298ba80..5c8cd9c9e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -7,6 +7,7 @@ Aaron Coleman Abdeali JK Abhijeet Kasurde Adam Johnson +Adam Uhlir Ahn Ki-Wook Alan Velasco Alexander Johnson diff --git a/changelog/4653.bugfix.rst b/changelog/4653.bugfix.rst new file mode 100644 index 000000000..5b5b36745 --- /dev/null +++ b/changelog/4653.bugfix.rst @@ -0,0 +1 @@ +``tmp_path`` fixture and other related ones provides resolved path (a.k.a real path) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 860c2d4af..6c140d1d5 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -65,7 +65,7 @@ class TempPathFactory(object): ensure_reset_dir(basetemp) else: from_env = os.environ.get("PYTEST_DEBUG_TEMPROOT") - temproot = Path(from_env or tempfile.gettempdir()) + temproot = Path(from_env or tempfile.gettempdir()).resolve() user = get_user() or "unknown" # use a sub-directory in the temproot to speed-up # make_numbered_dir() call diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 6040d9444..f49691dad 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -121,6 +121,22 @@ def test_tmpdir_always_is_realpath(testdir): assert not result.ret +def test_tmp_path_always_is_realpath(testdir, monkeypatch): + # for reasoning see: test_tmpdir_always_is_realpath test-case + realtemp = testdir.tmpdir.mkdir("myrealtemp") + linktemp = testdir.tmpdir.join("symlinktemp") + attempt_symlink_to(linktemp, str(realtemp)) + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(linktemp)) + testdir.makepyfile( + """ + def test_1(tmp_path): + assert tmp_path.resolve() == tmp_path + """ + ) + reprec = testdir.inline_run() + reprec.assertoutcome(passed=1) + + def test_tmpdir_too_long_on_parametrization(testdir): testdir.makepyfile( """ From f28b834426890bee7b7962b6a920f45108a9ce10 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 18 Jan 2019 22:05:41 +0100 Subject: [PATCH 06/18] fix #4649 - also transfer markers to keywordmapping as it turns out it is distinct from nodekeywords and behaves completely different --- changelog/4649.bugfix.rst | 1 + src/_pytest/mark/legacy.py | 9 +++++---- .../marks/marks_considered_keywords/conftest.py | 0 .../marks_considered_keywords/test_marks_as_keywords.py | 6 ++++++ testing/test_mark.py | 7 +++++++ 5 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 changelog/4649.bugfix.rst create mode 100644 testing/example_scripts/marks/marks_considered_keywords/conftest.py create mode 100644 testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py diff --git a/changelog/4649.bugfix.rst b/changelog/4649.bugfix.rst new file mode 100644 index 000000000..74b241781 --- /dev/null +++ b/changelog/4649.bugfix.rst @@ -0,0 +1 @@ +Restore marks being considered keywords for keyword expressions. diff --git a/src/_pytest/mark/legacy.py b/src/_pytest/mark/legacy.py index cea136bff..f784ffa20 100644 --- a/src/_pytest/mark/legacy.py +++ b/src/_pytest/mark/legacy.py @@ -45,13 +45,14 @@ class KeywordMapping(object): mapped_names.add(item.name) # Add the names added as extra keywords to current or parent items - for name in item.listextrakeywords(): - mapped_names.add(name) + mapped_names.update(item.listextrakeywords()) # Add the names attached to the current function through direct assignment if hasattr(item, "function"): - for name in item.function.__dict__: - mapped_names.add(name) + mapped_names.update(item.function.__dict__) + + # add the markers to the keywords as we no longer handle them correctly + mapped_names.update(mark.name for mark in item.iter_markers()) return cls(mapped_names) diff --git a/testing/example_scripts/marks/marks_considered_keywords/conftest.py b/testing/example_scripts/marks/marks_considered_keywords/conftest.py new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py b/testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py new file mode 100644 index 000000000..35a2c7b76 --- /dev/null +++ b/testing/example_scripts/marks/marks_considered_keywords/test_marks_as_keywords.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.mark.foo +def test_mark(): + pass diff --git a/testing/test_mark.py b/testing/test_mark.py index a10e2e19d..f7d8cf689 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -292,6 +292,13 @@ def test_keyword_option_custom(spec, testdir): assert list(passed) == list(passed_result) +def test_keyword_option_considers_mark(testdir): + testdir.copy_example("marks/marks_considered_keywords") + rec = testdir.inline_run("-k", "foo") + passed = rec.listoutcomes()[0] + assert len(passed) == 1 + + @pytest.mark.parametrize( "spec", [ From ec5e279f935370a505e68b6d1259cb8b5a2d73fb Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sun, 20 Jan 2019 11:59:48 -0800 Subject: [PATCH 07/18] Remove and ban use of py.builtin --- .pre-commit-config.yaml | 5 +++++ src/_pytest/_code/source.py | 4 +--- src/_pytest/assertion/rewrite.py | 2 +- testing/python/collect.py | 4 ++-- testing/test_assertion.py | 10 ++++++---- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ecfc004ba..80e78ab50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,3 +51,8 @@ repos: entry: 'changelog files must be named ####.(feature|bugfix|doc|deprecation|removal|vendor|trivial).rst' exclude: changelog/(\d+\.(feature|bugfix|doc|deprecation|removal|vendor|trivial).rst|README.rst|_template.rst) files: ^changelog/ + - id: py-deprecated + name: py library is deprecated + language: pygrep + entry: '\bpy\.(builtin\.|code\.|std\.)' + types: [python] diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index b74ecf88e..887f323f9 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -237,9 +237,7 @@ def getfslineno(obj): def findsource(obj): try: sourcelines, lineno = inspect.findsource(obj) - except py.builtin._sysex: - raise - except: # noqa + except Exception: return None, -1 source = Source() source.lines = [line.rstrip() for line in sourcelines] diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index df8b9becb..80f182723 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -678,7 +678,7 @@ class AssertionRewriter(ast.NodeVisitor): # Insert some special imports at the top of the module but after any # docstrings and __future__ imports. aliases = [ - ast.alias(py.builtin.builtins.__name__, "@py_builtins"), + ast.alias(six.moves.builtins.__name__, "@py_builtins"), ast.alias("_pytest.assertion.rewrite", "@pytest_ar"), ] doc = getattr(mod, "docstring", None) diff --git a/testing/python/collect.py b/testing/python/collect.py index 3147ee9e2..dcb4e6474 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -963,7 +963,7 @@ class TestTracebackCutting(object): def test_filter_traceback_generated_code(self): """test that filter_traceback() works with the fact that - py.code.Code.path attribute might return an str object. + _pytest._code.code.Code.path attribute might return an str object. In this case, one of the entries on the traceback was produced by dynamically generated code. See: https://bitbucket.org/pytest-dev/py/issues/71 @@ -984,7 +984,7 @@ class TestTracebackCutting(object): def test_filter_traceback_path_no_longer_valid(self, testdir): """test that filter_traceback() works with the fact that - py.code.Code.path attribute might return an str object. + _pytest._code.code.Code.path attribute might return an str object. In this case, one of the files in the traceback no longer exists. This fixes #1133. """ diff --git a/testing/test_assertion.py b/testing/test_assertion.py index cbd0d9068..b659233eb 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -7,7 +7,6 @@ import sys import textwrap import attr -import py import six import _pytest.assertion as plugin @@ -455,10 +454,13 @@ class TestAssert_reprcompare(object): assert len(expl) > 1 def test_Sequence(self): - col = py.builtin._tryimport("collections.abc", "collections", "sys") - if not hasattr(col, "MutableSequence"): + if sys.version_info >= (3, 3): + import collections.abc as collections_abc + else: + import collections as collections_abc + if not hasattr(collections_abc, "MutableSequence"): pytest.skip("cannot import MutableSequence") - MutableSequence = col.MutableSequence + MutableSequence = collections_abc.MutableSequence class TestSequence(MutableSequence): # works with a Sequence subclass def __init__(self, iterable): From dbb6c18c44bef06f77bf98acda274a6e093e7c49 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sun, 20 Jan 2019 10:40:20 -0800 Subject: [PATCH 08/18] copy saferepr from pylib verbatim Copied from b9da2ed6178cd37d4ed6b41f9fa8234dce96973f --- src/_pytest/_io/__init__.py | 0 src/_pytest/_io/saferepr.py | 71 +++++++++++++++++++++++++++++++++++ testing/io/test_saferepr.py | 75 +++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/_pytest/_io/__init__.py create mode 100644 src/_pytest/_io/saferepr.py create mode 100644 testing/io/test_saferepr.py diff --git a/src/_pytest/_io/__init__.py b/src/_pytest/_io/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py new file mode 100644 index 000000000..8518290ef --- /dev/null +++ b/src/_pytest/_io/saferepr.py @@ -0,0 +1,71 @@ +import py +import sys + +builtin_repr = repr + +reprlib = py.builtin._tryimport('repr', 'reprlib') + +class SafeRepr(reprlib.Repr): + """ subclass of repr.Repr that limits the resulting size of repr() + and includes information on exceptions raised during the call. + """ + def repr(self, x): + return self._callhelper(reprlib.Repr.repr, self, x) + + def repr_unicode(self, x, level): + # Strictly speaking wrong on narrow builds + def repr(u): + if "'" not in u: + return py.builtin._totext("'%s'") % u + elif '"' not in u: + return py.builtin._totext('"%s"') % u + else: + return py.builtin._totext("'%s'") % u.replace("'", r"\'") + s = repr(x[:self.maxstring]) + if len(s) > self.maxstring: + i = max(0, (self.maxstring-3)//2) + j = max(0, self.maxstring-3-i) + s = repr(x[:i] + x[len(x)-j:]) + s = s[:i] + '...' + s[len(s)-j:] + return s + + def repr_instance(self, x, level): + return self._callhelper(builtin_repr, x) + + def _callhelper(self, call, x, *args): + try: + # Try the vanilla repr and make sure that the result is a string + s = call(x, *args) + except py.builtin._sysex: + raise + except: + cls, e, tb = sys.exc_info() + exc_name = getattr(cls, '__name__', 'unknown') + try: + exc_info = str(e) + except py.builtin._sysex: + raise + except: + exc_info = 'unknown' + return '<[%s("%s") raised in repr()] %s object at 0x%x>' % ( + exc_name, exc_info, x.__class__.__name__, id(x)) + else: + if len(s) > self.maxsize: + i = max(0, (self.maxsize-3)//2) + j = max(0, self.maxsize-3-i) + s = s[:i] + '...' + s[len(s)-j:] + return s + +def saferepr(obj, maxsize=240): + """ return a size-limited safe repr-string for the given object. + Failing __repr__ functions of user instances will be represented + with a short exception info and 'saferepr' generally takes + care to never raise exceptions itself. This function is a wrapper + around the Repr/reprlib functionality of the standard 2.6 lib. + """ + # review exception handling + srepr = SafeRepr() + srepr.maxstring = maxsize + srepr.maxsize = maxsize + srepr.maxother = 160 + return srepr.repr(obj) diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py new file mode 100644 index 000000000..97be1416f --- /dev/null +++ b/testing/io/test_saferepr.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +from __future__ import generators +import py +import sys + +saferepr = py.io.saferepr + +class TestSafeRepr: + def test_simple_repr(self): + assert saferepr(1) == '1' + assert saferepr(None) == 'None' + + def test_maxsize(self): + s = saferepr('x'*50, maxsize=25) + assert len(s) == 25 + expected = repr('x'*10 + '...' + 'x'*10) + assert s == expected + + def test_maxsize_error_on_instance(self): + class A: + def __repr__(self): + raise ValueError('...') + + s = saferepr(('*'*50, A()), maxsize=25) + assert len(s) == 25 + assert s[0] == '(' and s[-1] == ')' + + def test_exceptions(self): + class BrokenRepr: + def __init__(self, ex): + self.ex = ex + foo = 0 + def __repr__(self): + raise self.ex + class BrokenReprException(Exception): + __str__ = None + __repr__ = None + assert 'Exception' in saferepr(BrokenRepr(Exception("broken"))) + s = saferepr(BrokenReprException("really broken")) + assert 'TypeError' in s + assert 'TypeError' in saferepr(BrokenRepr("string")) + + s2 = saferepr(BrokenRepr(BrokenReprException('omg even worse'))) + assert 'NameError' not in s2 + assert 'unknown' in s2 + + def test_big_repr(self): + from py._io.saferepr import SafeRepr + assert len(saferepr(range(1000))) <= \ + len('[' + SafeRepr().maxlist * "1000" + ']') + + def test_repr_on_newstyle(self): + class Function(object): + def __repr__(self): + return "<%s>" %(self.name) + try: + s = saferepr(Function()) + except Exception: + py.test.fail("saferepr failed for newstyle class") + + def test_unicode(self): + val = py.builtin._totext('£€', 'utf-8') + reprval = py.builtin._totext("'£€'", 'utf-8') + assert saferepr(val) == reprval + +def test_unicode_handling(): + value = py.builtin._totext('\xc4\x85\xc4\x87\n', 'utf-8').encode('utf8') + def f(): + raise Exception(value) + excinfo = py.test.raises(Exception, f) + s = str(excinfo) + if sys.version_info[0] < 3: + u = unicode(excinfo) + From 095ce2ca7fd3828f451a10c191f41517c9dfe34b Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sun, 20 Jan 2019 10:50:18 -0800 Subject: [PATCH 09/18] Fix linting errors and py references in saferepr.py --- src/_pytest/_io/saferepr.py | 55 +++++++++--------- testing/io/test_saferepr.py | 113 +++++++++++++++++------------------- 2 files changed, 80 insertions(+), 88 deletions(-) diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index 8518290ef..4d1d18d3b 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -1,14 +1,13 @@ -import py import sys -builtin_repr = repr +from six.moves import reprlib -reprlib = py.builtin._tryimport('repr', 'reprlib') class SafeRepr(reprlib.Repr): - """ subclass of repr.Repr that limits the resulting size of repr() - and includes information on exceptions raised during the call. + """subclass of repr.Repr that limits the resulting size of repr() + and includes information on exceptions raised during the call. """ + def repr(self, x): return self._callhelper(reprlib.Repr.repr, self, x) @@ -16,48 +15,50 @@ class SafeRepr(reprlib.Repr): # Strictly speaking wrong on narrow builds def repr(u): if "'" not in u: - return py.builtin._totext("'%s'") % u + return u"'%s'" % u elif '"' not in u: - return py.builtin._totext('"%s"') % u + return u'"%s"' % u else: - return py.builtin._totext("'%s'") % u.replace("'", r"\'") - s = repr(x[:self.maxstring]) + return u"'%s'" % u.replace("'", r"\'") + + s = repr(x[: self.maxstring]) if len(s) > self.maxstring: - i = max(0, (self.maxstring-3)//2) - j = max(0, self.maxstring-3-i) - s = repr(x[:i] + x[len(x)-j:]) - s = s[:i] + '...' + s[len(s)-j:] + i = max(0, (self.maxstring - 3) // 2) + j = max(0, self.maxstring - 3 - i) + s = repr(x[:i] + x[len(x) - j :]) + s = s[:i] + "..." + s[len(s) - j :] return s def repr_instance(self, x, level): - return self._callhelper(builtin_repr, x) + return self._callhelper(repr, x) def _callhelper(self, call, x, *args): try: # Try the vanilla repr and make sure that the result is a string s = call(x, *args) - except py.builtin._sysex: - raise - except: + except Exception: cls, e, tb = sys.exc_info() - exc_name = getattr(cls, '__name__', 'unknown') + exc_name = getattr(cls, "__name__", "unknown") try: exc_info = str(e) - except py.builtin._sysex: - raise - except: - exc_info = 'unknown' + except Exception: + exc_info = "unknown" return '<[%s("%s") raised in repr()] %s object at 0x%x>' % ( - exc_name, exc_info, x.__class__.__name__, id(x)) + exc_name, + exc_info, + x.__class__.__name__, + id(x), + ) else: if len(s) > self.maxsize: - i = max(0, (self.maxsize-3)//2) - j = max(0, self.maxsize-3-i) - s = s[:i] + '...' + s[len(s)-j:] + i = max(0, (self.maxsize - 3) // 2) + j = max(0, self.maxsize - 3 - i) + s = s[:i] + "..." + s[len(s) - j :] return s + def saferepr(obj, maxsize=240): - """ return a size-limited safe repr-string for the given object. + """return a size-limited safe repr-string for the given object. Failing __repr__ functions of user instances will be represented with a short exception info and 'saferepr' generally takes care to never raise exceptions itself. This function is a wrapper diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index 97be1416f..901203088 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -1,75 +1,66 @@ # -*- coding: utf-8 -*- +from _pytest._io.saferepr import saferepr -from __future__ import generators -import py -import sys -saferepr = py.io.saferepr +def test_simple_repr(): + assert saferepr(1) == "1" + assert saferepr(None) == "None" -class TestSafeRepr: - def test_simple_repr(self): - assert saferepr(1) == '1' - assert saferepr(None) == 'None' - def test_maxsize(self): - s = saferepr('x'*50, maxsize=25) - assert len(s) == 25 - expected = repr('x'*10 + '...' + 'x'*10) - assert s == expected +def test_maxsize(): + s = saferepr("x" * 50, maxsize=25) + assert len(s) == 25 + expected = repr("x" * 10 + "..." + "x" * 10) + assert s == expected - def test_maxsize_error_on_instance(self): - class A: - def __repr__(self): - raise ValueError('...') - s = saferepr(('*'*50, A()), maxsize=25) - assert len(s) == 25 - assert s[0] == '(' and s[-1] == ')' +def test_maxsize_error_on_instance(): + class A: + def __repr__(): + raise ValueError("...") - def test_exceptions(self): - class BrokenRepr: - def __init__(self, ex): - self.ex = ex - foo = 0 - def __repr__(self): - raise self.ex - class BrokenReprException(Exception): - __str__ = None - __repr__ = None - assert 'Exception' in saferepr(BrokenRepr(Exception("broken"))) - s = saferepr(BrokenReprException("really broken")) - assert 'TypeError' in s - assert 'TypeError' in saferepr(BrokenRepr("string")) + s = saferepr(("*" * 50, A()), maxsize=25) + assert len(s) == 25 + assert s[0] == "(" and s[-1] == ")" - s2 = saferepr(BrokenRepr(BrokenReprException('omg even worse'))) - assert 'NameError' not in s2 - assert 'unknown' in s2 - def test_big_repr(self): - from py._io.saferepr import SafeRepr - assert len(saferepr(range(1000))) <= \ - len('[' + SafeRepr().maxlist * "1000" + ']') +def test_exceptions(): + class BrokenRepr: + def __init__(self, ex): + self.ex = ex - def test_repr_on_newstyle(self): - class Function(object): - def __repr__(self): - return "<%s>" %(self.name) - try: - s = saferepr(Function()) - except Exception: - py.test.fail("saferepr failed for newstyle class") + def __repr__(self): + raise self.ex - def test_unicode(self): - val = py.builtin._totext('£€', 'utf-8') - reprval = py.builtin._totext("'£€'", 'utf-8') - assert saferepr(val) == reprval + class BrokenReprException(Exception): + __str__ = None + __repr__ = None -def test_unicode_handling(): - value = py.builtin._totext('\xc4\x85\xc4\x87\n', 'utf-8').encode('utf8') - def f(): - raise Exception(value) - excinfo = py.test.raises(Exception, f) - s = str(excinfo) - if sys.version_info[0] < 3: - u = unicode(excinfo) + assert "Exception" in saferepr(BrokenRepr(Exception("broken"))) + s = saferepr(BrokenReprException("really broken")) + assert "TypeError" in s + assert "TypeError" in saferepr(BrokenRepr("string")) + s2 = saferepr(BrokenRepr(BrokenReprException("omg even worse"))) + assert "NameError" not in s2 + assert "unknown" in s2 + + +def test_big_repr(): + from _pytest._io.saferepr import SafeRepr + + assert len(saferepr(range(1000))) <= len("[" + SafeRepr().maxlist * "1000" + "]") + + +def test_repr_on_newstyle(): + class Function(object): + def __repr__(self): + return "<%s>" % (self.name) + + assert saferepr(Function()) + + +def test_unicode(): + val = u"£€" + reprval = u"'£€'" + assert saferepr(val) == reprval From 0c6ca0da62c2c48003de5237e52ab04fca3b11e2 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sun, 20 Jan 2019 10:45:12 -0800 Subject: [PATCH 10/18] Fix usages of py.io.saferepr --- .pre-commit-config.yaml | 2 +- changelog/4657.trivial.rst | 1 + setup.cfg | 5 +++-- src/_pytest/_code/code.py | 7 ++++--- src/_pytest/assertion/rewrite.py | 9 +++++---- src/_pytest/assertion/util.py | 20 +++++++++----------- src/_pytest/compat.py | 3 ++- src/_pytest/pytester.py | 5 ++--- 8 files changed, 27 insertions(+), 25 deletions(-) create mode 100644 changelog/4657.trivial.rst diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 80e78ab50..fb0ab1c12 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,5 +54,5 @@ repos: - id: py-deprecated name: py library is deprecated language: pygrep - entry: '\bpy\.(builtin\.|code\.|std\.)' + entry: '\bpy\.(builtin\.|code\.|std\.|io\.saferepr)' types: [python] diff --git a/changelog/4657.trivial.rst b/changelog/4657.trivial.rst new file mode 100644 index 000000000..abdab08eb --- /dev/null +++ b/changelog/4657.trivial.rst @@ -0,0 +1 @@ +Copy saferepr from pylib diff --git a/setup.cfg b/setup.cfg index 8cd3858fd..9d0aa332e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,10 +36,11 @@ platforms = unix, linux, osx, cygwin, win32 zip_safe = no packages = _pytest - _pytest.assertion _pytest._code - _pytest.mark + _pytest._io + _pytest.assertion _pytest.config + _pytest.mark py_modules = pytest python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 1b49fe75b..fd99c6cdd 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -18,6 +18,7 @@ import six from six import text_type import _pytest +from _pytest._io.saferepr import saferepr from _pytest.compat import _PY2 from _pytest.compat import _PY3 from _pytest.compat import PY35 @@ -144,7 +145,7 @@ class Frame(object): def repr(self, object): """ return a 'safe' (non-recursive, one-line) string repr for 'object' """ - return py.io.saferepr(object) + return saferepr(object) def is_true(self, object): return object @@ -423,7 +424,7 @@ class ExceptionInfo(object): if exprinfo is None and isinstance(tup[1], AssertionError): exprinfo = getattr(tup[1], "msg", None) if exprinfo is None: - exprinfo = py.io.saferepr(tup[1]) + exprinfo = saferepr(tup[1]) if exprinfo and exprinfo.startswith(cls._assert_start_repr): _striptext = "AssertionError: " @@ -620,7 +621,7 @@ class FormattedExcinfo(object): return source def _saferepr(self, obj): - return py.io.saferepr(obj) + return saferepr(obj) def repr_args(self, entry): if self.funcargs: diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 80f182723..52f5ebce7 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -19,6 +19,7 @@ import atomicwrites import py import six +from _pytest._io.saferepr import saferepr from _pytest.assertion import util from _pytest.compat import spec_from_file_location from _pytest.pathlib import fnmatch_ex @@ -484,7 +485,7 @@ def _saferepr(obj): JSON reprs. """ - r = py.io.saferepr(obj) + r = saferepr(obj) # only occurs in python2.x, repr must return text in python3+ if isinstance(r, bytes): # Represent unprintable bytes as `\x##` @@ -503,7 +504,7 @@ def _format_assertmsg(obj): For strings this simply replaces newlines with '\n~' so that util.format_explanation() will preserve them instead of escaping - newlines. For other objects py.io.saferepr() is used first. + newlines. For other objects saferepr() is used first. """ # reprlib appears to have a bug which means that if a string @@ -512,7 +513,7 @@ def _format_assertmsg(obj): # However in either case we want to preserve the newline. replaces = [(u"\n", u"\n~"), (u"%", u"%%")] if not isinstance(obj, six.string_types): - obj = py.io.saferepr(obj) + obj = saferepr(obj) replaces.append((u"\\n", u"\n~")) if isinstance(obj, bytes): @@ -753,7 +754,7 @@ class AssertionRewriter(ast.NodeVisitor): return ast.Name(name, ast.Load()) def display(self, expr): - """Call py.io.saferepr on the expression.""" + """Call saferepr on the expression.""" return self.helper("saferepr", expr) def helper(self, name, *args): diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index b35b6abc5..6326dddbd 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -5,11 +5,11 @@ from __future__ import print_function import pprint -import py import six import _pytest._code from ..compat import Sequence +from _pytest._io.saferepr import saferepr # The _reprcompare attribute on the util module is used by the new assertion # interpretation code and assertion rewriter to detect this plugin was @@ -105,8 +105,8 @@ except NameError: def assertrepr_compare(config, op, left, right): """Return specialised explanations for some operators/operands""" width = 80 - 15 - len(op) - 2 # 15 chars indentation, 1 space around op - left_repr = py.io.saferepr(left, maxsize=int(width // 2)) - right_repr = py.io.saferepr(right, maxsize=width - len(left_repr)) + left_repr = saferepr(left, maxsize=int(width // 2)) + right_repr = saferepr(right, maxsize=width - len(left_repr)) summary = u"%s %s %s" % (ecu(left_repr), op, ecu(right_repr)) @@ -282,12 +282,12 @@ def _compare_eq_sequence(left, right, verbose=False): if len(left) > len(right): explanation += [ u"Left contains more items, first extra item: %s" - % py.io.saferepr(left[len(right)]) + % saferepr(left[len(right)]) ] elif len(left) < len(right): explanation += [ u"Right contains more items, first extra item: %s" - % py.io.saferepr(right[len(left)]) + % saferepr(right[len(left)]) ] return explanation @@ -299,11 +299,11 @@ def _compare_eq_set(left, right, verbose=False): if diff_left: explanation.append(u"Extra items in the left set:") for item in diff_left: - explanation.append(py.io.saferepr(item)) + explanation.append(saferepr(item)) if diff_right: explanation.append(u"Extra items in the right set:") for item in diff_right: - explanation.append(py.io.saferepr(item)) + explanation.append(saferepr(item)) return explanation @@ -320,9 +320,7 @@ def _compare_eq_dict(left, right, verbose=False): if diff: explanation += [u"Differing items:"] for k in diff: - explanation += [ - py.io.saferepr({k: left[k]}) + " != " + py.io.saferepr({k: right[k]}) - ] + explanation += [saferepr({k: left[k]}) + " != " + saferepr({k: right[k]})] extra_left = set(left) - set(right) if extra_left: explanation.append(u"Left contains more items:") @@ -376,7 +374,7 @@ def _notin_text(term, text, verbose=False): tail = text[index + len(term) :] correct_text = head + tail diff = _diff_text(correct_text, text, verbose) - newdiff = [u"%s is contained here:" % py.io.saferepr(term, maxsize=42)] + newdiff = [u"%s is contained here:" % saferepr(term, maxsize=42)] for line in diff: if line.startswith(u"Skipping"): continue diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index ff027f308..fa878a485 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -17,6 +17,7 @@ import six from six import text_type import _pytest +from _pytest._io.saferepr import saferepr from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -294,7 +295,7 @@ def get_real_func(obj): else: raise ValueError( ("could not find real function of {start}\nstopped at {current}").format( - start=py.io.saferepr(start_obj), current=py.io.saferepr(obj) + start=saferepr(start_obj), current=saferepr(obj) ) ) if isinstance(obj, functools.partial): diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index c59628948..7e255dc9c 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -20,6 +20,7 @@ import six import pytest from _pytest._code import Source +from _pytest._io.saferepr import saferepr from _pytest.assertion.rewrite import AssertionRewritingHook from _pytest.capture import MultiCapture from _pytest.capture import SysCapture @@ -1225,9 +1226,7 @@ def getdecoded(out): try: return out.decode("utf-8") except UnicodeDecodeError: - return "INTERNAL not-utf8-decodeable, truncated string:\n%s" % ( - py.io.saferepr(out), - ) + return "INTERNAL not-utf8-decodeable, truncated string:\n%s" % (saferepr(out),) class LineComp(object): From 92a2c1a9c40462737160171e1a4e8e637a45699c Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Mon, 21 Jan 2019 19:46:57 -0800 Subject: [PATCH 11/18] remove and ban py.io.BytesIO, py.process, py.path.local.sysfind --- .pre-commit-config.yaml | 11 ++++++++++- doc/en/example/attic.rst | 6 +++--- doc/en/example/multipython.py | 10 +++++----- src/_pytest/fixtures.py | 2 +- src/_pytest/pytester.py | 12 +++++------- src/_pytest/python.py | 2 +- testing/test_capture.py | 22 ++++++++++++---------- testing/test_parseopt.py | 3 ++- 8 files changed, 39 insertions(+), 29 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb0ab1c12..be0881649 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,5 +54,14 @@ repos: - id: py-deprecated name: py library is deprecated language: pygrep - entry: '\bpy\.(builtin\.|code\.|std\.|io\.saferepr)' + entry: > + (?x)\bpy\.( + _code\.| + builtin\.| + code\.| + io\.(BytesIO|saferepr)| + path\.local\.sysfind| + process\.| + std\. + ) types: [python] diff --git a/doc/en/example/attic.rst b/doc/en/example/attic.rst index d6fecf340..9bf3703ce 100644 --- a/doc/en/example/attic.rst +++ b/doc/en/example/attic.rst @@ -24,10 +24,10 @@ example: specifying and selecting acceptance tests pytest.skip("specify -A to run acceptance tests") self.tmpdir = request.config.mktemp(request.function.__name__, numbered=True) - def run(self, cmd): + def run(self, *cmd): """ called by test code to execute an acceptance test. """ self.tmpdir.chdir() - return py.process.cmdexec(cmd) + return subprocess.check_output(cmd).decode() and the actual test function example: @@ -36,7 +36,7 @@ and the actual test function example: def test_some_acceptance_aspect(accept): accept.tmpdir.mkdir("somesub") - result = accept.run("ls -la") + result = accept.run("ls", "-la") assert "somesub" in result If you run this test without specifying a command line option diff --git a/doc/en/example/multipython.py b/doc/en/example/multipython.py index 7846ddb98..4151c50a0 100644 --- a/doc/en/example/multipython.py +++ b/doc/en/example/multipython.py @@ -2,10 +2,10 @@ module containing a parametrized tests testing cross-python serialization via the pickle module. """ +import distutils.spawn +import subprocess import textwrap -import py - import pytest pythonlist = ["python2.7", "python3.4", "python3.5"] @@ -24,7 +24,7 @@ def python2(request, python1): class Python(object): def __init__(self, version, picklefile): - self.pythonpath = py.path.local.sysfind(version) + self.pythonpath = distutils.spawn.find_executable(version) if not self.pythonpath: pytest.skip("{!r} not found".format(version)) self.picklefile = picklefile @@ -43,7 +43,7 @@ class Python(object): ) ) ) - py.process.cmdexec("{} {}".format(self.pythonpath, dumpfile)) + subprocess.check_call((self.pythonpath, str(dumpfile))) def load_and_is_true(self, expression): loadfile = self.picklefile.dirpath("load.py") @@ -63,7 +63,7 @@ class Python(object): ) ) print(loadfile) - py.process.cmdexec("{} {}".format(self.pythonpath, loadfile)) + subprocess.check_call((self.pythonpath, str(loadfile))) @pytest.mark.parametrize("obj", [42, {}, {1: 3}]) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 0a1f258e5..fe7d53637 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -14,10 +14,10 @@ import attr import py import six from more_itertools import flatten -from py._code.code import FormattedExcinfo import _pytest from _pytest import nodes +from _pytest._code.code import FormattedExcinfo from _pytest._code.code import TerminalRepr from _pytest.compat import _format_args from _pytest.compat import _PytestWrapper diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 7e255dc9c..4b1564791 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -4,6 +4,7 @@ from __future__ import division from __future__ import print_function import codecs +import distutils.spawn import gc import os import platform @@ -80,7 +81,7 @@ class LsofFdLeakChecker(object): def _exec_lsof(self): pid = os.getpid() - return py.process.cmdexec("lsof -Ffn0 -p %d" % pid) + return subprocess.check_output(("lsof", "-Ffn0", "-p", str(pid))).decode() def _parse_lsof_output(self, out): def isopen(line): @@ -107,11 +108,8 @@ class LsofFdLeakChecker(object): def matching_platform(self): try: - py.process.cmdexec("lsof -v") - except (py.process.cmdexec.Error, UnicodeDecodeError): - # cmdexec may raise UnicodeDecodeError on Windows systems with - # locale other than English: - # https://bitbucket.org/pytest-dev/py/issues/66 + subprocess.check_output(("lsof", "-v")) + except (OSError, subprocess.CalledProcessError): return False else: return True @@ -153,7 +151,7 @@ def getexecutable(name, cache={}): try: return cache[name] except KeyError: - executable = py.path.local.sysfind(name) + executable = distutils.spawn.find_executable(name) if executable: import subprocess diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 48a50178f..0499f0c7f 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -936,7 +936,7 @@ class Metafunc(fixtures.FuncargnamesCompatAttr): :rtype: List[str] :return: the list of ids for each argname given """ - from py.io import saferepr + from _pytest._io.saferepr import saferepr idfn = None if callable(ids): diff --git a/testing/test_capture.py b/testing/test_capture.py index 43cd700d3..effc44cd4 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -4,8 +4,10 @@ from __future__ import division from __future__ import print_function import contextlib +import io import os import pickle +import subprocess import sys import textwrap from io import UnsupportedOperation @@ -851,7 +853,7 @@ class TestCaptureIO(object): def test_bytes_io(): - f = py.io.BytesIO() + f = io.BytesIO() f.write(b"hello") with pytest.raises(TypeError): f.write(u"hello") @@ -933,18 +935,18 @@ def test_dupfile(tmpfile): def test_dupfile_on_bytesio(): - io = py.io.BytesIO() - f = capture.safe_text_dupfile(io, "wb") + bio = io.BytesIO() + f = capture.safe_text_dupfile(bio, "wb") f.write("hello") - assert io.getvalue() == b"hello" + assert bio.getvalue() == b"hello" assert "BytesIO object" in f.name def test_dupfile_on_textio(): - io = py.io.TextIO() - f = capture.safe_text_dupfile(io, "wb") + tio = py.io.TextIO() + f = capture.safe_text_dupfile(tio, "wb") f.write("hello") - assert io.getvalue() == "hello" + assert tio.getvalue() == "hello" assert not hasattr(f, "name") @@ -952,12 +954,12 @@ def test_dupfile_on_textio(): def lsof_check(): pid = os.getpid() try: - out = py.process.cmdexec("lsof -p %d" % pid) - except (py.process.cmdexec.Error, UnicodeDecodeError): + out = subprocess.check_output(("lsof", "-p", str(pid))).decode() + except (OSError, subprocess.CalledProcessError, UnicodeDecodeError): # about UnicodeDecodeError, see note on pytester pytest.skip("could not run 'lsof'") yield - out2 = py.process.cmdexec("lsof -p %d" % pid) + out2 = subprocess.check_output(("lsof", "-p", str(pid))).decode() len1 = len([x for x in out.split("\n") if "REG" in x]) len2 = len([x for x in out2.split("\n") if "REG" in x]) assert len2 < len1 + 3, out2 diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index c3b4ee698..baf58a4f5 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -3,6 +3,7 @@ from __future__ import division from __future__ import print_function import argparse +import distutils.spawn import os import sys @@ -296,7 +297,7 @@ class TestParser(object): def test_argcomplete(testdir, monkeypatch): - if not py.path.local.sysfind("bash"): + if not distutils.spawn.find_executable("bash"): pytest.skip("bash not available") script = str(testdir.tmpdir.join("test_argcomplete")) pytest_bin = sys.argv[0] From a2954578aa04744795bfb018504ee1879423c722 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Tue, 22 Jan 2019 20:25:51 +0100 Subject: [PATCH 12/18] Remove stdlib test --- testing/test_capture.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/testing/test_capture.py b/testing/test_capture.py index effc44cd4..81ab4e8a8 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -852,15 +852,6 @@ class TestCaptureIO(object): assert f.getvalue() == "foo\r\n" -def test_bytes_io(): - f = io.BytesIO() - f.write(b"hello") - with pytest.raises(TypeError): - f.write(u"hello") - s = f.getvalue() - assert s == b"hello" - - def test_dontreadfrominput(): from _pytest.capture import DontReadFromInput From 9543d1901f3704432ff2f7a21d4a17fe9cf6cb2c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 22 Jan 2019 19:42:22 -0200 Subject: [PATCH 13/18] Group warnings by message instead of by test id --- changelog/4402.bugfix.rst | 4 ++ src/_pytest/terminal.py | 42 +++++++++---------- .../test_group_warnings_by_message.py | 16 +++++++ testing/test_warnings.py | 19 +++++++++ 4 files changed, 60 insertions(+), 21 deletions(-) create mode 100644 changelog/4402.bugfix.rst create mode 100644 testing/example_scripts/warnings/test_group_warnings_by_message.py diff --git a/changelog/4402.bugfix.rst b/changelog/4402.bugfix.rst new file mode 100644 index 000000000..9b338aaa5 --- /dev/null +++ b/changelog/4402.bugfix.rst @@ -0,0 +1,4 @@ +Warning summary now groups warnings by message instead of by test id. + +This makes the output more compact and better conveys the general idea of how much code is +actually generating warnings, instead of how many tests call that code. diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index bea02306b..2a3d71a69 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -7,7 +7,7 @@ from __future__ import division from __future__ import print_function import argparse -import itertools +import collections import platform import sys import time @@ -724,33 +724,33 @@ class TerminalReporter(object): final = hasattr(self, "_already_displayed_warnings") if final: - warnings = all_warnings[self._already_displayed_warnings :] + warning_reports = all_warnings[self._already_displayed_warnings :] else: - warnings = all_warnings - self._already_displayed_warnings = len(warnings) - if not warnings: + warning_reports = all_warnings + self._already_displayed_warnings = len(warning_reports) + if not warning_reports: return - grouped = itertools.groupby( - warnings, key=lambda wr: wr.get_location(self.config) - ) + reports_grouped_by_message = collections.OrderedDict() + for wr in warning_reports: + reports_grouped_by_message.setdefault(wr.message, []).append(wr) title = "warnings summary (final)" if final else "warnings summary" self.write_sep("=", title, yellow=True, bold=False) - for location, warning_records in grouped: - # legacy warnings show their location explicitly, while standard warnings look better without - # it because the location is already formatted into the message - warning_records = list(warning_records) - if location: - self._tw.line(str(location)) - for w in warning_records: + for message, warning_reports in reports_grouped_by_message.items(): + has_any_location = False + for w in warning_reports: + location = w.get_location(self.config) if location: - lines = w.message.splitlines() - indented = "\n".join(" " + x for x in lines) - message = indented.rstrip() - else: - message = w.message.rstrip() - self._tw.line(message) + self._tw.line(str(location)) + has_any_location = True + if has_any_location: + lines = message.splitlines() + indented = "\n".join(" " + x for x in lines) + message = indented.rstrip() + else: + message = message.rstrip() + self._tw.line(message) self._tw.line() self._tw.line("-- Docs: https://docs.pytest.org/en/latest/warnings.html") diff --git a/testing/example_scripts/warnings/test_group_warnings_by_message.py b/testing/example_scripts/warnings/test_group_warnings_by_message.py new file mode 100644 index 000000000..c736135b7 --- /dev/null +++ b/testing/example_scripts/warnings/test_group_warnings_by_message.py @@ -0,0 +1,16 @@ +import warnings + +import pytest + + +def func(): + warnings.warn(UserWarning("foo")) + + +@pytest.mark.parametrize("i", range(5)) +def test_foo(i): + func() + + +def test_bar(): + func() diff --git a/testing/test_warnings.py b/testing/test_warnings.py index e8075b617..984aae027 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -693,3 +693,22 @@ def test_warnings_checker_twice(): warnings.warn("Message A", UserWarning) with expectation: warnings.warn("Message B", UserWarning) + + +@pytest.mark.filterwarnings("always") +def test_group_warnings_by_message(testdir): + testdir.copy_example("warnings/test_group_warnings_by_message.py") + result = testdir.runpytest() + result.stdout.fnmatch_lines( + [ + "test_group_warnings_by_message.py::test_foo[0]", + "test_group_warnings_by_message.py::test_foo[1]", + "test_group_warnings_by_message.py::test_foo[2]", + "test_group_warnings_by_message.py::test_foo[3]", + "test_group_warnings_by_message.py::test_foo[4]", + "test_group_warnings_by_message.py::test_bar", + ] + ) + warning_code = 'warnings.warn(UserWarning("foo"))' + assert warning_code in result.stdout.str() + assert result.stdout.str().count(warning_code) == 1 From 0f546c4670146fbb89407cad85518e3a7dcfa833 Mon Sep 17 00:00:00 2001 From: wim glenn Date: Tue, 22 Jan 2019 23:26:30 -0600 Subject: [PATCH 14/18] pytest_terminal_summary uses result from pytest_report_teststatus hook, rather than hardcoded strings Less hacky way to make XPASS yellow markup. Make sure collect reports still have a "when" attribute. xfail changed to XFAIL in the test report, for consistency with other outcomes which are all CAPS --- changelog/4667.bugfix.rst | 1 + src/_pytest/junitxml.py | 2 +- src/_pytest/reports.py | 4 +++ src/_pytest/skipping.py | 51 +++++++++++++++++++------------------- src/_pytest/terminal.py | 8 +++--- testing/acceptance_test.py | 4 +-- testing/test_skipping.py | 2 +- testing/test_terminal.py | 2 +- 8 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 changelog/4667.bugfix.rst diff --git a/changelog/4667.bugfix.rst b/changelog/4667.bugfix.rst new file mode 100644 index 000000000..ac2d8567c --- /dev/null +++ b/changelog/4667.bugfix.rst @@ -0,0 +1 @@ +``pytest_terminal_summary`` uses result from ``pytest_report_teststatus`` hook, rather than hardcoded strings. diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 1a06ea6d1..8b9af2a88 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -213,7 +213,7 @@ class _NodeReporter(object): self._add_simple(Junit.skipped, "collection skipped", report.longrepr) def append_error(self, report): - if getattr(report, "when", None) == "teardown": + if report.when == "teardown": msg = "test teardown failure" else: msg = "test setup failure" diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index b2010cc2e..3bdbce791 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -19,6 +19,8 @@ def getslaveinfoline(node): class BaseReport(object): + when = None + def __init__(self, **kw): self.__dict__.update(kw) @@ -169,6 +171,8 @@ class TeardownErrorReport(BaseReport): class CollectReport(BaseReport): + when = "collect" + def __init__(self, nodeid, outcome, longrepr, result, sections=(), **extra): self.nodeid = nodeid self.outcome = outcome diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index f755fc4eb..9f7f525ce 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -180,9 +180,9 @@ def pytest_runtest_makereport(item, call): def pytest_report_teststatus(report): if hasattr(report, "wasxfail"): if report.skipped: - return "xfailed", "x", "xfail" + return "xfailed", "x", "XFAIL" elif report.passed: - return "xpassed", "X", ("XPASS", {"yellow": True}) + return "xpassed", "X", "XPASS" # called by the terminalreporter instance/plugin @@ -191,11 +191,6 @@ def pytest_report_teststatus(report): def pytest_terminal_summary(terminalreporter): tr = terminalreporter if not tr.reportchars: - # for name in "xfailed skipped failed xpassed": - # if not tr.stats.get(name, 0): - # tr.write_line("HINT: use '-r' option to see extra " - # "summary info about tests") - # break return lines = [] @@ -209,21 +204,23 @@ def pytest_terminal_summary(terminalreporter): tr._tw.line(line) -def show_simple(terminalreporter, lines, stat, format): +def show_simple(terminalreporter, lines, stat): failed = terminalreporter.stats.get(stat) if failed: for rep in failed: + verbose_word = _get_report_str(terminalreporter, rep) pos = terminalreporter.config.cwd_relative_nodeid(rep.nodeid) - lines.append(format % (pos,)) + lines.append("%s %s" % (verbose_word, pos)) def show_xfailed(terminalreporter, lines): xfailed = terminalreporter.stats.get("xfailed") if xfailed: for rep in xfailed: + verbose_word = _get_report_str(terminalreporter, rep) pos = terminalreporter.config.cwd_relative_nodeid(rep.nodeid) reason = rep.wasxfail - lines.append("XFAIL %s" % (pos,)) + lines.append("%s %s" % (verbose_word, pos)) if reason: lines.append(" " + str(reason)) @@ -232,9 +229,10 @@ def show_xpassed(terminalreporter, lines): xpassed = terminalreporter.stats.get("xpassed") if xpassed: for rep in xpassed: + verbose_word = _get_report_str(terminalreporter, rep) pos = terminalreporter.config.cwd_relative_nodeid(rep.nodeid) reason = rep.wasxfail - lines.append("XPASS %s %s" % (pos, reason)) + lines.append("%s %s %s" % (verbose_word, pos, reason)) def folded_skips(skipped): @@ -260,39 +258,42 @@ def show_skipped(terminalreporter, lines): tr = terminalreporter skipped = tr.stats.get("skipped", []) if skipped: - # if not tr.hasopt('skipped'): - # tr.write_line( - # "%d skipped tests, specify -rs for more info" % - # len(skipped)) - # return + verbose_word = _get_report_str(terminalreporter, report=skipped[0]) fskips = folded_skips(skipped) if fskips: - # tr.write_sep("_", "skipped test summary") for num, fspath, lineno, reason in fskips: if reason.startswith("Skipped: "): reason = reason[9:] if lineno is not None: lines.append( - "SKIP [%d] %s:%d: %s" % (num, fspath, lineno + 1, reason) + "%s [%d] %s:%d: %s" + % (verbose_word, num, fspath, lineno + 1, reason) ) else: - lines.append("SKIP [%d] %s: %s" % (num, fspath, reason)) + lines.append("%s [%d] %s: %s" % (verbose_word, num, fspath, reason)) -def shower(stat, format): +def shower(stat): def show_(terminalreporter, lines): - return show_simple(terminalreporter, lines, stat, format) + return show_simple(terminalreporter, lines, stat) return show_ +def _get_report_str(terminalreporter, report): + _category, _short, verbose = terminalreporter.config.hook.pytest_report_teststatus( + report=report + ) + return verbose + + REPORTCHAR_ACTIONS = { "x": show_xfailed, "X": show_xpassed, - "f": shower("failed", "FAIL %s"), - "F": shower("failed", "FAIL %s"), + "f": shower("failed"), + "F": shower("failed"), "s": show_skipped, "S": show_skipped, - "p": shower("passed", "PASSED %s"), - "E": shower("error", "ERROR %s"), + "p": shower("passed"), + "E": shower("error"), } diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index bea02306b..9e1d1cb62 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -376,8 +376,11 @@ class TerminalReporter(object): return running_xdist = hasattr(rep, "node") if markup is None: - if rep.passed: + was_xfail = hasattr(report, "wasxfail") + if rep.passed and not was_xfail: markup = {"green": True} + elif rep.passed and was_xfail: + markup = {"yellow": True} elif rep.failed: markup = {"red": True} elif rep.skipped: @@ -806,8 +809,7 @@ class TerminalReporter(object): self.write_sep("=", "ERRORS") for rep in self.stats["error"]: msg = self._getfailureheadline(rep) - if not hasattr(rep, "when"): - # collect + if rep.when == "collect": msg = "ERROR collecting " + msg elif rep.when == "setup": msg = "ERROR at setup of " + msg diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index b7f914335..af30f2123 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -804,8 +804,8 @@ class TestInvocationVariants(object): result = testdir.runpytest("-rf") lines = result.stdout.str().splitlines() for line in lines: - if line.startswith("FAIL "): - testid = line[5:].strip() + if line.startswith(("FAIL ", "FAILED ")): + _fail, _sep, testid = line.partition(" ") break result = testdir.runpytest(testid, "-rf") result.stdout.fnmatch_lines([line, "*1 failed*"]) diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 6b18011b6..be3f74760 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1202,6 +1202,6 @@ def test_summary_list_after_errors(testdir): [ "=* FAILURES *=", "*= short test summary info =*", - "FAIL test_summary_list_after_errors.py::test_fail", + "FAILED test_summary_list_after_errors.py::test_fail", ] ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 06345f88d..6837da5e0 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -625,7 +625,7 @@ class TestTerminalFunctional(object): "*test_verbose_reporting.py::test_fail *FAIL*", "*test_verbose_reporting.py::test_pass *PASS*", "*test_verbose_reporting.py::TestClass::test_skip *SKIP*", - "*test_verbose_reporting.py::test_gen *xfail*", + "*test_verbose_reporting.py::test_gen *XFAIL*", ] ) assert result.ret == 1 From 2d18546870837a12ebf2bcf93c2d5aa74cbaf0df Mon Sep 17 00:00:00 2001 From: wim glenn Date: Thu, 24 Jan 2019 11:12:59 -0600 Subject: [PATCH 15/18] resolving report.when attribute should be reliable now --- src/_pytest/pytester.py | 13 +++++-------- src/_pytest/skipping.py | 7 +++++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 4b1564791..9cadd2f9d 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -305,13 +305,10 @@ class HookRecorder(object): """return a testreport whose dotted import path matches""" values = [] for rep in self.getreports(names=names): - try: - if not when and rep.when != "call" and rep.passed: - # setup/teardown passing reports - let's ignore those - continue - except AttributeError: - pass - if when and getattr(rep, "when", None) != when: + if not when and rep.when != "call" and rep.passed: + # setup/teardown passing reports - let's ignore those + continue + if when and rep.when != when: continue if not inamepart or inamepart in rep.nodeid.split("::"): values.append(rep) @@ -338,7 +335,7 @@ class HookRecorder(object): failed = [] for rep in self.getreports("pytest_collectreport pytest_runtest_logreport"): if rep.passed: - if getattr(rep, "when", None) == "call": + if rep.when == "call": passed.append(rep) elif rep.skipped: skipped.append(rep) diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 9f7f525ce..49676aa80 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -244,8 +244,11 @@ def folded_skips(skipped): # folding reports with global pytestmark variable # this is workaround, because for now we cannot identify the scope of a skip marker # TODO: revisit after marks scope would be fixed - when = getattr(event, "when", None) - if when == "setup" and "skip" in keywords and "pytestmark" not in keywords: + if ( + event.when == "setup" + and "skip" in keywords + and "pytestmark" not in keywords + ): key = (key[0], None, key[2]) d.setdefault(key, []).append(event) values = [] From 8cf097635e7cfdf9837f89227d3f0f080b57b684 Mon Sep 17 00:00:00 2001 From: wim glenn Date: Thu, 24 Jan 2019 12:59:36 -0600 Subject: [PATCH 16/18] =?UTF-8?q?Fixed=20one=20weird=20test=20that=20creat?= =?UTF-8?q?es=20a=20class=20instead=20of=20using=20mocks..=20=C2=AF\=5F(?= =?UTF-8?q?=E3=83=84)=5F/=C2=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- testing/test_skipping.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/test_skipping.py b/testing/test_skipping.py index be3f74760..b2a515f11 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -770,6 +770,7 @@ def test_skip_reasons_folding(): # ev3 might be a collection report ev3 = X() + ev3.when = "collect" ev3.longrepr = longrepr ev3.skipped = True From 067f2c6148500b88d50837b4514598b176b3eda1 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 24 Jan 2019 20:41:18 -0200 Subject: [PATCH 17/18] Improve pytest.raises 'message' deprecation docs Based on recent discussions in #3974 --- doc/en/deprecations.rst | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index ebde15734..d18b458d8 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -25,11 +25,32 @@ Below is a complete list of all pytest features which are considered deprecated. .. deprecated:: 4.1 It is a common mistake to think this parameter will match the exception message, while in fact -it only serves to provide a custom message in case the ``pytest.raises`` check fails. To avoid this -mistake and because it is believed to be little used, pytest is deprecating it without providing -an alternative for the moment. +it only serves to provide a custom message in case the ``pytest.raises`` check fails. To prevent +users from making this mistake, and because it is believed to be little used, pytest is +deprecating it without providing an alternative for the moment. -If you have concerns about this, please comment on `issue #3974 `__. +If you have a valid use case for this parameter, consider that to obtain the same results +you can just call ``pytest.fail`` manually at the end of the ``with`` statement. + +For example: + +.. code-block:: python + + with pytest.raises(TimeoutError, message="Client got unexpected message"): + wait_for(websocket.recv(), 0.5) + + +Becomes: + +.. code-block:: python + + with pytest.raises(TimeoutError): + wait_for(websocket.recv(), 0.5) + pytest.fail("Client got unexpected message") + + +If you still have concerns about this deprecation and future removal, please comment on +`issue #3974 `__. ``pytest.config`` global From 1c5009c3fb3b96aa9a551bacffe51eda7043a036 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 28 Jan 2019 12:50:04 -0200 Subject: [PATCH 18/18] Handle unittest.SkipTest exception with non-ascii characters Fix #4669 --- changelog/4669.bugfix.rst | 1 + src/_pytest/nose.py | 4 +++- testing/test_nose.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 changelog/4669.bugfix.rst diff --git a/changelog/4669.bugfix.rst b/changelog/4669.bugfix.rst new file mode 100644 index 000000000..e5c18353c --- /dev/null +++ b/changelog/4669.bugfix.rst @@ -0,0 +1 @@ +Correctly handle ``unittest.SkipTest`` exception containing non-ascii characters on Python 2. diff --git a/src/_pytest/nose.py b/src/_pytest/nose.py index 6facc547f..13dda68e7 100644 --- a/src/_pytest/nose.py +++ b/src/_pytest/nose.py @@ -5,6 +5,8 @@ from __future__ import print_function import sys +import six + from _pytest import python from _pytest import runner from _pytest import unittest @@ -24,7 +26,7 @@ def pytest_runtest_makereport(item, call): if call.excinfo and call.excinfo.errisinstance(get_skip_exceptions()): # let's substitute the excinfo with a pytest.skip one call2 = runner.CallInfo.from_call( - lambda: runner.skip(str(call.excinfo.value)), call.when + lambda: runner.skip(six.text_type(call.excinfo.value)), call.when ) call.excinfo = call2.excinfo diff --git a/testing/test_nose.py b/testing/test_nose.py index 3e9966529..6f3d292dd 100644 --- a/testing/test_nose.py +++ b/testing/test_nose.py @@ -1,3 +1,4 @@ +# encoding: utf-8 from __future__ import absolute_import from __future__ import division from __future__ import print_function @@ -366,3 +367,17 @@ def test_nottest_class_decorator(testdir): assert not reprec.getfailedcollections() calls = reprec.getreports("pytest_runtest_logreport") assert not calls + + +def test_skip_test_with_unicode(testdir): + testdir.makepyfile( + """ + # encoding: utf-8 + import unittest + class TestClass(): + def test_io(self): + raise unittest.SkipTest(u'😊') + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines("* 1 skipped *")