diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ecfc004ba..be0881649 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,3 +51,17 @@ 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: > + (?x)\bpy\.( + _code\.| + builtin\.| + code\.| + io\.(BytesIO|saferepr)| + path\.local\.sysfind| + process\.| + std\. + ) + types: [python] diff --git a/AUTHORS b/AUTHORS index 957a86973..35e07dcb4 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 @@ -52,6 +53,7 @@ Christian Boelsen Christian Theunert Christian Tismer Christopher Gilling +Christopher Dignam CrazyMerlyn Cyrus Maden Dhiren Serai 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/changelog/4536.bugfix.rst b/changelog/4536.bugfix.rst new file mode 100644 index 000000000..0ec84a62b --- /dev/null +++ b/changelog/4536.bugfix.rst @@ -0,0 +1 @@ +``monkeypatch.delattr`` handles class descriptors like ``staticmethod``/``classmethod``. 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/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/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/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/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/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/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 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/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**. 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 4473547d6..a38af989a 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 @@ -142,7 +143,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 @@ -421,7 +422,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: " @@ -618,7 +619,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/_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/_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..4d1d18d3b --- /dev/null +++ b/src/_pytest/_io/saferepr.py @@ -0,0 +1,72 @@ +import sys + +from six.moves import 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 u"'%s'" % u + elif '"' not in u: + return u'"%s"' % u + else: + 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 :] + return s + + def repr_instance(self, x, level): + 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 Exception: + cls, e, tb = sys.exc_info() + exc_name = getattr(cls, "__name__", "unknown") + try: + exc_info = str(e) + 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), + ) + 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/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 16f6fa243..301bdedc5 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 @@ -471,7 +472,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##` @@ -490,7 +491,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 @@ -499,7 +500,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): @@ -665,7 +666,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) @@ -740,7 +741,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/fixtures.py b/src/_pytest/fixtures.py index 5f288787e..3947d190c 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/junitxml.py b/src/_pytest/junitxml.py index e2e1d43bb..122e0c7ce 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -244,7 +244,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/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/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 2c81de177..46d9718da 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/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/src/_pytest/pytester.py b/src/_pytest/pytester.py index c59628948..9cadd2f9d 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 @@ -20,6 +21,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 @@ -79,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): @@ -106,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 @@ -152,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 @@ -306,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) @@ -339,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) @@ -1225,9 +1221,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): diff --git a/src/_pytest/python.py b/src/_pytest/python.py index a777e7318..85373f47c 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1029,7 +1029,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/src/_pytest/reports.py b/src/_pytest/reports.py index 6ea5798cf..eabfe88e5 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) @@ -159,6 +161,8 @@ class TestReport(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..49676aa80 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): @@ -246,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 = [] @@ -260,39 +261,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 3c6504b66..d4ca8a7ae 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 @@ -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: @@ -727,33 +730,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") @@ -809,8 +812,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/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/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/io/test_saferepr.py b/testing/io/test_saferepr.py new file mode 100644 index 000000000..901203088 --- /dev/null +++ b/testing/io/test_saferepr.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from _pytest._io.saferepr import saferepr + + +def test_simple_repr(): + assert saferepr(1) == "1" + assert saferepr(None) == "None" + + +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(): + class A: + def __repr__(): + raise ValueError("...") + + s = saferepr(("*" * 50, A()), maxsize=25) + assert len(s) == 25 + assert s[0] == "(" and s[-1] == ")" + + +def test_exceptions(): + class BrokenRepr: + def __init__(self, ex): + self.ex = ex + + 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(): + 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 diff --git a/testing/python/collect.py b/testing/python/collect.py index b9954c3f0..bc7462674 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -960,7 +960,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 @@ -981,7 +981,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): diff --git a/testing/test_capture.py b/testing/test_capture.py index 43cd700d3..81ab4e8a8 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 @@ -850,15 +852,6 @@ class TestCaptureIO(object): assert f.getvalue() == "foo\r\n" -def test_bytes_io(): - f = py.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 @@ -933,18 +926,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 +945,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_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", [ diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 9e44b4975..0a953d3f1 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() 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 *") 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] diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 6b18011b6..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 @@ -1202,6 +1203,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 89d455b39..71e49fb42 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -614,7 +614,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 diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 3e6bde379..22a5bf796 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( """ 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