From 04f27d4eb469fb9c76fd2d100564a0f7d30028df Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 18 Oct 2019 18:26:03 +0200 Subject: [PATCH 001/158] unittest: do not use TestCase.debug() with `--pdb` Fixes https://github.com/pytest-dev/pytest/issues/5991 Fixes https://github.com/pytest-dev/pytest/issues/3823 Ref: https://github.com/pytest-dev/pytest-django/issues/772 Ref: https://github.com/pytest-dev/pytest/pull/1890 Ref: https://github.com/pytest-dev/pytest-django/pull/782 - inject wrapped testMethod - adjust test_trial_error - add test for `--trace` with unittests --- changelog/3823.bugfix.rst | 1 + changelog/5991.bugfix.rst | 1 + doc/en/unittest.rst | 11 ------- src/_pytest/unittest.py | 62 ++++++++++++++++++++++++++------------- testing/test_unittest.py | 47 ++++++++++++++++++++++++----- 5 files changed, 83 insertions(+), 39 deletions(-) create mode 100644 changelog/3823.bugfix.rst create mode 100644 changelog/5991.bugfix.rst diff --git a/changelog/3823.bugfix.rst b/changelog/3823.bugfix.rst new file mode 100644 index 000000000..a653fecdd --- /dev/null +++ b/changelog/3823.bugfix.rst @@ -0,0 +1 @@ +``--trace`` now works with unittests. diff --git a/changelog/5991.bugfix.rst b/changelog/5991.bugfix.rst new file mode 100644 index 000000000..5659069da --- /dev/null +++ b/changelog/5991.bugfix.rst @@ -0,0 +1 @@ +Fix interaction with ``--pdb`` and unittests: do not use unittest's ``TestCase.debug()``. diff --git a/doc/en/unittest.rst b/doc/en/unittest.rst index 0f6737c0d..cd7858190 100644 --- a/doc/en/unittest.rst +++ b/doc/en/unittest.rst @@ -238,17 +238,6 @@ was executed ahead of the ``test_method``. .. _pdb-unittest-note: -.. note:: - - Running tests from ``unittest.TestCase`` subclasses with ``--pdb`` will - disable tearDown and cleanup methods for the case that an Exception - occurs. This allows proper post mortem debugging for all applications - which have significant logic in their tearDown machinery. However, - supporting this feature has the following side effect: If people - overwrite ``unittest.TestCase`` ``__call__`` or ``run``, they need to - to overwrite ``debug`` in the same way (this is also true for standard - unittest). - .. note:: Due to architectural differences between the two frameworks, setup and diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 11dc77cc4..bef209b0d 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -1,4 +1,5 @@ """ discovery and running of std-library "unittest" style tests. """ +import functools import sys import traceback @@ -107,6 +108,7 @@ class TestCaseFunction(Function): nofuncargs = True _excinfo = None _testcase = None + _need_tearDown = None def setup(self): self._testcase = self.parent.obj(self.name) @@ -115,6 +117,8 @@ class TestCaseFunction(Function): self._request._fillfixtures() def teardown(self): + if self._need_tearDown: + self._testcase.tearDown() self._testcase = None self._obj = None @@ -187,29 +191,45 @@ class TestCaseFunction(Function): def stopTest(self, testcase): pass - def _handle_skip(self): - # implements the skipping machinery (see #2137) - # analog to pythons Lib/unittest/case.py:run - testMethod = getattr(self._testcase, self._testcase._testMethodName) - if getattr(self._testcase.__class__, "__unittest_skip__", False) or getattr( - testMethod, "__unittest_skip__", False - ): - # If the class or method was skipped. - skip_why = getattr( - self._testcase.__class__, "__unittest_skip_why__", "" - ) or getattr(testMethod, "__unittest_skip_why__", "") - self._testcase._addSkip(self, self._testcase, skip_why) - return True - return False - def runtest(self): - if self.config.pluginmanager.get_plugin("pdbinvoke") is None: + testMethod = getattr(self._testcase, self._testcase._testMethodName) + + class _GetOutOf_testPartExecutor(KeyboardInterrupt): + """Helper exception to get out of unittests's testPartExecutor.""" + + unittest = sys.modules.get("unittest") + + reraise = () + if unittest: + reraise += (unittest.SkipTest,) + + @functools.wraps(testMethod) + def wrapped_testMethod(*args, **kwargs): + try: + self.ihook.pytest_pyfunc_call(pyfuncitem=self) + except reraise: + raise + except Exception as exc: + expecting_failure_method = getattr( + testMethod, "__unittest_expecting_failure__", False + ) + expecting_failure_class = getattr( + self, "__unittest_expecting_failure__", False + ) + expecting_failure = expecting_failure_class or expecting_failure_method + self._need_tearDown = True + + if expecting_failure: + raise + + raise _GetOutOf_testPartExecutor(exc) + + self._testcase._wrapped_testMethod = wrapped_testMethod + self._testcase._testMethodName = "_wrapped_testMethod" + try: self._testcase(result=self) - else: - # disables tearDown and cleanups for post mortem debugging (see #1890) - if self._handle_skip(): - return - self._testcase.debug() + except _GetOutOf_testPartExecutor as exc: + raise exc.args[0] from exc.args[0] def _prunetraceback(self, excinfo): Function._prunetraceback(self, excinfo) diff --git a/testing/test_unittest.py b/testing/test_unittest.py index f56284d85..b34f54313 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -537,24 +537,28 @@ class TestTrialUnittest: ) result.stdout.fnmatch_lines( [ - "test_trial_error.py::TC::test_four FAILED", + "test_trial_error.py::TC::test_four SKIPPED", "test_trial_error.py::TC::test_four ERROR", "test_trial_error.py::TC::test_one FAILED", "test_trial_error.py::TC::test_three FAILED", - "test_trial_error.py::TC::test_two FAILED", + "test_trial_error.py::TC::test_two SKIPPED", + "test_trial_error.py::TC::test_two ERROR", "*ERRORS*", "*_ ERROR at teardown of TC.test_four _*", + "NOTE: Incompatible Exception Representation, displaying natively:", + "*DelayedCalls*", + "*_ ERROR at teardown of TC.test_two _*", + "NOTE: Incompatible Exception Representation, displaying natively:", "*DelayedCalls*", "*= FAILURES =*", - "*_ TC.test_four _*", - "*NameError*crash*", + # "*_ TC.test_four _*", + # "*NameError*crash*", "*_ TC.test_one _*", "*NameError*crash*", "*_ TC.test_three _*", + "NOTE: Incompatible Exception Representation, displaying natively:", "*DelayedCalls*", - "*_ TC.test_two _*", - "*NameError*crash*", - "*= 4 failed, 1 error in *", + "*= 2 failed, 2 skipped, 2 errors in *", ] ) @@ -1096,3 +1100,32 @@ def test_exit_outcome(testdir): ) result = testdir.runpytest() result.stdout.fnmatch_lines(["*Exit: pytest_exit called*", "*= no tests ran in *"]) + + +def test_trace(testdir, monkeypatch): + calls = [] + + def check_call(*args, **kwargs): + calls.append((args, kwargs)) + assert args == ("runcall",) + + class _pdb: + def runcall(*args, **kwargs): + calls.append((args, kwargs)) + + return _pdb + + monkeypatch.setattr("_pytest.debugging.pytestPDB._init_pdb", check_call) + + p1 = testdir.makepyfile( + """ + import unittest + + class MyTestCase(unittest.TestCase): + def test(self): + self.assertEqual('foo', 'foo') + """ + ) + result = testdir.runpytest("--trace", str(p1)) + assert len(calls) == 2 + assert result.ret == 0 From f7b1de70c037d0ca43adc25966677d5a78034abc Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 12 Nov 2019 12:47:03 -0300 Subject: [PATCH 002/158] No need to call tearDown on expected failures - Isolate logic for getting expected exceptions - Use original method name, as users see it when entering the debugger --- src/_pytest/unittest.py | 42 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index bef209b0d..de6be7dc5 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -108,7 +108,6 @@ class TestCaseFunction(Function): nofuncargs = True _excinfo = None _testcase = None - _need_tearDown = None def setup(self): self._testcase = self.parent.obj(self.name) @@ -117,8 +116,6 @@ class TestCaseFunction(Function): self._request._fillfixtures() def teardown(self): - if self._need_tearDown: - self._testcase.tearDown() self._testcase = None self._obj = None @@ -191,45 +188,44 @@ class TestCaseFunction(Function): def stopTest(self, testcase): pass + def _expecting_failure(self, test_method) -> bool: + """Return True if the given unittest method (or the entire class) is marked + with @expectedFailure""" + expecting_failure_method = getattr( + test_method, "__unittest_expecting_failure__", False + ) + expecting_failure_class = getattr(self, "__unittest_expecting_failure__", False) + return bool(expecting_failure_class or expecting_failure_method) + def runtest(self): + import unittest + testMethod = getattr(self._testcase, self._testcase._testMethodName) class _GetOutOf_testPartExecutor(KeyboardInterrupt): - """Helper exception to get out of unittests's testPartExecutor.""" - - unittest = sys.modules.get("unittest") - - reraise = () - if unittest: - reraise += (unittest.SkipTest,) + """Helper exception to get out of unittests's testPartExecutor (see TestCase.run).""" @functools.wraps(testMethod) def wrapped_testMethod(*args, **kwargs): + """Wrap the original method to call into pytest's machinery, so other pytest + features can have a chance to kick in (notably --pdb)""" try: self.ihook.pytest_pyfunc_call(pyfuncitem=self) - except reraise: + except unittest.SkipTest: raise except Exception as exc: - expecting_failure_method = getattr( - testMethod, "__unittest_expecting_failure__", False - ) - expecting_failure_class = getattr( - self, "__unittest_expecting_failure__", False - ) - expecting_failure = expecting_failure_class or expecting_failure_method - self._need_tearDown = True - + expecting_failure = self._expecting_failure(testMethod) if expecting_failure: raise - raise _GetOutOf_testPartExecutor(exc) - self._testcase._wrapped_testMethod = wrapped_testMethod - self._testcase._testMethodName = "_wrapped_testMethod" + setattr(self._testcase, self._testcase._testMethodName, wrapped_testMethod) try: self._testcase(result=self) except _GetOutOf_testPartExecutor as exc: raise exc.args[0] from exc.args[0] + finally: + delattr(self._testcase, self._testcase._testMethodName) def _prunetraceback(self, excinfo): Function._prunetraceback(self, excinfo) From 59369651dbe6a3bac420e16dcded9ad095b1680b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 12 Nov 2019 14:03:40 -0300 Subject: [PATCH 003/158] Bring back explicit tear down Otherwise 'normal' failures won't call teardown explicitly --- src/_pytest/unittest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index de6be7dc5..71ff580a6 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -110,12 +110,15 @@ class TestCaseFunction(Function): _testcase = None def setup(self): + self._needs_explicit_tearDown = False self._testcase = self.parent.obj(self.name) self._obj = getattr(self._testcase, self.name) if hasattr(self, "_request"): self._request._fillfixtures() def teardown(self): + if self._needs_explicit_tearDown: + self._testcase.tearDown() self._testcase = None self._obj = None @@ -217,6 +220,7 @@ class TestCaseFunction(Function): expecting_failure = self._expecting_failure(testMethod) if expecting_failure: raise + self._needs_explicit_tearDown = True raise _GetOutOf_testPartExecutor(exc) setattr(self._testcase, self._testcase._testMethodName, wrapped_testMethod) From 426a4cdca901606d3ffd716c063e394cec964f9f Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 18 Nov 2019 01:08:25 +0100 Subject: [PATCH 004/158] _idval: remove trailing newline from exception --- src/_pytest/python.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 30853de94..e6de5f3c6 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1156,8 +1156,7 @@ def _idval(val, argname, idx, idfn, item, config): if generated_id is not None: val = generated_id except Exception as e: - # See issue https://github.com/pytest-dev/pytest/issues/2169 - msg = "{}: error raised while trying to determine id of parameter '{}' at position {}\n" + msg = "{}: error raised while trying to determine id of parameter '{}' at position {}" msg = msg.format(item.nodeid, argname, idx) raise ValueError(msg) from e elif config: From 51f9cd0e02371d1a4770625aff178a3f8ab6db5d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 20 Nov 2019 16:03:32 +0200 Subject: [PATCH 005/158] argparsing: remove "map_long_option" Action attribute support This feature was added in commit 007a77c2ba14b3df8790efb433a2f849edf4f5d2, but was never used in pytest itself. A GitHub code search doesn't find any users either (only pytest repo copies). It seems safe to clean up. --- src/_pytest/config/argparsing.py | 16 +++++----------- testing/test_parseopt.py | 6 +++--- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 7cbb676bd..8366a8d66 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -409,8 +409,6 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): """shorten help for long options that differ only in extra hyphens - collapse **long** options that are the same except for extra hyphens - - special action attribute map_long_option allows suppressing additional - long options - shortcut if there are only two options and one of them is a short one - cache result on action object as this is called at least 2 times """ @@ -434,9 +432,6 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): action._formatted_action_invocation = orgstr return orgstr return_list = [] - option_map = getattr(action, "map_long_option", {}) - if option_map is None: - option_map = {} short_long = {} # type: Dict[str, str] for option in options: if len(option) == 2 or option[2] == " ": @@ -446,12 +441,11 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): 'long optional argument without "--": [%s]' % (option), self ) xxoption = option[2:] - if xxoption.split()[0] not in option_map: - shortened = xxoption.replace("-", "") - if shortened not in short_long or len(short_long[shortened]) < len( - xxoption - ): - short_long[shortened] = xxoption + shortened = xxoption.replace("-", "") + if shortened not in short_long or len(short_long[shortened]) < len( + xxoption + ): + short_long[shortened] = xxoption # now short_long has been filled out to the longest with dashes # **and** we keep the right option ordering from add_argument for option in options: diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 915747378..5f7d5222b 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -205,11 +205,11 @@ class TestParser: ) parser.add_argument( "-t", "--twoword", "--duo", "--two-word", "--two", help="foo" - ).map_long_option = {"two": "two-word"} + ) # throws error on --deux only! parser.add_argument( "-d", "--deuxmots", "--deux-mots", action="store_true", help="foo" - ).map_long_option = {"deux": "deux-mots"} + ) parser.add_argument("-s", action="store_true", help="single short") parser.add_argument("--abc", "-a", action="store_true", help="bar") parser.add_argument("--klm", "-k", "--kl-m", action="store_true", help="bar") @@ -221,7 +221,7 @@ class TestParser: ) parser.add_argument( "-x", "--exit-on-first", "--exitfirst", action="store_true", help="spam" - ).map_long_option = {"exitfirst": "exit-on-first"} + ) parser.add_argument("files_and_dirs", nargs="*") args = parser.parse_args(["-k", "--duo", "hallo", "--exitfirst"]) assert args.twoword == "hallo" From c0b1a39192a998b4368ac859677b7e22f8ee56f2 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 20 Nov 2019 17:56:39 +0100 Subject: [PATCH 006/158] minor: move internal _pformat_dispatch function --- src/_pytest/_io/saferepr.py | 21 +++++++++++++++++++++ src/_pytest/assertion/util.py | 22 +--------------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index 7fded872d..884f0a21e 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -80,3 +80,24 @@ def saferepr(obj: Any, maxsize: int = 240) -> str: around the Repr/reprlib functionality of the standard 2.6 lib. """ return SafeRepr(maxsize).repr(obj) + + +class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter): + """PrettyPrinter that always dispatches (regardless of width).""" + + def _format(self, object, stream, indent, allowance, context, level): + p = self._dispatch.get(type(object).__repr__, None) + + objid = id(object) + if objid in context or p is None: + return super()._format(object, stream, indent, allowance, context, level) + + context[objid] = 1 + p(self, object, stream, indent, allowance, context, level + 1) + del context[objid] + + +def _pformat_dispatch(object, indent=1, width=80, depth=None, *, compact=False): + return AlwaysDispatchingPrettyPrinter( + indent=1, width=80, depth=None, compact=False + ).pformat(object) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 4af35bd57..67f8d4618 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -13,6 +13,7 @@ from typing import Tuple import _pytest._code from _pytest import outcomes +from _pytest._io.saferepr import _pformat_dispatch from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr from _pytest.compat import ATTRS_EQ_FIELD @@ -28,27 +29,6 @@ _reprcompare = None # type: Optional[Callable[[str, object, object], Optional[s _assertion_pass = None # type: Optional[Callable[[int, str, str], None]] -class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter): - """PrettyPrinter that always dispatches (regardless of width).""" - - def _format(self, object, stream, indent, allowance, context, level): - p = self._dispatch.get(type(object).__repr__, None) - - objid = id(object) - if objid in context or p is None: - return super()._format(object, stream, indent, allowance, context, level) - - context[objid] = 1 - p(self, object, stream, indent, allowance, context, level + 1) - del context[objid] - - -def _pformat_dispatch(object, indent=1, width=80, depth=None, *, compact=False): - return AlwaysDispatchingPrettyPrinter( - indent=1, width=80, depth=None, compact=False - ).pformat(object) - - def format_explanation(explanation: str) -> str: """This formats an explanation From ccb3ef3b33fcf419d03260c1d18f352da373725d Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 20 Nov 2019 18:02:00 +0100 Subject: [PATCH 007/158] testing/python/metafunc.py: import _idval once --- testing/python/metafunc.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 65855f724..d3be5504e 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -9,6 +9,7 @@ from hypothesis import strategies import pytest from _pytest import fixtures from _pytest import python +from _pytest.python import _idval class TestMetafunc: @@ -209,8 +210,6 @@ class TestMetafunc: deadline=400.0 ) # very close to std deadline and CI boxes are not reliable in CPU power def test_idval_hypothesis(self, value): - from _pytest.python import _idval - escaped = _idval(value, "a", 6, None, item=None, config=None) assert isinstance(escaped, str) escaped.encode("ascii") @@ -221,8 +220,6 @@ class TestMetafunc: escapes if they're not. """ - from _pytest.python import _idval - values = [ ("", ""), ("ascii", "ascii"), @@ -242,7 +239,6 @@ class TestMetafunc: disable_test_id_escaping_and_forfeit_all_rights_to_community_support option. (#5294) """ - from _pytest.python import _idval class MockConfig: def __init__(self, config): @@ -274,8 +270,6 @@ class TestMetafunc: "binary escape", where any byte < 127 is escaped into its hex form. - python3: bytes objects are always escaped using "binary escape". """ - from _pytest.python import _idval - values = [ (b"", ""), (b"\xc3\xb4\xff\xe4", "\\xc3\\xb4\\xff\\xe4"), @@ -289,7 +283,6 @@ class TestMetafunc: """unittest for the expected behavior to obtain ids for parametrized values that are classes or functions: their __name__. """ - from _pytest.python import _idval class TestClass: pass From 2c941b5d13cc8688a1d655fc0ef41a4de8c4e251 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 12 Nov 2019 14:52:19 +0100 Subject: [PATCH 008/158] parametrized: ids: support generator/iterator Fixes https://github.com/pytest-dev/pytest/issues/759 - Adjust test_parametrized_ids_invalid_type, create list to convert tuples Ref: https://github.com/pytest-dev/pytest/issues/1857#issuecomment-552922498 - Changelog for int to str conversion Ref: https://github.com/pytest-dev/pytest/issues/1857#issuecomment-552932952 --- changelog/1857.improvement.rst | 1 + changelog/759.improvement.rst | 1 + src/_pytest/mark/structures.py | 26 +++++++- src/_pytest/python.py | 93 ++++++++++++++++++++------- testing/python/metafunc.py | 112 +++++++++++++++++++++++++++++++-- 5 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 changelog/1857.improvement.rst create mode 100644 changelog/759.improvement.rst diff --git a/changelog/1857.improvement.rst b/changelog/1857.improvement.rst new file mode 100644 index 000000000..9a8ce90f5 --- /dev/null +++ b/changelog/1857.improvement.rst @@ -0,0 +1 @@ +``pytest.mark.parametrize`` accepts integers for ``ids`` again, converting it to strings. diff --git a/changelog/759.improvement.rst b/changelog/759.improvement.rst new file mode 100644 index 000000000..83ace7485 --- /dev/null +++ b/changelog/759.improvement.rst @@ -0,0 +1 @@ +``pytest.mark.parametrize`` supports iterators and generators for ``ids``. diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 3002f8abc..a4ec9665c 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -2,6 +2,8 @@ import inspect import warnings from collections import namedtuple from collections.abc import MutableMapping +from typing import List +from typing import Optional from typing import Set import attr @@ -144,7 +146,15 @@ class Mark: #: keyword arguments of the mark decorator kwargs = attr.ib() # Dict[str, object] - def combined_with(self, other): + #: source Mark for ids with parametrize Marks + _param_ids_from = attr.ib(type=Optional["Mark"], default=None, repr=False) + #: resolved/generated ids with parametrize Marks + _param_ids_generated = attr.ib(type=Optional[List[str]], default=None, repr=False) + + def _has_param_ids(self): + return "ids" in self.kwargs or len(self.args) >= 4 + + def combined_with(self, other: "Mark") -> "Mark": """ :param other: the mark to combine with :type other: Mark @@ -153,8 +163,20 @@ class Mark: combines by appending args and merging the mappings """ assert self.name == other.name + + # Remember source of ids with parametrize Marks. + param_ids_from = None # type: Optional[Mark] + if self.name == "parametrize": + if other._has_param_ids(): + param_ids_from = other + elif self._has_param_ids(): + param_ids_from = self + return Mark( - self.name, self.args + other.args, dict(self.kwargs, **other.kwargs) + self.name, + self.args + other.args, + dict(self.kwargs, **other.kwargs), + param_ids_from=param_ids_from, ) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index d787638c9..4e3a68867 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -10,6 +10,7 @@ from collections.abc import Sequence from functools import partial from textwrap import dedent from typing import List +from typing import Optional from typing import Tuple import py @@ -36,6 +37,7 @@ from _pytest.deprecated import FUNCARGNAMES from _pytest.main import FSHookProxy from _pytest.mark import MARK_GEN from _pytest.mark.structures import get_unpacked_marks +from _pytest.mark.structures import Mark from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail from _pytest.outcomes import skip @@ -122,7 +124,7 @@ def pytest_cmdline_main(config): def pytest_generate_tests(metafunc): for marker in metafunc.definition.iter_markers(name="parametrize"): - metafunc.parametrize(*marker.args, **marker.kwargs) + metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker) def pytest_configure(config): @@ -914,7 +916,16 @@ class Metafunc: warnings.warn(FUNCARGNAMES, stacklevel=2) return self.fixturenames - def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None): + def parametrize( + self, + argnames, + argvalues, + indirect=False, + ids=None, + scope=None, + *, + _param_mark: Optional[Mark] = None + ): """ Add new invocations to the underlying test function using the list of argvalues for the given argnames. Parametrization is performed during the collection phase. If you need to setup expensive resources @@ -937,13 +948,22 @@ class Metafunc: function so that it can perform more expensive setups during the setup phase of a test rather than at collection time. - :arg ids: list of string ids, or a callable. - If strings, each is corresponding to the argvalues so that they are - part of the test id. If None is given as id of specific test, the - automatically generated id for that argument will be used. - If callable, it should take one argument (a single argvalue) and return - a string or return None. If None, the automatically generated id for that - argument will be used. + :arg ids: sequence of (or generator for) ids for ``argvalues``, + or a callable to return part of the id for each argvalue. + + With sequences (and generators like ``itertools.count()``) the + returned ids should be of type ``string``, ``int``, ``float``, + ``bool``, or ``None``. + They are mapped to the corresponding index in ``argvalues``. + ``None`` means to use the auto-generated id. + + If it is a callable it will be called for each entry in + ``argvalues``, and the return value is used as part of the + auto-generated id for the whole set (where parts are joined with + dashes ("-")). + This is useful to provide more specific ids for certain items, e.g. + dates. Returning ``None`` will use an auto-generated id. + If no ids are provided they will be generated automatically from the argvalues. @@ -977,8 +997,18 @@ class Metafunc: arg_values_types = self._resolve_arg_value_types(argnames, indirect) + # Use any already (possibly) generated ids with parametrize Marks. + if _param_mark and _param_mark._param_ids_from: + generated_ids = _param_mark._param_ids_from._param_ids_generated + if generated_ids is not None: + ids = generated_ids + ids = self._resolve_arg_ids(argnames, ids, parameters, item=self.definition) + # Store used (possibly generated) ids with parametrize Marks. + if _param_mark and _param_mark._param_ids_from and generated_ids is None: + object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) + scopenum = scope2index( scope, descr="parametrize() call in {}".format(self.function.__name__) ) @@ -1013,27 +1043,48 @@ class Metafunc: :rtype: List[str] :return: the list of ids for each argname given """ - from _pytest._io.saferepr import saferepr - idfn = None if callable(ids): idfn = ids ids = None if ids: func_name = self.function.__name__ - if len(ids) != len(parameters): - msg = "In {}: {} parameter sets specified, with different number of ids: {}" - fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False) - for id_value in ids: - if id_value is not None and not isinstance(id_value, str): - msg = "In {}: ids must be list of strings, found: {} (type: {!r})" - fail( - msg.format(func_name, saferepr(id_value), type(id_value)), - pytrace=False, - ) + ids = self._validate_ids(ids, parameters, func_name) ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item) return ids + def _validate_ids(self, ids, parameters, func_name): + try: + len(ids) + except TypeError: + try: + it = iter(ids) + except TypeError: + raise TypeError("ids must be a callable, sequence or generator") + else: + import itertools + + new_ids = list(itertools.islice(it, len(parameters))) + else: + new_ids = list(ids) + + if len(new_ids) != len(parameters): + msg = "In {}: {} parameter sets specified, with different number of ids: {}" + fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False) + for idx, id_value in enumerate(new_ids): + if id_value is not None: + if isinstance(id_value, (float, int, bool)): + new_ids[idx] = str(id_value) + elif not isinstance(id_value, str): + from _pytest._io.saferepr import saferepr + + msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}" + fail( + msg.format(func_name, saferepr(id_value), type(id_value), idx), + pytrace=False, + ) + return new_ids + def _resolve_arg_value_types(self, argnames, indirect): """Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg" to the function, based on the ``indirect`` parameter of the parametrized() call. diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index d3be5504e..9a1e1f968 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -9,6 +9,7 @@ from hypothesis import strategies import pytest from _pytest import fixtures from _pytest import python +from _pytest.outcomes import fail from _pytest.python import _idval @@ -62,6 +63,39 @@ class TestMetafunc: pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6])) pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6])) + with pytest.raises( + TypeError, match="^ids must be a callable, sequence or generator$" + ): + metafunc.parametrize("y", [5, 6], ids=42) + + def test_parametrize_error_iterator(self): + def func(x): + raise NotImplementedError() + + class Exc(Exception): + def __repr__(self): + return "Exc(from_gen)" + + def gen(): + yield 0 + yield None + yield Exc() + + metafunc = self.Metafunc(func) + metafunc.parametrize("x", [1, 2], ids=gen()) + assert [(x.funcargs, x.id) for x in metafunc._calls] == [ + ({"x": 1}, "0"), + ({"x": 2}, "2"), + ] + with pytest.raises( + fail.Exception, + match=( + r"In func: ids must be list of string/float/int/bool, found:" + r" Exc\(from_gen\) \(type: \) at index 2" + ), + ): + metafunc.parametrize("x", [1, 2, 3], ids=gen()) + def test_parametrize_bad_scope(self, testdir): def func(x): pass @@ -168,6 +202,26 @@ class TestMetafunc: ("x", "y"), [("abc", "def"), ("ghi", "jkl")], ids=["one"] ) + def test_parametrize_ids_iterator_without_mark(self): + import itertools + + def func(x, y): + pass + + it = itertools.count() + + metafunc = self.Metafunc(func) + metafunc.parametrize("x", [1, 2], ids=it) + metafunc.parametrize("y", [3, 4], ids=it) + ids = [x.id for x in metafunc._calls] + assert ids == ["0-2", "0-3", "1-2", "1-3"] + + metafunc = self.Metafunc(func) + metafunc.parametrize("x", [1, 2], ids=it) + metafunc.parametrize("y", [3, 4], ids=it) + ids = [x.id for x in metafunc._calls] + assert ids == ["4-6", "4-7", "5-6", "5-7"] + def test_parametrize_empty_list(self): """#510""" @@ -527,9 +581,22 @@ class TestMetafunc: @pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids) def test(arg): assert arg + + @pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids) + def test_int(arg): + assert arg """ ) - assert testdir.runpytest().ret == 0 + result = testdir.runpytest("-vv", "-s") + result.stdout.fnmatch_lines( + [ + "test_parametrize_ids_returns_non_string.py::test[arg0] PASSED", + "test_parametrize_ids_returns_non_string.py::test[arg1] PASSED", + "test_parametrize_ids_returns_non_string.py::test_int[1] PASSED", + "test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED", + "test_parametrize_ids_returns_non_string.py::test_int[True] PASSED", + ] + ) def test_idmaker_with_ids(self): from _pytest.python import idmaker @@ -1179,12 +1246,12 @@ class TestMetafuncFunctional: result.stdout.fnmatch_lines(["* 1 skipped *"]) def test_parametrized_ids_invalid_type(self, testdir): - """Tests parametrized with ids as non-strings (#1857).""" + """Test error with non-strings/non-ints, without generator (#1857).""" testdir.makepyfile( """ import pytest - @pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2)) + @pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type)) def test_ids_numbers(x,expected): assert x * 2 == expected """ @@ -1192,7 +1259,8 @@ class TestMetafuncFunctional: result = testdir.runpytest() result.stdout.fnmatch_lines( [ - "*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*" + "In test_ids_numbers: ids must be list of string/float/int/bool," + " found: (type: ) at index 2" ] ) @@ -1773,3 +1841,39 @@ class TestMarkersWithParametrization: ) result = testdir.runpytest() result.assert_outcomes(passed=1) + + def test_parametrize_iterator(self, testdir): + testdir.makepyfile( + """ + import itertools + import pytest + + id_parametrize = pytest.mark.parametrize( + ids=("param%d" % i for i in itertools.count()) + ) + + @id_parametrize('y', ['a', 'b']) + def test1(y): + pass + + @id_parametrize('y', ['a', 'b']) + def test2(y): + pass + + @pytest.mark.parametrize("a, b", [(1, 2), (3, 4)], ids=itertools.count()) + def test_converted_to_str(a, b): + pass + """ + ) + result = testdir.runpytest("-vv", "-s") + result.stdout.fnmatch_lines( + [ + "test_parametrize_iterator.py::test1[param0] PASSED", + "test_parametrize_iterator.py::test1[param1] PASSED", + "test_parametrize_iterator.py::test2[param0] PASSED", + "test_parametrize_iterator.py::test2[param1] PASSED", + "test_parametrize_iterator.py::test_converted_to_str[0] PASSED", + "test_parametrize_iterator.py::test_converted_to_str[1] PASSED", + "*= 6 passed in *", + ] + ) From dac16cd9e5113a5b769d89557e9dcdc5001fe205 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 17 Nov 2019 14:35:33 +0200 Subject: [PATCH 009/158] Add type annotations to _pytest.config.argparsing and corresponding Config code --- src/_pytest/_argcomplete.py | 11 +- src/_pytest/config/__init__.py | 24 +++-- src/_pytest/config/argparsing.py | 168 ++++++++++++++++++++----------- testing/test_parseopt.py | 65 ++++++------ 4 files changed, 161 insertions(+), 107 deletions(-) diff --git a/src/_pytest/_argcomplete.py b/src/_pytest/_argcomplete.py index 688c9077d..7ca216ecf 100644 --- a/src/_pytest/_argcomplete.py +++ b/src/_pytest/_argcomplete.py @@ -53,19 +53,22 @@ If things do not work right away: which should throw a KeyError: 'COMPLINE' (which is properly set by the global argcomplete script). """ +import argparse import os import sys from glob import glob +from typing import Any +from typing import List from typing import Optional class FastFilesCompleter: "Fast file completer class" - def __init__(self, directories=True): + def __init__(self, directories: bool = True) -> None: self.directories = directories - def __call__(self, prefix, **kwargs): + def __call__(self, prefix: str, **kwargs: Any) -> List[str]: """only called on non option completions""" if os.path.sep in prefix[1:]: prefix_dir = len(os.path.dirname(prefix) + os.path.sep) @@ -94,13 +97,13 @@ if os.environ.get("_ARGCOMPLETE"): sys.exit(-1) filescompleter = FastFilesCompleter() # type: Optional[FastFilesCompleter] - def try_argcomplete(parser): + def try_argcomplete(parser: argparse.ArgumentParser) -> None: argcomplete.autocomplete(parser, always_complete_options=False) else: - def try_argcomplete(parser): + def try_argcomplete(parser: argparse.ArgumentParser) -> None: pass filescompleter = None diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 1c98f0266..4e3323b0c 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -45,6 +45,8 @@ from _pytest.warning_types import PytestConfigWarning if False: # TYPE_CHECKING from typing import Type + from .argparsing import Argument + hookimpl = HookimplMarker("pytest") hookspec = HookspecMarker("pytest") @@ -679,7 +681,7 @@ class Config: plugins = attr.ib() dir = attr.ib(type=Path) - def __init__(self, pluginmanager, *, invocation_params=None): + def __init__(self, pluginmanager, *, invocation_params=None) -> None: from .argparsing import Parser, FILE_OR_DIR if invocation_params is None: @@ -792,11 +794,11 @@ class Config: config.pluginmanager.consider_pluginarg(x) return config - def _processopt(self, opt): + def _processopt(self, opt: "Argument") -> None: for name in opt._short_opts + opt._long_opts: self._opt2dest[name] = opt.dest - if hasattr(opt, "default") and opt.dest: + if hasattr(opt, "default"): if not hasattr(self.option, opt.dest): setattr(self.option, opt.dest, opt.default) @@ -804,7 +806,7 @@ class Config: def pytest_load_initial_conftests(self, early_config): self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) - def _initini(self, args) -> None: + def _initini(self, args: Sequence[str]) -> None: ns, unknown_args = self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) @@ -821,7 +823,7 @@ class Config: self._parser.addini("minversion", "minimally required pytest version") self._override_ini = ns.override_ini or () - def _consider_importhook(self, args): + def _consider_importhook(self, args: Sequence[str]) -> None: """Install the PEP 302 import hook if using assertion rewriting. Needs to parse the --assert= option from the commandline @@ -861,19 +863,19 @@ class Config: for name in _iter_rewritable_modules(package_files): hook.mark_rewrite(name) - def _validate_args(self, args, via): + def _validate_args(self, args: List[str], via: str) -> List[str]: """Validate known args.""" - self._parser._config_source_hint = via + self._parser._config_source_hint = via # type: ignore try: self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) finally: - del self._parser._config_source_hint + del self._parser._config_source_hint # type: ignore return args - def _preparse(self, args, addopts=True): + def _preparse(self, args: List[str], addopts: bool = True) -> None: if addopts: env_addopts = os.environ.get("PYTEST_ADDOPTS", "") if len(env_addopts): @@ -937,7 +939,7 @@ class Config: ) ) - def parse(self, args, addopts=True): + def parse(self, args: List[str], addopts: bool = True) -> None: # parse given cmdline arguments into this config object. assert not hasattr( self, "args" @@ -948,7 +950,7 @@ class Config: self._preparse(args, addopts=addopts) # XXX deprecated hook: self.hook.pytest_cmdline_preparse(config=self, args=args) - self._parser.after_preparse = True + self._parser.after_preparse = True # type: ignore try: args = self._parser.parse_setoption( args, self.option, namespace=self.option diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 8366a8d66..d0870ed56 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -3,15 +3,24 @@ import sys import warnings from gettext import gettext from typing import Any +from typing import Callable +from typing import cast from typing import Dict from typing import List +from typing import Mapping from typing import Optional +from typing import Sequence from typing import Tuple +from typing import Union import py from _pytest.config.exceptions import UsageError +if False: # TYPE_CHECKING + from typing import NoReturn + from typing_extensions import Literal # noqa: F401 + FILE_OR_DIR = "file_or_dir" @@ -22,9 +31,13 @@ class Parser: there's an error processing the command line arguments. """ - prog = None + prog = None # type: Optional[str] - def __init__(self, usage=None, processopt=None): + def __init__( + self, + usage: Optional[str] = None, + processopt: Optional[Callable[["Argument"], None]] = None, + ) -> None: self._anonymous = OptionGroup("custom options", parser=self) self._groups = [] # type: List[OptionGroup] self._processopt = processopt @@ -33,12 +46,14 @@ class Parser: self._ininames = [] # type: List[str] self.extra_info = {} # type: Dict[str, Any] - def processoption(self, option): + def processoption(self, option: "Argument") -> None: if self._processopt: if option.dest: self._processopt(option) - def getgroup(self, name, description="", after=None): + def getgroup( + self, name: str, description: str = "", after: Optional[str] = None + ) -> "OptionGroup": """ get (or create) a named option Group. :name: name of the option group. @@ -61,13 +76,13 @@ class Parser: self._groups.insert(i + 1, group) return group - def addoption(self, *opts, **attrs): + def addoption(self, *opts: str, **attrs: Any) -> None: """ register a command line option. :opts: option names, can be short or long options. - :attrs: same attributes which the ``add_option()`` function of the + :attrs: same attributes which the ``add_argument()`` function of the `argparse library - `_ + `_ accepts. After command line parsing options are available on the pytest config @@ -77,7 +92,11 @@ class Parser: """ self._anonymous.addoption(*opts, **attrs) - def parse(self, args, namespace=None): + def parse( + self, + args: Sequence[Union[str, py.path.local]], + namespace: Optional[argparse.Namespace] = None, + ) -> argparse.Namespace: from _pytest._argcomplete import try_argcomplete self.optparser = self._getparser() @@ -98,27 +117,37 @@ class Parser: n = option.names() a = option.attrs() arggroup.add_argument(*n, **a) + file_or_dir_arg = optparser.add_argument(FILE_OR_DIR, nargs="*") # bash like autocompletion for dirs (appending '/') # Type ignored because typeshed doesn't know about argcomplete. - optparser.add_argument( # type: ignore - FILE_OR_DIR, nargs="*" - ).completer = filescompleter + file_or_dir_arg.completer = filescompleter # type: ignore return optparser - def parse_setoption(self, args, option, namespace=None): + def parse_setoption( + self, + args: Sequence[Union[str, py.path.local]], + option: argparse.Namespace, + namespace: Optional[argparse.Namespace] = None, + ) -> List[str]: parsedoption = self.parse(args, namespace=namespace) for name, value in parsedoption.__dict__.items(): setattr(option, name, value) - return getattr(parsedoption, FILE_OR_DIR) + return cast(List[str], getattr(parsedoption, FILE_OR_DIR)) - def parse_known_args(self, args, namespace=None) -> argparse.Namespace: + def parse_known_args( + self, + args: Sequence[Union[str, py.path.local]], + namespace: Optional[argparse.Namespace] = None, + ) -> argparse.Namespace: """parses and returns a namespace object with known arguments at this point. """ return self.parse_known_and_unknown_args(args, namespace=namespace)[0] def parse_known_and_unknown_args( - self, args, namespace=None + self, + args: Sequence[Union[str, py.path.local]], + namespace: Optional[argparse.Namespace] = None, ) -> Tuple[argparse.Namespace, List[str]]: """parses and returns a namespace object with known arguments, and the remaining arguments unknown at this point. @@ -127,7 +156,13 @@ class Parser: args = [str(x) if isinstance(x, py.path.local) else x for x in args] return optparser.parse_known_args(args, namespace=namespace) - def addini(self, name, help, type=None, default=None): + def addini( + self, + name: str, + help: str, + type: Optional["Literal['pathlist', 'args', 'linelist', 'bool']"] = None, + default=None, + ) -> None: """ register an ini-file option. :name: name of the ini-variable @@ -149,11 +184,11 @@ class ArgumentError(Exception): inconsistent arguments. """ - def __init__(self, msg, option): + def __init__(self, msg: str, option: Union["Argument", str]) -> None: self.msg = msg self.option_id = str(option) - def __str__(self): + def __str__(self) -> str: if self.option_id: return "option {}: {}".format(self.option_id, self.msg) else: @@ -170,12 +205,11 @@ class Argument: _typ_map = {"int": int, "string": str, "float": float, "complex": complex} - def __init__(self, *names, **attrs): + def __init__(self, *names: str, **attrs: Any) -> None: """store parms in private vars for use in add_argument""" self._attrs = attrs self._short_opts = [] # type: List[str] self._long_opts = [] # type: List[str] - self.dest = attrs.get("dest") if "%default" in (attrs.get("help") or ""): warnings.warn( 'pytest now uses argparse. "%default" should be' @@ -221,23 +255,25 @@ class Argument: except KeyError: pass self._set_opt_strings(names) - if not self.dest: - if self._long_opts: - self.dest = self._long_opts[0][2:].replace("-", "_") - else: - try: - self.dest = self._short_opts[0][1:] - except IndexError: - raise ArgumentError("need a long or short option", self) + dest = attrs.get("dest") # type: Optional[str] + if dest: + self.dest = dest + elif self._long_opts: + self.dest = self._long_opts[0][2:].replace("-", "_") + else: + try: + self.dest = self._short_opts[0][1:] + except IndexError: + self.dest = "???" # Needed for the error repr. + raise ArgumentError("need a long or short option", self) - def names(self): + def names(self) -> List[str]: return self._short_opts + self._long_opts - def attrs(self): + def attrs(self) -> Mapping[str, Any]: # update any attributes set by processopt attrs = "default dest help".split() - if self.dest: - attrs.append(self.dest) + attrs.append(self.dest) for attr in attrs: try: self._attrs[attr] = getattr(self, attr) @@ -250,7 +286,7 @@ class Argument: self._attrs["help"] = a return self._attrs - def _set_opt_strings(self, opts): + def _set_opt_strings(self, opts: Sequence[str]) -> None: """directly from optparse might not be necessary as this is passed to argparse later on""" @@ -293,13 +329,15 @@ class Argument: class OptionGroup: - def __init__(self, name, description="", parser=None): + def __init__( + self, name: str, description: str = "", parser: Optional[Parser] = None + ) -> None: self.name = name self.description = description self.options = [] # type: List[Argument] self.parser = parser - def addoption(self, *optnames, **attrs): + def addoption(self, *optnames: str, **attrs: Any) -> None: """ add an option to this group. if a shortened version of a long option is specified it will @@ -315,11 +353,11 @@ class OptionGroup: option = Argument(*optnames, **attrs) self._addoption_instance(option, shortupper=False) - def _addoption(self, *optnames, **attrs): + def _addoption(self, *optnames: str, **attrs: Any) -> None: option = Argument(*optnames, **attrs) self._addoption_instance(option, shortupper=True) - def _addoption_instance(self, option, shortupper=False): + def _addoption_instance(self, option: "Argument", shortupper: bool = False) -> None: if not shortupper: for opt in option._short_opts: if opt[0] == "-" and opt[1].islower(): @@ -330,9 +368,12 @@ class OptionGroup: class MyOptionParser(argparse.ArgumentParser): - def __init__(self, parser, extra_info=None, prog=None): - if not extra_info: - extra_info = {} + def __init__( + self, + parser: Parser, + extra_info: Optional[Dict[str, Any]] = None, + prog: Optional[str] = None, + ) -> None: self._parser = parser argparse.ArgumentParser.__init__( self, @@ -344,34 +385,42 @@ class MyOptionParser(argparse.ArgumentParser): ) # extra_info is a dict of (param -> value) to display if there's # an usage error to provide more contextual information to the user - self.extra_info = extra_info + self.extra_info = extra_info if extra_info else {} - def error(self, message): + def error(self, message: str) -> "NoReturn": """Transform argparse error message into UsageError.""" msg = "{}: error: {}".format(self.prog, message) if hasattr(self._parser, "_config_source_hint"): - msg = "{} ({})".format(msg, self._parser._config_source_hint) + # Type ignored because the attribute is set dynamically. + msg = "{} ({})".format(msg, self._parser._config_source_hint) # type: ignore raise UsageError(self.format_usage() + msg) - def parse_args(self, args=None, namespace=None): + # Type ignored because typeshed has a very complex type in the superclass. + def parse_args( # type: ignore + self, + args: Optional[Sequence[str]] = None, + namespace: Optional[argparse.Namespace] = None, + ) -> argparse.Namespace: """allow splitting of positional arguments""" - args, argv = self.parse_known_args(args, namespace) - if argv: - for arg in argv: + parsed, unrecognized = self.parse_known_args(args, namespace) + if unrecognized: + for arg in unrecognized: if arg and arg[0] == "-": - lines = ["unrecognized arguments: %s" % (" ".join(argv))] + lines = ["unrecognized arguments: %s" % (" ".join(unrecognized))] for k, v in sorted(self.extra_info.items()): lines.append(" {}: {}".format(k, v)) self.error("\n".join(lines)) - getattr(args, FILE_OR_DIR).extend(argv) - return args + getattr(parsed, FILE_OR_DIR).extend(unrecognized) + return parsed if sys.version_info[:2] < (3, 9): # pragma: no cover # Backport of https://github.com/python/cpython/pull/14316 so we can # disable long --argument abbreviations without breaking short flags. - def _parse_optional(self, arg_string): + def _parse_optional( + self, arg_string: str + ) -> Optional[Tuple[Optional[argparse.Action], str, Optional[str]]]: if not arg_string: return None if not arg_string[0] in self.prefix_chars: @@ -413,23 +462,25 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): - cache result on action object as this is called at least 2 times """ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: """Use more accurate terminal width via pylib.""" if "width" not in kwargs: kwargs["width"] = py.io.get_terminal_width() super().__init__(*args, **kwargs) - def _format_action_invocation(self, action): + def _format_action_invocation(self, action: argparse.Action) -> str: orgstr = argparse.HelpFormatter._format_action_invocation(self, action) if orgstr and orgstr[0] != "-": # only optional arguments return orgstr - res = getattr(action, "_formatted_action_invocation", None) + res = getattr( + action, "_formatted_action_invocation", None + ) # type: Optional[str] if res: return res options = orgstr.split(", ") if len(options) == 2 and (len(options[0]) == 2 or len(options[1]) == 2): # a shortcut for '-h, --help' or '--abc', '-a' - action._formatted_action_invocation = orgstr + action._formatted_action_invocation = orgstr # type: ignore return orgstr return_list = [] short_long = {} # type: Dict[str, str] @@ -438,7 +489,7 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): continue if not option.startswith("--"): raise ArgumentError( - 'long optional argument without "--": [%s]' % (option), self + 'long optional argument without "--": [%s]' % (option), option ) xxoption = option[2:] shortened = xxoption.replace("-", "") @@ -453,5 +504,6 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): return_list.append(option) if option[2:] == short_long.get(option.replace("-", "")): return_list.append(option.replace(" ", "=", 1)) - action._formatted_action_invocation = ", ".join(return_list) - return action._formatted_action_invocation + formatted_action_invocation = ", ".join(return_list) + action._formatted_action_invocation = formatted_action_invocation # type: ignore + return formatted_action_invocation diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 5f7d5222b..cdccc240e 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -12,22 +12,22 @@ from _pytest.config.exceptions import UsageError @pytest.fixture -def parser(): +def parser() -> parseopt.Parser: return parseopt.Parser() class TestParser: - def test_no_help_by_default(self): + def test_no_help_by_default(self) -> None: parser = parseopt.Parser(usage="xyz") pytest.raises(UsageError, lambda: parser.parse(["-h"])) - def test_custom_prog(self, parser): + def test_custom_prog(self, parser: parseopt.Parser) -> None: """Custom prog can be set for `argparse.ArgumentParser`.""" assert parser._getparser().prog == os.path.basename(sys.argv[0]) parser.prog = "custom-prog" assert parser._getparser().prog == "custom-prog" - def test_argument(self): + def test_argument(self) -> None: with pytest.raises(parseopt.ArgumentError): # need a short or long option argument = parseopt.Argument() @@ -45,7 +45,7 @@ class TestParser: "Argument(_short_opts: ['-t'], _long_opts: ['--test'], dest: 'abc')" ) - def test_argument_type(self): + def test_argument_type(self) -> None: argument = parseopt.Argument("-t", dest="abc", type=int) assert argument.type is int argument = parseopt.Argument("-t", dest="abc", type=str) @@ -60,7 +60,7 @@ class TestParser: ) assert argument.type is str - def test_argument_processopt(self): + def test_argument_processopt(self) -> None: argument = parseopt.Argument("-t", type=int) argument.default = 42 argument.dest = "abc" @@ -68,19 +68,19 @@ class TestParser: assert res["default"] == 42 assert res["dest"] == "abc" - def test_group_add_and_get(self, parser): + def test_group_add_and_get(self, parser: parseopt.Parser) -> None: group = parser.getgroup("hello", description="desc") assert group.name == "hello" assert group.description == "desc" - def test_getgroup_simple(self, parser): + def test_getgroup_simple(self, parser: parseopt.Parser) -> None: group = parser.getgroup("hello", description="desc") assert group.name == "hello" assert group.description == "desc" group2 = parser.getgroup("hello") assert group2 is group - def test_group_ordering(self, parser): + def test_group_ordering(self, parser: parseopt.Parser) -> None: parser.getgroup("1") parser.getgroup("2") parser.getgroup("3", after="1") @@ -88,20 +88,20 @@ class TestParser: groups_names = [x.name for x in groups] assert groups_names == list("132") - def test_group_addoption(self): + def test_group_addoption(self) -> None: group = parseopt.OptionGroup("hello") group.addoption("--option1", action="store_true") assert len(group.options) == 1 assert isinstance(group.options[0], parseopt.Argument) - def test_group_addoption_conflict(self): + def test_group_addoption_conflict(self) -> None: group = parseopt.OptionGroup("hello again") group.addoption("--option1", "--option-1", action="store_true") with pytest.raises(ValueError) as err: group.addoption("--option1", "--option-one", action="store_true") assert str({"--option1"}) in str(err.value) - def test_group_shortopt_lowercase(self, parser): + def test_group_shortopt_lowercase(self, parser: parseopt.Parser) -> None: group = parser.getgroup("hello") with pytest.raises(ValueError): group.addoption("-x", action="store_true") @@ -109,30 +109,30 @@ class TestParser: group._addoption("-x", action="store_true") assert len(group.options) == 1 - def test_parser_addoption(self, parser): + def test_parser_addoption(self, parser: parseopt.Parser) -> None: group = parser.getgroup("custom options") assert len(group.options) == 0 group.addoption("--option1", action="store_true") assert len(group.options) == 1 - def test_parse(self, parser): + def test_parse(self, parser: parseopt.Parser) -> None: parser.addoption("--hello", dest="hello", action="store") args = parser.parse(["--hello", "world"]) assert args.hello == "world" assert not getattr(args, parseopt.FILE_OR_DIR) - def test_parse2(self, parser): + def test_parse2(self, parser: parseopt.Parser) -> None: args = parser.parse([py.path.local()]) assert getattr(args, parseopt.FILE_OR_DIR)[0] == py.path.local() - def test_parse_known_args(self, parser): + def test_parse_known_args(self, parser: parseopt.Parser) -> None: parser.parse_known_args([py.path.local()]) parser.addoption("--hello", action="store_true") ns = parser.parse_known_args(["x", "--y", "--hello", "this"]) assert ns.hello assert ns.file_or_dir == ["x"] - def test_parse_known_and_unknown_args(self, parser): + def test_parse_known_and_unknown_args(self, parser: parseopt.Parser) -> None: parser.addoption("--hello", action="store_true") ns, unknown = parser.parse_known_and_unknown_args( ["x", "--y", "--hello", "this"] @@ -141,7 +141,7 @@ class TestParser: assert ns.file_or_dir == ["x"] assert unknown == ["--y", "this"] - def test_parse_will_set_default(self, parser): + def test_parse_will_set_default(self, parser: parseopt.Parser) -> None: parser.addoption("--hello", dest="hello", default="x", action="store") option = parser.parse([]) assert option.hello == "x" @@ -149,25 +149,22 @@ class TestParser: parser.parse_setoption([], option) assert option.hello == "x" - def test_parse_setoption(self, parser): + def test_parse_setoption(self, parser: parseopt.Parser) -> None: parser.addoption("--hello", dest="hello", action="store") parser.addoption("--world", dest="world", default=42) - class A: - pass - - option = A() + option = argparse.Namespace() args = parser.parse_setoption(["--hello", "world"], option) assert option.hello == "world" assert option.world == 42 assert not args - def test_parse_special_destination(self, parser): + def test_parse_special_destination(self, parser: parseopt.Parser) -> None: parser.addoption("--ultimate-answer", type=int) args = parser.parse(["--ultimate-answer", "42"]) assert args.ultimate_answer == 42 - def test_parse_split_positional_arguments(self, parser): + def test_parse_split_positional_arguments(self, parser: parseopt.Parser) -> None: parser.addoption("-R", action="store_true") parser.addoption("-S", action="store_false") args = parser.parse(["-R", "4", "2", "-S"]) @@ -181,7 +178,7 @@ class TestParser: assert args.R is True assert args.S is False - def test_parse_defaultgetter(self): + def test_parse_defaultgetter(self) -> None: def defaultget(option): if not hasattr(option, "type"): return @@ -199,7 +196,7 @@ class TestParser: assert option.this == 42 assert option.no is False - def test_drop_short_helper(self): + def test_drop_short_helper(self) -> None: parser = argparse.ArgumentParser( formatter_class=parseopt.DropShorterLongHelpFormatter, allow_abbrev=False ) @@ -236,32 +233,32 @@ class TestParser: args = parser.parse_args(["file", "dir"]) assert "|".join(args.files_and_dirs) == "file|dir" - def test_drop_short_0(self, parser): + def test_drop_short_0(self, parser: parseopt.Parser) -> None: parser.addoption("--funcarg", "--func-arg", action="store_true") parser.addoption("--abc-def", "--abc-def", action="store_true") parser.addoption("--klm-hij", action="store_true") with pytest.raises(UsageError): parser.parse(["--funcarg", "--k"]) - def test_drop_short_2(self, parser): + def test_drop_short_2(self, parser: parseopt.Parser) -> None: parser.addoption("--func-arg", "--doit", action="store_true") args = parser.parse(["--doit"]) assert args.func_arg is True - def test_drop_short_3(self, parser): + def test_drop_short_3(self, parser: parseopt.Parser) -> None: parser.addoption("--func-arg", "--funcarg", "--doit", action="store_true") args = parser.parse(["abcd"]) assert args.func_arg is False assert args.file_or_dir == ["abcd"] - def test_drop_short_help0(self, parser, capsys): + def test_drop_short_help0(self, parser: parseopt.Parser, capsys) -> None: parser.addoption("--func-args", "--doit", help="foo", action="store_true") parser.parse([]) help = parser.optparser.format_help() assert "--func-args, --doit foo" in help # testing would be more helpful with all help generated - def test_drop_short_help1(self, parser, capsys): + def test_drop_short_help1(self, parser: parseopt.Parser, capsys) -> None: group = parser.getgroup("general") group.addoption("--doit", "--func-args", action="store_true", help="foo") group._addoption( @@ -275,7 +272,7 @@ class TestParser: help = parser.optparser.format_help() assert "-doit, --func-args foo" in help - def test_multiple_metavar_help(self, parser): + def test_multiple_metavar_help(self, parser: parseopt.Parser) -> None: """ Help text for options with a metavar tuple should display help in the form "--preferences=value1 value2 value3" (#2004). @@ -290,7 +287,7 @@ class TestParser: assert "--preferences=value1 value2 value3" in help -def test_argcomplete(testdir, monkeypatch): +def test_argcomplete(testdir, monkeypatch) -> None: if not distutils.spawn.find_executable("bash"): pytest.skip("bash not available") script = str(testdir.tmpdir.join("test_argcomplete")) From 2d449e95e4c270f1b3a15239b72cd3c5338f798b Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 20 Nov 2019 18:58:02 +0100 Subject: [PATCH 010/158] Respect --fulltrace with collection errors --- changelog/6247.improvement.rst | 1 + src/_pytest/nodes.py | 6 ++++-- testing/python/collect.py | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 changelog/6247.improvement.rst diff --git a/changelog/6247.improvement.rst b/changelog/6247.improvement.rst new file mode 100644 index 000000000..6634d6b80 --- /dev/null +++ b/changelog/6247.improvement.rst @@ -0,0 +1 @@ +``--fulltrace`` is honored with collection errors. diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 22c2ce337..33067334c 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -365,12 +365,14 @@ class Collector(Node): def repr_failure(self, excinfo): """ represent a collection failure. """ - if excinfo.errisinstance(self.CollectError): + if excinfo.errisinstance(self.CollectError) and not self.config.getoption( + "fulltrace", False + ): exc = excinfo.value return str(exc.args[0]) # Respect explicit tbstyle option, but default to "short" - # (None._repr_failure_py defaults to "long" without "fulltrace" option). + # (_repr_failure_py uses "long" with "fulltrace" option always). tbstyle = self.config.getoption("tbstyle", "auto") if tbstyle == "auto": tbstyle = "short" diff --git a/testing/python/collect.py b/testing/python/collect.py index 30f9841b5..e036cb7d9 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1210,6 +1210,28 @@ def test_syntax_error_with_non_ascii_chars(testdir): result.stdout.fnmatch_lines(["*ERROR collecting*", "*SyntaxError*", "*1 error in*"]) +def test_collecterror_with_fulltrace(testdir): + testdir.makepyfile("assert 0") + result = testdir.runpytest("--fulltrace") + result.stdout.fnmatch_lines( + [ + "collected 0 items / 1 error", + "", + "*= ERRORS =*", + "*_ ERROR collecting test_collecterror_with_fulltrace.py _*", + "", + "*/_pytest/python.py:*: ", + "_ _ _ _ _ _ _ _ *", + "", + "> assert 0", + "E assert 0", + "", + "test_collecterror_with_fulltrace.py:1: AssertionError", + "*! Interrupted: 1 error during collection !*", + ] + ) + + def test_skip_duplicates_by_default(testdir): """Test for issue https://github.com/pytest-dev/pytest/issues/1609 (#1609) From a02310a1401a185676f3421c6598434f2d73594a Mon Sep 17 00:00:00 2001 From: Philipp Loose Date: Fri, 22 Nov 2019 19:18:07 +0100 Subject: [PATCH 011/158] Add stacklevel tests for warnings, 'location' to pytest_warning_captured Resolves #4445 and #5928 (thanks to allanlewis) Add CHANGELOG for location parameter --- AUTHORS | 1 + changelog/4445.bugfix.rst | 1 + changelog/5928.bugfix.rst | 1 + changelog/5984.improvement.rst | 1 + src/_pytest/config/__init__.py | 10 +-- src/_pytest/hookspec.py | 6 +- src/_pytest/mark/structures.py | 1 + src/_pytest/warnings.py | 6 +- testing/test_warnings.py | 158 +++++++++++++++++++++++++++++++++ 9 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 changelog/4445.bugfix.rst create mode 100644 changelog/5928.bugfix.rst create mode 100644 changelog/5984.improvement.rst diff --git a/AUTHORS b/AUTHORS index 6e2f472fe..a3e526c5a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -207,6 +207,7 @@ Oscar Benjamin Patrick Hayes Paweł Adamczak Pedro Algarvio +Philipp Loose Pieter Mulder Piotr Banaszkiewicz Pulkit Goyal diff --git a/changelog/4445.bugfix.rst b/changelog/4445.bugfix.rst new file mode 100644 index 000000000..f7583b2bf --- /dev/null +++ b/changelog/4445.bugfix.rst @@ -0,0 +1 @@ +Fixed some warning reports produced by pytest to point to the correct location of the warning in the user's code. diff --git a/changelog/5928.bugfix.rst b/changelog/5928.bugfix.rst new file mode 100644 index 000000000..fbc53757d --- /dev/null +++ b/changelog/5928.bugfix.rst @@ -0,0 +1 @@ +Report ``PytestUnknownMarkWarning`` at the level of the user's code, not ``pytest``'s. diff --git a/changelog/5984.improvement.rst b/changelog/5984.improvement.rst new file mode 100644 index 000000000..1a0ad66f7 --- /dev/null +++ b/changelog/5984.improvement.rst @@ -0,0 +1 @@ +The ``pytest_warning_captured`` hook now receives a ``location`` parameter with the code location that generated the warning. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 4e3323b0c..070d24bf5 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -133,13 +133,7 @@ def directory_arg(path, optname): # Plugins that cannot be disabled via "-p no:X" currently. -essential_plugins = ( - "mark", - "main", - "runner", - "fixtures", - "helpconfig", # Provides -p. -) +essential_plugins = ("mark", "main", "runner", "fixtures", "helpconfig") # Provides -p. default_plugins = essential_plugins + ( "python", @@ -589,7 +583,7 @@ class PytestPluginManager(PluginManager): _issue_warning_captured( PytestConfigWarning("skipped plugin {!r}: {}".format(modname, e.msg)), self.hook, - stacklevel=1, + stacklevel=2, ) else: mod = sys.modules[importspec] diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 03e060eb8..74dff1e82 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -562,7 +562,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): @hookspec(historic=True) -def pytest_warning_captured(warning_message, when, item): +def pytest_warning_captured(warning_message, when, item, location): """ Process a warning captured by the internal pytest warnings plugin. @@ -582,6 +582,10 @@ def pytest_warning_captured(warning_message, when, item): in a future release. The item being executed if ``when`` is ``"runtest"``, otherwise ``None``. + + :param tuple location: + Holds information about the execution context of the captured warning (filename, linenumber, function). + ``function`` evaluates to when the execution context is at the module level. """ diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index a4ec9665c..020260dd5 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -347,6 +347,7 @@ class MarkGenerator: "custom marks to avoid this warning - for details, see " "https://docs.pytest.org/en/latest/mark.html" % name, PytestUnknownMarkWarning, + 2, ) return MarkDecorator(Mark(name, (), {})) diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 8ac1ee225..b6ee049ec 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -149,6 +149,10 @@ def _issue_warning_captured(warning, hook, stacklevel): warnings.warn(warning, stacklevel=stacklevel) # Mypy can't infer that record=True means records is not None; help it. assert records is not None + frame = sys._getframe(stacklevel - 1) + location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name hook.pytest_warning_captured.call_historic( - kwargs=dict(warning_message=records[0], when="config", item=None) + kwargs=dict( + warning_message=records[0], when="config", item=None, location=location + ) ) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index c4af14dac..8a9cc618f 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,3 +1,4 @@ +import os import warnings import pytest @@ -641,3 +642,160 @@ def test_pytest_configure_warning(testdir, recwarn): assert "INTERNALERROR" not in result.stderr.str() warning = recwarn.pop() assert str(warning.message) == "from pytest_configure" + + +class TestStackLevel: + @pytest.fixture + def capwarn(self, testdir): + class CapturedWarnings: + captured = [] + + @classmethod + def pytest_warning_captured(cls, warning_message, when, item, location): + cls.captured.append((warning_message, location)) + + testdir.plugins = [CapturedWarnings()] + + return CapturedWarnings + + def test_issue4445_rewrite(self, testdir, capwarn): + """#4445: Make sure the warning points to a reasonable location + See origin of _issue_warning_captured at: _pytest.assertion.rewrite.py:241 + """ + testdir.makepyfile(some_mod="") + conftest = testdir.makeconftest( + """ + import some_mod + import pytest + + pytest.register_assert_rewrite("some_mod") + """ + ) + testdir.parseconfig() + + # with stacklevel=5 the warning originates from register_assert_rewrite + # function in the created conftest.py + assert len(capwarn.captured) == 1 + warning, location = capwarn.captured.pop() + file, lineno, func = location + + assert "Module already imported" in str(warning.message) + assert file == str(conftest) + assert func == "" # the above conftest.py + assert lineno == 4 + + def test_issue4445_preparse(self, testdir, capwarn): + """#4445: Make sure the warning points to a reasonable location + See origin of _issue_warning_captured at: _pytest.config.__init__.py:910 + """ + testdir.makeconftest( + """ + import nothing + """ + ) + testdir.parseconfig("--help") + + # with stacklevel=2 the warning should originate from config._preparse and is + # thrown by an errorneous conftest.py + assert len(capwarn.captured) == 1 + warning, location = capwarn.captured.pop() + file, _, func = location + + assert "could not load initial conftests" in str(warning.message) + assert "config{sep}__init__.py".format(sep=os.sep) in file + assert func == "_preparse" + + def test_issue4445_import_plugin(self, testdir, capwarn): + """#4445: Make sure the warning points to a reasonable location + See origin of _issue_warning_captured at: _pytest.config.__init__.py:585 + """ + testdir.makepyfile( + some_plugin=""" + import pytest + pytest.skip("thing", allow_module_level=True) + """ + ) + testdir.syspathinsert() + testdir.parseconfig("-p", "some_plugin") + + # with stacklevel=2 the warning should originate from + # config.PytestPluginManager.import_plugin is thrown by a skipped plugin + + # During config parsing the the pluginargs are checked in a while loop + # that as a result of the argument count runs import_plugin twice, hence + # two identical warnings are captured (is this intentional?). + assert len(capwarn.captured) == 2 + warning, location = capwarn.captured.pop() + file, _, func = location + + assert "skipped plugin 'some_plugin': thing" in str(warning.message) + assert "config{sep}__init__.py".format(sep=os.sep) in file + assert func == "import_plugin" + + def test_issue4445_resultlog(self, testdir, capwarn): + """#4445: Make sure the warning points to a reasonable location + See origin of _issue_warning_captured at: _pytest.resultlog.py:35 + """ + testdir.makepyfile( + """ + def test_dummy(): + pass + """ + ) + # Use parseconfigure() because the warning in resultlog.py is triggered in + # the pytest_configure hook + testdir.parseconfigure( + "--result-log={dir}".format(dir=testdir.tmpdir.join("result.log")) + ) + + # with stacklevel=2 the warning originates from resultlog.pytest_configure + # and is thrown when --result-log is used + warning, location = capwarn.captured.pop() + file, _, func = location + + assert "--result-log is deprecated" in str(warning.message) + assert "resultlog.py" in file + assert func == "pytest_configure" + + def test_issue4445_cacheprovider_set(self, testdir, capwarn): + """#4445: Make sure the warning points to a reasonable location + See origin of _issue_warning_captured at: _pytest.cacheprovider.py:59 + """ + testdir.tmpdir.join(".pytest_cache").write("something wrong") + testdir.runpytest(plugins=[capwarn()]) + + # with stacklevel=3 the warning originates from one stacklevel above + # _issue_warning_captured in cacheprovider.Cache.set and is thrown + # when there are errors during cache folder creation + + # set is called twice (in module stepwise and in cacheprovider) so emits + # two warnings when there are errors during cache folder creation. (is this intentional?) + assert len(capwarn.captured) == 2 + warning, location = capwarn.captured.pop() + file, lineno, func = location + + assert "could not create cache path" in str(warning.message) + assert "cacheprovider.py" in file + assert func == "set" + + def test_issue4445_issue5928_mark_generator(self, testdir): + """#4445 and #5928: Make sure the warning from an unknown mark points to + the test file where this mark is used. + """ + testfile = testdir.makepyfile( + """ + import pytest + + @pytest.mark.unknown + def test_it(): + pass + """ + ) + result = testdir.runpytest_subprocess() + # with stacklevel=2 the warning should originate from the above created test file + result.stdout.fnmatch_lines_random( + [ + "*{testfile}:3*".format(testfile=str(testfile)), + "*Unknown pytest.mark.unknown*", + ] + ) From b0ebcfb7857ef9e14064ca22baad4f6623f0251a Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 18 Nov 2019 16:00:01 +0100 Subject: [PATCH 012/158] pytester: remove special handling of env during inner runs Closes https://github.com/pytest-dev/pytest/issues/6213. --- changelog/6213.improvement.rst | 1 + src/_pytest/pytester.py | 16 +++------------- testing/test_pdb.py | 2 +- testing/test_pytester.py | 24 +++++++++--------------- 4 files changed, 14 insertions(+), 29 deletions(-) create mode 100644 changelog/6213.improvement.rst diff --git a/changelog/6213.improvement.rst b/changelog/6213.improvement.rst new file mode 100644 index 000000000..735d4455f --- /dev/null +++ b/changelog/6213.improvement.rst @@ -0,0 +1 @@ +pytester: the ``testdir`` fixture respects environment settings from the ``monkeypatch`` fixture for inner runs. diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index f44a69a95..97ad953a3 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -547,7 +547,8 @@ class Testdir: # Environment (updates) for inner runs. tmphome = str(self.tmpdir) - self._env_run_update = {"HOME": tmphome, "USERPROFILE": tmphome} + mp.setenv("HOME", tmphome) + mp.setenv("USERPROFILE", tmphome) def __repr__(self): return "".format(self.tmpdir) @@ -853,12 +854,6 @@ class Testdir: plugins = list(plugins) finalizers = [] try: - # Do not load user config (during runs only). - mp_run = MonkeyPatch() - for k, v in self._env_run_update.items(): - mp_run.setenv(k, v) - finalizers.append(mp_run.undo) - # Any sys.module or sys.path changes done while running pytest # inline should be reverted after the test run completes to avoid # clashing with later inline tests run within the same pytest test, @@ -1091,7 +1086,6 @@ class Testdir: env["PYTHONPATH"] = os.pathsep.join( filter(None, [os.getcwd(), env.get("PYTHONPATH", "")]) ) - env.update(self._env_run_update) kw["env"] = env if stdin is Testdir.CLOSE_STDIN: @@ -1261,11 +1255,7 @@ class Testdir: pytest.skip("pexpect.spawn not available") logfile = self.tmpdir.join("spawn.out").open("wb") - # Do not load user config. - env = os.environ.copy() - env.update(self._env_run_update) - - child = pexpect.spawn(cmd, logfile=logfile, env=env) + child = pexpect.spawn(cmd, logfile=logfile) self.request.addfinalizer(logfile.close) child.timeout = expect_timeout return child diff --git a/testing/test_pdb.py b/testing/test_pdb.py index 25d2292e9..0f7816dbe 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -22,7 +22,7 @@ def pdb_env(request): if "testdir" in request.fixturenames: # Disable pdb++ with inner tests. testdir = request.getfixturevalue("testdir") - testdir._env_run_update["PDBPP_HIJACK_PDB"] = "0" + testdir.monkeypatch.setenv("PDBPP_HIJACK_PDB", "0") def runpdb_and_get_report(testdir, source): diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 5bdbacdd0..3dab13b4b 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -550,17 +550,15 @@ def test_no_matching_after_match(): assert str(e.value).splitlines() == ["fnmatch: '*'", " with: '1'"] -def test_pytester_addopts(request, monkeypatch): +def test_pytester_addopts_before_testdir(request, monkeypatch): + orig = os.environ.get("PYTEST_ADDOPTS", None) monkeypatch.setenv("PYTEST_ADDOPTS", "--orig-unused") - testdir = request.getfixturevalue("testdir") - - try: - assert "PYTEST_ADDOPTS" not in os.environ - finally: - testdir.finalize() - - assert os.environ["PYTEST_ADDOPTS"] == "--orig-unused" + assert "PYTEST_ADDOPTS" not in os.environ + testdir.finalize() + assert os.environ.get("PYTEST_ADDOPTS") == "--orig-unused" + monkeypatch.undo() + assert os.environ.get("PYTEST_ADDOPTS") == orig def test_run_stdin(testdir): @@ -640,14 +638,10 @@ def test_popen_default_stdin_stderr_and_stdin_None(testdir): def test_spawn_uses_tmphome(testdir): - import os - tmphome = str(testdir.tmpdir) + assert os.environ.get("HOME") == tmphome - # Does use HOME only during run. - assert os.environ.get("HOME") != tmphome - - testdir._env_run_update["CUSTOMENV"] = "42" + testdir.monkeypatch.setenv("CUSTOMENV", "42") p1 = testdir.makepyfile( """ From c99c7d0f95170a5e308ed6c37e63b2d90794a4f9 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 16 Oct 2019 21:52:04 +0200 Subject: [PATCH 013/158] deprecate direct node construction and introduce Node.from_parent --- changelog/5975.deprecation.rst | 6 ++++++ src/_pytest/deprecated.py | 6 ++++++ src/_pytest/doctest.py | 16 ++++++++++++---- src/_pytest/main.py | 6 +++++- src/_pytest/nodes.py | 20 +++++++++++++++++++- src/_pytest/pytester.py | 4 ++-- src/_pytest/python.py | 28 ++++++++++++++++++---------- src/_pytest/unittest.py | 14 ++++++++------ testing/deprecated_test.py | 17 +++++++++++++++++ testing/python/collect.py | 6 +++--- testing/python/integration.py | 4 ++-- testing/python/metafunc.py | 2 +- testing/test_collection.py | 6 +++--- testing/test_mark.py | 6 +++++- testing/test_pluginmanager.py | 2 +- 15 files changed, 108 insertions(+), 35 deletions(-) create mode 100644 changelog/5975.deprecation.rst diff --git a/changelog/5975.deprecation.rst b/changelog/5975.deprecation.rst new file mode 100644 index 000000000..6e5dbc2ac --- /dev/null +++ b/changelog/5975.deprecation.rst @@ -0,0 +1,6 @@ +Deprecate using direct constructors for ``Nodes``. + +Instead they are new constructed via ``Node.from_parent``. + +This transitional mechanism enables us to detangle the very intensely +entangled ``Node`` relationships by enforcing more controlled creation/configruation patterns. diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 5a7066041..1fdc37c04 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -9,6 +9,7 @@ All constants defined in this module should be either PytestWarning instances or in case of warnings which need to format their messages. """ from _pytest.warning_types import PytestDeprecationWarning +from _pytest.warning_types import UnformattedWarning # set of plugins which have been integrated into the core; we use this list to ignore # them during registration to avoid conflicts @@ -35,6 +36,11 @@ FIXTURE_POSITIONAL_ARGUMENTS = PytestDeprecationWarning( "as a keyword argument instead." ) +NODE_USE_FROM_PARENT = UnformattedWarning( + PytestDeprecationWarning, + "direct construction of {name} has been deprecated, please use {name}.from_parent", +) + JUNIT_XML_DEFAULT_FAMILY = PytestDeprecationWarning( "The 'junit_family' default value will change to 'xunit2' in pytest 6.0.\n" "Add 'junit_family=legacy' to your pytest.ini file to silence this warning and make your suite compatible." diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index f7d96257e..66fbf8396 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -108,9 +108,9 @@ def pytest_collect_file(path, parent): config = parent.config if path.ext == ".py": if config.option.doctestmodules and not _is_setup_py(config, path, parent): - return DoctestModule(path, parent) + return DoctestModule.from_parent(parent, fspath=path) elif _is_doctest(config, path, parent): - return DoctestTextfile(path, parent) + return DoctestTextfile.from_parent(parent, fspath=path) def _is_setup_py(config, path, parent): @@ -215,6 +215,10 @@ class DoctestItem(pytest.Item): self.obj = None self.fixture_request = None + @classmethod + def from_parent(cls, parent, *, name, runner, dtest): + return cls._create(name=name, parent=parent, runner=runner, dtest=dtest) + def setup(self): if self.dtest is not None: self.fixture_request = _setup_fixtures(self) @@ -370,7 +374,9 @@ class DoctestTextfile(pytest.Module): parser = doctest.DocTestParser() test = parser.get_doctest(text, globs, name, filename, 0) if test.examples: - yield DoctestItem(test.name, self, runner, test) + yield DoctestItem.from_parent( + self, name=test.name, runner=runner, dtest=test + ) def _check_all_skipped(test): @@ -467,7 +473,9 @@ class DoctestModule(pytest.Module): for test in finder.find(module, module.__name__): if test.examples: # skip empty doctests - yield DoctestItem(test.name, self, runner, test) + yield DoctestItem.from_parent( + self, name=test.name, runner=runner, dtest=test + ) def _setup_fixtures(doctest_item): diff --git a/src/_pytest/main.py b/src/_pytest/main.py index b4261c188..e46f54d9c 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -184,7 +184,7 @@ def pytest_addoption(parser): def wrap_session(config, doit): """Skeleton command line program""" - session = Session(config) + session = Session.from_config(config) session.exitstatus = ExitCode.OK initstate = 0 try: @@ -395,6 +395,10 @@ class Session(nodes.FSCollector): self.config.pluginmanager.register(self, name="session") + @classmethod + def from_config(cls, config): + return cls._create(config) + def __repr__(self): return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( self.__class__.__name__, diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 33067334c..3eaafa91d 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -18,6 +18,7 @@ from _pytest._code.code import ReprExceptionInfo from _pytest.compat import cached_property from _pytest.compat import getfslineno from _pytest.config import Config +from _pytest.deprecated import NODE_USE_FROM_PARENT from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureLookupError from _pytest.fixtures import FixtureLookupErrorRepr @@ -73,7 +74,16 @@ def ischildnode(baseid, nodeid): return node_parts[: len(base_parts)] == base_parts -class Node: +class NodeMeta(type): + def __call__(self, *k, **kw): + warnings.warn(NODE_USE_FROM_PARENT.format(name=self.__name__), stacklevel=2) + return super().__call__(*k, **kw) + + def _create(self, *k, **kw): + return super().__call__(*k, **kw) + + +class Node(metaclass=NodeMeta): """ base class for Collector and Item the test collection tree. Collector subclasses have children, Items are terminal nodes.""" @@ -133,6 +143,10 @@ class Node: if self.name != "()": self._nodeid += "::" + self.name + @classmethod + def from_parent(cls, parent, *, name): + return cls._create(parent=parent, name=name) + @property def ihook(self): """ fspath sensitive hook proxy used to call pytest hooks""" @@ -418,6 +432,10 @@ class FSCollector(Collector): super().__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath) + @classmethod + def from_parent(cls, parent, *, fspath): + return cls._create(parent=parent, fspath=fspath) + class File(FSCollector): """ base class for collecting tests from a file. """ diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 97ad953a3..a1acf747e 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -744,7 +744,7 @@ class Testdir: :param arg: a :py:class:`py.path.local` instance of the file """ - session = Session(config) + session = Session.from_config(config) assert "::" not in str(arg) p = py.path.local(arg) config.hook.pytest_sessionstart(session=session) @@ -762,7 +762,7 @@ class Testdir: """ config = self.parseconfigure(path) - session = Session(config) + session = Session.from_config(config) x = session.fspath.bestrelpath(path) config.hook.pytest_sessionstart(session=session) res = session.perform_collect([x], genitems=False)[0] diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 4e3a68867..95bae5a23 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -190,8 +190,8 @@ def path_matches_patterns(path, patterns): def pytest_pycollect_makemodule(path, parent): if path.basename == "__init__.py": - return Package(path, parent) - return Module(path, parent) + return Package.from_parent(parent, fspath=path) + return Module.from_parent(parent, fspath=path) @hookimpl(hookwrapper=True) @@ -203,7 +203,7 @@ def pytest_pycollect_makeitem(collector, name, obj): # nothing was collected elsewhere, let's do it here if safe_isclass(obj): if collector.istestclass(obj, name): - outcome.force_result(Class(name, parent=collector)) + outcome.force_result(Class.from_parent(collector, name=name, obj=obj)) elif collector.istestfunction(obj, name): # mock seems to store unbound methods (issue473), normalize it obj = getattr(obj, "__func__", obj) @@ -222,7 +222,7 @@ def pytest_pycollect_makeitem(collector, name, obj): ) elif getattr(obj, "__test__", True): if is_generator(obj): - res = Function(name, parent=collector) + res = Function.from_parent(collector, name=name) reason = "yield tests were removed in pytest 4.0 - {name} will be ignored".format( name=name ) @@ -381,7 +381,7 @@ class PyCollector(PyobjMixin, nodes.Collector): cls = clscol and clscol.obj or None fm = self.session._fixturemanager - definition = FunctionDefinition(name=name, parent=self, callobj=funcobj) + definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) fixtureinfo = fm.getfixtureinfo(definition, funcobj, cls) metafunc = Metafunc( @@ -396,7 +396,7 @@ class PyCollector(PyobjMixin, nodes.Collector): self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) if not metafunc._calls: - yield Function(name, parent=self, fixtureinfo=fixtureinfo) + yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) else: # add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm) @@ -408,9 +408,9 @@ class PyCollector(PyobjMixin, nodes.Collector): for callspec in metafunc._calls: subname = "{}[{}]".format(name, callspec.id) - yield Function( + yield Function.from_parent( + self, name=subname, - parent=self, callspec=callspec, callobj=funcobj, fixtureinfo=fixtureinfo, @@ -626,7 +626,7 @@ class Package(Module): if init_module.check(file=1) and path_matches_patterns( init_module, self.config.getini("python_files") ): - yield Module(init_module, self) + yield Module.from_parent(self, fspath=init_module) pkg_prefixes = set() for path in this_path.visit(rec=self._recurse, bf=True, sort=True): # We will visit our own __init__.py file, in which case we skip it. @@ -677,6 +677,10 @@ def _get_first_non_fixture_func(obj, names): class Class(PyCollector): """ Collector for test methods. """ + @classmethod + def from_parent(cls, parent, *, name, obj=None): + return cls._create(name=name, parent=parent) + def collect(self): if not safe_getattr(self.obj, "__test__", True): return [] @@ -702,7 +706,7 @@ class Class(PyCollector): self._inject_setup_class_fixture() self._inject_setup_method_fixture() - return [Instance(name="()", parent=self)] + return [Instance.from_parent(self, name="()")] def _inject_setup_class_fixture(self): """Injects a hidden autouse, class scoped fixture into the collected class object @@ -1454,6 +1458,10 @@ class Function(FunctionMixin, nodes.Item): #: .. versionadded:: 3.0 self.originalname = originalname + @classmethod + def from_parent(cls, parent, **kw): + return cls._create(parent=parent, **kw) + def _initrequest(self): self.funcargs = {} self._request = fixtures.FixtureRequest(self) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 11dc77cc4..1ddb9c867 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -23,7 +23,7 @@ def pytest_pycollect_makeitem(collector, name, obj): except Exception: return # yes, so let's collect it - return UnitTestCase(name, parent=collector) + return UnitTestCase.from_parent(collector, name=name, obj=obj) class UnitTestCase(Class): @@ -51,7 +51,7 @@ class UnitTestCase(Class): if not getattr(x, "__test__", True): continue funcobj = getimfunc(x) - yield TestCaseFunction(name, parent=self, callobj=funcobj) + yield TestCaseFunction.from_parent(self, name=name, callobj=funcobj) foundsomething = True if not foundsomething: @@ -59,7 +59,8 @@ class UnitTestCase(Class): if runtest is not None: ut = sys.modules.get("twisted.trial.unittest", None) if ut is None or runtest != ut.TestCase.runTest: - yield TestCaseFunction("runTest", parent=self) + # TODO: callobj consistency + yield TestCaseFunction.from_parent(self, name="runTest") def _inject_setup_teardown_fixtures(self, cls): """Injects a hidden auto-use fixture to invoke setUpClass/setup_method and corresponding @@ -190,20 +191,21 @@ class TestCaseFunction(Function): def _handle_skip(self): # implements the skipping machinery (see #2137) # analog to pythons Lib/unittest/case.py:run - testMethod = getattr(self._testcase, self._testcase._testMethodName) + test_method = getattr(self._testcase, self._testcase._testMethodName) if getattr(self._testcase.__class__, "__unittest_skip__", False) or getattr( - testMethod, "__unittest_skip__", False + test_method, "__unittest_skip__", False ): # If the class or method was skipped. skip_why = getattr( self._testcase.__class__, "__unittest_skip_why__", "" - ) or getattr(testMethod, "__unittest_skip_why__", "") + ) or getattr(test_method, "__unittest_skip_why__", "") self._testcase._addSkip(self, self._testcase, skip_why) return True return False def runtest(self): if self.config.pluginmanager.get_plugin("pdbinvoke") is None: + # TODO: move testcase reporter into separate class, this shouldnt be on item self._testcase(result=self) else: # disables tearDown and cleanups for post mortem debugging (see #1890) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 5390d038d..59cb69a00 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -1,5 +1,8 @@ +import inspect + import pytest from _pytest import deprecated +from _pytest import nodes @pytest.mark.filterwarnings("default") @@ -73,3 +76,17 @@ def test_warn_about_imminent_junit_family_default_change(testdir, junit_family): result.stdout.no_fnmatch_line(warning_msg) else: result.stdout.fnmatch_lines([warning_msg]) + + +def test_node_direct_ctor_warning(): + class MockConfig: + pass + + ms = MockConfig() + with pytest.warns( + DeprecationWarning, + match="direct construction of .* has been deprecated, please use .*.from_parent", + ) as w: + nodes.Node(name="test", config=ms, session=ms, nodeid="None") + assert w[0].lineno == inspect.currentframe().f_lineno - 1 + assert w[0].filename == __file__ diff --git a/testing/python/collect.py b/testing/python/collect.py index e036cb7d9..9ac1c9d31 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -281,10 +281,10 @@ class TestFunction: from _pytest.fixtures import FixtureManager config = testdir.parseconfigure() - session = testdir.Session(config) + session = testdir.Session.from_config(config) session._fixturemanager = FixtureManager(session) - return pytest.Function(config=config, parent=session, **kwargs) + return pytest.Function.from_parent(config=config, parent=session, **kwargs) def test_function_equality(self, testdir, tmpdir): def func1(): @@ -1024,7 +1024,7 @@ class TestReportInfo: return "ABCDE", 42, "custom" def pytest_pycollect_makeitem(collector, name, obj): if name == "test_func": - return MyFunction(name, parent=collector) + return MyFunction.from_parent(name=name, parent=collector) """ ) item = testdir.getitem("def test_func(): pass") diff --git a/testing/python/integration.py b/testing/python/integration.py index 73419eef4..35e86e6b9 100644 --- a/testing/python/integration.py +++ b/testing/python/integration.py @@ -10,7 +10,7 @@ class TestOEJSKITSpecials: import pytest def pytest_pycollect_makeitem(collector, name, obj): if name == "MyClass": - return MyCollector(name, parent=collector) + return MyCollector.from_parent(collector, name=name) class MyCollector(pytest.Collector): def reportinfo(self): return self.fspath, 3, "xyz" @@ -40,7 +40,7 @@ class TestOEJSKITSpecials: import pytest def pytest_pycollect_makeitem(collector, name, obj): if name == "MyClass": - return MyCollector(name, parent=collector) + return MyCollector.from_parent(collector, name=name) class MyCollector(pytest.Collector): def reportinfo(self): return self.fspath, 3, "xyz" diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 9a1e1f968..9b6471cdc 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -30,7 +30,7 @@ class TestMetafunc: names = fixtures.getfuncargnames(func) fixtureinfo = FixtureInfo(names) - definition = DefinitionMock(func) + definition = DefinitionMock._create(func) return python.Metafunc(definition, fixtureinfo, config) def test_no_funcargs(self, testdir): diff --git a/testing/test_collection.py b/testing/test_collection.py index b791ac6f9..624e9dd4e 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -75,7 +75,7 @@ class TestCollector: pass def pytest_collect_file(path, parent): if path.ext == ".xxx": - return CustomFile(path, parent=parent) + return CustomFile.from_parent(fspath=path, parent=parent) """ ) node = testdir.getpathnode(hello) @@ -446,7 +446,7 @@ class TestSession: p.move(target) subdir.chdir() config = testdir.parseconfig(p.basename) - rcol = Session(config=config) + rcol = Session.from_config(config) assert rcol.fspath == subdir parts = rcol._parsearg(p.basename) @@ -463,7 +463,7 @@ class TestSession: # XXX migrate to collectonly? (see below) config = testdir.parseconfig(id) topdir = testdir.tmpdir - rcol = Session(config) + rcol = Session.from_config(config) assert topdir == rcol.fspath # rootid = rcol.nodeid # root2 = rcol.perform_collect([rcol.nodeid], genitems=False)[0] diff --git a/testing/test_mark.py b/testing/test_mark.py index 0e4422025..33276b63c 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -962,7 +962,11 @@ def test_mark_expressions_no_smear(testdir): def test_addmarker_order(): - node = Node("Test", config=mock.Mock(), session=mock.Mock(), nodeid="Test") + session = mock.Mock() + session.own_markers = [] + session.parent = None + session.nodeid = "" + node = Node.from_parent(session, name="Test") node.add_marker("foo") node.add_marker("bar") node.add_marker("baz", append=False) diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 836b458c6..56d5a7625 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -122,7 +122,7 @@ class TestPytestPluginInteractions: def test_hook_proxy(self, testdir): """Test the gethookproxy function(#2016)""" config = testdir.parseconfig() - session = Session(config) + session = Session.from_config(config) testdir.makepyfile(**{"tests/conftest.py": "", "tests/subdir/conftest.py": ""}) conftest1 = testdir.tmpdir.join("tests/conftest.py") From 15ffe6320498f37a57dd23a45866e5903d61f56c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 23 Nov 2019 01:08:56 +0100 Subject: [PATCH 014/158] update doc examples **untested** --- doc/en/deprecations.rst | 10 ++++++++++ doc/en/example/nonpython/conftest.py | 4 ++-- doc/en/example/py2py3/conftest.py | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 748d3ac65..88112b12a 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -19,6 +19,16 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`_pytest.warning_types.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. + +Node Construction changed to ``Node.from_parent`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 5.3 + +The construction of nodes new should use the named constructor ``from_parent``. +This limitation in api surface intends to enable better/simpler refactoring of the collection tree. + + ``junit_family`` default value change to "xunit2" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/en/example/nonpython/conftest.py b/doc/en/example/nonpython/conftest.py index 93d8285bf..d30ab3841 100644 --- a/doc/en/example/nonpython/conftest.py +++ b/doc/en/example/nonpython/conftest.py @@ -4,7 +4,7 @@ import pytest def pytest_collect_file(parent, path): if path.ext == ".yaml" and path.basename.startswith("test"): - return YamlFile(path, parent) + return YamlFile.from_parent(parent, fspath=path) class YamlFile(pytest.File): @@ -13,7 +13,7 @@ class YamlFile(pytest.File): raw = yaml.safe_load(self.fspath.open()) for name, spec in sorted(raw.items()): - yield YamlItem(name, self, spec) + yield YamlItem.from_parent(self, name=name, spec=spec) class YamlItem(pytest.Item): diff --git a/doc/en/example/py2py3/conftest.py b/doc/en/example/py2py3/conftest.py index 844510a25..0291b37b4 100644 --- a/doc/en/example/py2py3/conftest.py +++ b/doc/en/example/py2py3/conftest.py @@ -13,4 +13,4 @@ class DummyCollector(pytest.collect.File): def pytest_pycollect_makemodule(path, parent): bn = path.basename if "py3" in bn and not py3 or ("py2" in bn and py3): - return DummyCollector(path, parent=parent) + return DummyCollector.from_parent(parent, fspath=path) From bc7282576f0490a67795b1844ed8e6c6ab497223 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sun, 24 Nov 2019 23:20:03 +0100 Subject: [PATCH 015/158] typing: minor improvements --- src/_pytest/fixtures.py | 10 ++++------ src/_pytest/main.py | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 34ecf2e21..44802e000 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -36,6 +36,7 @@ if False: # TYPE_CHECKING from typing import Type from _pytest import nodes + from _pytest.main import Session @attr.s(frozen=True) @@ -44,7 +45,7 @@ class PseudoFixtureDef: scope = attr.ib() -def pytest_sessionstart(session): +def pytest_sessionstart(session: "Session"): import _pytest.python import _pytest.nodes @@ -510,13 +511,11 @@ class FixtureRequest: values.append(fixturedef) current = current._parent_request - def _compute_fixture_value(self, fixturedef): + def _compute_fixture_value(self, fixturedef: "FixtureDef") -> None: """ Creates a SubRequest based on "self" and calls the execute method of the given fixturedef object. This will force the FixtureDef object to throw away any previous results and compute a new fixture value, which will be stored into the FixtureDef object itself. - - :param FixtureDef fixturedef: """ # prepare a subrequest object before calling fixture function # (latter managed by fixturedef) @@ -544,9 +543,8 @@ class FixtureRequest: if has_params: frame = inspect.stack()[3] frameinfo = inspect.getframeinfo(frame[0]) - source_path = frameinfo.filename + source_path = py.path.local(frameinfo.filename) source_lineno = frameinfo.lineno - source_path = py.path.local(source_path) if source_path.relto(funcitem.config.rootdir): source_path = source_path.relto(funcitem.config.rootdir) msg = ( diff --git a/src/_pytest/main.py b/src/_pytest/main.py index e46f54d9c..53bfbeb5a 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -15,6 +15,7 @@ from _pytest import nodes from _pytest.config import directory_arg from _pytest.config import hookimpl from _pytest.config import UsageError +from _pytest.fixtures import FixtureManager from _pytest.outcomes import exit from _pytest.runner import collect_one_node from _pytest.runner import SetupState @@ -372,6 +373,7 @@ class Session(nodes.FSCollector): Interrupted = Interrupted Failed = Failed _setupstate = None # type: SetupState + _fixturemanager = None # type: FixtureManager def __init__(self, config): nodes.FSCollector.__init__( From a1219ab8fc4522cc22e8b17a90e9c0e44612d998 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 2 Nov 2019 13:13:33 +0100 Subject: [PATCH 016/158] pytester: use no colors with inline runs by default Sets `PY_COLORS=0` in the environment by default, which is used by pylib. Via https://github.com/blueyed/pytest/pull/58 (initially cherry picked from commit f153ad33d10) --- src/_pytest/pytester.py | 5 +++-- testing/test_pdb.py | 14 +++++++------- testing/test_terminal.py | 2 ++ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index a1acf747e..d5744167c 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -544,11 +544,12 @@ class Testdir: mp.delenv("TOX_ENV_DIR", raising=False) # Discard outer pytest options. mp.delenv("PYTEST_ADDOPTS", raising=False) - - # Environment (updates) for inner runs. + # Ensure no user config is used. tmphome = str(self.tmpdir) mp.setenv("HOME", tmphome) mp.setenv("USERPROFILE", tmphome) + # Do not use colors for inner runs by default. + mp.setenv("PY_COLORS", "0") def __repr__(self): return "".format(self.tmpdir) diff --git a/testing/test_pdb.py b/testing/test_pdb.py index 0f7816dbe..8949b0de8 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -193,7 +193,7 @@ class TestPDB: ) child = testdir.spawn_pytest("-rs --pdb %s" % p1) child.expect("Skipping also with pdb active") - child.expect_exact("= \x1b[33m\x1b[1m1 skipped\x1b[0m\x1b[33m in") + child.expect_exact("= 1 skipped in") child.sendeof() self.flush(child) @@ -221,7 +221,7 @@ class TestPDB: child.sendeof() rest = child.read().decode("utf8") assert "Exit: Quitting debugger" in rest - assert "= \x1b[31m\x1b[1m1 failed\x1b[0m\x1b[31m in" in rest + assert "= 1 failed in" in rest assert "def test_1" not in rest assert "get rekt" not in rest self.flush(child) @@ -506,7 +506,7 @@ class TestPDB: rest = child.read().decode("utf8") assert "! _pytest.outcomes.Exit: Quitting debugger !" in rest - assert "= \x1b[33mno tests ran\x1b[0m\x1b[33m in" in rest + assert "= no tests ran in" in rest assert "BdbQuit" not in rest assert "UNEXPECTED EXCEPTION" not in rest @@ -725,7 +725,7 @@ class TestPDB: assert "> PDB continue (IO-capturing resumed) >" in rest else: assert "> PDB continue >" in rest - assert "= \x1b[32m\x1b[1m1 passed\x1b[0m\x1b[32m in" in rest + assert "= 1 passed in" in rest def test_pdb_used_outside_test(self, testdir): p1 = testdir.makepyfile( @@ -1041,7 +1041,7 @@ class TestTraceOption: child.sendline("q") child.expect_exact("Exit: Quitting debugger") rest = child.read().decode("utf8") - assert "= \x1b[32m\x1b[1m2 passed\x1b[0m\x1b[32m in" in rest + assert "= 2 passed in" in rest assert "reading from stdin while output" not in rest # Only printed once - not on stderr. assert "Exit: Quitting debugger" not in child.before.decode("utf8") @@ -1086,7 +1086,7 @@ class TestTraceOption: child.sendline("c") child.expect_exact("> PDB continue (IO-capturing resumed) >") rest = child.read().decode("utf8") - assert "= \x1b[32m\x1b[1m6 passed\x1b[0m\x1b[32m in" in rest + assert "= 6 passed in" in rest assert "reading from stdin while output" not in rest # Only printed once - not on stderr. assert "Exit: Quitting debugger" not in child.before.decode("utf8") @@ -1197,7 +1197,7 @@ def test_pdb_suspends_fixture_capturing(testdir, fixture): TestPDB.flush(child) assert child.exitstatus == 0 - assert "= \x1b[32m\x1b[1m1 passed\x1b[0m\x1b[32m in" in rest + assert "= 1 passed in" in rest assert "> PDB continue (IO-capturing resumed for fixture %s) >" % (fixture) in rest diff --git a/testing/test_terminal.py b/testing/test_terminal.py index d31033197..fab13b07e 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -154,6 +154,8 @@ class TestTerminal: "test2.py": "def test_2(): pass", } ) + # Explicitly test colored output. + testdir.monkeypatch.setenv("PY_COLORS", "1") child = testdir.spawn_pytest("-v test1.py test2.py") child.expect(r"collecting \.\.\.") From 8feeb093984fcb17491bc1b6d6dcaf94a4cbdae0 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 Nov 2019 11:25:23 +0100 Subject: [PATCH 017/158] fixes #5065 --- testing/{test_pdb.py => test_debugging.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename testing/{test_pdb.py => test_debugging.py} (100%) diff --git a/testing/test_pdb.py b/testing/test_debugging.py similarity index 100% rename from testing/test_pdb.py rename to testing/test_debugging.py From 16ff9f591e38d1f2a79441f177130b1d89098c6e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 2 Dec 2019 18:01:08 +0200 Subject: [PATCH 018/158] Update mypy 0.740 -> 0.750 Release notes: https://mypy-lang.blogspot.com/2019/11/mypy-0.html --- .pre-commit-config.yaml | 2 +- src/_pytest/_code/code.py | 2 +- src/_pytest/_code/source.py | 4 +++- src/_pytest/assertion/rewrite.py | 5 ++--- src/_pytest/compat.py | 7 ++----- src/_pytest/recwarn.py | 4 +++- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4196152f..9548cd079 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: - id: pyupgrade args: [--py3-plus] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.740 + rev: v0.750 hooks: - id: mypy files: ^(src/|testing/) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index a8f117366..d1a8ec2f1 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -479,7 +479,7 @@ class ExceptionInfo(Generic[_E]): assert tup[1] is not None, "no current exception" assert tup[2] is not None, "no current exception" exc_info = (tup[0], tup[1], tup[2]) - return cls.from_exc_info(exc_info, exprinfo) + return ExceptionInfo.from_exc_info(exc_info, exprinfo) @classmethod def for_later(cls) -> "ExceptionInfo[_E]": diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index d7cef683d..ac3ee231e 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -339,7 +339,9 @@ def getstatementrange_ast( block_finder.started = source.lines[start][0].isspace() it = ((x + "\n") for x in source.lines[start:end]) try: - for tok in tokenize.generate_tokens(lambda: next(it)): + # Type ignored until next mypy release. + # https://github.com/python/typeshed/commit/c0d46a20353b733befb85d8b9cc24e5b0bcd8f9a + for tok in tokenize.generate_tokens(lambda: next(it)): # type: ignore block_finder.tokeneater(*tok) except (inspect.EndOfBlock, IndentationError): end = block_finder.last + start diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 51ea1801b..6bfb876e4 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -1074,14 +1074,13 @@ def try_makedirs(cache_dir) -> bool: def get_cache_dir(file_path: Path) -> Path: """Returns the cache directory to write .pyc files for the given .py file path""" - # Type ignored until added in next mypy release. - if sys.version_info >= (3, 8) and sys.pycache_prefix: # type: ignore + if sys.version_info >= (3, 8) and sys.pycache_prefix: # given: # prefix = '/tmp/pycs' # path = '/home/user/proj/test_app.py' # we want: # '/tmp/pycs/home/user/proj' - return Path(sys.pycache_prefix) / Path(*file_path.parts[1:-1]) # type: ignore + return Path(sys.pycache_prefix) / Path(*file_path.parts[1:-1]) else: # classic pycache directory return file_path.parent / "__pycache__" diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index fc810b3e5..8dd74b577 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -43,8 +43,7 @@ MODULE_NOT_FOUND_ERROR = ( if sys.version_info >= (3, 8): - # Type ignored until next mypy release. - from importlib import metadata as importlib_metadata # type: ignore + from importlib import metadata as importlib_metadata else: import importlib_metadata # noqa: F401 @@ -385,9 +384,7 @@ else: if sys.version_info >= (3, 8): - # TODO: Remove type ignore on next mypy update. - # https://github.com/python/typeshed/commit/add0b5e930a1db16560fde45a3b710eefc625709 - from functools import cached_property # type: ignore + from functools import cached_property else: class cached_property(Generic[_S, _T]): diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 5cf32c894..e3d7b72ec 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -129,7 +129,9 @@ def warns( # noqa: F811 return func(*args[1:], **kwargs) -class WarningsRecorder(warnings.catch_warnings): +# Type ignored until next mypy release. Regression fixed by: +# https://github.com/python/typeshed/commit/41bf6a19822d6694973449d795f8bfe1d50d5a03 +class WarningsRecorder(warnings.catch_warnings): # type: ignore """A context manager to record raised warnings. Adapted from `warnings.catch_warnings`. From e24b6b03886a36461af3381637a33fd8de6bee06 Mon Sep 17 00:00:00 2001 From: Christoph Buelter Date: Thu, 5 Dec 2019 13:56:45 +0100 Subject: [PATCH 019/158] Change -k EXPRESSION matching to be case-insensitive --- src/_pytest/mark/legacy.py | 10 +++++++++- testing/test_collection.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/_pytest/mark/legacy.py b/src/_pytest/mark/legacy.py index 3721e3b02..651288cb8 100644 --- a/src/_pytest/mark/legacy.py +++ b/src/_pytest/mark/legacy.py @@ -57,7 +57,15 @@ class KeywordMapping: return cls(mapped_names) def __getitem__(self, subname): - for name in self._names: + """Return whether subname is included within stored names. + + The string inclusion check is case-insensitive. + + """ + subname = subname.lower() + names = [name.lower() for name in self._names] + + for name in names: if subname in name: return True return False diff --git a/testing/test_collection.py b/testing/test_collection.py index 624e9dd4e..fcbfcf5fb 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -809,6 +809,40 @@ class TestNodekeywords: reprec = testdir.inline_run("-k repr") reprec.assertoutcome(passed=1, failed=0) + def test_keyword_matching_is_case_insensitive_by_default(self, testdir): + """Check that selection via -k EXPRESSION is case-insensitive. + + Since markers are also added to the node keywords, they too can + be matched without having to think about case sensitivity. + + """ + testdir.makepyfile( + """ + import pytest + + def test_sPeCiFiCToPiC_1(): + assert True + + class TestSpecificTopic_2: + def test(self): + assert True + + @pytest.mark.sPeCiFiCToPic_3 + def test(): + assert True + + @pytest.mark.sPeCiFiCToPic_4 + class Test: + def test(self): + assert True + + """ + ) + num_all_tests_passed = 4 + for expression in ("specifictopic", "SPECIFICTOPIC", "SpecificTopic"): + reprec = testdir.inline_run("-k " + expression) + reprec.assertoutcome(passed=num_all_tests_passed, failed=0) + COLLECTION_ERROR_PY_FILES = dict( test_01_failure=""" From ac5929eef35508d267bc4010cb9b4ae0b6de3f74 Mon Sep 17 00:00:00 2001 From: Christoph Buelter Date: Thu, 5 Dec 2019 14:13:22 +0100 Subject: [PATCH 020/158] Update docs about case-insensitive expression matching --- doc/en/example/markers.rst | 4 ++++ doc/en/usage.rst | 4 ++-- src/_pytest/mark/__init__.py | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index 8143b3fd4..e64f31fd5 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -148,6 +148,10 @@ which implements a substring match on the test names instead of the exact match on markers that ``-m`` provides. This makes it easy to select tests based on their names: +.. versionadded: 5.3.1/6.0 + +The expression matching is now case-insensitive. + .. code-block:: pytest $ pytest -v -k http # running with the above defined example module diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 245a67b68..527794823 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -94,8 +94,8 @@ Pytest supports several ways to run and select tests from the command-line. pytest -k "MyClass and not method" -This will run tests which contain names that match the given *string expression*, which can -include Python operators that use filenames, class names and function names as variables. +This will run tests which contain names that match the given *string expression* (case-insensitive), +which can include Python operators that use filenames, class names and function names as variables. The example above will run ``TestMyClass.test_something`` but not ``TestMyClass.test_method_simple``. .. _nodeids: diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index e21e234e7..f493bd839 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -52,7 +52,8 @@ def pytest_addoption(parser): "-k 'not test_method and not test_other' will eliminate the matches. " "Additionally keywords are matched to classes and functions " "containing extra names in their 'extra_keyword_matches' set, " - "as well as functions which have names assigned directly to them.", + "as well as functions which have names assigned directly to them. " + "The matching is case-insensitive.", ) group._addoption( From 24d4882d8267b9ee0f4e2d0f046b8343361d2e91 Mon Sep 17 00:00:00 2001 From: Christoph Buelter Date: Thu, 5 Dec 2019 14:20:07 +0100 Subject: [PATCH 021/158] Update authors file and sort the list --- AUTHORS | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index a3e526c5a..105c1be53 100644 --- a/AUTHORS +++ b/AUTHORS @@ -59,12 +59,12 @@ Christian Fetzer Christian Neumüller Christian Theunert Christian Tismer -Christopher Gilling +Christoph Buelter Christopher Dignam +Christopher Gilling CrazyMerlyn Cyrus Maden Damian Skrzypczak -Dhiren Serai Daniel Grana Daniel Hahler Daniel Nuri @@ -79,6 +79,7 @@ David Szotten David Vierra Daw-Ran Liou Denis Kirisov +Dhiren Serai Diego Russo Dmitry Dygalo Dmitry Pribysh From 5a7de2c2cb34d49c3ea3aa0baee4a6b568ef37cd Mon Sep 17 00:00:00 2001 From: Christoph Buelter Date: Thu, 5 Dec 2019 14:28:21 +0100 Subject: [PATCH 022/158] Add changelog file for PR 6316 --- changelog/6316.improvement.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/6316.improvement.rst diff --git a/changelog/6316.improvement.rst b/changelog/6316.improvement.rst new file mode 100644 index 000000000..6ab7d8717 --- /dev/null +++ b/changelog/6316.improvement.rst @@ -0,0 +1 @@ +Matching of ``-k EXPRESSION`` to test names is now case-insensitive. From 623b3982b0ac8268c70d204c225893c26d7d9f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BClter?= Date: Thu, 5 Dec 2019 16:59:08 +0100 Subject: [PATCH 023/158] Update doc/en/example/markers.rst Co-Authored-By: Bruno Oliveira --- doc/en/example/markers.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index e64f31fd5..e83beedd0 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -148,7 +148,7 @@ which implements a substring match on the test names instead of the exact match on markers that ``-m`` provides. This makes it easy to select tests based on their names: -.. versionadded: 5.3.1/6.0 +.. versionadded: 5.4 The expression matching is now case-insensitive. From a326fa22c6e5ad711ffecd3a8f9432737d0ccc1b Mon Sep 17 00:00:00 2001 From: Christoph Buelter Date: Thu, 5 Dec 2019 17:02:18 +0100 Subject: [PATCH 024/158] Add a failing test to ensure not everything matches by accident --- testing/test_collection.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/testing/test_collection.py b/testing/test_collection.py index fcbfcf5fb..e7bcb60a9 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -836,12 +836,15 @@ class TestNodekeywords: def test(self): assert True + def test_failing_5(): + assert False, "This should not match" + """ ) - num_all_tests_passed = 4 + num_matching_tests = 4 for expression in ("specifictopic", "SPECIFICTOPIC", "SpecificTopic"): reprec = testdir.inline_run("-k " + expression) - reprec.assertoutcome(passed=num_all_tests_passed, failed=0) + reprec.assertoutcome(passed=num_matching_tests, failed=0) COLLECTION_ERROR_PY_FILES = dict( From 3a0f436c1a02f41af942e2d9f3d3a64f5fff3db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BClter?= Date: Fri, 6 Dec 2019 08:40:49 +0100 Subject: [PATCH 025/158] Iterate a generator expression instead of a temporary list Co-Authored-By: Bruno Oliveira --- src/_pytest/mark/legacy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/mark/legacy.py b/src/_pytest/mark/legacy.py index 651288cb8..766b8f9bd 100644 --- a/src/_pytest/mark/legacy.py +++ b/src/_pytest/mark/legacy.py @@ -63,7 +63,7 @@ class KeywordMapping: """ subname = subname.lower() - names = [name.lower() for name in self._names] + names = (name.lower() for name in self._names) for name in names: if subname in name: From c6ed69a6663657655579e76265750a6dbe9bfab9 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 6 Dec 2019 08:47:39 -0300 Subject: [PATCH 026/158] Replace 'removal' by 'breaking' changelog category As discussed, sometimes we will need to introduce changes which are not necessarily removals but might break existing suites --- .pre-commit-config.yaml | 4 ++-- changelog/{6316.improvement.rst => 6316.breaking.rst} | 0 changelog/README.rst | 2 +- pyproject.toml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename changelog/{6316.improvement.rst => 6316.breaking.rst} (100%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c89a6272..64f3f32ac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,8 +53,8 @@ repos: - id: changelogs-rst name: changelog filenames language: fail - entry: 'changelog files must be named ####.(feature|bugfix|doc|deprecation|removal|vendor|trivial).rst' - exclude: changelog/(\d+\.(feature|improvement|bugfix|doc|deprecation|removal|vendor|trivial).rst|README.rst|_template.rst) + entry: 'changelog files must be named ####.(breaking|bugfix|deprecation|doc|feature|improvement|trivial|vendor).rst' + exclude: changelog/(\d+\.(breaking|bugfix|deprecation|doc|feature|improvement|trivial|vendor).rst|README.rst|_template.rst) files: ^changelog/ - id: py-deprecated name: py library is deprecated diff --git a/changelog/6316.improvement.rst b/changelog/6316.breaking.rst similarity index 100% rename from changelog/6316.improvement.rst rename to changelog/6316.breaking.rst diff --git a/changelog/README.rst b/changelog/README.rst index adabc9ca1..dd0e7dfea 100644 --- a/changelog/README.rst +++ b/changelog/README.rst @@ -18,7 +18,7 @@ Each file should be named like ``..rst``, where * ``bugfix``: fixes a reported bug. * ``doc``: documentation improvement, like rewording an entire session or adding missing docs. * ``deprecation``: feature deprecation. -* ``removal``: feature removal. +* ``breaking``: a change which may break existing suites, such as feature removal or behavior change. * ``vendor``: changes in packages vendored in pytest. * ``trivial``: fixing a small typo or internal change that might be noteworthy. diff --git a/pyproject.toml b/pyproject.toml index 31bf3bf4b..4ac1fd754 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,8 @@ title_format = "pytest {version} ({project_date})" template = "changelog/_template.rst" [[tool.towncrier.type]] - directory = "removal" - name = "Removals" + directory = "breaking" + name = "Breaking Changes" showcontent = true [[tool.towncrier.type]] From 3812985ed4601d67543d0592e39480d33ed80268 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 1 Dec 2019 12:46:16 +0100 Subject: [PATCH 027/158] update backward compatibility policy to allow for breakage Co-Authored-By: Anthony Sottile Co-Authored-By: Bruno Oliveira Co-Authored-By: Hugo van Kemenade --- doc/en/backwards-compatibility.rst | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/doc/en/backwards-compatibility.rst b/doc/en/backwards-compatibility.rst index 56afd98af..d5b2d79d6 100644 --- a/doc/en/backwards-compatibility.rst +++ b/doc/en/backwards-compatibility.rst @@ -3,6 +3,61 @@ Backwards Compatibility Policy ============================== +.. versionadded: 6.0 + +pytest is actively evolving and is a project that has been decades in the making, +we keep learning about new and better structures to express different details about testing. + +While we implement those modifications we try to ensure an easy transition and don't want to impose unnecessary churn on our users and community/plugin authors. + +As of now, pytest considers multipe types of backward compatibility transitions: + +a) trivial: APIs which trivially translate to the new mechanism, + and do not cause problematic changes. + + We try to support those indefinitely while encouraging users to switch to newer/better mechanisms through documentation. + +b) transitional: the old and new API don't conflict + and we can help users transition by using warnings, while supporting both for a prolonged time. + + We will only start the removal of deprecated functionality in major releases (e.g. if we deprecate something in 3.0 we will start to remove it in 4.0), and keep it around for at least two minor releases (e.g. if we deprecate something in 3.9 and 4.0 is the next release, we start to remove it in 5.0, not in 4.0). + + When the deprecation expires (e.g. 4.0 is released), we won't remove the deprecated functionality immediately, but will use the standard warning filters to turn them into **errors** by default. This approach makes it explicit that removal is imminent, and still gives you time to turn the deprecated feature into a warning instead of an error so it can be dealt with in your own time. In the next minor release (e.g. 4.1), the feature will be effectively removed. + + +c) true breakage: should only to be considered when normal transition is unreasonably unsustainable and would offset important development/features by years. + In addition, they should be limited to APIs where the number of actual users is very small (for example only impacting some plugins), and can be coordinated with the community in advance. + + Examples for such upcoming changes: + + * removal of ``pytest_runtest_protocol/nextitem`` - `#895`_ + * rearranging of the node tree to include ``FunctionDefinition`` + * rearranging of ``SetupState`` `#895`_ + + True breakages must be announced first in an issue containing: + + * Detailed description of the change + * Rationale + * Expected impact on users and plugin authors (example in `#895`_) + + After there's no hard *-1* on the issue it should be followed up by an initial proof-of-concept Pull Request. + + This POC serves as both a coordination point to assess impact and potential inspriation to come up with a transitional solution after all. + + After a reasonable amount of time the PR can be merged to base a new major release. + + For the PR to mature from POC to acceptance, it must contain: + * Setup of deprecation errors/warnings that help users fix and port their code. If it is possible to introduce a deprecation period under the current series, before the true breakage, it should be introduced in a separate PR and be part of the current release stream. + * Detailed description of the rationale and examples on how to port code in ``doc/en/deprecations.rst``. + + +History +========= + + +Focus primary on smooth transition - stance (pre 6.0) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Keeping backwards compatibility has a very high priority in the pytest project. Although we have deprecated functionality over the years, most of it is still supported. All deprecations in pytest were done because simpler or more efficient ways of accomplishing the same tasks have emerged, making the old way of doing things unnecessary. With the pytest 3.0 release we introduced a clear communication scheme for when we will actually remove the old busted joint and politely ask you to use the new hotness instead, while giving you enough time to adjust your tests or raise concerns if there are valid reasons to keep deprecated functionality around. @@ -20,3 +75,6 @@ Deprecation Roadmap Features currently deprecated and removed in previous releases can be found in :ref:`deprecations`. We track future deprecation and removal of features using milestones and the `deprecation `_ and `removal `_ labels on GitHub. + + +.. _`#895`: https://github.com/pytest-dev/pytest/issues/895 From e13ad22364ce4c2403268988228df73a5d26d8bb Mon Sep 17 00:00:00 2001 From: cmachalo Date: Tue, 3 Dec 2019 14:15:13 -0800 Subject: [PATCH 028/158] Include new --capture-mode=tee-sys option Fix #4597 --- AUTHORS | 1 + changelog/4597.feature.rst | 1 + doc/en/capture.rst | 19 ++++++++++++----- src/_pytest/capture.py | 20 ++++++++++++++++-- src/_pytest/compat.py | 11 ++++++++++ testing/acceptance_test.py | 25 +++++++++++++++++++++++ testing/test_capture.py | 42 +++++++++++++++++++++++++++++++++++++- 7 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 changelog/4597.feature.rst diff --git a/AUTHORS b/AUTHORS index a3e526c5a..163b317bc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -52,6 +52,7 @@ Carl Friedrich Bolz Carlos Jenkins Ceridwen Charles Cloud +Charles Machalow Charnjit SiNGH (CCSJ) Chris Lamb Christian Boelsen diff --git a/changelog/4597.feature.rst b/changelog/4597.feature.rst new file mode 100644 index 000000000..aac395373 --- /dev/null +++ b/changelog/4597.feature.rst @@ -0,0 +1 @@ +New :ref:`--capture=tee-sys ` option to allow both live printing and capturing of test output. diff --git a/doc/en/capture.rst b/doc/en/capture.rst index 3e744e764..3982c6116 100644 --- a/doc/en/capture.rst +++ b/doc/en/capture.rst @@ -21,27 +21,36 @@ file descriptors. This allows to capture output from simple print statements as well as output from a subprocess started by a test. +.. _capture-method: + Setting capturing methods or disabling capturing ------------------------------------------------- -There are two ways in which ``pytest`` can perform capturing: +There are three ways in which ``pytest`` can perform capturing: -* file descriptor (FD) level capturing (default): All writes going to the +* ``fd`` (file descriptor) level capturing (default): All writes going to the operating system file descriptors 1 and 2 will be captured. * ``sys`` level capturing: Only writes to Python files ``sys.stdout`` and ``sys.stderr`` will be captured. No capturing of writes to filedescriptors is performed. +* ``tee-sys`` capturing: Python writes to ``sys.stdout`` and ``sys.stderr`` + will be captured, however the writes will also be passed-through to + the actual ``sys.stdout`` and ``sys.stderr``. This allows output to be + 'live printed' and captured for plugin use, such as junitxml (new in pytest 5.4). + .. _`disable capturing`: You can influence output capturing mechanisms from the command line: .. code-block:: bash - pytest -s # disable all capturing - pytest --capture=sys # replace sys.stdout/stderr with in-mem files - pytest --capture=fd # also point filedescriptors 1 and 2 to temp file + pytest -s # disable all capturing + pytest --capture=sys # replace sys.stdout/stderr with in-mem files + pytest --capture=fd # also point filedescriptors 1 and 2 to temp file + pytest --capture=tee-sys # combines 'sys' and '-s', capturing sys.stdout/stderr + # and passing it along to the actual sys.stdout/stderr .. _printdebugging: diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 0cd3ce604..24072d34a 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -11,6 +11,7 @@ from io import UnsupportedOperation from tempfile import TemporaryFile import pytest +from _pytest.compat import CaptureAndPassthroughIO from _pytest.compat import CaptureIO from _pytest.fixtures import FixtureRequest @@ -24,8 +25,8 @@ def pytest_addoption(parser): action="store", default="fd" if hasattr(os, "dup") else "sys", metavar="method", - choices=["fd", "sys", "no"], - help="per-test capturing method: one of fd|sys|no.", + choices=["fd", "sys", "no", "tee-sys"], + help="per-test capturing method: one of fd|sys|no|tee-sys.", ) group._addoption( "-s", @@ -90,6 +91,8 @@ class CaptureManager: return MultiCapture(out=True, err=True, Capture=SysCapture) elif method == "no": return MultiCapture(out=False, err=False, in_=False) + elif method == "tee-sys": + return MultiCapture(out=True, err=True, in_=False, Capture=TeeSysCapture) raise ValueError("unknown capturing method: %r" % method) # pragma: no cover def is_capturing(self): @@ -681,6 +684,19 @@ class SysCapture: self._old.flush() +class TeeSysCapture(SysCapture): + def __init__(self, fd, tmpfile=None): + name = patchsysdict[fd] + self._old = getattr(sys, name) + self.name = name + if tmpfile is None: + if name == "stdin": + tmpfile = DontReadFromInput() + else: + tmpfile = CaptureAndPassthroughIO(self._old) + self.tmpfile = tmpfile + + class SysCaptureBinary(SysCapture): # Ignore type because it doesn't match the type in the superclass (str). EMPTY_BUFFER = b"" # type: ignore diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 8dd74b577..c566a39e8 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -13,6 +13,7 @@ from inspect import signature from typing import Any from typing import Callable from typing import Generic +from typing import IO from typing import Optional from typing import overload from typing import Tuple @@ -371,6 +372,16 @@ class CaptureIO(io.TextIOWrapper): return self.buffer.getvalue().decode("UTF-8") +class CaptureAndPassthroughIO(CaptureIO): + def __init__(self, other: IO) -> None: + self._other = other + super().__init__() + + def write(self, s) -> int: + super().write(s) + return self._other.write(s) + + if sys.version_info < (3, 5, 2): # pragma: no cover def overload(f): # noqa: F811 diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 8f7be14be..ffb6836e3 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1285,3 +1285,28 @@ def test_pdb_can_be_rewritten(testdir): ] ) assert result.ret == 1 + + +def test_tee_stdio_captures_and_live_prints(testdir): + testpath = testdir.makepyfile( + """ + import sys + def test_simple(): + print ("@this is stdout@") + print ("@this is stderr@", file=sys.stderr) + """ + ) + result = testdir.runpytest_subprocess( + testpath, "--capture=tee-sys", "--junitxml=output.xml" + ) + + # ensure stdout/stderr were 'live printed' + result.stdout.fnmatch_lines(["*@this is stdout@*"]) + result.stderr.fnmatch_lines(["*@this is stderr@*"]) + + # now ensure the output is in the junitxml + with open(os.path.join(testdir.tmpdir.strpath, "output.xml"), "r") as f: + fullXml = f.read() + + assert "@this is stdout@\n" in fullXml + assert "@this is stderr@\n" in fullXml diff --git a/testing/test_capture.py b/testing/test_capture.py index 94af3aef7..1885c9bb6 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -32,6 +32,10 @@ def StdCapture(out=True, err=True, in_=True): return capture.MultiCapture(out, err, in_, Capture=capture.SysCapture) +def TeeStdCapture(out=True, err=True, in_=True): + return capture.MultiCapture(out, err, in_, Capture=capture.TeeSysCapture) + + class TestCaptureManager: def test_getmethod_default_no_fd(self, monkeypatch): from _pytest.capture import pytest_addoption @@ -816,6 +820,25 @@ class TestCaptureIO: assert f.getvalue() == "foo\r\n" +class TestCaptureAndPassthroughIO(TestCaptureIO): + def test_text(self): + sio = io.StringIO() + f = capture.CaptureAndPassthroughIO(sio) + f.write("hello") + s1 = f.getvalue() + assert s1 == "hello" + s2 = sio.getvalue() + assert s2 == s1 + f.close() + sio.close() + + def test_unicode_and_str_mixture(self): + sio = io.StringIO() + f = capture.CaptureAndPassthroughIO(sio) + f.write("\u00f6") + pytest.raises(TypeError, f.write, b"hello") + + def test_dontreadfrominput(): from _pytest.capture import DontReadFromInput @@ -1112,6 +1135,23 @@ class TestStdCapture: pytest.raises(IOError, sys.stdin.read) +class TestTeeStdCapture(TestStdCapture): + captureclass = staticmethod(TeeStdCapture) + + def test_capturing_error_recursive(self): + """ for TeeStdCapture since we passthrough stderr/stdout, cap1 + should get all output, while cap2 should only get "cap2\n" """ + + with self.getcapture() as cap1: + print("cap1") + with self.getcapture() as cap2: + print("cap2") + out2, err2 = cap2.readouterr() + out1, err1 = cap1.readouterr() + assert out1 == "cap1\ncap2\n" + assert out2 == "cap2\n" + + class TestStdCaptureFD(TestStdCapture): pytestmark = needsosdup captureclass = staticmethod(StdCaptureFD) @@ -1252,7 +1292,7 @@ def test_close_and_capture_again(testdir): ) -@pytest.mark.parametrize("method", ["SysCapture", "FDCapture"]) +@pytest.mark.parametrize("method", ["SysCapture", "FDCapture", "TeeSysCapture"]) def test_capturing_and_logging_fundamentals(testdir, method): if method == "StdCaptureFD" and not hasattr(os, "dup"): pytest.skip("need os.dup") From 071106042289e67341e5435e456780fe72854809 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 9 Dec 2019 15:57:19 -0300 Subject: [PATCH 029/158] Change 4639 from feature to improvement An improvement seems more adequate here. --- changelog/{4639.feature.rst => 4639.improvement.rst} | 1 + 1 file changed, 1 insertion(+) rename changelog/{4639.feature.rst => 4639.improvement.rst} (99%) diff --git a/changelog/4639.feature.rst b/changelog/4639.improvement.rst similarity index 99% rename from changelog/4639.feature.rst rename to changelog/4639.improvement.rst index f296f1649..e18b3b619 100644 --- a/changelog/4639.feature.rst +++ b/changelog/4639.improvement.rst @@ -1,3 +1,4 @@ Revert "A warning is now issued when assertions are made for ``None``". + The warning proved to be less useful than initially expected and had quite a few false positive cases. From 59067ad33d55e66a54ec617effe4350e42d66f65 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 12 Dec 2019 07:41:23 -0300 Subject: [PATCH 030/158] Make -r letters "f" and "F" aliases As far as the output is concerned, they are both identical so it doesn't make sense to have both. setup, teardown, and collect failures are already reported as "errors", "E". --- src/_pytest/terminal.py | 13 ++++++------- testing/test_terminal.py | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index e88545eca..d1ee701e8 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -173,6 +173,9 @@ def getreportopt(config: Config) -> str: elif config.option.disable_warnings and "w" in reportchars: reportchars = reportchars.replace("w", "") for char in reportchars: + # f and F are aliases + if char == "F": + char = "f" if char == "a": reportopts = "sxXwEf" elif char == "A": @@ -185,19 +188,16 @@ def getreportopt(config: Config) -> str: @pytest.hookimpl(trylast=True) # after _pytest.runner def pytest_report_teststatus(report: TestReport) -> Tuple[str, str, str]: + letter = "F" if report.passed: letter = "." elif report.skipped: letter = "s" - elif report.failed: - letter = "F" - if report.when != "call": - letter = "f" - # Report failed CollectReports as "error" (in line with pytest_collectreport). outcome = report.outcome - if report.when == "collect" and outcome == "failed": + if report.when in ("collect", "setup", "teardown") and outcome == "failed": outcome = "error" + letter = "E" return outcome, letter, outcome.upper() @@ -988,7 +988,6 @@ class TerminalReporter: "x": show_xfailed, "X": show_xpassed, "f": partial(show_simple, "failed"), - "F": partial(show_simple, "failed"), "s": show_skipped, "S": show_skipped, "p": partial(show_simple, "passed"), diff --git a/testing/test_terminal.py b/testing/test_terminal.py index fab13b07e..2d875a64c 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -754,6 +754,18 @@ class TestTerminalFunctional: result = testdir.runpytest(*params) result.stdout.fnmatch_lines(["collected 3 items", "hello from hook: 3 items"]) + def test_summary_f_alias(self, testdir): + testdir.makepyfile( + """ + def test(): + assert False + """ + ) + result = testdir.runpytest("-rfF") + expected = "FAILED test_summary_f_alias.py::test - assert False" + result.stdout.fnmatch_lines([expected]) + assert result.stdout.lines.count(expected) == 1 + def test_fail_extra_reporting(testdir, monkeypatch): monkeypatch.setenv("COLUMNS", "80") @@ -1685,12 +1697,16 @@ class TestProgressWithTeardown: testdir.makepyfile( """ def test_foo(fail_teardown): - assert False + assert 0 """ ) - output = testdir.runpytest() + output = testdir.runpytest("-rfE") output.stdout.re_match_lines( - [r"test_teardown_with_test_also_failing.py FE\s+\[100%\]"] + [ + r"test_teardown_with_test_also_failing.py FE\s+\[100%\]", + "FAILED test_teardown_with_test_also_failing.py::test_foo - assert 0", + "ERROR test_teardown_with_test_also_failing.py::test_foo - assert False", + ] ) def test_teardown_many(self, testdir, many_files): From fa51a26743930576e2587a92217b1d9f5063175d Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 12 Dec 2019 07:48:07 -0300 Subject: [PATCH 031/158] Make -r letters "s" and "S" aliases Similar reasons as the previous commit --- src/_pytest/terminal.py | 8 ++++---- testing/test_terminal.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index d1ee701e8..2a99bfdd5 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -172,10 +172,11 @@ def getreportopt(config: Config) -> str: reportchars += "w" elif config.option.disable_warnings and "w" in reportchars: reportchars = reportchars.replace("w", "") + aliases = {"F", "S"} for char in reportchars: - # f and F are aliases - if char == "F": - char = "f" + # handle old aliases + if char in aliases: + char = char.lower() if char == "a": reportopts = "sxXwEf" elif char == "A": @@ -989,7 +990,6 @@ class TerminalReporter: "X": show_xpassed, "f": partial(show_simple, "failed"), "s": show_skipped, - "S": show_skipped, "p": partial(show_simple, "passed"), "E": partial(show_simple, "error"), } # type: Mapping[str, Callable[[List[str]], None]] diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 2d875a64c..0fe0e09e1 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -755,6 +755,7 @@ class TestTerminalFunctional: result.stdout.fnmatch_lines(["collected 3 items", "hello from hook: 3 items"]) def test_summary_f_alias(self, testdir): + """Test that 'f' and 'F' report chars are aliases and don't show up twice in the summary (#6334)""" testdir.makepyfile( """ def test(): @@ -766,6 +767,22 @@ class TestTerminalFunctional: result.stdout.fnmatch_lines([expected]) assert result.stdout.lines.count(expected) == 1 + def test_summary_s_alias(self, testdir): + """Test that 's' and 'S' report chars are aliases and don't show up twice in the summary""" + testdir.makepyfile( + """ + import pytest + + @pytest.mark.skip + def test(): + pass + """ + ) + result = testdir.runpytest("-rsS") + expected = "SKIPPED [1] test_summary_s_alias.py:3: unconditional skip" + result.stdout.fnmatch_lines([expected]) + assert result.stdout.lines.count(expected) == 1 + def test_fail_extra_reporting(testdir, monkeypatch): monkeypatch.setenv("COLUMNS", "80") From 9b74bf1e0c4e0f3ce4e440ec50ca9b5f190ce8c7 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 12 Dec 2019 08:05:22 -0300 Subject: [PATCH 032/158] Add CHANGELOG entry for #6334 --- changelog/6334.bugfix.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/6334.bugfix.rst diff --git a/changelog/6334.bugfix.rst b/changelog/6334.bugfix.rst new file mode 100644 index 000000000..abd4c748b --- /dev/null +++ b/changelog/6334.bugfix.rst @@ -0,0 +1,3 @@ +Fix summary entries appearing twice when ``f/F`` and ``s/S`` report chars were used at the same time in the ``-r`` command-line option (for example ``-rFf``). + +The upper case variants were never documented and the preferred form should be the lower case. From 226f0c48bf6863ec452a9c304bde3d7ebed0f752 Mon Sep 17 00:00:00 2001 From: gftea Date: Fri, 6 Dec 2019 22:09:42 +0100 Subject: [PATCH 033/158] fix #5686, mktemp now fails given absolute and non-normalized paths. --- changelog/5686.improvement.rst | 1 + src/_pytest/tmpdir.py | 9 +++++++++ testing/test_tmpdir.py | 33 ++++++++++++++++++++++++++------- 3 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 changelog/5686.improvement.rst diff --git a/changelog/5686.improvement.rst b/changelog/5686.improvement.rst new file mode 100644 index 000000000..e77997d87 --- /dev/null +++ b/changelog/5686.improvement.rst @@ -0,0 +1 @@ +``tmpdir_factory.mktemp`` now fails when given absolute and non-normalized paths. diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index bd8fb7d8a..b87e37167 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -45,8 +45,17 @@ class TempPathFactory: given_basetemp=config.option.basetemp, trace=config.trace.get("tmpdir") ) + def _ensure_relative_to_basetemp(self, basename: str): + basename = os.path.normpath(basename) + if (self.getbasetemp() / basename).resolve().parent != self.getbasetemp(): + raise ValueError( + "{} is not a normalized and relative path".format(basename) + ) + return basename + def mktemp(self, basename: str, numbered: bool = True) -> Path: """makes a temporary directory managed by the factory""" + basename = self._ensure_relative_to_basetemp(basename) if not numbered: p = self.getbasetemp().joinpath(basename) p.mkdir() diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index eb1c1f300..b7cf8d2b5 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -74,19 +74,38 @@ class TestConfigTmpdir: assert not mytemp.join("hello").check() -def test_basetemp(testdir): +testdata = [ + ("mypath", True), + ("/mypath1", False), + ("./mypath1", True), + ("../mypath3", False), + ("../../mypath4", False), + ("mypath5/..", False), + ("mypath6/../mypath6", True), + ("mypath7/../mypath7/..", False), +] + + +@pytest.mark.parametrize("basename, is_ok", testdata) +def test_mktemp(testdir, basename, is_ok): mytemp = testdir.tmpdir.mkdir("mytemp") p = testdir.makepyfile( """ import pytest - def test_1(tmpdir_factory): - tmpdir_factory.mktemp('hello', numbered=False) - """ + def test_abs_path(tmpdir_factory): + tmpdir_factory.mktemp('{}', numbered=False) + """.format( + basename + ) ) + result = testdir.runpytest(p, "--basetemp=%s" % mytemp) - assert result.ret == 0 - print(mytemp) - assert mytemp.join("hello").check() + if is_ok: + assert result.ret == 0 + assert mytemp.join(basename).check() + else: + assert result.ret == 1 + result.stdout.fnmatch_lines("*ValueError*") def test_tmpdir_always_is_realpath(testdir): From afbaee7649c7b2c7a6167e47aea8fb0f46444755 Mon Sep 17 00:00:00 2001 From: Vinay Calastry Date: Tue, 10 Dec 2019 19:47:51 -0800 Subject: [PATCH 034/158] Deprecate --no-print-logs option --- changelog/3238.deprecation.rst | 5 +++++ doc/en/deprecations.rst | 14 ++++++++++++ src/_pytest/deprecated.py | 7 ++++-- src/_pytest/logging.py | 6 +++++ testing/deprecated_test.py | 41 ++++++++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 changelog/3238.deprecation.rst diff --git a/changelog/3238.deprecation.rst b/changelog/3238.deprecation.rst new file mode 100644 index 000000000..e4e8f25ea --- /dev/null +++ b/changelog/3238.deprecation.rst @@ -0,0 +1,5 @@ +Option ``--no-print-logs`` is deprecated and meant to be removed in a future release. If you use ``--no-print-logs``, please try out ``--show-capture`` and +provide feedback. + +``--show-capture`` command-line option was added in ``pytest 3.5.0`` and allows to specify how to +display captured output when tests fail: ``no``, ``stdout``, ``stderr``, ``log`` or ``all`` (the default). diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 88112b12a..bb91b9a11 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -20,6 +20,20 @@ Below is a complete list of all pytest features which are considered deprecated. :ref:`standard warning filters `. +``--no-print-logs`` command-line option +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 5.4 + + +Option ``--no-print-logs`` is deprecated and meant to be removed in a future release. If you use ``--no-print-logs``, please try out ``--show-capture`` and +provide feedback. + +``--show-capture`` command-line option was added in ``pytest 3.5.0` and allows to specify how to +display captured output when tests fail: ``no``, ``stdout``, ``stderr``, ``log`` or ``all`` (the default). + + + Node Construction changed to ``Node.from_parent`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 1fdc37c04..afaa0e72a 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -19,13 +19,11 @@ DEPRECATED_EXTERNAL_PLUGINS = { "pytest_faulthandler", } - FUNCARGNAMES = PytestDeprecationWarning( "The `funcargnames` attribute was an alias for `fixturenames`, " "since pytest 2.3 - use the newer attribute instead." ) - RESULT_LOG = PytestDeprecationWarning( "--result-log is deprecated, please try the new pytest-reportlog plugin.\n" "See https://docs.pytest.org/en/latest/deprecations.html#result-log-result-log for more information." @@ -45,3 +43,8 @@ JUNIT_XML_DEFAULT_FAMILY = PytestDeprecationWarning( "The 'junit_family' default value will change to 'xunit2' in pytest 6.0.\n" "Add 'junit_family=legacy' to your pytest.ini file to silence this warning and make your suite compatible." ) + +NO_PRINT_LOGS = PytestDeprecationWarning( + "--no-print-logs is deprecated and scheduled for removal in pytest 6.0.\n" + "Please use --show-capture instead." +) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index ccd79b834..e4ccad2c3 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -485,6 +485,12 @@ class LoggingPlugin: self._config = config self.print_logs = get_option_ini(config, "log_print") + if not self.print_logs: + from _pytest.warnings import _issue_warning_captured + from _pytest.deprecated import NO_PRINT_LOGS + + _issue_warning_captured(NO_PRINT_LOGS, self._config.hook, stacklevel=2) + self.formatter = self._create_formatter( get_option_ini(config, "log_format"), get_option_ini(config, "log_date_format"), diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 59cb69a00..c7ca80cbd 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -90,3 +90,44 @@ def test_node_direct_ctor_warning(): nodes.Node(name="test", config=ms, session=ms, nodeid="None") assert w[0].lineno == inspect.currentframe().f_lineno - 1 assert w[0].filename == __file__ + + +def assert_no_print_logs(testdir, args): + result = testdir.runpytest(*args) + result.stdout.fnmatch_lines( + [ + "*--no-print-logs is deprecated and scheduled for removal in pytest 6.0*", + "*Please use --show-capture instead.*", + ] + ) + + +@pytest.mark.filterwarnings("default") +def test_noprintlogs_is_deprecated_cmdline(testdir): + testdir.makepyfile( + """ + def test_foo(): + pass + """ + ) + + assert_no_print_logs(testdir, ("--no-print-logs",)) + + +@pytest.mark.filterwarnings("default") +def test_noprintlogs_is_deprecated_ini(testdir): + testdir.makeini( + """ + [pytest] + log_print=False + """ + ) + + testdir.makepyfile( + """ + def test_foo(): + pass + """ + ) + + assert_no_print_logs(testdir, ()) From ed57b8e08a34c0c80e7e1abc0c28400feb2f7404 Mon Sep 17 00:00:00 2001 From: captainCapitalism <32553875+captainCapitalism@users.noreply.github.com> Date: Thu, 19 Dec 2019 11:35:52 +0100 Subject: [PATCH 035/158] invocation in last section 'pythonpath.rst' title swapped The order of invocations 'python -m pytest' and 'pytest' are different in the header and the explanation. Me being lazy reading about the behaviour of 'former' looked up quickly the title and rushed to implementation to discover it actually works the other way - as stated in the documentation. So I propose to switch the order in the title to achieve consistent ordering and not confusing somebody like me again! :) --- doc/en/pythonpath.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/pythonpath.rst b/doc/en/pythonpath.rst index 0054acc59..65f7b51c8 100644 --- a/doc/en/pythonpath.rst +++ b/doc/en/pythonpath.rst @@ -74,7 +74,7 @@ This is also discussed in details in :ref:`test discovery`. .. _`pytest vs python -m pytest`: -Invoking ``pytest`` versus ``python -m pytest`` +Invoking ``python -m pytest`` versus ``pytest`` ----------------------------------------------- Running pytest with ``python -m pytest [...]`` instead of ``pytest [...]`` yields nearly From a9608d54e0c8db747abe02644415d95fbd75e441 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 26 Dec 2019 08:19:11 -0300 Subject: [PATCH 036/158] Switch the order of the commands back and update the text As suggested during review --- doc/en/pythonpath.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/en/pythonpath.rst b/doc/en/pythonpath.rst index 65f7b51c8..f2c86fab9 100644 --- a/doc/en/pythonpath.rst +++ b/doc/en/pythonpath.rst @@ -74,9 +74,11 @@ This is also discussed in details in :ref:`test discovery`. .. _`pytest vs python -m pytest`: -Invoking ``python -m pytest`` versus ``pytest`` +Invoking ``pytest`` versus ``python -m pytest`` ----------------------------------------------- -Running pytest with ``python -m pytest [...]`` instead of ``pytest [...]`` yields nearly -equivalent behaviour, except that the former call will add the current directory to ``sys.path``. +Running pytest with ``pytest [...]`` instead of ``python -m pytest [...]`` yields nearly +equivalent behaviour, except that the latter will add the current directory to ``sys.path``, which +is standard ``python`` behavior. + See also :ref:`cmdline`. From a5224f74904bd39a7c0e25ee75936734a6cbfe15 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 30 Dec 2019 15:28:37 +0100 Subject: [PATCH 037/158] Revert black formatting of essential_plugins Done in a02310a140 (likely automatic), but loses information of the comment obviously. --- src/_pytest/config/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index eba0906b5..fb965b261 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -133,7 +133,13 @@ def directory_arg(path, optname): # Plugins that cannot be disabled via "-p no:X" currently. -essential_plugins = ("mark", "main", "runner", "fixtures", "helpconfig") # Provides -p. +essential_plugins = ( + "mark", + "main", + "runner", + "fixtures", + "helpconfig", # Provides -p. +) default_plugins = essential_plugins + ( "python", From 4848bbdf9a4480ec85b520c6f3224256f1346679 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 1 Jan 2020 14:49:59 +0200 Subject: [PATCH 038/158] Update mypy 0.750 -> 0.761 This fixes some type: ignores due to typeshed update. Newer mypy seem to ignore unannotated functions better, so add a few minor annotations so that existing correct type:ignores make sense. --- .pre-commit-config.yaml | 2 +- src/_pytest/_code/source.py | 4 +--- src/_pytest/assertion/__init__.py | 2 +- src/_pytest/doctest.py | 4 +++- src/_pytest/pytester.py | 2 +- src/_pytest/recwarn.py | 4 +--- src/_pytest/reports.py | 2 +- src/_pytest/runner.py | 2 +- testing/code/test_source.py | 8 ++------ 9 files changed, 12 insertions(+), 18 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64f3f32ac..b2368cf8f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: - id: pyupgrade args: [--py3-plus] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.750 + rev: v0.761 hooks: - id: mypy files: ^(src/|testing/) diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index ac3ee231e..d7cef683d 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -339,9 +339,7 @@ def getstatementrange_ast( block_finder.started = source.lines[start][0].isspace() it = ((x + "\n") for x in source.lines[start:end]) try: - # Type ignored until next mypy release. - # https://github.com/python/typeshed/commit/c0d46a20353b733befb85d8b9cc24e5b0bcd8f9a - for tok in tokenize.generate_tokens(lambda: next(it)): # type: ignore + for tok in tokenize.generate_tokens(lambda: next(it)): block_finder.tokeneater(*tok) except (inspect.EndOfBlock, IndentationError): end = block_finder.last + start diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 34d6701ed..f96afce6d 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -33,7 +33,7 @@ def pytest_addoption(parser): ) -def register_assert_rewrite(*names): +def register_assert_rewrite(*names) -> None: """Register one or more module names to be rewritten on import. This function will make sure that this module or all modules inside diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 75eac7db6..3475ac9c2 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -451,7 +451,9 @@ class DoctestModule(pytest.Module): obj = getattr(obj, "fget", obj) return doctest.DocTestFinder._find_lineno(self, obj, source_lines) - def _find(self, tests, obj, name, module, source_lines, globs, seen): + def _find( + self, tests, obj, name, module, source_lines, globs, seen + ) -> None: if _is_mocked(obj): return with _patch_unwrap_mock_aware(): diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index d5744167c..605dd5eb7 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -829,7 +829,7 @@ class Testdir: items = [x.item for x in rec.getcalls("pytest_itemcollected")] return items, rec - def inline_run(self, *args, plugins=(), no_reraise_ctrlc=False): + def inline_run(self, *args, plugins=(), no_reraise_ctrlc: bool = False): """Run ``pytest.main()`` in-process, returning a HookRecorder. Runs the :py:func:`pytest.main` function to run all of pytest inside diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index e3d7b72ec..5cf32c894 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -129,9 +129,7 @@ def warns( # noqa: F811 return func(*args[1:], **kwargs) -# Type ignored until next mypy release. Regression fixed by: -# https://github.com/python/typeshed/commit/41bf6a19822d6694973449d795f8bfe1d50d5a03 -class WarningsRecorder(warnings.catch_warnings): # type: ignore +class WarningsRecorder(warnings.catch_warnings): """A context manager to record raised warnings. Adapted from `warnings.catch_warnings`. diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 5d445c2f8..95236450d 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -259,7 +259,7 @@ class TestReport(BaseReport): ) @classmethod - def from_item_and_call(cls, item, call): + def from_item_and_call(cls, item, call) -> "TestReport": """ Factory method to create and fill a TestReport with standard item and call info. """ diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 67e28e905..50e4d4307 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -250,7 +250,7 @@ def pytest_runtest_makereport(item, call): return TestReport.from_item_and_call(item, call) -def pytest_make_collect_report(collector): +def pytest_make_collect_report(collector) -> CollectReport: call = CallInfo.from_call(lambda: list(collector.collect()), "collect") longrepr = None if not call.excinfo: diff --git a/testing/code/test_source.py b/testing/code/test_source.py index bf52dccd7..1390d8b0a 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -318,16 +318,12 @@ class TestSourceParsingAndCompiling: @pytest.mark.parametrize("name", ["", None, "my"]) def test_compilefuncs_and_path_sanity(self, name: Optional[str]) -> None: - def check(comp, name): + def check(comp, name) -> None: co = comp(self.source, name) if not name: expected = "codegen %s:%d>" % (mypath, mylineno + 2 + 2) # type: ignore else: - expected = "codegen %r %s:%d>" % ( - name, - mypath, # type: ignore - mylineno + 2 + 2, # type: ignore - ) # type: ignore + expected = "codegen %r %s:%d>" % (name, mypath, mylineno + 2 + 2) # type: ignore fn = co.co_filename assert fn.endswith(expected) From c627ac4e599464e9ce2d1b33d1139b9dcf89468a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 5 Jan 2020 12:33:12 -0300 Subject: [PATCH 039/158] Remove unused _pytest.code.Source.isparseable function Besides unused, it uses the (deprecated in Python 3.9) parser module Fix #6404 --- changelog/6404.trivial.rst | 1 + src/_pytest/_code/source.py | 20 -------------------- testing/code/test_source.py | 10 ---------- 3 files changed, 1 insertion(+), 30 deletions(-) create mode 100644 changelog/6404.trivial.rst diff --git a/changelog/6404.trivial.rst b/changelog/6404.trivial.rst new file mode 100644 index 000000000..5a60d01a4 --- /dev/null +++ b/changelog/6404.trivial.rst @@ -0,0 +1 @@ +Removed unused ``_pytest.code.Source.isparseable`` function. diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index d7cef683d..341462792 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -136,26 +136,6 @@ class Source: newsource.lines[:] = deindent(self.lines) return newsource - def isparseable(self, deindent: bool = True) -> bool: - """ return True if source is parseable, heuristically - deindenting it by default. - """ - from parser import suite as syntax_checker - - if deindent: - source = str(self.deindent()) - else: - source = str(self) - try: - # compile(source+'\n', "x", "exec") - syntax_checker(source + "\n") - except KeyboardInterrupt: - raise - except Exception: - return False - else: - return True - def __str__(self) -> str: return "\n".join(self.lines) diff --git a/testing/code/test_source.py b/testing/code/test_source.py index 1390d8b0a..511626fa0 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -121,15 +121,6 @@ def test_syntaxerror_rerepresentation() -> None: assert ex.value.text == "xyz xyz\n" -def test_isparseable() -> None: - assert Source("hello").isparseable() - assert Source("if 1:\n pass").isparseable() - assert Source(" \nif 1:\n pass").isparseable() - assert not Source("if 1:\n").isparseable() - assert not Source(" \nif 1:\npass").isparseable() - assert not Source(chr(0)).isparseable() - - class TestAccesses: def setup_class(self) -> None: self.source = Source( @@ -143,7 +134,6 @@ class TestAccesses: def test_getrange(self) -> None: x = self.source[0:2] - assert x.isparseable() assert len(x.lines) == 2 assert str(x) == "def f(x):\n pass" From 12f74a28fada7badd7d7830611ba14b8a40e1dd1 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 5 Jan 2020 14:12:40 -0300 Subject: [PATCH 040/158] Revert "Remove unused _pytest.code.Source.isparseable function" This reverts commit c627ac4e599464e9ce2d1b33d1139b9dcf89468a. --- changelog/6404.trivial.rst | 1 - src/_pytest/_code/source.py | 20 ++++++++++++++++++++ testing/code/test_source.py | 10 ++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) delete mode 100644 changelog/6404.trivial.rst diff --git a/changelog/6404.trivial.rst b/changelog/6404.trivial.rst deleted file mode 100644 index 5a60d01a4..000000000 --- a/changelog/6404.trivial.rst +++ /dev/null @@ -1 +0,0 @@ -Removed unused ``_pytest.code.Source.isparseable`` function. diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index 341462792..d7cef683d 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -136,6 +136,26 @@ class Source: newsource.lines[:] = deindent(self.lines) return newsource + def isparseable(self, deindent: bool = True) -> bool: + """ return True if source is parseable, heuristically + deindenting it by default. + """ + from parser import suite as syntax_checker + + if deindent: + source = str(self.deindent()) + else: + source = str(self) + try: + # compile(source+'\n', "x", "exec") + syntax_checker(source + "\n") + except KeyboardInterrupt: + raise + except Exception: + return False + else: + return True + def __str__(self) -> str: return "\n".join(self.lines) diff --git a/testing/code/test_source.py b/testing/code/test_source.py index 511626fa0..1390d8b0a 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -121,6 +121,15 @@ def test_syntaxerror_rerepresentation() -> None: assert ex.value.text == "xyz xyz\n" +def test_isparseable() -> None: + assert Source("hello").isparseable() + assert Source("if 1:\n pass").isparseable() + assert Source(" \nif 1:\n pass").isparseable() + assert not Source("if 1:\n").isparseable() + assert not Source(" \nif 1:\npass").isparseable() + assert not Source(chr(0)).isparseable() + + class TestAccesses: def setup_class(self) -> None: self.source = Source( @@ -134,6 +143,7 @@ class TestAccesses: def test_getrange(self) -> None: x = self.source[0:2] + assert x.isparseable() assert len(x.lines) == 2 assert str(x) == "def f(x):\n pass" From 91a96ec3d68f81372dc8279b8d8e4d8a02679709 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 5 Jan 2020 14:15:51 -0300 Subject: [PATCH 041/158] Remove usage of parser module, deprecated in Python 3.9 Fix #6404 --- changelog/6404.trivial.rst | 1 + src/_pytest/_code/source.py | 9 ++------- 2 files changed, 3 insertions(+), 7 deletions(-) create mode 100644 changelog/6404.trivial.rst diff --git a/changelog/6404.trivial.rst b/changelog/6404.trivial.rst new file mode 100644 index 000000000..8252098b6 --- /dev/null +++ b/changelog/6404.trivial.rst @@ -0,0 +1 @@ +Remove usage of ``parser`` module, deprecated in Python 3.9. diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index d7cef683d..2b9c2a6ea 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -140,18 +140,13 @@ class Source: """ return True if source is parseable, heuristically deindenting it by default. """ - from parser import suite as syntax_checker - if deindent: source = str(self.deindent()) else: source = str(self) try: - # compile(source+'\n', "x", "exec") - syntax_checker(source + "\n") - except KeyboardInterrupt: - raise - except Exception: + ast.parse(source) + except (SyntaxError, ValueError, TypeError): return False else: return True From 2d2c67d7c0a8b85eb89c615f03fedcd7ed5b8d02 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 6 Jan 2020 19:37:01 +0100 Subject: [PATCH 042/158] cacheprovider: pytest_collection_modifyitems: copy items --- src/_pytest/cacheprovider.py | 2 +- testing/test_cacheprovider.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 802c52122..b28b3a1b7 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -258,7 +258,7 @@ class LFPlugin: self._report_status = "no previously failed tests, " if self.config.getoption("last_failed_no_failures") == "none": self._report_status += "deselecting all items." - config.hook.pytest_deselected(items=items) + config.hook.pytest_deselected(items=items[:]) items[:] = [] else: self._report_status += "not deselecting items." diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index f0b279abf..6a2e204ac 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -681,11 +681,28 @@ class TestLastFailed: result.stdout.fnmatch_lines(["*2 passed*"]) result = testdir.runpytest("--lf", "--lfnf", "all") result.stdout.fnmatch_lines(["*2 passed*"]) + + # Ensure the list passed to pytest_deselected is a copy, + # and not a reference which is cleared right after. + testdir.makeconftest( + """ + deselected = [] + + def pytest_deselected(items): + global deselected + deselected = items + + def pytest_sessionfinish(): + print("\\ndeselected={}".format(len(deselected))) + """ + ) + result = testdir.runpytest("--lf", "--lfnf", "none") result.stdout.fnmatch_lines( [ "collected 2 items / 2 deselected", "run-last-failure: no previously failed tests, deselecting all items.", + "deselected=2", "* 2 deselected in *", ] ) From 13baab746d98fd99424cb9589f8ebd2dde3f5ca3 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 9 Jan 2020 22:19:29 +0100 Subject: [PATCH 043/158] terminal: use "yellow" with any "xpassed" tests Closes https://github.com/pytest-dev/pytest/issues/449. --- changelog/449.improvement.rst | 1 + src/_pytest/terminal.py | 2 +- testing/test_terminal.py | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 changelog/449.improvement.rst diff --git a/changelog/449.improvement.rst b/changelog/449.improvement.rst new file mode 100644 index 000000000..12ff81bb5 --- /dev/null +++ b/changelog/449.improvement.rst @@ -0,0 +1 @@ +Use "yellow" main color with any XPASSED tests. diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 2a99bfdd5..a8ab5c130 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -1107,7 +1107,7 @@ def _get_main_color(stats) -> Tuple[str, List[str]]: # main color if "failed" in stats or "error" in stats: main_color = "red" - elif "warnings" in stats or unknown_type_seen: + elif "warnings" in stats or "xpassed" in stats or unknown_type_seen: main_color = "yellow" elif "passed" in stats: main_color = "green" diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 0fe0e09e1..c109d2c78 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1385,10 +1385,10 @@ def test_terminal_summary_warnings_header_once(testdir): ), ("yellow", [("1 xpassed", {"bold": True, "yellow": True})], {"xpassed": (1,)}), ( - "green", + "yellow", [ - ("1 passed", {"bold": True, "green": True}), - ("1 xpassed", {"bold": False, "yellow": True}), + ("1 passed", {"bold": False, "green": True}), + ("1 xpassed", {"bold": True, "yellow": True}), ], {"xpassed": (1,), "passed": (1,)}, ), From fa645a7003c3338abe37eed51f6495b4cc376e53 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 9 Jan 2020 18:20:46 -0300 Subject: [PATCH 044/158] Improve docstrings for mktemp --- src/_pytest/tmpdir.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index b87e37167..85c5b8381 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -54,7 +54,20 @@ class TempPathFactory: return basename def mktemp(self, basename: str, numbered: bool = True) -> Path: - """makes a temporary directory managed by the factory""" + """Creates a new temporary directory managed by the factory. + + :param basename: + Directory base name, must be a relative path. + + :param numbered: + If True, ensure the directory is unique by adding a number + prefix greater than any existing one: ``basename="foo"`` and ``numbered=True`` + means that this function will create directories named ``"foo-0"``, + ``"foo-1"``, ``"foo-2"`` and so on. + + :return: + The path to the new directory. + """ basename = self._ensure_relative_to_basetemp(basename) if not numbered: p = self.getbasetemp().joinpath(basename) @@ -99,10 +112,9 @@ class TempdirFactory: _tmppath_factory = attr.ib(type=TempPathFactory) - def mktemp(self, basename: str, numbered: bool = True): - """Create a subdirectory of the base temporary directory and return it. - If ``numbered``, ensure the directory is unique by adding a number - prefix greater than any existing one. + def mktemp(self, basename: str, numbered: bool = True) -> py.path.local: + """ + Same as :meth:`TempPathFactory.mkdir`, but returns a ``py.path.local`` object. """ return py.path.local(self._tmppath_factory.mktemp(basename, numbered).resolve()) From 21d189eb52f5042de2668f9eee48696205a87442 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 14 Jan 2020 09:18:34 -0300 Subject: [PATCH 045/158] Enable GitHub actions for 'features' --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b1bae1c18..d6e5ddd1d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,9 +10,11 @@ on: push: branches: - master + - features pull_request: branches: - master + - features jobs: build: From fd1691a2b32cbcbc4341bf808877dc51936a74f5 Mon Sep 17 00:00:00 2001 From: Pauli Virtanen Date: Mon, 30 Dec 2019 19:54:42 +0200 Subject: [PATCH 046/158] Make --showlocals work together with --tb=short Enable showing local variables when asked to do so in the short traceback mode. Fixes #494 --- AUTHORS | 1 + changelog/6384.improvement.rst | 1 + src/_pytest/_code/code.py | 10 +++++----- testing/test_terminal.py | 20 ++++++++++++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 changelog/6384.improvement.rst diff --git a/AUTHORS b/AUTHORS index 3289b9913..bd8ee5453 100644 --- a/AUTHORS +++ b/AUTHORS @@ -208,6 +208,7 @@ Omer Hadari Ondřej Súkup Oscar Benjamin Patrick Hayes +Pauli Virtanen Paweł Adamczak Pedro Algarvio Philipp Loose diff --git a/changelog/6384.improvement.rst b/changelog/6384.improvement.rst new file mode 100644 index 000000000..75d1e605d --- /dev/null +++ b/changelog/6384.improvement.rst @@ -0,0 +1 @@ +Make `--showlocals` work also with `--tb=short`. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index d1a8ec2f1..9c8702d17 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -785,9 +785,7 @@ class FormattedExcinfo: message = excinfo and excinfo.typename or "" path = self._makepath(entry.path) filelocrepr = ReprFileLocation(path, entry.lineno + 1, message) - localsrepr = None - if not short: - localsrepr = self.repr_locals(entry.locals) + localsrepr = self.repr_locals(entry.locals) return ReprEntry(lines, reprargs, localsrepr, filelocrepr, style) if excinfo: lines.extend(self.get_exconly(excinfo, indent=4)) @@ -1044,6 +1042,8 @@ class ReprEntry(TerminalRepr): for line in self.lines: red = line.startswith("E ") tw.line(line, bold=True, red=red) + if self.reprlocals: + self.reprlocals.toterminal(tw, indent=" " * 8) return if self.reprfuncargs: self.reprfuncargs.toterminal(tw) @@ -1085,9 +1085,9 @@ class ReprLocals(TerminalRepr): def __init__(self, lines: Sequence[str]) -> None: self.lines = lines - def toterminal(self, tw) -> None: + def toterminal(self, tw, indent="") -> None: for line in self.lines: - tw.line(line) + tw.line(indent + line) class ReprFuncArgs(TerminalRepr): diff --git a/testing/test_terminal.py b/testing/test_terminal.py index c109d2c78..3b149d82a 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -670,6 +670,26 @@ class TestTerminalFunctional: ] ) + def test_showlocals_short(self, testdir): + p1 = testdir.makepyfile( + """ + def test_showlocals_short(): + x = 3 + y = "xxxx" + assert 0 + """ + ) + result = testdir.runpytest(p1, "-l", "--tb=short") + result.stdout.fnmatch_lines( + [ + "test_showlocals_short.py:*", + " assert 0", + "E assert 0", + " x = 3", + " y = 'xxxx'", + ] + ) + @pytest.fixture def verbose_testfile(self, testdir): return testdir.makepyfile( From b9c136b809a33009ad514672666c515953957b33 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 14 Jan 2020 10:51:44 -0300 Subject: [PATCH 047/158] Use a dummy RemoteTraceback for test in Python 3.5 Windows Somehow in Python 3.5 on Windows this test fails with: File "c:\hostedtoolcache\windows\python\3.5.4\x64\Lib\multiprocessing\connection.py", line 302, in _recv_bytes overlapped=True) OSError: [WinError 6] The handle is invalid This only happens in this platform and Python version, decided to use a dummy traceback as originally done in #6412. --- testing/test_reports.py | 51 +++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/testing/test_reports.py b/testing/test_reports.py index d0bafec23..8c509ec47 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -1,3 +1,5 @@ +import sys + import pytest from _pytest._code.code import ExceptionChainRepr from _pytest.pathlib import Path @@ -314,27 +316,52 @@ class TestReportSerialization: # elsewhere and we do check the contents of the longrepr object after loading it. loaded_report.longrepr.toterminal(tw_mock) - def test_chained_exceptions_no_reprcrash( - self, testdir, tw_mock, - ): + def test_chained_exceptions_no_reprcrash(self, testdir, tw_mock): """Regression test for tracebacks without a reprcrash (#5971) This happens notably on exceptions raised by multiprocess.pool: the exception transfer from subprocess to main process creates an artificial exception, which ExceptionInfo can't obtain the ReprFileLocation from. """ - testdir.makepyfile( + # somehow in Python 3.5 on Windows this test fails with: + # File "c:\...\3.5.4\x64\Lib\multiprocessing\connection.py", line 302, in _recv_bytes + # overlapped=True) + # OSError: [WinError 6] The handle is invalid + # + # so in this platform we opted to use a mock traceback which is identical to the + # one produced by the multiprocessing module + if sys.version_info[:2] <= (3, 5) and sys.platform.startswith("win"): + testdir.makepyfile( + """ + # equivalent of multiprocessing.pool.RemoteTraceback + class RemoteTraceback(Exception): + def __init__(self, tb): + self.tb = tb + def __str__(self): + return self.tb + def test_a(): + try: + raise ValueError('value error') + except ValueError as e: + # equivalent to how multiprocessing.pool.rebuild_exc does it + e.__cause__ = RemoteTraceback('runtime error') + raise e """ - from concurrent.futures import ProcessPoolExecutor + ) + else: + testdir.makepyfile( + """ + from concurrent.futures import ProcessPoolExecutor - def func(): - raise ValueError('value error') + def func(): + raise ValueError('value error') + + def test_a(): + with ProcessPoolExecutor() as p: + p.submit(func).result() + """ + ) - def test_a(): - with ProcessPoolExecutor() as p: - p.submit(func).result() - """ - ) reprec = testdir.inline_run() reports = reprec.getreports("pytest_runtest_logreport") From 8ba0b7bc2ad2ead90a827ffeec5bfed1e264431c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 31 Dec 2019 20:20:28 +0100 Subject: [PATCH 048/158] fix #6341 - disallow session/config in Node.from_parent --- changelog/5975.deprecation.rst | 4 ++++ src/_pytest/doctest.py | 10 ++++++++-- src/_pytest/nodes.py | 23 ++++++++++++++++++++--- src/_pytest/python.py | 12 +++++++++--- testing/python/collect.py | 2 +- testing/test_nodes.py | 7 +++++++ 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/changelog/5975.deprecation.rst b/changelog/5975.deprecation.rst index 6e5dbc2ac..257249efe 100644 --- a/changelog/5975.deprecation.rst +++ b/changelog/5975.deprecation.rst @@ -4,3 +4,7 @@ Instead they are new constructed via ``Node.from_parent``. This transitional mechanism enables us to detangle the very intensely entangled ``Node`` relationships by enforcing more controlled creation/configruation patterns. + +As part of that session/config are already disallowed parameters and as we work on the details we might need disallow a few more as well. + +Subclasses are expected to use `super().from_parent` if they intend to expand the creation of `Nodes`. diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 75eac7db6..aa318dfbf 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -216,8 +216,14 @@ class DoctestItem(pytest.Item): self.fixture_request = None @classmethod - def from_parent(cls, parent, *, name, runner, dtest): - return cls._create(name=name, parent=parent, runner=runner, dtest=dtest) + def from_parent( # type: ignore + cls, parent: "Union[DoctestTextfile, DoctestModule]", *, name, runner, dtest + ): + # incompatible signature due to to imposed limits on sublcass + """ + the public named constructor + """ + return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) def setup(self): if self.dtest is not None: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 3eaafa91d..385d8d6e4 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -144,8 +144,22 @@ class Node(metaclass=NodeMeta): self._nodeid += "::" + self.name @classmethod - def from_parent(cls, parent, *, name): - return cls._create(parent=parent, name=name) + def from_parent(cls, parent: "Node", **kw): + """ + Public Constructor for Nodes + + This indirection got introduced in order to enable removing + the fragile logic from the node constructors. + + Subclasses can use ``super().from_parent(...)`` when overriding the construction + + :param parent: the parent node of this test Node + """ + if "config" in kw: + raise TypeError("config is not a valid argument for from_parent") + if "session" in kw: + raise TypeError("session is not a valid argument for from_parent") + return cls._create(parent=parent, **kw) @property def ihook(self): @@ -434,7 +448,10 @@ class FSCollector(Collector): @classmethod def from_parent(cls, parent, *, fspath): - return cls._create(parent=parent, fspath=fspath) + """ + The public constructor + """ + return super().from_parent(parent=parent, fspath=fspath) class File(FSCollector): diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 1b01f4faa..a51be68c8 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -679,7 +679,10 @@ class Class(PyCollector): @classmethod def from_parent(cls, parent, *, name, obj=None): - return cls._create(name=name, parent=parent) + """ + The public constructor + """ + return super().from_parent(name=name, parent=parent) def collect(self): if not safe_getattr(self.obj, "__test__", True): @@ -1458,8 +1461,11 @@ class Function(FunctionMixin, nodes.Item): self.originalname = originalname @classmethod - def from_parent(cls, parent, **kw): - return cls._create(parent=parent, **kw) + def from_parent(cls, parent, **kw): # todo: determine sound type limitations + """ + The public constructor + """ + return super().from_parent(parent=parent, **kw) def _initrequest(self): self.funcargs = {} diff --git a/testing/python/collect.py b/testing/python/collect.py index 9ac1c9d31..93e3de424 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -284,7 +284,7 @@ class TestFunction: session = testdir.Session.from_config(config) session._fixturemanager = FixtureManager(session) - return pytest.Function.from_parent(config=config, parent=session, **kwargs) + return pytest.Function.from_parent(parent=session, **kwargs) def test_function_equality(self, testdir, tmpdir): def func1(): diff --git a/testing/test_nodes.py b/testing/test_nodes.py index b13ce1fe6..dbb3e2e8f 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -22,6 +22,13 @@ def test_ischildnode(baseid, nodeid, expected): assert result is expected +def test_node_from_parent_disallowed_arguments(): + with pytest.raises(TypeError, match="session is"): + nodes.Node.from_parent(None, session=None) + with pytest.raises(TypeError, match="config is"): + nodes.Node.from_parent(None, config=None) + + def test_std_warn_not_pytestwarning(testdir): items = testdir.getitems( """ From ab6406b42e6ce7fc2d72ace17c5f88679773dfc4 Mon Sep 17 00:00:00 2001 From: Jakub Mitoraj Date: Thu, 16 Jan 2020 08:14:46 +0100 Subject: [PATCH 049/158] Update junit_logging with no,log,system-out,system-err,out-err,all --- AUTHORS | 1 + changelog/6469.feature.rst | 1 + doc/en/reference.rst | 2 +- src/_pytest/junitxml.py | 67 +++++--------- testing/acceptance_test.py | 11 ++- testing/test_junitxml.py | 185 ++++++++++++++++++++++++++----------- 6 files changed, 161 insertions(+), 106 deletions(-) create mode 100644 changelog/6469.feature.rst diff --git a/AUTHORS b/AUTHORS index 3289b9913..389eb41d8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -122,6 +122,7 @@ Ilya Konstantinov Ionuț Turturică Iwan Briquemont Jaap Broekhuizen +Jakub Mitoraj Jan Balster Janne Vanhala Jason R. Coombs diff --git a/changelog/6469.feature.rst b/changelog/6469.feature.rst new file mode 100644 index 000000000..4b05fdb33 --- /dev/null +++ b/changelog/6469.feature.rst @@ -0,0 +1 @@ +User can set ``junit_logging`` to one of ``no|log|system-out|system-err|out-err|all`` in order to put selected output in xml file. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 50e32d660..a72e59b17 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1166,7 +1166,7 @@ passed multiple times. The expected format is ``name=value``. For example:: .. versionadded:: 3.5 Configures if stdout/stderr should be written to the JUnit XML file. Valid values are - ``system-out``, ``system-err``, and ``no`` (the default). + ``log``, ``out-err``, ``all``, ``system-out``, ``system-err``, and ``no`` (the default). .. code-block:: ini diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 206e44d96..6ef535839 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -167,51 +167,28 @@ class _NodeReporter: content_out = report.capstdout content_log = report.caplog content_err = report.capstderr + if self.xml.logging == "no": + return + content_all = "" + if self.xml.logging in ["log", "all"]: + content_all = self._prepare_content(content_log, " Captured Log ") + if self.xml.logging in ["system-out", "out-err", "all"]: + content_all += self._prepare_content(content_out, " Captured Out ") + self._write_content(report, content_all, "system-out") + content_all = "" + if self.xml.logging in ["system-err", "out-err", "all"]: + content_all += self._prepare_content(content_err, " Captured Err ") + self._write_content(report, content_all, "system-err") + content_all = "" + if content_all: + self._write_content(report, content_all, "system-out") - if content_log or content_out: - if content_log and self.xml.logging == "system-out": - if content_out: - # syncing stdout and the log-output is not done yet. It's - # probably not worth the effort. Therefore, first the captured - # stdout is shown and then the captured logs. - content = "\n".join( - [ - " Captured Stdout ".center(80, "-"), - content_out, - "", - " Captured Log ".center(80, "-"), - content_log, - ] - ) - else: - content = content_log - else: - content = content_out + def _prepare_content(self, content, header): + return "\n".join([header.center(80, "-"), content, ""]) - if content: - tag = getattr(Junit, "system-out") - self.append(tag(bin_xml_escape(content))) - - if content_log or content_err: - if content_log and self.xml.logging == "system-err": - if content_err: - content = "\n".join( - [ - " Captured Stderr ".center(80, "-"), - content_err, - "", - " Captured Log ".center(80, "-"), - content_log, - ] - ) - else: - content = content_log - else: - content = content_err - - if content: - tag = getattr(Junit, "system-err") - self.append(tag(bin_xml_escape(content))) + def _write_content(self, report, content, jheader): + tag = getattr(Junit, jheader) + self.append(tag(bin_xml_escape(content))) def append_pass(self, report): self.add_stats("passed") @@ -408,9 +385,9 @@ def pytest_addoption(parser): parser.addini( "junit_logging", "Write captured log messages to JUnit report: " - "one of no|system-out|system-err", + "one of no|log|system-out|system-err|out-err|all", default="no", - ) # choices=['no', 'stdout', 'stderr']) + ) parser.addini( "junit_log_passing_tests", "Capture log information for passing tests to JUnit report: ", diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index ffb6836e3..b7dbcd7c0 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1297,7 +1297,11 @@ def test_tee_stdio_captures_and_live_prints(testdir): """ ) result = testdir.runpytest_subprocess( - testpath, "--capture=tee-sys", "--junitxml=output.xml" + testpath, + "--capture=tee-sys", + "--junitxml=output.xml", + "-o", + "junit_logging=all", ) # ensure stdout/stderr were 'live printed' @@ -1307,6 +1311,5 @@ def test_tee_stdio_captures_and_live_prints(testdir): # now ensure the output is in the junitxml with open(os.path.join(testdir.tmpdir.strpath, "output.xml"), "r") as f: fullXml = f.read() - - assert "@this is stdout@\n" in fullXml - assert "@this is stderr@\n" in fullXml + assert "@this is stdout@\n" in fullXml + assert "@this is stderr@\n" in fullXml diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 0132db59d..62bd5cbe2 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -445,7 +445,9 @@ class TestPython: fnode.assert_attr(message="internal error") assert "Division" in fnode.toxml() - @pytest.mark.parametrize("junit_logging", ["no", "system-out", "system-err"]) + @pytest.mark.parametrize( + "junit_logging", ["no", "log", "system-out", "system-err", "out-err", "all"] + ) @parametrize_families def test_failure_function( self, testdir, junit_logging, run_and_parse, xunit_family @@ -467,35 +469,48 @@ class TestPython: result, dom = run_and_parse( "-o", "junit_logging=%s" % junit_logging, family=xunit_family ) - assert result.ret + assert result.ret, "Expected ret > 0" node = dom.find_first_by_tag("testsuite") node.assert_attr(failures=1, tests=1) tnode = node.find_first_by_tag("testcase") tnode.assert_attr(classname="test_failure_function", name="test_fail") fnode = tnode.find_first_by_tag("failure") fnode.assert_attr(message="ValueError: 42") - assert "ValueError" in fnode.toxml() - systemout = fnode.next_sibling - assert systemout.tag == "system-out" - systemout_xml = systemout.toxml() - assert "hello-stdout" in systemout_xml - assert "info msg" not in systemout_xml - systemerr = systemout.next_sibling - assert systemerr.tag == "system-err" - systemerr_xml = systemerr.toxml() - assert "hello-stderr" in systemerr_xml - assert "info msg" not in systemerr_xml + assert "ValueError" in fnode.toxml(), "ValueError not included" - if junit_logging == "system-out": - assert "warning msg" in systemout_xml - assert "warning msg" not in systemerr_xml - elif junit_logging == "system-err": - assert "warning msg" not in systemout_xml - assert "warning msg" in systemerr_xml - else: - assert junit_logging == "no" - assert "warning msg" not in systemout_xml - assert "warning msg" not in systemerr_xml + if junit_logging in ["log", "all"]: + logdata = tnode.find_first_by_tag("system-out") + log_xml = logdata.toxml() + assert logdata.tag == "system-out", "Expected tag: system-out" + assert "info msg" not in log_xml, "Unexpected INFO message" + assert "warning msg" in log_xml, "Missing WARN message" + if junit_logging in ["system-out", "out-err", "all"]: + systemout = tnode.find_first_by_tag("system-out") + systemout_xml = systemout.toxml() + assert systemout.tag == "system-out", "Expected tag: system-out" + assert "info msg" not in systemout_xml, "INFO message found in system-out" + assert ( + "hello-stdout" in systemout_xml + ), "Missing 'hello-stdout' in system-out" + if junit_logging in ["system-err", "out-err", "all"]: + systemerr = tnode.find_first_by_tag("system-err") + systemerr_xml = systemerr.toxml() + assert systemerr.tag == "system-err", "Expected tag: system-err" + assert "info msg" not in systemerr_xml, "INFO message found in system-err" + assert ( + "hello-stderr" in systemerr_xml + ), "Missing 'hello-stderr' in system-err" + assert ( + "warning msg" not in systemerr_xml + ), "WARN message found in system-err" + if junit_logging == "no": + assert not tnode.find_by_tag("log"), "Found unexpected content: log" + assert not tnode.find_by_tag( + "system-out" + ), "Found unexpected content: system-out" + assert not tnode.find_by_tag( + "system-err" + ), "Found unexpected content: system-err" @parametrize_families def test_failure_verbose_message(self, testdir, run_and_parse, xunit_family): @@ -523,7 +538,9 @@ class TestPython: assert 0 """ ) - result, dom = run_and_parse(family=xunit_family) + result, dom = run_and_parse( + "-o", "junit_logging=system-out", family=xunit_family + ) assert result.ret node = dom.find_first_by_tag("testsuite") node.assert_attr(failures=3, tests=3) @@ -536,7 +553,7 @@ class TestPython: ) sysout = tnode.find_first_by_tag("system-out") text = sysout.text - assert text == "%s\n" % char + assert "%s\n" % char in text @parametrize_families def test_junit_prefixing(self, testdir, run_and_parse, xunit_family): @@ -597,7 +614,10 @@ class TestPython: fnode = tnode.find_first_by_tag("skipped") fnode.assert_attr(type="pytest.xfail", message="42") - def test_xfail_captures_output_once(self, testdir, run_and_parse): + @pytest.mark.parametrize( + "junit_logging", ["no", "log", "system-out", "system-err", "out-err", "all"] + ) + def test_xfail_captures_output_once(self, testdir, junit_logging, run_and_parse): testdir.makepyfile( """ import sys @@ -610,11 +630,18 @@ class TestPython: assert 0 """ ) - result, dom = run_and_parse() + result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) node = dom.find_first_by_tag("testsuite") tnode = node.find_first_by_tag("testcase") - assert len(tnode.find_by_tag("system-err")) == 1 - assert len(tnode.find_by_tag("system-out")) == 1 + if junit_logging in ["system-err", "out-err", "all"]: + assert len(tnode.find_by_tag("system-err")) == 1 + else: + assert len(tnode.find_by_tag("system-err")) == 0 + + if junit_logging in ["log", "system-out", "out-err", "all"]: + assert len(tnode.find_by_tag("system-out")) == 1 + else: + assert len(tnode.find_by_tag("system-out")) == 0 @parametrize_families def test_xfailure_xpass(self, testdir, run_and_parse, xunit_family): @@ -696,20 +723,29 @@ class TestPython: result, dom = run_and_parse() print(dom.toxml()) - def test_pass_captures_stdout(self, testdir, run_and_parse): + @pytest.mark.parametrize("junit_logging", ["no", "system-out"]) + def test_pass_captures_stdout(self, testdir, run_and_parse, junit_logging): testdir.makepyfile( """ def test_pass(): print('hello-stdout') """ ) - result, dom = run_and_parse() + result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) node = dom.find_first_by_tag("testsuite") pnode = node.find_first_by_tag("testcase") - systemout = pnode.find_first_by_tag("system-out") - assert "hello-stdout" in systemout.toxml() + if junit_logging == "no": + assert not node.find_by_tag( + "system-out" + ), "system-out should not be generated" + if junit_logging == "system-out": + systemout = pnode.find_first_by_tag("system-out") + assert ( + "hello-stdout" in systemout.toxml() + ), "'hello-stdout' should be in system-out" - def test_pass_captures_stderr(self, testdir, run_and_parse): + @pytest.mark.parametrize("junit_logging", ["no", "system-err"]) + def test_pass_captures_stderr(self, testdir, run_and_parse, junit_logging): testdir.makepyfile( """ import sys @@ -717,13 +753,21 @@ class TestPython: sys.stderr.write('hello-stderr') """ ) - result, dom = run_and_parse() + result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) node = dom.find_first_by_tag("testsuite") pnode = node.find_first_by_tag("testcase") - systemout = pnode.find_first_by_tag("system-err") - assert "hello-stderr" in systemout.toxml() + if junit_logging == "no": + assert not node.find_by_tag( + "system-err" + ), "system-err should not be generated" + if junit_logging == "system-err": + systemerr = pnode.find_first_by_tag("system-err") + assert ( + "hello-stderr" in systemerr.toxml() + ), "'hello-stderr' should be in system-err" - def test_setup_error_captures_stdout(self, testdir, run_and_parse): + @pytest.mark.parametrize("junit_logging", ["no", "system-out"]) + def test_setup_error_captures_stdout(self, testdir, run_and_parse, junit_logging): testdir.makepyfile( """ import pytest @@ -736,13 +780,21 @@ class TestPython: pass """ ) - result, dom = run_and_parse() + result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) node = dom.find_first_by_tag("testsuite") pnode = node.find_first_by_tag("testcase") - systemout = pnode.find_first_by_tag("system-out") - assert "hello-stdout" in systemout.toxml() + if junit_logging == "no": + assert not node.find_by_tag( + "system-out" + ), "system-out should not be generated" + if junit_logging == "system-out": + systemout = pnode.find_first_by_tag("system-out") + assert ( + "hello-stdout" in systemout.toxml() + ), "'hello-stdout' should be in system-out" - def test_setup_error_captures_stderr(self, testdir, run_and_parse): + @pytest.mark.parametrize("junit_logging", ["no", "system-err"]) + def test_setup_error_captures_stderr(self, testdir, run_and_parse, junit_logging): testdir.makepyfile( """ import sys @@ -756,13 +808,21 @@ class TestPython: pass """ ) - result, dom = run_and_parse() + result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) node = dom.find_first_by_tag("testsuite") pnode = node.find_first_by_tag("testcase") - systemout = pnode.find_first_by_tag("system-err") - assert "hello-stderr" in systemout.toxml() + if junit_logging == "no": + assert not node.find_by_tag( + "system-err" + ), "system-err should not be generated" + if junit_logging == "system-err": + systemerr = pnode.find_first_by_tag("system-err") + assert ( + "hello-stderr" in systemerr.toxml() + ), "'hello-stderr' should be in system-err" - def test_avoid_double_stdout(self, testdir, run_and_parse): + @pytest.mark.parametrize("junit_logging", ["no", "system-out"]) + def test_avoid_double_stdout(self, testdir, run_and_parse, junit_logging): testdir.makepyfile( """ import sys @@ -777,12 +837,17 @@ class TestPython: sys.stdout.write('hello-stdout call') """ ) - result, dom = run_and_parse() + result, dom = run_and_parse("-o", "junit_logging=%s" % junit_logging) node = dom.find_first_by_tag("testsuite") pnode = node.find_first_by_tag("testcase") - systemout = pnode.find_first_by_tag("system-out") - assert "hello-stdout call" in systemout.toxml() - assert "hello-stdout teardown" in systemout.toxml() + if junit_logging == "no": + assert not node.find_by_tag( + "system-out" + ), "system-out should not be generated" + if junit_logging == "system-out": + systemout = pnode.find_first_by_tag("system-out") + assert "hello-stdout call" in systemout.toxml() + assert "hello-stdout teardown" in systemout.toxml() def test_mangle_test_address(): @@ -850,7 +915,8 @@ class TestNonPython: assert "custom item runtest failed" in fnode.toxml() -def test_nullbyte(testdir): +@pytest.mark.parametrize("junit_logging", ["no", "system-out"]) +def test_nullbyte(testdir, junit_logging): # A null byte can not occur in XML (see section 2.2 of the spec) testdir.makepyfile( """ @@ -862,13 +928,17 @@ def test_nullbyte(testdir): """ ) xmlf = testdir.tmpdir.join("junit.xml") - testdir.runpytest("--junitxml=%s" % xmlf) + testdir.runpytest("--junitxml=%s" % xmlf, "-o", "junit_logging=%s" % junit_logging) text = xmlf.read() assert "\x00" not in text - assert "#x00" in text + if junit_logging == "system-out": + assert "#x00" in text + if junit_logging == "no": + assert "#x00" not in text -def test_nullbyte_replace(testdir): +@pytest.mark.parametrize("junit_logging", ["no", "system-out"]) +def test_nullbyte_replace(testdir, junit_logging): # Check if the null byte gets replaced testdir.makepyfile( """ @@ -880,9 +950,12 @@ def test_nullbyte_replace(testdir): """ ) xmlf = testdir.tmpdir.join("junit.xml") - testdir.runpytest("--junitxml=%s" % xmlf) + testdir.runpytest("--junitxml=%s" % xmlf, "-o", "junit_logging=%s" % junit_logging) text = xmlf.read() - assert "#x0" in text + if junit_logging == "system-out": + assert "#x0" in text + if junit_logging == "no": + assert "#x0" not in text def test_invalid_xml_escape(): From 9298f7e4a9a33c496fa40102a18c022fbfc783b3 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 16 Jan 2020 07:47:00 -0300 Subject: [PATCH 050/158] Improve CHANGELOG and docs for junit_logging --- changelog/6469.feature.rst | 2 +- doc/en/reference.rst | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/changelog/6469.feature.rst b/changelog/6469.feature.rst index 4b05fdb33..3a2cfb0e0 100644 --- a/changelog/6469.feature.rst +++ b/changelog/6469.feature.rst @@ -1 +1 @@ -User can set ``junit_logging`` to one of ``no|log|system-out|system-err|out-err|all`` in order to put selected output in xml file. +New options have been added to the :confval:`junit_logging` option: ``log``, ``out-err``, and ``all``. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index a72e59b17..7d4f1dafa 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1164,9 +1164,17 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: junit_logging .. versionadded:: 3.5 + .. versionchanged:: 5.4 + ``log``, ``all``, ``out-err`` options added. - Configures if stdout/stderr should be written to the JUnit XML file. Valid values are - ``log``, ``out-err``, ``all``, ``system-out``, ``system-err``, and ``no`` (the default). + Configures if captured output should be written to the JUnit XML file. Valid values are: + + * ``log``: write only ``logging`` captured output. + * ``system-out``: write captured ``stdout`` contents. + * ``system-err``: write captured ``stderr`` contents. + * ``out-err``: write both captured ``stdout`` and ``stderr`` contents. + * ``all``: write captured ``logging``, ``stdout`` and ``stderr`` contents. + * ``no`` (the default): no captured output is written. .. code-block:: ini From 5f4cd536f9c1ab279346a60f0b9dbbd1e01afa7f Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 16 Jan 2020 19:47:23 +0100 Subject: [PATCH 051/158] Use _pytest.compat.TYPE_CHECKING --- src/_pytest/config/argparsing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d0870ed56..fa9d8f5dc 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -15,9 +15,10 @@ from typing import Union import py +from _pytest.compat import TYPE_CHECKING from _pytest.config.exceptions import UsageError -if False: # TYPE_CHECKING +if TYPE_CHECKING: from typing import NoReturn from typing_extensions import Literal # noqa: F401 From 7a626921c0d2d88a5f24b3c9d2be87503a926974 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 18 Jan 2020 13:08:58 +0100 Subject: [PATCH 052/158] [features] tests: fix test_crash_on_closing_tmpfile_py27 (cherry picked from commit 4f0eec2022c9c25c98c719f1745f57e9afe04274) --- testing/test_capture.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testing/test_capture.py b/testing/test_capture.py index 1885c9bb6..b1fee95db 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1407,6 +1407,8 @@ def test_crash_on_closing_tmpfile_py27(testdir): printing.wait() """ ) + # Do not consider plugins like hypothesis, which might output to stderr. + testdir.monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") result = testdir.runpytest_subprocess(str(p)) assert result.ret == 0 assert result.stderr.str() == "" From 40d0031ccef3f16a58e692d3e72dc02739d91584 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 16 Jan 2020 18:12:47 -0300 Subject: [PATCH 053/158] Drop deploy from Travis in favor of GitHub actions GitHub actions already should deploy. This is a stop gap while we figure out why coverage dropped when removing Travis builds in #6470 (cherry picked from commit d1d7e5d41bec40b3f0ee9f54d4adaae4db0cc185) --- .travis.yml | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5b67f15dd..96eeb5354 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,10 +2,8 @@ language: python dist: xenial stages: - baseline -- name: test - if: repo = pytest-dev/pytest AND tag IS NOT present -- name: deploy - if: repo = pytest-dev/pytest AND tag IS present +- test + python: '3.7' cache: false @@ -72,30 +70,6 @@ jobs: directories: - $HOME/.cache/pre-commit - - stage: deploy - python: '3.6' - install: pip install -U setuptools setuptools_scm tox - script: skip - # token to upload github release notes: GH_RELEASE_NOTES_TOKEN - env: - - secure: "OjOeL7/0JUDkV00SsTs732e8vQjHynpbG9FKTNtZZJ+1Zn4Cib+hAlwmlBnvVukML0X60YpcfjnC4quDOIGLPsh5zeXnvJmYtAIIUNQXjWz8NhcGYrhyzuP1rqV22U68RTCdmOq3lMYU/W2acwHP7T49PwJtOiUM5kF120UAQ0Zi5EmkqkIvH8oM5mO9Dlver+/U7Htpz9rhKrHBXQNCMZI6yj2aUyukqB2PN2fjAlDbCF//+FmvYw9NjT4GeFOSkTCf4ER9yfqs7yglRfwiLtOCZ2qKQhWZNsSJDB89rxIRXWavJUjJKeY2EW2/NkomYJDpqJLIF4JeFRw/HhA47CYPeo6BJqyyNV+0CovL1frpWfi9UQw2cMbgFUkUIUk3F6DD59PHNIOX2R/HX56dQsw7WKl3QuHlCOkICXYg8F7Ta684IoKjeTX03/6QNOkURfDBwfGszY0FpbxrjCSWKom6RyZdyidnESaxv9RzjcIRZVh1rp8KMrwS1OrwRSdG0zjlsPr49hWMenN/8fKgcHTV4/r1Tj6mip0dorSRCrgUNIeRBKgmui6FS8642ab5JNKOxMteVPVR2sFuhjOQ0Jy+PmvceYY9ZMWc3+/B/KVh0dZ3hwvLGZep/vxDS2PwCA5/xw31714vT5LxidKo8yECjBynMU/wUTTS695D3NY=" - addons: - apt: - packages: - # required by publish-gh-release-notes - - pandoc - after_deploy: tox -e publish-gh-release-notes - deploy: - provider: pypi - user: nicoddemus - distributions: sdist bdist_wheel - skip_upload_docs: true - password: - secure: xanTgTUu6XDQVqB/0bwJQXoDMnU5tkwZc5koz6mBkkqZhKdNOi2CLoC1XhiSZ+ah24l4V1E0GAqY5kBBcy9d7NVe4WNg4tD095LsHw+CRU6/HCVIFfyk2IZ+FPAlguesCcUiJSXOrlBF+Wj68wEvLoK7EoRFbJeiZ/f91Ww1sbtDlqXABWGHrmhPJL5Wva7o7+wG7JwJowqdZg1pbQExsCc7b53w4v2RBu3D6TJaTAzHiVsW+nUSI67vKI/uf+cR/OixsTfy37wlHgSwihYmrYLFls3V0bSpahCim3bCgMaFZx8S8xrdgJ++PzBCof2HeflFKvW+VCkoYzGEG4NrTWJoNz6ni4red9GdvfjGH3YCjAKS56h9x58zp2E5rpsb/kVq5/45xzV+dq6JRuhQ1nJWjBC6fSKAc/bfwnuFK3EBxNLkvBssLHvsNjj5XG++cB8DdS9wVGUqjpoK4puaXUWFqy4q3S9F86HEsKNgExtieA9qNx+pCIZVs6JCXZNjr0I5eVNzqJIyggNgJG6RyravsU35t9Zd9doL5g4Y7UKmAGTn1Sz24HQ4sMQgXdm2SyD8gEK5je4tlhUvfGtDvMSlstq71kIn9nRpFnqB6MFlbYSEAZmo8dGbCquoUc++6Rum208wcVbrzzVtGlXB/Ow9AbFMYeAGA0+N/K1e59c= - on: - tags: true - repo: pytest-dev/pytest - before_script: - | # Do not (re-)upload coverage with cron runs. From 89f92a459a5f36e8b24dcbfab4d5f24e58924fc9 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 18 Jan 2020 13:25:42 +0100 Subject: [PATCH 054/158] ci: Travis: remove non-coverage jobs This helps with regard to slowness until https://github.com/pytest-dev/pytest/pull/6470 is resolved. (cherry picked from commit a7292a054471f8d2a51f5a0d768c70a8a37e5275) --- .travis.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.travis.yml b/.travis.yml index 96eeb5354..d813cf07a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,5 @@ language: python dist: xenial -stages: -- baseline -- test - python: '3.7' cache: false @@ -46,25 +42,11 @@ jobs: - env: TOXENV=py37-lsof-numpy-oldattrs-pexpect-twisted PYTEST_COVERAGE=1 PYTEST_ADDOPTS= python: '3.7' - # Coverage tracking is slow with pypy, skip it. - - env: TOXENV=pypy3-xdist - python: 'pypy3' - # Coverage for Python 3.5.{0,1} specific code, mostly typing related. - env: TOXENV=py35 PYTEST_COVERAGE=1 PYTEST_ADDOPTS="-k test_raises_cyclic_reference" python: '3.5.1' dist: trusty - # Specialized factors for py37. - - env: TOXENV=py37-pluggymaster-xdist - - env: TOXENV=py37-freeze - - - env: TOXENV=py38-xdist - python: '3.8' - - - stage: baseline - env: TOXENV=py36-xdist - python: '3.6' - env: TOXENV=linting,docs,doctesting PYTEST_COVERAGE=1 cache: directories: From 7b1e3d1c9a04e940b0e94341c7029f8c5c2ceb54 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 17 Jan 2020 09:02:12 -0300 Subject: [PATCH 055/158] Clear collection caches after collection is done Also rename the involved variables to convey its intent better and add type hints --- src/_pytest/main.py | 52 ++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index a8c720b54..6bf6f333e 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -6,21 +6,29 @@ import importlib import os import sys from typing import Dict +from typing import FrozenSet +from typing import List import attr import py import _pytest._code from _pytest import nodes +from _pytest.compat import TYPE_CHECKING from _pytest.config import directory_arg from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.fixtures import FixtureManager +from _pytest.nodes import Node from _pytest.outcomes import exit from _pytest.runner import collect_one_node from _pytest.runner import SetupState +if TYPE_CHECKING: + from _pytest.python import Package + + class ExitCode(enum.IntEnum): """ .. versionadded:: 5.0 @@ -381,7 +389,7 @@ class Session(nodes.FSCollector): _setupstate = None # type: SetupState _fixturemanager = None # type: FixtureManager - def __init__(self, config): + def __init__(self, config) -> None: nodes.FSCollector.__init__( self, config.rootdir, parent=None, config=config, session=self, nodeid="" ) @@ -392,14 +400,16 @@ class Session(nodes.FSCollector): self.trace = config.trace.root.get("collection") self._norecursepatterns = config.getini("norecursedirs") self.startdir = config.invocation_dir - self._initialpaths = frozenset() + self._initialpaths = frozenset() # type: FrozenSet[py.path.local] + # Keep track of any collected nodes in here, so we don't duplicate fixtures - self._node_cache = {} + self._collection_node_cache = {} # type: Dict[str, List[Node]] + # Dirnames of pkgs with dunder-init files. + self._collection_pkg_roots = {} # type: Dict[py.path.local, Package] + self._bestrelpathcache = _bestrelpath_cache( config.rootdir ) # type: Dict[str, str] - # Dirnames of pkgs with dunder-init files. - self._pkg_roots = {} self.config.pluginmanager.register(self, name="session") @@ -511,6 +521,8 @@ class Session(nodes.FSCollector): self._notfound.append((report_arg, sys.exc_info()[1])) self.trace.root.indent -= 1 + self._collection_node_cache.clear() + self._collection_pkg_roots.clear() def _collect(self, arg): from _pytest.python import Package @@ -530,13 +542,13 @@ class Session(nodes.FSCollector): if parent.isdir(): pkginit = parent.join("__init__.py") if pkginit.isfile(): - if pkginit not in self._node_cache: + if pkginit not in self._collection_node_cache: col = self._collectfile(pkginit, handle_dupes=False) if col: if isinstance(col[0], Package): - self._pkg_roots[parent] = col[0] + self._collection_pkg_roots[parent] = col[0] # always store a list in the cache, matchnodes expects it - self._node_cache[col[0].fspath] = [col[0]] + self._collection_node_cache[col[0].fspath] = [col[0]] # If it's a directory argument, recurse and look for any Subpackages. # Let the Package collector deal with subnodes, don't collect here. @@ -556,28 +568,28 @@ class Session(nodes.FSCollector): for x in self._collectfile(pkginit): yield x if isinstance(x, Package): - self._pkg_roots[dirpath] = x - if dirpath in self._pkg_roots: + self._collection_pkg_roots[dirpath] = x + if dirpath in self._collection_pkg_roots: # Do not collect packages here. continue for x in self._collectfile(path): key = (type(x), x.fspath) - if key in self._node_cache: - yield self._node_cache[key] + if key in self._collection_node_cache: + yield self._collection_node_cache[key] else: - self._node_cache[key] = x + self._collection_node_cache[key] = x yield x else: assert argpath.check(file=1) - if argpath in self._node_cache: - col = self._node_cache[argpath] + if argpath in self._collection_node_cache: + col = self._collection_node_cache[argpath] else: - collect_root = self._pkg_roots.get(argpath.dirname, self) + collect_root = self._collection_pkg_roots.get(argpath.dirname, self) col = collect_root._collectfile(argpath, handle_dupes=False) if col: - self._node_cache[argpath] = col + self._collection_node_cache[argpath] = col m = self.matchnodes(col, names) # If __init__.py was the only file requested, then the matched node will be # the corresponding Package, and the first yielded item will be the __init__ @@ -690,11 +702,11 @@ class Session(nodes.FSCollector): continue assert isinstance(node, nodes.Collector) key = (type(node), node.nodeid) - if key in self._node_cache: - rep = self._node_cache[key] + if key in self._collection_node_cache: + rep = self._collection_node_cache[key] else: rep = collect_one_node(node) - self._node_cache[key] = rep + self._collection_node_cache[key] = rep if rep.passed: has_matched = False for x in rep.result: From 00097df5cdc383315e020039233cd8e7562aa316 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 22 Jan 2020 13:04:30 +0100 Subject: [PATCH 056/158] tests: add test_plugin_loading_order Ref: https://github.com/pytest-dev/pytest/pull/6443 --- testing/test_config.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/testing/test_config.py b/testing/test_config.py index 498cbf7eb..cc54e5b23 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -670,6 +670,32 @@ def test_disable_plugin_autoload(testdir, monkeypatch, parse_args, should_load): assert has_loaded == should_load +def test_plugin_loading_order(testdir): + """Test order of plugin loading with `-p`.""" + p1 = testdir.makepyfile( + """ + def test_terminal_plugin(request): + import myplugin + assert myplugin.terminal_plugin == [True, True] + """, + **{ + "myplugin": """ + terminal_plugin = [] + + def pytest_configure(config): + terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) + + def pytest_sessionstart(session): + config = session.config + terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) + """ + } + ) + testdir.syspathinsert() + result = testdir.runpytest("-p", "myplugin", str(p1)) + assert result.ret == 0 + + def test_cmdline_processargs_simple(testdir): testdir.makeconftest( """ From dd5c2b22bd3534ee02039932a51fc7a9eba01ca0 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 23 Jan 2020 14:47:27 +0200 Subject: [PATCH 057/158] Refactor Session._initialparts to have a more explicit type Previously, _initialparts was a list whose first item was a `py.path.local` and the rest were `str`s. This is not something that mypy is capable of modeling. The type `List[Union[str, py.path.local]]` is too broad and would require asserts for every access. Instead, make each item a `Tuple[py.path.local, List[str]]`. This way the structure is clear and the types are accurate. To make sure any users who might have been accessing this (private) field will not break silently, change the name to _initial_parts. --- src/_pytest/main.py | 40 ++++++++++++++++++-------------------- testing/test_collection.py | 14 ++++++------- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 057dae4f4..f8735abee 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -8,6 +8,7 @@ import sys from typing import Dict from typing import FrozenSet from typing import List +from typing import Tuple import attr import py @@ -485,13 +486,13 @@ class Session(nodes.FSCollector): self.trace("perform_collect", self, args) self.trace.root.indent += 1 self._notfound = [] - initialpaths = [] - self._initialparts = [] + initialpaths = [] # type: List[py.path.local] + self._initial_parts = [] # type: List[Tuple[py.path.local, List[str]]] self.items = items = [] for arg in args: - parts = self._parsearg(arg) - self._initialparts.append(parts) - initialpaths.append(parts[0]) + fspath, parts = self._parsearg(arg) + self._initial_parts.append((fspath, parts)) + initialpaths.append(fspath) self._initialpaths = frozenset(initialpaths) rep = collect_one_node(self) self.ihook.pytest_collectreport(report=rep) @@ -511,13 +512,13 @@ class Session(nodes.FSCollector): return items def collect(self): - for initialpart in self._initialparts: - self.trace("processing argument", initialpart) + for fspath, parts in self._initial_parts: + self.trace("processing argument", (fspath, parts)) self.trace.root.indent += 1 try: - yield from self._collect(initialpart) + yield from self._collect(fspath, parts) except NoMatch: - report_arg = "::".join(map(str, initialpart)) + report_arg = "::".join((str(fspath), *parts)) # we are inside a make_report hook so # we cannot directly pass through the exception self._notfound.append((report_arg, sys.exc_info()[1])) @@ -526,12 +527,9 @@ class Session(nodes.FSCollector): self._collection_node_cache.clear() self._collection_pkg_roots.clear() - def _collect(self, arg): + def _collect(self, argpath, names): from _pytest.python import Package - names = arg[:] - argpath = names.pop(0) - # Start with a Session root, and delve to argpath item (dir or file) # and stack all Packages found on the way. # No point in finding packages when collecting doctests @@ -555,7 +553,7 @@ class Session(nodes.FSCollector): # If it's a directory argument, recurse and look for any Subpackages. # Let the Package collector deal with subnodes, don't collect here. if argpath.check(dir=1): - assert not names, "invalid arg {!r}".format(arg) + assert not names, "invalid arg {!r}".format((argpath, names)) seen_dirs = set() for path in argpath.visit( @@ -665,19 +663,19 @@ class Session(nodes.FSCollector): def _parsearg(self, arg): """ return (fspath, names) tuple after checking the file exists. """ - parts = str(arg).split("::") + strpath, *parts = str(arg).split("::") if self.config.option.pyargs: - parts[0] = self._tryconvertpyarg(parts[0]) - relpath = parts[0].replace("/", os.sep) - path = self.config.invocation_dir.join(relpath, abs=True) - if not path.check(): + strpath = self._tryconvertpyarg(strpath) + relpath = strpath.replace("/", os.sep) + fspath = self.config.invocation_dir.join(relpath, abs=True) + if not fspath.check(): if self.config.option.pyargs: raise UsageError( "file or package not found: " + arg + " (missing __init__.py?)" ) raise UsageError("file not found: " + arg) - parts[0] = path.realpath() - return parts + fspath = fspath.realpath() + return (fspath, parts) def matchnodes(self, matching, names): self.trace("matchnodes", matching, names) diff --git a/testing/test_collection.py b/testing/test_collection.py index 885b05ccd..760cb2b7f 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -438,7 +438,7 @@ class TestCustomConftests: class TestSession: - def test_parsearg(self, testdir): + def test_parsearg(self, testdir) -> None: p = testdir.makepyfile("def test_func(): pass") subdir = testdir.mkdir("sub") subdir.ensure("__init__.py") @@ -448,14 +448,14 @@ class TestSession: config = testdir.parseconfig(p.basename) rcol = Session.from_config(config) assert rcol.fspath == subdir - parts = rcol._parsearg(p.basename) + fspath, parts = rcol._parsearg(p.basename) - assert parts[0] == target + assert fspath == target + assert len(parts) == 0 + fspath, parts = rcol._parsearg(p.basename + "::test_func") + assert fspath == target + assert parts[0] == "test_func" assert len(parts) == 1 - parts = rcol._parsearg(p.basename + "::test_func") - assert parts[0] == target - assert parts[1] == "test_func" - assert len(parts) == 2 def test_collect_topdir(self, testdir): p = testdir.makepyfile("def test_func(): pass") From f3967333a145d8a793a0ab53ac5e0cb0b6c87cac Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 24 Jan 2020 11:43:14 +0200 Subject: [PATCH 058/158] Split Session._collection_node_cache to 3 mutually exclusive parts Previously, this cache was used with 3 different and mutually exclusive key-type -> value-type combinations. Mypy can't properly type this. It's also quite confusing. Split to 3 different dicts instead. --- src/_pytest/main.py | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 5a0de75c7..a0cae18ed 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -8,6 +8,7 @@ import sys from typing import Dict from typing import FrozenSet from typing import List +from typing import Sequence from typing import Tuple import attr @@ -21,13 +22,15 @@ from _pytest.config import directory_arg from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.fixtures import FixtureManager -from _pytest.nodes import Node from _pytest.outcomes import exit +from _pytest.reports import CollectReport from _pytest.runner import collect_one_node from _pytest.runner import SetupState if TYPE_CHECKING: + from typing import Type + from _pytest.python import Package @@ -407,7 +410,16 @@ class Session(nodes.FSCollector): self._initialpaths = frozenset() # type: FrozenSet[py.path.local] # Keep track of any collected nodes in here, so we don't duplicate fixtures - self._collection_node_cache = {} # type: Dict[str, List[Node]] + self._collection_node_cache1 = ( + {} + ) # type: Dict[py.path.local, Sequence[nodes.Collector]] + self._collection_node_cache2 = ( + {} + ) # type: Dict[Tuple[Type[nodes.Collector], py.path.local], nodes.Collector] + self._collection_node_cache3 = ( + {} + ) # type: Dict[Tuple[Type[nodes.Collector], str], CollectReport] + # Dirnames of pkgs with dunder-init files. self._collection_pkg_roots = {} # type: Dict[py.path.local, Package] @@ -525,7 +537,9 @@ class Session(nodes.FSCollector): self._notfound.append((report_arg, sys.exc_info()[1])) self.trace.root.indent -= 1 - self._collection_node_cache.clear() + self._collection_node_cache1.clear() + self._collection_node_cache2.clear() + self._collection_node_cache3.clear() self._collection_pkg_roots.clear() def _collect(self, argpath, names): @@ -543,13 +557,13 @@ class Session(nodes.FSCollector): if parent.isdir(): pkginit = parent.join("__init__.py") if pkginit.isfile(): - if pkginit not in self._collection_node_cache: + if pkginit not in self._collection_node_cache1: col = self._collectfile(pkginit, handle_dupes=False) if col: if isinstance(col[0], Package): self._collection_pkg_roots[parent] = col[0] # always store a list in the cache, matchnodes expects it - self._collection_node_cache[col[0].fspath] = [col[0]] + self._collection_node_cache1[col[0].fspath] = [col[0]] # If it's a directory argument, recurse and look for any Subpackages. # Let the Package collector deal with subnodes, don't collect here. @@ -576,21 +590,21 @@ class Session(nodes.FSCollector): for x in self._collectfile(path): key = (type(x), x.fspath) - if key in self._collection_node_cache: - yield self._collection_node_cache[key] + if key in self._collection_node_cache2: + yield self._collection_node_cache2[key] else: - self._collection_node_cache[key] = x + self._collection_node_cache2[key] = x yield x else: assert argpath.check(file=1) - if argpath in self._collection_node_cache: - col = self._collection_node_cache[argpath] + if argpath in self._collection_node_cache1: + col = self._collection_node_cache1[argpath] else: collect_root = self._collection_pkg_roots.get(argpath.dirname, self) col = collect_root._collectfile(argpath, handle_dupes=False) if col: - self._collection_node_cache[argpath] = col + self._collection_node_cache1[argpath] = col m = self.matchnodes(col, names) # If __init__.py was the only file requested, then the matched node will be # the corresponding Package, and the first yielded item will be the __init__ @@ -703,11 +717,11 @@ class Session(nodes.FSCollector): continue assert isinstance(node, nodes.Collector) key = (type(node), node.nodeid) - if key in self._collection_node_cache: - rep = self._collection_node_cache[key] + if key in self._collection_node_cache3: + rep = self._collection_node_cache3[key] else: rep = collect_one_node(node) - self._collection_node_cache[key] = rep + self._collection_node_cache3[key] = rep if rep.passed: has_matched = False for x in rep.result: From fe343a79f817734cc95f28fee91452d7d7fd8c21 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 27 Dec 2019 19:39:57 +0200 Subject: [PATCH 059/158] Remove deprecated license_file from setup.cfg Starting with wheel 0.32.0 (2018-09-29), the `license_file` option is deprecated. * https://wheel.readthedocs.io/en/stable/news.html The wheel will continue to include `LICENSE`, it is now included automatically: * https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file And `LICENSE` is still included in sdists thanks to setuptools-scm: * https://github.com/pytest-dev/pytest/pull/6348#issuecomment-567836331 --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 54b64af96..bef9f7871 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,7 +10,6 @@ project_urls = author = Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others license = MIT license -license_file = LICENSE keywords = test, unittest classifiers = Development Status :: 6 - Mature From 9b8039cf094063a80bd13ce32e3f4b48a77fe89c Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 14 Jan 2020 18:58:02 +0100 Subject: [PATCH 060/158] Sync `{Session,Package}._recurse` --- src/_pytest/main.py | 2 +- src/_pytest/python.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 7ce4c19ea..283ff29b1 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -625,7 +625,7 @@ class Session(nodes.FSCollector): return ihook.pytest_collect_file(path=path, parent=self) - def _recurse(self, dirpath): + def _recurse(self, dirpath: py.path.local) -> bool: if dirpath.basename == "__pycache__": return False ihook = self.gethookproxy(dirpath.dirpath()) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 82dca3bcc..869e45261 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -571,12 +571,12 @@ class Package(Module): func = partial(_call_with_optional_argument, teardown_module, self.obj) self.addfinalizer(func) - def _recurse(self, dirpath): + def _recurse(self, dirpath: py.path.local) -> bool: if dirpath.basename == "__pycache__": return False ihook = self.gethookproxy(dirpath.dirpath()) if ihook.pytest_ignore_collect(path=dirpath, config=self.config): - return + return False for pat in self._norecursepatterns: if dirpath.check(fnmatch=pat): return False From 039d582b52795e1682ec98370439081483920a95 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 25 Jan 2020 10:58:26 +0100 Subject: [PATCH 061/158] Fix `EncodedFile.writelines` This is implemented by the underlying stream already, which additionally checks if the stream is not closed, and calls `write` per line. Ref/via: https://github.com/pytest-dev/pytest/pull/6558#issuecomment-578210807 --- changelog/6566.bugfix.rst | 1 + src/_pytest/capture.py | 6 +++--- testing/test_capture.py | 12 ++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 changelog/6566.bugfix.rst diff --git a/changelog/6566.bugfix.rst b/changelog/6566.bugfix.rst new file mode 100644 index 000000000..4af976f22 --- /dev/null +++ b/changelog/6566.bugfix.rst @@ -0,0 +1 @@ +Fix ``EncodedFile.writelines`` to call the underlying buffer's ``writelines`` method. diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index c79bfeef0..e51fe2b67 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -9,6 +9,7 @@ import os import sys from io import UnsupportedOperation from tempfile import TemporaryFile +from typing import List import pytest from _pytest.compat import CaptureIO @@ -426,9 +427,8 @@ class EncodedFile: ) return self.buffer.write(obj) - def writelines(self, linelist): - data = "".join(linelist) - self.write(data) + def writelines(self, linelist: List[str]) -> None: + self.buffer.writelines([x.encode(self.encoding, "replace") for x in linelist]) @property def name(self): diff --git a/testing/test_capture.py b/testing/test_capture.py index 7d459e91c..ebe30703b 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1497,3 +1497,15 @@ def test_typeerror_encodedfile_write(testdir): def test_stderr_write_returns_len(capsys): """Write on Encoded files, namely captured stderr, should return number of characters written.""" assert sys.stderr.write("Foo") == 3 + + +def test_encodedfile_writelines(tmpfile) -> None: + ef = capture.EncodedFile(tmpfile, "utf-8") + with pytest.raises(AttributeError): + ef.writelines([b"line1", b"line2"]) # type: ignore[list-item] # noqa: F821 + assert ef.writelines(["line1", "line2"]) is None # type: ignore[func-returns-value] # noqa: F821 + tmpfile.seek(0) + assert tmpfile.read() == b"line1line2" + tmpfile.close() + with pytest.raises(ValueError): + ef.read() From 778d4364fa95e9da504ba82ef7208a8b5d57d720 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 25 Jan 2020 18:14:49 +0100 Subject: [PATCH 062/158] tests: test_collection_collect_only_live_logging: allow for 1s Might be slow on CI. Ref: https://github.com/pytest-dev/pytest/pull/6570/checks?check_run_id=408752475#step:6:109 --- testing/logging/test_reporting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 201f42f32..4333bbb00 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -946,7 +946,7 @@ def test_collection_collect_only_live_logging(testdir, verbose): expected_lines.extend( [ "*test_collection_collect_only_live_logging.py::test_simple*", - "no tests ran in 0.[0-9][0-9]s", + "no tests ran in [0-1].[0-9][0-9]s", ] ) elif verbose == "-qq": From df1f43ee28d38350542a23acb27647feab46f473 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 25 Jan 2020 18:20:46 +0100 Subject: [PATCH 063/158] ci: codecov: use `--retry-connrefused` with curl While it might not help with the following, it certainly might happen as well. ``` + curl -S -L --connect-timeout 5 --retry 6 -s https://codecov.io/bash -o codecov-upload.sh curl: (16) Error in the HTTP2 framing layer ``` --- scripts/report-coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/report-coverage.sh b/scripts/report-coverage.sh index fbcf20ca9..6aa931383 100755 --- a/scripts/report-coverage.sh +++ b/scripts/report-coverage.sh @@ -14,5 +14,5 @@ python -m coverage combine python -m coverage xml python -m coverage report -m # Set --connect-timeout to work around https://github.com/curl/curl/issues/4461 -curl -S -L --connect-timeout 5 --retry 6 -s https://codecov.io/bash -o codecov-upload.sh +curl -S -L --connect-timeout 5 --retry 6 --retry-connrefused -s https://codecov.io/bash -o codecov-upload.sh bash codecov-upload.sh -Z -X fix -f coverage.xml "$@" From 817c094ce62b322ad5a6ccce07ddd0ea3b75710e Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 14 Jan 2020 17:57:24 +0100 Subject: [PATCH 064/158] Clean up Package.__init__ Makes `parent` a required arg, which would have failed before via `parent.session` anyway. Keeps calling/passing unused args for B/C. --- src/_pytest/python.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 869e45261..9def7e49e 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -545,15 +545,23 @@ class Module(nodes.File, PyCollector): class Package(Module): - def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None): + def __init__( + self, + fspath: py.path.local, + parent: nodes.Collector, + # NOTE: following args are unused: + config=None, + session=None, + nodeid=None, + ) -> None: + # NOTE: could be just the following, but kept as-is for compat. + # nodes.FSCollector.__init__(self, fspath, parent=parent) session = parent.session nodes.FSCollector.__init__( self, fspath, parent=parent, config=config, session=session, nodeid=nodeid ) + self.name = fspath.dirname - self.trace = session.trace - self._norecursepatterns = session._norecursepatterns - self.fspath = fspath def setup(self): # not using fixtures to call setup_module here because autouse fixtures From 6b7e1a246cbaec554166b855c74fdb58cf54c08b Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 14 Jan 2020 19:10:56 +0100 Subject: [PATCH 065/158] Sync `{Session,Package}.gethookproxy` Only copy'n'paste error from c416b1d935. --- src/_pytest/python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 9def7e49e..916ef7bd5 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -594,7 +594,7 @@ class Package(Module): def gethookproxy(self, fspath): # check if we have the common case of running - # hooks with all conftest.py filesall conftest.py + # hooks with all conftest.py files pm = self.config.pluginmanager my_conftestmodules = pm._getconftestmodules(fspath) remove_mods = pm._conftest_plugins.difference(my_conftestmodules) From e2934c3f8c03c83469f4c6670c207773a6e02df4 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 14 Jan 2020 19:15:26 +0100 Subject: [PATCH 066/158] Move common code between Session and Package to FSCollector --- src/_pytest/main.py | 39 +-------------------------------------- src/_pytest/nodes.py | 41 +++++++++++++++++++++++++++++++++++++++++ src/_pytest/python.py | 27 +-------------------------- 3 files changed, 43 insertions(+), 64 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 283ff29b1..066c885b8 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -348,18 +348,6 @@ def pytest_collection_modifyitems(items, config): items[:] = remaining -class FSHookProxy: - def __init__(self, fspath, pm, remove_mods): - self.fspath = fspath - self.pm = pm - self.remove_mods = remove_mods - - def __getattr__(self, name): - x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) - self.__dict__[name] = x - return x - - class NoMatch(Exception): """ raised if matching cannot locate a matching names. """ @@ -401,7 +389,6 @@ class Session(nodes.FSCollector): self.shouldstop = False self.shouldfail = False self.trace = config.trace.root.get("collection") - self._norecursepatterns = config.getini("norecursedirs") self.startdir = config.invocation_dir self._initialpaths = frozenset() # type: FrozenSet[py.path.local] @@ -450,18 +437,7 @@ class Session(nodes.FSCollector): return path in self._initialpaths def gethookproxy(self, fspath): - # check if we have the common case of running - # hooks with all conftest.py files - pm = self.config.pluginmanager - my_conftestmodules = pm._getconftestmodules(fspath) - remove_mods = pm._conftest_plugins.difference(my_conftestmodules) - if remove_mods: - # one or more conftests are not in use at this fspath - proxy = FSHookProxy(fspath, pm, remove_mods) - else: - # all plugins are active for this fspath - proxy = self.config.hook - return proxy + return super()._gethookproxy(fspath) def perform_collect(self, args=None, genitems=True): hook = self.config.hook @@ -625,19 +601,6 @@ class Session(nodes.FSCollector): return ihook.pytest_collect_file(path=path, parent=self) - def _recurse(self, dirpath: py.path.local) -> bool: - if dirpath.basename == "__pycache__": - return False - ihook = self.gethookproxy(dirpath.dirpath()) - if ihook.pytest_ignore_collect(path=dirpath, config=self.config): - return False - for pat in self._norecursepatterns: - if dirpath.check(fnmatch=pat): - return False - ihook = self.gethookproxy(dirpath) - ihook.pytest_collect_directory(path=dirpath, parent=self) - return True - @staticmethod def _visit_filter(f): return f.check(file=1) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index ab976efae..f9f1f4f68 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -393,6 +393,18 @@ def _check_initialpaths_for_relpath(session, fspath): return fspath.relto(initial_path) +class FSHookProxy: + def __init__(self, fspath, pm, remove_mods): + self.fspath = fspath + self.pm = pm + self.remove_mods = remove_mods + + def __getattr__(self, name): + x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) + self.__dict__[name] = x + return x + + class FSCollector(Collector): def __init__( self, fspath: py.path.local, parent=None, config=None, session=None, nodeid=None @@ -417,6 +429,35 @@ class FSCollector(Collector): super().__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath) + self._norecursepatterns = self.config.getini("norecursedirs") + + def _gethookproxy(self, fspath): + # check if we have the common case of running + # hooks with all conftest.py files + pm = self.config.pluginmanager + my_conftestmodules = pm._getconftestmodules(fspath) + remove_mods = pm._conftest_plugins.difference(my_conftestmodules) + if remove_mods: + # one or more conftests are not in use at this fspath + proxy = FSHookProxy(fspath, pm, remove_mods) + else: + # all plugins are active for this fspath + proxy = self.config.hook + return proxy + + def _recurse(self, dirpath: py.path.local) -> bool: + if dirpath.basename == "__pycache__": + return False + ihook = self._gethookproxy(dirpath.dirpath()) + if ihook.pytest_ignore_collect(path=dirpath, config=self.config): + return False + for pat in self._norecursepatterns: + if dirpath.check(fnmatch=pat): + return False + ihook = self._gethookproxy(dirpath) + ihook.pytest_collect_directory(path=dirpath, parent=self) + return True + class File(FSCollector): """ base class for collecting tests from a file. """ diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 916ef7bd5..23a67d023 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -35,7 +35,6 @@ from _pytest.compat import safe_isclass from _pytest.compat import STRING_TYPES from _pytest.config import hookimpl from _pytest.deprecated import FUNCARGNAMES -from _pytest.main import FSHookProxy from _pytest.mark import MARK_GEN from _pytest.mark.structures import get_unpacked_marks from _pytest.mark.structures import normalize_mark_list @@ -579,32 +578,8 @@ class Package(Module): func = partial(_call_with_optional_argument, teardown_module, self.obj) self.addfinalizer(func) - def _recurse(self, dirpath: py.path.local) -> bool: - if dirpath.basename == "__pycache__": - return False - ihook = self.gethookproxy(dirpath.dirpath()) - if ihook.pytest_ignore_collect(path=dirpath, config=self.config): - return False - for pat in self._norecursepatterns: - if dirpath.check(fnmatch=pat): - return False - ihook = self.gethookproxy(dirpath) - ihook.pytest_collect_directory(path=dirpath, parent=self) - return True - def gethookproxy(self, fspath): - # check if we have the common case of running - # hooks with all conftest.py files - pm = self.config.pluginmanager - my_conftestmodules = pm._getconftestmodules(fspath) - remove_mods = pm._conftest_plugins.difference(my_conftestmodules) - if remove_mods: - # one or more conftests are not in use at this fspath - proxy = FSHookProxy(fspath, pm, remove_mods) - else: - # all plugins are active for this fspath - proxy = self.config.hook - return proxy + return super()._gethookproxy(fspath) def _collectfile(self, path, handle_dupes=True): assert ( From 3f8f3952107998f03a7fd1826427a9262a267f6c Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 25 Jan 2020 18:11:38 +0100 Subject: [PATCH 067/158] typing: EncodedFile --- src/_pytest/capture.py | 15 +++++++-------- testing/test_capture.py | 3 ++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index e51fe2b67..33d2243b3 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -9,6 +9,7 @@ import os import sys from io import UnsupportedOperation from tempfile import TemporaryFile +from typing import BinaryIO from typing import List import pytest @@ -414,29 +415,27 @@ def safe_text_dupfile(f, mode, default_encoding="UTF8"): class EncodedFile: errors = "strict" # possibly needed by py3 code (issue555) - def __init__(self, buffer, encoding): + def __init__(self, buffer: BinaryIO, encoding: str) -> None: self.buffer = buffer self.encoding = encoding - def write(self, obj): - if isinstance(obj, str): - obj = obj.encode(self.encoding, "replace") - else: + def write(self, obj: str) -> int: + if not isinstance(obj, str): raise TypeError( "write() argument must be str, not {}".format(type(obj).__name__) ) - return self.buffer.write(obj) + return self.buffer.write(obj.encode(self.encoding, "replace")) def writelines(self, linelist: List[str]) -> None: self.buffer.writelines([x.encode(self.encoding, "replace") for x in linelist]) @property - def name(self): + def name(self) -> str: """Ensure that file.name is a string.""" return repr(self.buffer) @property - def mode(self): + def mode(self) -> str: return self.buffer.mode.replace("b", "") def __getattr__(self, name): diff --git a/testing/test_capture.py b/testing/test_capture.py index ebe30703b..e6862f313 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -7,6 +7,7 @@ import sys import textwrap from io import StringIO from io import UnsupportedOperation +from typing import BinaryIO from typing import List from typing import TextIO @@ -1499,7 +1500,7 @@ def test_stderr_write_returns_len(capsys): assert sys.stderr.write("Foo") == 3 -def test_encodedfile_writelines(tmpfile) -> None: +def test_encodedfile_writelines(tmpfile: BinaryIO) -> None: ef = capture.EncodedFile(tmpfile, "utf-8") with pytest.raises(AttributeError): ef.writelines([b"line1", b"line2"]) # type: ignore[list-item] # noqa: F821 From d678d380cb407bea0b80e0246752b24e61267478 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 25 Jan 2020 19:21:19 +0100 Subject: [PATCH 068/158] typing: tests: tmpfile --- testing/test_capture.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testing/test_capture.py b/testing/test_capture.py index e6862f313..9261c8441 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -8,6 +8,7 @@ import textwrap from io import StringIO from io import UnsupportedOperation from typing import BinaryIO +from typing import Generator from typing import List from typing import TextIO @@ -832,7 +833,7 @@ def test_dontreadfrominput(): @pytest.fixture -def tmpfile(testdir): +def tmpfile(testdir) -> Generator[BinaryIO, None, None]: f = testdir.makepyfile("").open("wb+") yield f if not f.closed: From 40758e86ca9d287069df45c732d586b4905613f6 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sun, 26 Jan 2020 16:41:17 +0100 Subject: [PATCH 069/158] tests: add test_via_exec Via https://github.com/pytest-dev/pytest/issues/6574. --- testing/test_terminal.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 09c9d5485..f284ad577 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -13,6 +13,7 @@ import py import pytest from _pytest.main import ExitCode +from _pytest.pytester import Testdir from _pytest.reports import BaseReport from _pytest.terminal import _folded_skips from _pytest.terminal import _get_line_with_reprcrash_message @@ -1923,3 +1924,11 @@ def test_collecterror(testdir): "*= 1 error in *", ] ) + + +def test_via_exec(testdir: Testdir) -> None: + p1 = testdir.makepyfile("exec('def test_via_exec(): pass')") + result = testdir.runpytest(str(p1), "-vv") + result.stdout.fnmatch_lines( + ["test_via_exec.py::test_via_exec <- PASSED*", "*= 1 passed in *"] + ) From bf5c76359cf9dce7a94989d7f9edd4e22e6ffa3a Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sun, 26 Jan 2020 23:14:32 +0100 Subject: [PATCH 070/158] fixup! typing: tests: tmpfile --- src/_pytest/capture.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 33d2243b3..ccbeb0884 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -10,7 +10,7 @@ import sys from io import UnsupportedOperation from tempfile import TemporaryFile from typing import BinaryIO -from typing import List +from typing import Iterable import pytest from _pytest.compat import CaptureIO @@ -419,15 +419,15 @@ class EncodedFile: self.buffer = buffer self.encoding = encoding - def write(self, obj: str) -> int: - if not isinstance(obj, str): + def write(self, s: str) -> int: + if not isinstance(s, str): raise TypeError( - "write() argument must be str, not {}".format(type(obj).__name__) + "write() argument must be str, not {}".format(type(s).__name__) ) - return self.buffer.write(obj.encode(self.encoding, "replace")) + return self.buffer.write(s.encode(self.encoding, "replace")) - def writelines(self, linelist: List[str]) -> None: - self.buffer.writelines([x.encode(self.encoding, "replace") for x in linelist]) + def writelines(self, lines: Iterable[str]) -> None: + self.buffer.writelines(x.encode(self.encoding, "replace") for x in lines) @property def name(self) -> str: From c2980eb80ffc9a7ab81d1424afeac77ec46a2fe2 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sun, 26 Jan 2020 23:36:28 +0100 Subject: [PATCH 071/158] pytester: test for _makefile joining an absolute path Ref: https://github.com/pytest-dev/pytest/pull/6578#discussion_r371035867 --- testing/test_pytester.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 869e35db3..35a06e33a 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -710,3 +710,13 @@ def test_testdir_outcomes_with_multiple_errors(testdir): result.assert_outcomes(error=2) assert result.parseoutcomes() == {"error": 2} + + +def test_makefile_joins_absolute_path(testdir: Testdir) -> None: + absfile = testdir.tmpdir / "absfile" + if sys.platform == "win32": + with pytest.raises(OSError): + testdir.makepyfile(**{str(absfile): ""}) + else: + p1 = testdir.makepyfile(**{str(absfile): ""}) + assert str(p1) == (testdir.tmpdir / absfile) + ".py" From cbad31973641af61da06b9a2efc10a1eb6faf197 Mon Sep 17 00:00:00 2001 From: ParetoLife Date: Mon, 27 Jan 2020 10:50:05 +0100 Subject: [PATCH 072/158] Update getting-started.rst From the description it seemed to me as if just prefixing your methods with ``test_`` was enough, but you also need to prefix your class with ``Test``. Of course, in the reference material this is clearly stated, but I think it makes sense to mention it here as well, since you also mention the part about the methods' prefix. --- doc/en/getting-started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 59197d0d7..83b4677e9 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -127,7 +127,7 @@ Once you develop multiple tests, you may want to group them into a class. pytest x = "hello" assert hasattr(x, "check") -``pytest`` discovers all tests following its :ref:`Conventions for Python test discovery `, so it finds both ``test_`` prefixed functions. There is no need to subclass anything. We can simply run the module by passing its filename: +``pytest`` discovers all tests following its :ref:`Conventions for Python test discovery `, so it finds both ``test_`` prefixed functions. There is no need to subclass anything, but make sure to prefix your class with ``Test`` otherwise the class will be skipped. We can simply run the module by passing its filename: .. code-block:: pytest From ae5d16be10c139da6a21eab34f7decbf93af721b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 27 Jan 2020 20:57:32 +0100 Subject: [PATCH 073/158] typing: FSHookProxy/gethookproxy Taken out of https://github.com/pytest-dev/pytest/pull/6556. --- src/_pytest/main.py | 2 +- src/_pytest/nodes.py | 9 ++++++--- src/_pytest/python.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 066c885b8..e4eb4bbc8 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -436,7 +436,7 @@ class Session(nodes.FSCollector): def isinitpath(self, path): return path in self._initialpaths - def gethookproxy(self, fspath): + def gethookproxy(self, fspath: py.path.local): return super()._gethookproxy(fspath) def perform_collect(self, args=None, genitems=True): diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f9f1f4f68..5447f2541 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -19,6 +19,7 @@ from _pytest.compat import cached_property from _pytest.compat import getfslineno from _pytest.compat import TYPE_CHECKING from _pytest.config import Config +from _pytest.config import PytestPluginManager from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureLookupError from _pytest.fixtures import FixtureLookupErrorRepr @@ -394,12 +395,14 @@ def _check_initialpaths_for_relpath(session, fspath): class FSHookProxy: - def __init__(self, fspath, pm, remove_mods): + def __init__( + self, fspath: py.path.local, pm: PytestPluginManager, remove_mods + ) -> None: self.fspath = fspath self.pm = pm self.remove_mods = remove_mods - def __getattr__(self, name): + def __getattr__(self, name: str): x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) self.__dict__[name] = x return x @@ -431,7 +434,7 @@ class FSCollector(Collector): self._norecursepatterns = self.config.getini("norecursedirs") - def _gethookproxy(self, fspath): + def _gethookproxy(self, fspath: py.path.local): # check if we have the common case of running # hooks with all conftest.py files pm = self.config.pluginmanager diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 23a67d023..c30dbc477 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -578,7 +578,7 @@ class Package(Module): func = partial(_call_with_optional_argument, teardown_module, self.obj) self.addfinalizer(func) - def gethookproxy(self, fspath): + def gethookproxy(self, fspath: py.path.local): return super()._gethookproxy(fspath) def _collectfile(self, path, handle_dupes=True): From d017b69f38e20c9e87f3a8093947230a99dba2ac Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 27 Jan 2020 22:18:35 +0100 Subject: [PATCH 074/158] mypy: show_error_codes=True --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index bef9f7871..708951da4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,6 +65,7 @@ formats = sdist.tgz,bdist_wheel mypy_path = src ignore_missing_imports = True no_implicit_optional = True +show_error_codes = True strict_equality = True warn_redundant_casts = True warn_return_any = True From 440881d63a89595b2bb7e67e658e09c72d7ab21d Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 27 Jan 2020 00:54:20 +0100 Subject: [PATCH 075/158] typing: Testdir.__init__ --- src/_pytest/pytester.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 8e7fa5e09..3dfdc3ce4 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -35,6 +35,7 @@ from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import Path from _pytest.reports import TestReport +from _pytest.tmpdir import TempdirFactory if TYPE_CHECKING: from typing import Type @@ -534,13 +535,13 @@ class Testdir: class TimeoutExpired(Exception): pass - def __init__(self, request, tmpdir_factory): + def __init__(self, request: FixtureRequest, tmpdir_factory: TempdirFactory) -> None: self.request = request - self._mod_collections = WeakKeyDictionary() + self._mod_collections = WeakKeyDictionary() # type: ignore[var-annotated] # noqa: F821 name = request.function.__name__ self.tmpdir = tmpdir_factory.mktemp(name, numbered=True) self.test_tmproot = tmpdir_factory.mktemp("tmp-" + name, numbered=True) - self.plugins = [] + self.plugins = [] # type: ignore[var-annotated] # noqa: F821 self._cwd_snapshot = CwdSnapshot() self._sys_path_snapshot = SysPathsSnapshot() self._sys_modules_snapshot = self.__take_sys_modules_snapshot() From 94ac0f7e6b27df6675d1295bc5893b337855b3f6 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 27 Jan 2020 22:55:42 +0100 Subject: [PATCH 076/158] typing: self._mod_collections, collect_by_name --- src/_pytest/pytester.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 3dfdc3ce4..66f6701a0 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -33,7 +33,10 @@ from _pytest.fixtures import FixtureRequest from _pytest.main import ExitCode from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch +from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.pathlib import Path +from _pytest.python import Module from _pytest.reports import TestReport from _pytest.tmpdir import TempdirFactory @@ -537,7 +540,9 @@ class Testdir: def __init__(self, request: FixtureRequest, tmpdir_factory: TempdirFactory) -> None: self.request = request - self._mod_collections = WeakKeyDictionary() # type: ignore[var-annotated] # noqa: F821 + self._mod_collections = ( + WeakKeyDictionary() + ) # type: WeakKeyDictionary[Module, List[Union[Item, Collector]]] name = request.function.__name__ self.tmpdir = tmpdir_factory.mktemp(name, numbered=True) self.test_tmproot = tmpdir_factory.mktemp("tmp-" + name, numbered=True) @@ -1065,7 +1070,9 @@ class Testdir: self.config = config = self.parseconfigure(path, *configargs) return self.getnode(config, path) - def collect_by_name(self, modcol, name): + def collect_by_name( + self, modcol: Module, name: str + ) -> Optional[Union[Item, Collector]]: """Return the collection node for name from the module collection. This will search a module collection node for a collection node @@ -1074,13 +1081,13 @@ class Testdir: :param modcol: a module collection node; see :py:meth:`getmodulecol` :param name: the name of the node to return - """ if modcol not in self._mod_collections: self._mod_collections[modcol] = list(modcol.collect()) for colitem in self._mod_collections[modcol]: if colitem.name == name: return colitem + return None def popen( self, From 9c716e4d747900f80d4dab33185dc20d1f90f859 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 27 Jan 2020 22:56:50 +0100 Subject: [PATCH 077/158] typing: Testdir.plugins --- src/_pytest/config/__init__.py | 7 +++++++ src/_pytest/pytester.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 3516b333e..ed3334e5f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -48,6 +48,13 @@ if TYPE_CHECKING: from typing import Type +_PluggyPlugin = object +"""A type to represent plugin objects. +Plugins can be any namespace, so we can't narrow it down much, but we use an +alias to make the intent clear. +Ideally this type would be provided by pluggy itself.""" + + hookimpl = HookimplMarker("pytest") hookspec = HookspecMarker("pytest") diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 66f6701a0..cfe1b9a6c 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -29,6 +29,7 @@ from _pytest._io.saferepr import saferepr from _pytest.capture import MultiCapture from _pytest.capture import SysCapture from _pytest.compat import TYPE_CHECKING +from _pytest.config import _PluggyPlugin from _pytest.fixtures import FixtureRequest from _pytest.main import ExitCode from _pytest.main import Session @@ -546,7 +547,7 @@ class Testdir: name = request.function.__name__ self.tmpdir = tmpdir_factory.mktemp(name, numbered=True) self.test_tmproot = tmpdir_factory.mktemp("tmp-" + name, numbered=True) - self.plugins = [] # type: ignore[var-annotated] # noqa: F821 + self.plugins = [] # type: List[Union[str, _PluggyPlugin]] self._cwd_snapshot = CwdSnapshot() self._sys_path_snapshot = SysPathsSnapshot() self._sys_modules_snapshot = self.__take_sys_modules_snapshot() From ad0f4f0ac056e690b4c91c1e04ff47e40110b293 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 00:41:27 +0100 Subject: [PATCH 078/158] tests: cover collect_by_name with non-existing --- testing/test_collection.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/testing/test_collection.py b/testing/test_collection.py index 62de0b953..5073f675e 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -9,6 +9,7 @@ import pytest from _pytest.main import _in_venv from _pytest.main import ExitCode from _pytest.main import Session +from _pytest.pytester import Testdir class TestCollector: @@ -18,7 +19,7 @@ class TestCollector: assert not issubclass(Collector, Item) assert not issubclass(Item, Collector) - def test_check_equality(self, testdir): + def test_check_equality(self, testdir: Testdir) -> None: modcol = testdir.getmodulecol( """ def test_pass(): pass @@ -40,12 +41,15 @@ class TestCollector: assert fn1 != fn3 for fn in fn1, fn2, fn3: - assert fn != 3 + assert isinstance(fn, pytest.Function) + assert fn != 3 # type: ignore[comparison-overlap] # noqa: F821 assert fn != modcol - assert fn != [1, 2, 3] - assert [1, 2, 3] != fn + assert fn != [1, 2, 3] # type: ignore[comparison-overlap] # noqa: F821 + assert [1, 2, 3] != fn # type: ignore[comparison-overlap] # noqa: F821 assert modcol != fn + assert testdir.collect_by_name(modcol, "doesnotexist") is None + def test_getparent(self, testdir): modcol = testdir.getmodulecol( """ From 35ba053f00e4f8a85ed76cde67f87af35c598528 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 01:23:44 +0100 Subject: [PATCH 079/158] typing: ignore false positive with more-itertools Fixed in https://github.com/erikrose/more-itertools/pull/374. --- src/_pytest/python_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 5e5eddc5b..24145016c 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -685,7 +685,7 @@ def raises( # noqa: F811 """ __tracebackhide__ = True for exc in filterfalse( - inspect.isclass, always_iterable(expected_exception, BASE_TYPE) + inspect.isclass, always_iterable(expected_exception, BASE_TYPE) # type: ignore[arg-type] # noqa: F821 ): msg = "exceptions must be derived from BaseException, not %s" raise TypeError(msg % type(exc)) From 12c5a6af64f3380974cb665b7d252c20d54de95d Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 01:49:45 +0100 Subject: [PATCH 080/158] typing: fix Code.path Fixes: > src/_pytest/_code/code.py:83: error: Incompatible types in assignment > (expression has type "str", variable has type "local") [assignment] --- src/_pytest/_code/code.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 94ad4292e..b176dde98 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -77,12 +77,11 @@ class Code: # maybe don't try this checking if not p.check(): raise OSError("py.path check failed.") + return p except OSError: # XXX maybe try harder like the weird logic # in the standard lib [linecache.updatecache] does? - p = self.raw.co_filename - - return p + return self.raw.co_filename @property def fullsource(self) -> Optional["Source"]: From b01e3794281468ff9f80f7e445d525d675e70dab Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 15:52:46 +0100 Subject: [PATCH 081/158] tests: harden test_teardown_many_verbose --- testing/test_terminal.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index f284ad577..c3a0c17e1 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -33,6 +33,8 @@ COLORS = { } RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()} +TRANS_FNMATCH = str.maketrans({"[": "[[]", "]": "[]]"}) + class Option: def __init__(self, verbosity=0): @@ -1772,14 +1774,19 @@ class TestProgressWithTeardown: [r"test_bar.py (\.E){5}\s+\[ 25%\]", r"test_foo.py (\.E){15}\s+\[100%\]"] ) - def test_teardown_many_verbose(self, testdir, many_files): - output = testdir.runpytest("-v") - output.stdout.re_match_lines( + def test_teardown_many_verbose(self, testdir: Testdir, many_files) -> None: + result = testdir.runpytest("-v") + result.stdout.fnmatch_lines( [ - r"test_bar.py::test_bar\[0\] PASSED\s+\[ 5%\]", - r"test_bar.py::test_bar\[0\] ERROR\s+\[ 5%\]", - r"test_bar.py::test_bar\[4\] PASSED\s+\[ 25%\]", - r"test_bar.py::test_bar\[4\] ERROR\s+\[ 25%\]", + line.translate(TRANS_FNMATCH) + for line in [ + "test_bar.py::test_bar[0] PASSED * [ 5%]", + "test_bar.py::test_bar[0] ERROR * [ 5%]", + "test_bar.py::test_bar[4] PASSED * [ 25%]", + "test_foo.py::test_foo[14] PASSED * [100%]", + "test_foo.py::test_foo[14] ERROR * [100%]", + "=* 20 passed, 20 errors in *", + ] ] ) From b2e6f66438dd4a2d8e794a02810fb54241505f0f Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 00:56:13 +0100 Subject: [PATCH 082/158] ci: GHA: run less jobs with coverage This often might be causing for jobs to take longer than 10 minutes, which is a timeout Codecov uses to wait for successful CI. Also it is good in general to have CI finish faster, of course. --- .github/workflows/main.yml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c55316874..242c2eb83 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,6 +52,7 @@ jobs: python: "3.5" os: windows-latest tox_env: "py35-xdist" + use_coverage: true - name: "windows-py36" python: "3.6" os: windows-latest @@ -68,6 +69,7 @@ jobs: python: "3.8" os: windows-latest tox_env: "py38" + use_coverage: true - name: "ubuntu-py35" python: "3.5" @@ -81,6 +83,7 @@ jobs: python: "3.7" os: ubuntu-latest tox_env: "py37-lsof-numpy-oldattrs-pexpect-twisted" + use_coverage: true - name: "ubuntu-py37-pluggy" python: "3.7" os: ubuntu-latest @@ -89,8 +92,6 @@ jobs: python: "3.7" os: ubuntu-latest tox_env: "py37-freeze" - # coverage does not apply for freeze test, skip it - skip_coverage: true - name: "ubuntu-py38" python: "3.8" os: ubuntu-latest @@ -99,8 +100,6 @@ jobs: python: "pypy3" os: ubuntu-latest tox_env: "pypy3-xdist" - # coverage too slow with pypy3, skip it - skip_coverage: true - name: "macos-py37" python: "3.7" @@ -110,21 +109,21 @@ jobs: python: "3.8" os: macos-latest tox_env: "py38-xdist" + use_coverage: true - name: "linting" python: "3.7" os: ubuntu-latest tox_env: "linting" - skip_coverage: true - name: "docs" python: "3.7" os: ubuntu-latest tox_env: "docs" - skip_coverage: true - name: "doctesting" python: "3.7" os: ubuntu-latest tox_env: "doctesting" + use_coverage: true steps: - uses: actions/checkout@v1 @@ -138,11 +137,11 @@ jobs: pip install tox coverage - name: Test without coverage - if: "matrix.skip_coverage" + if: "! matrix.use_coverage" run: "tox -e ${{ matrix.tox_env }}" - name: Test with coverage - if: "! matrix.skip_coverage" + if: "matrix.use_coverage" env: _PYTEST_TOX_COVERAGE_RUN: "coverage run -m" COVERAGE_PROCESS_START: ".coveragerc" @@ -150,12 +149,12 @@ jobs: run: "tox -e ${{ matrix.tox_env }}" - name: Prepare coverage token - if: (!matrix.skip_coverage && ( github.repository == 'pytest-dev/pytest' || github.event_name == 'pull_request' )) + if: (matrix.use_coverage && ( github.repository == 'pytest-dev/pytest' || github.event_name == 'pull_request' )) run: | python scripts/append_codecov_token.py - name: Report coverage - if: (!matrix.skip_coverage) + if: (matrix.use_coverage) env: CODECOV_NAME: ${{ matrix.name }} run: bash scripts/report-coverage.sh -F GHA,${{ runner.os }} From 1cf9e68dbce51f512f3ed9197a8cdc67a9171ded Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 18:39:47 +0100 Subject: [PATCH 083/158] tests: cover absolute path handling in _compute_fixture_value --- src/_pytest/fixtures.py | 5 +++-- testing/python/fixtures.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 464828de4..d70b2c64a 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -550,8 +550,9 @@ class FixtureRequest: source_path = frameinfo.filename source_lineno = frameinfo.lineno source_path = py.path.local(source_path) - if source_path.relto(funcitem.config.rootdir): - source_path_str = source_path.relto(funcitem.config.rootdir) + rel_source_path = source_path.relto(funcitem.config.rootdir) + if rel_source_path: + source_path_str = rel_source_path else: source_path_str = str(source_path) msg = ( diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 8cfaae50d..d9ea5cf58 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -3662,13 +3662,30 @@ class TestParameterizedSubRequest: " test_foos.py::test_foo", "", "Requested fixture 'fix_with_param' defined in:", - "*fix.py:4", + "{}:4".format(fixfile), "Requested here:", "test_foos.py:4", "*1 failed*", ] ) + # With non-overlapping rootdir, passing tests_dir. + rootdir = testdir.mkdir("rootdir") + rootdir.chdir() + result = testdir.runpytest("--rootdir", rootdir, tests_dir) + result.stdout.fnmatch_lines( + [ + "The requested fixture has no parameter defined for test:", + " test_foos.py::test_foo", + "", + "Requested fixture 'fix_with_param' defined in:", + "{}:4".format(fixfile), + "Requested here:", + "{}:4".format(testfile), + "*1 failed*", + ] + ) + def test_pytest_fixture_setup_and_post_finalizer_hook(testdir): testdir.makeconftest( From 7c878742773d50e3bc04d1a5b0a786ce69562847 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 19:02:35 +0100 Subject: [PATCH 084/158] source_path: py.path.local directly Via bc7282576. --- src/_pytest/fixtures.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index d70b2c64a..5d54078de 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -547,9 +547,8 @@ class FixtureRequest: if has_params: frame = inspect.stack()[3] frameinfo = inspect.getframeinfo(frame[0]) - source_path = frameinfo.filename + source_path = py.path.local(frameinfo.filename) source_lineno = frameinfo.lineno - source_path = py.path.local(source_path) rel_source_path = source_path.relto(funcitem.config.rootdir) if rel_source_path: source_path_str = rel_source_path From e25d46aae621fb8b294aeab98dfcc81f051c5f77 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 21:44:12 +0100 Subject: [PATCH 085/158] typing: MonkeyPatch.context --- src/_pytest/monkeypatch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 090bf61d6..8aec7b818 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -4,6 +4,7 @@ import re import sys import warnings from contextlib import contextmanager +from typing import Generator import pytest from _pytest.fixtures import fixture @@ -108,7 +109,7 @@ class MonkeyPatch: self._savesyspath = None @contextmanager - def context(self): + def context(self) -> Generator["MonkeyPatch", None, None]: """ Context manager that returns a new :class:`MonkeyPatch` object which undoes any patching done inside the ``with`` block upon exit: From 80d4dd6f0bf289194f540f4ab6d6cf96776b8072 Mon Sep 17 00:00:00 2001 From: Holger Kohr Date: Sat, 25 Jan 2020 20:28:00 +0100 Subject: [PATCH 086/158] Replace `==` with `is` for comparison of cache keys Closes #6497 --- AUTHORS | 1 + changelog/6497.bugfix.rst | 4 ++++ src/_pytest/fixtures.py | 4 +++- testing/python/fixtures.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 changelog/6497.bugfix.rst diff --git a/AUTHORS b/AUTHORS index aa2237c68..a3200f774 100644 --- a/AUTHORS +++ b/AUTHORS @@ -112,6 +112,7 @@ Guido Wesdorp Guoqiang Zhang Harald Armin Massa Henk-Jaap Wagenaar +Holger Kohr Hugo van Kemenade Hui Wang (coldnight) Ian Bicking diff --git a/changelog/6497.bugfix.rst b/changelog/6497.bugfix.rst new file mode 100644 index 000000000..66d436abd --- /dev/null +++ b/changelog/6497.bugfix.rst @@ -0,0 +1,4 @@ +Fix bug in the comparison of request key with cached key in fixture. + +A construct ``if key == cached_key:`` can fail either because ``==`` is explicitly disallowed, or for, e.g., NumPy arrays, where the result of ``a == b`` cannot generally be converted to `bool`. +The implemented fix replaces `==` with ``is``. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 464828de4..bd8472e36 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -898,7 +898,9 @@ class FixtureDef: cached_result = getattr(self, "cached_result", None) if cached_result is not None: result, cache_key, err = cached_result - if my_cache_key == cache_key: + # note: comparison with `==` can fail (or be expensive) for e.g. + # numpy arrays (#6497) + if my_cache_key is cache_key: if err is not None: _, val, tb = err raise val.with_traceback(tb) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 8cfaae50d..c72437ed5 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1102,6 +1102,38 @@ class TestFixtureUsages: "*Fixture 'badscope' from test_invalid_scope.py got an unexpected scope value 'functions'" ) + @pytest.mark.parametrize("scope", ["function", "session"]) + def test_parameters_without_eq_semantics(self, scope, testdir): + testdir.makepyfile( + """ + class NoEq1: # fails on `a == b` statement + def __eq__(self, _): + raise RuntimeError + + class NoEq2: # fails on `if a == b:` statement + def __eq__(self, _): + class NoBool: + def __bool__(self): + raise RuntimeError + return NoBool() + + import pytest + @pytest.fixture(params=[NoEq1(), NoEq2()], scope={scope!r}) + def no_eq(request): + return request.param + + def test1(no_eq): + pass + + def test2(no_eq): + pass + """.format( + scope=scope + ) + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*4 passed*"]) + def test_funcarg_parametrized_and_used_twice(self, testdir): testdir.makepyfile( """ From abd5fc80e84b79d38e2cb622c7124155b0931ddd Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Tue, 28 Jan 2020 13:27:54 -0800 Subject: [PATCH 087/158] Fix node ids which contain a parametrized empty-string variable --- changelog/6597.bugfix.rst | 1 + src/_pytest/python.py | 2 +- testing/python/collect.py | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 changelog/6597.bugfix.rst diff --git a/changelog/6597.bugfix.rst b/changelog/6597.bugfix.rst new file mode 100644 index 000000000..e5af13a0b --- /dev/null +++ b/changelog/6597.bugfix.rst @@ -0,0 +1 @@ +Fix node ids which contain a parametrized empty-string variable. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index de2a93344..d8eb30077 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -873,7 +873,7 @@ class CallSpec2: @property def id(self): - return "-".join(map(str, filter(None, self._idlist))) + return "-".join(map(str, self._idlist)) def setmulti2(self, valtypes, argnames, valset, id, marks, scopenum, param_index): for arg, val in zip(argnames, valset): diff --git a/testing/python/collect.py b/testing/python/collect.py index b30921fe3..62b61accf 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -492,6 +492,19 @@ class TestFunction: ) assert "foo" in keywords[1] and "bar" in keywords[1] and "baz" in keywords[1] + def test_parametrize_with_empty_string_arguments(self, testdir): + items = testdir.getitems( + """\ + import pytest + + @pytest.mark.parametrize('v', ('', ' ')) + @pytest.mark.parametrize('w', ('', ' ')) + def test(v, w): ... + """ + ) + names = {item.name for item in items} + assert names == {"test[-]", "test[ -]", "test[- ]", "test[ - ]"} + def test_function_equality_with_callspec(self, testdir): items = testdir.getitems( """ From ddaa5d88aca31ba87383840aff394695088bc9c3 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 21 Jan 2020 13:39:34 +0100 Subject: [PATCH 088/158] terminal: default to `fE` with `-r` (reportchars) Adds handling of `N` to reset `reportchars`, which can be used to get the old behavior (`-rN`), and also allows for an alternative to `--disable-warnings` (https://github.com/pytest-dev/pytest/issues/5066), since `w` was included by default (without `--disable-warnings`). Fixes https://github.com/pytest-dev/pytest/issues/6454 --- changelog/6454.bugfix.rst | 1 + changelog/6454.feature.rst | 1 + doc/en/usage.rst | 8 +++-- src/_pytest/terminal.py | 32 +++++++++++-------- testing/test_terminal.py | 63 ++++++++++++++++++++++++++------------ 5 files changed, 71 insertions(+), 34 deletions(-) create mode 100644 changelog/6454.bugfix.rst create mode 100644 changelog/6454.feature.rst diff --git a/changelog/6454.bugfix.rst b/changelog/6454.bugfix.rst new file mode 100644 index 000000000..370b08b01 --- /dev/null +++ b/changelog/6454.bugfix.rst @@ -0,0 +1 @@ +`--disable-warnings` is honored with `-ra` and `-rA`. diff --git a/changelog/6454.feature.rst b/changelog/6454.feature.rst new file mode 100644 index 000000000..6cb5a4c79 --- /dev/null +++ b/changelog/6454.feature.rst @@ -0,0 +1 @@ +Changed default for `-r` to `fE`, which displays failures and errors in the :ref:`short test summary `. `-rN` can be used to disable it (the old behavior). diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 527794823..ff8a1748f 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -169,11 +169,11 @@ option you make sure a trace is shown. Detailed summary report ----------------------- - - The ``-r`` flag can be used to display a "short test summary info" at the end of the test session, making it easy in large test suites to get a clear picture of all failures, skips, xfails, etc. +It defaults to ``fE`` to list failures and errors. + Example: .. code-block:: python @@ -261,8 +261,12 @@ Here is the full list of available characters that can be used: - ``X`` - xpassed - ``p`` - passed - ``P`` - passed with output + +Special characters for (de)selection of groups: + - ``a`` - all except ``pP`` - ``A`` - all + - ``N`` - none, this can be used to display nothing (since ``fE`` is the default) More than one character can be used, so for example to only see failed and skipped tests, you can execute: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 395a9ff8a..1bfd2f91d 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -33,6 +33,8 @@ from _pytest.reports import TestReport REPORT_COLLECTING_RESOLUTION = 0.5 +_REPORTCHARS_DEFAULT = "fE" + class MoreQuietAction(argparse.Action): """ @@ -88,12 +90,13 @@ def pytest_addoption(parser): "-r", action="store", dest="reportchars", - default="", + default=_REPORTCHARS_DEFAULT, metavar="chars", help="show extra test summary info as specified by chars: (f)ailed, " "(E)rror, (s)kipped, (x)failed, (X)passed, " "(p)assed, (P)assed with output, (a)ll except passed (p/P), or (A)ll. " - "(w)arnings are enabled by default (see --disable-warnings).", + "(w)arnings are enabled by default (see --disable-warnings), " + "'N' can be used to reset the list. (default: 'fE').", ) group._addoption( "--disable-warnings", @@ -166,24 +169,27 @@ def pytest_configure(config: Config) -> None: def getreportopt(config: Config) -> str: - reportopts = "" reportchars = config.option.reportchars - if not config.option.disable_warnings and "w" not in reportchars: - reportchars += "w" - elif config.option.disable_warnings and "w" in reportchars: - reportchars = reportchars.replace("w", "") - aliases = {"F", "S"} + + old_aliases = {"F", "S"} + reportopts = "" for char in reportchars: - # handle old aliases - if char in aliases: + if char in old_aliases: char = char.lower() if char == "a": - reportopts = "sxXwEf" + reportopts = "sxXEf" elif char == "A": - reportopts = "PpsxXwEf" - break + reportopts = "PpsxXEf" + elif char == "N": + reportopts = "" elif char not in reportopts: reportopts += char + + if not config.option.disable_warnings and "w" not in reportopts: + reportopts = "w" + reportopts + elif config.option.disable_warnings and "w" in reportopts: + reportopts = reportopts.replace("w", "") + return reportopts diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 88ee75493..7bdb29112 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -813,9 +813,9 @@ class TestTerminalFunctional: def test_fail_extra_reporting(testdir, monkeypatch): monkeypatch.setenv("COLUMNS", "80") testdir.makepyfile("def test_this(): assert 0, 'this_failed' * 100") - result = testdir.runpytest() + result = testdir.runpytest("-rN") result.stdout.no_fnmatch_line("*short test summary*") - result = testdir.runpytest("-rf") + result = testdir.runpytest() result.stdout.fnmatch_lines( [ "*test summary*", @@ -984,37 +984,62 @@ def test_color_yes_collection_on_non_atty(testdir, verbose): def test_getreportopt(): + from _pytest.terminal import _REPORTCHARS_DEFAULT + class Config: class Option: - reportchars = "" - disable_warnings = True + reportchars = _REPORTCHARS_DEFAULT + disable_warnings = False option = Option() config = Config() + assert _REPORTCHARS_DEFAULT == "fE" + + # Default. + assert getreportopt(config) == "wfE" + config.option.reportchars = "sf" - assert getreportopt(config) == "sf" + assert getreportopt(config) == "wsf" + + config.option.reportchars = "sfxw" + assert getreportopt(config) == "sfxw" + + config.option.reportchars = "a" + assert getreportopt(config) == "wsxXEf" + + config.option.reportchars = "N" + assert getreportopt(config) == "w" + + config.option.reportchars = "NwfE" + assert getreportopt(config) == "wfE" + + config.option.reportchars = "NfENx" + assert getreportopt(config) == "wx" + + # Now with --disable-warnings. + config.option.disable_warnings = True + config.option.reportchars = "a" + assert getreportopt(config) == "sxXEf" + + config.option.reportchars = "sfx" + assert getreportopt(config) == "sfx" config.option.reportchars = "sfxw" assert getreportopt(config) == "sfx" - # Now with --disable-warnings. - config.option.disable_warnings = False config.option.reportchars = "a" - assert getreportopt(config) == "sxXwEf" # NOTE: "w" included! - - config.option.reportchars = "sfx" - assert getreportopt(config) == "sfxw" - - config.option.reportchars = "sfxw" - assert getreportopt(config) == "sfxw" - - config.option.reportchars = "a" - assert getreportopt(config) == "sxXwEf" # NOTE: "w" included! + assert getreportopt(config) == "sxXEf" config.option.reportchars = "A" - assert getreportopt(config) == "PpsxXwEf" + assert getreportopt(config) == "PpsxXEf" + + config.option.reportchars = "AN" + assert getreportopt(config) == "" + + config.option.reportchars = "NwfE" + assert getreportopt(config) == "fE" def test_terminalreporter_reportopt_addopts(testdir): @@ -1131,7 +1156,7 @@ class TestGenericReporting: ) for tbopt in ["long", "short", "no"]: print("testing --tb=%s..." % tbopt) - result = testdir.runpytest("--tb=%s" % tbopt) + result = testdir.runpytest("-rN", "--tb=%s" % tbopt) s = result.stdout.str() if tbopt == "long": assert "print(6*7)" in s From a3f482cebab103bed8e8b10abb343753865905ee Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 29 Jan 2020 01:06:23 +0100 Subject: [PATCH 089/158] tests: move test_getfslineno It should be in `test_code` when testing `_pytest._code.getfslineno`, not to be confused with `_pytest._code.source.getfslineno`. Adds an extra assert (via https://github.com/pytest-dev/pytest/pull/6590). --- testing/code/test_code.py | 33 +++++++++++++++++++++++++++++++++ testing/code/test_source.py | 30 ------------------------------ 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/testing/code/test_code.py b/testing/code/test_code.py index f8e1ce17f..31977c719 100644 --- a/testing/code/test_code.py +++ b/testing/code/test_code.py @@ -1,9 +1,13 @@ +import inspect import sys from types import FrameType from unittest import mock +import py.path + import _pytest._code import pytest +from _pytest._code import getfslineno def test_ne() -> None: @@ -179,3 +183,32 @@ class TestReprFuncArgs: tw_mock.lines[0] == r"unicode_string = São Paulo, utf8_string = b'S\xc3\xa3o Paulo'" ) + + +def test_getfslineno() -> None: + def f(x) -> None: + pass + + fspath, lineno = getfslineno(f) + + assert isinstance(fspath, py.path.local) + assert fspath.basename == "test_code.py" + assert lineno == f.__code__.co_firstlineno - 1 # see findsource + + class A: + pass + + fspath, lineno = getfslineno(A) + + _, A_lineno = inspect.findsource(A) + assert isinstance(fspath, py.path.local) + assert fspath.basename == "test_code.py" + assert lineno == A_lineno + + assert getfslineno(3) == ("", -1) + + class B: + pass + + B.__name__ = "B2" + assert getfslineno(B)[1] == -1 diff --git a/testing/code/test_source.py b/testing/code/test_source.py index 030e60676..d769d4d77 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -495,36 +495,6 @@ def test_findsource() -> None: assert src[lineno] == " def x():" -def test_getfslineno() -> None: - from _pytest._code import getfslineno - - def f(x) -> None: - pass - - fspath, lineno = getfslineno(f) - - assert isinstance(fspath, py.path.local) - assert fspath.basename == "test_source.py" - assert lineno == f.__code__.co_firstlineno - 1 # see findsource - - class A: - pass - - fspath, lineno = getfslineno(A) - - _, A_lineno = inspect.findsource(A) - assert fspath.basename == "test_source.py" - assert lineno == A_lineno - - assert getfslineno(3) == ("", -1) - - class B: - pass - - B.__name__ = "B2" - assert getfslineno(B)[1] == -1 - - def test_code_of_object_instance_with_call() -> None: class A: pass From 3f4b8d3aec5b3605e48ac2b895ddb9b2cbea7163 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 29 Jan 2020 02:54:12 +0100 Subject: [PATCH 090/158] test_code: improve coverage --- testing/code/test_code.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/code/test_code.py b/testing/code/test_code.py index 31977c719..69b53d340 100644 --- a/testing/code/test_code.py +++ b/testing/code/test_code.py @@ -187,7 +187,7 @@ class TestReprFuncArgs: def test_getfslineno() -> None: def f(x) -> None: - pass + raise NotImplementedError() fspath, lineno = getfslineno(f) From d848a205631d1c44d8b6f6d79fd25341abe8bcd6 Mon Sep 17 00:00:00 2001 From: sdementen Date: Mon, 27 Jan 2020 10:45:17 +0100 Subject: [PATCH 091/158] Extend the incremental marker for parametrize The incremental marker is adapted to handle properly test classes with parametrize defined at class level. Fix #3125 --- doc/en/example/simple.rst | 40 +++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 1570850fc..1a5c5b444 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -461,21 +461,49 @@ an ``incremental`` marker which is to be used on classes: # content of conftest.py - import pytest + # store history of failures per test class name and per index in parametrize (if parametrize used) + _test_failed_incremental: Dict[str, Dict[Tuple[int, ...], str]] = {} def pytest_runtest_makereport(item, call): if "incremental" in item.keywords: + # incremental marker is used if call.excinfo is not None: - parent = item.parent - parent._previousfailed = item + # the test has failed + # retrieve the class name of the test + cls_name = str(item.cls) + # retrieve the index of the test (if parametrize is used in combination with incremental) + parametrize_index = ( + tuple(item.callspec.indices.values()) + if hasattr(item, "callspec") + else () + ) + # retrieve the name of the test function + test_name = item.originalname or item.name + # store in _test_failed_incremental the original name of the failed test + _test_failed_incremental.setdefault(cls_name, {}).setdefault( + parametrize_index, test_name + ) def pytest_runtest_setup(item): if "incremental" in item.keywords: - previousfailed = getattr(item.parent, "_previousfailed", None) - if previousfailed is not None: - pytest.xfail("previous test failed ({})".format(previousfailed.name)) + # retrieve the class name of the test + cls_name = str(item.cls) + # check if a previous test has failed for this class + if cls_name in _test_failed_incremental: + # retrieve the index of the test (if parametrize is used in combination with incremental) + parametrize_index = ( + tuple(item.callspec.indices.values()) + if hasattr(item, "callspec") + else () + ) + # retrieve the name of the first test function to fail for this class name and index + test_name = _test_failed_incremental[cls_name].get(parametrize_index, None) + # if name found, test has failed for the combination of class name & test name + if test_name is not None: + pytest.xfail("previous test failed ({})".format(test_name)) + These two hook implementations work together to abort incremental-marked tests in a class. Here is a test module example: From d478e2bbcaefa0f5b193a201dca29bae8c552a91 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 29 Jan 2020 18:28:11 +0100 Subject: [PATCH 092/158] doc: release-5.3.4 (cherry picked from commit fd1a51a23fa687cf344f3506dff6cde0166faf2c) --- doc/en/announce/index.rst | 1 + doc/en/announce/release-5.3.5.rst | 19 +++++++++++++++++++ doc/en/changelog.rst | 9 +++++++++ 3 files changed, 29 insertions(+) create mode 100644 doc/en/announce/release-5.3.5.rst diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index fb17b8e93..a35039587 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-5.3.5 release-5.3.4 release-5.3.3 release-5.3.2 diff --git a/doc/en/announce/release-5.3.5.rst b/doc/en/announce/release-5.3.5.rst new file mode 100644 index 000000000..46095339f --- /dev/null +++ b/doc/en/announce/release-5.3.5.rst @@ -0,0 +1,19 @@ +pytest-5.3.5 +======================================= + +pytest 5.3.5 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Daniel Hahler +* Ran Benita + + +Happy testing, +The pytest Development Team diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index e0a2495cc..f0ad2b8ec 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -28,6 +28,15 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 5.3.5 (2020-01-29) +========================= + +Bug Fixes +--------- + +- `#6517 `_: Fix regression in pytest 5.3.4 causing an INTERNALERROR due to a wrong assertion. + + pytest 5.3.4 (2020-01-20) ========================= From 3dbc61dd80bb22a0e52aacd7be3d988cfd72263b Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 29 Jan 2020 01:20:45 +0100 Subject: [PATCH 093/158] tests: test_code: improve/clarify imports --- testing/code/test_code.py | 47 ++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/testing/code/test_code.py b/testing/code/test_code.py index 69b53d340..5d35c19ab 100644 --- a/testing/code/test_code.py +++ b/testing/code/test_code.py @@ -5,15 +5,18 @@ from unittest import mock import py.path -import _pytest._code import pytest +from _pytest._code import Code +from _pytest._code import ExceptionInfo +from _pytest._code import Frame from _pytest._code import getfslineno +from _pytest._code.code import ReprFuncArgs def test_ne() -> None: - code1 = _pytest._code.Code(compile('foo = "bar"', "", "exec")) + code1 = Code(compile('foo = "bar"', "", "exec")) assert code1 == code1 - code2 = _pytest._code.Code(compile('foo = "baz"', "", "exec")) + code2 = Code(compile('foo = "baz"', "", "exec")) assert code2 != code1 @@ -21,7 +24,7 @@ def test_code_gives_back_name_for_not_existing_file() -> None: name = "abc-123" co_code = compile("pass\n", name, "exec") assert co_code.co_filename == name - code = _pytest._code.Code(co_code) + code = Code(co_code) assert str(code.path) == name assert code.fullsource is None @@ -30,7 +33,7 @@ def test_code_with_class() -> None: class A: pass - pytest.raises(TypeError, _pytest._code.Code, A) + pytest.raises(TypeError, Code, A) def x() -> None: @@ -38,13 +41,13 @@ def x() -> None: def test_code_fullsource() -> None: - code = _pytest._code.Code(x) + code = Code(x) full = code.fullsource assert "test_code_fullsource()" in str(full) def test_code_source() -> None: - code = _pytest._code.Code(x) + code = Code(x) src = code.source() expected = """def x() -> None: raise NotImplementedError()""" @@ -55,7 +58,7 @@ def test_frame_getsourcelineno_myself() -> None: def func() -> FrameType: return sys._getframe(0) - f = _pytest._code.Frame(func()) + f = Frame(func()) source, lineno = f.code.fullsource, f.lineno assert source is not None assert source[lineno].startswith(" return sys._getframe(0)") @@ -65,13 +68,13 @@ def test_getstatement_empty_fullsource() -> None: def func() -> FrameType: return sys._getframe(0) - f = _pytest._code.Frame(func()) + f = Frame(func()) with mock.patch.object(f.code.__class__, "fullsource", None): assert f.statement == "" def test_code_from_func() -> None: - co = _pytest._code.Code(test_frame_getsourcelineno_myself) + co = Code(test_frame_getsourcelineno_myself) assert co.firstlineno assert co.path @@ -90,25 +93,25 @@ def test_code_getargs() -> None: def f1(x): raise NotImplementedError() - c1 = _pytest._code.Code(f1) + c1 = Code(f1) assert c1.getargs(var=True) == ("x",) def f2(x, *y): raise NotImplementedError() - c2 = _pytest._code.Code(f2) + c2 = Code(f2) assert c2.getargs(var=True) == ("x", "y") def f3(x, **z): raise NotImplementedError() - c3 = _pytest._code.Code(f3) + c3 = Code(f3) assert c3.getargs(var=True) == ("x", "z") def f4(x, *y, **z): raise NotImplementedError() - c4 = _pytest._code.Code(f4) + c4 = Code(f4) assert c4.getargs(var=True) == ("x", "y", "z") @@ -116,25 +119,25 @@ def test_frame_getargs() -> None: def f1(x) -> FrameType: return sys._getframe(0) - fr1 = _pytest._code.Frame(f1("a")) + fr1 = Frame(f1("a")) assert fr1.getargs(var=True) == [("x", "a")] def f2(x, *y) -> FrameType: return sys._getframe(0) - fr2 = _pytest._code.Frame(f2("a", "b", "c")) + fr2 = Frame(f2("a", "b", "c")) assert fr2.getargs(var=True) == [("x", "a"), ("y", ("b", "c"))] def f3(x, **z) -> FrameType: return sys._getframe(0) - fr3 = _pytest._code.Frame(f3("a", b="c")) + fr3 = Frame(f3("a", b="c")) assert fr3.getargs(var=True) == [("x", "a"), ("z", {"b": "c"})] def f4(x, *y, **z) -> FrameType: return sys._getframe(0) - fr4 = _pytest._code.Frame(f4("a", "b", c="d")) + fr4 = Frame(f4("a", "b", c="d")) assert fr4.getargs(var=True) == [("x", "a"), ("y", ("b",)), ("z", {"c": "d"})] @@ -146,12 +149,12 @@ class TestExceptionInfo: else: assert False except AssertionError: - exci = _pytest._code.ExceptionInfo.from_current() + exci = ExceptionInfo.from_current() assert exci.getrepr() def test_from_current_with_missing(self) -> None: with pytest.raises(AssertionError, match="no current exception"): - _pytest._code.ExceptionInfo.from_current() + ExceptionInfo.from_current() class TestTracebackEntry: @@ -162,7 +165,7 @@ class TestTracebackEntry: else: assert False except AssertionError: - exci = _pytest._code.ExceptionInfo.from_current() + exci = ExceptionInfo.from_current() entry = exci.traceback[0] source = entry.getsource() assert source is not None @@ -172,8 +175,6 @@ class TestTracebackEntry: class TestReprFuncArgs: def test_not_raise_exception_with_mixed_encoding(self, tw_mock) -> None: - from _pytest._code.code import ReprFuncArgs - args = [("unicode_string", "São Paulo"), ("utf8_string", b"S\xc3\xa3o Paulo")] r = ReprFuncArgs(args) From 97f16459932b9337c101dc61f64bce9d62faaa75 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 29 Jan 2020 21:56:01 +0200 Subject: [PATCH 094/158] Don't wrap the markdown for GitHub releases --- scripts/publish-gh-release-notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/publish-gh-release-notes.py b/scripts/publish-gh-release-notes.py index f8d8b3986..583b5bfc7 100644 --- a/scripts/publish-gh-release-notes.py +++ b/scripts/publish-gh-release-notes.py @@ -61,7 +61,7 @@ def parse_changelog(tag_name): def convert_rst_to_md(text): - return pypandoc.convert_text(text, "md", format="rst") + return pypandoc.convert_text(text, "md", format="rst", extra_args=["--wrap=none"]) def main(argv): From 78eddcb5b10d652d757c9a80cba642d89aa56f11 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 29 Jan 2020 22:43:22 +0100 Subject: [PATCH 095/158] tests: move test_getfslineno back Reverts https://github.com/pytest-dev/pytest/pull/6610. The tested `getfslineno` is `src/_pytest/_code/source.py` actually, exported via `src/_pytest/_code/__init__.py`. I've confused it with the one in `src/_pytest/compat.py` apparently. --- testing/code/test_code.py | 33 --------------------------------- testing/code/test_source.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/testing/code/test_code.py b/testing/code/test_code.py index 5d35c19ab..826a37708 100644 --- a/testing/code/test_code.py +++ b/testing/code/test_code.py @@ -1,15 +1,11 @@ -import inspect import sys from types import FrameType from unittest import mock -import py.path - import pytest from _pytest._code import Code from _pytest._code import ExceptionInfo from _pytest._code import Frame -from _pytest._code import getfslineno from _pytest._code.code import ReprFuncArgs @@ -184,32 +180,3 @@ class TestReprFuncArgs: tw_mock.lines[0] == r"unicode_string = São Paulo, utf8_string = b'S\xc3\xa3o Paulo'" ) - - -def test_getfslineno() -> None: - def f(x) -> None: - raise NotImplementedError() - - fspath, lineno = getfslineno(f) - - assert isinstance(fspath, py.path.local) - assert fspath.basename == "test_code.py" - assert lineno == f.__code__.co_firstlineno - 1 # see findsource - - class A: - pass - - fspath, lineno = getfslineno(A) - - _, A_lineno = inspect.findsource(A) - assert isinstance(fspath, py.path.local) - assert fspath.basename == "test_code.py" - assert lineno == A_lineno - - assert getfslineno(3) == ("", -1) - - class B: - pass - - B.__name__ = "B2" - assert getfslineno(B)[1] == -1 diff --git a/testing/code/test_source.py b/testing/code/test_source.py index d769d4d77..b5efdb317 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -9,10 +9,11 @@ from typing import Any from typing import Dict from typing import Optional -import py +import py.path import _pytest._code import pytest +from _pytest._code import getfslineno from _pytest._code import Source @@ -495,6 +496,35 @@ def test_findsource() -> None: assert src[lineno] == " def x():" +def test_getfslineno() -> None: + def f(x) -> None: + raise NotImplementedError() + + fspath, lineno = getfslineno(f) + + assert isinstance(fspath, py.path.local) + assert fspath.basename == "test_source.py" + assert lineno == f.__code__.co_firstlineno - 1 # see findsource + + class A: + pass + + fspath, lineno = getfslineno(A) + + _, A_lineno = inspect.findsource(A) + assert isinstance(fspath, py.path.local) + assert fspath.basename == "test_source.py" + assert lineno == A_lineno + + assert getfslineno(3) == ("", -1) + + class B: + pass + + B.__name__ = "B2" + assert getfslineno(B)[1] == -1 + + def test_code_of_object_instance_with_call() -> None: class A: pass From 6d7e06e6beb2f30fef966b4b9479fc7d0c94c4ae Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 29 Jan 2020 20:28:04 -0300 Subject: [PATCH 096/158] Use --wrap=preserve in release notes script Follow up to #6621 after premature merge --- scripts/publish-gh-release-notes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/publish-gh-release-notes.py b/scripts/publish-gh-release-notes.py index 583b5bfc7..2531b0221 100644 --- a/scripts/publish-gh-release-notes.py +++ b/scripts/publish-gh-release-notes.py @@ -61,7 +61,9 @@ def parse_changelog(tag_name): def convert_rst_to_md(text): - return pypandoc.convert_text(text, "md", format="rst", extra_args=["--wrap=none"]) + return pypandoc.convert_text( + text, "md", format="rst", extra_args=["--wrap=preserve"] + ) def main(argv): From d91459fc758cd91848ace87f7c4071a38433f647 Mon Sep 17 00:00:00 2001 From: Daniel Arndt Date: Thu, 30 Jan 2020 09:32:54 -0400 Subject: [PATCH 097/158] Reword fixture docs for clarity --- doc/en/fixture.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index 2094027f3..bb1da8a03 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -849,7 +849,7 @@ Running this test will *skip* the invocation of ``data_set`` with value ``2``: Modularity: using fixtures from a fixture function ---------------------------------------------------------- -You can not only use fixtures in test functions but fixture functions +In additon to using fixtures in test functions, fixture functions can use other fixtures themselves. This contributes to a modular design of your fixtures and allows re-use of framework-specific fixtures across many projects. As a simple example, we can extend the previous example From 55bffb7c15a342431c49dc816bcc99e9ffa7e010 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 30 Jan 2020 18:30:51 +0200 Subject: [PATCH 098/158] Fix typo --- doc/en/fixture.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index bb1da8a03..b54a12731 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -849,7 +849,7 @@ Running this test will *skip* the invocation of ``data_set`` with value ``2``: Modularity: using fixtures from a fixture function ---------------------------------------------------------- -In additon to using fixtures in test functions, fixture functions +In addition to using fixtures in test functions, fixture functions can use other fixtures themselves. This contributes to a modular design of your fixtures and allows re-use of framework-specific fixtures across many projects. As a simple example, we can extend the previous example From 864338de71d6b56f0029b8ea8f45581001e2bab1 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 30 Jan 2020 19:55:40 +0100 Subject: [PATCH 099/158] Revert "ci: codecov: use `--retry-connrefused` with curl" Not known with `curl` on Travis at least. Reverts https://github.com/pytest-dev/pytest/pull/6573. This reverts commit df1f43ee28d38350542a23acb27647feab46f473. --- scripts/report-coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/report-coverage.sh b/scripts/report-coverage.sh index 6aa931383..fbcf20ca9 100755 --- a/scripts/report-coverage.sh +++ b/scripts/report-coverage.sh @@ -14,5 +14,5 @@ python -m coverage combine python -m coverage xml python -m coverage report -m # Set --connect-timeout to work around https://github.com/curl/curl/issues/4461 -curl -S -L --connect-timeout 5 --retry 6 --retry-connrefused -s https://codecov.io/bash -o codecov-upload.sh +curl -S -L --connect-timeout 5 --retry 6 -s https://codecov.io/bash -o codecov-upload.sh bash codecov-upload.sh -Z -X fix -f coverage.xml "$@" From 88b800355a42a697aad123fb9546e4dbadc2cf34 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 28 Jan 2020 17:05:41 +0100 Subject: [PATCH 100/158] typing: pytest_collection --- src/_pytest/assertion/__init__.py | 6 +++++- src/_pytest/hookspec.py | 10 +++++++++- src/_pytest/logging.py | 3 ++- src/_pytest/main.py | 5 ++++- src/_pytest/terminal.py | 2 +- src/_pytest/warnings.py | 4 +++- 6 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index f96afce6d..a060723a7 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -7,6 +7,10 @@ from typing import Optional from _pytest.assertion import rewrite from _pytest.assertion import truncate from _pytest.assertion import util +from _pytest.compat import TYPE_CHECKING + +if TYPE_CHECKING: + from _pytest.main import Session def pytest_addoption(parser): @@ -91,7 +95,7 @@ def install_importhook(config): return hook -def pytest_collection(session): +def pytest_collection(session: "Session") -> None: # this hook is only called when test modules are collected # so for example not in the master process of pytest-xdist # (which does not collect test modules) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 03e060eb8..2d9834c7f 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -1,6 +1,14 @@ """ hook specifications for pytest plugins, invoked from main.py and builtin plugins. """ +from typing import Any +from typing import Optional + from pluggy import HookspecMarker +from _pytest.compat import TYPE_CHECKING + +if TYPE_CHECKING: + from _pytest.main import Session + hookspec = HookspecMarker("pytest") @@ -158,7 +166,7 @@ def pytest_load_initial_conftests(early_config, parser, args): @hookspec(firstresult=True) -def pytest_collection(session): +def pytest_collection(session: "Session") -> Optional[Any]: """Perform the collection protocol for the given session. Stops at first non-None result, see :ref:`firstresult`. diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index ccd79b834..df0da3daa 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -5,6 +5,7 @@ from contextlib import contextmanager from io import StringIO from typing import AbstractSet from typing import Dict +from typing import Generator from typing import List from typing import Mapping @@ -591,7 +592,7 @@ class LoggingPlugin: ) is not None or self._config.getini("log_cli") @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_collection(self): + def pytest_collection(self) -> Generator[None, None, None]: with self.live_logs_context(): if self.log_cli_handler: self.log_cli_handler.set_when("collection") diff --git a/src/_pytest/main.py b/src/_pytest/main.py index e4eb4bbc8..e5666da9f 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -8,6 +8,8 @@ import sys from typing import Dict from typing import FrozenSet from typing import List +from typing import Optional +from typing import Union import attr import py @@ -249,7 +251,7 @@ def pytest_cmdline_main(config): return wrap_session(config, _main) -def _main(config, session): +def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: """ default command line protocol for initialization, session, running tests and reporting. """ config.hook.pytest_collection(session=session) @@ -259,6 +261,7 @@ def _main(config, session): return ExitCode.TESTS_FAILED elif session.testscollected == 0: return ExitCode.NO_TESTS_COLLECTED + return None def pytest_collection(session): diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 804d5928f..2206b5d98 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -514,7 +514,7 @@ class TerminalReporter: # py < 1.6.0 return self._tw.chars_on_current_line - def pytest_collection(self): + def pytest_collection(self) -> None: if self.isatty: if self.config.option.verbose >= 0: self.write("collecting ... ", bold=True) diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 8ac1ee225..d6c098dd0 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -1,8 +1,10 @@ import sys import warnings from contextlib import contextmanager +from typing import Generator import pytest +from _pytest.main import Session def _setoption(wmod, arg): @@ -117,7 +119,7 @@ def pytest_runtest_protocol(item): @pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_collection(session): +def pytest_collection(session: Session) -> Generator[None, None, None]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="collect", item=None From 442dccef6524b313cc62887e02e964aa285321ee Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 30 Jan 2020 22:55:01 +0100 Subject: [PATCH 101/158] python: factor out async_warn --- src/_pytest/python.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index c30dbc477..1b94aaf00 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -146,27 +146,30 @@ def pytest_configure(config): ) -@hookimpl(trylast=True) -def pytest_pyfunc_call(pyfuncitem): - def async_warn(): - msg = "async def functions are not natively supported and have been skipped.\n" - msg += "You need to install a suitable plugin for your async framework, for example:\n" - msg += " - pytest-asyncio\n" - msg += " - pytest-trio\n" - msg += " - pytest-tornasync" - warnings.warn(PytestUnhandledCoroutineWarning(msg.format(pyfuncitem.nodeid))) - skip(msg="async def function and no async plugin installed (see warnings)") +def async_warn(nodeid: str) -> None: + msg = "async def functions are not natively supported and have been skipped.\n" + msg += ( + "You need to install a suitable plugin for your async framework, for example:\n" + ) + msg += " - pytest-asyncio\n" + msg += " - pytest-trio\n" + msg += " - pytest-tornasync" + warnings.warn(PytestUnhandledCoroutineWarning(msg.format(nodeid))) + skip(msg="async def function and no async plugin installed (see warnings)") + +@hookimpl(trylast=True) +def pytest_pyfunc_call(pyfuncitem: "Function"): testfunction = pyfuncitem.obj if iscoroutinefunction(testfunction) or ( sys.version_info >= (3, 6) and inspect.isasyncgenfunction(testfunction) ): - async_warn() + async_warn(pyfuncitem.nodeid) funcargs = pyfuncitem.funcargs testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} result = testfunction(**testargs) if hasattr(result, "__await__") or hasattr(result, "__aiter__"): - async_warn() + async_warn(pyfuncitem.nodeid) return True From 2902c7263c186721bc9707dd4a6ff878139db819 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 30 Jan 2020 23:02:17 +0100 Subject: [PATCH 102/158] fixtures: move import of ParameterSet to top level This gets typically used always (via `getfixtureinfo`). --- src/_pytest/fixtures.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e950fc9e8..5b3686b58 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -31,6 +31,7 @@ from _pytest.compat import safe_getattr from _pytest.compat import TYPE_CHECKING from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS from _pytest.deprecated import FUNCARGNAMES +from _pytest.mark import ParameterSet from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -1263,8 +1264,6 @@ class FixtureManager: This things are done later as well when dealing with parametrization so this could be improved """ - from _pytest.mark import ParameterSet - parametrize_argnames = [] for marker in node.iter_markers(name="parametrize"): if not marker.kwargs.get("indirect", False): From 5b81bd862c948160773e650d826feed8bb80420f Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 30 Jan 2020 23:21:45 +0100 Subject: [PATCH 103/158] minor: doc: getfuncargnames: move TODO out of docstring --- src/_pytest/compat.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index f0b0d548f..085f634a4 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -143,12 +143,12 @@ def getfuncargnames( the case of cls, the function is a static method. The name parameter should be the original name in which the function was collected. - - @RonnyPfannschmidt: This function should be refactored when we - revisit fixtures. The fixture mechanism should ask the node for - the fixture names, and not try to obtain directly from the - function object well after collection has occurred. """ + # TODO(RonnyPfannschmidt): This function should be refactored when we + # revisit fixtures. The fixture mechanism should ask the node for + # the fixture names, and not try to obtain directly from the + # function object well after collection has occurred. + # The parameters attribute of a Signature object contains an # ordered mapping of parameter names to Parameter instances. This # creates a tuple of the names of the parameters that don't have From 8301993e5e8b3780d35a5eb888ba603731f5c809 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 31 Jan 2020 00:16:41 +0100 Subject: [PATCH 104/158] tests: use `-rfEX` `-fE` is the default in `features` now [1], but the idea is to add `X` also to it in the long run, so let's dogfood it ourselves. 1: https://github.com/pytest-dev/pytest/pull/6524 2: https://github.com/pytest-dev/pytest/pull/6524#issuecomment-577650703 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 707f239d0..65af0a4a8 100644 --- a/tox.ini +++ b/tox.ini @@ -147,7 +147,7 @@ commands = python scripts/publish-gh-release-notes.py {posargs} [pytest] minversion = 2.0 -addopts = -ra -p pytester --strict-markers +addopts = -rfEX -p pytester --strict-markers rsyncdirs = tox.ini doc src testing python_files = test_*.py *_test.py testing/*/*.py python_classes = Test Acceptance From 70739296e1d388f1d2ba4a2e2b24eff53c72171b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 30 Jan 2020 19:59:10 -0300 Subject: [PATCH 105/158] Remove deprecated 'pytest_itemstart' hook This hook has been deprecated/removed for more than 10 years in a2fe6714f860dfffb657d423355d6c644ccb7550. --- changelog/6637.breaking.rst | 3 +++ src/_pytest/hookspec.py | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 changelog/6637.breaking.rst diff --git a/changelog/6637.breaking.rst b/changelog/6637.breaking.rst new file mode 100644 index 000000000..d88282ae3 --- /dev/null +++ b/changelog/6637.breaking.rst @@ -0,0 +1,3 @@ +Removed the long-deprecated ``pytest_itemstart`` hook. + +This hook has been marked as deprecated and not been even called by pytest for over 10 years now. diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 74dff1e82..57d31d93b 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -307,10 +307,6 @@ def pytest_runtestloop(session): """ -def pytest_itemstart(item, node): - """(**Deprecated**) use pytest_runtest_logstart. """ - - @hookspec(firstresult=True) def pytest_runtest_protocol(item, nextitem): """ implements the runtest_setup/call/teardown protocol for From c9eeafade5079f70d424fc3ba6a55b5b33ceeda1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 1 Feb 2020 01:56:45 +0200 Subject: [PATCH 106/158] Fix favicon for Chrome and Opera (#6639) * Fix favicon for Chrome and Opera * Delete pytest1favi.ico Co-authored-by: Bruno Oliveira --- doc/en/conf.py | 2 +- doc/en/img/favicon.png | Bin 0 -> 1334 bytes doc/en/img/pytest1favi.ico | Bin 3742 -> 0 bytes 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 doc/en/img/favicon.png delete mode 100644 doc/en/img/pytest1favi.ico diff --git a/doc/en/conf.py b/doc/en/conf.py index bd2fd9871..85521309f 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -162,7 +162,7 @@ html_logo = "img/pytest1.png" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -html_favicon = "img/pytest1favi.ico" +html_favicon = "img/favicon.png" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/doc/en/img/favicon.png b/doc/en/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..5c8824d67d34e31baa8623a3d04ef21ee234041e GIT binary patch literal 1334 zcmZ8fdoLuxtft~da-IHza(;D8Wb67)Uid_&a76| z$+o0(nNn-1Y3I^Xm$Pm-oc>d-)7E5d*;&t_>c~E~?sCrazTfSAp6@%)`&A_+CfHiL zSpxuUSxkB|9C0u_UNeQ+Fj6-S2QyI&F9v|l8k_aw=5Xzq!A#}>Q0N6fX*mFEaI5rp z0CEw?NCyC|2f#V|eDAyA0PG1#;_?|_!f1p=0QCFqn^BR#XU6q8FgocASVquzA6%H< z%n;`cw!gJfJnK`o`S-9vL@fAw`#b)l@{aNFg` zwyp>WeOqjCQCh1q?rL86mAnW|7GKR{>mzBp$RK@WP(3rcoQXbEA6m z(QVXaC)Kj!Tfzcb!ja~1q%|zyJR=-@{RlKZ*m11kkFn`pyP$7VzITRvW{Y<1Qjunh ze10anZ9BG|2mEliNG`jEFK${r{VGFhuW?t$4yEQ5jlp34x zoz45_c1VGn|7$B~cs%l(-%g8nd%gW0s1MzPN04YV8rKY%Bm6Yo%H+h9t?#py%xYFx zgyog>t8V(2D}P3^S4vDxTnTu4J3OA?Zi^$}-*}UN$2*%O*QnJR7}eEkjYh4m;TxW% z>9pD=onEW`M*po=tJ5`UbstbUEH2)S$zr1}2iPp;ems}Oe#!lW~9k z-Y-L4jc03)MMozwQ|Wvz_h@WR_e%N51kz_Pr{b9;B!DTG6Rfg#|BHS6A0wSejW_ zSeRFqpQ)%glYk)fct*m=&EZ?#1cDdw;&*zTP9(sD3lAR$QXe`y?sbv}_>(AP@}=+F zTH2eNe`w7T%Q6)bp;$U+XXdVI{3-9L!SL+v^`iW&V@5mAdgQ~T+q^xaykY+k;mCBe z?NrAYPjt@2jP_OMcnq-0=_<|XT1Y?J5SF#EZf?^#N;%voSiCG8e?PtMh?Tuy06#)^ zx^}R27Cf41AM)xh9vbE~yq$IT6P^M`#?_B&c$$x`y7zfIc=dZ*0z97`9nCx90(~Va z8L3LaaitK=$PvN;P!TFZK>{f>giocRfgxy65E-GO2tx6oefb}PJWC+K3Lqgcgc68A e@R3k7_y8J=kP#Xr&{T?fNB}HGBE3B}z3gwU9wCAN literal 0 HcmV?d00001 diff --git a/doc/en/img/pytest1favi.ico b/doc/en/img/pytest1favi.ico deleted file mode 100644 index 6a34fe5c9f7e4a258b0f4ed2cf2efb021dcfbcb2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3742 zcmeH~dq`7J9LLYpi2mqd^q8)6v1v}Nr1~S~no?=oTofTuk>MYE+`Cu(QASSEw2)}d z6$DXPT5ORT<{$ZnVr$Em@adG=F=}A}6nZv2|;xN%CKDZ->&y%2v=Pb&6Fd^s*4ALkRSKA?y zk?`ID3v3k<@8sX(V?0<|7-Q`gBe1LCTVyLn%%aJH0VpZEaUdD`NfEy$1%reXN@NujG>|jJ8TQdLr3_awt`8-1mAv&m&Q{lI26mC$w@Ybrr(u0X7;*^SuTb}PnP zcwi&eQq0NpY+o)qQ#>c4-W?^sS`sazQ2S_}GJO5+!U^hgr)7)J_aa|%+BY-Hxnum& zEmaa#POW@#&P7=dnVZ~`bSqqw7pQ&mg=(*Cq1q>-cBb^+J+pOyPM$90VXfkNb0q%DKIGpQ(8=>o;qW4VHC`>FBAe)2#0$ii$Ka`fVj Date: Sat, 1 Feb 2020 06:27:41 +0100 Subject: [PATCH 107/158] PyCollector._genfunctions: use already created fixtureinfo (#6636) `Function` creates a `_fixtureinfo` already: https://github.com/pytest-dev/pytest/blob/fed535694/src/_pytest/python.py#L1392-L1395 --- src/_pytest/python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 1b94aaf00..65ef1272b 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -390,7 +390,7 @@ class PyCollector(PyobjMixin, nodes.Collector): fm = self.session._fixturemanager definition = FunctionDefinition(name=name, parent=self, callobj=funcobj) - fixtureinfo = fm.getfixtureinfo(definition, funcobj, cls) + fixtureinfo = definition._fixtureinfo metafunc = Metafunc( definition, fixtureinfo, self.config, cls=cls, module=module From 09a0e454923f3c22bc985ef42e789c5e0b0c0179 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 21:41:25 +0100 Subject: [PATCH 108/158] testing/test_pytester.py: cosmetics --- testing/test_pytester.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 35a06e33a..2e61632fd 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -459,14 +459,12 @@ def test_testdir_run_timeout_expires(testdir) -> None: def test_linematcher_with_nonlist() -> None: """Test LineMatcher with regard to passing in a set (accidentally).""" lm = LineMatcher([]) - with pytest.raises(AssertionError): lm.fnmatch_lines(set()) with pytest.raises(AssertionError): lm.fnmatch_lines({}) lm.fnmatch_lines([]) lm.fnmatch_lines(()) - assert lm._getlines({}) == {} assert lm._getlines(set()) == set() @@ -500,7 +498,7 @@ def test_linematcher_match_failure() -> None: @pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"]) -def test_no_matching(function) -> None: +def test_linematcher_no_matching(function) -> None: if function == "no_fnmatch_line": good_pattern = "*.py OK*" bad_pattern = "*X.py OK*" @@ -548,7 +546,7 @@ def test_no_matching(function) -> None: func(bad_pattern) # bad pattern does not match any line: passes -def test_no_matching_after_match() -> None: +def test_linematcher_no_matching_after_match() -> None: lm = LineMatcher(["1", "2", "3"]) lm.fnmatch_lines(["1", "3"]) with pytest.raises(Failed) as e: From b10ab0211c789ea28bb372fe93fffeeb8cd338ad Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 22:06:15 +0100 Subject: [PATCH 109/158] Use TypeError instead of AssertionError for no sequence Improve/extends tests. --- src/_pytest/pytester.py | 3 ++- testing/test_pytester.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index cfe1b9a6c..82a7a7bbe 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1430,7 +1430,8 @@ class LineMatcher: :param str match_nickname: the nickname for the match function that will be logged to stdout when a match occurs """ - assert isinstance(lines2, collections.abc.Sequence) + if not isinstance(lines2, collections.abc.Sequence): + raise TypeError("invalid type for lines2: {}".format(type(lines2).__name__)) lines2 = self._getlines(lines2) lines1 = self.lines[:] nextline = None diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 2e61632fd..da5fb99a1 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -458,15 +458,26 @@ def test_testdir_run_timeout_expires(testdir) -> None: def test_linematcher_with_nonlist() -> None: """Test LineMatcher with regard to passing in a set (accidentally).""" + from _pytest._code.source import Source + lm = LineMatcher([]) - with pytest.raises(AssertionError): + with pytest.raises(TypeError, match="invalid type for lines2: set"): lm.fnmatch_lines(set()) - with pytest.raises(AssertionError): + with pytest.raises(TypeError, match="invalid type for lines2: dict"): lm.fnmatch_lines({}) + with pytest.raises(TypeError, match="invalid type for lines2: set"): + lm.re_match_lines(set()) + with pytest.raises(TypeError, match="invalid type for lines2: dict"): + lm.re_match_lines({}) + with pytest.raises(TypeError, match="invalid type for lines2: Source"): + lm.fnmatch_lines(Source()) lm.fnmatch_lines([]) lm.fnmatch_lines(()) + lm.fnmatch_lines("") assert lm._getlines({}) == {} assert lm._getlines(set()) == set() + assert lm._getlines(Source()) == [] + assert lm._getlines(Source("pass\npass")) == ["pass", "pass"] def test_linematcher_match_failure() -> None: From 2681b0aed7b1c92e6971bbbff527321958d6e627 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 22:30:34 +0100 Subject: [PATCH 110/158] typing: pytester: LineMatcher --- src/_pytest/pytester.py | 68 ++++++++++++++++++++++------------------ testing/test_pytester.py | 14 ++++----- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 82a7a7bbe..f77845ca4 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -413,8 +413,8 @@ class RunResult: def __init__( self, ret: Union[int, ExitCode], - outlines: Sequence[str], - errlines: Sequence[str], + outlines: List[str], + errlines: List[str], duration: float, ) -> None: try: @@ -1327,48 +1327,42 @@ class LineMatcher: The constructor takes a list of lines without their trailing newlines, i.e. ``text.splitlines()``. - """ - def __init__(self, lines): + def __init__(self, lines: List[str]) -> None: self.lines = lines - self._log_output = [] + self._log_output = [] # type: List[str] - def str(self): - """Return the entire original text.""" - return "\n".join(self.lines) - - def _getlines(self, lines2): + def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]: if isinstance(lines2, str): lines2 = Source(lines2) if isinstance(lines2, Source): lines2 = lines2.strip().lines return lines2 - def fnmatch_lines_random(self, lines2): + def fnmatch_lines_random(self, lines2: Sequence[str]) -> None: """Check lines exist in the output using in any order. Lines are checked using ``fnmatch.fnmatch``. The argument is a list of lines which have to occur in the output, in any order. - """ self._match_lines_random(lines2, fnmatch) - def re_match_lines_random(self, lines2): + def re_match_lines_random(self, lines2: Sequence[str]) -> None: """Check lines exist in the output using ``re.match``, in any order. The argument is a list of lines which have to occur in the output, in any order. - """ - self._match_lines_random(lines2, lambda name, pat: re.match(pat, name)) + self._match_lines_random(lines2, lambda name, pat: bool(re.match(pat, name))) - def _match_lines_random(self, lines2, match_func): + def _match_lines_random( + self, lines2: Sequence[str], match_func: Callable[[str, str], bool] + ) -> None: """Check lines exist in the output. The argument is a list of lines which have to occur in the output, in any order. Each line can contain glob whildcards. - """ lines2 = self._getlines(lines2) for line in lines2: @@ -1380,25 +1374,24 @@ class LineMatcher: self._log("line %r not found in output" % line) raise ValueError(self._log_text) - def get_lines_after(self, fnline): + def get_lines_after(self, fnline: str) -> Sequence[str]: """Return all lines following the given line in the text. The given line can contain glob wildcards. - """ for i, line in enumerate(self.lines): if fnline == line or fnmatch(line, fnline): return self.lines[i + 1 :] raise ValueError("line %r not found in output" % fnline) - def _log(self, *args): + def _log(self, *args) -> None: self._log_output.append(" ".join(str(x) for x in args)) @property - def _log_text(self): + def _log_text(self) -> str: return "\n".join(self._log_output) - def fnmatch_lines(self, lines2): + def fnmatch_lines(self, lines2: Sequence[str]) -> None: """Search captured text for matching lines using ``fnmatch.fnmatch``. The argument is a list of lines which have to match and can use glob @@ -1408,7 +1401,7 @@ class LineMatcher: __tracebackhide__ = True self._match_lines(lines2, fnmatch, "fnmatch") - def re_match_lines(self, lines2): + def re_match_lines(self, lines2: Sequence[str]) -> None: """Search captured text for matching lines using ``re.match``. The argument is a list of lines which have to match using ``re.match``. @@ -1417,9 +1410,16 @@ class LineMatcher: The matches and non-matches are also shown as part of the error message. """ __tracebackhide__ = True - self._match_lines(lines2, lambda name, pat: re.match(pat, name), "re.match") + self._match_lines( + lines2, lambda name, pat: bool(re.match(pat, name)), "re.match" + ) - def _match_lines(self, lines2, match_func, match_nickname): + def _match_lines( + self, + lines2: Sequence[str], + match_func: Callable[[str, str], bool], + match_nickname: str, + ) -> None: """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``. :param list[str] lines2: list of string patterns to match. The actual @@ -1465,7 +1465,7 @@ class LineMatcher: self._fail(msg) self._log_output = [] - def no_fnmatch_line(self, pat): + def no_fnmatch_line(self, pat: str) -> None: """Ensure captured lines do not match the given pattern, using ``fnmatch.fnmatch``. :param str pat: the pattern to match lines. @@ -1473,15 +1473,19 @@ class LineMatcher: __tracebackhide__ = True self._no_match_line(pat, fnmatch, "fnmatch") - def no_re_match_line(self, pat): + def no_re_match_line(self, pat: str) -> None: """Ensure captured lines do not match the given pattern, using ``re.match``. :param str pat: the regular expression to match lines. """ __tracebackhide__ = True - self._no_match_line(pat, lambda name, pat: re.match(pat, name), "re.match") + self._no_match_line( + pat, lambda name, pat: bool(re.match(pat, name)), "re.match" + ) - def _no_match_line(self, pat, match_func, match_nickname): + def _no_match_line( + self, pat: str, match_func: Callable[[str, str], bool], match_nickname: str + ) -> None: """Ensure captured lines does not have a the given pattern, using ``fnmatch.fnmatch`` :param str pat: the pattern to match lines @@ -1502,8 +1506,12 @@ class LineMatcher: self._log("{:>{width}}".format("and:", width=wnick), repr(line)) self._log_output = [] - def _fail(self, msg): + def _fail(self, msg: str) -> None: __tracebackhide__ = True log_text = self._log_text self._log_output = [] pytest.fail(log_text) + + def str(self) -> str: + """Return the entire original text.""" + return "\n".join(self.lines) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index da5fb99a1..a6901e967 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -462,20 +462,20 @@ def test_linematcher_with_nonlist() -> None: lm = LineMatcher([]) with pytest.raises(TypeError, match="invalid type for lines2: set"): - lm.fnmatch_lines(set()) + lm.fnmatch_lines(set()) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="invalid type for lines2: dict"): - lm.fnmatch_lines({}) + lm.fnmatch_lines({}) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="invalid type for lines2: set"): - lm.re_match_lines(set()) + lm.re_match_lines(set()) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="invalid type for lines2: dict"): - lm.re_match_lines({}) + lm.re_match_lines({}) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="invalid type for lines2: Source"): - lm.fnmatch_lines(Source()) + lm.fnmatch_lines(Source()) # type: ignore[arg-type] # noqa: F821 lm.fnmatch_lines([]) lm.fnmatch_lines(()) lm.fnmatch_lines("") - assert lm._getlines({}) == {} - assert lm._getlines(set()) == set() + assert lm._getlines({}) == {} # type: ignore[arg-type,comparison-overlap] # noqa: F821 + assert lm._getlines(set()) == set() # type: ignore[arg-type,comparison-overlap] # noqa: F821 assert lm._getlines(Source()) == [] assert lm._getlines(Source("pass\npass")) == ["pass", "pass"] From 50f81db8175a6195b6efcf92ec9e1dd4bd51efcc Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 23:28:27 +0100 Subject: [PATCH 111/158] revisit/improve docstrings --- src/_pytest/pytester.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index f77845ca4..11f81b76e 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1341,29 +1341,18 @@ class LineMatcher: return lines2 def fnmatch_lines_random(self, lines2: Sequence[str]) -> None: - """Check lines exist in the output using in any order. - - Lines are checked using ``fnmatch.fnmatch``. The argument is a list of - lines which have to occur in the output, in any order. + """Check lines exist in the output in any order (using :func:`python:fnmatch.fnmatch`). """ self._match_lines_random(lines2, fnmatch) def re_match_lines_random(self, lines2: Sequence[str]) -> None: - """Check lines exist in the output using ``re.match``, in any order. - - The argument is a list of lines which have to occur in the output, in - any order. + """Check lines exist in the output in any order (using :func:`python:re.match`). """ self._match_lines_random(lines2, lambda name, pat: bool(re.match(pat, name))) def _match_lines_random( self, lines2: Sequence[str], match_func: Callable[[str, str], bool] ) -> None: - """Check lines exist in the output. - - The argument is a list of lines which have to occur in the output, in - any order. Each line can contain glob whildcards. - """ lines2 = self._getlines(lines2) for line in lines2: for x in self.lines: @@ -1392,22 +1381,26 @@ class LineMatcher: return "\n".join(self._log_output) def fnmatch_lines(self, lines2: Sequence[str]) -> None: - """Search captured text for matching lines using ``fnmatch.fnmatch``. + """Check lines exist in the output (using :func:`python:fnmatch.fnmatch`). The argument is a list of lines which have to match and can use glob wildcards. If they do not match a pytest.fail() is called. The matches and non-matches are also shown as part of the error message. + + :param lines2: string patterns to match. """ __tracebackhide__ = True self._match_lines(lines2, fnmatch, "fnmatch") def re_match_lines(self, lines2: Sequence[str]) -> None: - """Search captured text for matching lines using ``re.match``. + """Check lines exist in the output (using :func:`python:re.match`). The argument is a list of lines which have to match using ``re.match``. If they do not match a pytest.fail() is called. The matches and non-matches are also shown as part of the error message. + + :param lines2: string patterns to match. """ __tracebackhide__ = True self._match_lines( From 5256542ea41583e1c02783b2650b7c8a23749088 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 23:33:24 +0100 Subject: [PATCH 112/158] pytester.LineMatcher: add support for matching lines consecutively --- changelog/6653.improvement.rst | 1 + src/_pytest/pytester.py | 30 ++++++++++++++++++++++++++---- testing/test_pytester.py | 20 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 changelog/6653.improvement.rst diff --git a/changelog/6653.improvement.rst b/changelog/6653.improvement.rst new file mode 100644 index 000000000..4c081e673 --- /dev/null +++ b/changelog/6653.improvement.rst @@ -0,0 +1 @@ +Add support for matching lines consecutively with :attr:`LineMatcher <_pytest.pytester.LineMatcher>`'s :func:`~_pytest.pytester.LineMatcher.fnmatch_lines` and :func:`~_pytest.pytester.LineMatcher.re_match_lines`. diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 11f81b76e..f80a62e6f 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1380,7 +1380,9 @@ class LineMatcher: def _log_text(self) -> str: return "\n".join(self._log_output) - def fnmatch_lines(self, lines2: Sequence[str]) -> None: + def fnmatch_lines( + self, lines2: Sequence[str], *, consecutive: bool = False + ) -> None: """Check lines exist in the output (using :func:`python:fnmatch.fnmatch`). The argument is a list of lines which have to match and can use glob @@ -1388,11 +1390,14 @@ class LineMatcher: matches and non-matches are also shown as part of the error message. :param lines2: string patterns to match. + :param consecutive: match lines consecutive? """ __tracebackhide__ = True - self._match_lines(lines2, fnmatch, "fnmatch") + self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive) - def re_match_lines(self, lines2: Sequence[str]) -> None: + def re_match_lines( + self, lines2: Sequence[str], *, consecutive: bool = False + ) -> None: """Check lines exist in the output (using :func:`python:re.match`). The argument is a list of lines which have to match using ``re.match``. @@ -1401,10 +1406,14 @@ class LineMatcher: The matches and non-matches are also shown as part of the error message. :param lines2: string patterns to match. + :param consecutive: match lines consecutively? """ __tracebackhide__ = True self._match_lines( - lines2, lambda name, pat: bool(re.match(pat, name)), "re.match" + lines2, + lambda name, pat: bool(re.match(pat, name)), + "re.match", + consecutive=consecutive, ) def _match_lines( @@ -1412,6 +1421,8 @@ class LineMatcher: lines2: Sequence[str], match_func: Callable[[str, str], bool], match_nickname: str, + *, + consecutive: bool = False ) -> None: """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``. @@ -1422,6 +1433,7 @@ class LineMatcher: pattern :param str match_nickname: the nickname for the match function that will be logged to stdout when a match occurs + :param consecutive: match lines consecutively? """ if not isinstance(lines2, collections.abc.Sequence): raise TypeError("invalid type for lines2: {}".format(type(lines2).__name__)) @@ -1431,20 +1443,30 @@ class LineMatcher: extralines = [] __tracebackhide__ = True wnick = len(match_nickname) + 1 + started = False for line in lines2: nomatchprinted = False while lines1: nextline = lines1.pop(0) if line == nextline: self._log("exact match:", repr(line)) + started = True break elif match_func(nextline, line): self._log("%s:" % match_nickname, repr(line)) self._log( "{:>{width}}".format("with:", width=wnick), repr(nextline) ) + started = True break else: + if consecutive and started: + msg = "no consecutive match: {!r}".format(line) + self._log(msg) + self._log( + "{:>{width}}".format("with:", width=wnick), repr(nextline) + ) + self._fail(msg) if not nomatchprinted: self._log( "{:>{width}}".format("nomatch:", width=wnick), repr(line) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index a6901e967..bc0d9d0c3 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -508,6 +508,26 @@ def test_linematcher_match_failure() -> None: ] +def test_linematcher_consecutive(): + lm = LineMatcher(["1", "", "2"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1", "2"], consecutive=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + "no consecutive match: '2'", + " with: ''", + ] + + lm.re_match_lines(["1", r"\d?", "2"], consecutive=True) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.re_match_lines(["1", r"\d", "2"], consecutive=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + r"no consecutive match: '\\d'", + " with: ''", + ] + + @pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"]) def test_linematcher_no_matching(function) -> None: if function == "no_fnmatch_line": From 8bd612b36734972d54bdf3f9c27cd69919372927 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 2 Feb 2020 22:50:30 +0100 Subject: [PATCH 113/158] typing: wrap_session Pulled out of https://github.com/pytest-dev/pytest/pull/6556. --- src/_pytest/main.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index e5666da9f..8ef06db38 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -5,6 +5,7 @@ import functools import importlib import os import sys +from typing import Callable from typing import Dict from typing import FrozenSet from typing import List @@ -23,7 +24,7 @@ from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.fixtures import FixtureManager from _pytest.nodes import Node -from _pytest.outcomes import exit +from _pytest.outcomes import Exit from _pytest.runner import collect_one_node from _pytest.runner import SetupState @@ -194,7 +195,9 @@ def pytest_addoption(parser): ) -def wrap_session(config, doit): +def wrap_session( + config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] +) -> Union[int, ExitCode]: """Skeleton command line program""" session = Session(config) session.exitstatus = ExitCode.OK @@ -211,10 +214,10 @@ def wrap_session(config, doit): raise except Failed: session.exitstatus = ExitCode.TESTS_FAILED - except (KeyboardInterrupt, exit.Exception): + except (KeyboardInterrupt, Exit): excinfo = _pytest._code.ExceptionInfo.from_current() - exitstatus = ExitCode.INTERRUPTED - if isinstance(excinfo.value, exit.Exception): + exitstatus = ExitCode.INTERRUPTED # type: Union[int, ExitCode] + if isinstance(excinfo.value, Exit): if excinfo.value.returncode is not None: exitstatus = excinfo.value.returncode if initstate < 2: @@ -228,7 +231,7 @@ def wrap_session(config, doit): excinfo = _pytest._code.ExceptionInfo.from_current() try: config.notify_exception(excinfo, config.option) - except exit.Exception as exc: + except Exit as exc: if exc.returncode is not None: session.exitstatus = exc.returncode sys.stderr.write("{}: {}\n".format(type(exc).__name__, exc)) @@ -237,7 +240,8 @@ def wrap_session(config, doit): sys.stderr.write("mainloop: caught unexpected SystemExit!\n") finally: - excinfo = None # Explicitly break reference cycle. + # Explicitly break reference cycle. + excinfo = None # type: ignore session.startdir.chdir() if initstate >= 2: config.hook.pytest_sessionfinish( @@ -382,6 +386,7 @@ class Session(nodes.FSCollector): _setupstate = None # type: SetupState # Set on the session by fixtures.pytest_sessionstart. _fixturemanager = None # type: FixtureManager + exitstatus = None # type: Union[int, ExitCode] def __init__(self, config: Config) -> None: nodes.FSCollector.__init__( From 99d162e44a0d40675b855dbcde9734b29032f8aa Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sun, 2 Feb 2020 22:23:41 +0100 Subject: [PATCH 114/158] Handle `Exit` exception in `pytest_sessionfinish` Similar to a7268aa (https://github.com/pytest-dev/pytest/pull/6258). --- changelog/6660.bugfix.rst | 1 + src/_pytest/main.py | 11 ++++++++--- testing/test_main.py | 25 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 changelog/6660.bugfix.rst diff --git a/changelog/6660.bugfix.rst b/changelog/6660.bugfix.rst new file mode 100644 index 000000000..bcc2e1d94 --- /dev/null +++ b/changelog/6660.bugfix.rst @@ -0,0 +1 @@ +:func:`pytest.exit() <_pytest.outcomes.exit>` is handled when emitted from the :func:`pytest_sessionfinish <_pytest.hookspec.pytest_sessionfinish>` hook. This includes quitting from a debugger. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 8ef06db38..59c3c6714 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -244,9 +244,14 @@ def wrap_session( excinfo = None # type: ignore session.startdir.chdir() if initstate >= 2: - config.hook.pytest_sessionfinish( - session=session, exitstatus=session.exitstatus - ) + try: + config.hook.pytest_sessionfinish( + session=session, exitstatus=session.exitstatus + ) + except Exit as exc: + if exc.returncode is not None: + session.exitstatus = exc.returncode + sys.stderr.write("{}: {}\n".format(type(exc).__name__, exc)) config._ensure_unconfigure() return session.exitstatus diff --git a/testing/test_main.py b/testing/test_main.py index b47791b29..49e3decd0 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -1,5 +1,8 @@ +from typing import Optional + import pytest from _pytest.main import ExitCode +from _pytest.pytester import Testdir @pytest.mark.parametrize( @@ -50,3 +53,25 @@ def test_wrap_session_notify_exception(ret_exc, testdir): assert result.stderr.lines == ["mainloop: caught unexpected SystemExit!"] else: assert result.stderr.lines == ["Exit: exiting after {}...".format(exc.__name__)] + + +@pytest.mark.parametrize("returncode", (None, 42)) +def test_wrap_session_exit_sessionfinish( + returncode: Optional[int], testdir: Testdir +) -> None: + testdir.makeconftest( + """ + import pytest + def pytest_sessionfinish(): + pytest.exit(msg="exit_pytest_sessionfinish", returncode={returncode}) + """.format( + returncode=returncode + ) + ) + result = testdir.runpytest() + if returncode: + assert result.ret == returncode + else: + assert result.ret == ExitCode.NO_TESTS_COLLECTED + assert result.stdout.lines[-1] == "collected 0 items" + assert result.stderr.lines == ["Exit: exit_pytest_sessionfinish"] From c55bf23cbeb17df0621a9da2a15cff06a9792de8 Mon Sep 17 00:00:00 2001 From: rebecca-palmer Date: Mon, 3 Feb 2020 07:56:37 +0000 Subject: [PATCH 115/158] doc: s/pytest_mark/pytestmark (#6661) --- doc/en/reference.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 50e32d660..088f6a065 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -901,8 +901,8 @@ Can be either a ``str`` or ``Sequence[str]``. pytest_plugins = ("myapp.testsupport.tools", "myapp.testsupport.regression") -pytest_mark -~~~~~~~~~~~ +pytestmark +~~~~~~~~~~ **Tutorial**: :ref:`scoped-marking` From 8ec4d03c91dec7b68a1659094f36399b87ce4672 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 3 Feb 2020 10:25:32 +0100 Subject: [PATCH 116/158] Inline FunctionMixin with Function (#6664) `Generator` was removed in 7eb28f9eb, and this pleases mypy to correctly complain that `FunctionMixin` has no `config` (within `_prunetraceback`). * typing: _prunetraceback * minor: imports, typing --- src/_pytest/python.py | 82 ++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 5e3b5f286..466f260f7 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -21,6 +21,7 @@ import _pytest from _pytest import fixtures from _pytest import nodes from _pytest._code import filter_traceback +from _pytest._code.code import ExceptionInfo from _pytest.compat import ascii_escaped from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func @@ -503,9 +504,7 @@ class Module(nodes.File, PyCollector): try: mod = self.fspath.pyimport(ensuresyspath=importmode) except SyntaxError: - raise self.CollectError( - _pytest._code.ExceptionInfo.from_current().getrepr(style="short") - ) + raise self.CollectError(ExceptionInfo.from_current().getrepr(style="short")) except self.fspath.ImportMismatchError: e = sys.exc_info()[1] raise self.CollectError( @@ -518,8 +517,6 @@ class Module(nodes.File, PyCollector): "unique basename for your test file modules" % e.args ) except ImportError: - from _pytest._code.code import ExceptionInfo - exc_info = ExceptionInfo.from_current() if self.config.getoption("verbose") < 2: exc_info.traceback = exc_info.traceback.filter(filter_traceback) @@ -773,45 +770,6 @@ class Instance(PyCollector): return self.obj -class FunctionMixin(PyobjMixin): - """ mixin for the code common to Function and Generator. - """ - - def setup(self): - """ perform setup for this test function. """ - if isinstance(self.parent, Instance): - self.parent.newinstance() - self.obj = self._getobj() - - def _prunetraceback(self, excinfo): - if hasattr(self, "_obj") and not self.config.getoption("fulltrace", False): - code = _pytest._code.Code(get_real_func(self.obj)) - path, firstlineno = code.path, code.firstlineno - traceback = excinfo.traceback - ntraceback = traceback.cut(path=path, firstlineno=firstlineno) - if ntraceback == traceback: - ntraceback = ntraceback.cut(path=path) - if ntraceback == traceback: - ntraceback = ntraceback.filter(filter_traceback) - if not ntraceback: - ntraceback = traceback - - excinfo.traceback = ntraceback.filter() - # issue364: mark all but first and last frames to - # only show a single-line message for each frame - if self.config.getoption("tbstyle", "auto") == "auto": - if len(excinfo.traceback) > 2: - for entry in excinfo.traceback[1:-1]: - entry.set_repr_style("short") - - def repr_failure(self, excinfo, outerr=None): - assert outerr is None, "XXX outerr usage is deprecated" - style = self.config.getoption("tbstyle", "auto") - if style == "auto": - style = "long" - return self._repr_failure_py(excinfo, style=style) - - def hasinit(obj): init = getattr(obj, "__init__", None) if init: @@ -1397,7 +1355,7 @@ def write_docstring(tw, doc, indent=" "): tw.write(indent + line + "\n") -class Function(FunctionMixin, nodes.Item): +class Function(PyobjMixin, nodes.Item): """ a Function Item is responsible for setting up and executing a Python test function. """ @@ -1501,10 +1459,40 @@ class Function(FunctionMixin, nodes.Item): """ execute the underlying test function. """ self.ihook.pytest_pyfunc_call(pyfuncitem=self) - def setup(self): - super().setup() + def setup(self) -> None: + if isinstance(self.parent, Instance): + self.parent.newinstance() + self.obj = self._getobj() fixtures.fillfixtures(self) + def _prunetraceback(self, excinfo: ExceptionInfo) -> None: + if hasattr(self, "_obj") and not self.config.getoption("fulltrace", False): + code = _pytest._code.Code(get_real_func(self.obj)) + path, firstlineno = code.path, code.firstlineno + traceback = excinfo.traceback + ntraceback = traceback.cut(path=path, firstlineno=firstlineno) + if ntraceback == traceback: + ntraceback = ntraceback.cut(path=path) + if ntraceback == traceback: + ntraceback = ntraceback.filter(filter_traceback) + if not ntraceback: + ntraceback = traceback + + excinfo.traceback = ntraceback.filter() + # issue364: mark all but first and last frames to + # only show a single-line message for each frame + if self.config.getoption("tbstyle", "auto") == "auto": + if len(excinfo.traceback) > 2: + for entry in excinfo.traceback[1:-1]: + entry.set_repr_style("short") + + def repr_failure(self, excinfo, outerr=None): + assert outerr is None, "XXX outerr usage is deprecated" + style = self.config.getoption("tbstyle", "auto") + if style == "auto": + style = "long" + return self._repr_failure_py(excinfo, style=style) + class FunctionDefinition(Function): """ From fb289667e32d5837b1d15e34b2909ee74f876960 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 3 Feb 2020 13:53:31 +0100 Subject: [PATCH 117/158] Remove testing/test_modimport.py testing/test_meta.py ensures this already as a side effect (+ tests a few more (`__init__.py` files) and should have been combined with it right away [1]. 1: https://github.com/pytest-dev/pytest/pull/4510#discussion_r289123446 Ref: https://github.com/pytest-dev/pytest/commit/eaa05531e Ref: https://github.com/pytest-dev/pytest/commit/4d31ea831 --- testing/test_modimport.py | 40 --------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 testing/test_modimport.py diff --git a/testing/test_modimport.py b/testing/test_modimport.py deleted file mode 100644 index 3d7a07323..000000000 --- a/testing/test_modimport.py +++ /dev/null @@ -1,40 +0,0 @@ -import subprocess -import sys - -import py - -import _pytest -import pytest - -pytestmark = pytest.mark.slow - -MODSET = [ - x - for x in py.path.local(_pytest.__file__).dirpath().visit("*.py") - if x.purebasename != "__init__" -] - - -@pytest.mark.parametrize("modfile", MODSET, ids=lambda x: x.purebasename) -def test_fileimport(modfile): - # this test ensures all internal packages can import - # without needing the pytest namespace being set - # this is critical for the initialization of xdist - - p = subprocess.Popen( - [ - sys.executable, - "-c", - "import sys, py; py.path.local(sys.argv[1]).pyimport()", - modfile.strpath, - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - (out, err) = p.communicate() - assert p.returncode == 0, "importing %s failed (exitcode %d): out=%r, err=%r" % ( - modfile, - p.returncode, - out, - err, - ) From abffd16ce6e950a27b013f017b0bee167f095bf8 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 3 Feb 2020 14:04:16 +0100 Subject: [PATCH 118/158] Keep (revisited) comment from https://github.com/pytest-dev/pytest/commit/4d31ea831 --- testing/test_meta.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testing/test_meta.py b/testing/test_meta.py index 296aa42aa..ffc8fd38a 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -1,3 +1,9 @@ +""" +Test importing of all internal packages and modules. + +This ensures all internal packages can be imported without needing the pytest +namespace being set, which is critical for the initialization of xdist. +""" import pkgutil import subprocess import sys From 75714ee70726e46220cf02b85b3edb2894b6eebd Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 3 Feb 2020 14:10:54 +0100 Subject: [PATCH 119/158] pluginmanager.consider_preparse: add exclude_only kwarg (#6443) Plugins specified with ``-p`` are now loaded after internal plugins, which results in their hooks being called *before* the internal ones. This makes the ``-p`` behavior consistent with ``PYTEST_PLUGINS``. * fix/adjust test_disable_plugin_autoload * adjust test_plugin_loading_order --- changelog/6443.breaking.rst | 3 +++ src/_pytest/config/__init__.py | 8 +++++--- testing/test_config.py | 13 ++++++++++++- testing/test_warnings.py | 5 +---- 4 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 changelog/6443.breaking.rst diff --git a/changelog/6443.breaking.rst b/changelog/6443.breaking.rst new file mode 100644 index 000000000..b39c53014 --- /dev/null +++ b/changelog/6443.breaking.rst @@ -0,0 +1,3 @@ +Plugins specified with ``-p`` are now loaded after internal plugins, which results in their hooks being called *before* the internal ones. + +This makes the ``-p`` behavior consistent with ``PYTEST_PLUGINS``. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 73c35ce63..6fbbf1959 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -193,7 +193,7 @@ def get_config(args=None, plugins=None): if args is not None: # Handle any "-p no:plugin" args. - pluginmanager.consider_preparse(args) + pluginmanager.consider_preparse(args, exclude_only=True) for spec in default_plugins: pluginmanager.import_plugin(spec) @@ -499,7 +499,7 @@ class PytestPluginManager(PluginManager): # # - def consider_preparse(self, args): + def consider_preparse(self, args, *, exclude_only=False): i = 0 n = len(args) while i < n: @@ -516,6 +516,8 @@ class PytestPluginManager(PluginManager): parg = opt[2:] else: continue + if exclude_only and not parg.startswith("no:"): + continue self.consider_pluginarg(parg) def consider_pluginarg(self, arg): @@ -951,7 +953,7 @@ class Config: self._checkversion() self._consider_importhook(args) - self.pluginmanager.consider_preparse(args) + self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): # Don't autoload from setuptools entry point. Only explicitly specified # plugins are going to be loaded. diff --git a/testing/test_config.py b/testing/test_config.py index cc54e5b23..8a1be0b27 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -659,6 +659,13 @@ def test_disable_plugin_autoload(testdir, monkeypatch, parse_args, should_load): class PseudoPlugin: x = 42 + attrs_used = [] + + def __getattr__(self, name): + assert name == "__loader__" + self.attrs_used.append(name) + return object() + def distributions(): return (Distribution(),) @@ -668,6 +675,10 @@ def test_disable_plugin_autoload(testdir, monkeypatch, parse_args, should_load): config = testdir.parseconfig(*parse_args) has_loaded = config.pluginmanager.get_plugin("mytestplugin") is not None assert has_loaded == should_load + if should_load: + assert PseudoPlugin.attrs_used == ["__loader__"] + else: + assert PseudoPlugin.attrs_used == [] def test_plugin_loading_order(testdir): @@ -676,7 +687,7 @@ def test_plugin_loading_order(testdir): """ def test_terminal_plugin(request): import myplugin - assert myplugin.terminal_plugin == [True, True] + assert myplugin.terminal_plugin == [False, True] """, **{ "myplugin": """ diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 99b24e332..e4d957385 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -679,10 +679,7 @@ class TestStackLevel: # with stacklevel=2 the warning should originate from # config.PytestPluginManager.import_plugin is thrown by a skipped plugin - # During config parsing the the pluginargs are checked in a while loop - # that as a result of the argument count runs import_plugin twice, hence - # two identical warnings are captured (is this intentional?). - assert len(capwarn.captured) == 2 + assert len(capwarn.captured) == 1 warning, location = capwarn.captured.pop() file, _, func = location From 1480aa31a76feef504f392e52f5730ee12476988 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 3 Feb 2020 14:35:50 -0300 Subject: [PATCH 120/158] Explicitly state on the PR template that we can squash commits (#6662) * Explicitly state on the PR template that we can squash commits This way we don't need to ask every time, and users who for some reason would not like us to squash their commits can explicitly state so. --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7f9aa9556..2e221f73e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,7 @@ Here is a quick checklist that should be present in PRs. - [ ] Target the `features` branch for new features, improvements, and removals/deprecations. - [ ] Include documentation when adding new features. - [ ] Include new tests or update existing tests when applicable. +- [X] Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself. Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please: From b0d45267c58859bcb79f7ab980f4b410c4bbd109 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sun, 2 Feb 2020 03:42:53 +0100 Subject: [PATCH 121/158] internal: clean up getfslineno Everything was using `_pytest.compat.getfslineno` basically, which wrapped `_pytest._code.source.getfslineno`. This moves the extra code from there into it directly, and uses the latter everywhere. This helps to eventually remove the one in compat eventually, and also causes less cyclic imports. --- src/_pytest/_code/source.py | 8 ++++++++ src/_pytest/compat.py | 12 ++++-------- src/_pytest/fixtures.py | 2 +- src/_pytest/mark/structures.py | 2 +- src/_pytest/nodes.py | 2 +- src/_pytest/python.py | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index 379393b10..b5e18863f 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -17,6 +17,7 @@ from typing import Union import py +from _pytest.compat import get_real_func from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING @@ -290,6 +291,13 @@ def getfslineno(obj) -> Tuple[Optional[Union["Literal['']", py.path.local]], int """ from .code import Code + # xxx let decorators etc specify a sane ordering + # NOTE: this used to be done in _pytest.compat.getfslineno, initially added + # in 6ec13a2b9. It ("place_as") appears to be something very custom. + obj = get_real_func(obj) + if hasattr(obj, "place_as"): + obj = obj.place_as + try: code = Code(obj) except TypeError: diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 085f634a4..d6ee1d522 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -22,7 +22,6 @@ from typing import Union import attr import py -import _pytest from _pytest._io.saferepr import saferepr from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -308,13 +307,10 @@ def get_real_method(obj, holder): def getfslineno(obj) -> Tuple[Union[str, py.path.local], int]: - # xxx let decorators etc specify a sane ordering - obj = get_real_func(obj) - if hasattr(obj, "place_as"): - obj = obj.place_as - fslineno = _pytest._code.getfslineno(obj) - assert isinstance(fslineno[1], int), obj - return fslineno + """(**Deprecated**, use _pytest._code.source.getfslineno directly)""" + from _pytest._code.source import getfslineno + + return getfslineno(obj) def getimfunc(func): diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5b3686b58..a6bfeb6d3 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -16,12 +16,12 @@ import py import _pytest from _pytest._code.code import FormattedExcinfo from _pytest._code.code import TerminalRepr +from _pytest._code.source import getfslineno from _pytest._io import TerminalWriter from _pytest.compat import _format_args from _pytest.compat import _PytestWrapper from _pytest.compat import get_real_func from _pytest.compat import get_real_method -from _pytest.compat import getfslineno from _pytest.compat import getfuncargnames from _pytest.compat import getimfunc from _pytest.compat import getlocation diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 3002f8abc..de4333a62 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -6,9 +6,9 @@ from typing import Set import attr +from .._code.source import getfslineno from ..compat import ascii_escaped from ..compat import ATTRS_EQ_FIELD -from ..compat import getfslineno from ..compat import NOTSET from _pytest.outcomes import fail from _pytest.warning_types import PytestUnknownMarkWarning diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 5447f2541..218684e14 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -15,8 +15,8 @@ import _pytest._code from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import ReprExceptionInfo +from _pytest._code.source import getfslineno from _pytest.compat import cached_property -from _pytest.compat import getfslineno from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import PytestPluginManager diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 65ef1272b..525498de2 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -20,10 +20,10 @@ import _pytest from _pytest import fixtures from _pytest import nodes from _pytest._code import filter_traceback +from _pytest._code.source import getfslineno from _pytest.compat import ascii_escaped from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func -from _pytest.compat import getfslineno from _pytest.compat import getimfunc from _pytest.compat import getlocation from _pytest.compat import is_generator From 61f2a26675561d510ab4f736a5b3c5d4f8aa043c Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 3 Feb 2020 18:40:23 +0100 Subject: [PATCH 122/158] Code/getfslineno: keep empty co_filename Previously this would be turned via `py.path.local("")` into the current working directory. This appears to be what `fspath = fn and py.path.local(fn) or None` tries to avoid in `getfslineno`'s `TypeError` handling already, if `Code` would raise it. --- src/_pytest/_code/code.py | 2 ++ testing/code/test_source.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index b176dde98..cafd870f0 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -72,6 +72,8 @@ class Code: """ return a path object pointing to source code (or a str in case of OSError / non-existing file). """ + if not self.raw.co_filename: + return "" try: p = py.path.local(self.raw.co_filename) # maybe don't try this checking diff --git a/testing/code/test_source.py b/testing/code/test_source.py index b5efdb317..cf0930974 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -524,6 +524,14 @@ def test_getfslineno() -> None: B.__name__ = "B2" assert getfslineno(B)[1] == -1 + co = compile("...", "", "eval") + assert co.co_filename == "" + + if hasattr(sys, "pypy_version_info"): + assert getfslineno(co) == ("", -1) + else: + assert getfslineno(co) == ("", 0) + def test_code_of_object_instance_with_call() -> None: class A: From dab90ef726cf33579e692820f82797d8e906ff8a Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 3 Feb 2020 18:50:12 +0100 Subject: [PATCH 123/158] typing: fix getfslineno Closes https://github.com/pytest-dev/pytest/pull/6590. --- src/_pytest/_code/source.py | 11 +++++------ src/_pytest/compat.py | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index b5e18863f..432e1cbe8 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -8,6 +8,7 @@ import warnings from bisect import bisect_right from types import CodeType from types import FrameType +from typing import Any from typing import Iterator from typing import List from typing import Optional @@ -283,7 +284,7 @@ def compile_( # noqa: F811 return s.compile(filename, mode, flags, _genframe=_genframe) -def getfslineno(obj) -> Tuple[Optional[Union["Literal['']", py.path.local]], int]: +def getfslineno(obj: Any) -> Tuple[Union[str, py.path.local], int]: """ Return source location (path, lineno) for the given object. If the source cannot be determined return ("", -1). @@ -306,18 +307,16 @@ def getfslineno(obj) -> Tuple[Optional[Union["Literal['']", py.path.local]], int except TypeError: return "", -1 - fspath = fn and py.path.local(fn) or None + fspath = fn and py.path.local(fn) or "" lineno = -1 if fspath: try: _, lineno = findsource(obj) except IOError: pass + return fspath, lineno else: - fspath = code.path - lineno = code.firstlineno - assert isinstance(lineno, int) - return fspath, lineno + return code.path, code.firstlineno # diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index d6ee1d522..3a3645c5a 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -308,9 +308,9 @@ def get_real_method(obj, holder): def getfslineno(obj) -> Tuple[Union[str, py.path.local], int]: """(**Deprecated**, use _pytest._code.source.getfslineno directly)""" - from _pytest._code.source import getfslineno + import _pytest._code.source - return getfslineno(obj) + return _pytest._code.source.getfslineno(obj) def getimfunc(func): From 9c7f1d9b329f97914d75c2891f20def973429fa5 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 4 Feb 2020 02:40:59 +0100 Subject: [PATCH 124/158] Remove compat.getfslineno --- src/_pytest/compat.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 3a3645c5a..f204dbd2d 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -306,13 +306,6 @@ def get_real_method(obj, holder): return obj -def getfslineno(obj) -> Tuple[Union[str, py.path.local], int]: - """(**Deprecated**, use _pytest._code.source.getfslineno directly)""" - import _pytest._code.source - - return _pytest._code.source.getfslineno(obj) - - def getimfunc(func): try: return func.__func__ From aa0328782f9c92d7497ff28f77972afe3cb5b8e2 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 4 Feb 2020 02:56:23 +0100 Subject: [PATCH 125/158] assertion: save/restore hooks on item (#6646) --- changelog/6646.bugfix.rst | 1 + src/_pytest/assertion/__init__.py | 10 ++++++---- src/_pytest/config/__init__.py | 5 ++++- testing/test_assertion.py | 17 +++++++++++++---- 4 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 changelog/6646.bugfix.rst diff --git a/changelog/6646.bugfix.rst b/changelog/6646.bugfix.rst new file mode 100644 index 000000000..4dba3ed07 --- /dev/null +++ b/changelog/6646.bugfix.rst @@ -0,0 +1 @@ +Assertion rewriting hooks are (re)stored for the current item, which fixes them being still used after e.g. pytester's :func:`testdir.runpytest <_pytest.pytester.Testdir.runpytest>` etc. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a060723a7..cdb034703 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -8,6 +8,7 @@ from _pytest.assertion import rewrite from _pytest.assertion import truncate from _pytest.assertion import util from _pytest.compat import TYPE_CHECKING +from _pytest.config import hookimpl if TYPE_CHECKING: from _pytest.main import Session @@ -105,7 +106,8 @@ def pytest_collection(session: "Session") -> None: assertstate.hook.set_session(session) -def pytest_runtest_setup(item): +@hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_protocol(item): """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks The newinterpret and rewrite modules will use util._reprcompare if @@ -143,6 +145,7 @@ def pytest_runtest_setup(item): return res return None + saved_assert_hooks = util._reprcompare, util._assertion_pass util._reprcompare = callbinrepr if item.ihook.pytest_assertion_pass.get_hookimpls(): @@ -154,10 +157,9 @@ def pytest_runtest_setup(item): util._assertion_pass = call_assertion_pass_hook + yield -def pytest_runtest_teardown(item): - util._reprcompare = None - util._assertion_pass = None + util._reprcompare, util._assertion_pass = saved_assert_hooks def pytest_sessionfinish(session): diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index ed3334e5f..d4477ba81 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -27,7 +27,6 @@ from pluggy import HookspecMarker from pluggy import PluginManager import _pytest._code -import _pytest.assertion import _pytest.deprecated import _pytest.hookspec # the extension point definitions from .exceptions import PrintHelp @@ -260,6 +259,8 @@ class PytestPluginManager(PluginManager): """ def __init__(self): + import _pytest.assertion + super().__init__("pytest") # The objects are module objects, only used generically. self._conftest_plugins = set() # type: Set[object] @@ -891,6 +892,8 @@ class Config: ns, unknown_args = self._parser.parse_known_and_unknown_args(args) mode = getattr(ns, "assertmode", "plain") if mode == "rewrite": + import _pytest.assertion + try: hook = _pytest.assertion.install_importhook(self) except SystemError: diff --git a/testing/test_assertion.py b/testing/test_assertion.py index e975a3fea..dc260b39f 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -72,10 +72,19 @@ class TestImportHookInstallation: result = testdir.runpytest_subprocess() result.stdout.fnmatch_lines( [ - "E * AssertionError: ([[][]], [[][]], [[][]])*", - "E * assert" - " {'failed': 1, 'passed': 0, 'skipped': 0} ==" - " {'failed': 0, 'passed': 1, 'skipped': 0}", + "> r.assertoutcome(passed=1)", + "E AssertionError: ([[][]], [[][]], [[][]])*", + "E assert {'failed': 1,... 'skipped': 0} == {'failed': 0,... 'skipped': 0}", + "E Omitting 1 identical items, use -vv to show", + "E Differing items:", + "E Use -v to get the full diff", + ] + ) + # XXX: unstable output. + result.stdout.fnmatch_lines_random( + [ + "E {'failed': 1} != {'failed': 0}", + "E {'passed': 0} != {'passed': 1}", ] ) From 4316fe8a92ce457b897043c32bb49243858e9960 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 4 Feb 2020 02:59:20 +0100 Subject: [PATCH 126/158] testing/conftest.py: testdir: set PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 (#6655) Fixes https://github.com/pytest-dev/pytest/pull/4518. --- testing/acceptance_test.py | 2 ++ testing/conftest.py | 7 +++++++ testing/test_helpconfig.py | 1 + testing/test_junitxml.py | 1 + testing/test_terminal.py | 2 ++ 5 files changed, 13 insertions(+) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index f65a60b44..9bc7367c8 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -104,6 +104,8 @@ class TestGeneralUsage: @pytest.mark.parametrize("load_cov_early", [True, False]) def test_early_load_setuptools_name(self, testdir, monkeypatch, load_cov_early): + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + testdir.makepyfile(mytestplugin1_module="") testdir.makepyfile(mytestplugin2_module="") testdir.makepyfile(mycov_module="") diff --git a/testing/conftest.py b/testing/conftest.py index 33b817a12..3127fda6a 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,6 +1,7 @@ import sys import pytest +from _pytest.pytester import Testdir if sys.gettrace(): @@ -118,3 +119,9 @@ def dummy_yaml_custom_test(testdir): """ ) testdir.makefile(".yaml", test1="") + + +@pytest.fixture +def testdir(testdir: Testdir) -> Testdir: + testdir.monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + return testdir diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 1dee5b0f5..a06ba0e26 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -3,6 +3,7 @@ from _pytest.main import ExitCode def test_version(testdir, pytestconfig): + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") result = testdir.runpytest("--version") assert result.ret == 0 # p = py.path.local(py.__file__).dirpath() diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 365332d70..6532a89b1 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1227,6 +1227,7 @@ def test_runs_twice(testdir, run_and_parse): def test_runs_twice_xdist(testdir, run_and_parse): pytest.importorskip("xdist") + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") f = testdir.makepyfile( """ def test_pass(): diff --git a/testing/test_terminal.py b/testing/test_terminal.py index c3a0c17e1..cc2f6d5fb 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -604,6 +604,7 @@ class TestTerminalFunctional: assert result.ret == 0 def test_header_trailer_info(self, testdir, request): + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") testdir.makepyfile( """ def test_passes(): @@ -714,6 +715,7 @@ class TestTerminalFunctional: if not pytestconfig.pluginmanager.get_plugin("xdist"): pytest.skip("xdist plugin not installed") + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") result = testdir.runpytest( verbose_testfile, "-v", "-n 1", "-Walways::pytest.PytestWarning" ) From 5a4c1b628b6b6b349618cabd8da4b52a63ec5b98 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 4 Feb 2020 03:07:53 +0100 Subject: [PATCH 127/158] Use inspect.getdoc to massage fixture docstrings (#6668) Ref: https://github.com/pytest-dev/pytest/pull/2575 --- src/_pytest/python.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 466f260f7..b960d9784 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -9,7 +9,6 @@ from collections import Counter from collections import defaultdict from collections.abc import Sequence from functools import partial -from textwrap import dedent from typing import List from typing import Optional from typing import Tuple @@ -1247,7 +1246,7 @@ def _show_fixtures_per_test(config, session): else: funcargspec = argname tw.line(funcargspec, green=True) - fixture_doc = fixture_def.func.__doc__ + fixture_doc = inspect.getdoc(fixture_def.func) if fixture_doc: write_docstring(tw, fixture_doc) else: @@ -1332,7 +1331,7 @@ def _showfixtures_main(config, session): tw.write(" -- %s" % bestrel, yellow=True) tw.write("\n") loc = getlocation(fixturedef.func, curdir) - doc = fixturedef.func.__doc__ or "" + doc = inspect.getdoc(fixturedef.func) if doc: write_docstring(tw, doc) else: @@ -1341,18 +1340,8 @@ def _showfixtures_main(config, session): def write_docstring(tw, doc, indent=" "): - doc = doc.rstrip() - if "\n" in doc: - firstline, rest = doc.split("\n", 1) - else: - firstline, rest = doc, "" - - if firstline.strip(): - tw.line(indent + firstline.strip()) - - if rest: - for line in dedent(rest).split("\n"): - tw.write(indent + line + "\n") + for line in doc.split("\n"): + tw.write(indent + line + "\n") class Function(PyobjMixin, nodes.Item): From cdc7e130679c35fbb54bcff033a2b7b2d8ff3029 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 5 Feb 2020 20:42:57 +0100 Subject: [PATCH 128/158] pytester: clarify _makefile signature (#6675) --- src/_pytest/pytester.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index cfe1b9a6c..60088502e 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -608,14 +608,14 @@ class Testdir: """ self.tmpdir.chdir() - def _makefile(self, ext, args, kwargs, encoding="utf-8"): - items = list(kwargs.items()) + def _makefile(self, ext, lines, files, encoding="utf-8"): + items = list(files.items()) def to_text(s): return s.decode(encoding) if isinstance(s, bytes) else str(s) - if args: - source = "\n".join(to_text(x) for x in args) + if lines: + source = "\n".join(to_text(x) for x in lines) basename = self.request.function.__name__ items.insert(0, (basename, source)) From ef437ea44831c949650376c24f925a023f4192db Mon Sep 17 00:00:00 2001 From: Minuddin Ahmed Rana Date: Thu, 6 Feb 2020 01:45:21 +0600 Subject: [PATCH 129/158] Remove incorrect choices comment (#6677) --- src/_pytest/junitxml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 206e44d96..c99c79f10 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -410,7 +410,7 @@ def pytest_addoption(parser): "Write captured log messages to JUnit report: " "one of no|system-out|system-err", default="no", - ) # choices=['no', 'stdout', 'stderr']) + ) parser.addini( "junit_log_passing_tests", "Capture log information for passing tests to JUnit report: ", From 9e262038c84a99d1353551e8cbb32f46362b58b4 Mon Sep 17 00:00:00 2001 From: Vladyslav Rachek <36896640+erheron@users.noreply.github.com> Date: Fri, 7 Feb 2020 00:20:25 +0100 Subject: [PATCH 130/158] [parametrize] enforce explicit argnames declaration (#6330) Every argname used in `parametrize` either must be declared explicitly in the python test function, or via `indirect` list Fix #5712 --- AUTHORS | 1 + changelog/5712.feature.rst | 2 ++ doc/en/example/parametrize.rst | 3 ++ src/_pytest/fixtures.py | 16 +++++++---- src/_pytest/python.py | 33 ++++++++++++++++++++++ testing/python/collect.py | 2 +- testing/python/metafunc.py | 51 ++++++++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 changelog/5712.feature.rst diff --git a/AUTHORS b/AUTHORS index bd4c1fe5b..d223c34de 100644 --- a/AUTHORS +++ b/AUTHORS @@ -274,6 +274,7 @@ Vidar T. Fauske Virgil Dupras Vitaly Lashmanov Vlad Dragos +Vladyslav Rachek Volodymyr Piskun Wei Lin Wil Cooley diff --git a/changelog/5712.feature.rst b/changelog/5712.feature.rst new file mode 100644 index 000000000..5b4971e4f --- /dev/null +++ b/changelog/5712.feature.rst @@ -0,0 +1,2 @@ +Now all arguments to ``@pytest.mark.parametrize`` need to be explicitly declared in the function signature or via ``indirect``. +Previously it was possible to omit an argument if a fixture with the same name existed, which was just an accident of implementation and was not meant to be a part of the API. diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index 15593b28a..f1425342b 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -398,6 +398,9 @@ The result of this test will be successful: .. regendoc:wipe +Note, that each argument in `parametrize` list should be explicitly declared in corresponding +python test function or via `indirect`. + Parametrizing test methods through per-class configuration -------------------------------------------------------------- diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index fb513be68..cc98c8192 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1,6 +1,5 @@ import functools import inspect -import itertools import sys import warnings from collections import defaultdict @@ -1279,10 +1278,8 @@ class FixtureManager: else: argnames = () - usefixtures = itertools.chain.from_iterable( - mark.args for mark in node.iter_markers(name="usefixtures") - ) - initialnames = tuple(usefixtures) + argnames + usefixtures = get_use_fixtures_for_node(node) + initialnames = usefixtures + argnames fm = node.session._fixturemanager initialnames, names_closure, arg2fixturedefs = fm.getfixtureclosure( initialnames, node, ignore_args=self._get_direct_parametrize_args(node) @@ -1479,3 +1476,12 @@ class FixtureManager: for fixturedef in fixturedefs: if nodes.ischildnode(fixturedef.baseid, nodeid): yield fixturedef + + +def get_use_fixtures_for_node(node) -> Tuple[str, ...]: + """Returns the names of all the usefixtures() marks on the given node""" + return tuple( + str(name) + for mark in node.iter_markers(name="usefixtures") + for name in mark.args + ) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index b960d9784..69bc5ce72 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -955,6 +955,8 @@ class Metafunc: arg_values_types = self._resolve_arg_value_types(argnames, indirect) + self._validate_explicit_parameters(argnames, indirect) + # Use any already (possibly) generated ids with parametrize Marks. if _param_mark and _param_mark._param_ids_from: generated_ids = _param_mark._param_ids_from._param_ids_generated @@ -1105,6 +1107,37 @@ class Metafunc: pytrace=False, ) + def _validate_explicit_parameters(self, argnames, indirect): + """ + The argnames in *parametrize* should either be declared explicitly via + indirect list or in the function signature + + :param List[str] argnames: list of argument names passed to ``parametrize()``. + :param indirect: same ``indirect`` parameter of ``parametrize()``. + :raise ValueError: if validation fails + """ + if isinstance(indirect, bool) and indirect is True: + return + parametrized_argnames = list() + funcargnames = _pytest.compat.getfuncargnames(self.function) + if isinstance(indirect, Sequence): + for arg in argnames: + if arg not in indirect: + parametrized_argnames.append(arg) + elif indirect is False: + parametrized_argnames = argnames + + usefixtures = fixtures.get_use_fixtures_for_node(self.definition) + + for arg in parametrized_argnames: + if arg not in funcargnames and arg not in usefixtures: + func_name = self.function.__name__ + msg = ( + 'In function "{func_name}":\n' + 'Parameter "{arg}" should be declared explicitly via indirect or in function itself' + ).format(func_name=func_name, arg=arg) + fail(msg, pytrace=False) + def _find_parametrized_scope(argnames, arg2fixturedefs, indirect): """Find the most appropriate scope for a parametrized call based on its arguments. diff --git a/testing/python/collect.py b/testing/python/collect.py index e299991cf..072180226 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -463,7 +463,7 @@ class TestFunction: return '3' @pytest.mark.parametrize('fix2', ['2']) - def test_it(fix1): + def test_it(fix1, fix2): assert fix1 == '21' assert not fix3_instantiated """ diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index cc1c3a34f..ac13011e3 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -28,6 +28,9 @@ class TestMetafunc: class DefinitionMock(python.FunctionDefinition): obj = attr.ib() + def listchain(self): + return [] + names = fixtures.getfuncargnames(func) fixtureinfo = FixtureInfo(names) definition = DefinitionMock._create(func) @@ -1877,3 +1880,51 @@ class TestMarkersWithParametrization: "*= 6 passed in *", ] ) + + def test_parametrize_explicit_parameters_func(self, testdir): + testdir.makepyfile( + """ + import pytest + + + @pytest.fixture + def fixture(arg): + return arg + + @pytest.mark.parametrize("arg", ["baz"]) + def test_without_arg(fixture): + assert "baz" == fixture + """ + ) + result = testdir.runpytest() + result.assert_outcomes(error=1) + result.stdout.fnmatch_lines( + [ + '*In function "test_without_arg"*', + '*Parameter "arg" should be declared explicitly via indirect or in function itself*', + ] + ) + + def test_parametrize_explicit_parameters_method(self, testdir): + testdir.makepyfile( + """ + import pytest + + class Test: + @pytest.fixture + def test_fixture(self, argument): + return argument + + @pytest.mark.parametrize("argument", ["foobar"]) + def test_without_argument(self, test_fixture): + assert "foobar" == test_fixture + """ + ) + result = testdir.runpytest() + result.assert_outcomes(error=1) + result.stdout.fnmatch_lines( + [ + '*In function "test_without_argument"*', + '*Parameter "argument" should be declared explicitly via indirect or in function itself*', + ] + ) From b4ace46c4290222c71240f0be530c974f5c96eeb Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 7 Feb 2020 19:23:37 +0100 Subject: [PATCH 131/158] capture: cleanup item fixture handling (#6663) This started by looking at how to get the current test item in general, and then I noticed that it is not necessary for the capture plugin to track it manually in the first place. --- src/_pytest/capture.py | 140 ++++++++++++++++++---------------------- testing/test_capture.py | 4 +- 2 files changed, 66 insertions(+), 78 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 08f520629..fbba0ecb5 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -10,11 +10,14 @@ import sys from io import UnsupportedOperation from tempfile import TemporaryFile from typing import BinaryIO +from typing import Generator from typing import Iterable +from typing import Optional import pytest from _pytest.compat import CaptureAndPassthroughIO from _pytest.compat import CaptureIO +from _pytest.config import Config from _pytest.fixtures import FixtureRequest patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"} @@ -40,7 +43,7 @@ def pytest_addoption(parser): @pytest.hookimpl(hookwrapper=True) -def pytest_load_initial_conftests(early_config, parser, args): +def pytest_load_initial_conftests(early_config: Config): ns = early_config.known_args_namespace if ns.capture == "fd": _py36_windowsconsoleio_workaround(sys.stdout) @@ -76,14 +79,14 @@ class CaptureManager: case special handling is needed to ensure the fixtures take precedence over the global capture. """ - def __init__(self, method): + def __init__(self, method) -> None: self._method = method self._global_capturing = None - self._current_item = None + self._capture_fixture = None # type: Optional[CaptureFixture] def __repr__(self): - return "".format( - self._method, self._global_capturing, self._current_item + return "".format( + self._method, self._global_capturing, self._capture_fixture ) def _getcapture(self, method): @@ -100,11 +103,8 @@ class CaptureManager: def is_capturing(self): if self.is_globally_capturing(): return "global" - capture_fixture = getattr(self._current_item, "_capture_fixture", None) - if capture_fixture is not None: - return ( - "fixture %s" % self._current_item._capture_fixture.request.fixturename - ) + if self._capture_fixture: + return "fixture %s" % self._capture_fixture.request.fixturename return False # Global capturing control @@ -136,41 +136,59 @@ class CaptureManager: def suspend(self, in_=False): # Need to undo local capsys-et-al if it exists before disabling global capture. - self.suspend_fixture(self._current_item) + self.suspend_fixture() self.suspend_global_capture(in_) def resume(self): self.resume_global_capture() - self.resume_fixture(self._current_item) + self.resume_fixture() def read_global_capture(self): return self._global_capturing.readouterr() # Fixture Control (it's just forwarding, think about removing this later) - def activate_fixture(self, item): + @contextlib.contextmanager + def _capturing_for_request( + self, request: FixtureRequest + ) -> Generator["CaptureFixture", None, None]: + if self._capture_fixture: + other_name = next( + k + for k, v in map_fixname_class.items() + if v is self._capture_fixture.captureclass + ) + raise request.raiseerror( + "cannot use {} and {} at the same time".format( + request.fixturename, other_name + ) + ) + capture_class = map_fixname_class[request.fixturename] + self._capture_fixture = CaptureFixture(capture_class, request) + self.activate_fixture() + yield self._capture_fixture + self._capture_fixture.close() + self._capture_fixture = None + + def activate_fixture(self): """If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over the global capture. """ - fixture = getattr(item, "_capture_fixture", None) - if fixture is not None: - fixture._start() + if self._capture_fixture: + self._capture_fixture._start() - def deactivate_fixture(self, item): + def deactivate_fixture(self): """Deactivates the ``capsys`` or ``capfd`` fixture of this item, if any.""" - fixture = getattr(item, "_capture_fixture", None) - if fixture is not None: - fixture.close() + if self._capture_fixture: + self._capture_fixture.close() - def suspend_fixture(self, item): - fixture = getattr(item, "_capture_fixture", None) - if fixture is not None: - fixture._suspend() + def suspend_fixture(self): + if self._capture_fixture: + self._capture_fixture._suspend() - def resume_fixture(self, item): - fixture = getattr(item, "_capture_fixture", None) - if fixture is not None: - fixture._resume() + def resume_fixture(self): + if self._capture_fixture: + self._capture_fixture._resume() # Helper context managers @@ -186,11 +204,11 @@ class CaptureManager: @contextlib.contextmanager def item_capture(self, when, item): self.resume_global_capture() - self.activate_fixture(item) + self.activate_fixture() try: yield finally: - self.deactivate_fixture(item) + self.deactivate_fixture() self.suspend_global_capture(in_=False) out, err = self.read_global_capture() @@ -214,12 +232,6 @@ class CaptureManager: else: yield - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_protocol(self, item): - self._current_item = item - yield - self._current_item = None - @pytest.hookimpl(hookwrapper=True) def pytest_runtest_setup(self, item): with self.item_capture("setup", item): @@ -244,18 +256,6 @@ class CaptureManager: self.stop_global_capturing() -capture_fixtures = {"capfd", "capfdbinary", "capsys", "capsysbinary"} - - -def _ensure_only_one_capture_fixture(request: FixtureRequest, name): - fixtures = sorted(set(request.fixturenames) & capture_fixtures - {name}) - if fixtures: - arg = fixtures[0] if len(fixtures) == 1 else fixtures - raise request.raiseerror( - "cannot use {} and {} at the same time".format(arg, name) - ) - - @pytest.fixture def capsys(request): """Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. @@ -264,8 +264,8 @@ def capsys(request): calls, which return a ``(out, err)`` namedtuple. ``out`` and ``err`` will be ``text`` objects. """ - _ensure_only_one_capture_fixture(request, "capsys") - with _install_capture_fixture_on_item(request, SysCapture) as fixture: + capman = request.config.pluginmanager.getplugin("capturemanager") + with capman._capturing_for_request(request) as fixture: yield fixture @@ -277,8 +277,8 @@ def capsysbinary(request): method calls, which return a ``(out, err)`` namedtuple. ``out`` and ``err`` will be ``bytes`` objects. """ - _ensure_only_one_capture_fixture(request, "capsysbinary") - with _install_capture_fixture_on_item(request, SysCaptureBinary) as fixture: + capman = request.config.pluginmanager.getplugin("capturemanager") + with capman._capturing_for_request(request) as fixture: yield fixture @@ -290,12 +290,12 @@ def capfd(request): calls, which return a ``(out, err)`` namedtuple. ``out`` and ``err`` will be ``text`` objects. """ - _ensure_only_one_capture_fixture(request, "capfd") if not hasattr(os, "dup"): pytest.skip( "capfd fixture needs os.dup function which is not available in this system" ) - with _install_capture_fixture_on_item(request, FDCapture) as fixture: + capman = request.config.pluginmanager.getplugin("capturemanager") + with capman._capturing_for_request(request) as fixture: yield fixture @@ -307,35 +307,15 @@ def capfdbinary(request): calls, which return a ``(out, err)`` namedtuple. ``out`` and ``err`` will be ``byte`` objects. """ - _ensure_only_one_capture_fixture(request, "capfdbinary") if not hasattr(os, "dup"): pytest.skip( "capfdbinary fixture needs os.dup function which is not available in this system" ) - with _install_capture_fixture_on_item(request, FDCaptureBinary) as fixture: + capman = request.config.pluginmanager.getplugin("capturemanager") + with capman._capturing_for_request(request) as fixture: yield fixture -@contextlib.contextmanager -def _install_capture_fixture_on_item(request, capture_class): - """ - Context manager which creates a ``CaptureFixture`` instance and "installs" it on - the item/node of the given request. Used by ``capsys`` and ``capfd``. - - The CaptureFixture is added as attribute of the item because it needs to accessed - by ``CaptureManager`` during its ``pytest_runtest_*`` hooks. - """ - request.node._capture_fixture = fixture = CaptureFixture(capture_class, request) - capmanager = request.config.pluginmanager.getplugin("capturemanager") - # Need to active this fixture right away in case it is being used by another fixture (setup phase). - # If this fixture is being used only by a test function (call phase), then we wouldn't need this - # activation, but it doesn't hurt. - capmanager.activate_fixture(request.node) - yield fixture - fixture.close() - del request.node._capture_fixture - - class CaptureFixture: """ Object returned by :py:func:`capsys`, :py:func:`capsysbinary`, :py:func:`capfd` and :py:func:`capfdbinary` @@ -707,6 +687,14 @@ class SysCaptureBinary(SysCapture): return res +map_fixname_class = { + "capfd": FDCapture, + "capfdbinary": FDCaptureBinary, + "capsys": SysCapture, + "capsysbinary": SysCaptureBinary, +} + + class DontReadFromInput: encoding = None diff --git a/testing/test_capture.py b/testing/test_capture.py index 7d6afa1e4..1a70cb1a5 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -477,9 +477,9 @@ class TestCaptureFixture: result.stdout.fnmatch_lines( [ "*test_one*", - "*capsys*capfd*same*time*", + "E * cannot use capfd and capsys at the same time", "*test_two*", - "*capfd*capsys*same*time*", + "E * cannot use capsys and capfd at the same time", "*2 failed in*", ] ) From 30cb598e9c4bda0d35aeea6657ba6a957bcec957 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sun, 9 Feb 2020 11:42:07 +0100 Subject: [PATCH 132/158] Typing around/from types in docs (#6699) --- src/_pytest/logging.py | 23 +++++++++++++---------- src/_pytest/mark/structures.py | 7 +++++-- src/_pytest/nodes.py | 11 +++++++---- src/_pytest/python.py | 9 ++++++--- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index df0da3daa..3fccee005 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -10,6 +10,7 @@ from typing import List from typing import Mapping import pytest +from _pytest import nodes from _pytest.compat import nullcontext from _pytest.config import _strtobool from _pytest.config import create_terminal_writer @@ -326,13 +327,13 @@ class LogCaptureFixture: logger.setLevel(level) @property - def handler(self): + def handler(self) -> LogCaptureHandler: """ :rtype: LogCaptureHandler """ - return self._item.catch_log_handler + return self._item.catch_log_handler # type: ignore[no-any-return] # noqa: F723 - def get_records(self, when): + def get_records(self, when: str) -> List[logging.LogRecord]: """ Get the logging records for one of the possible test phases. @@ -346,7 +347,7 @@ class LogCaptureFixture: """ handler = self._item.catch_log_handlers.get(when) if handler: - return handler.records + return handler.records # type: ignore[no-any-return] # noqa: F723 else: return [] @@ -613,7 +614,9 @@ class LoggingPlugin: yield @contextmanager - def _runtest_for_main(self, item, when): + def _runtest_for_main( + self, item: nodes.Item, when: str + ) -> Generator[None, None, None]: """Implements the internals of pytest_runtest_xxx() hook.""" with catching_logs( LogCaptureHandler(), formatter=self.formatter, level=self.log_level @@ -626,15 +629,15 @@ class LoggingPlugin: return if not hasattr(item, "catch_log_handlers"): - item.catch_log_handlers = {} - item.catch_log_handlers[when] = log_handler - item.catch_log_handler = log_handler + item.catch_log_handlers = {} # type: ignore[attr-defined] # noqa: F821 + item.catch_log_handlers[when] = log_handler # type: ignore[attr-defined] # noqa: F821 + item.catch_log_handler = log_handler # type: ignore[attr-defined] # noqa: F821 try: yield # run test finally: if when == "teardown": - del item.catch_log_handler - del item.catch_log_handlers + del item.catch_log_handler # type: ignore[attr-defined] # noqa: F821 + del item.catch_log_handlers # type: ignore[attr-defined] # noqa: F821 if self.print_logs: # Add a captured log section to the report. diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index de4333a62..161f623ee 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -2,7 +2,10 @@ import inspect import warnings from collections import namedtuple from collections.abc import MutableMapping +from typing import Iterable +from typing import List from typing import Set +from typing import Union import attr @@ -144,7 +147,7 @@ class Mark: #: keyword arguments of the mark decorator kwargs = attr.ib() # Dict[str, object] - def combined_with(self, other): + def combined_with(self, other: "Mark") -> "Mark": """ :param other: the mark to combine with :type other: Mark @@ -249,7 +252,7 @@ def get_unpacked_marks(obj): return normalize_mark_list(mark_list) -def normalize_mark_list(mark_list): +def normalize_mark_list(mark_list: Iterable[Union[Mark, MarkDecorator]]) -> List[Mark]: """ normalizes marker decorating helpers to mark objects diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 218684e14..641f889fe 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -333,7 +333,9 @@ class Node: return self._repr_failure_py(excinfo, style) -def get_fslocation_from_item(item): +def get_fslocation_from_item( + item: "Item", +) -> Tuple[Union[str, py.path.local], Optional[int]]: """Tries to extract the actual location from an item, depending on available attributes: * "fslocation": a pair (path, lineno) @@ -342,9 +344,10 @@ def get_fslocation_from_item(item): :rtype: a tuple of (str|LocalPath, int) with filename and line number. """ - result = getattr(item, "location", None) - if result is not None: - return result[:2] + try: + return item.location[:2] + except AttributeError: + pass obj = getattr(item, "obj", None) if obj is not None: return getfslineno(obj) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 525498de2..5309c8dd0 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -10,6 +10,7 @@ from collections import defaultdict from collections.abc import Sequence from functools import partial from textwrap import dedent +from typing import Dict from typing import List from typing import Tuple from typing import Union @@ -36,6 +37,7 @@ from _pytest.compat import STRING_TYPES from _pytest.config import hookimpl from _pytest.deprecated import FUNCARGNAMES from _pytest.mark import MARK_GEN +from _pytest.mark import ParameterSet from _pytest.mark.structures import get_unpacked_marks from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail @@ -947,7 +949,6 @@ class Metafunc: to set a dynamic scope using test context or configuration. """ from _pytest.fixtures import scope2index - from _pytest.mark import ParameterSet argnames, parameters = ParameterSet._for_parametrize( argnames, @@ -996,7 +997,9 @@ class Metafunc: newcalls.append(newcallspec) self._calls = newcalls - def _resolve_arg_ids(self, argnames, ids, parameters, item): + def _resolve_arg_ids( + self, argnames: List[str], ids, parameters: List[ParameterSet], item: nodes.Item + ): """Resolves the actual ids for the given argnames, based on the ``ids`` parameter given to ``parametrize``. @@ -1028,7 +1031,7 @@ class Metafunc: ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item) return ids - def _resolve_arg_value_types(self, argnames, indirect): + def _resolve_arg_value_types(self, argnames: List[str], indirect) -> Dict[str, str]: """Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg" to the function, based on the ``indirect`` parameter of the parametrized() call. From a62d9a40e7aaf1936f99436fdbd3b7bc7d5994c1 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 10 Feb 2020 10:28:41 +0100 Subject: [PATCH 133/158] ci: Travis: 3.5.1: upgrade pip, setuptools, virtualenv Ref: https://github.com/jaraco/zipp/issues/40 --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 59c7951e4..d773d4ab5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -46,6 +46,9 @@ jobs: - env: TOXENV=py35 PYTEST_COVERAGE=1 PYTEST_ADDOPTS="-k test_raises_cyclic_reference" python: '3.5.1' dist: trusty + before_install: + # Work around https://github.com/jaraco/zipp/issues/40. + - python -m pip install -U pip 'setuptools>=34.4.0' virtualenv before_script: - | From 12824e62798165cd39306420ffa440e3987e87b1 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 10 Feb 2020 10:59:28 +0100 Subject: [PATCH 134/158] ci: Travis: remove non-py35 jobs --- .travis.yml | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/.travis.yml b/.travis.yml index d773d4ab5..32ab7f6fd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python -dist: xenial -python: '3.7' +dist: trusty +python: '3.5.1' cache: false env: @@ -16,36 +16,8 @@ install: jobs: include: - # OSX tests - first (in test stage), since they are the slower ones. - # Coverage for: - # - osx - # - verbose=1 - - os: osx - osx_image: xcode10.1 - language: generic - env: TOXENV=py37-xdist PYTEST_COVERAGE=1 PYTEST_ADDOPTS=-v - before_install: - - which python3 - - python3 -V - - ln -sfn "$(which python3)" /usr/local/bin/python - - python -V - - test $(python -c 'import sys; print("%d%d" % sys.version_info[0:2])') = 37 - - # Full run of latest supported version, without xdist. - # Coverage for: - # - pytester's LsofFdLeakChecker - # - TestArgComplete (linux only) - # - numpy - # - old attrs - # - verbose=0 - # - test_sys_breakpoint_interception (via pexpect). - - env: TOXENV=py37-lsof-numpy-oldattrs-pexpect-twisted PYTEST_COVERAGE=1 PYTEST_ADDOPTS= - python: '3.7' - # Coverage for Python 3.5.{0,1} specific code, mostly typing related. - env: TOXENV=py35 PYTEST_COVERAGE=1 PYTEST_ADDOPTS="-k test_raises_cyclic_reference" - python: '3.5.1' - dist: trusty before_install: # Work around https://github.com/jaraco/zipp/issues/40. - python -m pip install -U pip 'setuptools>=34.4.0' virtualenv From 449290406c37010f04bcc114b3af356bb1ae50f8 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 10 Feb 2020 11:52:19 +0100 Subject: [PATCH 135/158] test_argcomplete: remove usage of `distutils.spawn` (#6703) Fixes collection error with Python 3.5.3 (Travis): testing/test_parseopt.py:2: in import distutils.spawn .tox/py35-coverage/lib/python3.5/distutils/__init__.py:4: in import imp .tox/py35-coverage/lib/python3.5/imp.py:33: in PendingDeprecationWarning, stacklevel=2) E PendingDeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses Build log: https://travis-ci.org/blueyed/pytest/builds/648305304 --- testing/test_parseopt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index ded5167d8..7c94fdb1e 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -1,7 +1,7 @@ import argparse -import distutils.spawn import os import shlex +import shutil import sys import py @@ -291,7 +291,7 @@ class TestParser: def test_argcomplete(testdir, monkeypatch): - if not distutils.spawn.find_executable("bash"): + if not shutil.which("bash"): pytest.skip("bash not available") script = str(testdir.tmpdir.join("test_argcomplete")) From f9dd58000a42fba69936ca18ea54c642d4fd73da Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 10 Feb 2020 13:03:05 +0100 Subject: [PATCH 136/158] Fix CaptureManager.__repr__ (#6697) --- src/_pytest/capture.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index fbba0ecb5..bd17d05ef 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -85,7 +85,7 @@ class CaptureManager: self._capture_fixture = None # type: Optional[CaptureFixture] def __repr__(self): - return "".format( + return "".format( self._method, self._global_capturing, self._capture_fixture ) From 0b2b40e35dbcebb26fc72c068bfb11637e09e10b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 10 Feb 2020 14:43:06 +0200 Subject: [PATCH 137/158] Remove some redundant commas Fix mypy errors: src/_pytest/runner.py:36: error: "addoption" of "OptionGroup" does not return a value [func-returns-value] src/_pytest/helpconfig.py:64: error: "addoption" of "OptionGroup" does not return a value [func-returns-value] src/_pytest/terminal.py:67: error: "_addoption" of "OptionGroup" does not return a value [func-returns-value] src/_pytest/terminal.py:75: error: "_addoption" of "OptionGroup" does not return a value [func-returns-value] --- src/_pytest/helpconfig.py | 2 +- src/_pytest/runner.py | 2 +- src/_pytest/terminal.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 21155de2c..87cd2c0a7 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -66,7 +66,7 @@ def pytest_addoption(parser): action="store_true", default=False, help="trace considerations of conftest.py files.", - ), + ) group.addoption( "--debug", action="store_true", diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 8928ea6d4..e10e4d8bd 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -39,7 +39,7 @@ def pytest_addoption(parser): default=None, metavar="N", help="show N slowest setup/test durations (N=0 for all).", - ), + ) def pytest_terminal_summary(terminalreporter): diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 1248abe1b..713f6d91e 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -70,7 +70,7 @@ def pytest_addoption(parser): default=0, dest="verbose", help="increase verbosity.", - ), + ) group._addoption( "-q", "--quiet", @@ -78,7 +78,7 @@ def pytest_addoption(parser): default=0, dest="verbose", help="decrease verbosity.", - ), + ) group._addoption( "--verbosity", dest="verbose", From 3e4e6297ce031bdbe907da0bdf461982e4fb808e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 10 Feb 2020 16:25:34 +0200 Subject: [PATCH 138/158] Remove unused field FixtureManager._arg2finish Not used since 4f0879ff9b3e7ad0da1839131c35b4e6412b972f. --- src/_pytest/fixtures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index cc98c8192..37b0485e1 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1249,7 +1249,6 @@ class FixtureManager: self.config = session.config self._arg2fixturedefs = {} self._holderobjseen = set() - self._arg2finish = {} self._nodeid_and_autousenames = [("", self.config.getini("usefixtures"))] session.config.pluginmanager.register(self, "funcmanage") From 7cc513b2af070c13b227a60b17aecfd35ee1a871 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 10 Feb 2020 22:03:09 +0200 Subject: [PATCH 139/158] Remove unused NodeMarkers This class was both added and became unused during the development of a PR: https://github.com/pytest-dev/pytest/pull/3317 --- src/_pytest/mark/structures.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 020260dd5..1ca7c6969 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -9,7 +9,6 @@ from typing import Set import attr from ..compat import ascii_escaped -from ..compat import ATTRS_EQ_FIELD from ..compat import getfslineno from ..compat import NOTSET from _pytest.outcomes import fail @@ -391,35 +390,3 @@ class NodeKeywords(MutableMapping): def __repr__(self): return "".format(self.node) - - -# mypy cannot find this overload, remove when on attrs>=19.2 -@attr.s(hash=False, **{ATTRS_EQ_FIELD: False}) # type: ignore -class NodeMarkers: - """ - internal structure for storing marks belonging to a node - - ..warning:: - - unstable api - - """ - - own_markers = attr.ib(default=attr.Factory(list)) - - def update(self, add_markers): - """update the own markers - """ - self.own_markers.extend(add_markers) - - def find(self, name): - """ - find markers in own nodes or parent nodes - needs a better place - """ - for mark in self.own_markers: - if mark.name == name: - yield mark - - def __iter__(self): - return iter(self.own_markers) From d59adc61f98c6d3d27c0417981c7355319fdacd1 Mon Sep 17 00:00:00 2001 From: Stefan Scherfke Date: Tue, 4 Feb 2020 14:38:18 +0100 Subject: [PATCH 140/158] Reverse / fix meaning of "+/-" in error diffs The convention is "assert result is expected". Pytest's error diffs now reflect this. "-" means that sth. expected is missing in the result and "+" means that there are unexpected extras in the result. Fixes: #3333 --- AUTHORS | 1 + changelog/6673.breaking.rst | 1 + doc/en/example/reportingdemo.rst | 16 +- src/_pytest/assertion/util.py | 19 ++- testing/acceptance_test.py | 4 +- testing/test_assertion.py | 108 ++++++------ testing/test_assertrewrite.py | 16 +- testing/test_error_diffs.py | 274 +++++++++++++++++++++++++++++++ 8 files changed, 360 insertions(+), 79 deletions(-) create mode 100644 changelog/6673.breaking.rst create mode 100644 testing/test_error_diffs.py diff --git a/AUTHORS b/AUTHORS index bd4c1fe5b..56c7fb0e3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -246,6 +246,7 @@ Simon Gomizelj Skylar Downes Srinivas Reddy Thatiparthy Stefan Farmbauer +Stefan Scherfke Stefan Zimmermann Stefano Taschini Steffen Allner diff --git a/changelog/6673.breaking.rst b/changelog/6673.breaking.rst new file mode 100644 index 000000000..d64305eb6 --- /dev/null +++ b/changelog/6673.breaking.rst @@ -0,0 +1 @@ +Reversed / fix meaning of "+/-" in error diffs. "-" means that sth. expected is missing in the result and "+" means that there are unexpected extras in the result. diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index 1c06782f6..1ab0f9c82 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -81,8 +81,8 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_text(self): > assert "spam" == "eggs" E AssertionError: assert 'spam' == 'eggs' - E - spam - E + eggs + E - eggs + E + spam failure_demo.py:45: AssertionError _____________ TestSpecialisedExplanations.test_eq_similar_text _____________ @@ -92,9 +92,9 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_eq_similar_text(self): > assert "foo 1 bar" == "foo 2 bar" E AssertionError: assert 'foo 1 bar' == 'foo 2 bar' - E - foo 1 bar + E - foo 2 bar E ? ^ - E + foo 2 bar + E + foo 1 bar E ? ^ failure_demo.py:48: AssertionError @@ -106,8 +106,8 @@ Here is a nice run of several failures and how ``pytest`` presents things: > assert "foo\nspam\nbar" == "foo\neggs\nbar" E AssertionError: assert 'foo\nspam\nbar' == 'foo\neggs\nbar' E foo - E - spam - E + eggs + E - eggs + E + spam E bar failure_demo.py:51: AssertionError @@ -122,9 +122,9 @@ Here is a nice run of several failures and how ``pytest`` presents things: E AssertionError: assert '111111111111...2222222222222' == '111111111111...2222222222222' E Skipping 90 identical leading characters in diff, use -v to show E Skipping 91 identical trailing characters in diff, use -v to show - E - 1111111111a222222222 + E - 1111111111b222222222 E ? ^ - E + 1111111111b222222222 + E + 1111111111a222222222 E ? ^ failure_demo.py:56: AssertionError diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 67f8d4618..51a25490f 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -225,9 +225,11 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: left = repr(str(left)) right = repr(str(right)) explanation += ["Strings contain only whitespace, escaping them using repr()"] + # "right" is the expected base against which we compare "left", + # see https://github.com/pytest-dev/pytest/issues/3333 explanation += [ line.strip("\n") - for line in ndiff(left.splitlines(keepends), right.splitlines(keepends)) + for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) ] return explanation @@ -238,8 +240,8 @@ def _compare_eq_verbose(left: Any, right: Any) -> List[str]: right_lines = repr(right).splitlines(keepends) explanation = [] # type: List[str] - explanation += ["-" + line for line in left_lines] - explanation += ["+" + line for line in right_lines] + explanation += ["+" + line for line in left_lines] + explanation += ["-" + line for line in right_lines] return explanation @@ -279,8 +281,10 @@ def _compare_eq_iterable( _surrounding_parens_on_own_lines(right_formatting) explanation = ["Full diff:"] + # "right" is the expected base against which we compare "left", + # see https://github.com/pytest-dev/pytest/issues/3333 explanation.extend( - line.rstrip() for line in difflib.ndiff(left_formatting, right_formatting) + line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting) ) return explanation @@ -315,8 +319,9 @@ def _compare_eq_sequence( break if comparing_bytes: - # when comparing bytes, it doesn't help to show the "sides contain one or more items" - # longer explanation, so skip it + # when comparing bytes, it doesn't help to show the "sides contain one or more + # items" longer explanation, so skip it + return explanation len_diff = len_left - len_right @@ -443,7 +448,7 @@ def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]: head = text[:index] tail = text[index + len(term) :] correct_text = head + tail - diff = _diff_text(correct_text, text, verbose) + diff = _diff_text(text, correct_text, verbose) newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)] for line in diff: if line.startswith("Skipping"): diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 68e8a97f8..20b370055 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1277,8 +1277,8 @@ def test_pdb_can_be_rewritten(testdir): " def check():", "> assert 1 == 2", "E assert 1 == 2", - "E -1", - "E +2", + "E +1", + "E -2", "", "pdb.py:2: AssertionError", "*= 1 failed in *", diff --git a/testing/test_assertion.py b/testing/test_assertion.py index e975a3fea..ffc07158d 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -316,8 +316,8 @@ class TestAssert_reprcompare: def test_text_diff(self): diff = callequal("spam", "eggs")[1:] - assert "- spam" in diff - assert "+ eggs" in diff + assert "- eggs" in diff + assert "+ spam" in diff def test_text_skipping(self): lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs") @@ -327,15 +327,15 @@ class TestAssert_reprcompare: def test_text_skipping_verbose(self): lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs", verbose=1) - assert "- " + "a" * 50 + "spam" in lines - assert "+ " + "a" * 50 + "eggs" in lines + assert "- " + "a" * 50 + "eggs" in lines + assert "+ " + "a" * 50 + "spam" in lines def test_multiline_text_diff(self): left = "foo\nspam\nbar" right = "foo\neggs\nbar" diff = callequal(left, right) - assert "- spam" in diff - assert "+ eggs" in diff + assert "- eggs" in diff + assert "+ spam" in diff def test_bytes_diff_normal(self): """Check special handling for bytes diff (#5260)""" @@ -354,8 +354,8 @@ class TestAssert_reprcompare: "b'spam' == b'eggs'", "At index 0 diff: b's' != b'e'", "Full diff:", - "- b'spam'", - "+ b'eggs'", + "- b'eggs'", + "+ b'spam'", ] def test_list(self): @@ -370,9 +370,9 @@ class TestAssert_reprcompare: [0, 2], """ Full diff: - - [0, 1] + - [0, 2] ? ^ - + [0, 2] + + [0, 1] ? ^ """, id="lists", @@ -382,9 +382,9 @@ class TestAssert_reprcompare: {0: 2}, """ Full diff: - - {0: 1} + - {0: 2} ? ^ - + {0: 2} + + {0: 1} ? ^ """, id="dicts", @@ -394,9 +394,9 @@ class TestAssert_reprcompare: {0, 2}, """ Full diff: - - {0, 1} + - {0, 2} ? ^ - + {0, 2} + + {0, 1} ? ^ """, id="sets", @@ -433,7 +433,7 @@ class TestAssert_reprcompare: " 'a',", " 'b',", " 'c',", - "+ '" + long_d + "',", + "- '" + long_d + "',", " ]", ] @@ -446,7 +446,7 @@ class TestAssert_reprcompare: " 'a',", " 'b',", " 'c',", - "- '" + long_d + "',", + "+ '" + long_d + "',", " ]", ] @@ -462,10 +462,10 @@ class TestAssert_reprcompare: "At index 0 diff: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' != 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'", "Full diff:", " [", - "- 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", + "+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", " 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',", " 'cccccccccccccccccccccccccccccc',", - "+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", + "- 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',", " ]", ] @@ -480,28 +480,28 @@ class TestAssert_reprcompare: "Left contains 7 more items, first extra item: 'aaaaaaaaaa'", "Full diff:", " [", - "+ 'should not get wrapped',", - "- 'a',", - "- 'aaaaaaaaaa',", - "- 'aaaaaaaaaa',", - "- 'aaaaaaaaaa',", - "- 'aaaaaaaaaa',", - "- 'aaaaaaaaaa',", - "- 'aaaaaaaaaa',", - "- 'aaaaaaaaaa',", + "- 'should not get wrapped',", + "+ 'a',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", + "+ 'aaaaaaaaaa',", " ]", ] def test_dict_wrap(self): - d1 = {"common": 1, "env": {"env1": 1}} - d2 = {"common": 1, "env": {"env1": 1, "env2": 2}} + d1 = {"common": 1, "env": {"env1": 1, "env2": 2}} + d2 = {"common": 1, "env": {"env1": 1}} diff = callequal(d1, d2, verbose=True) assert diff == [ - "{'common': 1,...: {'env1': 1}} == {'common': 1,...1, 'env2': 2}}", + "{'common': 1,...1, 'env2': 2}} == {'common': 1,...: {'env1': 1}}", "Omitting 1 identical items, use -vv to show", "Differing items:", - "{'env': {'env1': 1}} != {'env': {'env1': 1, 'env2': 2}}", + "{'env': {'env1': 1, 'env2': 2}} != {'env': {'env1': 1}}", "Full diff:", "- {'common': 1, 'env': {'env1': 1}}", "+ {'common': 1, 'env': {'env1': 1, 'env2': 2}}", @@ -523,7 +523,7 @@ class TestAssert_reprcompare: " 'env': {'sub': {'long_a': '" + long_a + "',", " 'sub1': {'long_a': 'substring that gets wrapped substring '", " 'that gets wrapped '}}},", - "+ 'new': 1,", + "- 'new': 1,", " }", ] @@ -561,8 +561,8 @@ class TestAssert_reprcompare: "Right contains 2 more items:", "{'b': 1, 'c': 2}", "Full diff:", - "- {'a': 0}", - "+ {'b': 1, 'c': 2}", + "- {'b': 1, 'c': 2}", + "+ {'a': 0}", ] lines = callequal({"b": 1, "c": 2}, {"a": 0}, verbose=2) assert lines == [ @@ -572,8 +572,8 @@ class TestAssert_reprcompare: "Right contains 1 more item:", "{'a': 0}", "Full diff:", - "- {'b': 1, 'c': 2}", - "+ {'a': 0}", + "- {'a': 0}", + "+ {'b': 1, 'c': 2}", ] def test_sequence_different_items(self): @@ -583,8 +583,8 @@ class TestAssert_reprcompare: "At index 0 diff: 1 != 3", "Right contains one more item: 5", "Full diff:", - "- (1, 2)", - "+ (3, 4, 5)", + "- (3, 4, 5)", + "+ (1, 2)", ] lines = callequal((1, 2, 3), (4,), verbose=2) assert lines == [ @@ -592,8 +592,8 @@ class TestAssert_reprcompare: "At index 0 diff: 1 != 4", "Left contains 2 more items, first extra item: 2", "Full diff:", - "- (1, 2, 3)", - "+ (4,)", + "- (4,)", + "+ (1, 2, 3)", ] def test_set(self): @@ -654,12 +654,12 @@ class TestAssert_reprcompare: assert callequal(nums_x, nums_y) is None expl = callequal(nums_x, nums_y, verbose=1) - assert "-" + repr(nums_x) in expl - assert "+" + repr(nums_y) in expl + assert "+" + repr(nums_x) in expl + assert "-" + repr(nums_y) in expl expl = callequal(nums_x, nums_y, verbose=2) - assert "-" + repr(nums_x) in expl - assert "+" + repr(nums_y) in expl + assert "+" + repr(nums_x) in expl + assert "-" + repr(nums_y) in expl def test_list_bad_repr(self): class A: @@ -693,8 +693,8 @@ class TestAssert_reprcompare: right = "£" expl = callequal(left, right) assert expl[0] == "'£€' == '£'" - assert expl[1] == "- £€" - assert expl[2] == "+ £" + assert expl[1] == "- £" + assert expl[2] == "+ £€" def test_nonascii_text(self): """ @@ -707,7 +707,7 @@ class TestAssert_reprcompare: return "\xff" expl = callequal(A(), "1") - assert expl == ["ÿ == '1'", "+ 1"] + assert expl == ["ÿ == '1'", "- 1"] def test_format_nonascii_explanation(self): assert util.format_explanation("λ") @@ -1007,9 +1007,9 @@ class TestTruncateExplanation: # without -vv, truncate the message showing a few diff lines only result.stdout.fnmatch_lines( [ - "*- 1*", - "*- 3*", - "*- 5*", + "*+ 1*", + "*+ 3*", + "*+ 5*", "*truncated (%d lines hidden)*use*-vv*" % expected_truncated_lines, ] ) @@ -1062,9 +1062,9 @@ def test_reprcompare_whitespaces(): assert detail == [ r"'\r\n' == '\n'", r"Strings contain only whitespace, escaping them using repr()", - r"- '\r\n'", - r"? --", - r"+ '\n'", + r"- '\n'", + r"+ '\r\n'", + r"? ++", ] @@ -1312,8 +1312,8 @@ def test_diff_newline_at_end(testdir): r""" *assert 'asdf' == 'asdf\n' * - asdf + * ? - * + asdf - * ? + """ ) diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 8490a59e6..e03a13930 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -180,8 +180,8 @@ class TestAssertionRewrite: if verbose > 0: assert msg == ( "assert == 42\n" - " -\n" - " +42" + " +\n" + " -42" ) else: assert msg == "assert sys == 42" @@ -194,12 +194,12 @@ class TestAssertionRewrite: msg = getmsg(f, {"cls": X}).splitlines() if verbose > 1: - assert msg == ["assert {!r} == 42".format(X), " -{!r}".format(X), " +42"] + assert msg == ["assert {!r} == 42".format(X), " +{!r}".format(X), " -42"] elif verbose > 0: assert msg == [ "assert .X'> == 42", - " -{!r}".format(X), - " +42", + " +{!r}".format(X), + " -42", ] else: assert msg == ["assert cls == 42"] @@ -241,7 +241,7 @@ class TestAssertionRewrite: # XXX: looks like the "where" should also be there in verbose mode?! message = getmsg(f, {"cls": Y}).splitlines() if request.config.getoption("verbose") > 0: - assert message == ["assert 3 == 2", " -3", " +2"] + assert message == ["assert 3 == 2", " +3", " -2"] else: assert message == [ "assert 3 == 2", @@ -625,7 +625,7 @@ class TestAssertionRewrite: msg = getmsg(f) if request.config.getoption("verbose") > 0: - assert msg == "assert 10 == 11\n -10\n +11" + assert msg == "assert 10 == 11\n +10\n -11" else: assert msg == "assert 10 == 11\n + where 10 = len([0, 1, 2, 3, 4, 5, ...])" @@ -688,7 +688,7 @@ class TestAssertionRewrite: lines = util._format_lines([getmsg(f)]) if request.config.getoption("verbose") > 0: - assert lines == ["assert 0 == 1\n -0\n +1"] + assert lines == ["assert 0 == 1\n +0\n -1"] else: assert lines == ["assert 0 == 1\n + where 1 = \\n{ \\n~ \\n}.a"] diff --git a/testing/test_error_diffs.py b/testing/test_error_diffs.py new file mode 100644 index 000000000..c7198bde0 --- /dev/null +++ b/testing/test_error_diffs.py @@ -0,0 +1,274 @@ +""" +Tests and examples for correct "+/-" usage in error diffs. + +See https://github.com/pytest-dev/pytest/issues/3333 for details. + +""" +import sys + +import pytest + + +TESTCASES = [ + pytest.param( + """ + def test_this(): + result = [1, 4, 3] + expected = [1, 2, 3] + assert result == expected + """, + """ + > assert result == expected + E assert [1, 4, 3] == [1, 2, 3] + E At index 1 diff: 4 != 2 + E Full diff: + E - [1, 2, 3] + E ? ^ + E + [1, 4, 3] + E ? ^ + """, + id="Compare lists, one item differs", + ), + pytest.param( + """ + def test_this(): + result = [1, 2, 3] + expected = [1, 2] + assert result == expected + """, + """ + > assert result == expected + E assert [1, 2, 3] == [1, 2] + E Left contains one more item: 3 + E Full diff: + E - [1, 2] + E + [1, 2, 3] + E ? +++ + """, + id="Compare lists, one extra item", + ), + pytest.param( + """ + def test_this(): + result = [1, 3] + expected = [1, 2, 3] + assert result == expected + """, + """ + > assert result == expected + E assert [1, 3] == [1, 2, 3] + E At index 1 diff: 3 != 2 + E Right contains one more item: 3 + E Full diff: + E - [1, 2, 3] + E ? --- + E + [1, 3] + """, + id="Compare lists, one item missing", + ), + pytest.param( + """ + def test_this(): + result = (1, 4, 3) + expected = (1, 2, 3) + assert result == expected + """, + """ + > assert result == expected + E assert (1, 4, 3) == (1, 2, 3) + E At index 1 diff: 4 != 2 + E Full diff: + E - (1, 2, 3) + E ? ^ + E + (1, 4, 3) + E ? ^ + """, + id="Compare tuples", + ), + pytest.param( + """ + def test_this(): + result = {1, 3, 4} + expected = {1, 2, 3} + assert result == expected + """, + """ + > assert result == expected + E assert {1, 3, 4} == {1, 2, 3} + E Extra items in the left set: + E 4 + E Extra items in the right set: + E 2 + E Full diff: + E - {1, 2, 3} + E ? ^ ^ + E + {1, 3, 4} + E ? ^ ^ + """, + id="Compare sets", + ), + pytest.param( + """ + def test_this(): + result = {1: 'spam', 3: 'eggs'} + expected = {1: 'spam', 2: 'eggs'} + assert result == expected + """, + """ + > assert result == expected + E AssertionError: assert {1: 'spam', 3: 'eggs'} == {1: 'spam', 2: 'eggs'} + E Common items: + E {1: 'spam'} + E Left contains 1 more item: + E {3: 'eggs'} + E Right contains 1 more item: + E {2: 'eggs'} + E Full diff: + E - {1: 'spam', 2: 'eggs'} + E ? ^ + E + {1: 'spam', 3: 'eggs'} + E ? ^ + """, + id="Compare dicts with differing keys", + ), + pytest.param( + """ + def test_this(): + result = {1: 'spam', 2: 'eggs'} + expected = {1: 'spam', 2: 'bacon'} + assert result == expected + """, + """ + > assert result == expected + E AssertionError: assert {1: 'spam', 2: 'eggs'} == {1: 'spam', 2: 'bacon'} + E Common items: + E {1: 'spam'} + E Differing items: + E {2: 'eggs'} != {2: 'bacon'} + E Full diff: + E - {1: 'spam', 2: 'bacon'} + E ? ^^^^^ + E + {1: 'spam', 2: 'eggs'} + E ? ^^^^ + """, + id="Compare dicts with differing values", + ), + pytest.param( + """ + def test_this(): + result = {1: 'spam', 2: 'eggs'} + expected = {1: 'spam', 3: 'bacon'} + assert result == expected + """, + """ + > assert result == expected + E AssertionError: assert {1: 'spam', 2: 'eggs'} == {1: 'spam', 3: 'bacon'} + E Common items: + E {1: 'spam'} + E Left contains 1 more item: + E {2: 'eggs'} + E Right contains 1 more item: + E {3: 'bacon'} + E Full diff: + E - {1: 'spam', 3: 'bacon'} + E ? ^ ^^^^^ + E + {1: 'spam', 2: 'eggs'} + E ? ^ ^^^^ + """, + id="Compare dicts with differing items", + ), + pytest.param( + """ + def test_this(): + result = "spmaeggs" + expected = "spameggs" + assert result == expected + """, + """ + > assert result == expected + E AssertionError: assert 'spmaeggs' == 'spameggs' + E - spameggs + E ? - + E + spmaeggs + E ? + + """, + id="Compare strings", + ), + pytest.param( + """ + def test_this(): + result = "spam bacon eggs" + assert "bacon" not in result + """, + """ + > assert "bacon" not in result + E AssertionError: assert 'bacon' not in 'spam bacon eggs' + E 'bacon' is contained here: + E spam bacon eggs + E ? +++++ + """, + id='Test "not in" string', + ), +] +if sys.version_info[:2] >= (3, 7): + TESTCASES.extend( + [ + pytest.param( + """ + from dataclasses import dataclass + + @dataclass + class A: + a: int + b: str + + def test_this(): + result = A(1, 'spam') + expected = A(2, 'spam') + assert result == expected + """, + """ + > assert result == expected + E AssertionError: assert A(a=1, b='spam') == A(a=2, b='spam') + E Matching attributes: + E ['b'] + E Differing attributes: + E a: 1 != 2 + """, + id="Compare data classes", + ), + pytest.param( + """ + import attr + + @attr.s(auto_attribs=True) + class A: + a: int + b: str + + def test_this(): + result = A(1, 'spam') + expected = A(1, 'eggs') + assert result == expected + """, + """ + > assert result == expected + E AssertionError: assert A(a=1, b='spam') == A(a=1, b='eggs') + E Matching attributes: + E ['a'] + E Differing attributes: + E b: 'spam' != 'eggs' + """, + id="Compare attrs classes", + ), + ] + ) + + +@pytest.mark.parametrize("code, expected", TESTCASES) +def test_error_diff(code, expected, testdir): + expected = [l.lstrip() for l in expected.splitlines()] + p = testdir.makepyfile(code) + result = testdir.runpytest(p, "-vv") + result.stdout.fnmatch_lines(expected) + assert result.ret == 1 From d33da078a8a661c61d9453c282f0b86cc1455d05 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 10 Feb 2020 23:43:30 +0200 Subject: [PATCH 141/158] Move ExitCode's definition from _pytest.main to _pytest.config ExitCode is used in several internal modules and hooks and so with type annotations added, needs to be imported a lot. _pytest.main, being the entry point, generally sits at the top of the import tree. So, it's not great to have ExitCode defined in _pytest.main, because it will cause a lot of import cycles once type annotations are added (in fact there is already one, which this change removes). Move it to _pytest.config instead. _pytest.main still imports ExitCode, so importing from there still works, although external users should really be importing from `pytest`. --- doc/en/reference.rst | 2 +- doc/en/usage.rst | 2 +- src/_pytest/config/__init__.py | 28 +++++++++++++++++++++++++--- src/_pytest/main.py | 25 +------------------------ src/_pytest/pytester.py | 2 +- src/_pytest/terminal.py | 2 +- src/pytest/__init__.py | 2 +- testing/acceptance_test.py | 4 ++-- testing/python/collect.py | 2 +- testing/test_assertrewrite.py | 2 +- testing/test_cacheprovider.py | 2 +- testing/test_capture.py | 2 +- testing/test_collection.py | 2 +- testing/test_config.py | 4 ++-- testing/test_conftest.py | 2 +- testing/test_helpconfig.py | 2 +- testing/test_main.py | 2 +- testing/test_mark.py | 2 +- testing/test_pluginmanager.py | 2 +- testing/test_pytester.py | 2 +- testing/test_runner.py | 8 ++++---- testing/test_session.py | 2 +- testing/test_setuponly.py | 2 +- testing/test_terminal.py | 2 +- testing/test_unittest.py | 2 +- 25 files changed, 54 insertions(+), 55 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 7d4f1dafa..d9b0b4c8d 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -738,7 +738,7 @@ ExceptionInfo pytest.ExitCode ~~~~~~~~~~~~~~~ -.. autoclass:: _pytest.main.ExitCode +.. autoclass:: _pytest.config.ExitCode :members: diff --git a/doc/en/usage.rst b/doc/en/usage.rst index ff8a1748f..961e813cf 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -33,7 +33,7 @@ Running ``pytest`` can result in six different exit codes: :Exit code 4: pytest command line usage error :Exit code 5: No tests were collected -They are represented by the :class:`_pytest.main.ExitCode` enum. The exit codes being a part of the public API can be imported and accessed directly using: +They are represented by the :class:`_pytest.config.ExitCode` enum. The exit codes being a part of the public API can be imported and accessed directly using: .. code-block:: python diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6fbbf1959..b37ee7916 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1,6 +1,7 @@ """ command line options, ini-file and conftest.py processing. """ import argparse import copy +import enum import inspect import os import shlex @@ -61,6 +62,29 @@ hookimpl = HookimplMarker("pytest") hookspec = HookspecMarker("pytest") +class ExitCode(enum.IntEnum): + """ + .. versionadded:: 5.0 + + Encodes the valid exit codes by pytest. + + Currently users and plugins may supply other exit codes as well. + """ + + #: tests passed + OK = 0 + #: tests failed + TESTS_FAILED = 1 + #: pytest was interrupted + INTERRUPTED = 2 + #: an internal error got in the way + INTERNAL_ERROR = 3 + #: pytest was misused + USAGE_ERROR = 4 + #: pytest couldn't find tests + NO_TESTS_COLLECTED = 5 + + class ConftestImportFailure(Exception): def __init__(self, path, excinfo): Exception.__init__(self, path, excinfo) @@ -68,7 +92,7 @@ class ConftestImportFailure(Exception): self.excinfo = excinfo # type: Tuple[Type[Exception], Exception, TracebackType] -def main(args=None, plugins=None) -> "Union[int, _pytest.main.ExitCode]": +def main(args=None, plugins=None) -> Union[int, ExitCode]: """ return exit code, after performing an in-process test run. :arg args: list of command line arguments. @@ -76,8 +100,6 @@ def main(args=None, plugins=None) -> "Union[int, _pytest.main.ExitCode]": :arg plugins: list of plugin objects to be auto-registered during initialization. """ - from _pytest.main import ExitCode - try: try: config = _prepareconfig(args, plugins) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 1db97dc55..ea1c48f70 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -1,5 +1,4 @@ """ core implementation of testing process: init, session, runtest loop. """ -import enum import fnmatch import functools import importlib @@ -21,6 +20,7 @@ from _pytest import nodes from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import directory_arg +from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.fixtures import FixtureManager @@ -36,29 +36,6 @@ if TYPE_CHECKING: from _pytest.python import Package -class ExitCode(enum.IntEnum): - """ - .. versionadded:: 5.0 - - Encodes the valid exit codes by pytest. - - Currently users and plugins may supply other exit codes as well. - """ - - #: tests passed - OK = 0 - #: tests failed - TESTS_FAILED = 1 - #: pytest was interrupted - INTERRUPTED = 2 - #: an internal error got in the way - INTERNAL_ERROR = 3 - #: pytest was misused - USAGE_ERROR = 4 - #: pytest couldn't find tests - NO_TESTS_COLLECTED = 5 - - def pytest_addoption(parser): parser.addini( "norecursedirs", diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 0b51f8bb0..08ed29fc8 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -30,8 +30,8 @@ from _pytest.capture import MultiCapture from _pytest.capture import SysCapture from _pytest.compat import TYPE_CHECKING from _pytest.config import _PluggyPlugin +from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest -from _pytest.main import ExitCode from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 713f6d91e..9f12015d6 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -26,7 +26,7 @@ from more_itertools import collapse import pytest from _pytest import nodes from _pytest.config import Config -from _pytest.main import ExitCode +from _pytest.config import ExitCode from _pytest.main import Session from _pytest.reports import CollectReport from _pytest.reports import TestReport diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 7b79603af..33bc3d0fb 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -6,6 +6,7 @@ from _pytest import __version__ from _pytest.assertion import register_assert_rewrite from _pytest.compat import _setup_collect_fakemodule from _pytest.config import cmdline +from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import hookspec from _pytest.config import main @@ -15,7 +16,6 @@ from _pytest.fixtures import fillfixtures as _fillfuncargs from _pytest.fixtures import fixture from _pytest.fixtures import yield_fixture from _pytest.freeze_support import freeze_includes -from _pytest.main import ExitCode from _pytest.main import Session from _pytest.mark import MARK_GEN as mark from _pytest.mark import param diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 68e8a97f8..87cca494b 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -8,7 +8,7 @@ import py import pytest from _pytest.compat import importlib_metadata -from _pytest.main import ExitCode +from _pytest.config import ExitCode def prepend_pythonpath(*dirs): @@ -412,7 +412,7 @@ class TestGeneralUsage: def test_report_all_failed_collections_initargs(self, testdir): testdir.makeconftest( """ - from _pytest.main import ExitCode + from _pytest.config import ExitCode def pytest_sessionfinish(exitstatus): assert exitstatus == ExitCode.USAGE_ERROR diff --git a/testing/python/collect.py b/testing/python/collect.py index 072180226..460905860 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -4,7 +4,7 @@ import textwrap import _pytest._code import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode from _pytest.nodes import Collector diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 8490a59e6..017142255 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -21,7 +21,7 @@ from _pytest.assertion.rewrite import get_cache_dir from _pytest.assertion.rewrite import PYC_TAIL from _pytest.assertion.rewrite import PYTEST_TAG from _pytest.assertion.rewrite import rewrite_asserts -from _pytest.main import ExitCode +from _pytest.config import ExitCode from _pytest.pathlib import Path diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 2690a7de8..d37f18f0f 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -6,7 +6,7 @@ import sys import py import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode pytest_plugins = ("pytester",) diff --git a/testing/test_capture.py b/testing/test_capture.py index 1a70cb1a5..7447d9742 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -15,7 +15,7 @@ from typing import TextIO import pytest from _pytest import capture from _pytest.capture import CaptureManager -from _pytest.main import ExitCode +from _pytest.config import ExitCode # note: py.io capture tests where copied from # pylib 1.4.20.dev2 (rev 13d9af95547e) diff --git a/testing/test_collection.py b/testing/test_collection.py index 56f2efc84..20afa42d0 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -6,8 +6,8 @@ import textwrap import py import pytest +from _pytest.config import ExitCode from _pytest.main import _in_venv -from _pytest.main import ExitCode from _pytest.main import Session from _pytest.pytester import Testdir diff --git a/testing/test_config.py b/testing/test_config.py index 8a1be0b27..993d65a67 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -7,11 +7,11 @@ import pytest from _pytest.compat import importlib_metadata from _pytest.config import _iter_rewritable_modules from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config.exceptions import UsageError from _pytest.config.findpaths import determine_setup from _pytest.config.findpaths import get_common_ancestor from _pytest.config.findpaths import getcfg -from _pytest.main import ExitCode from _pytest.pathlib import Path @@ -1134,7 +1134,7 @@ class TestOverrideIniArgs: % (testdir.request.config._parser.optparser.prog,) ] ) - assert result.ret == _pytest.main.ExitCode.USAGE_ERROR + assert result.ret == _pytest.config.ExitCode.USAGE_ERROR def test_override_ini_does_not_contain_paths(self, _config_for_test, _sys_snapshot): """Check that -o no longer swallows all options after it (#3103)""" diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 9e893152d..a07af60f6 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -4,8 +4,8 @@ import textwrap import py import pytest +from _pytest.config import ExitCode from _pytest.config import PytestPluginManager -from _pytest.main import ExitCode from _pytest.pathlib import Path diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 1dee5b0f5..b96eeccc3 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -1,5 +1,5 @@ import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode def test_version(testdir, pytestconfig): diff --git a/testing/test_main.py b/testing/test_main.py index b47791b29..eea529f34 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -1,5 +1,5 @@ import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode @pytest.mark.parametrize( diff --git a/testing/test_mark.py b/testing/test_mark.py index 6ac4e0b23..76ee289b6 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -3,7 +3,7 @@ import sys from unittest import mock import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode from _pytest.mark import EMPTY_PARAMETERSET_OPTION from _pytest.mark import MarkGenerator as Mark from _pytest.nodes import Collector diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 43026f0a3..336f468a8 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -3,9 +3,9 @@ import sys import types import pytest +from _pytest.config import ExitCode from _pytest.config import PytestPluginManager from _pytest.config.exceptions import UsageError -from _pytest.main import ExitCode from _pytest.main import Session diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 8b69ce124..3c9e92e3f 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -8,8 +8,8 @@ import py.path import _pytest.pytester as pytester import pytest +from _pytest.config import ExitCode from _pytest.config import PytestPluginManager -from _pytest.main import ExitCode from _pytest.outcomes import Failed from _pytest.pytester import CwdSnapshot from _pytest.pytester import HookRecorder diff --git a/testing/test_runner.py b/testing/test_runner.py index 1600b6b7c..246917950 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -10,10 +10,10 @@ import py import _pytest._code import pytest -from _pytest import main from _pytest import outcomes from _pytest import reports from _pytest import runner +from _pytest.config import ExitCode from _pytest.outcomes import Exit from _pytest.outcomes import Failed from _pytest.outcomes import OutcomeException @@ -681,7 +681,7 @@ def test_pytest_fail_notrace_non_ascii(testdir) -> None: def test_pytest_no_tests_collected_exit_status(testdir) -> None: result = testdir.runpytest() result.stdout.fnmatch_lines(["*collected 0 items*"]) - assert result.ret == main.ExitCode.NO_TESTS_COLLECTED + assert result.ret == ExitCode.NO_TESTS_COLLECTED testdir.makepyfile( test_foo=""" @@ -692,12 +692,12 @@ def test_pytest_no_tests_collected_exit_status(testdir) -> None: result = testdir.runpytest() result.stdout.fnmatch_lines(["*collected 1 item*"]) result.stdout.fnmatch_lines(["*1 passed*"]) - assert result.ret == main.ExitCode.OK + assert result.ret == ExitCode.OK result = testdir.runpytest("-k nonmatch") result.stdout.fnmatch_lines(["*collected 1 item*"]) result.stdout.fnmatch_lines(["*1 deselected*"]) - assert result.ret == main.ExitCode.NO_TESTS_COLLECTED + assert result.ret == ExitCode.NO_TESTS_COLLECTED def test_exception_printing_skip() -> None: diff --git a/testing/test_session.py b/testing/test_session.py index 7b4eb817a..1f17acbbd 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -1,5 +1,5 @@ import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode class SessionTests: diff --git a/testing/test_setuponly.py b/testing/test_setuponly.py index 7549874db..e26a33dee 100644 --- a/testing/test_setuponly.py +++ b/testing/test_setuponly.py @@ -1,5 +1,5 @@ import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode @pytest.fixture(params=["--setup-only", "--setup-plan", "--setup-show"], scope="module") diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 4733469ae..53ae6d9d6 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -12,7 +12,7 @@ import pluggy import py import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode from _pytest.pytester import Testdir from _pytest.reports import BaseReport from _pytest.terminal import _folded_skips diff --git a/testing/test_unittest.py b/testing/test_unittest.py index 4b814532b..c5fc20239 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1,7 +1,7 @@ import gc import pytest -from _pytest.main import ExitCode +from _pytest.config import ExitCode def test_simple_unittest(testdir): From c3e53a072d99fe799a932d75ef180bae8f14531f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 25 Jan 2020 16:31:21 +0200 Subject: [PATCH 142/158] Switch to new git workflow Co-Authored-By: Daniel Hahler --- .github/PULL_REQUEST_TEMPLATE.md | 2 -- .github/workflows/main.yml | 2 ++ .pre-commit-config.yaml | 2 +- .travis.yml | 4 +--- CONTRIBUTING.rst | 11 ++--------- HOWTORELEASE.rst => RELEASING.rst | 30 ++++++++++++++---------------- doc/en/development_guide.rst | 2 +- doc/en/py27-py34-deprecation.rst | 10 +++++----- 8 files changed, 26 insertions(+), 37 deletions(-) rename HOWTORELEASE.rst => RELEASING.rst (57%) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2e221f73e..d189f7869 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,8 +3,6 @@ Thanks for submitting a PR, your contribution is really appreciated! Here is a quick checklist that should be present in PRs. -- [ ] Target the `master` branch for bug fixes, documentation updates and trivial changes. -- [ ] Target the `features` branch for new features, improvements, and removals/deprecations. - [ ] Include documentation when adding new features. - [ ] Include new tests or update existing tests when applicable. - [X] Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 242c2eb83..a8a9c527b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,12 +10,14 @@ on: push: branches: - master + - "[0-9]+.[0-9]+.x" tags: - "*" pull_request: branches: - master + - "[0-9]+.[0-9]+.x" jobs: build: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8894c713f..694dedc93 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,7 +47,7 @@ repos: - id: rst name: rst entry: rst-lint --encoding utf-8 - files: ^(HOWTORELEASE.rst|README.rst|TIDELIFT.rst)$ + files: ^(RELEASING.rst|README.rst|TIDELIFT.rst)$ language: python additional_dependencies: [pygments, restructuredtext_lint] - id: changelogs-rst diff --git a/.travis.yml b/.travis.yml index 32ab7f6fd..60be90e28 100644 --- a/.travis.yml +++ b/.travis.yml @@ -57,6 +57,4 @@ notifications: branches: only: - master - - features - - 4.6-maintenance - - /^\d+(\.\d+)+$/ + - /^\d+\.\d+\.x$/ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 0474fa3a3..57921de2f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -166,8 +166,6 @@ Short version #. Fork the repository. #. Enable and install `pre-commit `_ to ensure style-guides and code checks are followed. -#. Target ``master`` for bug fixes and doc changes. -#. Target ``features`` for new features or functionality changes. #. Follow **PEP-8** for naming and `black `_ for formatting. #. Tests are run using ``tox``:: @@ -204,14 +202,10 @@ Here is a simple overview, with pytest-specific bits: $ git clone git@github.com:YOUR_GITHUB_USERNAME/pytest.git $ cd pytest - # now, to fix a bug create your own branch off "master": + # now, create your own branch off "master": $ git checkout -b your-bugfix-branch-name master - # or to instead add a feature create your own branch off "features": - - $ git checkout -b your-feature-branch-name features - Given we have "major.minor.micro" version numbers, bug fixes will usually be released in micro releases whereas features will be released in minor releases and incompatible changes in major releases. @@ -294,8 +288,7 @@ Here is a simple overview, with pytest-specific bits: compare: your-branch-name base-fork: pytest-dev/pytest - base: master # if it's a bug fix - base: features # if it's a feature + base: master Writing Tests diff --git a/HOWTORELEASE.rst b/RELEASING.rst similarity index 57% rename from HOWTORELEASE.rst rename to RELEASING.rst index b6b596ba2..323e26cdf 100644 --- a/HOWTORELEASE.rst +++ b/RELEASING.rst @@ -10,40 +10,38 @@ taking a lot of time to make a new one. pytest releases must be prepared on **Linux** because the docs and examples expect to be executed on that platform. -#. Create a branch ``release-X.Y.Z`` with the version for the release. +To release a version ``MAJOR.MINOR.PATCH``, follow these steps: - * **maintenance releases**: from ``4.6-maintenance``; +#. For major and minor releases, create a new branch ``MAJOR.MINOR.x`` from the + latest ``master`` and push it to the ``pytest-dev/pytest`` repo. - * **patch releases**: from the latest ``master``; +#. Create a branch ``release-MAJOR.MINOR.PATCH`` from the ``MAJOR.MINOR.x`` branch. - * **minor releases**: from the latest ``features``; then merge with the latest ``master``; - - Ensure your are in a clean work tree. + Ensure your are updated and in a clean working tree. #. Using ``tox``, generate docs, changelog, announcements:: - $ tox -e release -- + $ tox -e release -- MAJOR.MINOR.PATCH This will generate a commit with all the changes ready for pushing. -#. Open a PR for this branch targeting ``master`` (or ``4.6-maintenance`` for - maintenance releases). +#. Open a PR for the ``release-MAJOR.MINOR.PATCH`` branch targeting ``MAJOR.MINOR.x``. -#. After all tests pass and the PR has been approved, publish to PyPI by pushing the tag:: +#. After all tests pass and the PR has been approved, tag the release commit + in the ``MAJOR.MINOR.x`` branch and push it. This will publish to PyPI:: - git tag - git push git@github.com:pytest-dev/pytest.git + git tag MAJOR.MINOR.PATCH + git push git@github.com:pytest-dev/pytest.git MAJOR.MINOR.PATCH Wait for the deploy to complete, then make sure it is `available on PyPI `_. #. Merge the PR. -#. If this is a maintenance release, cherry-pick the CHANGELOG / announce - files to the ``master`` branch:: +#. Cherry-pick the CHANGELOG / announce files to the ``master`` branch:: git fetch --all --prune - git checkout origin/master -b cherry-pick-maintenance-release - git cherry-pick --no-commit -m1 origin/4.6-maintenance + git checkout origin/master -b cherry-pick-release + git cherry-pick --no-commit -m1 origin/MAJOR.MINOR.x git checkout origin/master -- changelog git commit # no arguments diff --git a/doc/en/development_guide.rst b/doc/en/development_guide.rst index 31fc2c438..2f9762f2a 100644 --- a/doc/en/development_guide.rst +++ b/doc/en/development_guide.rst @@ -57,4 +57,4 @@ Issues created at those events should have other relevant labels added as well. Those labels should be removed after they are no longer relevant. -.. include:: ../../HOWTORELEASE.rst +.. include:: ../../RELEASING.rst diff --git a/doc/en/py27-py34-deprecation.rst b/doc/en/py27-py34-deprecation.rst index f09ee3aa4..f2d6b540d 100644 --- a/doc/en/py27-py34-deprecation.rst +++ b/doc/en/py27-py34-deprecation.rst @@ -29,9 +29,9 @@ Maintenance of 4.6.X versions ----------------------------- Until January 2020, the pytest core team ported many bug-fixes from the main release into the -``4.6-maintenance`` branch, with several 4.6.X releases being made along the year. +``4.6.x`` branch, with several 4.6.X releases being made along the year. -From now on, the core team will **no longer actively backport patches**, but the ``4.6-maintenance`` +From now on, the core team will **no longer actively backport patches**, but the ``4.6.x`` 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.6.X releases **until mid-2020** @@ -74,7 +74,7 @@ Please follow these instructions: #. ``git fetch --all --prune`` -#. ``git checkout origin/4.6-maintenance -b backport-XXXX`` # use the PR number here +#. ``git checkout origin/4.6.x -b backport-XXXX`` # use the PR number here #. Locate the merge commit on the PR, in the *merged* message, for example: @@ -82,14 +82,14 @@ Please follow these instructions: #. ``git cherry-pick -m1 REVISION`` # use the revision you found above (``0f8b462``). -#. Open a PR targeting ``4.6-maintenance``: +#. Open a PR targeting ``4.6.x``: * Prefix the message with ``[4.6]`` so it is an obvious backport * Delete the PR body, it usually contains a duplicate commit message. **Providing new PRs to 4.6** -Fresh pull requests to ``4.6-maintenance`` will be accepted provided that +Fresh pull requests to ``4.6.x`` will be accepted provided that the equivalent code in the active branches does not contain that bug (for example, a bug is specific to Python 2 only). From 3ea74310d7ac8902823fec453c61555dc77c528e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Feb 2020 07:06:20 -0300 Subject: [PATCH 143/158] Fix crash when faulthandler starts initialized (#6598) Use suggestion in review and use a subplugin so hooks will only be active if we enable faulthandler ourselves. Fix #6575 Co-authored-by: Daniel Hahler --- changelog/6575.bugfix.rst | 3 + src/_pytest/faulthandler.py | 122 +++++++++++++++++++++-------------- testing/test_faulthandler.py | 35 +++++++++- 3 files changed, 107 insertions(+), 53 deletions(-) create mode 100644 changelog/6575.bugfix.rst diff --git a/changelog/6575.bugfix.rst b/changelog/6575.bugfix.rst new file mode 100644 index 000000000..0fdfb64b3 --- /dev/null +++ b/changelog/6575.bugfix.rst @@ -0,0 +1,3 @@ +Fix internal crash when ``faulthandler`` starts initialized +(for example with ``PYTHONFAULTHANDLER=1`` environment variable set) and ``faulthandler_timeout`` defined +in the configuration file. diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index 068bec528..ed2dfd025 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -17,70 +17,92 @@ def pytest_addoption(parser): def pytest_configure(config): import faulthandler - # avoid trying to dup sys.stderr if faulthandler is already enabled - if faulthandler.is_enabled(): - return + if not faulthandler.is_enabled(): + # faulthhandler is not enabled, so install plugin that does the actual work + # of enabling faulthandler before each test executes. + config.pluginmanager.register(FaultHandlerHooks(), "faulthandler-hooks") + else: + from _pytest.warnings import _issue_warning_captured - stderr_fd_copy = os.dup(_get_stderr_fileno()) - config.fault_handler_stderr = os.fdopen(stderr_fd_copy, "w") - faulthandler.enable(file=config.fault_handler_stderr) + # Do not handle dumping to stderr if faulthandler is already enabled, so warn + # users that the option is being ignored. + timeout = FaultHandlerHooks.get_timeout_config_value(config) + if timeout > 0: + _issue_warning_captured( + pytest.PytestConfigWarning( + "faulthandler module enabled before pytest configuration step, " + "'faulthandler_timeout' option ignored" + ), + config.hook, + stacklevel=2, + ) -def _get_stderr_fileno(): - try: - return sys.stderr.fileno() - except (AttributeError, io.UnsupportedOperation): - # python-xdist monkeypatches sys.stderr with an object that is not an actual file. - # https://docs.python.org/3/library/faulthandler.html#issue-with-file-descriptors - # This is potentially dangerous, but the best we can do. - return sys.__stderr__.fileno() +class FaultHandlerHooks: + """Implements hooks that will actually install fault handler before tests execute, + as well as correctly handle pdb and internal errors.""" + def pytest_configure(self, config): + import faulthandler -def pytest_unconfigure(config): - import faulthandler + stderr_fd_copy = os.dup(self._get_stderr_fileno()) + config.fault_handler_stderr = os.fdopen(stderr_fd_copy, "w") + faulthandler.enable(file=config.fault_handler_stderr) - faulthandler.disable() - # close our dup file installed during pytest_configure - f = getattr(config, "fault_handler_stderr", None) - if f is not None: + def pytest_unconfigure(self, config): + import faulthandler + + faulthandler.disable() + # close our dup file installed during pytest_configure # re-enable the faulthandler, attaching it to the default sys.stderr # so we can see crashes after pytest has finished, usually during # garbage collection during interpreter shutdown config.fault_handler_stderr.close() del config.fault_handler_stderr - faulthandler.enable(file=_get_stderr_fileno()) + faulthandler.enable(file=self._get_stderr_fileno()) + @staticmethod + def _get_stderr_fileno(): + try: + return sys.stderr.fileno() + except (AttributeError, io.UnsupportedOperation): + # pytest-xdist monkeypatches sys.stderr with an object that is not an actual file. + # https://docs.python.org/3/library/faulthandler.html#issue-with-file-descriptors + # This is potentially dangerous, but the best we can do. + return sys.__stderr__.fileno() -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_protocol(item): - timeout = float(item.config.getini("faulthandler_timeout") or 0.0) - if timeout > 0: + @staticmethod + def get_timeout_config_value(config): + return float(config.getini("faulthandler_timeout") or 0.0) + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_protocol(self, item): + timeout = self.get_timeout_config_value(item.config) + stderr = item.config.fault_handler_stderr + if timeout > 0 and stderr is not None: + import faulthandler + + faulthandler.dump_traceback_later(timeout, file=stderr) + try: + yield + finally: + faulthandler.cancel_dump_traceback_later() + else: + yield + + @pytest.hookimpl(tryfirst=True) + def pytest_enter_pdb(self): + """Cancel any traceback dumping due to timeout before entering pdb. + """ import faulthandler - stderr = item.config.fault_handler_stderr - faulthandler.dump_traceback_later(timeout, file=stderr) - try: - yield - finally: - faulthandler.cancel_dump_traceback_later() - else: - yield + faulthandler.cancel_dump_traceback_later() + @pytest.hookimpl(tryfirst=True) + def pytest_exception_interact(self): + """Cancel any traceback dumping due to an interactive exception being + raised. + """ + import faulthandler -@pytest.hookimpl(tryfirst=True) -def pytest_enter_pdb(): - """Cancel any traceback dumping due to timeout before entering pdb. - """ - import faulthandler - - faulthandler.cancel_dump_traceback_later() - - -@pytest.hookimpl(tryfirst=True) -def pytest_exception_interact(): - """Cancel any traceback dumping due to an interactive exception being - raised. - """ - import faulthandler - - faulthandler.cancel_dump_traceback_later() + faulthandler.cancel_dump_traceback_later() diff --git a/testing/test_faulthandler.py b/testing/test_faulthandler.py index 73bb66cf8..49ade6e5d 100644 --- a/testing/test_faulthandler.py +++ b/testing/test_faulthandler.py @@ -88,7 +88,7 @@ def test_cancel_timeout_on_hook(monkeypatch, hook_name): exception (pytest-dev/pytest-faulthandler#14). """ import faulthandler - from _pytest import faulthandler as plugin_module + from _pytest.faulthandler import FaultHandlerHooks called = [] @@ -98,6 +98,35 @@ def test_cancel_timeout_on_hook(monkeypatch, hook_name): # call our hook explicitly, we can trust that pytest will call the hook # for us at the appropriate moment - hook_func = getattr(plugin_module, hook_name) - hook_func() + hook_func = getattr(FaultHandlerHooks, hook_name) + hook_func(self=None) assert called == [1] + + +@pytest.mark.parametrize("faulthandler_timeout", [0, 2]) +def test_already_initialized(faulthandler_timeout, testdir): + """Test for faulthandler being initialized earlier than pytest (#6575)""" + testdir.makepyfile( + """ + def test(): + import faulthandler + assert faulthandler.is_enabled() + """ + ) + result = testdir.run( + sys.executable, + "-X", + "faulthandler", + "-mpytest", + testdir.tmpdir, + "-o", + "faulthandler_timeout={}".format(faulthandler_timeout), + ) + # ensure warning is emitted if faulthandler_timeout is configured + warning_line = "*faulthandler.py*faulthandler module enabled before*" + if faulthandler_timeout > 0: + result.stdout.fnmatch_lines(warning_line) + else: + result.stdout.no_fnmatch_line(warning_line) + result.stdout.fnmatch_lines("*1 passed*") + assert result.ret == 0 From 4209ad6fcacb679d953f3dd6be96f330139006f0 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Feb 2020 08:32:37 -0300 Subject: [PATCH 144/158] Use code highlighting if pygments is installed (#6658) * Use code highlighting if pygments is installed * Use colorama constants instead of bare ascii codes Could not find the exact equivalent of 'hl-reset' code using colorama constants though. * Refactor ASCII color handling into a fixture * Revert back to using explicit color codes * In Python 3.5 skip rest of tests that require ordered markup in colored output --- changelog/6658.improvement.rst | 4 + src/_pytest/_code/code.py | 49 ++++++++++-- src/_pytest/_io/__init__.py | 42 ++++++++++- testing/code/test_terminal_writer.py | 28 +++++++ testing/conftest.py | 70 ++++++++++++++++++ testing/test_terminal.py | 107 +++++++++++++++++---------- tox.ini | 1 + 7 files changed, 252 insertions(+), 49 deletions(-) create mode 100644 changelog/6658.improvement.rst create mode 100644 testing/code/test_terminal_writer.py diff --git a/changelog/6658.improvement.rst b/changelog/6658.improvement.rst new file mode 100644 index 000000000..56b21f7e2 --- /dev/null +++ b/changelog/6658.improvement.rst @@ -0,0 +1,4 @@ +Code is now highlighted in tracebacks when ``pygments`` is installed. + +Users are encouraged to install ``pygments`` into their environment and provide feedback, because +the plan is to make ``pygments`` a regular dependency in the future. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index fba52926e..babac34f6 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1039,21 +1039,58 @@ class ReprEntry(TerminalRepr): self.reprfileloc = filelocrepr self.style = style + def _write_entry_lines(self, tw: TerminalWriter) -> None: + """Writes the source code portions of a list of traceback entries with syntax highlighting. + + Usually entries are lines like these: + + " x = 1" + "> assert x == 2" + "E assert 1 == 2" + + This function takes care of rendering the "source" portions of it (the lines without + the "E" prefix) using syntax highlighting, taking care to not highlighting the ">" + character, as doing so might break line continuations. + """ + + indent_size = 4 + + def is_fail(line): + return line.startswith("{} ".format(FormattedExcinfo.fail_marker)) + + if not self.lines: + return + + # separate indents and source lines that are not failures: we want to + # highlight the code but not the indentation, which may contain markers + # such as "> assert 0" + indents = [] + source_lines = [] + for line in self.lines: + if not is_fail(line): + indents.append(line[:indent_size]) + source_lines.append(line[indent_size:]) + + tw._write_source(source_lines, indents) + + # failure lines are always completely red and bold + for line in (x for x in self.lines if is_fail(x)): + tw.line(line, bold=True, red=True) + def toterminal(self, tw: TerminalWriter) -> None: if self.style == "short": assert self.reprfileloc is not None self.reprfileloc.toterminal(tw) - for line in self.lines: - red = line.startswith("E ") - tw.line(line, bold=True, red=red) + self._write_entry_lines(tw) if self.reprlocals: self.reprlocals.toterminal(tw, indent=" " * 8) return + if self.reprfuncargs: self.reprfuncargs.toterminal(tw) - for line in self.lines: - red = line.startswith("E ") - tw.line(line, bold=True, red=red) + + self._write_entry_lines(tw) + if self.reprlocals: tw.line("") self.reprlocals.toterminal(tw) diff --git a/src/_pytest/_io/__init__.py b/src/_pytest/_io/__init__.py index 047bb179a..f56579806 100644 --- a/src/_pytest/_io/__init__.py +++ b/src/_pytest/_io/__init__.py @@ -1,3 +1,39 @@ -# Reexport TerminalWriter from here instead of py, to make it easier to -# extend or swap our own implementation in the future. -from py.io import TerminalWriter as TerminalWriter # noqa: F401 +from typing import List +from typing import Sequence + +from py.io import TerminalWriter as BaseTerminalWriter # noqa: F401 + + +class TerminalWriter(BaseTerminalWriter): + def _write_source(self, lines: List[str], indents: Sequence[str] = ()) -> None: + """Write lines of source code possibly highlighted. + + Keeping this private for now because the API is clunky. We should discuss how + to evolve the terminal writer so we can have more precise color support, for example + being able to write part of a line in one color and the rest in another, and so on. + """ + if indents and len(indents) != len(lines): + raise ValueError( + "indents size ({}) should have same size as lines ({})".format( + len(indents), len(lines) + ) + ) + if not indents: + indents = [""] * len(lines) + source = "\n".join(lines) + new_lines = self._highlight(source).splitlines() + for indent, new_line in zip(indents, new_lines): + self.line(indent + new_line) + + def _highlight(self, source): + """Highlight the given source code according to the "code_highlight" option""" + if not self.hasmarkup: + return source + try: + from pygments.formatters.terminal import TerminalFormatter + from pygments.lexers.python import PythonLexer + from pygments import highlight + except ImportError: + return source + else: + return highlight(source, PythonLexer(), TerminalFormatter(bg="dark")) diff --git a/testing/code/test_terminal_writer.py b/testing/code/test_terminal_writer.py new file mode 100644 index 000000000..01da3c235 --- /dev/null +++ b/testing/code/test_terminal_writer.py @@ -0,0 +1,28 @@ +import re +from io import StringIO + +import pytest +from _pytest._io import TerminalWriter + + +@pytest.mark.parametrize( + "has_markup, expected", + [ + pytest.param( + True, "{kw}assert{hl-reset} {number}0{hl-reset}\n", id="with markup" + ), + pytest.param(False, "assert 0\n", id="no markup"), + ], +) +def test_code_highlight(has_markup, expected, color_mapping): + f = StringIO() + tw = TerminalWriter(f) + tw.hasmarkup = has_markup + tw._write_source(["assert 0"]) + assert f.getvalue().splitlines(keepends=True) == color_mapping.format([expected]) + + with pytest.raises( + ValueError, + match=re.escape("indents size (2) should have same size as lines (1)"), + ): + tw._write_source(["assert 0"], [" ", " "]) diff --git a/testing/conftest.py b/testing/conftest.py index 3127fda6a..90cdcb869 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,6 +1,9 @@ +import re import sys +from typing import List import pytest +from _pytest.pytester import RunResult from _pytest.pytester import Testdir if sys.gettrace(): @@ -78,6 +81,12 @@ def tw_mock(): def write(self, msg, **kw): self.lines.append((TWMock.WRITE, msg)) + def _write_source(self, lines, indents=()): + if not indents: + indents = [""] * len(lines) + for indent, line in zip(indents, lines): + self.line(indent + line) + def line(self, line, **kw): self.lines.append(line) @@ -125,3 +134,64 @@ def dummy_yaml_custom_test(testdir): def testdir(testdir: Testdir) -> Testdir: testdir.monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") return testdir + + +@pytest.fixture(scope="session") +def color_mapping(): + """Returns a utility class which can replace keys in strings in the form "{NAME}" + by their equivalent ASCII codes in the terminal. + + Used by tests which check the actual colors output by pytest. + """ + + class ColorMapping: + COLORS = { + "red": "\x1b[31m", + "green": "\x1b[32m", + "yellow": "\x1b[33m", + "bold": "\x1b[1m", + "reset": "\x1b[0m", + "kw": "\x1b[94m", + "hl-reset": "\x1b[39;49;00m", + "function": "\x1b[92m", + "number": "\x1b[94m", + "str": "\x1b[33m", + "print": "\x1b[96m", + } + RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()} + + @classmethod + def format(cls, lines: List[str]) -> List[str]: + """Straightforward replacement of color names to their ASCII codes.""" + return [line.format(**cls.COLORS) for line in lines] + + @classmethod + def format_for_fnmatch(cls, lines: List[str]) -> List[str]: + """Replace color names for use with LineMatcher.fnmatch_lines""" + return [line.format(**cls.COLORS).replace("[", "[[]") for line in lines] + + @classmethod + def format_for_rematch(cls, lines: List[str]) -> List[str]: + """Replace color names for use with LineMatcher.re_match_lines""" + return [line.format(**cls.RE_COLORS) for line in lines] + + @classmethod + def requires_ordered_markup(cls, result: RunResult): + """Should be called if a test expects markup to appear in the output + in the order they were passed, for example: + + tw.write(line, bold=True, red=True) + + In Python 3.5 there's no guarantee that the generated markup will appear + in the order called, so we do some limited color testing and skip the rest of + the test. + """ + if sys.version_info < (3, 6): + # terminal writer.write accepts keyword arguments, so + # py36+ is required so the markup appears in the expected order + output = result.stdout.str() + assert "test session starts" in output + assert "\x1b[1m" in output + pytest.skip("doing limited testing because lacking ordered markup") + + return ColorMapping diff --git a/testing/test_terminal.py b/testing/test_terminal.py index d1ebd25a1..a0b1b41be 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -3,7 +3,6 @@ terminal reporting of the full testing process. """ import collections import os -import re import sys import textwrap from io import StringIO @@ -24,14 +23,6 @@ from _pytest.terminal import TerminalReporter DistInfo = collections.namedtuple("DistInfo", ["project_name", "version"]) -COLORS = { - "red": "\x1b[31m", - "green": "\x1b[32m", - "yellow": "\x1b[33m", - "bold": "\x1b[1m", - "reset": "\x1b[0m", -} -RE_COLORS = {k: re.escape(v) for k, v in COLORS.items()} TRANS_FNMATCH = str.maketrans({"[": "[[]", "]": "[]]"}) @@ -889,7 +880,7 @@ def test_pass_output_reporting(testdir): ) -def test_color_yes(testdir): +def test_color_yes(testdir, color_mapping): p1 = testdir.makepyfile( """ def fail(): @@ -900,16 +891,10 @@ def test_color_yes(testdir): """ ) result = testdir.runpytest("--color=yes", str(p1)) - if sys.version_info < (3, 6): - # py36 required for ordered markup - output = result.stdout.str() - assert "test session starts" in output - assert "\x1b[1m" in output - return + color_mapping.requires_ordered_markup(result) result.stdout.fnmatch_lines( - [ - line.format(**COLORS).replace("[", "[[]") - for line in [ + color_mapping.format_for_fnmatch( + [ "{bold}=*= test session starts =*={reset}", "collected 1 item", "", @@ -918,26 +903,25 @@ def test_color_yes(testdir): "=*= FAILURES =*=", "{red}{bold}_*_ test_this _*_{reset}", "", - "{bold} def test_this():{reset}", - "{bold}> fail(){reset}", + " {kw}def{hl-reset} {function}test_this{hl-reset}():", + "> fail()", "", "{bold}{red}test_color_yes.py{reset}:5: ", "_ _ * _ _*", "", - "{bold} def fail():{reset}", - "{bold}> assert 0{reset}", + " {kw}def{hl-reset} {function}fail{hl-reset}():", + "> {kw}assert{hl-reset} {number}0{hl-reset}", "{bold}{red}E assert 0{reset}", "", "{bold}{red}test_color_yes.py{reset}:2: AssertionError", "{red}=*= {red}{bold}1 failed{reset}{red} in *s{reset}{red} =*={reset}", ] - ] + ) ) result = testdir.runpytest("--color=yes", "--tb=short", str(p1)) result.stdout.fnmatch_lines( - [ - line.format(**COLORS).replace("[", "[[]") - for line in [ + color_mapping.format_for_fnmatch( + [ "{bold}=*= test session starts =*={reset}", "collected 1 item", "", @@ -946,13 +930,13 @@ def test_color_yes(testdir): "=*= FAILURES =*=", "{red}{bold}_*_ test_this _*_{reset}", "{bold}{red}test_color_yes.py{reset}:5: in test_this", - "{bold} fail(){reset}", + " fail()", "{bold}{red}test_color_yes.py{reset}:2: in fail", - "{bold} assert 0{reset}", + " {kw}assert{hl-reset} {number}0{hl-reset}", "{bold}{red}E assert 0{reset}", "{red}=*= {red}{bold}1 failed{reset}{red} in *s{reset}{red} =*={reset}", ] - ] + ) ) @@ -1673,7 +1657,7 @@ class TestProgressOutputStyle: ] ) - def test_colored_progress(self, testdir, monkeypatch): + def test_colored_progress(self, testdir, monkeypatch, color_mapping): monkeypatch.setenv("PY_COLORS", "1") testdir.makepyfile( test_bar=""" @@ -1697,14 +1681,13 @@ class TestProgressOutputStyle: ) result = testdir.runpytest() result.stdout.re_match_lines( - [ - line.format(**RE_COLORS) - for line in [ + color_mapping.format_for_rematch( + [ r"test_bar.py ({green}\.{reset}){{10}}{green} \s+ \[ 50%\]{reset}", r"test_foo.py ({green}\.{reset}){{5}}{yellow} \s+ \[ 75%\]{reset}", r"test_foobar.py ({red}F{reset}){{5}}{red} \s+ \[100%\]{reset}", ] - ] + ) ) def test_count(self, many_tests_files, testdir): @@ -1856,12 +1839,13 @@ class TestProgressWithTeardown: [r"test_bar.py (\.E){5}\s+\[ 25%\]", r"test_foo.py (\.E){15}\s+\[100%\]"] ) - def test_teardown_many_verbose(self, testdir: Testdir, many_files) -> None: + def test_teardown_many_verbose( + self, testdir: Testdir, many_files, color_mapping + ) -> None: result = testdir.runpytest("-v") result.stdout.fnmatch_lines( - [ - line.translate(TRANS_FNMATCH) - for line in [ + color_mapping.format_for_fnmatch( + [ "test_bar.py::test_bar[0] PASSED * [ 5%]", "test_bar.py::test_bar[0] ERROR * [ 5%]", "test_bar.py::test_bar[4] PASSED * [ 25%]", @@ -1869,7 +1853,7 @@ class TestProgressWithTeardown: "test_foo.py::test_foo[14] ERROR * [100%]", "=* 20 passed, 20 errors in *", ] - ] + ) ) def test_xdist_normal(self, many_files, testdir, monkeypatch): @@ -2021,3 +2005,46 @@ def test_via_exec(testdir: Testdir) -> None: result.stdout.fnmatch_lines( ["test_via_exec.py::test_via_exec <- PASSED*", "*= 1 passed in *"] ) + + +class TestCodeHighlight: + def test_code_highlight_simple(self, testdir: Testdir, color_mapping) -> None: + testdir.makepyfile( + """ + def test_foo(): + assert 1 == 10 + """ + ) + result = testdir.runpytest("--color=yes") + color_mapping.requires_ordered_markup(result) + result.stdout.fnmatch_lines( + color_mapping.format_for_fnmatch( + [ + " {kw}def{hl-reset} {function}test_foo{hl-reset}():", + "> {kw}assert{hl-reset} {number}1{hl-reset} == {number}10{hl-reset}", + "{bold}{red}E assert 1 == 10{reset}", + ] + ) + ) + + def test_code_highlight_continuation(self, testdir: Testdir, color_mapping) -> None: + testdir.makepyfile( + """ + def test_foo(): + print(''' + '''); assert 0 + """ + ) + result = testdir.runpytest("--color=yes") + color_mapping.requires_ordered_markup(result) + + result.stdout.fnmatch_lines( + color_mapping.format_for_fnmatch( + [ + " {kw}def{hl-reset} {function}test_foo{hl-reset}():", + " {print}print{hl-reset}({str}'''{hl-reset}{str}{hl-reset}", + "> {str} {hl-reset}{str}'''{hl-reset}); {kw}assert{hl-reset} {number}0{hl-reset}", + "{bold}{red}E assert 0{reset}", + ] + ) + ) diff --git a/tox.ini b/tox.ini index 65af0a4a8..2cdc7ad56 100644 --- a/tox.ini +++ b/tox.ini @@ -49,6 +49,7 @@ deps = numpy: numpy pexpect: pexpect pluggymaster: git+https://github.com/pytest-dev/pluggy.git@master + pygments twisted: twisted xdist: pytest-xdist>=1.13 {env:_PYTEST_TOX_EXTRA_DEP:} From b7ad4c2bed3906e1a12d9bb1829328b4045a72de Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 12 Feb 2020 16:07:57 +0100 Subject: [PATCH 145/158] _pformat_dispatch: pass through args (#6715) --- src/_pytest/_io/saferepr.py | 2 +- testing/io/test_saferepr.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index 884f0a21e..23af4d0bb 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -99,5 +99,5 @@ class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter): def _pformat_dispatch(object, indent=1, width=80, depth=None, *, compact=False): return AlwaysDispatchingPrettyPrinter( - indent=1, width=80, depth=None, compact=False + indent=indent, width=width, depth=depth, compact=compact ).pformat(object) diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index e24d9b470..06084202e 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -1,4 +1,5 @@ import pytest +from _pytest._io.saferepr import _pformat_dispatch from _pytest._io.saferepr import saferepr @@ -147,3 +148,9 @@ def test_unicode(): val = "£€" reprval = "'£€'" assert saferepr(val) == reprval + + +def test_pformat_dispatch(): + assert _pformat_dispatch("a") == "'a'" + assert _pformat_dispatch("a" * 10, width=5) == "'aaaaaaaaaa'" + assert _pformat_dispatch("foo bar", width=5) == "('foo '\n 'bar')" From 8a4d5227e25dfeaf9addb8b7b0ad17fa1f4c5b26 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 13 Feb 2020 12:31:48 +0200 Subject: [PATCH 146/158] Remove unused CallSpec2 fields _globalid, _globalparam --- src/_pytest/python.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 6402164f9..ced115815 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -789,8 +789,6 @@ class CallSpec2: self.funcargs = {} self._idlist = [] self.params = {} - self._globalid = NOTSET - self._globalparam = NOTSET self._arg2scopenum = {} # used for sorting parametrized resources self.marks = [] self.indices = {} @@ -803,8 +801,6 @@ class CallSpec2: cs.indices.update(self.indices) cs._arg2scopenum.update(self._arg2scopenum) cs._idlist = list(self._idlist) - cs._globalid = self._globalid - cs._globalparam = self._globalparam return cs def _checkargnotcontained(self, arg): @@ -815,9 +811,7 @@ class CallSpec2: try: return self.params[name] except KeyError: - if self._globalparam is NOTSET: - raise ValueError(name) - return self._globalparam + raise ValueError(name) @property def id(self): From 07b7b6fa7d833124bb6d16d67c75a64d82ce0ec4 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 13 Feb 2020 12:09:32 +0100 Subject: [PATCH 147/158] doc: add docstring for CaptureManager._capturing_for_request (#6698) Based on the removed doc for `_install_capture_fixture_on_item`. Follow-up to https://github.com/pytest-dev/pytest/pull/6663. Co-authored-by: Ran Benita --- src/_pytest/capture.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index bd17d05ef..d3f10da2f 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -152,6 +152,13 @@ class CaptureManager: def _capturing_for_request( self, request: FixtureRequest ) -> Generator["CaptureFixture", None, None]: + """ + Context manager that creates a ``CaptureFixture`` instance for the + given ``request``, ensuring there is only a single one being requested + at the same time. + + This is used as a helper with ``capsys``, ``capfd`` etc. + """ if self._capture_fixture: other_name = next( k From dbae5a7ff88138e78fc245e95a6fe03536fe7512 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 13 Feb 2020 12:30:22 +0100 Subject: [PATCH 148/158] Recognize `-V` as a short option for `--version` (#6721) --- src/_pytest/helpconfig.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 87cd2c0a7..ae37fdea4 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -40,8 +40,9 @@ def pytest_addoption(parser): group = parser.getgroup("debugconfig") group.addoption( "--version", + "-V", action="store_true", - help="display pytest lib version and import information.", + help="display pytest version and information about plugins.", ) group._addoption( "-h", From 83137c89e926155b9c905cc6c64c84e02ec87076 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 14 Feb 2020 02:16:25 +0100 Subject: [PATCH 149/158] tests: test_unicode_plus_minus: use unicode sign directly (#6727) Was globbed for Python 2 before (57c448991). --- testing/python/approx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/python/approx.py b/testing/python/approx.py index f72045624..76d995773 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -452,7 +452,7 @@ class TestApprox: expected = "4.0e-06" result = testdir.runpytest() result.stdout.fnmatch_lines( - ["*At index 0 diff: 3 != 4 * {}".format(expected), "=* 1 failed in *="] + ["*At index 0 diff: 3 != 4 ± {}".format(expected), "=* 1 failed in *="] ) @pytest.mark.parametrize( From d89b5057cadb8716e25b783fee8a1d670e043109 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 14 Feb 2020 02:17:05 +0100 Subject: [PATCH 150/158] assertrepr_compare: provide more info (location) with exceptions (#6728) --- src/_pytest/assertion/util.py | 7 ++++--- testing/test_assertion.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 51a25490f..7d525aa4c 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -175,9 +175,10 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ raise except Exception: explanation = [ - "(pytest_assertion plugin: representation of details failed. " - "Probably an object has a faulty __repr__.)", - str(_pytest._code.ExceptionInfo.from_current()), + "(pytest_assertion plugin: representation of details failed: {}.".format( + _pytest._code.ExceptionInfo.from_current()._getreprcrash() + ), + " Probably an object has a faulty __repr__.)", ] if not explanation: diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 0c0f12340..cf80fba43 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -677,8 +677,16 @@ class TestAssert_reprcompare: expl = callequal([], [A()]) assert "ValueError" in "".join(expl) - expl = callequal({}, {"1": A()}) - assert "faulty" in "".join(expl) + expl = callequal({}, {"1": A()}, verbose=2) + assert expl[0].startswith("{} == <[ValueError") + assert "raised in repr" in expl[0] + assert expl[1:] == [ + "(pytest_assertion plugin: representation of details failed:" + " {}:{}: ValueError: 42.".format( + __file__, A.__repr__.__code__.co_firstlineno + 1 + ), + " Probably an object has a faulty __repr__.)", + ] def test_one_repr_empty(self): """ From d839686c7bbe9d6f8d24e69d612e737f3341b6f3 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 14 Feb 2020 13:54:47 +0200 Subject: [PATCH 151/158] Don't delete FixtureDef.cached_result, set it to None instead Previously `cached_result` was either set or deleted. Type annotations cannot handle this, so use `None` for the non-set state instead. --- changelog/6737.breaking.rst | 7 +++++++ src/_pytest/fixtures.py | 12 ++++++------ src/_pytest/hookspec.py | 6 +++--- src/_pytest/setuponly.py | 4 ++-- 4 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 changelog/6737.breaking.rst diff --git a/changelog/6737.breaking.rst b/changelog/6737.breaking.rst new file mode 100644 index 000000000..aad661e87 --- /dev/null +++ b/changelog/6737.breaking.rst @@ -0,0 +1,7 @@ +The ``cached_result`` attribute of ``FixtureDef`` is now set to ``None`` when +the result is unavailable, instead of being deleted. + +If your plugin perform checks like ``hasattr(fixturedef, 'cached_result')``, +for example in a ``pytest_fixture_post_finalizer`` hook implementation, replace +it with ``fixturedef.cached_result is not None``. If you ``del`` the attribute, +set it to ``None`` instead. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index bd2abb385..cdd249d93 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -855,6 +855,7 @@ class FixtureDef: self.argnames = getfuncargnames(func, name=argname, is_method=unittest) self.unittest = unittest self.ids = ids + self.cached_result = None self._finalizers = [] def addfinalizer(self, finalizer): @@ -881,8 +882,7 @@ class FixtureDef: # the cached fixture value and remove # all finalizers because they may be bound methods which will # keep instances alive - if hasattr(self, "cached_result"): - del self.cached_result + self.cached_result = None self._finalizers = [] def execute(self, request): @@ -894,9 +894,8 @@ class FixtureDef: fixturedef.addfinalizer(functools.partial(self.finish, request=request)) my_cache_key = self.cache_key(request) - cached_result = getattr(self, "cached_result", None) - if cached_result is not None: - result, cache_key, err = cached_result + if self.cached_result is not None: + result, cache_key, err = self.cached_result # note: comparison with `==` can fail (or be expensive) for e.g. # numpy arrays (#6497) if my_cache_key is cache_key: @@ -908,7 +907,7 @@ class FixtureDef: # we have a previous but differently parametrized fixture instance # so we need to tear it down before creating a new one self.finish(request) - assert not hasattr(self, "cached_result") + assert self.cached_result is None hook = self._fixturemanager.session.gethookproxy(request.node.fspath) return hook.pytest_fixture_setup(fixturedef=self, request=request) @@ -953,6 +952,7 @@ def pytest_fixture_setup(fixturedef, request): kwargs = {} for argname in fixturedef.argnames: fixdef = request._get_active_fixturedef(argname) + assert fixdef.cached_result is not None result, arg_cache_key, exc = fixdef.cached_result request._check_scope(argname, request.scope, fixdef.scope) kwargs[argname] = result diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 3cd7f5ffe..62e2155a2 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -423,9 +423,9 @@ def pytest_fixture_setup(fixturedef, request): def pytest_fixture_post_finalizer(fixturedef, request): - """ called after fixture teardown, but before the cache is cleared so - the fixture result cache ``fixturedef.cached_result`` can - still be accessed.""" + """Called after fixture teardown, but before the cache is cleared, so + the fixture result ``fixturedef.cached_result`` is still available (not + ``None``).""" # ------------------------------------------------------------------------- diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index a277ebc85..aa5a95ff9 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -34,8 +34,8 @@ def pytest_fixture_setup(fixturedef, request): _show_fixture_action(fixturedef, "SETUP") -def pytest_fixture_post_finalizer(fixturedef): - if hasattr(fixturedef, "cached_result"): +def pytest_fixture_post_finalizer(fixturedef) -> None: + if fixturedef.cached_result is not None: config = fixturedef._fixturemanager.config if config.option.setupshow: _show_fixture_action(fixturedef, "TEARDOWN") From de3353aac14cf612dd563fe48b58503e9468de1e Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 14 Feb 2020 16:58:17 +0100 Subject: [PATCH 152/158] test_load_initial_conftest_last_ordering: handle testing package (#6706) --- testing/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_config.py b/testing/test_config.py index 993d65a67..d88241368 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -839,7 +839,7 @@ def test_load_initial_conftest_last_ordering(_config_for_test): pm.register(m) hc = pm.hook.pytest_load_initial_conftests values = hc._nonwrappers + hc._wrappers - expected = ["_pytest.config", "test_config", "_pytest.capture"] + expected = ["_pytest.config", m.__module__, "_pytest.capture"] assert [x.function.__module__ for x in values] == expected From 4b70ba2c2113cc1fed40c4db04f31df8990d6a2c Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 14 Feb 2020 17:00:01 +0100 Subject: [PATCH 153/158] tests: harden test_better_reporting_on_conftest_load_failure (#6713) --- testing/acceptance_test.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 4cbaebeb1..c7c16d60d 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -190,10 +190,10 @@ class TestGeneralUsage: ) @pytest.mark.filterwarnings("default") - def test_better_reporting_on_conftest_load_failure(self, testdir, request): + def test_better_reporting_on_conftest_load_failure(self, testdir): """Show a user-friendly traceback on conftest import failures (#486, #3332)""" testdir.makepyfile("") - testdir.makeconftest( + conftest = testdir.makeconftest( """ def foo(): import qwerty @@ -208,22 +208,18 @@ class TestGeneralUsage: """ ) result = testdir.runpytest() - dirname = request.node.name + "0" exc_name = ( "ModuleNotFoundError" if sys.version_info >= (3, 6) else "ImportError" ) - result.stderr.fnmatch_lines( - [ - "ImportError while loading conftest '*{sep}{dirname}{sep}conftest.py'.".format( - dirname=dirname, sep=os.sep - ), - "conftest.py:3: in ", - " foo()", - "conftest.py:2: in foo", - " import qwerty", - "E {}: No module named 'qwerty'".format(exc_name), - ] - ) + assert result.stdout.lines == [] + assert result.stderr.lines == [ + "ImportError while loading conftest '{}'.".format(conftest), + "conftest.py:3: in ", + " foo()", + "conftest.py:2: in foo", + " import qwerty", + "E {}: No module named 'qwerty'".format(exc_name), + ] def test_early_skip(self, testdir): testdir.mkdir("xyz") From 9631b3c16633eacd2c91dbb9cdc6a2177c693048 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Fri, 14 Feb 2020 19:39:34 +0100 Subject: [PATCH 154/158] reports: use attr.asdict with serialize_repr_{crash,traceback} (#6732) * Turn ReprTraceback into attrs class * Use attr.asdict with serialize_repr_{crash,traceback} * Turn ReprFileLocation into attrs class, convert py.path.local --- src/_pytest/_code/code.py | 23 +++++++++-------------- src/_pytest/reports.py | 7 ++++--- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index babac34f6..fd38b950c 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -975,18 +975,13 @@ class ReprExceptionInfo(ExceptionRepr): super().toterminal(tw) +@attr.s class ReprTraceback(TerminalRepr): - entrysep = "_ " + reprentries = attr.ib(type=Sequence[Union["ReprEntry", "ReprEntryNative"]]) + extraline = attr.ib(type=Optional[str]) + style = attr.ib(type="_TracebackStyle") - def __init__( - self, - reprentries: Sequence[Union["ReprEntry", "ReprEntryNative"]], - extraline: Optional[str], - style: "_TracebackStyle", - ) -> None: - self.reprentries = reprentries - self.extraline = extraline - self.style = style + entrysep = "_ " def toterminal(self, tw: TerminalWriter) -> None: # the entries might have different styles @@ -1105,11 +1100,11 @@ class ReprEntry(TerminalRepr): ) +@attr.s class ReprFileLocation(TerminalRepr): - def __init__(self, path, lineno: int, message: str) -> None: - self.path = str(path) - self.lineno = lineno - self.message = message + path = attr.ib(type=str, converter=str) + lineno = attr.ib(type=int) + message = attr.ib(type=str) def toterminal(self, tw: TerminalWriter) -> None: # filename and lineno output for each entry, diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 3ad67c224..38b028f48 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -6,6 +6,7 @@ from typing import Optional from typing import Tuple from typing import Union +import attr import py from _pytest._code.code import ExceptionChainRepr @@ -375,8 +376,8 @@ def _report_to_json(report): entry_data["data"][key] = value.__dict__.copy() return entry_data - def serialize_repr_traceback(reprtraceback): - result = reprtraceback.__dict__.copy() + def serialize_repr_traceback(reprtraceback: ReprTraceback): + result = attr.asdict(reprtraceback) result["reprentries"] = [ serialize_repr_entry(x) for x in reprtraceback.reprentries ] @@ -384,7 +385,7 @@ def _report_to_json(report): def serialize_repr_crash(reprcrash: Optional[ReprFileLocation]): if reprcrash is not None: - return reprcrash.__dict__.copy() + return attr.asdict(reprcrash) else: return None From b09762df27d33bd42cb06197bee5b43bf3199eef Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 15 Feb 2020 00:32:16 +0100 Subject: [PATCH 155/158] doc: link to `python_files` from "Assertion Rewriting" (#6705) Ref: https://github.com/pytest-dev/pytest/issues/6377 Ref: https://github.com/blueyed/pytest/pull/145 --- doc/en/writing_plugins.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/en/writing_plugins.rst b/doc/en/writing_plugins.rst index 2f7283791..045e13e75 100644 --- a/doc/en/writing_plugins.rst +++ b/doc/en/writing_plugins.rst @@ -181,6 +181,7 @@ done via a :pep:`302` import hook which gets installed early on when ``pytest`` starts up and will perform this rewriting when modules get imported. However since we do not want to test different bytecode then you will run in production this hook only rewrites test modules +themselves (as defined by the :confval:`python_files` configuration option) themselves as well as any modules which are part of plugins. Any other imported module will not be rewritten and normal assertion behaviour will happen. From 67e69a7e498b947ce25eac1fa30d02e6f4a4585d Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 15 Feb 2020 01:22:01 +0100 Subject: [PATCH 156/158] tests: harden test_xdist_verbose (#6700) --- testing/test_terminal.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index a0b1b41be..8c775bd7a 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1761,6 +1761,22 @@ class TestProgressOutputStyle: r"\[gw\d\] \[\s*\d+%\] PASSED test_foobar.py::test_foobar\[1\]", ] ) + output.stdout.fnmatch_lines_random( + [ + line.translate(TRANS_FNMATCH) + for line in [ + "test_bar.py::test_bar[0] ", + "test_foo.py::test_foo[0] ", + "test_foobar.py::test_foobar[0] ", + "[gw?] [ 5%] PASSED test_*[?] ", + "[gw?] [ 10%] PASSED test_*[?] ", + "[gw?] [ 55%] PASSED test_*[?] ", + "[gw?] [ 60%] PASSED test_*[?] ", + "[gw?] [ 95%] PASSED test_*[?] ", + "[gw?] [100%] PASSED test_*[?] ", + ] + ] + ) def test_capture_no(self, many_tests_files, testdir): output = testdir.runpytest("-s") From 02aa8adae1ccf6b6f758ef798b7eeab697b8c49f Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 11 Feb 2020 13:10:51 +0100 Subject: [PATCH 157/158] cacheprovider: use warnings directly Allows for filtering of PytestCacheWarning. Using `_issue_warning_captured` is not necessary here, and was probably only used because the cacheprovider misses warnings during `pytest_sessionfinish`, which is also fixed here. I think the usage of `_issue_warning_captured` can be removed/reduced further, but also that this is good enough for now. Ref: https://github.com/pytest-dev/pytest/issues/6681. --- src/_pytest/cacheprovider.py | 4 ++-- src/_pytest/warnings.py | 9 +++++++++ testing/test_cacheprovider.py | 4 +--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 2f7f88454..9f94605d4 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -71,10 +71,10 @@ class Cache: return resolve_from_str(config.getini("cache_dir"), config.rootdir) def warn(self, fmt, **args): - from _pytest.warnings import _issue_warning_captured + import warnings from _pytest.warning_types import PytestCacheWarning - _issue_warning_captured( + warnings.warn( PytestCacheWarning(fmt.format(**args) if args else fmt), self._config.hook, stacklevel=3, diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 18e4def21..2a4d189d5 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -136,6 +136,15 @@ def pytest_terminal_summary(terminalreporter): yield +@pytest.hookimpl(hookwrapper=True) +def pytest_sessionfinish(session): + config = session.config + with catch_warnings_for_item( + config=config, ihook=config.hook, when="config", item=None + ): + yield + + def _issue_warning_captured(warning, hook, stacklevel): """ This function should be used instead of calling ``warnings.warn`` directly when we are in the "configure" stage: diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index d37f18f0f..6dd987b61 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -56,9 +56,7 @@ class TestNewAPI: testdir.tmpdir.ensure_dir(".pytest_cache").chmod(mode) @pytest.mark.skipif(sys.platform.startswith("win"), reason="no chmod on windows") - @pytest.mark.filterwarnings( - "ignore:could not create cache path:pytest.PytestWarning" - ) + @pytest.mark.filterwarnings("default") def test_cache_failure_warns(self, testdir, monkeypatch): monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") cache_dir = str(testdir.tmpdir.ensure_dir(".pytest_cache")) From 2b5adc88a7a83e03bd680d090314ade92602ff8c Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 15 Feb 2020 02:01:22 +0100 Subject: [PATCH 158/158] Move test_issue4445_cacheprovider_set into test_cache_failure_warns Would need to be adjusted anyway non-trivially, and we can just harden `test_cache_failure_warns` instead. --- testing/test_cacheprovider.py | 10 +++++++++- testing/test_warnings.py | 21 --------------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 6dd987b61..ce425e26b 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -68,7 +68,15 @@ class TestNewAPI: assert result.ret == 1 # warnings from nodeids, lastfailed, and stepwise result.stdout.fnmatch_lines( - ["*could not create cache path*", "*3 warnings*"] + [ + # Validate location/stacklevel of warning from cacheprovider. + "*= warnings summary =*", + "*/cacheprovider.py:314", + " */cacheprovider.py:314: PytestCacheWarning: could not create cache path " + "{}/v/cache/nodeids".format(cache_dir), + ' config.cache.set("cache/nodeids", self.cached_nodeids)', + "*1 failed, 3 warnings in*", + ] ) finally: testdir.tmpdir.ensure_dir(".pytest_cache").chmod(mode) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index e4d957385..b05816073 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -712,27 +712,6 @@ class TestStackLevel: assert "resultlog.py" in file assert func == "pytest_configure" - def test_issue4445_cacheprovider_set(self, testdir, capwarn): - """#4445: Make sure the warning points to a reasonable location - See origin of _issue_warning_captured at: _pytest.cacheprovider.py:59 - """ - testdir.tmpdir.join(".pytest_cache").write("something wrong") - testdir.runpytest(plugins=[capwarn()]) - - # with stacklevel=3 the warning originates from one stacklevel above - # _issue_warning_captured in cacheprovider.Cache.set and is thrown - # when there are errors during cache folder creation - - # set is called twice (in module stepwise and in cacheprovider) so emits - # two warnings when there are errors during cache folder creation. (is this intentional?) - assert len(capwarn.captured) == 2 - warning, location = capwarn.captured.pop() - file, lineno, func = location - - assert "could not create cache path" in str(warning.message) - assert "cacheprovider.py" in file - assert func == "set" - def test_issue4445_issue5928_mark_generator(self, testdir): """#4445 and #5928: Make sure the warning from an unknown mark points to the test file where this mark is used.