diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7f9aa9556de..2e221f73ec6 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: diff --git a/.travis.yml b/.travis.yml index 59c7951e407..76d7ef65b5a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ git: depth: false install: + - python -m pip install --upgrade pip - python -m pip install --upgrade --pre tox jobs: @@ -61,7 +62,7 @@ before_script: export _PYTEST_TOX_EXTRA_DEP=coverage-enable-subprocess fi -script: tox +script: tox -vv after_success: - | diff --git a/changelog/6646.bugfix.rst b/changelog/6646.bugfix.rst new file mode 100644 index 00000000000..4dba3ed0723 --- /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/changelog/6660.bugfix.rst b/changelog/6660.bugfix.rst new file mode 100644 index 00000000000..bcc2e1d9467 --- /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/doc/en/conf.py b/doc/en/conf.py index bd2fd9871f7..85521309fb6 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -162,7 +162,7 @@ # 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 00000000000..5c8824d67d3 Binary files /dev/null and b/doc/en/img/favicon.png differ diff --git a/doc/en/img/pytest1favi.ico b/doc/en/img/pytest1favi.ico deleted file mode 100644 index 6a34fe5c9f7..00000000000 Binary files a/doc/en/img/pytest1favi.ico and /dev/null differ diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 7d4f1dafa2f..3e7000b96a0 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` diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 09245d84d67..fba52926ee6 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -72,6 +72,8 @@ def path(self) -> Union[py.path.local, str]: """ 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/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index c7b10311427..28c11e5d5e3 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -8,6 +8,7 @@ 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 @@ -17,6 +18,7 @@ import py +from _pytest.compat import get_real_func from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING @@ -277,7 +279,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). @@ -285,6 +287,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: @@ -293,18 +302,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/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a060723a76b..cdb0347034f 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -8,6 +8,7 @@ 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 callbinrepr(op, left, right): 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 call_assertion_pass_hook(lineno, orig, expl): 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/compat.py b/src/_pytest/compat.py index 0fc48bdba44..a7c582470a4 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -23,7 +23,6 @@ import attr import py -import _pytest from _pytest._io.saferepr import saferepr from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -308,16 +307,6 @@ def get_real_method(obj, holder): return obj -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 - - def getimfunc(func): try: return func.__func__ diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6fbbf195986..35a4474f1f2 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -27,7 +27,6 @@ from pluggy import PluginManager import _pytest._code -import _pytest.assertion import _pytest.deprecated import _pytest.hookspec # the extension point definitions from .exceptions import PrintHelp @@ -262,6 +261,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] @@ -895,6 +896,8 @@ def _consider_importhook(self, args: Sequence[str]) -> None: 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/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index cc98c8192b5..1ee701b4abb 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -15,12 +15,12 @@ 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/main.py b/src/_pytest/main.py index 1db97dc553d..3fc18ac80bd 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -5,6 +5,7 @@ import importlib import os import sys +from typing import Callable from typing import Dict from typing import FrozenSet from typing import List @@ -24,7 +25,7 @@ from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.fixtures import FixtureManager -from _pytest.outcomes import exit +from _pytest.outcomes import Exit from _pytest.reports import CollectReport from _pytest.runner import collect_one_node from _pytest.runner import SetupState @@ -198,7 +199,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.from_config(config) session.exitstatus = ExitCode.OK @@ -215,10 +218,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: @@ -232,7 +235,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)) @@ -241,12 +244,18 @@ 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( - 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 @@ -386,6 +395,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__( diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 020260dd5e8..70a401fee94 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -8,9 +8,9 @@ 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 0b0e394ace4..9cabf2079f3 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -15,8 +15,8 @@ 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/pytester.py b/src/_pytest/pytester.py index 0b51f8bb05c..ac433c29f05 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -610,14 +610,14 @@ def chdir(self): """ 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)) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 69bc5ce7242..e7614bca361 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -21,10 +21,10 @@ from _pytest import nodes from _pytest._code import filter_traceback from _pytest._code.code import ExceptionInfo +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 @@ -392,7 +392,7 @@ def _genfunctions(self, name, funcobj): fm = self.session._fixturemanager definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) - fixtureinfo = fm.getfixtureinfo(definition, funcobj, cls) + fixtureinfo = definition._fixtureinfo metafunc = Metafunc( definition, fixtureinfo, self.config, cls=cls, module=module diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 68e8a97f8f3..5495e6791d2 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -104,6 +104,8 @@ def test_option(pytestconfig): @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/code/test_source.py b/testing/code/test_source.py index b5efdb31702..cf09309744a 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -524,6 +524,14 @@ class B: 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: diff --git a/testing/conftest.py b/testing/conftest.py index 33b817a1226..3127fda6a83 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 runtest(self): """ ) 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_assertion.py b/testing/test_assertion.py index e975a3fea2b..dc260b39f9e 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -72,10 +72,19 @@ def test_dummy_failure(testdir): # how meta! 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}", ] ) diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 1dee5b0f51d..a06ba0e2667 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -3,6 +3,7 @@ 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 5d3fdcbb5cc..ef2f808a25d 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1300,6 +1300,7 @@ def test_pass(): 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_main.py b/testing/test_main.py index b47791b29c1..49e3decd0c9 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 pytest_internalerror(excrepr, excinfo): 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"] diff --git a/testing/test_meta.py b/testing/test_meta.py index 296aa42aaac..ffc8fd38aba 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 diff --git a/testing/test_modimport.py b/testing/test_modimport.py deleted file mode 100644 index 3d7a073232c..00000000000 --- 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, - ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 4733469ae49..c832fb3adf5 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -606,6 +606,7 @@ def test_method(self): assert result.ret == 0 def test_header_trailer_info(self, testdir, request): + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") testdir.makepyfile( """ def test_passes(): @@ -736,6 +737,7 @@ def test_verbose_reporting_xdist(self, verbose_testfile, testdir, pytestconfig): 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" )