From 1780924b279a80aa3cccd840229dfe3e6f60e88b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 22 May 2020 16:10:51 -0300 Subject: [PATCH 001/140] Introduce _pytest.timing as a way to control timing during tests _pytest.timing is an indirection to 'time' functions, which pytest production code should use instead of 'time' directly. 'mock_timing' is a new fixture which then mocks those functions, allowing us to write time-reliable tests which run instantly and are not flaky. This was triggered by recent flaky junitxml tests on Windows related to timing issues. --- src/_pytest/junitxml.py | 8 ++--- src/_pytest/pytester.py | 10 +++---- src/_pytest/runner.py | 11 ++++--- src/_pytest/terminal.py | 10 +++---- src/_pytest/timing.py | 13 +++++++++ testing/acceptance_test.py | 60 ++++++++++++-------------------------- testing/conftest.py | 38 ++++++++++++++++++++++++ testing/test_junitxml.py | 12 ++++---- 8 files changed, 94 insertions(+), 68 deletions(-) create mode 100644 src/_pytest/timing.py diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 77e184312..4f9831e06 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -13,7 +13,6 @@ import os import platform import re import sys -import time from datetime import datetime import py @@ -21,6 +20,7 @@ import py import pytest from _pytest import deprecated from _pytest import nodes +from _pytest import timing from _pytest.config import filename_arg from _pytest.store import StoreKey from _pytest.warnings import _issue_warning_captured @@ -627,14 +627,14 @@ class LogXML: reporter._add_simple(Junit.error, "internal error", excrepr) def pytest_sessionstart(self): - self.suite_start_time = time.time() + self.suite_start_time = timing.time() def pytest_sessionfinish(self): dirname = os.path.dirname(os.path.abspath(self.logfile)) if not os.path.isdir(dirname): os.makedirs(dirname) logfile = open(self.logfile, "w", encoding="utf-8") - suite_stop_time = time.time() + suite_stop_time = timing.time() suite_time_delta = suite_stop_time - self.suite_start_time numtests = ( @@ -662,7 +662,7 @@ class LogXML: logfile.close() def pytest_terminal_summary(self, terminalreporter): - terminalreporter.write_sep("-", "generated xml file: %s" % (self.logfile)) + terminalreporter.write_sep("-", "generated xml file: {}".format(self.logfile)) def add_global_property(self, name, value): __tracebackhide__ = True diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 32b03bd4a..07250b245 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -7,7 +7,6 @@ import platform import re import subprocess import sys -import time import traceback from fnmatch import fnmatch from io import StringIO @@ -24,6 +23,7 @@ from weakref import WeakKeyDictionary import py import pytest +from _pytest import timing from _pytest._code import Source from _pytest.capture import MultiCapture from _pytest.capture import SysCapture @@ -941,7 +941,7 @@ class Testdir: if syspathinsert: self.syspathinsert() - now = time.time() + now = timing.time() capture = MultiCapture(Capture=SysCapture) capture.start_capturing() try: @@ -970,7 +970,7 @@ class Testdir: sys.stderr.write(err) res = RunResult( - reprec.ret, out.splitlines(), err.splitlines(), time.time() - now + reprec.ret, out.splitlines(), err.splitlines(), timing.time() - now ) res.reprec = reprec # type: ignore return res @@ -1171,7 +1171,7 @@ class Testdir: f1 = open(str(p1), "w", encoding="utf8") f2 = open(str(p2), "w", encoding="utf8") try: - now = time.time() + now = timing.time() popen = self.popen( cmdargs, stdin=stdin, @@ -1218,7 +1218,7 @@ class Testdir: ret = ExitCode(ret) except ValueError: pass - return RunResult(ret, out, err, time.time() - now) + return RunResult(ret, out, err, timing.time() - now) def _dump_lines(self, lines, fp): try: diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index e7211369c..aa8a5aa8b 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -2,8 +2,6 @@ import bdb import os import sys -from time import perf_counter # Intentionally not `import time` to avoid being -from time import time # affected by tests which monkeypatch `time` (issue #185). from typing import Callable from typing import Dict from typing import List @@ -15,6 +13,7 @@ import attr from .reports import CollectErrorRepr from .reports import CollectReport from .reports import TestReport +from _pytest import timing from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest.compat import TYPE_CHECKING @@ -254,8 +253,8 @@ class CallInfo: #: context of invocation: one of "setup", "call", #: "teardown", "memocollect" excinfo = None - start = time() - precise_start = perf_counter() + start = timing.time() + precise_start = timing.perf_counter() try: result = func() except BaseException: @@ -264,9 +263,9 @@ class CallInfo: raise result = None # use the perf counter - precise_stop = perf_counter() + precise_stop = timing.perf_counter() duration = precise_stop - precise_start - stop = time() + stop = timing.time() return cls( start=start, stop=stop, diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 3de0612bf..c09621628 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -7,7 +7,6 @@ import datetime import inspect import platform import sys -import time import warnings from functools import partial from typing import Any @@ -26,6 +25,7 @@ from more_itertools import collapse import pytest from _pytest import nodes +from _pytest import timing from _pytest._io import TerminalWriter from _pytest.compat import order_preserving_dict from _pytest.config import Config @@ -557,7 +557,7 @@ class TerminalReporter: if self.isatty: if self.config.option.verbose >= 0: self.write("collecting ... ", flush=True, bold=True) - self._collect_report_last_write = time.time() + self._collect_report_last_write = timing.time() elif self.config.option.verbose >= 1: self.write("collecting ... ", flush=True, bold=True) @@ -577,7 +577,7 @@ class TerminalReporter: if not final: # Only write "collecting" report every 0.5s. - t = time.time() + t = timing.time() if ( self._collect_report_last_write is not None and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION @@ -614,7 +614,7 @@ class TerminalReporter: @pytest.hookimpl(trylast=True) def pytest_sessionstart(self, session: Session) -> None: self._session = session - self._sessionstarttime = time.time() + self._sessionstarttime = timing.time() if not self.showheader: return self.write_sep("=", "test session starts", bold=True) @@ -969,7 +969,7 @@ class TerminalReporter: if self.verbosity < -1: return - session_duration = time.time() - self._sessionstarttime + session_duration = timing.time() - self._sessionstarttime (parts, main_color) = self.build_summary_stats_line() line_parts = [] diff --git a/src/_pytest/timing.py b/src/_pytest/timing.py new file mode 100644 index 000000000..ded917b35 --- /dev/null +++ b/src/_pytest/timing.py @@ -0,0 +1,13 @@ +""" +Indirection for time functions. + +We intentionally grab some "time" functions internally to avoid tests mocking "time" to affect +pytest runtime information (issue #185). + +Fixture "mock_timinig" also interacts with this module for pytest's own tests. +""" +from time import perf_counter +from time import sleep +from time import time + +__all__ = ["perf_counter", "sleep", "time"] diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 45a23ee93..e2df92d80 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -6,11 +6,9 @@ import types import attr import py -import _pytest.runner import pytest from _pytest.compat import importlib_metadata from _pytest.config import ExitCode -from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Testdir @@ -896,37 +894,21 @@ class TestInvocationVariants: assert request.config.pluginmanager.hasplugin("python") -def fake_time(monkeypatch: MonkeyPatch) -> None: - """Monkeypatch time functions to make TestDurations not rely on actual time.""" - import time - - current_time = 1586202699.9859412 - - def sleep(seconds: float) -> None: - nonlocal current_time - current_time += seconds - - monkeypatch.setattr(time, "sleep", sleep) - monkeypatch.setattr(_pytest.runner, "time", lambda: current_time) - monkeypatch.setattr(_pytest.runner, "perf_counter", lambda: current_time) - - class TestDurations: source = """ - import time + from _pytest import timing def test_something(): pass def test_2(): - time.sleep(0.010) + timing.sleep(0.010) def test_1(): - time.sleep(0.002) + timing.sleep(0.002) def test_3(): - time.sleep(0.020) + timing.sleep(0.020) """ - def test_calls(self, testdir): + def test_calls(self, testdir, mock_timing): testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) result = testdir.runpytest_inprocess("--durations=10") assert result.ret == 0 @@ -938,18 +920,17 @@ class TestDurations: ["(8 durations < 0.005s hidden. Use -vv to show these durations.)"] ) - def test_calls_show_2(self, testdir): + def test_calls_show_2(self, testdir, mock_timing): + testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) result = testdir.runpytest_inprocess("--durations=2") assert result.ret == 0 lines = result.stdout.get_lines_after("*slowest*durations*") assert "4 passed" in lines[2] - def test_calls_showall(self, testdir): + def test_calls_showall(self, testdir, mock_timing): testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) result = testdir.runpytest_inprocess("--durations=0") assert result.ret == 0 @@ -962,9 +943,8 @@ class TestDurations: else: raise AssertionError("not found {} {}".format(x, y)) - def test_calls_showall_verbose(self, testdir): + def test_calls_showall_verbose(self, testdir, mock_timing): testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) result = testdir.runpytest_inprocess("--durations=0", "-vv") assert result.ret == 0 @@ -976,17 +956,15 @@ class TestDurations: else: raise AssertionError("not found {} {}".format(x, y)) - def test_with_deselected(self, testdir): + def test_with_deselected(self, testdir, mock_timing): testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) result = testdir.runpytest_inprocess("--durations=2", "-k test_3") assert result.ret == 0 result.stdout.fnmatch_lines(["*durations*", "*call*test_3*"]) - def test_with_failing_collection(self, testdir): + def test_with_failing_collection(self, testdir, mock_timing): testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) testdir.makepyfile(test_collecterror="""xyz""") result = testdir.runpytest_inprocess("--durations=2", "-k test_1") assert result.ret == 2 @@ -996,9 +974,8 @@ class TestDurations: # output result.stdout.no_fnmatch_line("*duration*") - def test_with_not(self, testdir): + def test_with_not(self, testdir, mock_timing): testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) result = testdir.runpytest_inprocess("-k not 1") assert result.ret == 0 @@ -1006,27 +983,26 @@ class TestDurations: class TestDurationsWithFixture: source = """ import pytest - import time + from _pytest import timing @pytest.fixture def setup_fixt(): - time.sleep(0.02) + timing.sleep(2) def test_1(setup_fixt): - time.sleep(0.02) + timing.sleep(5) """ - def test_setup_function(self, testdir): + def test_setup_function(self, testdir, mock_timing): testdir.makepyfile(self.source) - fake_time(testdir.monkeypatch) result = testdir.runpytest_inprocess("--durations=10") assert result.ret == 0 result.stdout.fnmatch_lines_random( """ *durations* - * setup *test_1* - * call *test_1* + 5.00s call *test_1* + 2.00s setup *test_1* """ ) diff --git a/testing/conftest.py b/testing/conftest.py index 58386b162..f43018948 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -197,3 +197,41 @@ def color_mapping(): ) return ColorMapping + + +@pytest.fixture +def mock_timing(monkeypatch): + """Mocks _pytest.timing with a known object that can be used to control timing in tests + deterministically. + + pytest itself should always use functions from `_pytest.timing` instead of `time` directly. + + This then allows us more control over time during testing, if testing code also + uses `_pytest.timing` functions. + + Time is static, and only advances through `sleep` calls, thus tests might sleep over large + numbers and obtain accurate time() calls at the end, making tests reliable and instant. + """ + import attr + + @attr.s + class MockTiming: + + _current_time = attr.ib(default=1590150050.0) + + def sleep(self, seconds): + self._current_time += seconds + + def time(self): + return self._current_time + + def patch(self): + from _pytest import timing + + monkeypatch.setattr(timing, "sleep", self.sleep) + monkeypatch.setattr(timing, "time", self.time) + monkeypatch.setattr(timing, "perf_counter", self.time) + + result = MockTiming() + result.patch() + return result diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index a1f86b0b8..83e61e1d9 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -201,23 +201,23 @@ class TestPython: timestamp = datetime.strptime(node["timestamp"], "%Y-%m-%dT%H:%M:%S.%f") assert start_time <= timestamp < datetime.now() - def test_timing_function(self, testdir, run_and_parse): + def test_timing_function(self, testdir, run_and_parse, mock_timing): testdir.makepyfile( """ - import time, pytest + from _pytest import timing def setup_module(): - time.sleep(0.01) + timing.sleep(1) def teardown_module(): - time.sleep(0.01) + timing.sleep(2) def test_sleep(): - time.sleep(0.01) + timing.sleep(4) """ ) result, dom = run_and_parse() node = dom.find_first_by_tag("testsuite") tnode = node.find_first_by_tag("testcase") val = tnode["time"] - assert round(float(val), 2) >= 0.03 + assert float(val) == 7.0 @pytest.mark.parametrize("duration_report", ["call", "total"]) def test_junit_duration_report( From 5507752c530033865986880c93154465aac53e92 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 002/140] fixtures: remove special cases when deciding when pytest.fixture() is a direct decoration pytest.fixture() can be used either as @pytest.fixture def func(): ... or as @pytest.fixture() def func(): ... or (while maybe not intended) func = pytest.fixture(func) so it needs to inspect internally whether it got a function in the first positional argument or not. Previously, there were was oddity. In the following, func = pytest.fixture(func, autouse=True) # OR func = pytest.fixture(func, parms=['a', 'b']) The result is as if `func` wasn't passed. There isn't any reason for this special that I can understand, so remove it. --- changelog/7253.bugfix.rst | 3 +++ src/_pytest/fixtures.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 changelog/7253.bugfix.rst diff --git a/changelog/7253.bugfix.rst b/changelog/7253.bugfix.rst new file mode 100644 index 000000000..e73ef663f --- /dev/null +++ b/changelog/7253.bugfix.rst @@ -0,0 +1,3 @@ +When using ``pytest.fixture`` on a function directly, as in ``pytest.fixture(func)``, +if the ``autouse`` or ``params`` arguments are also passed, the function is no longer +ignored, but is marked as a fixture. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 82a148127..a1574634a 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1152,13 +1152,15 @@ def fixture( if params is not None: params = list(params) - if fixture_function and params is None and autouse is False: - # direct decoration - return FixtureFunctionMarker(scope, params, autouse, name=name)( - fixture_function - ) + fixture_marker = FixtureFunctionMarker( + scope=scope, params=params, autouse=autouse, ids=ids, name=name, + ) - return FixtureFunctionMarker(scope, params, autouse, ids=ids, name=name) + # Direct decoration. + if fixture_function: + return fixture_marker(fixture_function) + + return fixture_marker def yield_fixture( From eef4f87e7b9958687b6032f8475535ac0beca10f Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 30 May 2020 20:36:02 -0400 Subject: [PATCH 003/140] Output a warning to stderr when an invalid key is read from an INI config file --- src/_pytest/config/__init__.py | 9 +++++++ testing/test_config.py | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index bb5034ab1..65e5271c2 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1020,6 +1020,7 @@ class Config: ) self._checkversion() + self._validatekeys() self._consider_importhook(args) self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): @@ -1072,6 +1073,14 @@ class Config: ) ) + def _validatekeys(self): + for key in self._get_unknown_ini_keys(): + sys.stderr.write("WARNING: unknown config ini key: {}\n".format(key)) + + def _get_unknown_ini_keys(self) -> List[str]: + parser_inicfg = self._parser._inidict + return [name for name in self.inicfg if name not in parser_inicfg] + def parse(self, args: List[str], addopts: bool = True) -> None: # parse given cmdline arguments into this config object. assert not hasattr( diff --git a/testing/test_config.py b/testing/test_config.py index 17385dc17..9323e6716 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -147,6 +147,52 @@ class TestParseIni: result = testdir.inline_run("--confcutdir=.") assert result.ret == 0 + @pytest.mark.parametrize( + "ini_file_text, invalid_keys, stderr_output", + [ + ( + """ + [pytest] + unknown_ini = value1 + another_unknown_ini = value2 + """, + ["unknown_ini", "another_unknown_ini"], + [ + "WARNING: unknown config ini key: unknown_ini", + "WARNING: unknown config ini key: another_unknown_ini", + ], + ), + ( + """ + [pytest] + unknown_ini = value1 + minversion = 5.0.0 + """, + ["unknown_ini"], + ["WARNING: unknown config ini key: unknown_ini"], + ), + ( + """ + [pytest] + minversion = 5.0.0 + """, + [], + [], + ), + ], + ) + def test_invalid_ini_keys_generate_warings( + self, testdir, ini_file_text, invalid_keys, stderr_output + ): + testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text)) + config = testdir.parseconfig() + assert config._get_unknown_ini_keys() == invalid_keys, str( + config._get_unknown_ini_keys() + ) + + result = testdir.runpytest() + result.stderr.fnmatch_lines(stderr_output) + class TestConfigCmdlineParsing: def test_parsing_again_fails(self, testdir): From 8f2c2a5dd9fffc2a59d3ed868c801746ce4b51b5 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 00:49:21 -0400 Subject: [PATCH 004/140] Add test case for invalid ini key in different section header --- testing/test_config.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/testing/test_config.py b/testing/test_config.py index 9323e6716..e35019337 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -173,6 +173,16 @@ class TestParseIni: ), ( """ + [some_other_header] + unknown_ini = value1 + [pytest] + minversion = 5.0.0 + """, + [], + [], + ), + ( + """ [pytest] minversion = 5.0.0 """, From db203afba32cc162ab9e8d1da079dde600bd21cc Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 02:45:40 -0400 Subject: [PATCH 005/140] Add in --strict-config flag to force warnings to errors --- src/_pytest/config/__init__.py | 9 ++++++--- src/_pytest/main.py | 5 +++++ testing/test_config.py | 20 ++++++++++++++------ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 65e5271c2..63f7d4cb4 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1020,7 +1020,7 @@ class Config: ) self._checkversion() - self._validatekeys() + self._validatekeys(args) self._consider_importhook(args) self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): @@ -1073,9 +1073,12 @@ class Config: ) ) - def _validatekeys(self): + def _validatekeys(self, args: Sequence[str]): for key in self._get_unknown_ini_keys(): - sys.stderr.write("WARNING: unknown config ini key: {}\n".format(key)) + message = "Unknown config ini key: {}\n".format(key) + if "--strict-config" in args: + fail(message, pytrace=False) + sys.stderr.write("WARNING: {}".format(message)) def _get_unknown_ini_keys(self) -> List[str]: parser_inicfg = self._parser._inidict diff --git a/src/_pytest/main.py b/src/_pytest/main.py index de7e16744..4eb47be2c 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -70,6 +70,11 @@ def pytest_addoption(parser): default=0, help="exit after first num failures or errors.", ) + group._addoption( + "--strict-config", + action="store_true", + help="invalid ini keys for the `pytest` section of the configuration file raise errors.", + ) group._addoption( "--strict-markers", "--strict", diff --git a/testing/test_config.py b/testing/test_config.py index e35019337..6a08e93f3 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -148,7 +148,7 @@ class TestParseIni: assert result.ret == 0 @pytest.mark.parametrize( - "ini_file_text, invalid_keys, stderr_output", + "ini_file_text, invalid_keys, stderr_output, exception_text", [ ( """ @@ -158,9 +158,10 @@ class TestParseIni: """, ["unknown_ini", "another_unknown_ini"], [ - "WARNING: unknown config ini key: unknown_ini", - "WARNING: unknown config ini key: another_unknown_ini", + "WARNING: Unknown config ini key: unknown_ini", + "WARNING: Unknown config ini key: another_unknown_ini", ], + "Unknown config ini key: unknown_ini", ), ( """ @@ -169,7 +170,8 @@ class TestParseIni: minversion = 5.0.0 """, ["unknown_ini"], - ["WARNING: unknown config ini key: unknown_ini"], + ["WARNING: Unknown config ini key: unknown_ini"], + "Unknown config ini key: unknown_ini", ), ( """ @@ -180,6 +182,7 @@ class TestParseIni: """, [], [], + "", ), ( """ @@ -188,11 +191,12 @@ class TestParseIni: """, [], [], + "", ), ], ) - def test_invalid_ini_keys_generate_warings( - self, testdir, ini_file_text, invalid_keys, stderr_output + def test_invalid_ini_keys( + self, testdir, ini_file_text, invalid_keys, stderr_output, exception_text ): testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text)) config = testdir.parseconfig() @@ -203,6 +207,10 @@ class TestParseIni: result = testdir.runpytest() result.stderr.fnmatch_lines(stderr_output) + if stderr_output: + with pytest.raises(pytest.fail.Exception, match=exception_text): + testdir.runpytest("--strict-config") + class TestConfigCmdlineParsing: def test_parsing_again_fails(self, testdir): From 92d15c6af1943faeb629bf3cbd6488b56d9aef52 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 11:33:31 -0400 Subject: [PATCH 006/140] review feedback --- src/_pytest/config/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 63f7d4cb4..5fc23716d 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1020,7 +1020,6 @@ class Config: ) self._checkversion() - self._validatekeys(args) self._consider_importhook(args) self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): @@ -1031,6 +1030,7 @@ class Config: self.known_args_namespace = ns = self._parser.parse_known_args( args, namespace=copy.copy(self.option) ) + self._validatekeys() if self.known_args_namespace.confcutdir is None and self.inifile: confcutdir = py.path.local(self.inifile).dirname self.known_args_namespace.confcutdir = confcutdir @@ -1073,10 +1073,10 @@ class Config: ) ) - def _validatekeys(self, args: Sequence[str]): + def _validatekeys(self): for key in self._get_unknown_ini_keys(): message = "Unknown config ini key: {}\n".format(key) - if "--strict-config" in args: + if self.known_args_namespace.strict_config: fail(message, pytrace=False) sys.stderr.write("WARNING: {}".format(message)) From 9ae94b08e2ad55aed1abc7aa414adac626d005f9 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 31 May 2020 11:58:39 -0400 Subject: [PATCH 007/140] Add documentation --- AUTHORS | 1 + changelog/6856.feature.rst | 0 2 files changed, 1 insertion(+) create mode 100644 changelog/6856.feature.rst diff --git a/AUTHORS b/AUTHORS index 5d410da18..41b0e38b0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -109,6 +109,7 @@ Gabriel Reis Gene Wood George Kussumoto Georgy Dyuldin +Gleb Nikonorov Graham Horler Greg Price Gregory Lee diff --git a/changelog/6856.feature.rst b/changelog/6856.feature.rst new file mode 100644 index 000000000..e69de29bb From 2f406bb9cb8538e5a43551d6eeffe2be80bccefa Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 1 Jun 2020 09:21:08 -0300 Subject: [PATCH 008/140] Replace custom flask theme by the official one (#6453) Ref: #6402 --- .../flask => _templates}/relations.html | 0 .../flask => _templates}/slim_searchbox.html | 0 doc/en/_themes/.gitignore | 3 - doc/en/_themes/LICENSE | 37 -- doc/en/_themes/README | 31 - doc/en/_themes/flask/layout.html | 24 - doc/en/_themes/flask/static/flasky.css_t | 623 ------------------ doc/en/_themes/flask/theme.conf | 9 - doc/en/_themes/flask_theme_support.py | 87 --- doc/en/conf.py | 3 +- doc/en/requirements.txt | 3 +- 11 files changed, 4 insertions(+), 816 deletions(-) rename doc/en/{_themes/flask => _templates}/relations.html (100%) rename doc/en/{_themes/flask => _templates}/slim_searchbox.html (100%) delete mode 100644 doc/en/_themes/.gitignore delete mode 100644 doc/en/_themes/LICENSE delete mode 100644 doc/en/_themes/README delete mode 100644 doc/en/_themes/flask/layout.html delete mode 100644 doc/en/_themes/flask/static/flasky.css_t delete mode 100644 doc/en/_themes/flask/theme.conf delete mode 100644 doc/en/_themes/flask_theme_support.py diff --git a/doc/en/_themes/flask/relations.html b/doc/en/_templates/relations.html similarity index 100% rename from doc/en/_themes/flask/relations.html rename to doc/en/_templates/relations.html diff --git a/doc/en/_themes/flask/slim_searchbox.html b/doc/en/_templates/slim_searchbox.html similarity index 100% rename from doc/en/_themes/flask/slim_searchbox.html rename to doc/en/_templates/slim_searchbox.html diff --git a/doc/en/_themes/.gitignore b/doc/en/_themes/.gitignore deleted file mode 100644 index 66b6e4c2f..000000000 --- a/doc/en/_themes/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -*.pyc -*.pyo -.DS_Store diff --git a/doc/en/_themes/LICENSE b/doc/en/_themes/LICENSE deleted file mode 100644 index 8daab7ee6..000000000 --- a/doc/en/_themes/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -Copyright (c) 2010 by Armin Ronacher. - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Flask and Flask-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/en/_themes/README b/doc/en/_themes/README deleted file mode 100644 index b3292bdff..000000000 --- a/doc/en/_themes/README +++ /dev/null @@ -1,31 +0,0 @@ -Flask Sphinx Styles -=================== - -This repository contains sphinx styles for Flask and Flask related -projects. To use this style in your Sphinx documentation, follow -this guide: - -1. put this folder as _themes into your docs folder. Alternatively - you can also use git submodules to check out the contents there. -2. add this to your conf.py: - - sys.path.append(os.path.abspath('_themes')) - html_theme_path = ['_themes'] - html_theme = 'flask' - -The following themes exist: - -- 'flask' - the standard flask documentation theme for large - projects -- 'flask_small' - small one-page theme. Intended to be used by - very small addon libraries for flask. - -The following options exist for the flask_small theme: - - [options] - index_logo = '' filename of a picture in _static - to be used as replacement for the - h1 in the index.rst file. - index_logo_height = 120px height of the index logo - github_fork = '' repository name on github for the - "fork me" badge diff --git a/doc/en/_themes/flask/layout.html b/doc/en/_themes/flask/layout.html deleted file mode 100644 index f2fa8e6aa..000000000 --- a/doc/en/_themes/flask/layout.html +++ /dev/null @@ -1,24 +0,0 @@ -{%- extends "basic/layout.html" %} -{%- block extrahead %} - {{ super() }} - {% if theme_touch_icon %} - - {% endif %} - -{% endblock %} -{%- block relbar2 %}{% endblock %} -{% block header %} - {{ super() }} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{%- block footer %} - - {% if pagename == 'index' %} -
- {% endif %} -{%- endblock %} diff --git a/doc/en/_themes/flask/static/flasky.css_t b/doc/en/_themes/flask/static/flasky.css_t deleted file mode 100644 index 108c85401..000000000 --- a/doc/en/_themes/flask/static/flasky.css_t +++ /dev/null @@ -1,623 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -{% set page_width = '1020px' %} -{% set sidebar_width = '220px' %} -/* muted version of green logo color #C9D22A */ -{% set link_color = '#606413' %} -/* blue logo color */ -{% set link_hover_color = '#009de0' %} -{% set base_font = 'sans-serif' %} -{% set header_font = 'sans-serif' %} - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: {{ base_font }}; - font-size: 16px; - background-color: white; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - width: {{ page_width }}; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 {{ sidebar_width }}; -} - -div.sphinxsidebar { - width: {{ sidebar_width }}; -} - -hr { - border: 0; - border-top: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - width: {{ page_width }}; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -div.related { - display: none; -} - -div.sphinxsidebar a { - text-decoration: none; - border-bottom: none; -} - -div.sphinxsidebar a:hover { - color: {{ link_hover_color }}; - border-bottom: 1px solid {{ link_hover_color }}; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 18px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0 0 20px 0; - margin: 0; - text-align: center; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: {{ header_font }}; - color: #444; - font-size: 21px; - font-weight: normal; - margin: 16px 0 0 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 18px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: {{ base_font }}; - font-size: 1em; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: {{ link_color }}; - text-decoration: underline; -} - -a:hover { - color: {{ link_hover_color }}; - text-decoration: underline; -} - -a.reference.internal em { - font-style: normal; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: {{ header_font }}; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -{% if theme_index_logo %} -div.indexwrapper h1 { - text-indent: -999999px; - background: url({{ theme_index_logo }}) no-repeat center center; - height: {{ theme_index_logo_height }}; -} -{% else %} -div.indexwrapper div.body h1 { - font-size: 200%; -} -{% endif %} -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #ddd; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -ul.simple li { - margin-bottom: 0.5em; -} - -div.topic ul.simple li { - margin-bottom: 0; -} - -div.topic li > p:first-child { - margin-top: 0; - margin-bottom: 0; -} - -div.admonition { - background: #fafafa; - padding: 10px 20px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition tt.xref, div.admonition a tt { - border-bottom: 1px solid #fafafa; -} - -div.admonition p.admonition-title { - font-family: {{ header_font }}; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition :last-child { - margin-bottom: 0; -} - -div.highlight { - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note, div.warning { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -div.topic a { - text-decoration: none; - border-bottom: none; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt, code { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; - background: #eee; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; - background: #fdfdfd; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td.label { - width: 0px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - background: #eee; - padding: 7px 12px; - line-height: 1.3em; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid white; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted {{ link_color }}; -} - -a.reference:hover { - border-bottom: 1px solid {{ link_hover_color }}; -} - -li.toctree-l1 a.reference, -li.toctree-l2 a.reference, -li.toctree-l3 a.reference, -li.toctree-l4 a.reference { - border-bottom: none; -} - -li.toctree-l1 a.reference:hover, -li.toctree-l2 a.reference:hover, -li.toctree-l3 a.reference:hover, -li.toctree-l4 a.reference:hover { - border-bottom: 1px solid {{ link_hover_color }}; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted {{ link_color }}; -} - -a.footnote-reference:hover { - border-bottom: 1px solid {{ link_hover_color }}; -} - -a:hover tt { - background: #EEE; -} - -#reference div.section h2 { - /* separate code elements in the reference section */ - border-top: 2px solid #ccc; - padding-top: 0.5em; -} - -#reference div.section h3 { - /* separate code elements in the reference section */ - border-top: 1px solid #ccc; - padding-top: 0.5em; -} - -dl.class, dl.function { - margin-top: 1em; - margin-bottom: 1em; -} - -dl.class > dd { - border-left: 3px solid #ccc; - margin-left: 0px; - padding-left: 30px; -} - -dl.field-list { - flex-direction: column; -} - -dl.field-list dd { - padding-left: 4em; - border-left: 3px solid #ccc; - margin-bottom: 0.5em; -} - -dl.field-list dd > ul { - list-style: none; - padding-left: 0px; -} - -dl.field-list dd > ul > li li :first-child { - text-indent: 0; -} - -dl.field-list dd > ul > li :first-child { - text-indent: -2em; - padding-left: 0px; -} - -dl.field-list dd > p:first-child { - text-indent: -2em; -} - -@media screen and (max-width: 870px) { - - div.sphinxsidebar { - display: none; - } - - div.document { - width: 100%; - - } - - div.documentwrapper { - margin-left: 0; - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - } - - div.bodywrapper { - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - margin-left: 0; - } - - ul { - margin-left: 0; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .bodywrapper { - margin: 0; - } - - .footer { - width: auto; - } - - .github { - display: none; - } - - - -} - - - -@media screen and (max-width: 875px) { - - body { - margin: 0; - padding: 20px 30px; - } - - div.documentwrapper { - float: none; - background: white; - } - - div.sphinxsidebar { - display: block; - float: none; - width: 102.5%; - margin: 50px -30px -20px -30px; - padding: 10px 20px; - background: #333; - color: white; - } - - div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, - div.sphinxsidebar h3 a, div.sphinxsidebar ul { - color: white; - } - - div.sphinxsidebar a { - color: #aaa; - } - - div.sphinxsidebar p.logo { - display: none; - } - - div.document { - width: 100%; - margin: 0; - } - - div.related { - display: block; - margin: 0; - padding: 10px 0 20px 0; - } - - div.related ul, - div.related ul li { - margin: 0; - padding: 0; - } - - div.footer { - display: none; - } - - div.bodywrapper { - margin: 0; - } - - div.body { - min-height: 0; - padding: 0; - } - - .rtd_doc_footer { - display: none; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .footer { - width: auto; - } - - .github { - display: none; - } -} - -/* misc. */ - -.revsys-inline { - display: none!important; -} diff --git a/doc/en/_themes/flask/theme.conf b/doc/en/_themes/flask/theme.conf deleted file mode 100644 index 372b00283..000000000 --- a/doc/en/_themes/flask/theme.conf +++ /dev/null @@ -1,9 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -pygments_style = flask_theme_support.FlaskyStyle - -[options] -index_logo = '' -index_logo_height = 120px -touch_icon = diff --git a/doc/en/_themes/flask_theme_support.py b/doc/en/_themes/flask_theme_support.py deleted file mode 100644 index b107f2c89..000000000 --- a/doc/en/_themes/flask_theme_support.py +++ /dev/null @@ -1,87 +0,0 @@ -# flasky extensions. flasky pygments style based on tango style -from pygments.style import Style -from pygments.token import Comment -from pygments.token import Error -from pygments.token import Generic -from pygments.token import Keyword -from pygments.token import Literal -from pygments.token import Name -from pygments.token import Number -from pygments.token import Operator -from pygments.token import Other -from pygments.token import Punctuation -from pygments.token import String -from pygments.token import Whitespace - - -class FlaskyStyle(Style): - background_color = "#f8f8f8" - default_style = "" - - styles = { - # No corresponding class for the following: - # Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - Punctuation: "bold #000000", # class: 'p' - # because special names such as Name.Class, Name.Function, etc. - # are not recognized as such later in the parsing, we choose them - # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - Number: "#990000", # class: 'm' - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' - } diff --git a/doc/en/conf.py b/doc/en/conf.py index e62bc157a..72e2d4f20 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -43,6 +43,7 @@ todo_include_todos = 1 # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ + "pallets_sphinx_themes", "pygments_pytest", "sphinx.ext.autodoc", "sphinx.ext.autosummary", @@ -142,7 +143,7 @@ html_theme = "flask" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -html_theme_options = {"index_logo": None} +# html_theme_options = {"index_logo": None} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] diff --git a/doc/en/requirements.txt b/doc/en/requirements.txt index be22b7db8..1e5e7efdc 100644 --- a/doc/en/requirements.txt +++ b/doc/en/requirements.txt @@ -1,4 +1,5 @@ +pallets-sphinx-themes pygments-pytest>=1.1.0 +sphinx-removed-in>=0.2.0 sphinx>=1.8.2,<2.1 sphinxcontrib-trio -sphinx-removed-in>=0.2.0 From 2748feed38f826057f85d14229557304dbdfb26d Mon Sep 17 00:00:00 2001 From: Keri Volans Date: Mon, 1 Jun 2020 16:19:40 +0100 Subject: [PATCH 009/140] 7291: Replace py.iniconfig with iniconfig --- changelog/7291.trivial.rst | 1 + setup.py | 1 + src/_pytest/config/findpaths.py | 8 +++++--- src/_pytest/pytester.py | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 changelog/7291.trivial.rst diff --git a/changelog/7291.trivial.rst b/changelog/7291.trivial.rst new file mode 100644 index 000000000..9bc99f651 --- /dev/null +++ b/changelog/7291.trivial.rst @@ -0,0 +1 @@ +Replaced usages of py.iniconfig with iniconfig. diff --git a/setup.py b/setup.py index cd2ecbe07..79fef1f4d 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ INSTALL_REQUIRES = [ 'colorama;sys_platform=="win32"', "pluggy>=0.12,<1.0", 'importlib-metadata>=0.12;python_version<"3.8"', + "iniconfig", ] diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index f4f62e06b..2b252c4f4 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -6,6 +6,8 @@ from typing import Optional from typing import Tuple import py +from iniconfig import IniConfig +from iniconfig import ParseError from .exceptions import UsageError from _pytest.compat import TYPE_CHECKING @@ -40,8 +42,8 @@ def getcfg(args, config=None): p = base.join(inibasename) if exists(p): try: - iniconfig = py.iniconfig.IniConfig(p) - except py.iniconfig.ParseError as exc: + iniconfig = IniConfig(p) + except ParseError as exc: raise UsageError(str(exc)) if ( @@ -119,7 +121,7 @@ def determine_setup( ) -> Tuple[py.path.local, Optional[str], Any]: dirs = get_dirs_from_args(args) if inifile: - iniconfig = py.iniconfig.IniConfig(inifile) + iniconfig = IniConfig(inifile) is_cfg_file = str(inifile).endswith(".cfg") sections = ["tool:pytest", "pytest"] if is_cfg_file else ["pytest"] for section in sections: diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 9df86a22f..fc4e4d853 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -22,6 +22,7 @@ from typing import Union from weakref import WeakKeyDictionary import py +from iniconfig import IniConfig import pytest from _pytest._code import Source @@ -683,7 +684,7 @@ class Testdir: def getinicfg(self, source): """Return the pytest section from the tox.ini config file.""" p = self.makeini(source) - return py.iniconfig.IniConfig(p)["pytest"] + return IniConfig(p)["pytest"] def makepyfile(self, *args, **kwargs): r"""Shortcut for .makefile() with a .py extension. From 9214e63af38ad76c6d4f02f0db4a4cdb736ab032 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 2 Jun 2020 10:29:36 +0300 Subject: [PATCH 010/140] ci: use fetch-depth: 0 instead of fetching manually (#7297) --- .github/workflows/main.yml | 8 ++++---- .github/workflows/release-on-comment.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d1910014..262ed5946 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -128,8 +128,8 @@ jobs: steps: - uses: actions/checkout@v2 - # For setuptools-scm. - - run: git fetch --prune --unshallow + with: + fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v2 if: matrix.python != '3.9-dev' @@ -177,8 +177,8 @@ jobs: steps: - uses: actions/checkout@v2 - # For setuptools-scm. - - run: git fetch --prune --unshallow + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: diff --git a/.github/workflows/release-on-comment.yml b/.github/workflows/release-on-comment.yml index 9d803cd38..94863d896 100644 --- a/.github/workflows/release-on-comment.yml +++ b/.github/workflows/release-on-comment.yml @@ -15,8 +15,8 @@ jobs: steps: - uses: actions/checkout@v2 - # For setuptools-scm. - - run: git fetch --prune --unshallow + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 From eaf46f53545f4fd8f7d6bd64115c8751590a555c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 1 Jun 2020 21:09:40 -0300 Subject: [PATCH 011/140] Adjust codecov: only patch statuses Fix #6994 --- codecov.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codecov.yml b/codecov.yml index db2472009..f1cc86973 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1 +1,6 @@ -comment: off +# reference: https://docs.codecov.io/docs/codecovyml-reference +coverage: + status: + patch: true + project: false +comment: false From fe640934111b52b0461a3d35994730667525b783 Mon Sep 17 00:00:00 2001 From: Tor Colvin Date: Tue, 2 Jun 2020 07:56:33 -0400 Subject: [PATCH 012/140] Fix removal of very long paths on Windows (#6755) Co-authored-by: Bruno Oliveira --- AUTHORS | 1 + changelog/6755.bugfix.rst | 1 + src/_pytest/pathlib.py | 32 ++++++++++++++++++++++++++++++++ testing/test_pathlib.py | 24 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 changelog/6755.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 5d410da18..a76c0a632 100644 --- a/AUTHORS +++ b/AUTHORS @@ -277,6 +277,7 @@ Tom Dalton Tom Viner Tomáš Gavenčiak Tomer Keren +Tor Colvin Trevor Bekolay Tyler Goodlet Tzu-ping Chung diff --git a/changelog/6755.bugfix.rst b/changelog/6755.bugfix.rst new file mode 100644 index 000000000..8077baa4f --- /dev/null +++ b/changelog/6755.bugfix.rst @@ -0,0 +1 @@ +Support deleting paths longer than 260 characters on windows created inside tmpdir. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 21ec61e2c..90a7460b0 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -100,10 +100,41 @@ def on_rm_rf_error(func, path: str, exc, *, start_path: Path) -> bool: return True +def ensure_extended_length_path(path: Path) -> Path: + """Get the extended-length version of a path (Windows). + + On Windows, by default, the maximum length of a path (MAX_PATH) is 260 + characters, and operations on paths longer than that fail. But it is possible + to overcome this by converting the path to "extended-length" form before + performing the operation: + https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation + + On Windows, this function returns the extended-length absolute version of path. + On other platforms it returns path unchanged. + """ + if sys.platform.startswith("win32"): + path = path.resolve() + path = Path(get_extended_length_path_str(str(path))) + return path + + +def get_extended_length_path_str(path: str) -> str: + """Converts to extended length path as a str""" + long_path_prefix = "\\\\?\\" + unc_long_path_prefix = "\\\\?\\UNC\\" + if path.startswith((long_path_prefix, unc_long_path_prefix)): + return path + # UNC + if path.startswith("\\\\"): + return unc_long_path_prefix + path[2:] + return long_path_prefix + path + + def rm_rf(path: Path) -> None: """Remove the path contents recursively, even if some elements are read-only. """ + path = ensure_extended_length_path(path) onerror = partial(on_rm_rf_error, start_path=path) shutil.rmtree(str(path), onerror=onerror) @@ -220,6 +251,7 @@ def register_cleanup_lock_removal(lock_path: Path, register=atexit.register): def maybe_delete_a_numbered_dir(path: Path) -> None: """removes a numbered directory if its lock can be obtained and it does not seem to be in use""" + path = ensure_extended_length_path(path) lock_path = None try: lock_path = create_cleanup_lock(path) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 45daeaed7..03bed26ec 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -5,6 +5,7 @@ import py import pytest from _pytest.pathlib import fnmatch_ex +from _pytest.pathlib import get_extended_length_path_str from _pytest.pathlib import get_lock_path from _pytest.pathlib import maybe_delete_a_numbered_dir from _pytest.pathlib import Path @@ -89,3 +90,26 @@ def test_access_denied_during_cleanup(tmp_path, monkeypatch): lock_path = get_lock_path(path) maybe_delete_a_numbered_dir(path) assert not lock_path.is_file() + + +def test_long_path_during_cleanup(tmp_path): + """Ensure that deleting long path works (particularly on Windows (#6775)).""" + path = (tmp_path / ("a" * 250)).resolve() + if sys.platform == "win32": + # make sure that the full path is > 260 characters without any + # component being over 260 characters + assert len(str(path)) > 260 + extended_path = "\\\\?\\" + str(path) + else: + extended_path = str(path) + os.mkdir(extended_path) + assert os.path.isdir(extended_path) + maybe_delete_a_numbered_dir(path) + assert not os.path.isdir(extended_path) + + +def test_get_extended_length_path_str(): + assert get_extended_length_path_str(r"c:\foo") == r"\\?\c:\foo" + assert get_extended_length_path_str(r"\\share\foo") == r"\\?\UNC\share\foo" + assert get_extended_length_path_str(r"\\?\UNC\share\foo") == r"\\?\UNC\share\foo" + assert get_extended_length_path_str(r"\\?\c:\foo") == r"\\?\c:\foo" From a5d13d4ced6dc3f8d826233e02788b86aebb3743 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Tue, 2 Jun 2020 08:21:57 -0400 Subject: [PATCH 013/140] Add changelog entry --- changelog/6856.feature.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog/6856.feature.rst b/changelog/6856.feature.rst index e69de29bb..36892fa21 100644 --- a/changelog/6856.feature.rst +++ b/changelog/6856.feature.rst @@ -0,0 +1,3 @@ +A warning is now shown when an unknown key is read from a config INI file. + +The `--strict-config` flag has been added to treat these warnings as errors. From 5517f7264fcb85cf1563702bd9444e7be917daf3 Mon Sep 17 00:00:00 2001 From: xuiqzy Date: Tue, 2 Jun 2020 14:56:39 +0200 Subject: [PATCH 014/140] Remove doc line that is no longer relevant for Python3-only (#7263) * Fix typo in capture.rst documentation Rename ``capfsysbinary`` to ``capsysbinary`` as the former does not exist as far as i can see. * Make Python uppercase in doc/en/capture.rst Co-authored-by: Hugo van Kemenade * Remove the sentence entirely Co-authored-by: Hugo van Kemenade Co-authored-by: Ran Benita --- doc/en/capture.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/en/capture.rst b/doc/en/capture.rst index 7c8c25cc5..44d3a3bd1 100644 --- a/doc/en/capture.rst +++ b/doc/en/capture.rst @@ -145,8 +145,7 @@ The return value from ``readouterr`` changed to a ``namedtuple`` with two attrib If the code under test writes non-textual data, you can capture this using the ``capsysbinary`` fixture which instead returns ``bytes`` from -the ``readouterr`` method. The ``capfsysbinary`` fixture is currently only -available in python 3. +the ``readouterr`` method. From da5851c13e3dd992deb8267110e3099fc0216ced Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 2 Jun 2020 11:01:37 -0300 Subject: [PATCH 015/140] Update changelog/7291.trivial.rst --- changelog/7291.trivial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/7291.trivial.rst b/changelog/7291.trivial.rst index 9bc99f651..8f41528aa 100644 --- a/changelog/7291.trivial.rst +++ b/changelog/7291.trivial.rst @@ -1 +1 @@ -Replaced usages of py.iniconfig with iniconfig. +Replaced ``py.iniconfig`` with `iniconfig `__. From 85b5a289f01696ad9131213bd6045867b46c50c2 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 2 Jun 2020 19:59:25 +0300 Subject: [PATCH 016/140] warnings: fix missing None in existing hook & add some docs (#7288) --- doc/en/deprecations.rst | 11 +++++++++++ src/_pytest/hookspec.py | 27 +++++++++++++++++++-------- src/_pytest/warnings.py | 7 ++++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 0b7d3fecd..dba02248b 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -20,6 +20,17 @@ Below is a complete list of all pytest features which are considered deprecated. :ref:`standard warning filters `. +The ``pytest_warning_captured`` hook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 6.0 + +This hook has an `item` parameter which cannot be serialized by ``pytest-xdist``. + +Use the ``pytest_warning_recored`` hook instead, which replaces the ``item`` parameter +by a ``nodeid`` parameter. + + The ``pytest._fillfuncargs`` function ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 341f0a250..de29a40bf 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -623,9 +623,16 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): @hookspec(historic=True, warn_on_impl=WARNING_CAPTURED_HOOK) -def pytest_warning_captured(warning_message, when, item, location): +def pytest_warning_captured( + warning_message: "warnings.WarningMessage", + when: str, + item, + location: Optional[Tuple[str, int, str]], +) -> None: """(**Deprecated**) Process a warning captured by the internal pytest warnings plugin. + .. deprecated:: 6.0 + This hook is considered deprecated and will be removed in a future pytest version. Use :func:`pytest_warning_recorded` instead. @@ -644,8 +651,9 @@ def pytest_warning_captured(warning_message, when, item, location): 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. + When available, 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. """ @@ -654,8 +662,8 @@ def pytest_warning_recorded( warning_message: "warnings.WarningMessage", when: str, nodeid: str, - location: Tuple[str, int, str], -): + location: Optional[Tuple[str, int, str]], +) -> None: """ Process a warning captured by the internal pytest warnings plugin. @@ -672,9 +680,12 @@ def pytest_warning_recorded( :param str nodeid: full id of the item - :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. + :param tuple|None location: + When available, 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. + + .. versionadded:: 6.0 """ diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 8828a53d6..33d89428b 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -112,7 +112,12 @@ def catch_warnings_for_item(config, ihook, when, item): for warning_message in log: ihook.pytest_warning_captured.call_historic( - kwargs=dict(warning_message=warning_message, when=when, item=item) + kwargs=dict( + warning_message=warning_message, + when=when, + item=item, + location=None, + ) ) ihook.pytest_warning_recorded.call_historic( kwargs=dict( From be1a2e440e8e3d83f411478ae802f4b1193ba784 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 2 Jun 2020 14:19:18 -0300 Subject: [PATCH 017/140] Merge pull request #7301 from pytest-dev/release-5.4.3 Prepare release 5.4.3 --- doc/en/announce/index.rst | 1 + doc/en/announce/release-5.4.3.rst | 21 +++++++++++++++++++++ doc/en/changelog.rst | 23 +++++++++++++++++++++++ doc/en/example/parametrize.rst | 7 +++---- 4 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 doc/en/announce/release-5.4.3.rst diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index eeea78274..4405e6fe0 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-5.4.3 release-5.4.2 release-5.4.1 release-5.4.0 diff --git a/doc/en/announce/release-5.4.3.rst b/doc/en/announce/release-5.4.3.rst new file mode 100644 index 000000000..4d48fc119 --- /dev/null +++ b/doc/en/announce/release-5.4.3.rst @@ -0,0 +1,21 @@ +pytest-5.4.3 +======================================= + +pytest 5.4.3 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: + +* Anthony Sottile +* Bruno Oliveira +* Ran Benita +* Tor Colvin + + +Happy testing, +The pytest Development Team diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index 705bb1044..1a298072b 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -28,6 +28,29 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 5.4.3 (2020-06-02) +========================= + +Bug Fixes +--------- + +- `#6428 `_: Paths appearing in error messages are now correct in case the current working directory has + changed since the start of the session. + + +- `#6755 `_: Support deleting paths longer than 260 characters on windows created inside tmpdir. + + +- `#6956 `_: Prevent pytest from printing ConftestImportFailure traceback to stdout. + + +- `#7150 `_: Prevent hiding the underlying exception when ``ConfTestImportFailure`` is raised. + + +- `#7215 `_: Fix regression where running with ``--pdb`` would call the ``tearDown`` methods of ``unittest.TestCase`` + subclasses for skipped tests. + + pytest 5.4.2 (2020-05-08) ========================= diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index 02fd99004..9500af0d3 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -482,11 +482,10 @@ Running it results in some skips if we don't have all the python interpreters in .. code-block:: pytest . $ pytest -rs -q multipython.py - ssssssssssss...ssssssssssss [100%] + ssssssssssss......sss...... [100%] ========================= short test summary info ========================== - SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.5' not found - SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.7' not found - 3 passed, 24 skipped in 0.12s + SKIPPED [15] $REGENDOC_TMPDIR/CWD/multipython.py:29: 'python3.5' not found + 12 passed, 15 skipped in 0.12s Indirect parametrization of optional implementations/imports -------------------------------------------------------------------- From 8ac18bbecb2eb9646598b4bb1bfe429d4d26cc9d Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 2 Jun 2020 16:01:47 -0300 Subject: [PATCH 018/140] Show invalid ini keys sorted Otherwise this relies on the dictionary order of `config.inicfg`, which is insertion order in py36+ but "random" order in py35. --- src/_pytest/config/__init__.py | 2 +- testing/test_config.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 5fc23716d..343cdd960 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1074,7 +1074,7 @@ class Config: ) def _validatekeys(self): - for key in self._get_unknown_ini_keys(): + for key in sorted(self._get_unknown_ini_keys()): message = "Unknown config ini key: {}\n".format(key) if self.known_args_namespace.strict_config: fail(message, pytrace=False) diff --git a/testing/test_config.py b/testing/test_config.py index 6a08e93f3..c102202ed 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -158,10 +158,10 @@ class TestParseIni: """, ["unknown_ini", "another_unknown_ini"], [ - "WARNING: Unknown config ini key: unknown_ini", "WARNING: Unknown config ini key: another_unknown_ini", + "WARNING: Unknown config ini key: unknown_ini", ], - "Unknown config ini key: unknown_ini", + "Unknown config ini key: another_unknown_ini", ), ( """ @@ -200,9 +200,7 @@ class TestParseIni: ): testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text)) config = testdir.parseconfig() - assert config._get_unknown_ini_keys() == invalid_keys, str( - config._get_unknown_ini_keys() - ) + assert sorted(config._get_unknown_ini_keys()) == sorted(invalid_keys) result = testdir.runpytest() result.stderr.fnmatch_lines(stderr_output) From 8cca0238406fc2c34c50ea44f45fdf5fbc36efa4 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Tue, 2 Jun 2020 12:30:10 -0700 Subject: [PATCH 019/140] cache the pre-commit environment --- .github/workflows/main.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 262ed5946..056c8d3db 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,7 +39,6 @@ jobs: "macos-py37", "macos-py38", - "linting", "docs", "doctesting", ] @@ -112,10 +111,6 @@ jobs: tox_env: "py38-xdist" use_coverage: true - - name: "linting" - python: "3.7" - os: ubuntu-latest - tox_env: "linting" - name: "docs" python: "3.7" os: ubuntu-latest @@ -168,6 +163,20 @@ jobs: CODECOV_NAME: ${{ matrix.name }} run: bash scripts/report-coverage.sh -F GHA,${{ runner.os }} + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - name: set PY + run: echo "::set-env name=PY::$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')" + - uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} + - run: pip install tox + - run: tox -e linting + deploy: if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pytest' From 2e219ad4f32a3533cce53b865fb7f6548478de66 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 3 Jun 2020 21:51:55 +0300 Subject: [PATCH 020/140] testing: change a test to not use deprecated pluggy __multicall__ protocol It is slated to be removed in pluggy 1.0. --- testing/test_runner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/test_runner.py b/testing/test_runner.py index 7b0b27a4b..32620801d 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -506,9 +506,10 @@ def test_runtest_in_module_ordering(testdir) -> None: @pytest.fixture def mylist(self, request): return request.function.mylist - def pytest_runtest_call(self, item, __multicall__): + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item): try: - __multicall__.execute() + (yield).get_result() except ValueError: pass def test_hello1(self, mylist): From 789eea246425fefebf60f188f9a4b4606ff65514 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Thu, 4 Jun 2020 09:58:28 -0700 Subject: [PATCH 021/140] Run setup-py-upgrade and setup-cfg-fmt - also ran `pre-commit autoupdate` - https://github.com/asottile/setup-py-upgrade - https://github.com/asottile/setup-cfg-fmt --- .pre-commit-config.yaml | 18 ++++++++---- setup.cfg | 63 +++++++++++++++++++++++++++++------------ setup.py | 35 +---------------------- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 81e30ecc1..4f379968e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,12 @@ repos: - id: black args: [--safe, --quiet] - repo: https://github.com/asottile/blacken-docs - rev: v1.6.0 + rev: v1.7.0 hooks: - id: blacken-docs additional_dependencies: [black==19.10b0] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 + rev: v3.1.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -21,23 +21,29 @@ repos: exclude: _pytest/debugging.py language_version: python3 - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.1 + rev: 3.8.2 hooks: - id: flake8 language_version: python3 additional_dependencies: [flake8-typing-imports==1.9.0] - repo: https://github.com/asottile/reorder_python_imports - rev: v1.4.0 + rev: v2.3.0 hooks: - id: reorder-python-imports args: ['--application-directories=.:src', --py3-plus] - repo: https://github.com/asottile/pyupgrade - rev: v2.2.1 + rev: v2.4.4 hooks: - id: pyupgrade args: [--py3-plus] +- repo: https://github.com/asottile/setup-cfg-fmt + rev: v1.9.0 + hooks: + - id: setup-cfg-fmt + # TODO: when upgrading setup-cfg-fmt this can be removed + args: [--max-py-version=3.9] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.770 # NOTE: keep this in sync with setup.py. + rev: v0.780 # NOTE: keep this in sync with setup.cfg. hooks: - id: mypy files: ^(src/|testing/) diff --git a/setup.cfg b/setup.cfg index ab3e0f88c..a7dd6d1c3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,35 +2,35 @@ name = pytest description = pytest: simple powerful testing with Python long_description = file: README.rst +long_description_content_type = text/x-rst url = https://docs.pytest.org/en/latest/ -project_urls = - Source=https://github.com/pytest-dev/pytest - Tracker=https://github.com/pytest-dev/pytest/issues - author = Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others - -license = MIT license -keywords = test, unittest +license = MIT +license_file = LICENSE +platforms = unix, linux, osx, cygwin, win32 classifiers = Development Status :: 6 - Mature Intended Audience :: Developers License :: OSI Approved :: MIT License - Operating System :: POSIX - Operating System :: Microsoft :: Windows Operating System :: MacOS :: MacOS X - Topic :: Software Development :: Testing - Topic :: Software Development :: Libraries - Topic :: Utilities + Operating System :: Microsoft :: Windows + Operating System :: POSIX + Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 -platforms = unix, linux, osx, cygwin, win32 + Topic :: Software Development :: Libraries + Topic :: Software Development :: Testing + Topic :: Utilities +keywords = test, unittest +project_urls = + Source=https://github.com/pytest-dev/pytest + Tracker=https://github.com/pytest-dev/pytest/issues [options] -zip_safe = no packages = _pytest _pytest._code @@ -39,13 +39,40 @@ packages = _pytest.config _pytest.mark pytest - +install_requires = + attrs>=17.4.0 + iniconfig + more-itertools>=4.0.0 + packaging + pluggy>=0.12,<1.0 + py>=1.5.0 + atomicwrites>=1.0;sys_platform=="win32" + colorama;sys_platform=="win32" + importlib-metadata>=0.12;python_version<"3.8" + pathlib2>=2.2.0;python_version<"3.6" python_requires = >=3.5 +package_dir = + =src +setup_requires = + setuptools>=40.0 + setuptools-scm +zip_safe = no [options.entry_points] console_scripts = - pytest=pytest:console_main - py.test=pytest:console_main + pytest=pytest:console_main + py.test=pytest:console_main + +[options.extras_require] +checkqa-mypy = + mypy==0.780 +testing = + argcomplete + hypothesis>=3.56 + mock + nose + requests + xmlschema [build_sphinx] source-dir = doc/en/ @@ -57,7 +84,7 @@ upload-dir = doc/en/build/html [check-manifest] ignore = - src/_pytest/_version.py + src/_pytest/_version.py [devpi:upload] formats = sdist.tgz,bdist_wheel diff --git a/setup.py b/setup.py index 79fef1f4d..4475e30a7 100644 --- a/setup.py +++ b/setup.py @@ -1,41 +1,8 @@ from setuptools import setup -# TODO: if py gets upgrade to >=1.6, -# remove _width_of_current_line in terminal.py -INSTALL_REQUIRES = [ - "py>=1.5.0", - "packaging", - "attrs>=17.4.0", # should match oldattrs tox env. - "more-itertools>=4.0.0", - 'atomicwrites>=1.0;sys_platform=="win32"', - 'pathlib2>=2.2.0;python_version<"3.6"', - 'colorama;sys_platform=="win32"', - "pluggy>=0.12,<1.0", - 'importlib-metadata>=0.12;python_version<"3.8"', - "iniconfig", -] - def main(): - setup( - use_scm_version={"write_to": "src/_pytest/_version.py"}, - setup_requires=["setuptools-scm", "setuptools>=40.0"], - package_dir={"": "src"}, - extras_require={ - "testing": [ - "argcomplete", - "hypothesis>=3.56", - "mock", - "nose", - "requests", - "xmlschema", - ], - "checkqa-mypy": [ - "mypy==v0.770", # keep this in sync with .pre-commit-config.yaml. - ], - }, - install_requires=INSTALL_REQUIRES, - ) + setup(use_scm_version={"write_to": "src/_pytest/_version.py"}) if __name__ == "__main__": From 43fa1ee8f9e865319758617d6a1e15bf7eef972f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 022/140] Type annotate some misc places with no particular connection --- src/_pytest/config/__init__.py | 25 +++++++++++++------------ src/_pytest/mark/__init__.py | 6 +++--- src/_pytest/mark/structures.py | 6 +++--- src/_pytest/nodes.py | 3 ++- src/_pytest/runner.py | 20 ++++++++++---------- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 343cdd960..2da7e33aa 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -14,6 +14,7 @@ from types import TracebackType from typing import Any from typing import Callable from typing import Dict +from typing import IO from typing import List from typing import Optional from typing import Sequence @@ -295,7 +296,7 @@ class PytestPluginManager(PluginManager): * ``conftest.py`` loading during start-up; """ - def __init__(self): + def __init__(self) -> None: import _pytest.assertion super().__init__("pytest") @@ -315,7 +316,7 @@ class PytestPluginManager(PluginManager): self.add_hookspecs(_pytest.hookspec) self.register(self) if os.environ.get("PYTEST_DEBUG"): - err = sys.stderr + err = sys.stderr # type: IO[str] encoding = getattr(err, "encoding", "utf8") try: err = open( @@ -377,7 +378,7 @@ class PytestPluginManager(PluginManager): } return opts - def register(self, plugin, name=None): + def register(self, plugin: _PluggyPlugin, name: Optional[str] = None): if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS: warnings.warn( PytestConfigWarning( @@ -552,7 +553,7 @@ class PytestPluginManager(PluginManager): # # - def consider_preparse(self, args, *, exclude_only=False): + def consider_preparse(self, args, *, exclude_only: bool = False) -> None: i = 0 n = len(args) while i < n: @@ -573,7 +574,7 @@ class PytestPluginManager(PluginManager): continue self.consider_pluginarg(parg) - def consider_pluginarg(self, arg): + def consider_pluginarg(self, arg) -> None: if arg.startswith("no:"): name = arg[3:] if name in essential_plugins: @@ -598,13 +599,13 @@ class PytestPluginManager(PluginManager): del self._name2plugin["pytest_" + name] self.import_plugin(arg, consider_entry_points=True) - def consider_conftest(self, conftestmodule): + def consider_conftest(self, conftestmodule) -> None: self.register(conftestmodule, name=conftestmodule.__file__) - def consider_env(self): + def consider_env(self) -> None: self._import_plugin_specs(os.environ.get("PYTEST_PLUGINS")) - def consider_module(self, mod): + def consider_module(self, mod: types.ModuleType) -> None: self._import_plugin_specs(getattr(mod, "pytest_plugins", [])) def _import_plugin_specs(self, spec): @@ -612,7 +613,7 @@ class PytestPluginManager(PluginManager): for import_spec in plugins: self.import_plugin(import_spec) - def import_plugin(self, modname, consider_entry_points=False): + def import_plugin(self, modname: str, consider_entry_points: bool = False) -> None: """ Imports a plugin with ``modname``. If ``consider_entry_points`` is True, entry point names are also considered to find a plugin. @@ -843,19 +844,19 @@ class Config: """Backward compatibility""" return py.path.local(str(self.invocation_params.dir)) - def add_cleanup(self, func): + def add_cleanup(self, func) -> None: """ Add a function to be called when the config object gets out of use (usually coninciding with pytest_unconfigure).""" self._cleanup.append(func) - def _do_configure(self): + def _do_configure(self) -> None: assert not self._configured self._configured = True with warnings.catch_warnings(): warnings.simplefilter("default") self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) - def _ensure_unconfigure(self): + def _ensure_unconfigure(self) -> None: if self._configured: self._configured = False self.hook.pytest_unconfigure(config=self) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 1cd6e74c9..285c7336b 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -162,7 +162,7 @@ class KeywordMatcher: return False -def deselect_by_keyword(items, config): +def deselect_by_keyword(items, config: Config) -> None: keywordexpr = config.option.keyword.lstrip() if not keywordexpr: return @@ -218,7 +218,7 @@ class MarkMatcher: return name in self.own_mark_names -def deselect_by_mark(items, config): +def deselect_by_mark(items, config: Config) -> None: matchexpr = config.option.markexpr if not matchexpr: return @@ -243,7 +243,7 @@ def deselect_by_mark(items, config): items[:] = remaining -def pytest_collection_modifyitems(items, config): +def pytest_collection_modifyitems(items, config: Config) -> None: deselect_by_keyword(items, config) deselect_by_mark(items, config) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index a34a0c28d..eb6340e42 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -271,7 +271,7 @@ class MarkDecorator: return self.with_args(*args, **kwargs) -def get_unpacked_marks(obj): +def get_unpacked_marks(obj) -> List[Mark]: """ obtain the unpacked marks that are stored on an object """ @@ -400,8 +400,8 @@ class NodeKeywords(MutableMapping): seen.update(self.parent.keywords) return seen - def __len__(self): + def __len__(self) -> int: return len(self._seen()) - def __repr__(self): + def __repr__(self) -> str: return "".format(self.node) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 7a8c28cd4..df1c79dac 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -2,6 +2,7 @@ import os import warnings from functools import lru_cache from typing import Any +from typing import Callable from typing import Dict from typing import List from typing import Optional @@ -312,7 +313,7 @@ class Node(metaclass=NodeMeta): def listnames(self): return [x.name for x in self.listchain()] - def addfinalizer(self, fin): + def addfinalizer(self, fin: Callable[[], object]) -> None: """ register a function to be called when this node is finalized. This method can only be called when this node is active diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index aa8a5aa8b..dec6db788 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -321,9 +321,9 @@ class SetupState: def __init__(self): self.stack = [] # type: List[Node] - self._finalizers = {} # type: Dict[Node, List[Callable[[], None]]] + self._finalizers = {} # type: Dict[Node, List[Callable[[], object]]] - def addfinalizer(self, finalizer, colitem): + def addfinalizer(self, finalizer: Callable[[], object], colitem) -> None: """ attach a finalizer to the given colitem. """ assert colitem and not isinstance(colitem, tuple) assert callable(finalizer) @@ -334,7 +334,7 @@ class SetupState: colitem = self.stack.pop() self._teardown_with_finalization(colitem) - def _callfinalizers(self, colitem): + def _callfinalizers(self, colitem) -> None: finalizers = self._finalizers.pop(colitem, None) exc = None while finalizers: @@ -349,24 +349,24 @@ class SetupState: if exc: raise exc - def _teardown_with_finalization(self, colitem): + def _teardown_with_finalization(self, colitem) -> None: self._callfinalizers(colitem) colitem.teardown() for colitem in self._finalizers: assert colitem in self.stack - def teardown_all(self): + def teardown_all(self) -> None: while self.stack: self._pop_and_teardown() for key in list(self._finalizers): self._teardown_with_finalization(key) assert not self._finalizers - def teardown_exact(self, item, nextitem): + def teardown_exact(self, item, nextitem) -> None: needed_collectors = nextitem and nextitem.listchain() or [] self._teardown_towards(needed_collectors) - def _teardown_towards(self, needed_collectors): + def _teardown_towards(self, needed_collectors) -> None: exc = None while self.stack: if self.stack == needed_collectors[: len(self.stack)]: @@ -381,7 +381,7 @@ class SetupState: if exc: raise exc - def prepare(self, colitem): + def prepare(self, colitem) -> None: """ setup objects along the collector chain to the test-method and teardown previously setup objects.""" needed_collectors = colitem.listchain() @@ -390,14 +390,14 @@ class SetupState: # check if the last collection node has raised an error for col in self.stack: if hasattr(col, "_prepare_exc"): - exc = col._prepare_exc + exc = col._prepare_exc # type: ignore[attr-defined] # noqa: F821 raise exc for col in needed_collectors[len(self.stack) :]: self.stack.append(col) try: col.setup() except TEST_OUTCOME as e: - col._prepare_exc = e + col._prepare_exc = e # type: ignore[attr-defined] # noqa: F821 raise e From ff8b7884e8f1019f60f270eab2c4909ff557dd4e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 023/140] Type annotate ParameterSet --- src/_pytest/compat.py | 10 ++++- src/_pytest/mark/__init__.py | 10 ++++- src/_pytest/mark/structures.py | 79 ++++++++++++++++++++++++++++------ testing/test_doctest.py | 2 +- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 4cc22ba4a..84f9609a7 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -1,6 +1,7 @@ """ python version compatibility code """ +import enum import functools import inspect import os @@ -33,13 +34,20 @@ else: if TYPE_CHECKING: from typing import Type + from typing_extensions import Final _T = TypeVar("_T") _S = TypeVar("_S") -NOTSET = object() +# fmt: off +# Singleton type for NOTSET, as described in: +# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions +class NotSetType(enum.Enum): + token = 0 +NOTSET = NotSetType.token # type: Final # noqa: E305 +# fmt: on MODULE_NOT_FOUND_ERROR = ( "ModuleNotFoundError" if sys.version_info[:2] >= (3, 6) else "ImportError" diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 285c7336b..c23a38c76 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -1,7 +1,9 @@ """ generic mechanism for marking and selecting python functions. """ +import typing import warnings from typing import AbstractSet from typing import Optional +from typing import Union import attr @@ -31,7 +33,11 @@ __all__ = ["Mark", "MarkDecorator", "MarkGenerator", "get_empty_parameterset_mar old_mark_config_key = StoreKey[Optional[Config]]() -def param(*values, **kw): +def param( + *values: object, + marks: "Union[MarkDecorator, typing.Collection[Union[MarkDecorator, Mark]]]" = (), + id: Optional[str] = None +) -> ParameterSet: """Specify a parameter in `pytest.mark.parametrize`_ calls or :ref:`parametrized fixtures `. @@ -48,7 +54,7 @@ def param(*values, **kw): :keyword marks: a single mark or a list of marks to be applied to this parameter set. :keyword str id: the id to attribute to this parameter set. """ - return ParameterSet.param(*values, **kw) + return ParameterSet.param(*values, marks=marks, id=id) def pytest_addoption(parser): diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index eb6340e42..bfefe7a25 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -1,11 +1,12 @@ +import collections.abc import inspect +import typing import warnings -from collections import namedtuple -from collections.abc import MutableMapping from typing import Any from typing import Iterable from typing import List from typing import Mapping +from typing import NamedTuple from typing import Optional from typing import Sequence from typing import Set @@ -17,20 +18,29 @@ import attr from .._code import getfslineno from ..compat import ascii_escaped from ..compat import NOTSET +from ..compat import NotSetType +from ..compat import TYPE_CHECKING +from _pytest.config import Config from _pytest.outcomes import fail from _pytest.warning_types import PytestUnknownMarkWarning +if TYPE_CHECKING: + from _pytest.python import FunctionDefinition + + EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" -def istestfunc(func): +def istestfunc(func) -> bool: return ( hasattr(func, "__call__") and getattr(func, "__name__", "") != "" ) -def get_empty_parameterset_mark(config, argnames, func): +def get_empty_parameterset_mark( + config: Config, argnames: Sequence[str], func +) -> "MarkDecorator": from ..nodes import Collector requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION) @@ -53,16 +63,33 @@ def get_empty_parameterset_mark(config, argnames, func): fs, lineno, ) - return mark(reason=reason) + # Type ignored because MarkDecorator.__call__() is a bit tough to + # annotate ATM. + return mark(reason=reason) # type: ignore[no-any-return] # noqa: F723 -class ParameterSet(namedtuple("ParameterSet", "values, marks, id")): +class ParameterSet( + NamedTuple( + "ParameterSet", + [ + ("values", Sequence[Union[object, NotSetType]]), + ("marks", "typing.Collection[Union[MarkDecorator, Mark]]"), + ("id", Optional[str]), + ], + ) +): @classmethod - def param(cls, *values, marks=(), id=None): + def param( + cls, + *values: object, + marks: "Union[MarkDecorator, typing.Collection[Union[MarkDecorator, Mark]]]" = (), + id: Optional[str] = None + ) -> "ParameterSet": if isinstance(marks, MarkDecorator): marks = (marks,) else: - assert isinstance(marks, (tuple, list, set)) + # TODO(py36): Change to collections.abc.Collection. + assert isinstance(marks, (collections.abc.Sequence, set)) if id is not None: if not isinstance(id, str): @@ -73,7 +100,11 @@ class ParameterSet(namedtuple("ParameterSet", "values, marks, id")): return cls(values, marks, id) @classmethod - def extract_from(cls, parameterset, force_tuple=False): + def extract_from( + cls, + parameterset: Union["ParameterSet", Sequence[object], object], + force_tuple: bool = False, + ) -> "ParameterSet": """ :param parameterset: a legacy style parameterset that may or may not be a tuple, @@ -89,10 +120,20 @@ class ParameterSet(namedtuple("ParameterSet", "values, marks, id")): if force_tuple: return cls.param(parameterset) else: - return cls(parameterset, marks=[], id=None) + # TODO: Refactor to fix this type-ignore. Currently the following + # type-checks but crashes: + # + # @pytest.mark.parametrize(('x', 'y'), [1, 2]) + # def test_foo(x, y): pass + return cls(parameterset, marks=[], id=None) # type: ignore[arg-type] # noqa: F821 @staticmethod - def _parse_parametrize_args(argnames, argvalues, *args, **kwargs): + def _parse_parametrize_args( + argnames: Union[str, List[str], Tuple[str, ...]], + argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + *args, + **kwargs + ) -> Tuple[Union[List[str], Tuple[str, ...]], bool]: if not isinstance(argnames, (tuple, list)): argnames = [x.strip() for x in argnames.split(",") if x.strip()] force_tuple = len(argnames) == 1 @@ -101,13 +142,23 @@ class ParameterSet(namedtuple("ParameterSet", "values, marks, id")): return argnames, force_tuple @staticmethod - def _parse_parametrize_parameters(argvalues, force_tuple): + def _parse_parametrize_parameters( + argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + force_tuple: bool, + ) -> List["ParameterSet"]: return [ ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues ] @classmethod - def _for_parametrize(cls, argnames, argvalues, func, config, function_definition): + def _for_parametrize( + cls, + argnames: Union[str, List[str], Tuple[str, ...]], + argvalues: Iterable[Union["ParameterSet", Sequence[object], object]], + func, + config: Config, + function_definition: "FunctionDefinition", + ) -> Tuple[Union[List[str], Tuple[str, ...]], List["ParameterSet"]]: argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues) parameters = cls._parse_parametrize_parameters(argvalues, force_tuple) del argvalues @@ -370,7 +421,7 @@ class MarkGenerator: MARK_GEN = MarkGenerator() -class NodeKeywords(MutableMapping): +class NodeKeywords(collections.abc.MutableMapping): def __init__(self, node): self.node = node self.parent = node.parent diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 39afb4e98..c3ba60deb 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1051,7 +1051,7 @@ class TestLiterals: ("1e3", "999"), # The current implementation doesn't understand that numbers inside # strings shouldn't be treated as numbers: - pytest.param("'3.1416'", "'3.14'", marks=pytest.mark.xfail), + pytest.param("'3.1416'", "'3.14'", marks=pytest.mark.xfail), # type: ignore ], ) def test_number_non_matches(self, testdir, expression, output): From 0fb081aec6cd8ed95882d6e63ce93bd7ee4ba6ae Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 024/140] Type annotate some hookspecs & impls Annotate some "easy" arguments of hooks that repeat in a lot of internal plugins. Not all of the arguments are annotated fully for now. --- src/_pytest/assertion/__init__.py | 5 ++- src/_pytest/cacheprovider.py | 14 +++++-- src/_pytest/capture.py | 3 +- src/_pytest/config/__init__.py | 6 ++- src/_pytest/debugging.py | 11 ++++-- src/_pytest/doctest.py | 5 ++- src/_pytest/faulthandler.py | 10 +++-- src/_pytest/fixtures.py | 5 ++- src/_pytest/helpconfig.py | 14 +++++-- src/_pytest/hookspec.py | 65 ++++++++++++++++++------------- src/_pytest/junitxml.py | 12 +++--- src/_pytest/logging.py | 11 +++--- src/_pytest/mark/__init__.py | 13 +++++-- src/_pytest/mark/structures.py | 2 +- src/_pytest/nodes.py | 2 +- src/_pytest/pastebin.py | 8 ++-- src/_pytest/pytester.py | 7 ++-- src/_pytest/python.py | 17 +++++--- src/_pytest/resultlog.py | 8 ++-- src/_pytest/runner.py | 9 +++-- src/_pytest/setuponly.py | 11 +++++- src/_pytest/setupplan.py | 12 +++++- src/_pytest/skipping.py | 8 ++-- src/_pytest/stepwise.py | 11 ++++-- src/_pytest/terminal.py | 13 ++++--- src/_pytest/warnings.py | 6 ++- 26 files changed, 185 insertions(+), 103 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index b38c6c006..e73981677 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -13,12 +13,13 @@ from _pytest.assertion.rewrite import assertstate_key from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser if TYPE_CHECKING: from _pytest.main import Session -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--assert", @@ -167,7 +168,7 @@ def pytest_runtest_protocol(item): util._reprcompare, util._assertion_pass = saved_assert_hooks -def pytest_sessionfinish(session): +def pytest_sessionfinish(session: "Session") -> None: assertstate = session.config._store.get(assertstate_key, None) if assertstate: if assertstate.hook is not None: diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 511ee2acf..cd43c6cac 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -11,6 +11,7 @@ from typing import Generator from typing import List from typing import Optional from typing import Set +from typing import Union import attr import py @@ -24,6 +25,8 @@ from _pytest import nodes from _pytest._io import TerminalWriter from _pytest.compat import order_preserving_dict from _pytest.config import Config +from _pytest.config import ExitCode +from _pytest.config.argparsing import Parser from _pytest.main import Session from _pytest.python import Module @@ -329,11 +332,12 @@ class LFPlugin: else: self._report_status += "not deselecting items." - def pytest_sessionfinish(self, session): + def pytest_sessionfinish(self, session: Session) -> None: config = self.config if config.getoption("cacheshow") or hasattr(config, "slaveinput"): return + assert config.cache is not None saved_lastfailed = config.cache.get("cache/lastfailed", {}) if saved_lastfailed != self.lastfailed: config.cache.set("cache/lastfailed", self.lastfailed) @@ -382,7 +386,7 @@ class NFPlugin: config.cache.set("cache/nodeids", sorted(self.cached_nodeids)) -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--lf", @@ -440,16 +444,18 @@ def pytest_addoption(parser): ) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.cacheshow: from _pytest.main import wrap_session return wrap_session(config, cacheshow) + return None @pytest.hookimpl(tryfirst=True) def pytest_configure(config: Config) -> None: - config.cache = Cache.for_config(config) + # Type ignored: pending mechanism to store typed objects scoped to config. + config.cache = Cache.for_config(config) # type: ignore # noqa: F821 config.pluginmanager.register(LFPlugin(config), "lfplugin") config.pluginmanager.register(NFPlugin(config), "nfplugin") diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 64f4b8b92..5a0cfff36 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -16,6 +16,7 @@ from typing import Tuple import pytest from _pytest.compat import TYPE_CHECKING from _pytest.config import Config +from _pytest.config.argparsing import Parser if TYPE_CHECKING: from typing_extensions import Literal @@ -23,7 +24,7 @@ if TYPE_CHECKING: _CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group._addoption( "--capture", diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 2da7e33aa..d9abc17b4 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -407,7 +407,7 @@ class PytestPluginManager(PluginManager): """Return True if the plugin with the given name is registered.""" return bool(self.get_plugin(name)) - def pytest_configure(self, config): + def pytest_configure(self, config: "Config") -> None: # XXX now that the pluginmanager exposes hookimpl(tryfirst...) # we should remove tryfirst/trylast as markers config.addinivalue_line( @@ -868,7 +868,9 @@ class Config: def get_terminal_writer(self): return self.pluginmanager.get_plugin("terminalreporter")._tw - def pytest_cmdline_parse(self, pluginmanager, args): + def pytest_cmdline_parse( + self, pluginmanager: PytestPluginManager, args: List[str] + ) -> object: try: self.parse(args) except UsageError: diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 26c3095dc..0085d3197 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -4,8 +4,11 @@ import functools import sys from _pytest import outcomes +from _pytest.config import Config from _pytest.config import ConftestImportFailure from _pytest.config import hookimpl +from _pytest.config import PytestPluginManager +from _pytest.config.argparsing import Parser from _pytest.config.exceptions import UsageError @@ -20,7 +23,7 @@ def _validate_usepdb_cls(value): return (modname, classname) -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group._addoption( "--pdb", @@ -44,7 +47,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: import pdb if config.getvalue("trace"): @@ -74,8 +77,8 @@ def pytest_configure(config): class pytestPDB: """ Pseudo PDB that defers to the real pdb. """ - _pluginmanager = None - _config = None + _pluginmanager = None # type: PytestPluginManager + _config = None # type: Config _saved = [] # type: list _recursive_debug = 0 _wrapped_pdb_cls = None diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index e1dd9691c..50f115cd1 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -23,6 +23,7 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import safe_getattr from _pytest.compat import TYPE_CHECKING +from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest from _pytest.outcomes import OutcomeException from _pytest.python_api import approx @@ -52,7 +53,7 @@ RUNNER_CLASS = None CHECKER_CLASS = None # type: Optional[Type[doctest.OutputChecker]] -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addini( "doctest_optionflags", "option flags for doctests", @@ -102,7 +103,7 @@ def pytest_addoption(parser): ) -def pytest_unconfigure(): +def pytest_unconfigure() -> None: global RUNNER_CLASS RUNNER_CLASS = None diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index 32e3e50c9..9d777b415 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -4,13 +4,15 @@ import sys from typing import TextIO import pytest +from _pytest.config import Config +from _pytest.config.argparsing import Parser from _pytest.store import StoreKey fault_handler_stderr_key = StoreKey[TextIO]() -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: help = ( "Dump the traceback of all threads if a test takes " "more than TIMEOUT seconds to finish." @@ -18,7 +20,7 @@ def pytest_addoption(parser): parser.addini("faulthandler_timeout", help, default=0.0) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: import faulthandler if not faulthandler.is_enabled(): @@ -46,14 +48,14 @@ 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): + def pytest_configure(self, config: Config) -> None: import faulthandler stderr_fd_copy = os.dup(self._get_stderr_fileno()) config._store[fault_handler_stderr_key] = open(stderr_fd_copy, "w") faulthandler.enable(file=config._store[fault_handler_stderr_key]) - def pytest_unconfigure(self, config): + def pytest_unconfigure(self, config: Config) -> None: import faulthandler faulthandler.disable() diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index a1574634a..4583e70f2 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -29,6 +29,7 @@ from _pytest.compat import NOTSET from _pytest.compat import order_preserving_dict from _pytest.compat import safe_getattr from _pytest.compat import TYPE_CHECKING +from _pytest.config.argparsing import Parser from _pytest.deprecated import FILLFUNCARGS from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS from _pytest.deprecated import FUNCARGNAMES @@ -49,7 +50,7 @@ class PseudoFixtureDef: scope = attr.ib() -def pytest_sessionstart(session: "Session"): +def pytest_sessionstart(session: "Session") -> None: import _pytest.python import _pytest.nodes @@ -1202,7 +1203,7 @@ def pytestconfig(request): return request.config -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addini( "usefixtures", type="args", diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 402ffae66..c2519c8af 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -2,11 +2,16 @@ import os import sys from argparse import Action +from typing import Optional +from typing import Union import py import pytest +from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config import PrintHelp +from _pytest.config.argparsing import Parser class HelpAction(Action): @@ -36,7 +41,7 @@ class HelpAction(Action): raise PrintHelp -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--version", @@ -109,7 +114,7 @@ def pytest_cmdline_parse(): undo_tracing = config.pluginmanager.enable_tracing() sys.stderr.write("writing pytestdebug information to %s\n" % path) - def unset_tracing(): + def unset_tracing() -> None: debugfile.close() sys.stderr.write("wrote pytestdebug information to %s\n" % debugfile.name) config.trace.root.setwriter(None) @@ -133,7 +138,7 @@ def showversion(config): sys.stderr.write("pytest {}\n".format(pytest.__version__)) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.version > 0: showversion(config) return 0 @@ -142,9 +147,10 @@ def pytest_cmdline_main(config): showhelp(config) config._ensure_unconfigure() return 0 + return None -def showhelp(config): +def showhelp(config: Config) -> None: import textwrap reporter = config.pluginmanager.get_plugin("terminalreporter") diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index de29a40bf..8b4505691 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -1,5 +1,6 @@ """ hook specifications for pytest plugins, invoked from main.py and builtin plugins. """ from typing import Any +from typing import List from typing import Mapping from typing import Optional from typing import Tuple @@ -14,10 +15,14 @@ from _pytest.compat import TYPE_CHECKING if TYPE_CHECKING: import warnings from _pytest.config import Config + from _pytest.config import ExitCode + from _pytest.config import PytestPluginManager + from _pytest.config import _PluggyPlugin + from _pytest.config.argparsing import Parser from _pytest.main import Session + from _pytest.python import Metafunc from _pytest.reports import BaseReport - hookspec = HookspecMarker("pytest") # ------------------------------------------------------------------------- @@ -26,7 +31,7 @@ hookspec = HookspecMarker("pytest") @hookspec(historic=True) -def pytest_addhooks(pluginmanager): +def pytest_addhooks(pluginmanager: "PytestPluginManager") -> None: """called at plugin registration time to allow adding new hooks via a call to ``pluginmanager.add_hookspecs(module_or_class, prefix)``. @@ -39,7 +44,9 @@ def pytest_addhooks(pluginmanager): @hookspec(historic=True) -def pytest_plugin_registered(plugin, manager): +def pytest_plugin_registered( + plugin: "_PluggyPlugin", manager: "PytestPluginManager" +) -> None: """ a new pytest plugin got registered. :param plugin: the plugin module or instance @@ -51,7 +58,7 @@ def pytest_plugin_registered(plugin, manager): @hookspec(historic=True) -def pytest_addoption(parser, pluginmanager): +def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager") -> None: """register argparse-style options and ini-style config values, called once at the beginning of a test run. @@ -89,7 +96,7 @@ def pytest_addoption(parser, pluginmanager): @hookspec(historic=True) -def pytest_configure(config): +def pytest_configure(config: "Config") -> None: """ Allows plugins and conftest files to perform initial configuration. @@ -113,7 +120,9 @@ def pytest_configure(config): @hookspec(firstresult=True) -def pytest_cmdline_parse(pluginmanager, args): +def pytest_cmdline_parse( + pluginmanager: "PytestPluginManager", args: List[str] +) -> Optional[object]: """return initialized config object, parsing the specified args. Stops at first non-None result, see :ref:`firstresult` @@ -127,7 +136,7 @@ def pytest_cmdline_parse(pluginmanager, args): """ -def pytest_cmdline_preparse(config, args): +def pytest_cmdline_preparse(config: "Config", args: List[str]) -> None: """(**Deprecated**) modify command line arguments before option parsing. This hook is considered deprecated and will be removed in a future pytest version. Consider @@ -142,7 +151,7 @@ def pytest_cmdline_preparse(config, args): @hookspec(firstresult=True) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: "Config") -> "Optional[Union[ExitCode, int]]": """ called for performing the main command line action. The default implementation will invoke the configure hooks and runtest_mainloop. @@ -155,7 +164,9 @@ def pytest_cmdline_main(config): """ -def pytest_load_initial_conftests(early_config, parser, args): +def pytest_load_initial_conftests( + early_config: "Config", parser: "Parser", args: List[str] +) -> None: """ implements the loading of initial conftest files ahead of command line option parsing. @@ -198,7 +209,7 @@ def pytest_collection(session: "Session") -> Optional[Any]: """ -def pytest_collection_modifyitems(session, config, items): +def pytest_collection_modifyitems(session: "Session", config: "Config", items): """ called after collection has been performed, may filter or re-order the items in-place. @@ -208,7 +219,7 @@ def pytest_collection_modifyitems(session, config, items): """ -def pytest_collection_finish(session): +def pytest_collection_finish(session: "Session"): """ called after collection has been performed and modified. :param _pytest.main.Session session: the pytest session object @@ -216,7 +227,7 @@ def pytest_collection_finish(session): @hookspec(firstresult=True) -def pytest_ignore_collect(path, config): +def pytest_ignore_collect(path, config: "Config"): """ return True to prevent considering this path for collection. This hook is consulted for all files and directories prior to calling more specific hooks. @@ -304,12 +315,12 @@ def pytest_pyfunc_call(pyfuncitem): Stops at first non-None result, see :ref:`firstresult` """ -def pytest_generate_tests(metafunc): +def pytest_generate_tests(metafunc: "Metafunc") -> None: """ generate (multiple) parametrized calls to a test function.""" @hookspec(firstresult=True) -def pytest_make_parametrize_id(config, val, argname): +def pytest_make_parametrize_id(config: "Config", val, argname) -> Optional[str]: """Return a user-friendly string representation of the given ``val`` that will be used by @pytest.mark.parametrize calls. Return None if the hook doesn't know about ``val``. The parameter name is available as ``argname``, if required. @@ -328,7 +339,7 @@ def pytest_make_parametrize_id(config, val, argname): @hookspec(firstresult=True) -def pytest_runtestloop(session): +def pytest_runtestloop(session: "Session"): """ called for performing the main runtest loop (after collection finished). @@ -411,7 +422,7 @@ def pytest_runtest_logreport(report): @hookspec(firstresult=True) -def pytest_report_to_serializable(config, report): +def pytest_report_to_serializable(config: "Config", report): """ Serializes the given report object into a data structure suitable for sending over the wire, e.g. converted to JSON. @@ -419,7 +430,7 @@ def pytest_report_to_serializable(config, report): @hookspec(firstresult=True) -def pytest_report_from_serializable(config, data): +def pytest_report_from_serializable(config: "Config", data): """ Restores a report object previously serialized with pytest_report_to_serializable(). """ @@ -456,7 +467,7 @@ def pytest_fixture_post_finalizer(fixturedef, request): # ------------------------------------------------------------------------- -def pytest_sessionstart(session): +def pytest_sessionstart(session: "Session") -> None: """ called after the ``Session`` object has been created and before performing collection and entering the run test loop. @@ -464,7 +475,9 @@ def pytest_sessionstart(session): """ -def pytest_sessionfinish(session, exitstatus): +def pytest_sessionfinish( + session: "Session", exitstatus: "Union[int, ExitCode]" +) -> None: """ called after whole test run finished, right before returning the exit status to the system. :param _pytest.main.Session session: the pytest session object @@ -472,7 +485,7 @@ def pytest_sessionfinish(session, exitstatus): """ -def pytest_unconfigure(config): +def pytest_unconfigure(config: "Config") -> None: """ called before test process is exited. :param _pytest.config.Config config: pytest config object @@ -484,7 +497,7 @@ def pytest_unconfigure(config): # ------------------------------------------------------------------------- -def pytest_assertrepr_compare(config, op, left, right): +def pytest_assertrepr_compare(config: "Config", op, left, right): """return explanation for comparisons in failing assert expressions. Return None for no custom explanation, otherwise return a list @@ -539,7 +552,7 @@ def pytest_assertion_pass(item, lineno, orig, expl): # ------------------------------------------------------------------------- -def pytest_report_header(config, startdir): +def pytest_report_header(config: "Config", startdir): """ return a string or list of strings to be displayed as header info for terminal reporting. :param _pytest.config.Config config: pytest config object @@ -560,7 +573,7 @@ def pytest_report_header(config, startdir): """ -def pytest_report_collectionfinish(config, startdir, items): +def pytest_report_collectionfinish(config: "Config", startdir, items): """ .. versionadded:: 3.2 @@ -610,7 +623,7 @@ def pytest_report_teststatus( """ -def pytest_terminal_summary(terminalreporter, exitstatus, config): +def pytest_terminal_summary(terminalreporter, exitstatus, config: "Config"): """Add a section to terminal summary reporting. :param _pytest.terminal.TerminalReporter terminalreporter: the internal terminal reporter object @@ -723,7 +736,7 @@ def pytest_exception_interact(node, call, report): """ -def pytest_enter_pdb(config, pdb): +def pytest_enter_pdb(config: "Config", pdb): """ called upon pdb.set_trace(), can be used by plugins to take special action just before the python debugger enters in interactive mode. @@ -732,7 +745,7 @@ def pytest_enter_pdb(config, pdb): """ -def pytest_leave_pdb(config, pdb): +def pytest_leave_pdb(config: "Config", pdb): """ called when leaving pdb (e.g. with continue after pdb.set_trace()). Can be used by plugins to take special action just after the python diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index b26112ac1..b0790bc79 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -21,7 +21,9 @@ import pytest from _pytest import deprecated from _pytest import nodes from _pytest import timing +from _pytest.config import Config from _pytest.config import filename_arg +from _pytest.config.argparsing import Parser from _pytest.store import StoreKey from _pytest.warnings import _issue_warning_captured @@ -361,7 +363,7 @@ def record_testsuite_property(request): return record_func -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting") group.addoption( "--junitxml", @@ -406,7 +408,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: xmlpath = config.option.xmlpath # prevent opening xmllog on slave nodes (xdist) if xmlpath and not hasattr(config, "slaveinput"): @@ -426,7 +428,7 @@ def pytest_configure(config): config.pluginmanager.register(config._store[xml_key]) -def pytest_unconfigure(config): +def pytest_unconfigure(config: Config) -> None: xml = config._store.get(xml_key, None) if xml: del config._store[xml_key] @@ -624,10 +626,10 @@ class LogXML: reporter.attrs.update(classname="pytest", name="internal") reporter._add_simple(Junit.error, "internal error", excrepr) - def pytest_sessionstart(self): + def pytest_sessionstart(self) -> None: self.suite_start_time = timing.time() - def pytest_sessionfinish(self): + def pytest_sessionfinish(self) -> None: dirname = os.path.dirname(os.path.abspath(self.logfile)) if not os.path.isdir(dirname): os.makedirs(dirname) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index f6a206327..b832f6994 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -19,6 +19,7 @@ from _pytest.compat import nullcontext from _pytest.config import _strtobool from _pytest.config import Config from _pytest.config import create_terminal_writer +from _pytest.config.argparsing import Parser from _pytest.pathlib import Path from _pytest.store import StoreKey @@ -180,7 +181,7 @@ def get_option_ini(config, *names): return ret -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: """Add options to control log capturing.""" group = parser.getgroup("logging") @@ -478,7 +479,7 @@ def get_log_level_for_setting(config: Config, *setting_names: str) -> Optional[i # run after terminalreporter/capturemanager are configured @pytest.hookimpl(trylast=True) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config.pluginmanager.register(LoggingPlugin(config), "logging-plugin") @@ -601,7 +602,7 @@ class LoggingPlugin: return True @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_sessionstart(self): + def pytest_sessionstart(self) -> Generator[None, None, None]: self.log_cli_handler.set_when("sessionstart") with catching_logs(self.log_cli_handler, level=self.log_cli_level): @@ -679,7 +680,7 @@ class LoggingPlugin: self.log_cli_handler.set_when("finish") @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_sessionfinish(self): + def pytest_sessionfinish(self) -> Generator[None, None, None]: self.log_cli_handler.set_when("sessionfinish") with catching_logs(self.log_cli_handler, level=self.log_cli_level): @@ -687,7 +688,7 @@ class LoggingPlugin: yield @pytest.hookimpl - def pytest_unconfigure(self): + def pytest_unconfigure(self) -> None: # Close the FileHandler explicitly. # (logging.shutdown might have lost the weakref?!) self.log_file_handler.close() diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index c23a38c76..05afb7749 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -18,8 +18,10 @@ from .structures import MarkGenerator from .structures import ParameterSet from _pytest.compat import TYPE_CHECKING from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import UsageError +from _pytest.config.argparsing import Parser from _pytest.deprecated import MINUS_K_COLON from _pytest.deprecated import MINUS_K_DASH from _pytest.store import StoreKey @@ -27,6 +29,7 @@ from _pytest.store import StoreKey if TYPE_CHECKING: from _pytest.nodes import Item + __all__ = ["Mark", "MarkDecorator", "MarkGenerator", "get_empty_parameterset_mark"] @@ -57,7 +60,7 @@ def param( return ParameterSet.param(*values, marks=marks, id=id) -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group._addoption( "-k", @@ -100,7 +103,7 @@ def pytest_addoption(parser): @hookimpl(tryfirst=True) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: import _pytest.config if config.option.markers: @@ -116,6 +119,8 @@ def pytest_cmdline_main(config): config._ensure_unconfigure() return 0 + return None + @attr.s(slots=True) class KeywordMatcher: @@ -254,7 +259,7 @@ def pytest_collection_modifyitems(items, config: Config) -> None: deselect_by_mark(items, config) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config._store[old_mark_config_key] = MARK_GEN._config MARK_GEN._config = config @@ -267,5 +272,5 @@ def pytest_configure(config): ) -def pytest_unconfigure(config): +def pytest_unconfigure(config: Config) -> None: MARK_GEN._config = config._store.get(old_mark_config_key, None) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index bfefe7a25..7ae7d5d4f 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -374,7 +374,7 @@ class MarkGenerator: applies a 'slowtest' :class:`Mark` on ``test_function``. """ - _config = None + _config = None # type: Optional[Config] _markers = set() # type: Set[str] def __getattr__(self, name: str) -> MarkDecorator: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index df1c79dac..c9b633579 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -123,7 +123,7 @@ class Node(metaclass=NodeMeta): #: the pytest config object if config: - self.config = config + self.config = config # type: Config else: if not parent: raise TypeError("config or parent must be provided") diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index cbaa9a9f5..091d3f817 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -4,13 +4,15 @@ from io import StringIO from typing import IO import pytest +from _pytest.config import Config +from _pytest.config.argparsing import Parser from _pytest.store import StoreKey pastebinfile_key = StoreKey[IO[bytes]]() -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting") group._addoption( "--pastebin", @@ -24,7 +26,7 @@ def pytest_addoption(parser): @pytest.hookimpl(trylast=True) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: if config.option.pastebin == "all": tr = config.pluginmanager.getplugin("terminalreporter") # if no terminal reporter plugin is present, nothing we can do here; @@ -44,7 +46,7 @@ def pytest_configure(config): tr._tw.write = tee_write -def pytest_unconfigure(config): +def pytest_unconfigure(config: Config) -> None: if pastebinfile_key in config._store: pastebinfile = config._store[pastebinfile_key] # get terminal contents and delete file diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 3c81dd759..ae7bdcec8 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -31,6 +31,7 @@ from _pytest.compat import TYPE_CHECKING from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode +from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch @@ -53,7 +54,7 @@ IGNORE_PAM = [ # filenames added when obtaining details about the current user ] -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addoption( "--lsof", action="store_true", @@ -78,7 +79,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: if config.getvalue("lsof"): checker = LsofFdLeakChecker() if checker.matching_platform(): @@ -938,7 +939,7 @@ class Testdir: rec = [] class Collect: - def pytest_configure(x, config): + def pytest_configure(x, config: Config) -> None: rec.append(self.make_hook_recorder(config.pluginmanager)) plugins.append(Collect()) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 76fccb4a1..45d3384df 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -16,6 +16,7 @@ from typing import Dict from typing import Iterable from typing import List from typing import Optional +from typing import Set from typing import Tuple from typing import Union @@ -42,9 +43,12 @@ from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass from _pytest.compat import STRING_TYPES from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser from _pytest.deprecated import FUNCARGNAMES from _pytest.fixtures import FuncFixtureInfo +from _pytest.main import Session from _pytest.mark import MARK_GEN from _pytest.mark import ParameterSet from _pytest.mark.structures import get_unpacked_marks @@ -57,7 +61,7 @@ from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--fixtures", @@ -112,13 +116,14 @@ def pytest_addoption(parser): ) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.showfixtures: showfixtures(config) return 0 if config.option.show_fixtures_per_test: show_fixtures_per_test(config) return 0 + return None def pytest_generate_tests(metafunc: "Metafunc") -> None: @@ -127,7 +132,7 @@ def pytest_generate_tests(metafunc: "Metafunc") -> None: metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker) # type: ignore[misc] -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config.addinivalue_line( "markers", "parametrize(argnames, argvalues): call a test function multiple " @@ -1308,13 +1313,13 @@ def _show_fixtures_per_test(config, session): write_item(session_item) -def showfixtures(config): +def showfixtures(config: Config) -> Union[int, ExitCode]: from _pytest.main import wrap_session return wrap_session(config, _showfixtures_main) -def _showfixtures_main(config, session): +def _showfixtures_main(config: Config, session: Session) -> None: import _pytest.config session.perform_collect() @@ -1325,7 +1330,7 @@ def _showfixtures_main(config, session): fm = session._fixturemanager available = [] - seen = set() + seen = set() # type: Set[Tuple[str, str]] for argname, fixturedefs in fm._arg2fixturedefs.items(): assert fixturedefs is not None diff --git a/src/_pytest/resultlog.py b/src/_pytest/resultlog.py index 3cfa9e0e9..acc89afe2 100644 --- a/src/_pytest/resultlog.py +++ b/src/_pytest/resultlog.py @@ -5,13 +5,15 @@ import os import py +from _pytest.config import Config +from _pytest.config.argparsing import Parser from _pytest.store import StoreKey resultlog_key = StoreKey["ResultLog"]() -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting", "resultlog plugin options") group.addoption( "--resultlog", @@ -23,7 +25,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: resultlog = config.option.resultlog # prevent opening resultlog on slave nodes (xdist) if resultlog and not hasattr(config, "slaveinput"): @@ -40,7 +42,7 @@ def pytest_configure(config): _issue_warning_captured(RESULT_LOG, config.hook, stacklevel=2) -def pytest_unconfigure(config): +def pytest_unconfigure(config: Config) -> None: resultlog = config._store.get(resultlog_key, None) if resultlog: resultlog.logfile.close() diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index dec6db788..c7f6d8811 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -17,6 +17,7 @@ from _pytest import timing from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest.compat import TYPE_CHECKING +from _pytest.config.argparsing import Parser from _pytest.nodes import Collector from _pytest.nodes import Node from _pytest.outcomes import Exit @@ -27,11 +28,13 @@ if TYPE_CHECKING: from typing import Type from typing_extensions import Literal + from _pytest.main import Session + # # pytest plugin hooks -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting", "reporting", after="general") group.addoption( "--durations", @@ -75,11 +78,11 @@ def pytest_terminal_summary(terminalreporter): tr.write_line("{:02.2f}s {:<8} {}".format(rep.duration, rep.when, rep.nodeid)) -def pytest_sessionstart(session): +def pytest_sessionstart(session: "Session") -> None: session._setupstate = SetupState() -def pytest_sessionfinish(session): +def pytest_sessionfinish(session: "Session") -> None: session._setupstate.teardown_all() diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 9e4cd9519..fe328d519 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -1,8 +1,14 @@ +from typing import Optional +from typing import Union + import pytest from _pytest._io.saferepr import saferepr +from _pytest.config import Config +from _pytest.config import ExitCode +from _pytest.config.argparsing import Parser -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--setuponly", @@ -76,6 +82,7 @@ def _show_fixture_action(fixturedef, msg): @pytest.hookimpl(tryfirst=True) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.setuponly: config.option.setupshow = True + return None diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 6fdd3aed0..834d4ae2d 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -1,7 +1,13 @@ +from typing import Optional +from typing import Union + import pytest +from _pytest.config import Config +from _pytest.config import ExitCode +from _pytest.config.argparsing import Parser -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( "--setupplan", @@ -19,10 +25,12 @@ def pytest_fixture_setup(fixturedef, request): my_cache_key = fixturedef.cache_key(request) fixturedef.cached_result = (None, my_cache_key, None) return fixturedef.cached_result + return None @pytest.hookimpl(tryfirst=True) -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.setupplan: config.option.setuponly = True config.option.setupshow = True + return None diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 62a9ca491..5e5fcc080 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -1,5 +1,7 @@ """ support for skip/xfail functions and markers. """ +from _pytest.config import Config from _pytest.config import hookimpl +from _pytest.config.argparsing import Parser from _pytest.mark.evaluate import MarkEvaluator from _pytest.outcomes import fail from _pytest.outcomes import skip @@ -12,7 +14,7 @@ evalxfail_key = StoreKey[MarkEvaluator]() unexpectedsuccess_key = StoreKey[str]() -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--runxfail", @@ -31,7 +33,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: if config.option.runxfail: # yay a hack import pytest @@ -42,7 +44,7 @@ def pytest_configure(config): def nop(*args, **kwargs): pass - nop.Exception = xfail.Exception + nop.Exception = xfail.Exception # type: ignore[attr-defined] # noqa: F821 setattr(pytest, "xfail", nop) config.addinivalue_line( diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 6fa21cd1c..3cbf0be9f 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -1,7 +1,10 @@ import pytest +from _pytest.config import Config +from _pytest.config.argparsing import Parser +from _pytest.main import Session -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( "--sw", @@ -19,7 +22,7 @@ def pytest_addoption(parser): @pytest.hookimpl -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config.pluginmanager.register(StepwisePlugin(config), "stepwiseplugin") @@ -34,7 +37,7 @@ class StepwisePlugin: self.lastfailed = config.cache.get("cache/stepwise", None) self.skip = config.getvalue("stepwise_skip") - def pytest_sessionstart(self, session): + def pytest_sessionstart(self, session: Session) -> None: self.session = session def pytest_collection_modifyitems(self, session, config, items): @@ -100,7 +103,7 @@ class StepwisePlugin: if self.active and self.config.getoption("verbose") >= 0 and self.report_status: return "stepwise: %s" % self.report_status - def pytest_sessionfinish(self, session): + def pytest_sessionfinish(self, session: Session) -> None: if self.active: self.config.cache.set("cache/stepwise", self.lastfailed) else: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index e384e02b2..6f4b96e1e 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -17,6 +17,7 @@ from typing import Mapping from typing import Optional from typing import Set from typing import Tuple +from typing import Union import attr import pluggy @@ -29,8 +30,10 @@ from _pytest import timing from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth from _pytest.compat import order_preserving_dict +from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode +from _pytest.config.argparsing import Parser from _pytest.deprecated import TERMINALWRITER_WRITER from _pytest.main import Session from _pytest.reports import CollectReport @@ -77,7 +80,7 @@ class MoreQuietAction(argparse.Action): namespace.quiet = getattr(namespace, "quiet", 0) + 1 -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("terminal reporting", "reporting", after="general") group._addoption( "-v", @@ -423,7 +426,7 @@ class TerminalReporter: ) self._add_stats("warnings", [warning_report]) - def pytest_plugin_registered(self, plugin): + def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: if self.config.option.traceconfig: msg = "PLUGIN registered: {}".format(plugin) # XXX this event may happen during setup/teardown time @@ -717,7 +720,7 @@ class TerminalReporter: self._tw.line("{}{}".format(indent + " ", line)) @pytest.hookimpl(hookwrapper=True) - def pytest_sessionfinish(self, session: Session, exitstatus: ExitCode): + def pytest_sessionfinish(self, session: Session, exitstatus: Union[int, ExitCode]): outcome = yield outcome.get_result() self._tw.line("") @@ -752,10 +755,10 @@ class TerminalReporter: # Display any extra warnings from teardown here (if any). self.summary_warnings() - def pytest_keyboard_interrupt(self, excinfo): + def pytest_keyboard_interrupt(self, excinfo) -> None: self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) - def pytest_unconfigure(self): + def pytest_unconfigure(self) -> None: if hasattr(self, "_keyboardinterrupt_memo"): self._report_keyboardinterrupt() diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 33d89428b..83e338cb3 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -8,6 +8,8 @@ from typing import Tuple import pytest from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config +from _pytest.config.argparsing import Parser from _pytest.main import Session if TYPE_CHECKING: @@ -49,7 +51,7 @@ def _parse_filter( return (action, message, category, module, lineno) -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("pytest-warnings") group.addoption( "-W", @@ -66,7 +68,7 @@ def pytest_addoption(parser): ) -def pytest_configure(config): +def pytest_configure(config: Config) -> None: config.addinivalue_line( "markers", "filterwarnings(warning): add a warning filter to the given test. " From f8de4242414c06fcd1886afdffd76002280ce4e6 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 025/140] Type annotate CallSpec2 --- src/_pytest/python.py | 53 +++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 45d3384df..e46d498ab 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -15,6 +15,7 @@ from typing import Callable from typing import Dict from typing import Iterable from typing import List +from typing import Mapping from typing import Optional from typing import Set from typing import Tuple @@ -44,6 +45,7 @@ from _pytest.compat import safe_isclass from _pytest.compat import STRING_TYPES from _pytest.config import Config from _pytest.config import ExitCode +from _pytest.compat import TYPE_CHECKING from _pytest.config import hookimpl from _pytest.config.argparsing import Parser from _pytest.deprecated import FUNCARGNAMES @@ -53,6 +55,7 @@ from _pytest.mark import MARK_GEN from _pytest.mark import ParameterSet from _pytest.mark.structures import get_unpacked_marks from _pytest.mark.structures import Mark +from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail from _pytest.outcomes import skip @@ -60,6 +63,9 @@ from _pytest.pathlib import parts from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning +if TYPE_CHECKING: + from typing_extensions import Literal + def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") @@ -772,16 +778,17 @@ def hasnew(obj): class CallSpec2: - def __init__(self, metafunc): + def __init__(self, metafunc: "Metafunc") -> None: self.metafunc = metafunc - self.funcargs = {} - self._idlist = [] - self.params = {} - self._arg2scopenum = {} # used for sorting parametrized resources - self.marks = [] - self.indices = {} + self.funcargs = {} # type: Dict[str, object] + self._idlist = [] # type: List[str] + self.params = {} # type: Dict[str, object] + # Used for sorting parametrized resources. + self._arg2scopenum = {} # type: Dict[str, int] + self.marks = [] # type: List[Mark] + self.indices = {} # type: Dict[str, int] - def copy(self): + def copy(self) -> "CallSpec2": cs = CallSpec2(self.metafunc) cs.funcargs.update(self.funcargs) cs.params.update(self.params) @@ -791,25 +798,39 @@ class CallSpec2: cs._idlist = list(self._idlist) return cs - def _checkargnotcontained(self, arg): + def _checkargnotcontained(self, arg: str) -> None: if arg in self.params or arg in self.funcargs: raise ValueError("duplicate {!r}".format(arg)) - def getparam(self, name): + def getparam(self, name: str) -> object: try: return self.params[name] except KeyError: raise ValueError(name) @property - def id(self): + def id(self) -> str: return "-".join(map(str, self._idlist)) - def setmulti2(self, valtypes, argnames, valset, id, marks, scopenum, param_index): + def setmulti2( + self, + valtypes: "Mapping[str, Literal['params', 'funcargs']]", + argnames: typing.Sequence[str], + valset: Iterable[object], + id: str, + marks: Iterable[Union[Mark, MarkDecorator]], + scopenum: int, + param_index: int, + ) -> None: for arg, val in zip(argnames, valset): self._checkargnotcontained(arg) valtype_for_arg = valtypes[arg] - getattr(self, valtype_for_arg)[arg] = val + if valtype_for_arg == "params": + self.params[arg] = val + elif valtype_for_arg == "funcargs": + self.funcargs[arg] = val + else: # pragma: no cover + assert False, "Unhandled valtype for arg: {}".format(valtype_for_arg) self.indices[arg] = param_index self._arg2scopenum[arg] = scopenum self._idlist.append(id) @@ -1049,7 +1070,7 @@ class Metafunc: self, argnames: typing.Sequence[str], indirect: Union[bool, typing.Sequence[str]], - ) -> Dict[str, str]: + ) -> Dict[str, "Literal['params', 'funcargs']"]: """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. @@ -1061,7 +1082,9 @@ class Metafunc: * "funcargs" if the argname should be a parameter to the parametrized test function. """ if isinstance(indirect, bool): - valtypes = dict.fromkeys(argnames, "params" if indirect else "funcargs") + valtypes = dict.fromkeys( + argnames, "params" if indirect else "funcargs" + ) # type: Dict[str, Literal["params", "funcargs"]] elif isinstance(indirect, Sequence): valtypes = dict.fromkeys(argnames, "funcargs") for arg in indirect: From be00e12d47c820f0a90d24cd76ada8a0366c5a67 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 026/140] Type annotate main.py and some parts related to collection --- src/_pytest/config/__init__.py | 2 +- src/_pytest/doctest.py | 16 +++-- src/_pytest/hookspec.py | 14 ++++- src/_pytest/main.py | 105 +++++++++++++++++++++++---------- src/_pytest/nodes.py | 18 ++++-- src/_pytest/python.py | 53 +++++++++++------ src/_pytest/reports.py | 11 +++- src/_pytest/runner.py | 4 +- src/_pytest/unittest.py | 27 ++++++--- 9 files changed, 175 insertions(+), 75 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index d9abc17b4..ff6aee744 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -840,7 +840,7 @@ class Config: self.cache = None # type: Optional[Cache] @property - def invocation_dir(self): + def invocation_dir(self) -> py.path.local: """Backward compatibility""" return py.path.local(str(self.invocation_params.dir)) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 50f115cd1..026476b8a 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -7,6 +7,7 @@ import traceback import warnings from contextlib import contextmanager from typing import Dict +from typing import Iterable from typing import List from typing import Optional from typing import Sequence @@ -109,13 +110,18 @@ def pytest_unconfigure() -> None: RUNNER_CLASS = None -def pytest_collect_file(path: py.path.local, parent): +def pytest_collect_file( + path: py.path.local, parent +) -> Optional[Union["DoctestModule", "DoctestTextfile"]]: config = parent.config if path.ext == ".py": if config.option.doctestmodules and not _is_setup_py(path): - return DoctestModule.from_parent(parent, fspath=path) + mod = DoctestModule.from_parent(parent, fspath=path) # type: DoctestModule + return mod elif _is_doctest(config, path, parent): - return DoctestTextfile.from_parent(parent, fspath=path) + txt = DoctestTextfile.from_parent(parent, fspath=path) # type: DoctestTextfile + return txt + return None def _is_setup_py(path: py.path.local) -> bool: @@ -365,7 +371,7 @@ def _get_continue_on_failure(config): class DoctestTextfile(pytest.Module): obj = None - def collect(self): + def collect(self) -> Iterable[DoctestItem]: import doctest # inspired by doctest.testfile; ideally we would use it directly, @@ -444,7 +450,7 @@ def _patch_unwrap_mock_aware(): class DoctestModule(pytest.Module): - def collect(self): + def collect(self) -> Iterable[DoctestItem]: import doctest class MockAwareDocTestFinder(doctest.DocTestFinder): diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 8b4505691..1321eff54 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -6,6 +6,7 @@ from typing import Optional from typing import Tuple from typing import Union +import py.path from pluggy import HookspecMarker from .deprecated import COLLECT_DIRECTORY_HOOK @@ -20,9 +21,14 @@ if TYPE_CHECKING: from _pytest.config import _PluggyPlugin from _pytest.config.argparsing import Parser from _pytest.main import Session + from _pytest.nodes import Collector + from _pytest.nodes import Item from _pytest.python import Metafunc + from _pytest.python import Module + from _pytest.python import PyCollector from _pytest.reports import BaseReport + hookspec = HookspecMarker("pytest") # ------------------------------------------------------------------------- @@ -249,7 +255,7 @@ def pytest_collect_directory(path, parent): """ -def pytest_collect_file(path, parent): +def pytest_collect_file(path: py.path.local, parent) -> "Optional[Collector]": """ return collection Node or None for the given path. Any new node needs to have the specified ``parent`` as a parent. @@ -289,7 +295,7 @@ def pytest_make_collect_report(collector): @hookspec(firstresult=True) -def pytest_pycollect_makemodule(path, parent): +def pytest_pycollect_makemodule(path: py.path.local, parent) -> "Optional[Module]": """ return a Module collector or None for the given path. This hook will be called for each matching test module path. The pytest_collect_file hook needs to be used if you want to @@ -302,7 +308,9 @@ def pytest_pycollect_makemodule(path, parent): @hookspec(firstresult=True) -def pytest_pycollect_makeitem(collector, name, obj): +def pytest_pycollect_makeitem( + collector: "PyCollector", name: str, obj +) -> "Union[None, Item, Collector, List[Union[Item, Collector]]]": """ return custom item/collector for a python object in a module, or None. Stops at first non-None result, see :ref:`firstresult` """ diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 4eb47be2c..a0007d226 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -7,9 +7,11 @@ import sys from typing import Callable from typing import Dict from typing import FrozenSet +from typing import Iterator from typing import List from typing import Optional from typing import Sequence +from typing import Set from typing import Tuple from typing import Union @@ -18,12 +20,14 @@ import py import _pytest._code from _pytest import nodes +from _pytest.compat import overload 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.config.argparsing import Parser from _pytest.fixtures import FixtureManager from _pytest.outcomes import exit from _pytest.reports import CollectReport @@ -38,7 +42,7 @@ if TYPE_CHECKING: from _pytest.python import Package -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: parser.addini( "norecursedirs", "directory patterns to avoid for recursion", @@ -241,7 +245,7 @@ def wrap_session( return session.exitstatus -def pytest_cmdline_main(config): +def pytest_cmdline_main(config: Config) -> Union[int, ExitCode]: return wrap_session(config, _main) @@ -258,11 +262,11 @@ def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: return None -def pytest_collection(session): +def pytest_collection(session: "Session") -> Sequence[nodes.Item]: return session.perform_collect() -def pytest_runtestloop(session): +def pytest_runtestloop(session: "Session") -> bool: if session.testsfailed and not session.config.option.continue_on_collection_errors: raise session.Interrupted( "%d error%s during collection" @@ -282,7 +286,7 @@ def pytest_runtestloop(session): return True -def _in_venv(path): +def _in_venv(path: py.path.local) -> bool: """Attempts to detect if ``path`` is the root of a Virtual Environment by checking for the existence of the appropriate activate script""" bindir = path.join("Scripts" if sys.platform.startswith("win") else "bin") @@ -328,7 +332,7 @@ def pytest_ignore_collect( return None -def pytest_collection_modifyitems(items, config): +def pytest_collection_modifyitems(items, config: Config) -> None: deselect_prefixes = tuple(config.getoption("deselect") or []) if not deselect_prefixes: return @@ -385,8 +389,8 @@ class Session(nodes.FSCollector): ) self.testsfailed = 0 self.testscollected = 0 - self.shouldstop = False - self.shouldfail = False + self.shouldstop = False # type: Union[bool, str] + self.shouldfail = False # type: Union[bool, str] self.trace = config.trace.root.get("collection") self.startdir = config.invocation_dir self._initialpaths = frozenset() # type: FrozenSet[py.path.local] @@ -412,10 +416,11 @@ class Session(nodes.FSCollector): self.config.pluginmanager.register(self, name="session") @classmethod - def from_config(cls, config): - return cls._create(config) + def from_config(cls, config: Config) -> "Session": + session = cls._create(config) # type: Session + return session - def __repr__(self): + def __repr__(self) -> str: return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( self.__class__.__name__, self.name, @@ -429,14 +434,14 @@ class Session(nodes.FSCollector): return self._bestrelpathcache[node_path] @hookimpl(tryfirst=True) - def pytest_collectstart(self): + def pytest_collectstart(self) -> None: if self.shouldfail: raise self.Failed(self.shouldfail) if self.shouldstop: raise self.Interrupted(self.shouldstop) @hookimpl(tryfirst=True) - def pytest_runtest_logreport(self, report): + def pytest_runtest_logreport(self, report) -> None: if report.failed and not hasattr(report, "wasxfail"): self.testsfailed += 1 maxfail = self.config.getvalue("maxfail") @@ -445,13 +450,27 @@ class Session(nodes.FSCollector): pytest_collectreport = pytest_runtest_logreport - def isinitpath(self, path): + def isinitpath(self, path: py.path.local) -> bool: return path in self._initialpaths def gethookproxy(self, fspath: py.path.local): return super()._gethookproxy(fspath) - def perform_collect(self, args=None, genitems=True): + @overload + def perform_collect( + self, args: Optional[Sequence[str]] = ..., genitems: "Literal[True]" = ... + ) -> Sequence[nodes.Item]: + raise NotImplementedError() + + @overload # noqa: F811 + def perform_collect( # noqa: F811 + self, args: Optional[Sequence[str]] = ..., genitems: bool = ... + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + raise NotImplementedError() + + def perform_collect( # noqa: F811 + self, args: Optional[Sequence[str]] = None, genitems: bool = True + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: hook = self.config.hook try: items = self._perform_collect(args, genitems) @@ -464,15 +483,29 @@ class Session(nodes.FSCollector): self.testscollected = len(items) return items - def _perform_collect(self, args, genitems): + @overload + def _perform_collect( + self, args: Optional[Sequence[str]], genitems: "Literal[True]" + ) -> Sequence[nodes.Item]: + raise NotImplementedError() + + @overload # noqa: F811 + def _perform_collect( # noqa: F811 + self, args: Optional[Sequence[str]], genitems: bool + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + raise NotImplementedError() + + def _perform_collect( # noqa: F811 + self, args: Optional[Sequence[str]], genitems: bool + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: if args is None: args = self.config.args self.trace("perform_collect", self, args) self.trace.root.indent += 1 - self._notfound = [] + self._notfound = [] # type: List[Tuple[str, NoMatch]] initialpaths = [] # type: List[py.path.local] self._initial_parts = [] # type: List[Tuple[py.path.local, List[str]]] - self.items = items = [] + self.items = items = [] # type: List[nodes.Item] for arg in args: fspath, parts = self._parsearg(arg) self._initial_parts.append((fspath, parts)) @@ -495,7 +528,7 @@ class Session(nodes.FSCollector): self.items.extend(self.genitems(node)) return items - def collect(self): + def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: for fspath, parts in self._initial_parts: self.trace("processing argument", (fspath, parts)) self.trace.root.indent += 1 @@ -513,7 +546,9 @@ class Session(nodes.FSCollector): self._collection_node_cache3.clear() self._collection_pkg_roots.clear() - def _collect(self, argpath, names): + def _collect( + self, argpath: py.path.local, names: List[str] + ) -> Iterator[Union[nodes.Item, nodes.Collector]]: from _pytest.python import Package # Start with a Session root, and delve to argpath item (dir or file) @@ -541,7 +576,7 @@ class Session(nodes.FSCollector): if argpath.check(dir=1): assert not names, "invalid arg {!r}".format((argpath, names)) - seen_dirs = set() + seen_dirs = set() # type: Set[py.path.local] for path in argpath.visit( fil=self._visit_filter, rec=self._recurse, bf=True, sort=True ): @@ -582,8 +617,9 @@ class Session(nodes.FSCollector): # Module itself, so just use that. If this special case isn't taken, then all # the files in the package will be yielded. if argpath.basename == "__init__.py": + assert isinstance(m[0], nodes.Collector) try: - yield next(m[0].collect()) + yield next(iter(m[0].collect())) except StopIteration: # The package collects nothing with only an __init__.py # file in it, which gets ignored by the default @@ -593,10 +629,11 @@ class Session(nodes.FSCollector): yield from m @staticmethod - def _visit_filter(f): - return f.check(file=1) + def _visit_filter(f: py.path.local) -> bool: + # TODO: Remove type: ignore once `py` is typed. + return f.check(file=1) # type: ignore - def _tryconvertpyarg(self, x): + def _tryconvertpyarg(self, x: str) -> str: """Convert a dotted module name to path.""" try: spec = importlib.util.find_spec(x) @@ -605,14 +642,14 @@ class Session(nodes.FSCollector): # ValueError: not a module name except (AttributeError, ImportError, ValueError): return x - if spec is None or spec.origin in {None, "namespace"}: + if spec is None or spec.origin is None or spec.origin == "namespace": return x elif spec.submodule_search_locations: return os.path.dirname(spec.origin) else: return spec.origin - def _parsearg(self, arg): + def _parsearg(self, arg: str) -> Tuple[py.path.local, List[str]]: """ return (fspath, names) tuple after checking the file exists. """ strpath, *parts = str(arg).split("::") if self.config.option.pyargs: @@ -628,7 +665,9 @@ class Session(nodes.FSCollector): fspath = fspath.realpath() return (fspath, parts) - def matchnodes(self, matching, names): + def matchnodes( + self, matching: Sequence[Union[nodes.Item, nodes.Collector]], names: List[str], + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: self.trace("matchnodes", matching, names) self.trace.root.indent += 1 nodes = self._matchnodes(matching, names) @@ -639,13 +678,15 @@ class Session(nodes.FSCollector): raise NoMatch(matching, names[:1]) return nodes - def _matchnodes(self, matching, names): + def _matchnodes( + self, matching: Sequence[Union[nodes.Item, nodes.Collector]], names: List[str], + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: if not matching or not names: return matching name = names[0] assert name nextnames = names[1:] - resultnodes = [] + resultnodes = [] # type: List[Union[nodes.Item, nodes.Collector]] for node in matching: if isinstance(node, nodes.Item): if not names: @@ -676,7 +717,9 @@ class Session(nodes.FSCollector): node.ihook.pytest_collectreport(report=rep) return resultnodes - def genitems(self, node): + def genitems( + self, node: Union[nodes.Item, nodes.Collector] + ) -> Iterator[nodes.Item]: self.trace("genitems", node) if isinstance(node, nodes.Item): node.ihook.pytest_itemcollected(item=node) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index c9b633579..010dce925 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -4,8 +4,10 @@ from functools import lru_cache from typing import Any from typing import Callable from typing import Dict +from typing import Iterable from typing import List from typing import Optional +from typing import Sequence from typing import Set from typing import Tuple from typing import Union @@ -226,7 +228,7 @@ class Node(metaclass=NodeMeta): # methods for ordering nodes @property - def nodeid(self): + def nodeid(self) -> str: """ a ::-separated string denoting its collection tree address. """ return self._nodeid @@ -423,7 +425,7 @@ class Collector(Node): class CollectError(Exception): """ an error during collection, contains a custom message. """ - def collect(self): + def collect(self) -> Iterable[Union["Item", "Collector"]]: """ returns a list of children (items and collectors) for this collection node. """ @@ -522,6 +524,9 @@ class FSCollector(Collector): proxy = self.config.hook return proxy + def gethookproxy(self, fspath: py.path.local): + raise NotImplementedError() + def _recurse(self, dirpath: py.path.local) -> bool: if dirpath.basename == "__pycache__": return False @@ -535,7 +540,12 @@ class FSCollector(Collector): ihook.pytest_collect_directory(path=dirpath, parent=self) return True - def _collectfile(self, path, handle_dupes=True): + def isinitpath(self, path: py.path.local) -> bool: + raise NotImplementedError() + + def _collectfile( + self, path: py.path.local, handle_dupes: bool = True + ) -> Sequence[Collector]: assert ( path.isfile() ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( @@ -555,7 +565,7 @@ class FSCollector(Collector): else: duplicate_paths.add(path) - return ihook.pytest_collect_file(path=path, parent=self) + return ihook.pytest_collect_file(path=path, parent=self) # type: ignore[no-any-return] # noqa: F723 class File(FSCollector): diff --git a/src/_pytest/python.py b/src/_pytest/python.py index e46d498ab..e05aa398d 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -43,9 +43,9 @@ from _pytest.compat import REGEX_TYPE from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass from _pytest.compat import STRING_TYPES +from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import ExitCode -from _pytest.compat import TYPE_CHECKING from _pytest.config import hookimpl from _pytest.config.argparsing import Parser from _pytest.deprecated import FUNCARGNAMES @@ -184,16 +184,20 @@ def pytest_pyfunc_call(pyfuncitem: "Function"): return True -def pytest_collect_file(path, parent): +def pytest_collect_file(path: py.path.local, parent) -> Optional["Module"]: ext = path.ext if ext == ".py": if not parent.session.isinitpath(path): if not path_matches_patterns( path, parent.config.getini("python_files") + ["__init__.py"] ): - return + return None ihook = parent.session.gethookproxy(path) - return ihook.pytest_pycollect_makemodule(path=path, parent=parent) + module = ihook.pytest_pycollect_makemodule( + path=path, parent=parent + ) # type: Module + return module + return None def path_matches_patterns(path, patterns): @@ -201,14 +205,16 @@ def path_matches_patterns(path, patterns): return any(path.fnmatch(pattern) for pattern in patterns) -def pytest_pycollect_makemodule(path, parent): +def pytest_pycollect_makemodule(path: py.path.local, parent) -> "Module": if path.basename == "__init__.py": - return Package.from_parent(parent, fspath=path) - return Module.from_parent(parent, fspath=path) + pkg = Package.from_parent(parent, fspath=path) # type: Package + return pkg + mod = Module.from_parent(parent, fspath=path) # type: Module + return mod @hookimpl(hookwrapper=True) -def pytest_pycollect_makeitem(collector, name, obj): +def pytest_pycollect_makeitem(collector: "PyCollector", name: str, obj): outcome = yield res = outcome.get_result() if res is not None: @@ -372,7 +378,7 @@ class PyCollector(PyobjMixin, nodes.Collector): return True return False - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: if not getattr(self.obj, "__test__", True): return [] @@ -381,8 +387,8 @@ class PyCollector(PyobjMixin, nodes.Collector): dicts = [getattr(self.obj, "__dict__", {})] for basecls in self.obj.__class__.__mro__: dicts.append(basecls.__dict__) - seen = set() - values = [] + seen = set() # type: Set[str] + values = [] # type: List[Union[nodes.Item, nodes.Collector]] for dic in dicts: # Note: seems like the dict can change during iteration - # be careful not to remove the list() without consideration. @@ -404,9 +410,16 @@ class PyCollector(PyobjMixin, nodes.Collector): values.sort(key=sort_key) return values - def _makeitem(self, name, obj): + def _makeitem( + self, name: str, obj + ) -> Union[ + None, nodes.Item, nodes.Collector, List[Union[nodes.Item, nodes.Collector]] + ]: # assert self.ihook.fspath == self.fspath, self - return self.ihook.pytest_pycollect_makeitem(collector=self, name=name, obj=obj) + item = self.ihook.pytest_pycollect_makeitem( + collector=self, name=name, obj=obj + ) # type: Union[None, nodes.Item, nodes.Collector, List[Union[nodes.Item, nodes.Collector]]] + return item def _genfunctions(self, name, funcobj): module = self.getparent(Module).obj @@ -458,7 +471,7 @@ class Module(nodes.File, PyCollector): def _getobj(self): return self._importtestmodule() - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: self._inject_setup_module_fixture() self._inject_setup_function_fixture() self.session._fixturemanager.parsefactories(self) @@ -603,17 +616,17 @@ class Package(Module): def gethookproxy(self, fspath: py.path.local): return super()._gethookproxy(fspath) - def isinitpath(self, path): + def isinitpath(self, path: py.path.local) -> bool: return path in self.session._initialpaths - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: this_path = self.fspath.dirpath() init_module = this_path.join("__init__.py") if init_module.check(file=1) and path_matches_patterns( init_module, self.config.getini("python_files") ): yield Module.from_parent(self, fspath=init_module) - pkg_prefixes = set() + pkg_prefixes = set() # type: Set[py.path.local] 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. is_file = path.isfile() @@ -670,10 +683,11 @@ class Class(PyCollector): """ return super().from_parent(name=name, parent=parent) - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: if not safe_getattr(self.obj, "__test__", True): return [] if hasinit(self.obj): + assert self.parent is not None self.warn( PytestCollectionWarning( "cannot collect test class %r because it has a " @@ -683,6 +697,7 @@ class Class(PyCollector): ) return [] elif hasnew(self.obj): + assert self.parent is not None self.warn( PytestCollectionWarning( "cannot collect test class %r because it has a " @@ -756,7 +771,7 @@ class Instance(PyCollector): def _getobj(self): return self.parent.obj() - def collect(self): + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: self.session._fixturemanager.parsefactories(self) return super().collect() diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 178df6004..908ba7d3b 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -21,7 +21,8 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import TYPE_CHECKING -from _pytest.nodes import Node +from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.outcomes import skip from _pytest.pathlib import Path @@ -316,7 +317,13 @@ class CollectReport(BaseReport): when = "collect" def __init__( - self, nodeid: str, outcome, longrepr, result: List[Node], sections=(), **extra + self, + nodeid: str, + outcome, + longrepr, + result: Optional[List[Union[Item, Collector]]], + sections=(), + **extra ) -> None: self.nodeid = nodeid self.outcome = outcome diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index c7f6d8811..a2b9ee207 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -404,10 +404,10 @@ class SetupState: raise e -def collect_one_node(collector): +def collect_one_node(collector: Collector) -> CollectReport: ihook = collector.ihook ihook.pytest_collectstart(collector=collector) - rep = ihook.pytest_make_collect_report(collector=collector) + rep = ihook.pytest_make_collect_report(collector=collector) # type: CollectReport call = rep.__dict__.pop("call", None) if call and check_interactive_exception(call, rep): ihook.pytest_exception_interact(node=collector, call=call, report=rep) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 0d9133f60..b2e6ab89d 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -1,32 +1,43 @@ """ discovery and running of std-library "unittest" style tests. """ import sys import traceback +from typing import Iterable +from typing import Optional +from typing import Union import _pytest._code import pytest from _pytest.compat import getimfunc from _pytest.compat import is_async_function from _pytest.config import hookimpl +from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.outcomes import exit from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail from _pytest.python import Class from _pytest.python import Function +from _pytest.python import PyCollector from _pytest.runner import CallInfo from _pytest.skipping import skipped_by_mark_key from _pytest.skipping import unexpectedsuccess_key -def pytest_pycollect_makeitem(collector, name, obj): +def pytest_pycollect_makeitem( + collector: PyCollector, name: str, obj +) -> Optional["UnitTestCase"]: # has unittest been imported and is obj a subclass of its TestCase? try: - if not issubclass(obj, sys.modules["unittest"].TestCase): - return + ut = sys.modules["unittest"] + # Type ignored because `ut` is an opaque module. + if not issubclass(obj, ut.TestCase): # type: ignore + return None except Exception: - return + return None # yes, so let's collect it - return UnitTestCase.from_parent(collector, name=name, obj=obj) + item = UnitTestCase.from_parent(collector, name=name, obj=obj) # type: UnitTestCase + return item class UnitTestCase(Class): @@ -34,7 +45,7 @@ class UnitTestCase(Class): # to declare that our children do not support funcargs nofuncargs = True - def collect(self): + def collect(self) -> Iterable[Union[Item, Collector]]: from unittest import TestLoader cls = self.obj @@ -61,8 +72,8 @@ class UnitTestCase(Class): runtest = getattr(self.obj, "runTest", None) if runtest is not None: ut = sys.modules.get("twisted.trial.unittest", None) - if ut is None or runtest != ut.TestCase.runTest: - # TODO: callobj consistency + # Type ignored because `ut` is an opaque module. + if ut is None or runtest != ut.TestCase.runTest: # type: ignore yield TestCaseFunction.from_parent(self, name="runTest") def _inject_setup_teardown_fixtures(self, cls): From ef347295418451e1f09bfb9af1a77aba10b3e71c Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 027/140] Type annotate fixtures.py & related --- src/_pytest/fixtures.py | 256 +++++++++++++++++++++++-------------- src/_pytest/hookspec.py | 10 +- src/_pytest/python.py | 3 +- src/_pytest/setuponly.py | 22 ++-- src/_pytest/setupplan.py | 6 +- testing/python/metafunc.py | 2 +- 6 files changed, 193 insertions(+), 106 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 4583e70f2..4cd9a20ef 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -5,9 +5,19 @@ import sys import warnings from collections import defaultdict from collections import deque +from types import TracebackType +from typing import Any +from typing import Callable +from typing import cast from typing import Dict +from typing import Iterable +from typing import Iterator from typing import List +from typing import Optional +from typing import Sequence +from typing import Set from typing import Tuple +from typing import Union import attr import py @@ -29,6 +39,8 @@ from _pytest.compat import NOTSET from _pytest.compat import order_preserving_dict from _pytest.compat import safe_getattr from _pytest.compat import TYPE_CHECKING +from _pytest.config import _PluggyPlugin +from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.deprecated import FILLFUNCARGS from _pytest.deprecated import FIXTURE_POSITIONAL_ARGUMENTS @@ -38,16 +50,31 @@ from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME if TYPE_CHECKING: + from typing import NoReturn from typing import Type + from typing_extensions import Literal from _pytest import nodes from _pytest.main import Session + from _pytest.python import Metafunc + + _Scope = Literal["session", "package", "module", "class", "function"] + + +_FixtureCachedResult = Tuple[ + # The result. + Optional[object], + # Cache key. + object, + # Exc info if raised. + Optional[Tuple["Type[BaseException]", BaseException, TracebackType]], +] @attr.s(frozen=True) class PseudoFixtureDef: - cached_result = attr.ib() - scope = attr.ib() + cached_result = attr.ib(type="_FixtureCachedResult") + scope = attr.ib(type="_Scope") def pytest_sessionstart(session: "Session") -> None: @@ -92,7 +119,7 @@ def scopeproperty(name=None, doc=None): return decoratescope -def get_scope_package(node, fixturedef): +def get_scope_package(node, fixturedef: "FixtureDef"): import pytest cls = pytest.Package @@ -114,7 +141,9 @@ def get_scope_node(node, scope): return node.getparent(cls) -def add_funcarg_pseudo_fixture_def(collector, metafunc, fixturemanager): +def add_funcarg_pseudo_fixture_def( + collector, metafunc: "Metafunc", fixturemanager: "FixtureManager" +) -> None: # this function will transform all collected calls to a functions # if they use direct funcargs (i.e. direct parametrization) # because we want later test execution to be able to rely on @@ -124,8 +153,8 @@ def add_funcarg_pseudo_fixture_def(collector, metafunc, fixturemanager): if not metafunc._calls[0].funcargs: return # this function call does not have direct parametrization # collect funcargs of all callspecs into a list of values - arg2params = {} - arg2scope = {} + arg2params = {} # type: Dict[str, List[object]] + arg2scope = {} # type: Dict[str, _Scope] for callspec in metafunc._calls: for argname, argvalue in callspec.funcargs.items(): assert argname not in callspec.params @@ -233,7 +262,7 @@ def reorder_items(items): return list(reorder_items_atscope(items, argkeys_cache, items_by_argkey, 0)) -def fix_cache_order(item, argkeys_cache, items_by_argkey): +def fix_cache_order(item, argkeys_cache, items_by_argkey) -> None: for scopenum in range(0, scopenum_function): for key in argkeys_cache[scopenum].get(item, []): items_by_argkey[scopenum][key].appendleft(item) @@ -279,7 +308,7 @@ def reorder_items_atscope(items, argkeys_cache, items_by_argkey, scopenum): return items_done -def fillfixtures(function): +def fillfixtures(function) -> None: """ fill missing funcargs for a test function. """ warnings.warn(FILLFUNCARGS, stacklevel=2) try: @@ -309,15 +338,15 @@ def get_direct_param_fixture_func(request): @attr.s(slots=True) class FuncFixtureInfo: # original function argument names - argnames = attr.ib(type=tuple) + argnames = attr.ib(type=Tuple[str, ...]) # argnames that function immediately requires. These include argnames + # fixture names specified via usefixtures and via autouse=True in fixture # definitions. - initialnames = attr.ib(type=tuple) - names_closure = attr.ib() # List[str] - name2fixturedefs = attr.ib() # List[str, List[FixtureDef]] + initialnames = attr.ib(type=Tuple[str, ...]) + names_closure = attr.ib(type=List[str]) + name2fixturedefs = attr.ib(type=Dict[str, Sequence["FixtureDef"]]) - def prune_dependency_tree(self): + def prune_dependency_tree(self) -> None: """Recompute names_closure from initialnames and name2fixturedefs Can only reduce names_closure, which means that the new closure will @@ -328,7 +357,7 @@ class FuncFixtureInfo: tree. In this way the dependency tree can get pruned, and the closure of argnames may get reduced. """ - closure = set() + closure = set() # type: Set[str] working_set = set(self.initialnames) while working_set: argname = working_set.pop() @@ -353,27 +382,29 @@ class FixtureRequest: the fixture is parametrized indirectly. """ - def __init__(self, pyfuncitem): + def __init__(self, pyfuncitem) -> None: self._pyfuncitem = pyfuncitem #: fixture for which this request is being performed - self.fixturename = None + self.fixturename = None # type: Optional[str] #: Scope string, one of "function", "class", "module", "session" - self.scope = "function" + self.scope = "function" # type: _Scope self._fixture_defs = {} # type: Dict[str, FixtureDef] - fixtureinfo = pyfuncitem._fixtureinfo + fixtureinfo = pyfuncitem._fixtureinfo # type: FuncFixtureInfo self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy() - self._arg2index = {} - self._fixturemanager = pyfuncitem.session._fixturemanager + self._arg2index = {} # type: Dict[str, int] + self._fixturemanager = ( + pyfuncitem.session._fixturemanager + ) # type: FixtureManager @property - def fixturenames(self): + def fixturenames(self) -> List[str]: """names of all active fixtures in this request""" result = list(self._pyfuncitem._fixtureinfo.names_closure) result.extend(set(self._fixture_defs).difference(result)) return result @property - def funcargnames(self): + def funcargnames(self) -> List[str]: """ alias attribute for ``fixturenames`` for pre-2.3 compatibility""" warnings.warn(FUNCARGNAMES, stacklevel=2) return self.fixturenames @@ -383,15 +414,18 @@ class FixtureRequest: """ underlying collection node (depends on current request scope)""" return self._getscopeitem(self.scope) - def _getnextfixturedef(self, argname): + def _getnextfixturedef(self, argname: str) -> "FixtureDef": fixturedefs = self._arg2fixturedefs.get(argname, None) if fixturedefs is None: # we arrive here because of a dynamic call to # getfixturevalue(argname) usage which was naturally # not known at parsing/collection time + assert self._pyfuncitem.parent is not None parentid = self._pyfuncitem.parent.nodeid fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid) - self._arg2fixturedefs[argname] = fixturedefs + # TODO: Fix this type ignore. Either add assert or adjust types. + # Can this be None here? + self._arg2fixturedefs[argname] = fixturedefs # type: ignore[assignment] # noqa: F821 # fixturedefs list is immutable so we maintain a decreasing index index = self._arg2index.get(argname, 0) - 1 if fixturedefs is None or (-index > len(fixturedefs)): @@ -447,20 +481,20 @@ class FixtureRequest: """ pytest session object. """ return self._pyfuncitem.session - def addfinalizer(self, finalizer): + def addfinalizer(self, finalizer: Callable[[], object]) -> None: """ add finalizer/teardown function to be called after the last test within the requesting test context finished execution. """ # XXX usually this method is shadowed by fixturedef specific ones self._addfinalizer(finalizer, scope=self.scope) - def _addfinalizer(self, finalizer, scope): + def _addfinalizer(self, finalizer: Callable[[], object], scope) -> None: colitem = self._getscopeitem(scope) self._pyfuncitem.session._setupstate.addfinalizer( finalizer=finalizer, colitem=colitem ) - def applymarker(self, marker): + def applymarker(self, marker) -> None: """ Apply a marker to a single test function invocation. This method is useful if you don't want to have a keyword/marker on all function invocations. @@ -470,18 +504,18 @@ class FixtureRequest: """ self.node.add_marker(marker) - def raiseerror(self, msg): + def raiseerror(self, msg: Optional[str]) -> "NoReturn": """ raise a FixtureLookupError with the given message. """ raise self._fixturemanager.FixtureLookupError(None, self, msg) - def _fillfixtures(self): + def _fillfixtures(self) -> None: item = self._pyfuncitem fixturenames = getattr(item, "fixturenames", self.fixturenames) for argname in fixturenames: if argname not in item.funcargs: item.funcargs[argname] = self.getfixturevalue(argname) - def getfixturevalue(self, argname): + def getfixturevalue(self, argname: str) -> Any: """ Dynamically run a named fixture function. Declaring fixtures via function argument is recommended where possible. @@ -492,9 +526,13 @@ class FixtureRequest: :raise pytest.FixtureLookupError: If the given fixture could not be found. """ - return self._get_active_fixturedef(argname).cached_result[0] + fixturedef = self._get_active_fixturedef(argname) + assert fixturedef.cached_result is not None + return fixturedef.cached_result[0] - def _get_active_fixturedef(self, argname): + def _get_active_fixturedef( + self, argname: str + ) -> Union["FixtureDef", PseudoFixtureDef]: try: return self._fixture_defs[argname] except KeyError: @@ -503,7 +541,7 @@ class FixtureRequest: except FixtureLookupError: if argname == "request": cached_result = (self, [0], None) - scope = "function" + scope = "function" # type: _Scope return PseudoFixtureDef(cached_result, scope) raise # remove indent to prevent the python3 exception @@ -512,15 +550,16 @@ class FixtureRequest: self._fixture_defs[argname] = fixturedef return fixturedef - def _get_fixturestack(self): + def _get_fixturestack(self) -> List["FixtureDef"]: current = self - values = [] + values = [] # type: List[FixtureDef] while 1: fixturedef = getattr(current, "_fixturedef", None) if fixturedef is None: values.reverse() return values values.append(fixturedef) + assert isinstance(current, SubRequest) current = current._parent_request def _compute_fixture_value(self, fixturedef: "FixtureDef") -> None: @@ -593,13 +632,15 @@ class FixtureRequest: finally: self._schedule_finalizers(fixturedef, subrequest) - def _schedule_finalizers(self, fixturedef, subrequest): + def _schedule_finalizers( + self, fixturedef: "FixtureDef", subrequest: "SubRequest" + ) -> None: # if fixture function failed it might have registered finalizers self.session._setupstate.addfinalizer( functools.partial(fixturedef.finish, request=subrequest), subrequest.node ) - def _check_scope(self, argname, invoking_scope, requested_scope): + def _check_scope(self, argname, invoking_scope: "_Scope", requested_scope) -> None: if argname == "request": return if scopemismatch(invoking_scope, requested_scope): @@ -613,7 +654,7 @@ class FixtureRequest: pytrace=False, ) - def _factorytraceback(self): + def _factorytraceback(self) -> List[str]: lines = [] for fixturedef in self._get_fixturestack(): factory = fixturedef.func @@ -639,7 +680,7 @@ class FixtureRequest: ) return node - def __repr__(self): + def __repr__(self) -> str: return "" % (self.node) @@ -647,9 +688,16 @@ class SubRequest(FixtureRequest): """ a sub request for handling getting a fixture from a test function/fixture. """ - def __init__(self, request, scope, param, param_index, fixturedef): + def __init__( + self, + request: "FixtureRequest", + scope: "_Scope", + param, + param_index: int, + fixturedef: "FixtureDef", + ) -> None: self._parent_request = request - self.fixturename = fixturedef.argname + self.fixturename = fixturedef.argname # type: str if param is not NOTSET: self.param = param self.param_index = param_index @@ -661,13 +709,15 @@ class SubRequest(FixtureRequest): self._arg2index = request._arg2index self._fixturemanager = request._fixturemanager - def __repr__(self): + def __repr__(self) -> str: return "".format(self.fixturename, self._pyfuncitem) - def addfinalizer(self, finalizer): + def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._fixturedef.addfinalizer(finalizer) - def _schedule_finalizers(self, fixturedef, subrequest): + def _schedule_finalizers( + self, fixturedef: "FixtureDef", subrequest: "SubRequest" + ) -> None: # if the executing fixturedef was not explicitly requested in the argument list (via # getfixturevalue inside the fixture call) then ensure this fixture def will be finished # first @@ -678,20 +728,21 @@ class SubRequest(FixtureRequest): super()._schedule_finalizers(fixturedef, subrequest) -scopes = "session package module class function".split() +scopes = ["session", "package", "module", "class", "function"] # type: List[_Scope] scopenum_function = scopes.index("function") -def scopemismatch(currentscope, newscope): +def scopemismatch(currentscope: "_Scope", newscope: "_Scope") -> bool: return scopes.index(newscope) > scopes.index(currentscope) -def scope2index(scope, descr, where=None): +def scope2index(scope: str, descr: str, where: Optional[str] = None) -> int: """Look up the index of ``scope`` and raise a descriptive value error if not defined. """ + strscopes = scopes # type: Sequence[str] try: - return scopes.index(scope) + return strscopes.index(scope) except ValueError: fail( "{} {}got an unexpected scope value '{}'".format( @@ -704,7 +755,7 @@ def scope2index(scope, descr, where=None): class FixtureLookupError(LookupError): """ could not return a requested Fixture (missing or invalid). """ - def __init__(self, argname, request, msg=None): + def __init__(self, argname, request, msg: Optional[str] = None) -> None: self.argname = argname self.request = request self.fixturestack = request._get_fixturestack() @@ -782,14 +833,14 @@ class FixtureLookupErrorRepr(TerminalRepr): tw.line("%s:%d" % (self.filename, self.firstlineno + 1)) -def fail_fixturefunc(fixturefunc, msg): +def fail_fixturefunc(fixturefunc, msg: str) -> "NoReturn": fs, lineno = getfslineno(fixturefunc) location = "{}:{}".format(fs, lineno + 1) source = _pytest._code.Source(fixturefunc) fail(msg + ":\n\n" + str(source.indent()) + "\n" + location, pytrace=False) -def call_fixture_func(fixturefunc, request, kwargs): +def call_fixture_func(fixturefunc, request: FixtureRequest, kwargs) -> object: yieldctx = is_generator(fixturefunc) if yieldctx: generator = fixturefunc(**kwargs) @@ -806,7 +857,7 @@ def call_fixture_func(fixturefunc, request, kwargs): return fixture_result -def _teardown_yield_fixture(fixturefunc, it): +def _teardown_yield_fixture(fixturefunc, it) -> None: """Executes the teardown of a fixture function by advancing the iterator after the yield and ensure the iteration ends (if not it means there is more than one yield in the function)""" try: @@ -819,7 +870,7 @@ def _teardown_yield_fixture(fixturefunc, it): ) -def _eval_scope_callable(scope_callable, fixture_name, config): +def _eval_scope_callable(scope_callable, fixture_name: str, config: Config) -> str: try: result = scope_callable(fixture_name=fixture_name, config=config) except Exception: @@ -843,15 +894,15 @@ class FixtureDef: def __init__( self, - fixturemanager, + fixturemanager: "FixtureManager", baseid, - argname, + argname: str, func, - scope, - params, - unittest=False, + scope: str, + params: Optional[Sequence[object]], + unittest: bool = False, ids=None, - ): + ) -> None: self._fixturemanager = fixturemanager self.baseid = baseid or "" self.has_location = baseid is not None @@ -859,23 +910,28 @@ class FixtureDef: self.argname = argname if callable(scope): scope = _eval_scope_callable(scope, argname, fixturemanager.config) - self.scope = scope self.scopenum = scope2index( scope or "function", descr="Fixture '{}'".format(func.__name__), where=baseid, ) - self.params = params - self.argnames = getfuncargnames(func, name=argname, is_method=unittest) + # The cast is verified by scope2index. + # (Some of the type annotations below are supposed to be inferred, + # but mypy 0.761 has some trouble without them.) + self.scope = cast("_Scope", scope) # type: _Scope + self.params = params # type: Optional[Sequence[object]] + self.argnames = getfuncargnames( + func, name=argname, is_method=unittest + ) # type: Tuple[str, ...] self.unittest = unittest self.ids = ids - self.cached_result = None - self._finalizers = [] + self.cached_result = None # type: Optional[_FixtureCachedResult] + self._finalizers = [] # type: List[Callable[[], object]] - def addfinalizer(self, finalizer): + def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) - def finish(self, request): + def finish(self, request: SubRequest) -> None: exc = None try: while self._finalizers: @@ -899,12 +955,14 @@ class FixtureDef: self.cached_result = None self._finalizers = [] - def execute(self, request): + def execute(self, request: SubRequest): # get required arguments and register our own finish() # with their finalization for argname in self.argnames: fixturedef = request._get_active_fixturedef(argname) if argname != "request": + # PseudoFixtureDef is only for "request". + assert isinstance(fixturedef, FixtureDef) fixturedef.addfinalizer(functools.partial(self.finish, request=request)) my_cache_key = self.cache_key(request) @@ -926,16 +984,16 @@ class FixtureDef: hook = self._fixturemanager.session.gethookproxy(request.node.fspath) return hook.pytest_fixture_setup(fixturedef=self, request=request) - def cache_key(self, request): + def cache_key(self, request: SubRequest) -> object: return request.param_index if not hasattr(request, "param") else request.param - def __repr__(self): + def __repr__(self) -> str: return "".format( self.argname, self.scope, self.baseid ) -def resolve_fixture_function(fixturedef, request): +def resolve_fixture_function(fixturedef: FixtureDef, request: FixtureRequest): """Gets the actual callable that can be called to obtain the fixture value, dealing with unittest-specific instances and bound methods. """ @@ -961,7 +1019,7 @@ def resolve_fixture_function(fixturedef, request): return fixturefunc -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup(fixturedef: FixtureDef, request: SubRequest) -> object: """ Execution of fixture setup. """ kwargs = {} for argname in fixturedef.argnames: @@ -976,7 +1034,9 @@ def pytest_fixture_setup(fixturedef, request): try: result = call_fixture_func(fixturefunc, request, kwargs) except TEST_OUTCOME: - fixturedef.cached_result = (None, my_cache_key, sys.exc_info()) + exc_info = sys.exc_info() + assert exc_info[0] is not None + fixturedef.cached_result = (None, my_cache_key, exc_info) raise fixturedef.cached_result = (result, my_cache_key, None) return result @@ -1190,7 +1250,7 @@ def yield_fixture( @fixture(scope="session") -def pytestconfig(request): +def pytestconfig(request: FixtureRequest): """Session-scoped fixture that returns the :class:`_pytest.config.Config` object. Example:: @@ -1247,15 +1307,17 @@ class FixtureManager: FixtureLookupError = FixtureLookupError FixtureLookupErrorRepr = FixtureLookupErrorRepr - def __init__(self, session): + def __init__(self, session: "Session") -> None: self.session = session - self.config = session.config - self._arg2fixturedefs = {} - self._holderobjseen = set() - self._nodeid_and_autousenames = [("", self.config.getini("usefixtures"))] + self.config = session.config # type: Config + self._arg2fixturedefs = {} # type: Dict[str, List[FixtureDef]] + self._holderobjseen = set() # type: Set + self._nodeid_and_autousenames = [ + ("", self.config.getini("usefixtures")) + ] # type: List[Tuple[str, List[str]]] session.config.pluginmanager.register(self, "funcmanage") - def _get_direct_parametrize_args(self, node): + def _get_direct_parametrize_args(self, node) -> List[str]: """This function returns all the direct parametrization arguments of a node, so we don't mistake them for fixtures @@ -1264,7 +1326,7 @@ class FixtureManager: This things are done later as well when dealing with parametrization so this could be improved """ - parametrize_argnames = [] + parametrize_argnames = [] # type: List[str] for marker in node.iter_markers(name="parametrize"): if not marker.kwargs.get("indirect", False): p_argnames, _ = ParameterSet._parse_parametrize_args( @@ -1274,7 +1336,7 @@ class FixtureManager: return parametrize_argnames - def getfixtureinfo(self, node, func, cls, funcargs=True): + def getfixtureinfo(self, node, func, cls, funcargs: bool = True) -> FuncFixtureInfo: if funcargs and not getattr(node, "nofuncargs", False): argnames = getfuncargnames(func, name=node.name, cls=cls) else: @@ -1290,10 +1352,10 @@ class FixtureManager: ) return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs) - def pytest_plugin_registered(self, plugin): + def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: nodeid = None try: - p = py.path.local(plugin.__file__).realpath() + p = py.path.local(plugin.__file__).realpath() # type: ignore[attr-defined] # noqa: F821 except AttributeError: pass else: @@ -1309,9 +1371,9 @@ class FixtureManager: self.parsefactories(plugin, nodeid) - def _getautousenames(self, nodeid): + def _getautousenames(self, nodeid: str) -> List[str]: """ return a tuple of fixture names to be used. """ - autousenames = [] + autousenames = [] # type: List[str] for baseid, basenames in self._nodeid_and_autousenames: if nodeid.startswith(baseid): if baseid: @@ -1322,7 +1384,9 @@ class FixtureManager: autousenames.extend(basenames) return autousenames - def getfixtureclosure(self, fixturenames, parentnode, ignore_args=()): + def getfixtureclosure( + self, fixturenames: Tuple[str, ...], parentnode, ignore_args: Sequence[str] = () + ) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef]]]: # collect the closure of all fixtures , starting with the given # fixturenames as the initial set. As we have to visit all # factory definitions anyway, we also return an arg2fixturedefs @@ -1333,7 +1397,7 @@ class FixtureManager: parentid = parentnode.nodeid fixturenames_closure = self._getautousenames(parentid) - def merge(otherlist): + def merge(otherlist: Iterable[str]) -> None: for arg in otherlist: if arg not in fixturenames_closure: fixturenames_closure.append(arg) @@ -1345,7 +1409,7 @@ class FixtureManager: # need to return it as well, so save this. initialnames = tuple(fixturenames_closure) - arg2fixturedefs = {} + arg2fixturedefs = {} # type: Dict[str, Sequence[FixtureDef]] lastlen = -1 while lastlen != len(fixturenames_closure): lastlen = len(fixturenames_closure) @@ -1359,7 +1423,7 @@ class FixtureManager: arg2fixturedefs[argname] = fixturedefs merge(fixturedefs[-1].argnames) - def sort_by_scope(arg_name): + def sort_by_scope(arg_name: str) -> int: try: fixturedefs = arg2fixturedefs[arg_name] except KeyError: @@ -1370,7 +1434,7 @@ class FixtureManager: fixturenames_closure.sort(key=sort_by_scope) return initialnames, fixturenames_closure, arg2fixturedefs - def pytest_generate_tests(self, metafunc): + def pytest_generate_tests(self, metafunc: "Metafunc") -> None: for argname in metafunc.fixturenames: faclist = metafunc._arg2fixturedefs.get(argname) if faclist: @@ -1404,7 +1468,9 @@ class FixtureManager: # separate parametrized setups items[:] = reorder_items(items) - def parsefactories(self, node_or_obj, nodeid=NOTSET, unittest=False): + def parsefactories( + self, node_or_obj, nodeid=NOTSET, unittest: bool = False + ) -> None: if nodeid is not NOTSET: holderobj = node_or_obj else: @@ -1460,7 +1526,9 @@ class FixtureManager: if autousenames: self._nodeid_and_autousenames.append((nodeid or "", autousenames)) - def getfixturedefs(self, argname, nodeid): + def getfixturedefs( + self, argname: str, nodeid: str + ) -> Optional[Sequence[FixtureDef]]: """ Gets a list of fixtures which are applicable to the given node id. @@ -1474,7 +1542,9 @@ class FixtureManager: return None return tuple(self._matchfactories(fixturedefs, nodeid)) - def _matchfactories(self, fixturedefs, nodeid): + def _matchfactories( + self, fixturedefs: Iterable[FixtureDef], nodeid: str + ) -> Iterator[FixtureDef]: from _pytest import nodes for fixturedef in fixturedefs: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 1321eff54..3f6886009 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: from _pytest.config import PytestPluginManager from _pytest.config import _PluggyPlugin from _pytest.config.argparsing import Parser + from _pytest.fixtures import FixtureDef + from _pytest.fixtures import SubRequest from _pytest.main import Session from _pytest.nodes import Collector from _pytest.nodes import Item @@ -450,7 +452,9 @@ def pytest_report_from_serializable(config: "Config", data): @hookspec(firstresult=True) -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup( + fixturedef: "FixtureDef", request: "SubRequest" +) -> Optional[object]: """ performs fixture setup execution. :return: The return value of the call to the fixture function @@ -464,7 +468,9 @@ def pytest_fixture_setup(fixturedef, request): """ -def pytest_fixture_post_finalizer(fixturedef, request): +def pytest_fixture_post_finalizer( + fixturedef: "FixtureDef", request: "SubRequest" +) -> None: """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/python.py b/src/_pytest/python.py index e05aa398d..18b4fe2ff 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -65,6 +65,7 @@ from _pytest.warning_types import PytestUnhandledCoroutineWarning if TYPE_CHECKING: from typing_extensions import Literal + from _pytest.fixtures import _Scope def pytest_addoption(parser: Parser) -> None: @@ -905,7 +906,7 @@ class Metafunc: Callable[[object], Optional[object]], ] ] = None, - scope: "Optional[str]" = None, + scope: "Optional[_Scope]" = None, *, _param_mark: Optional[Mark] = None ) -> None: diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index fe328d519..932d0c279 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -1,3 +1,4 @@ +from typing import Generator from typing import Optional from typing import Union @@ -6,6 +7,8 @@ from _pytest._io.saferepr import saferepr from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureDef +from _pytest.fixtures import SubRequest def pytest_addoption(parser: Parser) -> None: @@ -25,7 +28,9 @@ def pytest_addoption(parser: Parser) -> None: @pytest.hookimpl(hookwrapper=True) -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup( + fixturedef: FixtureDef, request: SubRequest +) -> Generator[None, None, None]: yield if request.config.option.setupshow: if hasattr(request, "param"): @@ -33,24 +38,25 @@ def pytest_fixture_setup(fixturedef, request): # display it now and during the teardown (in .finish()). if fixturedef.ids: if callable(fixturedef.ids): - fixturedef.cached_param = fixturedef.ids(request.param) + param = fixturedef.ids(request.param) else: - fixturedef.cached_param = fixturedef.ids[request.param_index] + param = fixturedef.ids[request.param_index] else: - fixturedef.cached_param = request.param + param = request.param + fixturedef.cached_param = param # type: ignore[attr-defined] # noqa: F821 _show_fixture_action(fixturedef, "SETUP") -def pytest_fixture_post_finalizer(fixturedef) -> None: +def pytest_fixture_post_finalizer(fixturedef: FixtureDef) -> None: if fixturedef.cached_result is not None: config = fixturedef._fixturemanager.config if config.option.setupshow: _show_fixture_action(fixturedef, "TEARDOWN") if hasattr(fixturedef, "cached_param"): - del fixturedef.cached_param + del fixturedef.cached_param # type: ignore[attr-defined] # noqa: F821 -def _show_fixture_action(fixturedef, msg): +def _show_fixture_action(fixturedef: FixtureDef, msg: str) -> None: config = fixturedef._fixturemanager.config capman = config.pluginmanager.getplugin("capturemanager") if capman: @@ -73,7 +79,7 @@ def _show_fixture_action(fixturedef, msg): tw.write(" (fixtures used: {})".format(", ".join(deps))) if hasattr(fixturedef, "cached_param"): - tw.write("[{}]".format(saferepr(fixturedef.cached_param, maxsize=42))) + tw.write("[{}]".format(saferepr(fixturedef.cached_param, maxsize=42))) # type: ignore[attr-defined] tw.flush() diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 834d4ae2d..0994ebbf2 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -5,6 +5,8 @@ import pytest from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureDef +from _pytest.fixtures import SubRequest def pytest_addoption(parser: Parser) -> None: @@ -19,7 +21,9 @@ def pytest_addoption(parser: Parser) -> None: @pytest.hookimpl(tryfirst=True) -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup( + fixturedef: FixtureDef, request: SubRequest +) -> Optional[object]: # Will return a dummy fixture if the setuponly option is provided. if request.config.option.setupplan: my_cache_key = fixturedef.cache_key(request) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 4d4191098..c4b5bd222 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -113,7 +113,7 @@ class TestMetafunc: fail.Exception, match=r"parametrize\(\) call in func got an unexpected scope value 'doggy'", ): - metafunc.parametrize("x", [1], scope="doggy") + metafunc.parametrize("x", [1], scope="doggy") # type: ignore[arg-type] # noqa: F821 def test_parametrize_request_name(self, testdir: Testdir) -> None: """Show proper error when 'request' is used as a parameter name in parametrize (#6183)""" From 247c4c0482888b18203589a2d0461d598bd2d817 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 028/140] Type annotate some more hooks & impls --- src/_pytest/assertion/__init__.py | 9 +++--- src/_pytest/assertion/rewrite.py | 6 ++-- src/_pytest/cacheprovider.py | 3 +- src/_pytest/capture.py | 11 ++++--- src/_pytest/debugging.py | 14 +++++++-- src/_pytest/faulthandler.py | 6 ++-- src/_pytest/hookspec.py | 46 +++++++++++++++++++---------- src/_pytest/junitxml.py | 6 ++-- src/_pytest/logging.py | 14 +++++---- src/_pytest/main.py | 3 +- src/_pytest/nose.py | 3 +- src/_pytest/pastebin.py | 24 +++++++-------- src/_pytest/pytester.py | 3 +- src/_pytest/python.py | 2 +- src/_pytest/reports.py | 11 +++++-- src/_pytest/resultlog.py | 4 ++- src/_pytest/runner.py | 49 ++++++++++++++++++------------- src/_pytest/skipping.py | 14 +++++---- src/_pytest/stepwise.py | 3 +- src/_pytest/terminal.py | 15 +++++++--- src/_pytest/unittest.py | 16 +++++++--- src/_pytest/warnings.py | 8 +++-- testing/test_runner.py | 4 +-- 23 files changed, 175 insertions(+), 99 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index e73981677..997c17921 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -4,6 +4,7 @@ support for presenting detailed information in failing assertions. import sys from typing import Any from typing import List +from typing import Generator from typing import Optional from _pytest.assertion import rewrite @@ -14,6 +15,7 @@ from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser +from _pytest.nodes import Item if TYPE_CHECKING: from _pytest.main import Session @@ -113,7 +115,7 @@ def pytest_collection(session: "Session") -> None: @hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_protocol(item): +def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks The rewrite module will use util._reprcompare if @@ -122,8 +124,7 @@ def pytest_runtest_protocol(item): comparison for the test. """ - def callbinrepr(op, left, right): - # type: (str, object, object) -> Optional[str] + def callbinrepr(op, left: object, right: object) -> Optional[str]: """Call the pytest_assertrepr_compare hook and prepare the result This uses the first result from the hook and then ensures the @@ -156,7 +157,7 @@ def pytest_runtest_protocol(item): if item.ihook.pytest_assertion_pass.get_hookimpls(): - def call_assertion_pass_hook(lineno, orig, expl): + def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None: item.ihook.pytest_assertion_pass( item=item, lineno=lineno, orig=orig, expl=expl ) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index ecec2aa3d..bd4ea022c 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -444,14 +444,12 @@ def _call_reprcompare(ops, results, expls, each_obj): return expl -def _call_assertion_pass(lineno, orig, expl): - # type: (int, str, str) -> None +def _call_assertion_pass(lineno: int, orig: str, expl: str) -> None: if util._assertion_pass is not None: util._assertion_pass(lineno, orig, expl) -def _check_if_assertion_pass_impl(): - # type: () -> bool +def _check_if_assertion_pass_impl() -> bool: """Checks if any plugins implement the pytest_assertion_pass hook in order not to generate explanation unecessarily (might be expensive)""" return True if util._assertion_pass else False diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index cd43c6cac..c95f17152 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -29,6 +29,7 @@ from _pytest.config import ExitCode from _pytest.config.argparsing import Parser from _pytest.main import Session from _pytest.python import Module +from _pytest.reports import TestReport README_CONTENT = """\ # pytest cache directory # @@ -265,7 +266,7 @@ class LFPlugin: if self.active and self.config.getoption("verbose") >= 0: return "run-last-failure: %s" % self._report_status - def pytest_runtest_logreport(self, report): + def pytest_runtest_logreport(self, report: TestReport) -> None: if (report.when == "call" and report.passed) or report.skipped: self.lastfailed.pop(report.nodeid, None) elif report.failed: diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 5a0cfff36..13931ca10 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -17,6 +17,9 @@ import pytest from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config.argparsing import Parser +from _pytest.fixtures import SubRequest +from _pytest.nodes import Collector +from _pytest.nodes import Item if TYPE_CHECKING: from typing_extensions import Literal @@ -710,7 +713,7 @@ class CaptureManager: # Hooks @pytest.hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector): + def pytest_make_collect_report(self, collector: Collector): if isinstance(collector, pytest.File): self.resume_global_capture() outcome = yield @@ -725,17 +728,17 @@ class CaptureManager: yield @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_setup(self, item): + def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]: with self.item_capture("setup", item): yield @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item): + def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]: with self.item_capture("call", item): yield @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_teardown(self, item): + def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]: with self.item_capture("teardown", item): yield diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 0085d3197..423b20ce3 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -4,12 +4,18 @@ import functools import sys from _pytest import outcomes +from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import ConftestImportFailure from _pytest.config import hookimpl from _pytest.config import PytestPluginManager from _pytest.config.argparsing import Parser from _pytest.config.exceptions import UsageError +from _pytest.nodes import Node +from _pytest.reports import BaseReport + +if TYPE_CHECKING: + from _pytest.runner import CallInfo def _validate_usepdb_cls(value): @@ -259,7 +265,9 @@ class pytestPDB: class PdbInvoke: - def pytest_exception_interact(self, node, call, report): + def pytest_exception_interact( + self, node: Node, call: "CallInfo", report: BaseReport + ) -> None: capman = node.config.pluginmanager.getplugin("capturemanager") if capman: capman.suspend_global_capture(in_=True) @@ -306,7 +314,7 @@ def maybe_wrap_pytest_function_for_tracing(pyfuncitem): wrap_pytest_function_for_tracing(pyfuncitem) -def _enter_pdb(node, excinfo, rep): +def _enter_pdb(node: Node, excinfo, rep: BaseReport) -> BaseReport: # XXX we re-use the TerminalReporter's terminalwriter # because this seems to avoid some encoding related troubles # for not completely clear reasons. @@ -330,7 +338,7 @@ def _enter_pdb(node, excinfo, rep): rep.toterminal(tw) tw.sep(">", "entering PDB") tb = _postmortem_traceback(excinfo) - rep._pdbshown = True + rep._pdbshown = True # type: ignore[attr-defined] # noqa: F821 post_mortem(tb) return rep diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index 9d777b415..79936b78f 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -1,11 +1,13 @@ import io import os import sys +from typing import Generator from typing import TextIO import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser +from _pytest.nodes import Item from _pytest.store import StoreKey @@ -82,7 +84,7 @@ class FaultHandlerHooks: return float(config.getini("faulthandler_timeout") or 0.0) @pytest.hookimpl(hookwrapper=True, trylast=True) - def pytest_runtest_protocol(self, item): + def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]: timeout = self.get_timeout_config_value(item.config) stderr = item.config._store[fault_handler_stderr_key] if timeout > 0 and stderr is not None: @@ -105,7 +107,7 @@ class FaultHandlerHooks: faulthandler.cancel_dump_traceback_later() @pytest.hookimpl(tryfirst=True) - def pytest_exception_interact(self): + def pytest_exception_interact(self) -> None: """Cancel any traceback dumping due to an interactive exception being raised. """ diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 3f6886009..ccdb0bde9 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -25,10 +25,16 @@ if TYPE_CHECKING: from _pytest.main import Session from _pytest.nodes import Collector from _pytest.nodes import Item + from _pytest.nodes import Node + from _pytest.python import Function from _pytest.python import Metafunc from _pytest.python import Module from _pytest.python import PyCollector from _pytest.reports import BaseReport + from _pytest.reports import CollectReport + from _pytest.reports import TestReport + from _pytest.runner import CallInfo + from _pytest.terminal import TerminalReporter hookspec = HookspecMarker("pytest") @@ -268,7 +274,7 @@ def pytest_collect_file(path: py.path.local, parent) -> "Optional[Collector]": # logging hooks for collection -def pytest_collectstart(collector): +def pytest_collectstart(collector: "Collector") -> None: """ collector starts collecting. """ @@ -285,7 +291,7 @@ def pytest_deselected(items): @hookspec(firstresult=True) -def pytest_make_collect_report(collector): +def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectReport]": """ perform ``collector.collect()`` and return a CollectReport. Stops at first non-None result, see :ref:`firstresult` """ @@ -319,7 +325,7 @@ def pytest_pycollect_makeitem( @hookspec(firstresult=True) -def pytest_pyfunc_call(pyfuncitem): +def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: """ call underlying test function. Stops at first non-None result, see :ref:`firstresult` """ @@ -330,7 +336,9 @@ def pytest_generate_tests(metafunc: "Metafunc") -> None: @hookspec(firstresult=True) -def pytest_make_parametrize_id(config: "Config", val, argname) -> Optional[str]: +def pytest_make_parametrize_id( + config: "Config", val: object, argname: str +) -> Optional[str]: """Return a user-friendly string representation of the given ``val`` that will be used by @pytest.mark.parametrize calls. Return None if the hook doesn't know about ``val``. The parameter name is available as ``argname``, if required. @@ -349,7 +357,7 @@ def pytest_make_parametrize_id(config: "Config", val, argname) -> Optional[str]: @hookspec(firstresult=True) -def pytest_runtestloop(session: "Session"): +def pytest_runtestloop(session: "Session") -> Optional[object]: """ called for performing the main runtest loop (after collection finished). @@ -360,7 +368,9 @@ def pytest_runtestloop(session: "Session"): @hookspec(firstresult=True) -def pytest_runtest_protocol(item, nextitem): +def pytest_runtest_protocol( + item: "Item", nextitem: "Optional[Item]" +) -> Optional[object]: """ implements the runtest_setup/call/teardown protocol for the given test item, including capturing exceptions and calling reporting hooks. @@ -399,15 +409,15 @@ def pytest_runtest_logfinish(nodeid, location): """ -def pytest_runtest_setup(item): +def pytest_runtest_setup(item: "Item") -> None: """ called before ``pytest_runtest_call(item)``. """ -def pytest_runtest_call(item): +def pytest_runtest_call(item: "Item") -> None: """ called to execute the test ``item``. """ -def pytest_runtest_teardown(item, nextitem): +def pytest_runtest_teardown(item: "Item", nextitem: "Optional[Item]") -> None: """ called after ``pytest_runtest_call``. :arg nextitem: the scheduled-to-be-next test item (None if no further @@ -418,7 +428,7 @@ def pytest_runtest_teardown(item, nextitem): @hookspec(firstresult=True) -def pytest_runtest_makereport(item, call): +def pytest_runtest_makereport(item: "Item", call: "CallInfo") -> Optional[object]: """ return a :py:class:`_pytest.runner.TestReport` object for the given :py:class:`pytest.Item <_pytest.main.Item>` and :py:class:`_pytest.runner.CallInfo`. @@ -426,7 +436,7 @@ def pytest_runtest_makereport(item, call): Stops at first non-None result, see :ref:`firstresult` """ -def pytest_runtest_logreport(report): +def pytest_runtest_logreport(report: "TestReport") -> None: """ process a test setup/call/teardown report relating to the respective phase of executing a test. """ @@ -511,7 +521,9 @@ def pytest_unconfigure(config: "Config") -> None: # ------------------------------------------------------------------------- -def pytest_assertrepr_compare(config: "Config", op, left, right): +def pytest_assertrepr_compare( + config: "Config", op: str, left: object, right: object +) -> Optional[List[str]]: """return explanation for comparisons in failing assert expressions. Return None for no custom explanation, otherwise return a list @@ -523,7 +535,7 @@ def pytest_assertrepr_compare(config: "Config", op, left, right): """ -def pytest_assertion_pass(item, lineno, orig, expl): +def pytest_assertion_pass(item, lineno: int, orig: str, expl: str) -> None: """ **(Experimental)** @@ -637,7 +649,9 @@ def pytest_report_teststatus( """ -def pytest_terminal_summary(terminalreporter, exitstatus, config: "Config"): +def pytest_terminal_summary( + terminalreporter: "TerminalReporter", exitstatus: "ExitCode", config: "Config", +) -> None: """Add a section to terminal summary reporting. :param _pytest.terminal.TerminalReporter terminalreporter: the internal terminal reporter object @@ -741,7 +755,9 @@ def pytest_keyboard_interrupt(excinfo): """ called for keyboard interrupt. """ -def pytest_exception_interact(node, call, report): +def pytest_exception_interact( + node: "Node", call: "CallInfo", report: "BaseReport" +) -> None: """called when an exception was raised which can potentially be interactively handled. diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index b0790bc79..0ecfb09bb 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -24,7 +24,9 @@ from _pytest import timing from _pytest.config import Config from _pytest.config import filename_arg from _pytest.config.argparsing import Parser +from _pytest.reports import TestReport from _pytest.store import StoreKey +from _pytest.terminal import TerminalReporter from _pytest.warnings import _issue_warning_captured @@ -517,7 +519,7 @@ class LogXML: reporter.record_testreport(report) return reporter - def pytest_runtest_logreport(self, report): + def pytest_runtest_logreport(self, report: TestReport) -> None: """handle a setup/call/teardown report, generating the appropriate xml tags as necessary. @@ -661,7 +663,7 @@ class LogXML: logfile.write(Junit.testsuites([suite_node]).unicode(indent=0)) logfile.close() - def pytest_terminal_summary(self, terminalreporter): + def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None: terminalreporter.write_sep("-", "generated xml file: {}".format(self.logfile)) def add_global_property(self, name, value): diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index b832f6994..92046ed51 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -20,6 +20,7 @@ from _pytest.config import _strtobool from _pytest.config import Config from _pytest.config import create_terminal_writer from _pytest.config.argparsing import Parser +from _pytest.main import Session from _pytest.pathlib import Path from _pytest.store import StoreKey @@ -618,7 +619,7 @@ class LoggingPlugin: yield @pytest.hookimpl(hookwrapper=True) - def pytest_runtestloop(self, session): + def pytest_runtestloop(self, session: Session) -> Generator[None, None, None]: """Runs all collected test items.""" if session.config.option.collectonly: @@ -655,20 +656,21 @@ class LoggingPlugin: item.add_report_section(when, "log", log) @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_setup(self, item): + def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]: self.log_cli_handler.set_when("setup") - item._store[catch_log_records_key] = {} + empty = {} # type: Dict[str, List[logging.LogRecord]] + item._store[catch_log_records_key] = empty yield from self._runtest_for(item, "setup") @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item): + def pytest_runtest_call(self, item: nodes.Item) -> Generator[None, None, None]: self.log_cli_handler.set_when("call") yield from self._runtest_for(item, "call") @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_teardown(self, item): + def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, None]: self.log_cli_handler.set_when("teardown") yield from self._runtest_for(item, "teardown") @@ -676,7 +678,7 @@ class LoggingPlugin: del item._store[catch_log_handler_key] @pytest.hookimpl - def pytest_runtest_logfinish(self): + def pytest_runtest_logfinish(self) -> None: self.log_cli_handler.set_when("finish") @pytest.hookimpl(hookwrapper=True, tryfirst=True) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index a0007d226..d891335a7 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -31,6 +31,7 @@ from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureManager from _pytest.outcomes import exit from _pytest.reports import CollectReport +from _pytest.reports import TestReport from _pytest.runner import collect_one_node from _pytest.runner import SetupState @@ -441,7 +442,7 @@ class Session(nodes.FSCollector): raise self.Interrupted(self.shouldstop) @hookimpl(tryfirst=True) - def pytest_runtest_logreport(self, report) -> None: + def pytest_runtest_logreport(self, report: TestReport) -> None: if report.failed and not hasattr(report, "wasxfail"): self.testsfailed += 1 maxfail = self.config.getvalue("maxfail") diff --git a/src/_pytest/nose.py b/src/_pytest/nose.py index d6f3c2b22..8bdc310ac 100644 --- a/src/_pytest/nose.py +++ b/src/_pytest/nose.py @@ -2,6 +2,7 @@ from _pytest import python from _pytest import unittest from _pytest.config import hookimpl +from _pytest.nodes import Item @hookimpl(trylast=True) @@ -20,7 +21,7 @@ def teardown_nose(item): call_optional(item.parent.obj, "teardown") -def is_potential_nosetest(item): +def is_potential_nosetest(item: Item) -> bool: # extra check needed since we do not do nose style setup/teardown # on direct unittest style classes return isinstance(item, python.Function) and not isinstance( diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 091d3f817..7e6bbf50c 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -2,11 +2,14 @@ import tempfile from io import StringIO from typing import IO +from typing import Union import pytest from _pytest.config import Config +from _pytest.config import create_terminal_writer from _pytest.config.argparsing import Parser from _pytest.store import StoreKey +from _pytest.terminal import TerminalReporter pastebinfile_key = StoreKey[IO[bytes]]() @@ -63,11 +66,11 @@ def pytest_unconfigure(config: Config) -> None: tr.write_line("pastebin session-log: %s\n" % pastebinurl) -def create_new_paste(contents): +def create_new_paste(contents: Union[str, bytes]) -> str: """ Creates a new paste using bpaste.net service. - :contents: paste contents as utf-8 encoded bytes + :contents: paste contents string :returns: url to the pasted contents or error message """ import re @@ -79,7 +82,7 @@ def create_new_paste(contents): try: response = ( urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8") - ) + ) # type: str except OSError as exc_info: # urllib errors return "bad response: %s" % exc_info m = re.search(r'href="/raw/(\w+)"', response) @@ -89,23 +92,20 @@ def create_new_paste(contents): return "bad response: invalid format ('" + response + "')" -def pytest_terminal_summary(terminalreporter): - import _pytest.config - +def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None: if terminalreporter.config.option.pastebin != "failed": return - tr = terminalreporter - if "failed" in tr.stats: + if "failed" in terminalreporter.stats: terminalreporter.write_sep("=", "Sending information to Paste Service") - for rep in terminalreporter.stats.get("failed"): + for rep in terminalreporter.stats["failed"]: try: msg = rep.longrepr.reprtraceback.reprentries[-1].reprfileloc except AttributeError: - msg = tr._getfailureheadline(rep) + msg = terminalreporter._getfailureheadline(rep) file = StringIO() - tw = _pytest.config.create_terminal_writer(terminalreporter.config, file) + tw = create_terminal_writer(terminalreporter.config, file) rep.toterminal(tw) s = file.getvalue() assert len(s) pastebinurl = create_new_paste(s) - tr.write_line("{} --> {}".format(msg, pastebinurl)) + terminalreporter.write_line("{} --> {}".format(msg, pastebinurl)) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index ae7bdcec8..12ad0591c 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -12,6 +12,7 @@ from fnmatch import fnmatch from io import StringIO from typing import Callable from typing import Dict +from typing import Generator from typing import Iterable from typing import List from typing import Optional @@ -138,7 +139,7 @@ class LsofFdLeakChecker: return True @pytest.hookimpl(hookwrapper=True, tryfirst=True) - def pytest_runtest_protocol(self, item): + def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]: lines1 = self.get_open_files() yield if hasattr(sys, "pypy_version_info"): diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 18b4fe2ff..9b8dcf608 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -173,7 +173,7 @@ def async_warn_and_skip(nodeid: str) -> None: @hookimpl(trylast=True) -def pytest_pyfunc_call(pyfuncitem: "Function"): +def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: testfunction = pyfuncitem.obj if is_async_function(testfunction): async_warn_and_skip(pyfuncitem.nodeid) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 908ba7d3b..9763cb4ad 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -26,6 +26,9 @@ from _pytest.nodes import Item from _pytest.outcomes import skip from _pytest.pathlib import Path +if TYPE_CHECKING: + from _pytest.runner import CallInfo + def getslaveinfoline(node): try: @@ -42,7 +45,8 @@ def getslaveinfoline(node): class BaseReport: when = None # type: Optional[str] location = None # type: Optional[Tuple[str, Optional[int], str]] - longrepr = None + # TODO: Improve this Any. + longrepr = None # type: Optional[Any] sections = [] # type: List[Tuple[str, str]] nodeid = None # type: str @@ -270,7 +274,7 @@ class TestReport(BaseReport): ) @classmethod - def from_item_and_call(cls, item, call) -> "TestReport": + def from_item_and_call(cls, item: Item, call: "CallInfo") -> "TestReport": """ Factory method to create and fill a TestReport with standard item and call info. """ @@ -281,7 +285,8 @@ class TestReport(BaseReport): sections = [] if not call.excinfo: outcome = "passed" - longrepr = None + # TODO: Improve this Any. + longrepr = None # type: Optional[Any] else: if not isinstance(excinfo, ExceptionInfo): outcome = "failed" diff --git a/src/_pytest/resultlog.py b/src/_pytest/resultlog.py index acc89afe2..720ea9f49 100644 --- a/src/_pytest/resultlog.py +++ b/src/_pytest/resultlog.py @@ -7,6 +7,7 @@ import py from _pytest.config import Config from _pytest.config.argparsing import Parser +from _pytest.reports import TestReport from _pytest.store import StoreKey @@ -66,7 +67,7 @@ class ResultLog: testpath = report.fspath self.write_log_entry(testpath, lettercode, longrepr) - def pytest_runtest_logreport(self, report): + def pytest_runtest_logreport(self, report: TestReport) -> None: if report.when != "call" and report.passed: return res = self.config.hook.pytest_report_teststatus( @@ -80,6 +81,7 @@ class ResultLog: elif report.passed: longrepr = "" elif report.skipped: + assert report.longrepr is not None longrepr = str(report.longrepr[2]) else: longrepr = str(report.longrepr) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index a2b9ee207..568065d94 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -10,6 +10,7 @@ from typing import Tuple import attr +from .reports import BaseReport from .reports import CollectErrorRepr from .reports import CollectReport from .reports import TestReport @@ -19,6 +20,7 @@ from _pytest._code.code import ExceptionInfo from _pytest.compat import TYPE_CHECKING from _pytest.config.argparsing import Parser from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.nodes import Node from _pytest.outcomes import Exit from _pytest.outcomes import Skipped @@ -29,6 +31,7 @@ if TYPE_CHECKING: from typing_extensions import Literal from _pytest.main import Session + from _pytest.terminal import TerminalReporter # # pytest plugin hooks @@ -46,7 +49,7 @@ def pytest_addoption(parser: Parser) -> None: ) -def pytest_terminal_summary(terminalreporter): +def pytest_terminal_summary(terminalreporter: "TerminalReporter") -> None: durations = terminalreporter.config.option.durations verbose = terminalreporter.config.getvalue("verbose") if durations is None: @@ -86,17 +89,19 @@ def pytest_sessionfinish(session: "Session") -> None: session._setupstate.teardown_all() -def pytest_runtest_protocol(item, nextitem): +def pytest_runtest_protocol(item: Item, nextitem: Optional[Item]) -> bool: item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) runtestprotocol(item, nextitem=nextitem) item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) return True -def runtestprotocol(item, log=True, nextitem=None): +def runtestprotocol( + item: Item, log: bool = True, nextitem: Optional[Item] = None +) -> List[TestReport]: hasrequest = hasattr(item, "_request") - if hasrequest and not item._request: - item._initrequest() + if hasrequest and not item._request: # type: ignore[attr-defined] # noqa: F821 + item._initrequest() # type: ignore[attr-defined] # noqa: F821 rep = call_and_report(item, "setup", log) reports = [rep] if rep.passed: @@ -108,12 +113,12 @@ def runtestprotocol(item, log=True, nextitem=None): # after all teardown hooks have been called # want funcargs and request info to go away if hasrequest: - item._request = False - item.funcargs = None + item._request = False # type: ignore[attr-defined] # noqa: F821 + item.funcargs = None # type: ignore[attr-defined] # noqa: F821 return reports -def show_test_item(item): +def show_test_item(item: Item) -> None: """Show test function, parameters and the fixtures of the test item.""" tw = item.config.get_terminal_writer() tw.line() @@ -125,12 +130,12 @@ def show_test_item(item): tw.flush() -def pytest_runtest_setup(item): +def pytest_runtest_setup(item: Item) -> None: _update_current_test_var(item, "setup") item.session._setupstate.prepare(item) -def pytest_runtest_call(item): +def pytest_runtest_call(item: Item) -> None: _update_current_test_var(item, "call") try: del sys.last_type @@ -150,13 +155,15 @@ def pytest_runtest_call(item): raise e -def pytest_runtest_teardown(item, nextitem): +def pytest_runtest_teardown(item: Item, nextitem: Optional[Item]) -> None: _update_current_test_var(item, "teardown") item.session._setupstate.teardown_exact(item, nextitem) _update_current_test_var(item, None) -def _update_current_test_var(item, when): +def _update_current_test_var( + item: Item, when: Optional["Literal['setup', 'call', 'teardown']"] +) -> None: """ Update :envvar:`PYTEST_CURRENT_TEST` to reflect the current item and stage. @@ -188,11 +195,11 @@ def pytest_report_teststatus(report): def call_and_report( - item, when: "Literal['setup', 'call', 'teardown']", log=True, **kwds -): + item: Item, when: "Literal['setup', 'call', 'teardown']", log: bool = True, **kwds +) -> TestReport: call = call_runtest_hook(item, when, **kwds) hook = item.ihook - report = hook.pytest_runtest_makereport(item=item, call=call) + report = hook.pytest_runtest_makereport(item=item, call=call) # type: TestReport if log: hook.pytest_runtest_logreport(report=report) if check_interactive_exception(call, report): @@ -200,15 +207,17 @@ def call_and_report( return report -def check_interactive_exception(call, report): - return call.excinfo and not ( +def check_interactive_exception(call: "CallInfo", report: BaseReport) -> bool: + return call.excinfo is not None and not ( hasattr(report, "wasxfail") or call.excinfo.errisinstance(Skipped) or call.excinfo.errisinstance(bdb.BdbQuit) ) -def call_runtest_hook(item, when: "Literal['setup', 'call', 'teardown']", **kwds): +def call_runtest_hook( + item: Item, when: "Literal['setup', 'call', 'teardown']", **kwds +) -> "CallInfo": if when == "setup": ihook = item.ihook.pytest_runtest_setup elif when == "call": @@ -278,13 +287,13 @@ class CallInfo: excinfo=excinfo, ) - def __repr__(self): + def __repr__(self) -> str: if self.excinfo is None: return "".format(self.when, self._result) return "".format(self.when, self.excinfo) -def pytest_runtest_makereport(item, call): +def pytest_runtest_makereport(item: Item, call: CallInfo) -> TestReport: return TestReport.from_item_and_call(item, call) diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 5e5fcc080..5994b5b2f 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -3,9 +3,12 @@ from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser from _pytest.mark.evaluate import MarkEvaluator +from _pytest.nodes import Item from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail +from _pytest.python import Function +from _pytest.runner import CallInfo from _pytest.store import StoreKey @@ -74,7 +77,7 @@ def pytest_configure(config: Config) -> None: @hookimpl(tryfirst=True) -def pytest_runtest_setup(item): +def pytest_runtest_setup(item: Item) -> None: # Check if skip or skipif are specified as pytest marks item._store[skipped_by_mark_key] = False eval_skipif = MarkEvaluator(item, "skipif") @@ -96,7 +99,7 @@ def pytest_runtest_setup(item): @hookimpl(hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem): +def pytest_pyfunc_call(pyfuncitem: Function): check_xfail_no_run(pyfuncitem) outcome = yield passed = outcome.excinfo is None @@ -104,7 +107,7 @@ def pytest_pyfunc_call(pyfuncitem): check_strict_xfail(pyfuncitem) -def check_xfail_no_run(item): +def check_xfail_no_run(item: Item) -> None: """check xfail(run=False)""" if not item.config.option.runxfail: evalxfail = item._store[evalxfail_key] @@ -113,7 +116,7 @@ def check_xfail_no_run(item): xfail("[NOTRUN] " + evalxfail.getexplanation()) -def check_strict_xfail(pyfuncitem): +def check_strict_xfail(pyfuncitem: Function) -> None: """check xfail(strict=True) for the given PASSING test""" evalxfail = pyfuncitem._store[evalxfail_key] if evalxfail.istrue(): @@ -126,7 +129,7 @@ def check_strict_xfail(pyfuncitem): @hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item, call): +def pytest_runtest_makereport(item: Item, call: CallInfo): outcome = yield rep = outcome.get_result() evalxfail = item._store.get(evalxfail_key, None) @@ -171,6 +174,7 @@ def pytest_runtest_makereport(item, call): # the location of where the skip exception was raised within pytest _, _, reason = rep.longrepr filename, line = item.reportinfo()[:2] + assert line is not None rep.longrepr = str(filename), line + 1, reason diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 3cbf0be9f..1921245dc 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -2,6 +2,7 @@ import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session +from _pytest.reports import TestReport def pytest_addoption(parser: Parser) -> None: @@ -73,7 +74,7 @@ class StepwisePlugin: config.hook.pytest_deselected(items=already_passed) - def pytest_runtest_logreport(self, report): + def pytest_runtest_logreport(self, report: TestReport) -> None: if not self.active: return diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 6f4b96e1e..bc2b5bf23 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -12,6 +12,7 @@ from functools import partial from typing import Any from typing import Callable from typing import Dict +from typing import Generator from typing import List from typing import Mapping from typing import Optional @@ -30,15 +31,19 @@ from _pytest import timing from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth from _pytest.compat import order_preserving_dict +from _pytest.compat import TYPE_CHECKING from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser from _pytest.deprecated import TERMINALWRITER_WRITER -from _pytest.main import Session from _pytest.reports import CollectReport from _pytest.reports import TestReport +if TYPE_CHECKING: + from _pytest.main import Session + + REPORT_COLLECTING_RESOLUTION = 0.5 KNOWN_TYPES = ( @@ -610,7 +615,7 @@ class TerminalReporter: self.write_line(line) @pytest.hookimpl(trylast=True) - def pytest_sessionstart(self, session: Session) -> None: + def pytest_sessionstart(self, session: "Session") -> None: self._session = session self._sessionstarttime = timing.time() if not self.showheader: @@ -720,7 +725,9 @@ class TerminalReporter: self._tw.line("{}{}".format(indent + " ", line)) @pytest.hookimpl(hookwrapper=True) - def pytest_sessionfinish(self, session: Session, exitstatus: Union[int, ExitCode]): + def pytest_sessionfinish( + self, session: "Session", exitstatus: Union[int, ExitCode] + ): outcome = yield outcome.get_result() self._tw.line("") @@ -745,7 +752,7 @@ class TerminalReporter: self.summary_stats() @pytest.hookimpl(hookwrapper=True) - def pytest_terminal_summary(self): + def pytest_terminal_summary(self) -> Generator[None, None, None]: self.summary_errors() self.summary_failures() self.summary_warnings() diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index b2e6ab89d..3fbf7c88d 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -1,6 +1,8 @@ """ discovery and running of std-library "unittest" style tests. """ import sys import traceback +from typing import Any +from typing import Generator from typing import Iterable from typing import Optional from typing import Union @@ -253,7 +255,7 @@ class TestCaseFunction(Function): @hookimpl(tryfirst=True) -def pytest_runtest_makereport(item, call): +def pytest_runtest_makereport(item: Item, call: CallInfo) -> None: if isinstance(item, TestCaseFunction): if item._excinfo: call.excinfo = item._excinfo.pop(0) @@ -263,7 +265,13 @@ def pytest_runtest_makereport(item, call): pass unittest = sys.modules.get("unittest") - if unittest and call.excinfo and call.excinfo.errisinstance(unittest.SkipTest): + if ( + unittest + and call.excinfo + and call.excinfo.errisinstance( + unittest.SkipTest # type: ignore[attr-defined] # noqa: F821 + ) + ): # let's substitute the excinfo with a pytest.skip one call2 = CallInfo.from_call( lambda: pytest.skip(str(call.excinfo.value)), call.when @@ -275,9 +283,9 @@ def pytest_runtest_makereport(item, call): @hookimpl(hookwrapper=True) -def pytest_runtest_protocol(item): +def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: if isinstance(item, TestCaseFunction) and "twisted.trial.unittest" in sys.modules: - ut = sys.modules["twisted.python.failure"] + ut = sys.modules["twisted.python.failure"] # type: Any Failure__init__ = ut.Failure.__init__ check_testcase_implements_trial_reporter() diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 83e338cb3..622cbb806 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -11,6 +11,8 @@ from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session +from _pytest.nodes import Item +from _pytest.terminal import TerminalReporter if TYPE_CHECKING: from typing_extensions import Type @@ -145,7 +147,7 @@ def warning_record_to_str(warning_message): @pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_protocol(item): +def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: with catch_warnings_for_item( config=item.config, ihook=item.ihook, when="runtest", item=item ): @@ -162,7 +164,9 @@ def pytest_collection(session: Session) -> Generator[None, None, None]: @pytest.hookimpl(hookwrapper=True) -def pytest_terminal_summary(terminalreporter): +def pytest_terminal_summary( + terminalreporter: TerminalReporter, +) -> Generator[None, None, None]: config = terminalreporter.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None diff --git a/testing/test_runner.py b/testing/test_runner.py index 32620801d..be79b14fd 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -884,7 +884,7 @@ def test_store_except_info_on_error() -> None: raise IndexError("TEST") try: - runner.pytest_runtest_call(ItemMightRaise()) + runner.pytest_runtest_call(ItemMightRaise()) # type: ignore[arg-type] # noqa: F821 except IndexError: pass # Check that exception info is stored on sys @@ -895,7 +895,7 @@ def test_store_except_info_on_error() -> None: # The next run should clear the exception info stored by the previous run ItemMightRaise.raise_error = False - runner.pytest_runtest_call(ItemMightRaise()) + runner.pytest_runtest_call(ItemMightRaise()) # type: ignore[arg-type] # noqa: F821 assert not hasattr(sys, "last_type") assert not hasattr(sys, "last_value") assert not hasattr(sys, "last_traceback") From 30e3d473c4addd5fc906ee3fb6da438e28daf7b8 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 029/140] Type annotate _pytest._io.saferepr --- src/_pytest/_io/saferepr.py | 43 ++++++++++++++++++++++++------- src/_pytest/assertion/__init__.py | 2 +- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index 47a00de60..6b9f353a2 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -1,9 +1,12 @@ import pprint import reprlib from typing import Any +from typing import Dict +from typing import IO +from typing import Optional -def _try_repr_or_str(obj): +def _try_repr_or_str(obj: object) -> str: try: return repr(obj) except (KeyboardInterrupt, SystemExit): @@ -12,7 +15,7 @@ def _try_repr_or_str(obj): return '{}("{}")'.format(type(obj).__name__, obj) -def _format_repr_exception(exc: BaseException, obj: Any) -> str: +def _format_repr_exception(exc: BaseException, obj: object) -> str: try: exc_info = _try_repr_or_str(exc) except (KeyboardInterrupt, SystemExit): @@ -42,7 +45,7 @@ class SafeRepr(reprlib.Repr): self.maxstring = maxsize self.maxsize = maxsize - def repr(self, x: Any) -> str: + def repr(self, x: object) -> str: try: s = super().repr(x) except (KeyboardInterrupt, SystemExit): @@ -51,7 +54,7 @@ class SafeRepr(reprlib.Repr): s = _format_repr_exception(exc, x) return _ellipsize(s, self.maxsize) - def repr_instance(self, x: Any, level: int) -> str: + def repr_instance(self, x: object, level: int) -> str: try: s = repr(x) except (KeyboardInterrupt, SystemExit): @@ -61,7 +64,7 @@ class SafeRepr(reprlib.Repr): return _ellipsize(s, self.maxsize) -def safeformat(obj: Any) -> str: +def safeformat(obj: object) -> str: """return a pretty printed string for the given object. Failing __repr__ functions of user instances will be represented with a short exception info. @@ -72,7 +75,7 @@ def safeformat(obj: Any) -> str: return _format_repr_exception(exc, obj) -def saferepr(obj: Any, maxsize: int = 240) -> str: +def saferepr(obj: object, maxsize: int = 240) -> str: """return a size-limited safe repr-string for the given object. Failing __repr__ functions of user instances will be represented with a short exception info and 'saferepr' generally takes @@ -85,19 +88,39 @@ def saferepr(obj: Any, maxsize: int = 240) -> str: 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) + def _format( + self, + object: object, + stream: IO[str], + indent: int, + allowance: int, + context: Dict[int, Any], + level: int, + ) -> None: + # Type ignored because _dispatch is private. + p = self._dispatch.get(type(object).__repr__, None) # type: ignore[attr-defined] # noqa: F821 objid = id(object) if objid in context or p is None: - return super()._format(object, stream, indent, allowance, context, level) + # Type ignored because _format is private. + super()._format( # type: ignore[misc] # noqa: F821 + object, stream, indent, allowance, context, level, + ) + return 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): +def _pformat_dispatch( + object: object, + indent: int = 1, + width: int = 80, + depth: Optional[int] = None, + *, + compact: bool = False +) -> str: return AlwaysDispatchingPrettyPrinter( indent=indent, width=width, depth=depth, compact=compact ).pformat(object) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 997c17921..6504db574 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -3,8 +3,8 @@ support for presenting detailed information in failing assertions. """ import sys from typing import Any -from typing import List from typing import Generator +from typing import List from typing import Optional from _pytest.assertion import rewrite From d95132178c073debcb687075f0f986d7d0322e9d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:15 +0300 Subject: [PATCH 030/140] Type annotate _pytest.assertion --- src/_pytest/assertion/__init__.py | 10 +- src/_pytest/assertion/rewrite.py | 149 +++++++++++++++++++----------- src/_pytest/assertion/truncate.py | 21 ++++- testing/test_assertrewrite.py | 5 +- 4 files changed, 120 insertions(+), 65 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 6504db574..f404607c1 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -46,7 +46,7 @@ def pytest_addoption(parser: Parser) -> None: ) -def register_assert_rewrite(*names) -> None: +def register_assert_rewrite(*names: str) -> None: """Register one or more module names to be rewritten on import. This function will make sure that this module or all modules inside @@ -75,27 +75,27 @@ def register_assert_rewrite(*names) -> None: class DummyRewriteHook: """A no-op import hook for when rewriting is disabled.""" - def mark_rewrite(self, *names): + def mark_rewrite(self, *names: str) -> None: pass class AssertionState: """State for the assertion plugin.""" - def __init__(self, config, mode): + def __init__(self, config: Config, mode) -> None: self.mode = mode self.trace = config.trace.root.get("assertion") self.hook = None # type: Optional[rewrite.AssertionRewritingHook] -def install_importhook(config): +def install_importhook(config: Config) -> rewrite.AssertionRewritingHook: """Try to install the rewrite hook, raise SystemError if it fails.""" config._store[assertstate_key] = AssertionState(config, "rewrite") config._store[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config) sys.meta_path.insert(0, hook) config._store[assertstate_key].trace("installed rewrite import hook") - def undo(): + def undo() -> None: hook = config._store[assertstate_key].hook if hook is not None and hook in sys.meta_path: sys.meta_path.remove(hook) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index bd4ea022c..cec0c5501 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -13,11 +13,15 @@ import struct import sys import tokenize import types +from typing import Callable from typing import Dict +from typing import IO from typing import List from typing import Optional +from typing import Sequence from typing import Set from typing import Tuple +from typing import Union from _pytest._io.saferepr import saferepr from _pytest._version import version @@ -27,6 +31,8 @@ from _pytest.assertion.util import ( # noqa: F401 ) from _pytest.compat import fspath from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config +from _pytest.main import Session from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import Path from _pytest.pathlib import PurePath @@ -48,13 +54,13 @@ PYC_TAIL = "." + PYTEST_TAG + PYC_EXT class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader): """PEP302/PEP451 import hook which rewrites asserts.""" - def __init__(self, config): + def __init__(self, config: Config) -> None: self.config = config try: self.fnpats = config.getini("python_files") except ValueError: self.fnpats = ["test_*.py", "*_test.py"] - self.session = None + self.session = None # type: Optional[Session] self._rewritten_names = set() # type: Set[str] self._must_rewrite = set() # type: Set[str] # flag to guard against trying to rewrite a pyc file while we are already writing another pyc file, @@ -64,14 +70,19 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) self._marked_for_rewrite_cache = {} # type: Dict[str, bool] self._session_paths_checked = False - def set_session(self, session): + def set_session(self, session: Optional[Session]) -> None: self.session = session self._session_paths_checked = False # Indirection so we can mock calls to find_spec originated from the hook during testing _find_spec = importlib.machinery.PathFinder.find_spec - def find_spec(self, name, path=None, target=None): + def find_spec( + self, + name: str, + path: Optional[Sequence[Union[str, bytes]]] = None, + target: Optional[types.ModuleType] = None, + ) -> Optional[importlib.machinery.ModuleSpec]: if self._writing_pyc: return None state = self.config._store[assertstate_key] @@ -79,7 +90,8 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) return None state.trace("find_module called for: %s" % name) - spec = self._find_spec(name, path) + # Type ignored because mypy is confused about the `self` binding here. + spec = self._find_spec(name, path) # type: ignore if ( # the import machinery could not find a file to import spec is None @@ -108,10 +120,14 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) submodule_search_locations=spec.submodule_search_locations, ) - def create_module(self, spec): + def create_module( + self, spec: importlib.machinery.ModuleSpec + ) -> Optional[types.ModuleType]: return None # default behaviour is fine - def exec_module(self, module): + def exec_module(self, module: types.ModuleType) -> None: + assert module.__spec__ is not None + assert module.__spec__.origin is not None fn = Path(module.__spec__.origin) state = self.config._store[assertstate_key] @@ -151,7 +167,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) state.trace("found cached rewritten pyc for {}".format(fn)) exec(co, module.__dict__) - def _early_rewrite_bailout(self, name, state): + def _early_rewrite_bailout(self, name: str, state: "AssertionState") -> bool: """This is a fast way to get out of rewriting modules. Profiling has shown that the call to PathFinder.find_spec (inside of @@ -190,7 +206,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) state.trace("early skip of rewriting module: {}".format(name)) return True - def _should_rewrite(self, name, fn, state): + def _should_rewrite(self, name: str, fn: str, state: "AssertionState") -> bool: # always rewrite conftest files if os.path.basename(fn) == "conftest.py": state.trace("rewriting conftest file: {!r}".format(fn)) @@ -213,7 +229,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) return self._is_marked_for_rewrite(name, state) - def _is_marked_for_rewrite(self, name: str, state): + def _is_marked_for_rewrite(self, name: str, state: "AssertionState") -> bool: try: return self._marked_for_rewrite_cache[name] except KeyError: @@ -246,7 +262,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) self._must_rewrite.update(names) self._marked_for_rewrite_cache.clear() - def _warn_already_imported(self, name): + def _warn_already_imported(self, name: str) -> None: from _pytest.warning_types import PytestAssertRewriteWarning from _pytest.warnings import _issue_warning_captured @@ -258,13 +274,15 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) stacklevel=5, ) - def get_data(self, pathname): + def get_data(self, pathname: Union[str, bytes]) -> bytes: """Optional PEP302 get_data API.""" with open(pathname, "rb") as f: return f.read() -def _write_pyc_fp(fp, source_stat, co): +def _write_pyc_fp( + fp: IO[bytes], source_stat: os.stat_result, co: types.CodeType +) -> None: # Technically, we don't have to have the same pyc format as # (C)Python, since these "pycs" should never be seen by builtin # import. However, there's little reason deviate. @@ -280,7 +298,12 @@ def _write_pyc_fp(fp, source_stat, co): if sys.platform == "win32": from atomicwrites import atomic_write - def _write_pyc(state, co, source_stat, pyc): + def _write_pyc( + state: "AssertionState", + co: types.CodeType, + source_stat: os.stat_result, + pyc: Path, + ) -> bool: try: with atomic_write(fspath(pyc), mode="wb", overwrite=True) as fp: _write_pyc_fp(fp, source_stat, co) @@ -295,7 +318,12 @@ if sys.platform == "win32": else: - def _write_pyc(state, co, source_stat, pyc): + def _write_pyc( + state: "AssertionState", + co: types.CodeType, + source_stat: os.stat_result, + pyc: Path, + ) -> bool: proc_pyc = "{}.{}".format(pyc, os.getpid()) try: fp = open(proc_pyc, "wb") @@ -319,19 +347,21 @@ else: return True -def _rewrite_test(fn, config): +def _rewrite_test(fn: Path, config: Config) -> Tuple[os.stat_result, types.CodeType]: """read and rewrite *fn* and return the code object.""" - fn = fspath(fn) - stat = os.stat(fn) - with open(fn, "rb") as f: + fn_ = fspath(fn) + stat = os.stat(fn_) + with open(fn_, "rb") as f: source = f.read() - tree = ast.parse(source, filename=fn) - rewrite_asserts(tree, source, fn, config) - co = compile(tree, fn, "exec", dont_inherit=True) + tree = ast.parse(source, filename=fn_) + rewrite_asserts(tree, source, fn_, config) + co = compile(tree, fn_, "exec", dont_inherit=True) return stat, co -def _read_pyc(source, pyc, trace=lambda x: None): +def _read_pyc( + source: Path, pyc: Path, trace: Callable[[str], None] = lambda x: None +) -> Optional[types.CodeType]: """Possibly read a pytest pyc containing rewritten code. Return rewritten code if successful or None if not. @@ -368,12 +398,17 @@ def _read_pyc(source, pyc, trace=lambda x: None): return co -def rewrite_asserts(mod, source, module_path=None, config=None): +def rewrite_asserts( + mod: ast.Module, + source: bytes, + module_path: Optional[str] = None, + config: Optional[Config] = None, +) -> None: """Rewrite the assert statements in mod.""" AssertionRewriter(module_path, config, source).run(mod) -def _saferepr(obj): +def _saferepr(obj: object) -> str: """Get a safe repr of an object for assertion error messages. The assertion formatting (util.format_explanation()) requires @@ -387,7 +422,7 @@ def _saferepr(obj): return saferepr(obj).replace("\n", "\\n") -def _format_assertmsg(obj): +def _format_assertmsg(obj: object) -> str: """Format the custom assertion message given. For strings this simply replaces newlines with '\n~' so that @@ -410,7 +445,7 @@ def _format_assertmsg(obj): return obj -def _should_repr_global_name(obj): +def _should_repr_global_name(obj: object) -> bool: if callable(obj): return False @@ -420,7 +455,7 @@ def _should_repr_global_name(obj): return True -def _format_boolop(explanations, is_or): +def _format_boolop(explanations, is_or: bool): explanation = "(" + (is_or and " or " or " and ").join(explanations) + ")" if isinstance(explanation, str): return explanation.replace("%", "%%") @@ -428,8 +463,12 @@ def _format_boolop(explanations, is_or): return explanation.replace(b"%", b"%%") -def _call_reprcompare(ops, results, expls, each_obj): - # type: (Tuple[str, ...], Tuple[bool, ...], Tuple[str, ...], Tuple[object, ...]) -> str +def _call_reprcompare( + ops: Sequence[str], + results: Sequence[bool], + expls: Sequence[str], + each_obj: Sequence[object], +) -> str: for i, res, expl in zip(range(len(ops)), results, expls): try: done = not res @@ -607,7 +646,9 @@ class AssertionRewriter(ast.NodeVisitor): """ - def __init__(self, module_path, config, source): + def __init__( + self, module_path: Optional[str], config: Optional[Config], source: bytes + ) -> None: super().__init__() self.module_path = module_path self.config = config @@ -620,7 +661,7 @@ class AssertionRewriter(ast.NodeVisitor): self.source = source @functools.lru_cache(maxsize=1) - def _assert_expr_to_lineno(self): + def _assert_expr_to_lineno(self) -> Dict[int, str]: return _get_assertion_exprs(self.source) def run(self, mod: ast.Module) -> None: @@ -689,38 +730,38 @@ class AssertionRewriter(ast.NodeVisitor): nodes.append(field) @staticmethod - def is_rewrite_disabled(docstring): + def is_rewrite_disabled(docstring: str) -> bool: return "PYTEST_DONT_REWRITE" in docstring - def variable(self): + def variable(self) -> str: """Get a new variable.""" # Use a character invalid in python identifiers to avoid clashing. name = "@py_assert" + str(next(self.variable_counter)) self.variables.append(name) return name - def assign(self, expr): + def assign(self, expr: ast.expr) -> ast.Name: """Give *expr* a name.""" name = self.variable() self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr)) return ast.Name(name, ast.Load()) - def display(self, expr): + def display(self, expr: ast.expr) -> ast.expr: """Call saferepr on the expression.""" return self.helper("_saferepr", expr) - def helper(self, name, *args): + def helper(self, name: str, *args: ast.expr) -> ast.expr: """Call a helper in this module.""" py_name = ast.Name("@pytest_ar", ast.Load()) attr = ast.Attribute(py_name, name, ast.Load()) return ast.Call(attr, list(args), []) - def builtin(self, name): + def builtin(self, name: str) -> ast.Attribute: """Return the builtin called *name*.""" builtin_name = ast.Name("@py_builtins", ast.Load()) return ast.Attribute(builtin_name, name, ast.Load()) - def explanation_param(self, expr): + def explanation_param(self, expr: ast.expr) -> str: """Return a new named %-formatting placeholder for expr. This creates a %-formatting placeholder for expr in the @@ -733,7 +774,7 @@ class AssertionRewriter(ast.NodeVisitor): self.explanation_specifiers[specifier] = expr return "%(" + specifier + ")s" - def push_format_context(self): + def push_format_context(self) -> None: """Create a new formatting context. The format context is used for when an explanation wants to @@ -747,10 +788,10 @@ class AssertionRewriter(ast.NodeVisitor): self.explanation_specifiers = {} # type: Dict[str, ast.expr] self.stack.append(self.explanation_specifiers) - def pop_format_context(self, expl_expr): + def pop_format_context(self, expl_expr: ast.expr) -> ast.Name: """Format the %-formatted string with current format context. - The expl_expr should be an ast.Str instance constructed from + The expl_expr should be an str ast.expr instance constructed from the %-placeholders created by .explanation_param(). This will add the required code to format said string to .expl_stmts and return the ast.Name instance of the formatted string. @@ -768,13 +809,13 @@ class AssertionRewriter(ast.NodeVisitor): self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form)) return ast.Name(name, ast.Load()) - def generic_visit(self, node): + def generic_visit(self, node: ast.AST) -> Tuple[ast.Name, str]: """Handle expressions we don't have custom code for.""" assert isinstance(node, ast.expr) res = self.assign(node) return res, self.explanation_param(self.display(res)) - def visit_Assert(self, assert_): + def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]: """Return the AST statements to replace the ast.Assert instance. This rewrites the test of an assertion to provide @@ -787,6 +828,8 @@ class AssertionRewriter(ast.NodeVisitor): from _pytest.warning_types import PytestAssertRewriteWarning import warnings + # TODO: This assert should not be needed. + assert self.module_path is not None warnings.warn_explicit( PytestAssertRewriteWarning( "assertion is always true, perhaps remove parentheses?" @@ -889,7 +932,7 @@ class AssertionRewriter(ast.NodeVisitor): set_location(stmt, assert_.lineno, assert_.col_offset) return self.statements - def visit_Name(self, name): + def visit_Name(self, name: ast.Name) -> Tuple[ast.Name, str]: # Display the repr of the name if it's a local variable or # _should_repr_global_name() thinks it's acceptable. locs = ast.Call(self.builtin("locals"), [], []) @@ -899,7 +942,7 @@ class AssertionRewriter(ast.NodeVisitor): expr = ast.IfExp(test, self.display(name), ast.Str(name.id)) return name, self.explanation_param(expr) - def visit_BoolOp(self, boolop): + def visit_BoolOp(self, boolop: ast.BoolOp) -> Tuple[ast.Name, str]: res_var = self.variable() expl_list = self.assign(ast.List([], ast.Load())) app = ast.Attribute(expl_list, "append", ast.Load()) @@ -934,13 +977,13 @@ class AssertionRewriter(ast.NodeVisitor): expl = self.pop_format_context(expl_template) return ast.Name(res_var, ast.Load()), self.explanation_param(expl) - def visit_UnaryOp(self, unary): + def visit_UnaryOp(self, unary: ast.UnaryOp) -> Tuple[ast.Name, str]: pattern = UNARY_MAP[unary.op.__class__] operand_res, operand_expl = self.visit(unary.operand) res = self.assign(ast.UnaryOp(unary.op, operand_res)) return res, pattern % (operand_expl,) - def visit_BinOp(self, binop): + def visit_BinOp(self, binop: ast.BinOp) -> Tuple[ast.Name, str]: symbol = BINOP_MAP[binop.op.__class__] left_expr, left_expl = self.visit(binop.left) right_expr, right_expl = self.visit(binop.right) @@ -948,7 +991,7 @@ class AssertionRewriter(ast.NodeVisitor): res = self.assign(ast.BinOp(left_expr, binop.op, right_expr)) return res, explanation - def visit_Call(self, call): + def visit_Call(self, call: ast.Call) -> Tuple[ast.Name, str]: """ visit `ast.Call` nodes """ @@ -975,13 +1018,13 @@ class AssertionRewriter(ast.NodeVisitor): outer_expl = "{}\n{{{} = {}\n}}".format(res_expl, res_expl, expl) return res, outer_expl - def visit_Starred(self, starred): + def visit_Starred(self, starred: ast.Starred) -> Tuple[ast.Starred, str]: # From Python 3.5, a Starred node can appear in a function call res, expl = self.visit(starred.value) new_starred = ast.Starred(res, starred.ctx) return new_starred, "*" + expl - def visit_Attribute(self, attr): + def visit_Attribute(self, attr: ast.Attribute) -> Tuple[ast.Name, str]: if not isinstance(attr.ctx, ast.Load): return self.generic_visit(attr) value, value_expl = self.visit(attr.value) @@ -991,7 +1034,7 @@ class AssertionRewriter(ast.NodeVisitor): expl = pat % (res_expl, res_expl, value_expl, attr.attr) return res, expl - def visit_Compare(self, comp: ast.Compare): + def visit_Compare(self, comp: ast.Compare) -> Tuple[ast.expr, str]: self.push_format_context() left_res, left_expl = self.visit(comp.left) if isinstance(comp.left, (ast.Compare, ast.BoolOp)): @@ -1030,7 +1073,7 @@ class AssertionRewriter(ast.NodeVisitor): return res, self.explanation_param(self.pop_format_context(expl_call)) -def try_makedirs(cache_dir) -> bool: +def try_makedirs(cache_dir: Path) -> bool: """Attempts to create the given directory and sub-directories exist, returns True if successful or it already exists""" try: diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index d97b05b44..fb2bf9c8e 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -5,13 +5,20 @@ Current default behaviour is to truncate assertion explanations at ~8 terminal lines, unless running in "-vv" mode or running on CI. """ import os +from typing import List +from typing import Optional + +from _pytest.nodes import Item + DEFAULT_MAX_LINES = 8 DEFAULT_MAX_CHARS = 8 * 80 USAGE_MSG = "use '-vv' to show" -def truncate_if_required(explanation, item, max_length=None): +def truncate_if_required( + explanation: List[str], item: Item, max_length: Optional[int] = None +) -> List[str]: """ Truncate this assertion explanation if the given test item is eligible. """ @@ -20,7 +27,7 @@ def truncate_if_required(explanation, item, max_length=None): return explanation -def _should_truncate_item(item): +def _should_truncate_item(item: Item) -> bool: """ Whether or not this test item is eligible for truncation. """ @@ -28,13 +35,17 @@ def _should_truncate_item(item): return verbose < 2 and not _running_on_ci() -def _running_on_ci(): +def _running_on_ci() -> bool: """Check if we're currently running on a CI system.""" env_vars = ["CI", "BUILD_NUMBER"] return any(var in os.environ for var in env_vars) -def _truncate_explanation(input_lines, max_lines=None, max_chars=None): +def _truncate_explanation( + input_lines: List[str], + max_lines: Optional[int] = None, + max_chars: Optional[int] = None, +) -> List[str]: """ Truncate given list of strings that makes up the assertion explanation. @@ -73,7 +84,7 @@ def _truncate_explanation(input_lines, max_lines=None, max_chars=None): return truncated_explanation -def _truncate_by_char_count(input_lines, max_chars): +def _truncate_by_char_count(input_lines: List[str], max_chars: int) -> List[str]: # Check if truncation required if len("".join(input_lines)) <= max_chars: return input_lines diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 7bc853e82..212c631ef 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -952,7 +952,8 @@ class TestAssertionRewriteHookDetails: state = AssertionState(config, "rewrite") source_path = str(tmpdir.ensure("source.py")) pycpath = tmpdir.join("pyc").strpath - assert _write_pyc(state, [1], os.stat(source_path), pycpath) + co = compile("1", "f.py", "single") + assert _write_pyc(state, co, os.stat(source_path), pycpath) if sys.platform == "win32": from contextlib import contextmanager @@ -974,7 +975,7 @@ class TestAssertionRewriteHookDetails: monkeypatch.setattr("os.rename", raise_oserror) - assert not _write_pyc(state, [1], os.stat(source_path), pycpath) + assert not _write_pyc(state, co, os.stat(source_path), pycpath) def test_resources_provider_for_loader(self, testdir): """ From e68a26199cb2bae0f001ab495232525f38227ad9 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 031/140] Type annotate misc functions --- src/_pytest/cacheprovider.py | 60 ++++++++++++++++++++++-------------- src/_pytest/fixtures.py | 10 +++--- src/_pytest/hookspec.py | 4 ++- src/_pytest/main.py | 8 ++--- src/_pytest/mark/__init__.py | 7 +++-- src/_pytest/pathlib.py | 2 +- src/_pytest/pytester.py | 8 ++--- src/_pytest/stepwise.py | 18 ++++++++--- 8 files changed, 73 insertions(+), 44 deletions(-) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index c95f17152..bb08c5a6e 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -8,6 +8,7 @@ import json import os from typing import Dict from typing import Generator +from typing import Iterable from typing import List from typing import Optional from typing import Set @@ -27,10 +28,12 @@ from _pytest.compat import order_preserving_dict from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureRequest from _pytest.main import Session from _pytest.python import Module from _pytest.reports import TestReport + README_CONTENT = """\ # pytest cache directory # @@ -52,8 +55,8 @@ Signature: 8a477f597d28d172789f06886806bc55 @attr.s class Cache: - _cachedir = attr.ib(repr=False) - _config = attr.ib(repr=False) + _cachedir = attr.ib(type=Path, repr=False) + _config = attr.ib(type=Config, repr=False) # sub-directory under cache-dir for directories created by "makedir" _CACHE_PREFIX_DIRS = "d" @@ -62,14 +65,14 @@ class Cache: _CACHE_PREFIX_VALUES = "v" @classmethod - def for_config(cls, config): + def for_config(cls, config: Config) -> "Cache": cachedir = cls.cache_dir_from_config(config) if config.getoption("cacheclear") and cachedir.is_dir(): cls.clear_cache(cachedir) return cls(cachedir, config) @classmethod - def clear_cache(cls, cachedir: Path): + def clear_cache(cls, cachedir: Path) -> None: """Clears the sub-directories used to hold cached directories and values.""" for prefix in (cls._CACHE_PREFIX_DIRS, cls._CACHE_PREFIX_VALUES): d = cachedir / prefix @@ -77,10 +80,10 @@ class Cache: rm_rf(d) @staticmethod - def cache_dir_from_config(config): + def cache_dir_from_config(config: Config): return resolve_from_str(config.getini("cache_dir"), config.rootdir) - def warn(self, fmt, **args): + def warn(self, fmt: str, **args: object) -> None: import warnings from _pytest.warning_types import PytestCacheWarning @@ -90,7 +93,7 @@ class Cache: stacklevel=3, ) - def makedir(self, name): + def makedir(self, name: str) -> py.path.local: """ return a directory path object with the given name. If the directory does not yet exist, it will be created. You can use it to manage files likes e. g. store/retrieve database @@ -100,14 +103,14 @@ class Cache: Make sure the name contains your plugin or application identifiers to prevent clashes with other cache users. """ - name = Path(name) - if len(name.parts) > 1: + path = Path(name) + if len(path.parts) > 1: raise ValueError("name is not allowed to contain path separators") - res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, name) + res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path) res.mkdir(exist_ok=True, parents=True) return py.path.local(res) - def _getvaluepath(self, key): + def _getvaluepath(self, key: str) -> Path: return self._cachedir.joinpath(self._CACHE_PREFIX_VALUES, Path(key)) def get(self, key, default): @@ -128,7 +131,7 @@ class Cache: except (ValueError, OSError): return default - def set(self, key, value): + def set(self, key, value) -> None: """ save value for the given key. :param key: must be a ``/`` separated value. Usually the first @@ -158,7 +161,7 @@ class Cache: with f: f.write(data) - def _ensure_supporting_files(self): + def _ensure_supporting_files(self) -> None: """Create supporting files in the cache dir that are not really part of the cache.""" readme_path = self._cachedir / "README.md" readme_path.write_text(README_CONTENT) @@ -172,12 +175,12 @@ class Cache: class LFPluginCollWrapper: - def __init__(self, lfplugin: "LFPlugin"): + def __init__(self, lfplugin: "LFPlugin") -> None: self.lfplugin = lfplugin self._collected_at_least_one_failure = False @pytest.hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector) -> Generator: + def pytest_make_collect_report(self, collector: nodes.Collector) -> Generator: if isinstance(collector, Session): out = yield res = out.get_result() # type: CollectReport @@ -220,11 +223,13 @@ class LFPluginCollWrapper: class LFPluginCollSkipfiles: - def __init__(self, lfplugin: "LFPlugin"): + def __init__(self, lfplugin: "LFPlugin") -> None: self.lfplugin = lfplugin @pytest.hookimpl - def pytest_make_collect_report(self, collector) -> Optional[CollectReport]: + def pytest_make_collect_report( + self, collector: nodes.Collector + ) -> Optional[CollectReport]: if isinstance(collector, Module): if Path(str(collector.fspath)) not in self.lfplugin._last_failed_paths: self.lfplugin._skipped_files += 1 @@ -262,9 +267,10 @@ class LFPlugin: result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed} return {x for x in result if x.exists()} - def pytest_report_collectionfinish(self): + def pytest_report_collectionfinish(self) -> Optional[str]: if self.active and self.config.getoption("verbose") >= 0: return "run-last-failure: %s" % self._report_status + return None def pytest_runtest_logreport(self, report: TestReport) -> None: if (report.when == "call" and report.passed) or report.skipped: @@ -347,9 +353,10 @@ class LFPlugin: class NFPlugin: """ Plugin which implements the --nf (run new-first) option """ - def __init__(self, config): + def __init__(self, config: Config) -> None: self.config = config self.active = config.option.newfirst + assert config.cache is not None self.cached_nodeids = set(config.cache.get("cache/nodeids", [])) @pytest.hookimpl(hookwrapper=True, tryfirst=True) @@ -374,7 +381,7 @@ class NFPlugin: else: self.cached_nodeids.update(item.nodeid for item in items) - def _get_increasing_order(self, items): + def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]: return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True) def pytest_sessionfinish(self) -> None: @@ -384,6 +391,8 @@ class NFPlugin: if config.getoption("collectonly"): return + + assert config.cache is not None config.cache.set("cache/nodeids", sorted(self.cached_nodeids)) @@ -462,7 +471,7 @@ def pytest_configure(config: Config) -> None: @pytest.fixture -def cache(request): +def cache(request: FixtureRequest) -> Cache: """ Return a cache object that can persist state between testing sessions. @@ -474,12 +483,14 @@ def cache(request): Values can be any object handled by the json stdlib module. """ + assert request.config.cache is not None return request.config.cache -def pytest_report_header(config): +def pytest_report_header(config: Config) -> Optional[str]: """Display cachedir with --cache-show and if non-default.""" if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache": + assert config.cache is not None cachedir = config.cache._cachedir # TODO: evaluate generating upward relative paths # starting with .., ../.. if sensible @@ -489,11 +500,14 @@ def pytest_report_header(config): except ValueError: displaypath = cachedir return "cachedir: {}".format(displaypath) + return None -def cacheshow(config, session): +def cacheshow(config: Config, session: Session) -> int: from pprint import pformat + assert config.cache is not None + tw = TerminalWriter() tw.line("cachedir: " + str(config.cache._cachedir)) if not config.cache._cachedir.is_dir(): diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 4cd9a20ef..b45392ba5 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -57,6 +57,7 @@ if TYPE_CHECKING: from _pytest import nodes from _pytest.main import Session from _pytest.python import Metafunc + from _pytest.python import CallSpec2 _Scope = Literal["session", "package", "module", "class", "function"] @@ -217,10 +218,11 @@ def get_parametrized_fixture_keys(item, scopenum): the specified scope. """ assert scopenum < scopenum_function # function try: - cs = item.callspec + callspec = item.callspec # type: ignore[attr-defined] # noqa: F821 except AttributeError: pass else: + cs = callspec # type: CallSpec2 # cs.indices.items() is random order of argnames. Need to # sort this so that different calls to # get_parametrized_fixture_keys will be deterministic. @@ -434,9 +436,9 @@ class FixtureRequest: return fixturedefs[index] @property - def config(self): + def config(self) -> Config: """ the pytest config object associated with this request. """ - return self._pyfuncitem.config + return self._pyfuncitem.config # type: ignore[no-any-return] # noqa: F723 @scopeproperty() def function(self): @@ -1464,7 +1466,7 @@ class FixtureManager: else: continue # will raise FixtureLookupError at setup time - def pytest_collection_modifyitems(self, items): + def pytest_collection_modifyitems(self, items: "List[nodes.Item]") -> None: # separate parametrized setups items[:] = reorder_items(items) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index ccdb0bde9..c5d5bdedd 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -223,7 +223,9 @@ def pytest_collection(session: "Session") -> Optional[Any]: """ -def pytest_collection_modifyitems(session: "Session", config: "Config", items): +def pytest_collection_modifyitems( + session: "Session", config: "Config", items: List["Item"] +) -> None: """ called after collection has been performed, may filter or re-order the items in-place. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index d891335a7..a80097f5a 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -333,7 +333,7 @@ def pytest_ignore_collect( return None -def pytest_collection_modifyitems(items, config: Config) -> None: +def pytest_collection_modifyitems(items: List[nodes.Item], config: Config) -> None: deselect_prefixes = tuple(config.getoption("deselect") or []) if not deselect_prefixes: return @@ -487,18 +487,18 @@ class Session(nodes.FSCollector): @overload def _perform_collect( self, args: Optional[Sequence[str]], genitems: "Literal[True]" - ) -> Sequence[nodes.Item]: + ) -> List[nodes.Item]: raise NotImplementedError() @overload # noqa: F811 def _perform_collect( # noqa: F811 self, args: Optional[Sequence[str]], genitems: bool - ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + ) -> Union[List[Union[nodes.Item]], List[Union[nodes.Item, nodes.Collector]]]: raise NotImplementedError() def _perform_collect( # noqa: F811 self, args: Optional[Sequence[str]], genitems: bool - ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + ) -> Union[List[Union[nodes.Item]], List[Union[nodes.Item, nodes.Collector]]]: if args is None: args = self.config.args self.trace("perform_collect", self, args) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 05afb7749..16e821aee 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -2,6 +2,7 @@ import typing import warnings from typing import AbstractSet +from typing import List from typing import Optional from typing import Union @@ -173,7 +174,7 @@ class KeywordMatcher: return False -def deselect_by_keyword(items, config: Config) -> None: +def deselect_by_keyword(items: "List[Item]", config: Config) -> None: keywordexpr = config.option.keyword.lstrip() if not keywordexpr: return @@ -229,7 +230,7 @@ class MarkMatcher: return name in self.own_mark_names -def deselect_by_mark(items, config: Config) -> None: +def deselect_by_mark(items: "List[Item]", config: Config) -> None: matchexpr = config.option.markexpr if not matchexpr: return @@ -254,7 +255,7 @@ def deselect_by_mark(items, config: Config) -> None: items[:] = remaining -def pytest_collection_modifyitems(items, config: Config) -> None: +def pytest_collection_modifyitems(items: "List[Item]", config: Config) -> None: deselect_by_keyword(items, config) deselect_by_mark(items, config) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 90a7460b0..6878965e0 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -348,7 +348,7 @@ def make_numbered_dir_with_cleanup( raise e -def resolve_from_str(input, root): +def resolve_from_str(input: str, root): assert not isinstance(input, Path), "would break on py2" root = Path(root) input = expanduser(input) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 12ad0591c..60df17b90 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -647,8 +647,8 @@ class Testdir: for basename, value in items: p = self.tmpdir.join(basename).new(ext=ext) p.dirpath().ensure_dir() - source = Source(value) - source = "\n".join(to_text(line) for line in source.lines) + source_ = Source(value) + source = "\n".join(to_text(line) for line in source_.lines) p.write(source.strip().encode(encoding), "wb") if ret is None: ret = p @@ -839,7 +839,7 @@ class Testdir: config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) return res - def genitems(self, colitems): + def genitems(self, colitems: List[Union[Item, Collector]]) -> List[Item]: """Generate all test items from a collection node. This recurses into the collection node and returns a list of all the @@ -847,7 +847,7 @@ class Testdir: """ session = colitems[0].session - result = [] + result = [] # type: List[Item] for colitem in colitems: result.extend(session.genitems(colitem)) return result diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 1921245dc..85cbe2931 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -1,4 +1,8 @@ +from typing import List +from typing import Optional + import pytest +from _pytest import nodes from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session @@ -28,20 +32,23 @@ def pytest_configure(config: Config) -> None: class StepwisePlugin: - def __init__(self, config): + def __init__(self, config: Config) -> None: self.config = config self.active = config.getvalue("stepwise") - self.session = None + self.session = None # type: Optional[Session] self.report_status = "" if self.active: + assert config.cache is not None self.lastfailed = config.cache.get("cache/stepwise", None) self.skip = config.getvalue("stepwise_skip") def pytest_sessionstart(self, session: Session) -> None: self.session = session - def pytest_collection_modifyitems(self, session, config, items): + def pytest_collection_modifyitems( + self, session: Session, config: Config, items: List[nodes.Item] + ) -> None: if not self.active: return if not self.lastfailed: @@ -89,6 +96,7 @@ class StepwisePlugin: else: # Mark test as the last failing and interrupt the test session. self.lastfailed = report.nodeid + assert self.session is not None self.session.shouldstop = ( "Test failed, continuing from this test next run." ) @@ -100,11 +108,13 @@ class StepwisePlugin: if report.nodeid == self.lastfailed: self.lastfailed = None - def pytest_report_collectionfinish(self): + def pytest_report_collectionfinish(self) -> Optional[str]: if self.active and self.config.getoption("verbose") >= 0 and self.report_status: return "stepwise: %s" % self.report_status + return None def pytest_sessionfinish(self, session: Session) -> None: + assert self.config.cache is not None if self.active: self.config.cache.set("cache/stepwise", self.lastfailed) else: From 387d9d04f7173f25c0719610e07921ff19dc5b99 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 032/140] Type annotate tricky reorder_items() function in fixtures.py --- src/_pytest/fixtures.py | 60 ++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index b45392ba5..1a9178743 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -50,6 +50,7 @@ from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME if TYPE_CHECKING: + from typing import Deque from typing import NoReturn from typing import Type from typing_extensions import Literal @@ -213,7 +214,11 @@ def getfixturemarker(obj): return None -def get_parametrized_fixture_keys(item, scopenum): +# Parametrized fixture key, helper alias for code below. +_Key = Tuple[object, ...] + + +def get_parametrized_fixture_keys(item: "nodes.Item", scopenum: int) -> Iterator[_Key]: """ return list of keys for all parametrized arguments which match the specified scope. """ assert scopenum < scopenum_function # function @@ -230,13 +235,14 @@ def get_parametrized_fixture_keys(item, scopenum): if cs._arg2scopenum[argname] != scopenum: continue if scopenum == 0: # session - key = (argname, param_index) + key = (argname, param_index) # type: _Key elif scopenum == 1: # package key = (argname, param_index, item.fspath.dirpath()) elif scopenum == 2: # module key = (argname, param_index, item.fspath) elif scopenum == 3: # class - key = (argname, param_index, item.fspath, item.cls) + item_cls = item.cls # type: ignore[attr-defined] # noqa: F821 + key = (argname, param_index, item.fspath, item_cls) yield key @@ -246,47 +252,65 @@ def get_parametrized_fixture_keys(item, scopenum): # setups and teardowns -def reorder_items(items): - argkeys_cache = {} - items_by_argkey = {} +def reorder_items(items: "Sequence[nodes.Item]") -> "List[nodes.Item]": + argkeys_cache = {} # type: Dict[int, Dict[nodes.Item, Dict[_Key, None]]] + items_by_argkey = {} # type: Dict[int, Dict[_Key, Deque[nodes.Item]]] for scopenum in range(0, scopenum_function): - argkeys_cache[scopenum] = d = {} - items_by_argkey[scopenum] = item_d = defaultdict(deque) + d = {} # type: Dict[nodes.Item, Dict[_Key, None]] + argkeys_cache[scopenum] = d + item_d = defaultdict(deque) # type: Dict[_Key, Deque[nodes.Item]] + items_by_argkey[scopenum] = item_d for item in items: - keys = order_preserving_dict.fromkeys( - get_parametrized_fixture_keys(item, scopenum) + # cast is a workaround for https://github.com/python/typeshed/issues/3800. + keys = cast( + "Dict[_Key, None]", + order_preserving_dict.fromkeys( + get_parametrized_fixture_keys(item, scopenum), None + ), ) if keys: d[item] = keys for key in keys: item_d[key].append(item) - items = order_preserving_dict.fromkeys(items) - return list(reorder_items_atscope(items, argkeys_cache, items_by_argkey, 0)) + # cast is a workaround for https://github.com/python/typeshed/issues/3800. + items_dict = cast( + "Dict[nodes.Item, None]", order_preserving_dict.fromkeys(items, None) + ) + return list(reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, 0)) -def fix_cache_order(item, argkeys_cache, items_by_argkey) -> None: +def fix_cache_order( + item: "nodes.Item", + argkeys_cache: "Dict[int, Dict[nodes.Item, Dict[_Key, None]]]", + items_by_argkey: "Dict[int, Dict[_Key, Deque[nodes.Item]]]", +) -> None: for scopenum in range(0, scopenum_function): for key in argkeys_cache[scopenum].get(item, []): items_by_argkey[scopenum][key].appendleft(item) -def reorder_items_atscope(items, argkeys_cache, items_by_argkey, scopenum): +def reorder_items_atscope( + items: "Dict[nodes.Item, None]", + argkeys_cache: "Dict[int, Dict[nodes.Item, Dict[_Key, None]]]", + items_by_argkey: "Dict[int, Dict[_Key, Deque[nodes.Item]]]", + scopenum: int, +) -> "Dict[nodes.Item, None]": if scopenum >= scopenum_function or len(items) < 3: return items - ignore = set() + ignore = set() # type: Set[Optional[_Key]] items_deque = deque(items) - items_done = order_preserving_dict() + items_done = order_preserving_dict() # type: Dict[nodes.Item, None] scoped_items_by_argkey = items_by_argkey[scopenum] scoped_argkeys_cache = argkeys_cache[scopenum] while items_deque: - no_argkey_group = order_preserving_dict() + no_argkey_group = order_preserving_dict() # type: Dict[nodes.Item, None] slicing_argkey = None while items_deque: item = items_deque.popleft() if item in items_done or item in no_argkey_group: continue argkeys = order_preserving_dict.fromkeys( - k for k in scoped_argkeys_cache.get(item, []) if k not in ignore + (k for k in scoped_argkeys_cache.get(item, []) if k not in ignore), None ) if not argkeys: no_argkey_group[item] = None From 32dd0e87cb2e6750c1fc2356eb451c9811bdb065 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 033/140] Type annotate _pytest.doctest --- src/_pytest/doctest.py | 111 ++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 34 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 026476b8a..ab8085982 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -4,12 +4,17 @@ import inspect import platform import sys import traceback +import types import warnings from contextlib import contextmanager +from typing import Any +from typing import Callable from typing import Dict +from typing import Generator from typing import Iterable from typing import List from typing import Optional +from typing import Pattern from typing import Sequence from typing import Tuple from typing import Union @@ -24,6 +29,7 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import safe_getattr from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest from _pytest.outcomes import OutcomeException @@ -131,7 +137,7 @@ def _is_setup_py(path: py.path.local) -> bool: return b"setuptools" in contents or b"distutils" in contents -def _is_doctest(config, path, parent): +def _is_doctest(config: Config, path: py.path.local, parent) -> bool: if path.ext in (".txt", ".rst") and parent.session.isinitpath(path): return True globs = config.getoption("doctestglob") or ["test*.txt"] @@ -144,7 +150,7 @@ def _is_doctest(config, path, parent): class ReprFailDoctest(TerminalRepr): def __init__( self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]] - ): + ) -> None: self.reprlocation_lines = reprlocation_lines def toterminal(self, tw: TerminalWriter) -> None: @@ -155,7 +161,7 @@ class ReprFailDoctest(TerminalRepr): class MultipleDoctestFailures(Exception): - def __init__(self, failures): + def __init__(self, failures: "Sequence[doctest.DocTestFailure]") -> None: super().__init__() self.failures = failures @@ -170,21 +176,33 @@ def _init_runner_class() -> "Type[doctest.DocTestRunner]": """ def __init__( - self, checker=None, verbose=None, optionflags=0, continue_on_failure=True - ): + self, + checker: Optional[doctest.OutputChecker] = None, + verbose: Optional[bool] = None, + optionflags: int = 0, + continue_on_failure: bool = True, + ) -> None: doctest.DebugRunner.__init__( self, checker=checker, verbose=verbose, optionflags=optionflags ) self.continue_on_failure = continue_on_failure - def report_failure(self, out, test, example, got): + def report_failure( + self, out, test: "doctest.DocTest", example: "doctest.Example", got: str, + ) -> None: failure = doctest.DocTestFailure(test, example, got) if self.continue_on_failure: out.append(failure) else: raise failure - def report_unexpected_exception(self, out, test, example, exc_info): + def report_unexpected_exception( + self, + out, + test: "doctest.DocTest", + example: "doctest.Example", + exc_info: "Tuple[Type[BaseException], BaseException, types.TracebackType]", + ) -> None: if isinstance(exc_info[1], OutcomeException): raise exc_info[1] if isinstance(exc_info[1], bdb.BdbQuit): @@ -219,16 +237,27 @@ def _get_runner( class DoctestItem(pytest.Item): - def __init__(self, name, parent, runner=None, dtest=None): + def __init__( + self, + name: str, + parent: "Union[DoctestTextfile, DoctestModule]", + runner: Optional["doctest.DocTestRunner"] = None, + dtest: Optional["doctest.DocTest"] = None, + ) -> None: super().__init__(name, parent) self.runner = runner self.dtest = dtest self.obj = None - self.fixture_request = None + self.fixture_request = None # type: Optional[FixtureRequest] @classmethod def from_parent( # type: ignore - cls, parent: "Union[DoctestTextfile, DoctestModule]", *, name, runner, dtest + cls, + parent: "Union[DoctestTextfile, DoctestModule]", + *, + name: str, + runner: "doctest.DocTestRunner", + dtest: "doctest.DocTest" ): # incompatible signature due to to imposed limits on sublcass """ @@ -236,7 +265,7 @@ class DoctestItem(pytest.Item): """ return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) - def setup(self): + def setup(self) -> None: if self.dtest is not None: self.fixture_request = _setup_fixtures(self) globs = dict(getfixture=self.fixture_request.getfixturevalue) @@ -247,14 +276,18 @@ class DoctestItem(pytest.Item): self.dtest.globs.update(globs) def runtest(self) -> None: + assert self.dtest is not None + assert self.runner is not None _check_all_skipped(self.dtest) self._disable_output_capturing_for_darwin() failures = [] # type: List[doctest.DocTestFailure] - self.runner.run(self.dtest, out=failures) + # Type ignored because we change the type of `out` from what + # doctest expects. + self.runner.run(self.dtest, out=failures) # type: ignore[arg-type] # noqa: F821 if failures: raise MultipleDoctestFailures(failures) - def _disable_output_capturing_for_darwin(self): + def _disable_output_capturing_for_darwin(self) -> None: """ Disable output capturing. Otherwise, stdout is lost to doctest (#985) """ @@ -272,10 +305,12 @@ class DoctestItem(pytest.Item): failures = ( None - ) # type: Optional[List[Union[doctest.DocTestFailure, doctest.UnexpectedException]]] - if excinfo.errisinstance((doctest.DocTestFailure, doctest.UnexpectedException)): + ) # type: Optional[Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]]] + if isinstance( + excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException) + ): failures = [excinfo.value] - elif excinfo.errisinstance(MultipleDoctestFailures): + elif isinstance(excinfo.value, MultipleDoctestFailures): failures = excinfo.value.failures if failures is not None: @@ -289,7 +324,8 @@ class DoctestItem(pytest.Item): else: lineno = test.lineno + example.lineno + 1 message = type(failure).__name__ - reprlocation = ReprFileLocation(filename, lineno, message) + # TODO: ReprFileLocation doesn't expect a None lineno. + reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type] # noqa: F821 checker = _get_checker() report_choice = _get_report_choice( self.config.getoption("doctestreport") @@ -329,7 +365,8 @@ class DoctestItem(pytest.Item): else: return super().repr_failure(excinfo) - def reportinfo(self) -> Tuple[py.path.local, int, str]: + def reportinfo(self): + assert self.dtest is not None return self.fspath, self.dtest.lineno, "[doctest] %s" % self.name @@ -399,7 +436,7 @@ class DoctestTextfile(pytest.Module): ) -def _check_all_skipped(test): +def _check_all_skipped(test: "doctest.DocTest") -> None: """raises pytest.skip() if all examples in the given DocTest have the SKIP option set. """ @@ -410,7 +447,7 @@ def _check_all_skipped(test): pytest.skip("all tests skipped by +SKIP option") -def _is_mocked(obj): +def _is_mocked(obj: object) -> bool: """ returns if a object is possibly a mock object by checking the existence of a highly improbable attribute """ @@ -421,23 +458,26 @@ def _is_mocked(obj): @contextmanager -def _patch_unwrap_mock_aware(): +def _patch_unwrap_mock_aware() -> Generator[None, None, None]: """ contextmanager which replaces ``inspect.unwrap`` with a version that's aware of mock objects and doesn't recurse on them """ real_unwrap = inspect.unwrap - def _mock_aware_unwrap(obj, stop=None): + def _mock_aware_unwrap( + func: Callable[..., Any], *, stop: Optional[Callable[[Any], Any]] = None + ) -> Any: try: if stop is None or stop is _is_mocked: - return real_unwrap(obj, stop=_is_mocked) - return real_unwrap(obj, stop=lambda obj: _is_mocked(obj) or stop(obj)) + return real_unwrap(func, stop=_is_mocked) + _stop = stop + return real_unwrap(func, stop=lambda obj: _is_mocked(obj) or _stop(func)) except Exception as e: warnings.warn( "Got %r when unwrapping %r. This is usually caused " "by a violation of Python's object protocol; see e.g. " - "https://github.com/pytest-dev/pytest/issues/5080" % (e, obj), + "https://github.com/pytest-dev/pytest/issues/5080" % (e, func), PytestWarning, ) raise @@ -469,7 +509,10 @@ class DoctestModule(pytest.Module): """ if isinstance(obj, property): obj = getattr(obj, "fget", obj) - return doctest.DocTestFinder._find_lineno(self, obj, source_lines) + # Type ignored because this is a private function. + return doctest.DocTestFinder._find_lineno( # type: ignore + self, obj, source_lines, + ) def _find( self, tests, obj, name, module, source_lines, globs, seen @@ -510,17 +553,17 @@ class DoctestModule(pytest.Module): ) -def _setup_fixtures(doctest_item): +def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest: """ Used by DoctestTextfile and DoctestItem to setup fixture information. """ - def func(): + def func() -> None: pass - doctest_item.funcargs = {} + doctest_item.funcargs = {} # type: ignore[attr-defined] # noqa: F821 fm = doctest_item.session._fixturemanager - doctest_item._fixtureinfo = fm.getfixtureinfo( + doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined] # noqa: F821 node=doctest_item, func=func, cls=None, funcargs=False ) fixture_request = FixtureRequest(doctest_item) @@ -564,7 +607,7 @@ def _init_checker_class() -> "Type[doctest.OutputChecker]": re.VERBOSE, ) - def check_output(self, want, got, optionflags): + def check_output(self, want: str, got: str, optionflags: int) -> bool: if doctest.OutputChecker.check_output(self, want, got, optionflags): return True @@ -575,7 +618,7 @@ def _init_checker_class() -> "Type[doctest.OutputChecker]": if not allow_unicode and not allow_bytes and not allow_number: return False - def remove_prefixes(regex, txt): + def remove_prefixes(regex: Pattern[str], txt: str) -> str: return re.sub(regex, r"\1\2", txt) if allow_unicode: @@ -591,7 +634,7 @@ def _init_checker_class() -> "Type[doctest.OutputChecker]": return doctest.OutputChecker.check_output(self, want, got, optionflags) - def _remove_unwanted_precision(self, want, got): + def _remove_unwanted_precision(self, want: str, got: str) -> str: wants = list(self._number_re.finditer(want)) gots = list(self._number_re.finditer(got)) if len(wants) != len(gots): @@ -686,7 +729,7 @@ def _get_report_choice(key: str) -> int: @pytest.fixture(scope="session") -def doctest_namespace(): +def doctest_namespace() -> Dict[str, Any]: """ Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. """ From fc325bc0c3e5c8694ecf8e3b08770b4da47c59e9 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 034/140] Type annotate more of _pytest.nodes --- src/_pytest/mark/__init__.py | 6 ++-- src/_pytest/nodes.py | 63 +++++++++++++++++++++++++++--------- src/_pytest/python.py | 6 ++-- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 16e821aee..7bbea54d2 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -147,9 +147,9 @@ class KeywordMatcher: # Add the names of the current item and any parent items import pytest - for item in item.listchain(): - if not isinstance(item, (pytest.Instance, pytest.Session)): - mapped_names.add(item.name) + for node in item.listchain(): + if not isinstance(node, (pytest.Instance, pytest.Session)): + mapped_names.add(node.name) # Add the names added as extra keywords to current or parent items mapped_names.update(item.listextrakeywords()) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 010dce925..4fdf1df74 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -5,11 +5,13 @@ from typing import Any from typing import Callable from typing import Dict from typing import Iterable +from typing import Iterator from typing import List from typing import Optional from typing import Sequence from typing import Set from typing import Tuple +from typing import TypeVar from typing import Union import py @@ -20,6 +22,7 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import ReprExceptionInfo from _pytest.compat import cached_property +from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import ConftestImportFailure @@ -36,6 +39,8 @@ from _pytest.pathlib import Path from _pytest.store import Store if TYPE_CHECKING: + from typing import Type + # Imported here due to circular import. from _pytest.main import Session @@ -45,7 +50,7 @@ tracebackcutdir = py.path.local(_pytest.__file__).dirpath() @lru_cache(maxsize=None) -def _splitnode(nodeid): +def _splitnode(nodeid: str) -> Tuple[str, ...]: """Split a nodeid into constituent 'parts'. Node IDs are strings, and can be things like: @@ -70,7 +75,7 @@ def _splitnode(nodeid): return tuple(parts) -def ischildnode(baseid, nodeid): +def ischildnode(baseid: str, nodeid: str) -> bool: """Return True if the nodeid is a child node of the baseid. E.g. 'foo/bar::Baz' is a child of 'foo', 'foo/bar' and 'foo/bar::Baz', but not of 'foo/blorp' @@ -82,6 +87,9 @@ def ischildnode(baseid, nodeid): return node_parts[: len(base_parts)] == base_parts +_NodeType = TypeVar("_NodeType", bound="Node") + + class NodeMeta(type): def __call__(self, *k, **kw): warnings.warn(NODE_USE_FROM_PARENT.format(name=self.__name__), stacklevel=2) @@ -191,7 +199,7 @@ class Node(metaclass=NodeMeta): """ fspath sensitive hook proxy used to call pytest hooks""" return self.session.gethookproxy(self.fspath) - def __repr__(self): + def __repr__(self) -> str: return "<{} {}>".format(self.__class__.__name__, getattr(self, "name", None)) def warn(self, warning): @@ -232,16 +240,16 @@ class Node(metaclass=NodeMeta): """ a ::-separated string denoting its collection tree address. """ return self._nodeid - def __hash__(self): + def __hash__(self) -> int: return hash(self._nodeid) - def setup(self): + def setup(self) -> None: pass - def teardown(self): + def teardown(self) -> None: pass - def listchain(self): + def listchain(self) -> List["Node"]: """ return list of all parent collectors up to self, starting from root of collection tree. """ chain = [] @@ -276,7 +284,7 @@ class Node(metaclass=NodeMeta): else: self.own_markers.insert(0, marker_.mark) - def iter_markers(self, name=None): + def iter_markers(self, name: Optional[str] = None) -> Iterator[Mark]: """ :param name: if given, filter the results by the name attribute @@ -284,7 +292,9 @@ class Node(metaclass=NodeMeta): """ return (x[1] for x in self.iter_markers_with_node(name=name)) - def iter_markers_with_node(self, name=None): + def iter_markers_with_node( + self, name: Optional[str] = None + ) -> Iterator[Tuple["Node", Mark]]: """ :param name: if given, filter the results by the name attribute @@ -296,7 +306,17 @@ class Node(metaclass=NodeMeta): if name is None or getattr(mark, "name", None) == name: yield node, mark - def get_closest_marker(self, name, default=None): + @overload + def get_closest_marker(self, name: str) -> Optional[Mark]: + raise NotImplementedError() + + @overload # noqa: F811 + def get_closest_marker(self, name: str, default: Mark) -> Mark: # noqa: F811 + raise NotImplementedError() + + def get_closest_marker( # noqa: F811 + self, name: str, default: Optional[Mark] = None + ) -> Optional[Mark]: """return the first marker matching the name, from closest (for example function) to farther level (for example module level). @@ -305,14 +325,14 @@ class Node(metaclass=NodeMeta): """ return next(self.iter_markers(name=name), default) - def listextrakeywords(self): + def listextrakeywords(self) -> Set[str]: """ Return a set of all extra keywords in self and any parents.""" extra_keywords = set() # type: Set[str] for item in self.listchain(): extra_keywords.update(item.extra_keyword_matches) return extra_keywords - def listnames(self): + def listnames(self) -> List[str]: return [x.name for x in self.listchain()] def addfinalizer(self, fin: Callable[[], object]) -> None: @@ -323,12 +343,13 @@ class Node(metaclass=NodeMeta): """ self.session._setupstate.addfinalizer(fin, self) - def getparent(self, cls): + def getparent(self, cls: "Type[_NodeType]") -> Optional[_NodeType]: """ get the next parent node (including ourself) which is an instance of the given class""" current = self # type: Optional[Node] while current and not isinstance(current, cls): current = current.parent + assert current is None or isinstance(current, cls) return current def _prunetraceback(self, excinfo): @@ -479,7 +500,12 @@ class FSHookProxy: class FSCollector(Collector): def __init__( - self, fspath: py.path.local, parent=None, config=None, session=None, nodeid=None + self, + fspath: py.path.local, + parent=None, + config: Optional[Config] = None, + session: Optional["Session"] = None, + nodeid: Optional[str] = None, ) -> None: name = fspath.basename if parent is not None: @@ -579,7 +605,14 @@ class Item(Node): nextitem = None - def __init__(self, name, parent=None, config=None, session=None, nodeid=None): + def __init__( + self, + name, + parent=None, + config: Optional[Config] = None, + session: Optional["Session"] = None, + nodeid: Optional[str] = None, + ) -> None: super().__init__(name, parent, config, session, nodeid=nodeid) self._report_sections = [] # type: List[Tuple[str, str, str]] diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 9b8dcf608..55ed2b164 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -423,7 +423,9 @@ class PyCollector(PyobjMixin, nodes.Collector): return item def _genfunctions(self, name, funcobj): - module = self.getparent(Module).obj + modulecol = self.getparent(Module) + assert modulecol is not None + module = modulecol.obj clscol = self.getparent(Class) cls = clscol and clscol.obj or None fm = self.session._fixturemanager @@ -437,7 +439,7 @@ class PyCollector(PyobjMixin, nodes.Collector): methods = [] if hasattr(module, "pytest_generate_tests"): methods.append(module.pytest_generate_tests) - if hasattr(cls, "pytest_generate_tests"): + if cls is not None and hasattr(cls, "pytest_generate_tests"): methods.append(cls().pytest_generate_tests) self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) From 709bcbf3c413850b7bf10634b2637292ddda331d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 035/140] Type annotate _pytest.mark.evaluate --- src/_pytest/mark/evaluate.py | 37 ++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/_pytest/mark/evaluate.py b/src/_pytest/mark/evaluate.py index c47174e71..759191668 100644 --- a/src/_pytest/mark/evaluate.py +++ b/src/_pytest/mark/evaluate.py @@ -4,10 +4,14 @@ import sys import traceback from typing import Any from typing import Dict +from typing import List +from typing import Optional from ..outcomes import fail from ..outcomes import TEST_OUTCOME +from .structures import Mark from _pytest.config import Config +from _pytest.nodes import Item from _pytest.store import StoreKey @@ -28,29 +32,29 @@ def cached_eval(config: Config, expr: str, d: Dict[str, object]) -> Any: class MarkEvaluator: - def __init__(self, item, name): + def __init__(self, item: Item, name: str) -> None: self.item = item - self._marks = None - self._mark = None + self._marks = None # type: Optional[List[Mark]] + self._mark = None # type: Optional[Mark] self._mark_name = name - def __bool__(self): + def __bool__(self) -> bool: # don't cache here to prevent staleness return bool(self._get_marks()) - def wasvalid(self): + def wasvalid(self) -> bool: return not hasattr(self, "exc") - def _get_marks(self): + def _get_marks(self) -> List[Mark]: return list(self.item.iter_markers(name=self._mark_name)) - def invalidraise(self, exc): + def invalidraise(self, exc) -> Optional[bool]: raises = self.get("raises") if not raises: - return + return None return not isinstance(exc, raises) - def istrue(self): + def istrue(self) -> bool: try: return self._istrue() except TEST_OUTCOME: @@ -69,25 +73,26 @@ class MarkEvaluator: pytrace=False, ) - def _getglobals(self): + def _getglobals(self) -> Dict[str, object]: d = {"os": os, "sys": sys, "platform": platform, "config": self.item.config} if hasattr(self.item, "obj"): - d.update(self.item.obj.__globals__) + d.update(self.item.obj.__globals__) # type: ignore[attr-defined] # noqa: F821 return d - def _istrue(self): + def _istrue(self) -> bool: if hasattr(self, "result"): - return self.result + result = getattr(self, "result") # type: bool + return result self._marks = self._get_marks() if self._marks: self.result = False for mark in self._marks: self._mark = mark - if "condition" in mark.kwargs: - args = (mark.kwargs["condition"],) - else: + if "condition" not in mark.kwargs: args = mark.args + else: + args = (mark.kwargs["condition"],) for expr in args: self.expr = expr From 90e58f89615327d78a0c25d148321edb296ca982 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 036/140] Type annotate some parts related to runner & reports --- src/_pytest/cacheprovider.py | 2 +- src/_pytest/helpconfig.py | 3 +- src/_pytest/hookspec.py | 17 +++++--- src/_pytest/main.py | 4 +- src/_pytest/reports.py | 79 ++++++++++++++++++++++-------------- src/_pytest/resultlog.py | 5 ++- src/_pytest/runner.py | 49 ++++++++++++++-------- src/_pytest/skipping.py | 9 +++- src/_pytest/terminal.py | 15 +++---- src/_pytest/unittest.py | 7 ++-- testing/test_runner.py | 26 ++++++------ 11 files changed, 132 insertions(+), 84 deletions(-) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index bb08c5a6e..af7d57a24 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -278,7 +278,7 @@ class LFPlugin: elif report.failed: self.lastfailed[report.nodeid] = True - def pytest_collectreport(self, report): + def pytest_collectreport(self, report: CollectReport) -> None: passed = report.outcome in ("passed", "skipped") if passed: if report.nodeid in self.lastfailed: diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index c2519c8af..06e0954cf 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -2,6 +2,7 @@ import os import sys from argparse import Action +from typing import List from typing import Optional from typing import Union @@ -235,7 +236,7 @@ def getpluginversioninfo(config): return lines -def pytest_report_header(config): +def pytest_report_header(config: Config) -> List[str]: lines = [] if config.option.debug or config.option.traceconfig: lines.append( diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index c5d5bdedd..99f646bd6 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -3,6 +3,7 @@ from typing import Any from typing import List from typing import Mapping from typing import Optional +from typing import Sequence from typing import Tuple from typing import Union @@ -284,7 +285,7 @@ def pytest_itemcollected(item): """ we just collected a test item. """ -def pytest_collectreport(report): +def pytest_collectreport(report: "CollectReport") -> None: """ collector finished collecting. """ @@ -430,7 +431,7 @@ def pytest_runtest_teardown(item: "Item", nextitem: "Optional[Item]") -> None: @hookspec(firstresult=True) -def pytest_runtest_makereport(item: "Item", call: "CallInfo") -> Optional[object]: +def pytest_runtest_makereport(item: "Item", call: "CallInfo[None]") -> Optional[object]: """ return a :py:class:`_pytest.runner.TestReport` object for the given :py:class:`pytest.Item <_pytest.main.Item>` and :py:class:`_pytest.runner.CallInfo`. @@ -444,7 +445,7 @@ def pytest_runtest_logreport(report: "TestReport") -> None: @hookspec(firstresult=True) -def pytest_report_to_serializable(config: "Config", report): +def pytest_report_to_serializable(config: "Config", report: "BaseReport"): """ Serializes the given report object into a data structure suitable for sending over the wire, e.g. converted to JSON. @@ -580,7 +581,9 @@ def pytest_assertion_pass(item, lineno: int, orig: str, expl: str) -> None: # ------------------------------------------------------------------------- -def pytest_report_header(config: "Config", startdir): +def pytest_report_header( + config: "Config", startdir: py.path.local +) -> Union[str, List[str]]: """ return a string or list of strings to be displayed as header info for terminal reporting. :param _pytest.config.Config config: pytest config object @@ -601,7 +604,9 @@ def pytest_report_header(config: "Config", startdir): """ -def pytest_report_collectionfinish(config: "Config", startdir, items): +def pytest_report_collectionfinish( + config: "Config", startdir: py.path.local, items: "Sequence[Item]" +) -> Union[str, List[str]]: """ .. versionadded:: 3.2 @@ -758,7 +763,7 @@ def pytest_keyboard_interrupt(excinfo): def pytest_exception_interact( - node: "Node", call: "CallInfo", report: "BaseReport" + node: "Node", call: "CallInfo[object]", report: "Union[CollectReport, TestReport]" ) -> None: """called when an exception was raised which can potentially be interactively handled. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index a80097f5a..1c1cda18b 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -442,7 +442,9 @@ class Session(nodes.FSCollector): raise self.Interrupted(self.shouldstop) @hookimpl(tryfirst=True) - def pytest_runtest_logreport(self, report: TestReport) -> None: + def pytest_runtest_logreport( + self, report: Union[TestReport, CollectReport] + ) -> None: if report.failed and not hasattr(report, "wasxfail"): self.testsfailed += 1 maxfail = self.config.getvalue("maxfail") diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 9763cb4ad..7462cea0b 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,9 +1,12 @@ from io import StringIO from pprint import pprint from typing import Any +from typing import Iterable +from typing import Iterator from typing import List from typing import Optional from typing import Tuple +from typing import TypeVar from typing import Union import attr @@ -21,12 +24,17 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import skip from _pytest.pathlib import Path if TYPE_CHECKING: + from typing import NoReturn + from typing_extensions import Type + from typing_extensions import Literal + from _pytest.runner import CallInfo @@ -42,6 +50,9 @@ def getslaveinfoline(node): return s +_R = TypeVar("_R", bound="BaseReport") + + class BaseReport: when = None # type: Optional[str] location = None # type: Optional[Tuple[str, Optional[int], str]] @@ -74,13 +85,13 @@ class BaseReport: except UnicodeEncodeError: out.line("") - def get_sections(self, prefix): + def get_sections(self, prefix: str) -> Iterator[Tuple[str, str]]: for name, content in self.sections: if name.startswith(prefix): yield prefix, content @property - def longreprtext(self): + def longreprtext(self) -> str: """ Read-only property that returns the full string representation of ``longrepr``. @@ -95,7 +106,7 @@ class BaseReport: return exc.strip() @property - def caplog(self): + def caplog(self) -> str: """Return captured log lines, if log capturing is enabled .. versionadded:: 3.5 @@ -105,7 +116,7 @@ class BaseReport: ) @property - def capstdout(self): + def capstdout(self) -> str: """Return captured text from stdout, if capturing is enabled .. versionadded:: 3.0 @@ -115,7 +126,7 @@ class BaseReport: ) @property - def capstderr(self): + def capstderr(self) -> str: """Return captured text from stderr, if capturing is enabled .. versionadded:: 3.0 @@ -133,7 +144,7 @@ class BaseReport: return self.nodeid.split("::")[0] @property - def count_towards_summary(self): + def count_towards_summary(self) -> bool: """ **Experimental** @@ -148,7 +159,7 @@ class BaseReport: return True @property - def head_line(self): + def head_line(self) -> Optional[str]: """ **Experimental** @@ -168,8 +179,9 @@ class BaseReport: if self.location is not None: fspath, lineno, domain = self.location return domain + return None - def _get_verbose_word(self, config): + def _get_verbose_word(self, config: Config): _category, _short, verbose = config.hook.pytest_report_teststatus( report=self, config=config ) @@ -187,7 +199,7 @@ class BaseReport: return _report_to_json(self) @classmethod - def _from_json(cls, reportdict): + def _from_json(cls: "Type[_R]", reportdict) -> _R: """ This was originally the serialize_report() function from xdist (ca03269). @@ -200,7 +212,9 @@ class BaseReport: return cls(**kwargs) -def _report_unserialization_failure(type_name, report_class, reportdict): +def _report_unserialization_failure( + type_name: str, report_class: "Type[BaseReport]", reportdict +) -> "NoReturn": url = "https://github.com/pytest-dev/pytest/issues" stream = StringIO() pprint("-" * 100, stream=stream) @@ -221,15 +235,15 @@ class TestReport(BaseReport): def __init__( self, - nodeid, + nodeid: str, location: Tuple[str, Optional[int], str], keywords, - outcome, + outcome: "Literal['passed', 'failed', 'skipped']", longrepr, - when, - sections=(), - duration=0, - user_properties=None, + when: "Literal['setup', 'call', 'teardown']", + sections: Iterable[Tuple[str, str]] = (), + duration: float = 0, + user_properties: Optional[Iterable[Tuple[str, object]]] = None, **extra ) -> None: #: normalized collection node id @@ -268,23 +282,25 @@ class TestReport(BaseReport): self.__dict__.update(extra) - def __repr__(self): + def __repr__(self) -> str: return "<{} {!r} when={!r} outcome={!r}>".format( self.__class__.__name__, self.nodeid, self.when, self.outcome ) @classmethod - def from_item_and_call(cls, item: Item, call: "CallInfo") -> "TestReport": + def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport": """ Factory method to create and fill a TestReport with standard item and call info. """ when = call.when + # Remove "collect" from the Literal type -- only for collection calls. + assert when != "collect" duration = call.duration keywords = {x: 1 for x in item.keywords} excinfo = call.excinfo sections = [] if not call.excinfo: - outcome = "passed" + outcome = "passed" # type: Literal["passed", "failed", "skipped"] # TODO: Improve this Any. longrepr = None # type: Optional[Any] else: @@ -324,10 +340,10 @@ class CollectReport(BaseReport): def __init__( self, nodeid: str, - outcome, + outcome: "Literal['passed', 'skipped', 'failed']", longrepr, result: Optional[List[Union[Item, Collector]]], - sections=(), + sections: Iterable[Tuple[str, str]] = (), **extra ) -> None: self.nodeid = nodeid @@ -341,28 +357,29 @@ class CollectReport(BaseReport): def location(self): return (self.fspath, None, self.fspath) - def __repr__(self): + def __repr__(self) -> str: return "".format( self.nodeid, len(self.result), self.outcome ) class CollectErrorRepr(TerminalRepr): - def __init__(self, msg): + def __init__(self, msg) -> None: self.longrepr = msg def toterminal(self, out) -> None: out.line(self.longrepr, red=True) -def pytest_report_to_serializable(report): +def pytest_report_to_serializable(report: BaseReport): if isinstance(report, (TestReport, CollectReport)): data = report._to_json() data["$report_type"] = report.__class__.__name__ return data + return None -def pytest_report_from_serializable(data): +def pytest_report_from_serializable(data) -> Optional[BaseReport]: if "$report_type" in data: if data["$report_type"] == "TestReport": return TestReport._from_json(data) @@ -371,9 +388,10 @@ def pytest_report_from_serializable(data): assert False, "Unknown report_type unserialize data: {}".format( data["$report_type"] ) + return None -def _report_to_json(report): +def _report_to_json(report: BaseReport): """ This was originally the serialize_report() function from xdist (ca03269). @@ -381,11 +399,12 @@ def _report_to_json(report): serialization. """ - def serialize_repr_entry(entry): - entry_data = {"type": type(entry).__name__, "data": attr.asdict(entry)} - for key, value in entry_data["data"].items(): + def serialize_repr_entry(entry: Union[ReprEntry, ReprEntryNative]): + data = attr.asdict(entry) + for key, value in data.items(): if hasattr(value, "__dict__"): - entry_data["data"][key] = attr.asdict(value) + data[key] = attr.asdict(value) + entry_data = {"type": type(entry).__name__, "data": data} return entry_data def serialize_repr_traceback(reprtraceback: ReprTraceback): diff --git a/src/_pytest/resultlog.py b/src/_pytest/resultlog.py index 720ea9f49..c2b0cf556 100644 --- a/src/_pytest/resultlog.py +++ b/src/_pytest/resultlog.py @@ -7,6 +7,7 @@ import py from _pytest.config import Config from _pytest.config.argparsing import Parser +from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.store import StoreKey @@ -87,7 +88,7 @@ class ResultLog: longrepr = str(report.longrepr) self.log_outcome(report, code, longrepr) - def pytest_collectreport(self, report): + def pytest_collectreport(self, report: CollectReport) -> None: if not report.passed: if report.failed: code = "F" @@ -95,7 +96,7 @@ class ResultLog: else: assert report.skipped code = "S" - longrepr = "%s:%d: %s" % report.longrepr + longrepr = "%s:%d: %s" % report.longrepr # type: ignore self.log_outcome(report, code, longrepr) def pytest_internalerror(self, excrepr): diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 568065d94..f89b67399 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -3,10 +3,14 @@ import bdb import os import sys from typing import Callable +from typing import cast from typing import Dict +from typing import Generic from typing import List from typing import Optional from typing import Tuple +from typing import TypeVar +from typing import Union import attr @@ -179,7 +183,7 @@ def _update_current_test_var( os.environ.pop(var_name) -def pytest_report_teststatus(report): +def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: if report.when in ("setup", "teardown"): if report.failed: # category, shortletter, verbose-word @@ -188,6 +192,7 @@ def pytest_report_teststatus(report): return "skipped", "s", "SKIPPED" else: return "", "", "" + return None # @@ -217,9 +222,9 @@ def check_interactive_exception(call: "CallInfo", report: BaseReport) -> bool: def call_runtest_hook( item: Item, when: "Literal['setup', 'call', 'teardown']", **kwds -) -> "CallInfo": +) -> "CallInfo[None]": if when == "setup": - ihook = item.ihook.pytest_runtest_setup + ihook = item.ihook.pytest_runtest_setup # type: Callable[..., None] elif when == "call": ihook = item.ihook.pytest_runtest_call elif when == "teardown": @@ -234,11 +239,14 @@ def call_runtest_hook( ) +_T = TypeVar("_T") + + @attr.s(repr=False) -class CallInfo: +class CallInfo(Generic[_T]): """ Result/Exception info a function invocation. - :param result: The return value of the call, if it didn't raise. Can only be accessed + :param T result: The return value of the call, if it didn't raise. Can only be accessed if excinfo is None. :param Optional[ExceptionInfo] excinfo: The captured exception of the call, if it raised. :param float start: The system time when the call started, in seconds since the epoch. @@ -247,28 +255,34 @@ class CallInfo: :param str when: The context of invocation: "setup", "call", "teardown", ... """ - _result = attr.ib() + _result = attr.ib(type="Optional[_T]") excinfo = attr.ib(type=Optional[ExceptionInfo]) start = attr.ib(type=float) stop = attr.ib(type=float) duration = attr.ib(type=float) - when = attr.ib(type=str) + when = attr.ib(type="Literal['collect', 'setup', 'call', 'teardown']") @property - def result(self): + def result(self) -> _T: if self.excinfo is not None: raise AttributeError("{!r} has no valid result".format(self)) - return self._result + # The cast is safe because an exception wasn't raised, hence + # _result has the expected function return type (which may be + # None, that's why a cast and not an assert). + return cast(_T, self._result) @classmethod - def from_call(cls, func, when, reraise=None) -> "CallInfo": - #: context of invocation: one of "setup", "call", - #: "teardown", "memocollect" + def from_call( + cls, + func: "Callable[[], _T]", + when: "Literal['collect', 'setup', 'call', 'teardown']", + reraise: "Optional[Union[Type[BaseException], Tuple[Type[BaseException], ...]]]" = None, + ) -> "CallInfo[_T]": excinfo = None start = timing.time() precise_start = timing.perf_counter() try: - result = func() + result = func() # type: Optional[_T] except BaseException: excinfo = ExceptionInfo.from_current() if reraise is not None and excinfo.errisinstance(reraise): @@ -293,7 +307,7 @@ class CallInfo: return "".format(self.when, self.excinfo) -def pytest_runtest_makereport(item: Item, call: CallInfo) -> TestReport: +def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> TestReport: return TestReport.from_item_and_call(item, call) @@ -301,7 +315,7 @@ def pytest_make_collect_report(collector: Collector) -> CollectReport: call = CallInfo.from_call(lambda: list(collector.collect()), "collect") longrepr = None if not call.excinfo: - outcome = "passed" + outcome = "passed" # type: Literal["passed", "skipped", "failed"] else: skip_exceptions = [Skipped] unittest = sys.modules.get("unittest") @@ -321,9 +335,8 @@ def pytest_make_collect_report(collector: Collector) -> CollectReport: if not hasattr(errorinfo, "toterminal"): errorinfo = CollectErrorRepr(errorinfo) longrepr = errorinfo - rep = CollectReport( - collector.nodeid, outcome, longrepr, getattr(call, "result", None) - ) + result = call.result if not call.excinfo else None + rep = CollectReport(collector.nodeid, outcome, longrepr, result) rep.call = call # type: ignore # see collect_one_node return rep diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 5994b5b2f..54621f111 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -1,4 +1,7 @@ """ support for skip/xfail functions and markers. """ +from typing import Optional +from typing import Tuple + from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser @@ -8,6 +11,7 @@ from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail from _pytest.python import Function +from _pytest.reports import BaseReport from _pytest.runner import CallInfo from _pytest.store import StoreKey @@ -129,7 +133,7 @@ def check_strict_xfail(pyfuncitem: Function) -> None: @hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item: Item, call: CallInfo): +def pytest_runtest_makereport(item: Item, call: CallInfo[None]): outcome = yield rep = outcome.get_result() evalxfail = item._store.get(evalxfail_key, None) @@ -181,9 +185,10 @@ def pytest_runtest_makereport(item: Item, call: CallInfo): # called by terminalreporter progress reporting -def pytest_report_teststatus(report): +def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: if hasattr(report, "wasxfail"): if report.skipped: return "xfailed", "x", "XFAIL" elif report.passed: return "xpassed", "X", "XPASS" + return None diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index bc2b5bf23..1b9601a22 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -37,6 +37,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser from _pytest.deprecated import TERMINALWRITER_WRITER +from _pytest.reports import BaseReport from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -218,14 +219,14 @@ def getreportopt(config: Config) -> str: @pytest.hookimpl(trylast=True) # after _pytest.runner -def pytest_report_teststatus(report: TestReport) -> Tuple[str, str, str]: +def pytest_report_teststatus(report: BaseReport) -> Tuple[str, str, str]: letter = "F" if report.passed: letter = "." elif report.skipped: letter = "s" - outcome = report.outcome + outcome = report.outcome # type: str if report.when in ("collect", "setup", "teardown") and outcome == "failed": outcome = "error" letter = "E" @@ -364,7 +365,7 @@ class TerminalReporter: self._tw.write(extra, **kwargs) self.currentfspath = -2 - def ensure_newline(self): + def ensure_newline(self) -> None: if self.currentfspath: self._tw.line() self.currentfspath = None @@ -375,7 +376,7 @@ class TerminalReporter: def flush(self) -> None: self._tw.flush() - def write_line(self, line, **markup): + def write_line(self, line: Union[str, bytes], **markup) -> None: if not isinstance(line, str): line = str(line, errors="replace") self.ensure_newline() @@ -642,12 +643,12 @@ class TerminalReporter: ) self._write_report_lines_from_hooks(lines) - def _write_report_lines_from_hooks(self, lines): + def _write_report_lines_from_hooks(self, lines) -> None: lines.reverse() for line in collapse(lines): self.write_line(line) - def pytest_report_header(self, config): + def pytest_report_header(self, config: Config) -> List[str]: line = "rootdir: %s" % config.rootdir if config.inifile: @@ -664,7 +665,7 @@ class TerminalReporter: result.append("plugins: %s" % ", ".join(_plugin_nameversions(plugininfo))) return result - def pytest_collection_finish(self, session): + def pytest_collection_finish(self, session: "Session") -> None: self.report_collect(True) lines = self.config.hook.pytest_report_collectionfinish( diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 3fbf7c88d..f9eb6e719 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -255,7 +255,7 @@ class TestCaseFunction(Function): @hookimpl(tryfirst=True) -def pytest_runtest_makereport(item: Item, call: CallInfo) -> None: +def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None: if isinstance(item, TestCaseFunction): if item._excinfo: call.excinfo = item._excinfo.pop(0) @@ -272,9 +272,10 @@ def pytest_runtest_makereport(item: Item, call: CallInfo) -> None: unittest.SkipTest # type: ignore[attr-defined] # noqa: F821 ) ): + excinfo = call.excinfo # let's substitute the excinfo with a pytest.skip one - call2 = CallInfo.from_call( - lambda: pytest.skip(str(call.excinfo.value)), call.when + call2 = CallInfo[None].from_call( + lambda: pytest.skip(str(excinfo.value)), call.when ) call.excinfo = call2.excinfo diff --git a/testing/test_runner.py b/testing/test_runner.py index be79b14fd..9c19ded0e 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -465,27 +465,27 @@ def test_report_extra_parameters(reporttype: "Type[reports.BaseReport]") -> None def test_callinfo() -> None: - ci = runner.CallInfo.from_call(lambda: 0, "123") - assert ci.when == "123" + ci = runner.CallInfo.from_call(lambda: 0, "collect") + assert ci.when == "collect" assert ci.result == 0 assert "result" in repr(ci) - assert repr(ci) == "" - assert str(ci) == "" + assert repr(ci) == "" + assert str(ci) == "" - ci = runner.CallInfo.from_call(lambda: 0 / 0, "123") - assert ci.when == "123" - assert not hasattr(ci, "result") - assert repr(ci) == "".format(ci.excinfo) - assert str(ci) == repr(ci) - assert ci.excinfo + ci2 = runner.CallInfo.from_call(lambda: 0 / 0, "collect") + assert ci2.when == "collect" + assert not hasattr(ci2, "result") + assert repr(ci2) == "".format(ci2.excinfo) + assert str(ci2) == repr(ci2) + assert ci2.excinfo # Newlines are escaped. def raise_assertion(): assert 0, "assert_msg" - ci = runner.CallInfo.from_call(raise_assertion, "call") - assert repr(ci) == "".format(ci.excinfo) - assert "\n" not in repr(ci) + ci3 = runner.CallInfo.from_call(raise_assertion, "call") + assert repr(ci3) == "".format(ci3.excinfo) + assert "\n" not in repr(ci3) # design question: do we want general hooks in python files? From db5292868478e5b1a47f23a7686c5995cabb3bf2 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 037/140] Type annotate _pytest.logging --- src/_pytest/logging.py | 100 ++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 41 deletions(-) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 92046ed51..ce3a18f03 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -11,18 +11,24 @@ from typing import Generator from typing import List from typing import Mapping from typing import Optional +from typing import Tuple +from typing import TypeVar from typing import Union import pytest from _pytest import nodes +from _pytest._io import TerminalWriter +from _pytest.capture import CaptureManager from _pytest.compat import nullcontext from _pytest.config import _strtobool from _pytest.config import Config from _pytest.config import create_terminal_writer from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureRequest from _pytest.main import Session from _pytest.pathlib import Path from _pytest.store import StoreKey +from _pytest.terminal import TerminalReporter DEFAULT_LOG_FORMAT = "%(levelname)-8s %(name)s:%(filename)s:%(lineno)d %(message)s" @@ -32,7 +38,7 @@ catch_log_handler_key = StoreKey["LogCaptureHandler"]() catch_log_records_key = StoreKey[Dict[str, List[logging.LogRecord]]]() -def _remove_ansi_escape_sequences(text): +def _remove_ansi_escape_sequences(text: str) -> str: return _ANSI_ESCAPE_SEQ.sub("", text) @@ -52,7 +58,7 @@ class ColoredLevelFormatter(logging.Formatter): } # type: Mapping[int, AbstractSet[str]] LEVELNAME_FMT_REGEX = re.compile(r"%\(levelname\)([+-.]?\d*s)") - def __init__(self, terminalwriter, *args, **kwargs) -> None: + def __init__(self, terminalwriter: TerminalWriter, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._original_fmt = self._style._fmt self._level_to_fmt_mapping = {} # type: Dict[int, str] @@ -77,7 +83,7 @@ class ColoredLevelFormatter(logging.Formatter): colorized_formatted_levelname, self._fmt ) - def format(self, record): + def format(self, record: logging.LogRecord) -> str: fmt = self._level_to_fmt_mapping.get(record.levelno, self._original_fmt) self._style._fmt = fmt return super().format(record) @@ -90,18 +96,20 @@ class PercentStyleMultiline(logging.PercentStyle): formats the message as if each line were logged separately. """ - def __init__(self, fmt, auto_indent): + def __init__(self, fmt: str, auto_indent: Union[int, str, bool]) -> None: super().__init__(fmt) self._auto_indent = self._get_auto_indent(auto_indent) @staticmethod - def _update_message(record_dict, message): + def _update_message( + record_dict: Dict[str, object], message: str + ) -> Dict[str, object]: tmp = record_dict.copy() tmp["message"] = message return tmp @staticmethod - def _get_auto_indent(auto_indent_option) -> int: + def _get_auto_indent(auto_indent_option: Union[int, str, bool]) -> int: """Determines the current auto indentation setting Specify auto indent behavior (on/off/fixed) by passing in @@ -149,11 +157,11 @@ class PercentStyleMultiline(logging.PercentStyle): return 0 - def format(self, record): + def format(self, record: logging.LogRecord) -> str: if "\n" in record.message: if hasattr(record, "auto_indent"): # passed in from the "extra={}" kwarg on the call to logging.log() - auto_indent = self._get_auto_indent(record.auto_indent) + auto_indent = self._get_auto_indent(record.auto_indent) # type: ignore[attr-defined] # noqa: F821 else: auto_indent = self._auto_indent @@ -173,7 +181,7 @@ class PercentStyleMultiline(logging.PercentStyle): return self._fmt % record.__dict__ -def get_option_ini(config, *names): +def get_option_ini(config: Config, *names: str): for name in names: ret = config.getoption(name) # 'default' arg won't work as expected if ret is None: @@ -268,13 +276,16 @@ def pytest_addoption(parser: Parser) -> None: ) +_HandlerType = TypeVar("_HandlerType", bound=logging.Handler) + + # Not using @contextmanager for performance reasons. class catching_logs: """Context manager that prepares the whole logging machinery properly.""" __slots__ = ("handler", "level", "orig_level") - def __init__(self, handler, level=None): + def __init__(self, handler: _HandlerType, level: Optional[int] = None) -> None: self.handler = handler self.level = level @@ -330,7 +341,7 @@ class LogCaptureFixture: """Creates a new funcarg.""" self._item = item # dict of log name -> log level - self._initial_log_levels = {} # type: Dict[str, int] + self._initial_log_levels = {} # type: Dict[Optional[str], int] def _finalize(self) -> None: """Finalizes the fixture. @@ -364,17 +375,17 @@ class LogCaptureFixture: return self._item._store[catch_log_records_key].get(when, []) @property - def text(self): + def text(self) -> str: """Returns the formatted log text.""" return _remove_ansi_escape_sequences(self.handler.stream.getvalue()) @property - def records(self): + def records(self) -> List[logging.LogRecord]: """Returns the list of log records.""" return self.handler.records @property - def record_tuples(self): + def record_tuples(self) -> List[Tuple[str, int, str]]: """Returns a list of a stripped down version of log records intended for use in assertion comparison. @@ -385,7 +396,7 @@ class LogCaptureFixture: return [(r.name, r.levelno, r.getMessage()) for r in self.records] @property - def messages(self): + def messages(self) -> List[str]: """Returns a list of format-interpolated log messages. Unlike 'records', which contains the format string and parameters for interpolation, log messages in this list @@ -400,11 +411,11 @@ class LogCaptureFixture: """ return [r.getMessage() for r in self.records] - def clear(self): + def clear(self) -> None: """Reset the list of log records and the captured log text.""" self.handler.reset() - def set_level(self, level, logger=None): + def set_level(self, level: Union[int, str], logger: Optional[str] = None) -> None: """Sets the level for capturing of logs. The level will be restored to its previous value at the end of the test. @@ -415,31 +426,32 @@ class LogCaptureFixture: The levels of the loggers changed by this function will be restored to their initial values at the end of the test. """ - logger_name = logger - logger = logging.getLogger(logger_name) + logger_obj = logging.getLogger(logger) # save the original log-level to restore it during teardown - self._initial_log_levels.setdefault(logger_name, logger.level) - logger.setLevel(level) + self._initial_log_levels.setdefault(logger, logger_obj.level) + logger_obj.setLevel(level) @contextmanager - def at_level(self, level, logger=None): + def at_level( + self, level: int, logger: Optional[str] = None + ) -> Generator[None, None, None]: """Context manager that sets the level for capturing of logs. After the end of the 'with' statement the level is restored to its original value. :param int level: the logger to level. :param str logger: the logger to update the level. If not given, the root logger level is updated. """ - logger = logging.getLogger(logger) - orig_level = logger.level - logger.setLevel(level) + logger_obj = logging.getLogger(logger) + orig_level = logger_obj.level + logger_obj.setLevel(level) try: yield finally: - logger.setLevel(orig_level) + logger_obj.setLevel(orig_level) @pytest.fixture -def caplog(request): +def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]: """Access and control log capturing. Captured logs are available through the following properties/methods:: @@ -557,7 +569,7 @@ class LoggingPlugin: return formatter - def set_log_path(self, fname): + def set_log_path(self, fname: str) -> None: """Public method, which can set filename parameter for Logging.FileHandler(). Also creates parent directory if it does not exist. @@ -565,15 +577,15 @@ class LoggingPlugin: .. warning:: Please considered as an experimental API. """ - fname = Path(fname) + fpath = Path(fname) - if not fname.is_absolute(): - fname = Path(self._config.rootdir, fname) + if not fpath.is_absolute(): + fpath = Path(self._config.rootdir, fpath) - if not fname.parent.exists(): - fname.parent.mkdir(exist_ok=True, parents=True) + if not fpath.parent.exists(): + fpath.parent.mkdir(exist_ok=True, parents=True) - stream = fname.open(mode="w", encoding="UTF-8") + stream = fpath.open(mode="w", encoding="UTF-8") if sys.version_info >= (3, 7): old_stream = self.log_file_handler.setStream(stream) else: @@ -715,29 +727,35 @@ class _LiveLoggingStreamHandler(logging.StreamHandler): and won't appear in the terminal. """ - def __init__(self, terminal_reporter, capture_manager): + # Officially stream needs to be a IO[str], but TerminalReporter + # isn't. So force it. + stream = None # type: TerminalReporter # type: ignore + + def __init__( + self, terminal_reporter: TerminalReporter, capture_manager: CaptureManager + ) -> None: """ :param _pytest.terminal.TerminalReporter terminal_reporter: :param _pytest.capture.CaptureManager capture_manager: """ - logging.StreamHandler.__init__(self, stream=terminal_reporter) + logging.StreamHandler.__init__(self, stream=terminal_reporter) # type: ignore[arg-type] # noqa: F821 self.capture_manager = capture_manager self.reset() self.set_when(None) self._test_outcome_written = False - def reset(self): + def reset(self) -> None: """Reset the handler; should be called before the start of each test""" self._first_record_emitted = False - def set_when(self, when): + def set_when(self, when: Optional[str]) -> None: """Prepares for the given test phase (setup/call/teardown)""" self._when = when self._section_name_shown = False if when == "start": self._test_outcome_written = False - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: ctx_manager = ( self.capture_manager.global_and_fixture_disabled() if self.capture_manager @@ -764,10 +782,10 @@ class _LiveLoggingStreamHandler(logging.StreamHandler): class _LiveLoggingNullHandler(logging.NullHandler): """A handler used when live logging is disabled.""" - def reset(self): + def reset(self) -> None: pass - def set_when(self, when): + def set_when(self, when: str) -> None: pass def handleError(self, record: logging.LogRecord) -> None: From b51ea4f1a579b3ff3a8c122bc6218c7c109d8ecd Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 038/140] Type annotate _pytest.unittest --- src/_pytest/unittest.py | 86 ++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index f9eb6e719..a90b56c29 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -1,17 +1,23 @@ """ discovery and running of std-library "unittest" style tests. """ import sys import traceback +import types from typing import Any +from typing import Callable from typing import Generator from typing import Iterable +from typing import List from typing import Optional +from typing import Tuple from typing import Union import _pytest._code import pytest from _pytest.compat import getimfunc from _pytest.compat import is_async_function +from _pytest.compat import TYPE_CHECKING from _pytest.config import hookimpl +from _pytest.fixtures import FixtureRequest from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import exit @@ -25,6 +31,17 @@ from _pytest.runner import CallInfo from _pytest.skipping import skipped_by_mark_key from _pytest.skipping import unexpectedsuccess_key +if TYPE_CHECKING: + import unittest + from typing import Type + + from _pytest.fixtures import _Scope + + _SysExcInfoType = Union[ + Tuple[Type[BaseException], BaseException, types.TracebackType], + Tuple[None, None, None], + ] + def pytest_pycollect_makeitem( collector: PyCollector, name: str, obj @@ -78,30 +95,32 @@ class UnitTestCase(Class): if ut is None or runtest != ut.TestCase.runTest: # type: ignore yield TestCaseFunction.from_parent(self, name="runTest") - def _inject_setup_teardown_fixtures(self, cls): + def _inject_setup_teardown_fixtures(self, cls: type) -> None: """Injects a hidden auto-use fixture to invoke setUpClass/setup_method and corresponding teardown functions (#517)""" class_fixture = _make_xunit_fixture( cls, "setUpClass", "tearDownClass", scope="class", pass_self=False ) if class_fixture: - cls.__pytest_class_setup = class_fixture + cls.__pytest_class_setup = class_fixture # type: ignore[attr-defined] # noqa: F821 method_fixture = _make_xunit_fixture( cls, "setup_method", "teardown_method", scope="function", pass_self=True ) if method_fixture: - cls.__pytest_method_setup = method_fixture + cls.__pytest_method_setup = method_fixture # type: ignore[attr-defined] # noqa: F821 -def _make_xunit_fixture(obj, setup_name, teardown_name, scope, pass_self): +def _make_xunit_fixture( + obj: type, setup_name: str, teardown_name: str, scope: "_Scope", pass_self: bool +): setup = getattr(obj, setup_name, None) teardown = getattr(obj, teardown_name, None) if setup is None and teardown is None: return None @pytest.fixture(scope=scope, autouse=True) - def fixture(self, request): + def fixture(self, request: FixtureRequest) -> Generator[None, None, None]: if _is_skipped(self): reason = self.__unittest_skip_why__ pytest.skip(reason) @@ -122,32 +141,33 @@ def _make_xunit_fixture(obj, setup_name, teardown_name, scope, pass_self): class TestCaseFunction(Function): nofuncargs = True - _excinfo = None - _testcase = None + _excinfo = None # type: Optional[List[_pytest._code.ExceptionInfo]] + _testcase = None # type: Optional[unittest.TestCase] - def setup(self): + def setup(self) -> None: # a bound method to be called during teardown() if set (see 'runtest()') - self._explicit_tearDown = None - self._testcase = self.parent.obj(self.name) + self._explicit_tearDown = None # type: Optional[Callable[[], None]] + assert self.parent is not None + self._testcase = self.parent.obj(self.name) # type: ignore[attr-defined] # noqa: F821 self._obj = getattr(self._testcase, self.name) if hasattr(self, "_request"): self._request._fillfixtures() - def teardown(self): + def teardown(self) -> None: if self._explicit_tearDown is not None: self._explicit_tearDown() self._explicit_tearDown = None self._testcase = None self._obj = None - def startTest(self, testcase): + def startTest(self, testcase: "unittest.TestCase") -> None: pass - def _addexcinfo(self, rawexcinfo): + def _addexcinfo(self, rawexcinfo: "_SysExcInfoType") -> None: # unwrap potential exception info (see twisted trial support below) rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo) try: - excinfo = _pytest._code.ExceptionInfo(rawexcinfo) + excinfo = _pytest._code.ExceptionInfo(rawexcinfo) # type: ignore[arg-type] # noqa: F821 # invoke the attributes to trigger storing the traceback # trial causes some issue there excinfo.value @@ -176,7 +196,9 @@ class TestCaseFunction(Function): excinfo = _pytest._code.ExceptionInfo.from_current() self.__dict__.setdefault("_excinfo", []).append(excinfo) - def addError(self, testcase, rawexcinfo): + def addError( + self, testcase: "unittest.TestCase", rawexcinfo: "_SysExcInfoType" + ) -> None: try: if isinstance(rawexcinfo[1], exit.Exception): exit(rawexcinfo[1].msg) @@ -184,29 +206,38 @@ class TestCaseFunction(Function): pass self._addexcinfo(rawexcinfo) - def addFailure(self, testcase, rawexcinfo): + def addFailure( + self, testcase: "unittest.TestCase", rawexcinfo: "_SysExcInfoType" + ) -> None: self._addexcinfo(rawexcinfo) - def addSkip(self, testcase, reason): + def addSkip(self, testcase: "unittest.TestCase", reason: str) -> None: try: skip(reason) except skip.Exception: self._store[skipped_by_mark_key] = True self._addexcinfo(sys.exc_info()) - def addExpectedFailure(self, testcase, rawexcinfo, reason=""): + def addExpectedFailure( + self, + testcase: "unittest.TestCase", + rawexcinfo: "_SysExcInfoType", + reason: str = "", + ) -> None: try: xfail(str(reason)) except xfail.Exception: self._addexcinfo(sys.exc_info()) - def addUnexpectedSuccess(self, testcase, reason=""): + def addUnexpectedSuccess( + self, testcase: "unittest.TestCase", reason: str = "" + ) -> None: self._store[unexpectedsuccess_key] = reason - def addSuccess(self, testcase): + def addSuccess(self, testcase: "unittest.TestCase") -> None: pass - def stopTest(self, testcase): + def stopTest(self, testcase: "unittest.TestCase") -> None: pass def _expecting_failure(self, test_method) -> bool: @@ -218,14 +249,17 @@ class TestCaseFunction(Function): expecting_failure_class = getattr(self, "__unittest_expecting_failure__", False) return bool(expecting_failure_class or expecting_failure_method) - def runtest(self): + def runtest(self) -> None: from _pytest.debugging import maybe_wrap_pytest_function_for_tracing + assert self._testcase is not None + maybe_wrap_pytest_function_for_tracing(self) # let the unittest framework handle async functions if is_async_function(self.obj): - self._testcase(self) + # Type ignored because self acts as the TestResult, but is not actually one. + self._testcase(result=self) # type: ignore[arg-type] # noqa: F821 else: # when --pdb is given, we want to postpone calling tearDown() otherwise # when entering the pdb prompt, tearDown() would have probably cleaned up @@ -241,11 +275,11 @@ class TestCaseFunction(Function): # wrap_pytest_function_for_tracing replaces self.obj by a wrapper setattr(self._testcase, self.name, self.obj) try: - self._testcase(result=self) + self._testcase(result=self) # type: ignore[arg-type] # noqa: F821 finally: delattr(self._testcase, self.name) - def _prunetraceback(self, excinfo): + def _prunetraceback(self, excinfo: _pytest._code.ExceptionInfo) -> None: Function._prunetraceback(self, excinfo) traceback = excinfo.traceback.filter( lambda x: not x.frame.f_globals.get("__unittest") @@ -313,7 +347,7 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: yield -def check_testcase_implements_trial_reporter(done=[]): +def check_testcase_implements_trial_reporter(done: List[int] = []) -> None: if done: return from zope.interface import classImplements From 3e351afeb3443bb6c4940d80d8517693df71b397 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 039/140] Type annotate _pytest.capture --- src/_pytest/capture.py | 112 ++++++++++++++++++++++------------------- 1 file changed, 59 insertions(+), 53 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 13931ca10..bcc16ceb6 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -9,9 +9,11 @@ import os import sys from io import UnsupportedOperation from tempfile import TemporaryFile +from typing import Generator from typing import Optional from typing import TextIO from typing import Tuple +from typing import Union import pytest from _pytest.compat import TYPE_CHECKING @@ -46,7 +48,7 @@ def pytest_addoption(parser: Parser) -> None: ) -def _colorama_workaround(): +def _colorama_workaround() -> None: """ Ensure colorama is imported so that it attaches to the correct stdio handles on Windows. @@ -62,7 +64,7 @@ def _colorama_workaround(): pass -def _readline_workaround(): +def _readline_workaround() -> None: """ Ensure readline is imported so that it attaches to the correct stdio handles on Windows. @@ -87,7 +89,7 @@ def _readline_workaround(): pass -def _py36_windowsconsoleio_workaround(stream): +def _py36_windowsconsoleio_workaround(stream: TextIO) -> None: """ Python 3.6 implemented unicode console handling for Windows. This works by reading/writing to the raw console handle using @@ -202,7 +204,7 @@ class TeeCaptureIO(CaptureIO): self._other = other super().__init__() - def write(self, s) -> int: + def write(self, s: str) -> int: super().write(s) return self._other.write(s) @@ -222,13 +224,13 @@ class DontReadFromInput: def __iter__(self): return self - def fileno(self): + def fileno(self) -> int: raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()") - def isatty(self): + def isatty(self) -> bool: return False - def close(self): + def close(self) -> None: pass @property @@ -251,7 +253,7 @@ class SysCaptureBinary: EMPTY_BUFFER = b"" - def __init__(self, fd, tmpfile=None, *, tee=False): + def __init__(self, fd: int, tmpfile=None, *, tee: bool = False) -> None: name = patchsysdict[fd] self._old = getattr(sys, name) self.name = name @@ -288,7 +290,7 @@ class SysCaptureBinary: op, self._state, ", ".join(states) ) - def start(self): + def start(self) -> None: self._assert_state("start", ("initialized",)) setattr(sys, self.name, self.tmpfile) self._state = "started" @@ -301,7 +303,7 @@ class SysCaptureBinary: self.tmpfile.truncate() return res - def done(self): + def done(self) -> None: self._assert_state("done", ("initialized", "started", "suspended", "done")) if self._state == "done": return @@ -310,19 +312,19 @@ class SysCaptureBinary: self.tmpfile.close() self._state = "done" - def suspend(self): + def suspend(self) -> None: self._assert_state("suspend", ("started", "suspended")) setattr(sys, self.name, self._old) self._state = "suspended" - def resume(self): + def resume(self) -> None: self._assert_state("resume", ("started", "suspended")) if self._state == "started": return setattr(sys, self.name, self.tmpfile) self._state = "started" - def writeorg(self, data): + def writeorg(self, data) -> None: self._assert_state("writeorg", ("started", "suspended")) self._old.flush() self._old.buffer.write(data) @@ -352,7 +354,7 @@ class FDCaptureBinary: EMPTY_BUFFER = b"" - def __init__(self, targetfd): + def __init__(self, targetfd: int) -> None: self.targetfd = targetfd try: @@ -369,7 +371,9 @@ class FDCaptureBinary: # Further complications are the need to support suspend() and the # possibility of FD reuse (e.g. the tmpfile getting the very same # target FD). The following approach is robust, I believe. - self.targetfd_invalid = os.open(os.devnull, os.O_RDWR) + self.targetfd_invalid = os.open( + os.devnull, os.O_RDWR + ) # type: Optional[int] os.dup2(self.targetfd_invalid, targetfd) else: self.targetfd_invalid = None @@ -380,7 +384,8 @@ class FDCaptureBinary: self.syscapture = SysCapture(targetfd) else: self.tmpfile = EncodedFile( - TemporaryFile(buffering=0), + # TODO: Remove type ignore, fixed in next mypy release. + TemporaryFile(buffering=0), # type: ignore[arg-type] encoding="utf-8", errors="replace", write_through=True, @@ -392,7 +397,7 @@ class FDCaptureBinary: self._state = "initialized" - def __repr__(self): + def __repr__(self) -> str: return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format( self.__class__.__name__, self.targetfd, @@ -408,7 +413,7 @@ class FDCaptureBinary: op, self._state, ", ".join(states) ) - def start(self): + def start(self) -> None: """ Start capturing on targetfd using memorized tmpfile. """ self._assert_state("start", ("initialized",)) os.dup2(self.tmpfile.fileno(), self.targetfd) @@ -423,7 +428,7 @@ class FDCaptureBinary: self.tmpfile.truncate() return res - def done(self): + def done(self) -> None: """ stop capturing, restore streams, return original capture file, seeked to position zero. """ self._assert_state("done", ("initialized", "started", "suspended", "done")) @@ -439,7 +444,7 @@ class FDCaptureBinary: self.tmpfile.close() self._state = "done" - def suspend(self): + def suspend(self) -> None: self._assert_state("suspend", ("started", "suspended")) if self._state == "suspended": return @@ -447,7 +452,7 @@ class FDCaptureBinary: os.dup2(self.targetfd_save, self.targetfd) self._state = "suspended" - def resume(self): + def resume(self) -> None: self._assert_state("resume", ("started", "suspended")) if self._state == "started": return @@ -497,12 +502,12 @@ class MultiCapture: self.out = out self.err = err - def __repr__(self): + def __repr__(self) -> str: return "".format( self.out, self.err, self.in_, self._state, self._in_suspended, ) - def start_capturing(self): + def start_capturing(self) -> None: self._state = "started" if self.in_: self.in_.start() @@ -520,7 +525,7 @@ class MultiCapture: self.err.writeorg(err) return out, err - def suspend_capturing(self, in_=False): + def suspend_capturing(self, in_: bool = False) -> None: self._state = "suspended" if self.out: self.out.suspend() @@ -530,7 +535,7 @@ class MultiCapture: self.in_.suspend() self._in_suspended = True - def resume_capturing(self): + def resume_capturing(self) -> None: self._state = "resumed" if self.out: self.out.resume() @@ -540,7 +545,7 @@ class MultiCapture: self.in_.resume() self._in_suspended = False - def stop_capturing(self): + def stop_capturing(self) -> None: """ stop capturing and reset capturing streams """ if self._state == "stopped": raise ValueError("was already stopped") @@ -596,15 +601,15 @@ class CaptureManager: def __init__(self, method: "_CaptureMethod") -> None: self._method = method - self._global_capturing = None + self._global_capturing = None # type: Optional[MultiCapture] self._capture_fixture = None # type: Optional[CaptureFixture] - def __repr__(self): + def __repr__(self) -> str: return "".format( self._method, self._global_capturing, self._capture_fixture ) - def is_capturing(self): + def is_capturing(self) -> Union[str, bool]: if self.is_globally_capturing(): return "global" if self._capture_fixture: @@ -613,40 +618,41 @@ class CaptureManager: # Global capturing control - def is_globally_capturing(self): + def is_globally_capturing(self) -> bool: return self._method != "no" - def start_global_capturing(self): + def start_global_capturing(self) -> None: assert self._global_capturing is None self._global_capturing = _get_multicapture(self._method) self._global_capturing.start_capturing() - def stop_global_capturing(self): + def stop_global_capturing(self) -> None: if self._global_capturing is not None: self._global_capturing.pop_outerr_to_orig() self._global_capturing.stop_capturing() self._global_capturing = None - def resume_global_capture(self): + def resume_global_capture(self) -> None: # During teardown of the python process, and on rare occasions, capture # attributes can be `None` while trying to resume global capture. if self._global_capturing is not None: self._global_capturing.resume_capturing() - def suspend_global_capture(self, in_=False): + def suspend_global_capture(self, in_: bool = False) -> None: if self._global_capturing is not None: self._global_capturing.suspend_capturing(in_=in_) - def suspend(self, in_=False): + def suspend(self, in_: bool = False) -> None: # Need to undo local capsys-et-al if it exists before disabling global capture. self.suspend_fixture() self.suspend_global_capture(in_) - def resume(self): + def resume(self) -> None: self.resume_global_capture() self.resume_fixture() def read_global_capture(self): + assert self._global_capturing is not None return self._global_capturing.readouterr() # Fixture Control @@ -665,30 +671,30 @@ class CaptureManager: def unset_fixture(self) -> None: self._capture_fixture = None - def activate_fixture(self): + def activate_fixture(self) -> None: """If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over the global capture. """ if self._capture_fixture: self._capture_fixture._start() - def deactivate_fixture(self): + def deactivate_fixture(self) -> None: """Deactivates the ``capsys`` or ``capfd`` fixture of this item, if any.""" if self._capture_fixture: self._capture_fixture.close() - def suspend_fixture(self): + def suspend_fixture(self) -> None: if self._capture_fixture: self._capture_fixture._suspend() - def resume_fixture(self): + def resume_fixture(self) -> None: if self._capture_fixture: self._capture_fixture._resume() # Helper context managers @contextlib.contextmanager - def global_and_fixture_disabled(self): + def global_and_fixture_disabled(self) -> Generator[None, None, None]: """Context manager to temporarily disable global and current fixture capturing.""" self.suspend() try: @@ -697,7 +703,7 @@ class CaptureManager: self.resume() @contextlib.contextmanager - def item_capture(self, when, item): + def item_capture(self, when: str, item: Item) -> Generator[None, None, None]: self.resume_global_capture() self.activate_fixture() try: @@ -757,21 +763,21 @@ class CaptureFixture: fixtures. """ - def __init__(self, captureclass, request): + def __init__(self, captureclass, request: SubRequest) -> None: self.captureclass = captureclass self.request = request - self._capture = None + self._capture = None # type: Optional[MultiCapture] self._captured_out = self.captureclass.EMPTY_BUFFER self._captured_err = self.captureclass.EMPTY_BUFFER - def _start(self): + def _start(self) -> None: if self._capture is None: self._capture = MultiCapture( in_=None, out=self.captureclass(1), err=self.captureclass(2), ) self._capture.start_capturing() - def close(self): + def close(self) -> None: if self._capture is not None: out, err = self._capture.pop_outerr_to_orig() self._captured_out += out @@ -793,18 +799,18 @@ class CaptureFixture: self._captured_err = self.captureclass.EMPTY_BUFFER return CaptureResult(captured_out, captured_err) - def _suspend(self): + def _suspend(self) -> None: """Suspends this fixture's own capturing temporarily.""" if self._capture is not None: self._capture.suspend_capturing() - def _resume(self): + def _resume(self) -> None: """Resumes this fixture's own capturing temporarily.""" if self._capture is not None: self._capture.resume_capturing() @contextlib.contextmanager - def disabled(self): + def disabled(self) -> Generator[None, None, None]: """Temporarily disables capture while inside the 'with' block.""" capmanager = self.request.config.pluginmanager.getplugin("capturemanager") with capmanager.global_and_fixture_disabled(): @@ -815,7 +821,7 @@ class CaptureFixture: @pytest.fixture -def capsys(request): +def capsys(request: SubRequest): """Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsys.readouterr()`` method @@ -832,7 +838,7 @@ def capsys(request): @pytest.fixture -def capsysbinary(request): +def capsysbinary(request: SubRequest): """Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsysbinary.readouterr()`` @@ -849,7 +855,7 @@ def capsysbinary(request): @pytest.fixture -def capfd(request): +def capfd(request: SubRequest): """Enable text capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method @@ -866,7 +872,7 @@ def capfd(request): @pytest.fixture -def capfdbinary(request): +def capfdbinary(request: SubRequest): """Enable bytes capturing of writes to file descriptors ``1`` and ``2``. The captured output is made available via ``capfd.readouterr()`` method From 216a010ab70ca88f8d68f051c2b732fc90380e70 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 040/140] Type annotate _pytest.junitxml --- src/_pytest/junitxml.py | 147 +++++++++++++++++++++++----------------- src/_pytest/nodes.py | 3 +- 2 files changed, 84 insertions(+), 66 deletions(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 0ecfb09bb..47ba89d38 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -14,6 +14,11 @@ import platform import re import sys from datetime import datetime +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union import py @@ -21,14 +26,19 @@ import pytest from _pytest import deprecated from _pytest import nodes from _pytest import timing +from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import filename_arg from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureRequest from _pytest.reports import TestReport from _pytest.store import StoreKey from _pytest.terminal import TerminalReporter from _pytest.warnings import _issue_warning_captured +if TYPE_CHECKING: + from typing import Type + xml_key = StoreKey["LogXML"]() @@ -58,8 +68,8 @@ del _legal_xml_re _py_ext_re = re.compile(r"\.py$") -def bin_xml_escape(arg): - def repl(matchobj): +def bin_xml_escape(arg: str) -> py.xml.raw: + def repl(matchobj: "re.Match[str]") -> str: i = ord(matchobj.group()) if i <= 0xFF: return "#x%02X" % i @@ -69,7 +79,7 @@ def bin_xml_escape(arg): return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg))) -def merge_family(left, right): +def merge_family(left, right) -> None: result = {} for kl, vl in left.items(): for kr, vr in right.items(): @@ -92,28 +102,27 @@ families["xunit2"] = families["_base"] class _NodeReporter: - def __init__(self, nodeid, xml): + def __init__(self, nodeid: Union[str, TestReport], xml: "LogXML") -> None: self.id = nodeid self.xml = xml self.add_stats = self.xml.add_stats self.family = self.xml.family self.duration = 0 - self.properties = [] - self.nodes = [] - self.testcase = None - self.attrs = {} + self.properties = [] # type: List[Tuple[str, py.xml.raw]] + self.nodes = [] # type: List[py.xml.Tag] + self.attrs = {} # type: Dict[str, Union[str, py.xml.raw]] - def append(self, node): + def append(self, node: py.xml.Tag) -> None: self.xml.add_stats(type(node).__name__) self.nodes.append(node) - def add_property(self, name, value): + def add_property(self, name: str, value: str) -> None: self.properties.append((str(name), bin_xml_escape(value))) - def add_attribute(self, name, value): + def add_attribute(self, name: str, value: str) -> None: self.attrs[str(name)] = bin_xml_escape(value) - def make_properties_node(self): + def make_properties_node(self) -> Union[py.xml.Tag, str]: """Return a Junit node containing custom properties, if any. """ if self.properties: @@ -125,8 +134,7 @@ class _NodeReporter: ) return "" - def record_testreport(self, testreport): - assert not self.testcase + def record_testreport(self, testreport: TestReport) -> None: names = mangle_test_address(testreport.nodeid) existing_attrs = self.attrs classnames = names[:-1] @@ -136,9 +144,9 @@ class _NodeReporter: "classname": ".".join(classnames), "name": bin_xml_escape(names[-1]), "file": testreport.location[0], - } + } # type: Dict[str, Union[str, py.xml.raw]] if testreport.location[1] is not None: - attrs["line"] = testreport.location[1] + attrs["line"] = str(testreport.location[1]) if hasattr(testreport, "url"): attrs["url"] = testreport.url self.attrs = attrs @@ -156,19 +164,19 @@ class _NodeReporter: temp_attrs[key] = self.attrs[key] self.attrs = temp_attrs - def to_xml(self): + def to_xml(self) -> py.xml.Tag: testcase = Junit.testcase(time="%.3f" % self.duration, **self.attrs) testcase.append(self.make_properties_node()) for node in self.nodes: testcase.append(node) return testcase - def _add_simple(self, kind, message, data=None): + def _add_simple(self, kind: "Type[py.xml.Tag]", message: str, data=None) -> None: data = bin_xml_escape(data) node = kind(data, message=message) self.append(node) - def write_captured_output(self, report): + def write_captured_output(self, report: TestReport) -> None: if not self.xml.log_passing_tests and report.passed: return @@ -191,21 +199,22 @@ class _NodeReporter: if content_all: self._write_content(report, content_all, "system-out") - def _prepare_content(self, content, header): + def _prepare_content(self, content: str, header: str) -> str: return "\n".join([header.center(80, "-"), content, ""]) - def _write_content(self, report, content, jheader): + def _write_content(self, report: TestReport, content: str, jheader: str) -> None: tag = getattr(Junit, jheader) self.append(tag(bin_xml_escape(content))) - def append_pass(self, report): + def append_pass(self, report: TestReport) -> None: self.add_stats("passed") - def append_failure(self, report): + def append_failure(self, report: TestReport) -> None: # msg = str(report.longrepr.reprtraceback.extraline) if hasattr(report, "wasxfail"): self._add_simple(Junit.skipped, "xfail-marked test passes unexpectedly") else: + assert report.longrepr is not None if getattr(report.longrepr, "reprcrash", None) is not None: message = report.longrepr.reprcrash.message else: @@ -215,23 +224,24 @@ class _NodeReporter: fail.append(bin_xml_escape(report.longrepr)) self.append(fail) - def append_collect_error(self, report): + def append_collect_error(self, report: TestReport) -> None: # msg = str(report.longrepr.reprtraceback.extraline) + assert report.longrepr is not None self.append( Junit.error(bin_xml_escape(report.longrepr), message="collection failure") ) - def append_collect_skipped(self, report): + def append_collect_skipped(self, report: TestReport) -> None: self._add_simple(Junit.skipped, "collection skipped", report.longrepr) - def append_error(self, report): + def append_error(self, report: TestReport) -> None: if report.when == "teardown": msg = "test teardown failure" else: msg = "test setup failure" self._add_simple(Junit.error, msg, report.longrepr) - def append_skipped(self, report): + def append_skipped(self, report: TestReport) -> None: if hasattr(report, "wasxfail"): xfailreason = report.wasxfail if xfailreason.startswith("reason: "): @@ -242,6 +252,7 @@ class _NodeReporter: ) ) else: + assert report.longrepr is not None filename, lineno, skipreason = report.longrepr if skipreason.startswith("Skipped: "): skipreason = skipreason[9:] @@ -256,13 +267,17 @@ class _NodeReporter: ) self.write_captured_output(report) - def finalize(self): + def finalize(self) -> None: data = self.to_xml().unicode(indent=0) self.__dict__.clear() - self.to_xml = lambda: py.xml.raw(data) + # Type ignored becuase mypy doesn't like overriding a method. + # Also the return value doesn't match... + self.to_xml = lambda: py.xml.raw(data) # type: ignore # noqa: F821 -def _warn_incompatibility_with_xunit2(request, fixture_name): +def _warn_incompatibility_with_xunit2( + request: FixtureRequest, fixture_name: str +) -> None: """Emits a PytestWarning about the given fixture being incompatible with newer xunit revisions""" from _pytest.warning_types import PytestWarning @@ -278,7 +293,7 @@ def _warn_incompatibility_with_xunit2(request, fixture_name): @pytest.fixture -def record_property(request): +def record_property(request: FixtureRequest): """Add an extra properties the calling test. User properties become part of the test report and are available to the configured reporters, like JUnit XML. @@ -292,14 +307,14 @@ def record_property(request): """ _warn_incompatibility_with_xunit2(request, "record_property") - def append_property(name, value): + def append_property(name: str, value: object) -> None: request.node.user_properties.append((name, value)) return append_property @pytest.fixture -def record_xml_attribute(request): +def record_xml_attribute(request: FixtureRequest): """Add extra xml attributes to the tag for the calling test. The fixture is callable with ``(name, value)``, with value being automatically xml-encoded @@ -313,7 +328,7 @@ def record_xml_attribute(request): _warn_incompatibility_with_xunit2(request, "record_xml_attribute") # Declare noop - def add_attr_noop(name, value): + def add_attr_noop(name: str, value: str) -> None: pass attr_func = add_attr_noop @@ -326,7 +341,7 @@ def record_xml_attribute(request): return attr_func -def _check_record_param_type(param, v): +def _check_record_param_type(param: str, v: str) -> None: """Used by record_testsuite_property to check that the given parameter name is of the proper type""" __tracebackhide__ = True @@ -336,7 +351,7 @@ def _check_record_param_type(param, v): @pytest.fixture(scope="session") -def record_testsuite_property(request): +def record_testsuite_property(request: FixtureRequest): """ Records a new ```` tag as child of the root ````. This is suitable to writing global information regarding the entire test suite, and is compatible with ``xunit2`` JUnit family. @@ -354,7 +369,7 @@ def record_testsuite_property(request): __tracebackhide__ = True - def record_func(name, value): + def record_func(name: str, value: str): """noop function in case --junitxml was not passed in the command-line""" __tracebackhide__ = True _check_record_param_type("name", name) @@ -437,7 +452,7 @@ def pytest_unconfigure(config: Config) -> None: config.pluginmanager.unregister(xml) -def mangle_test_address(address): +def mangle_test_address(address: str) -> List[str]: path, possible_open_bracket, params = address.partition("[") names = path.split("::") try: @@ -456,13 +471,13 @@ class LogXML: def __init__( self, logfile, - prefix, - suite_name="pytest", - logging="no", - report_duration="total", + prefix: Optional[str], + suite_name: str = "pytest", + logging: str = "no", + report_duration: str = "total", family="xunit1", - log_passing_tests=True, - ): + log_passing_tests: bool = True, + ) -> None: logfile = os.path.expanduser(os.path.expandvars(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile)) self.prefix = prefix @@ -471,20 +486,24 @@ class LogXML: self.log_passing_tests = log_passing_tests self.report_duration = report_duration self.family = family - self.stats = dict.fromkeys(["error", "passed", "failure", "skipped"], 0) - self.node_reporters = {} # nodeid -> _NodeReporter - self.node_reporters_ordered = [] - self.global_properties = [] + self.stats = dict.fromkeys( + ["error", "passed", "failure", "skipped"], 0 + ) # type: Dict[str, int] + self.node_reporters = ( + {} + ) # type: Dict[Tuple[Union[str, TestReport], object], _NodeReporter] + self.node_reporters_ordered = [] # type: List[_NodeReporter] + self.global_properties = [] # type: List[Tuple[str, py.xml.raw]] # List of reports that failed on call but teardown is pending. - self.open_reports = [] + self.open_reports = [] # type: List[TestReport] self.cnt_double_fail_tests = 0 # Replaces convenience family with real family if self.family == "legacy": self.family = "xunit1" - def finalize(self, report): + def finalize(self, report: TestReport) -> None: nodeid = getattr(report, "nodeid", report) # local hack to handle xdist report order slavenode = getattr(report, "node", None) @@ -492,8 +511,8 @@ class LogXML: if reporter is not None: reporter.finalize() - def node_reporter(self, report): - nodeid = getattr(report, "nodeid", report) + def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter: + nodeid = getattr(report, "nodeid", report) # type: Union[str, TestReport] # local hack to handle xdist report order slavenode = getattr(report, "node", None) @@ -510,11 +529,11 @@ class LogXML: return reporter - def add_stats(self, key): + def add_stats(self, key: str) -> None: if key in self.stats: self.stats[key] += 1 - def _opentestcase(self, report): + def _opentestcase(self, report: TestReport) -> _NodeReporter: reporter = self.node_reporter(report) reporter.record_testreport(report) return reporter @@ -587,7 +606,7 @@ class LogXML: reporter.write_captured_output(report) for propname, propvalue in report.user_properties: - reporter.add_property(propname, propvalue) + reporter.add_property(propname, str(propvalue)) self.finalize(report) report_wid = getattr(report, "worker_id", None) @@ -607,7 +626,7 @@ class LogXML: if close_report: self.open_reports.remove(close_report) - def update_testcase_duration(self, report): + def update_testcase_duration(self, report: TestReport) -> None: """accumulates total duration for nodeid from given report and updates the Junit.testcase with the new total if already created. """ @@ -615,7 +634,7 @@ class LogXML: reporter = self.node_reporter(report) reporter.duration += getattr(report, "duration", 0.0) - def pytest_collectreport(self, report): + def pytest_collectreport(self, report: TestReport) -> None: if not report.passed: reporter = self._opentestcase(report) if report.failed: @@ -623,7 +642,7 @@ class LogXML: else: reporter.append_collect_skipped(report) - def pytest_internalerror(self, excrepr): + def pytest_internalerror(self, excrepr) -> None: reporter = self.node_reporter("internal") reporter.attrs.update(classname="pytest", name="internal") reporter._add_simple(Junit.error, "internal error", excrepr) @@ -652,10 +671,10 @@ class LogXML: self._get_global_properties_node(), [x.to_xml() for x in self.node_reporters_ordered], name=self.suite_name, - errors=self.stats["error"], - failures=self.stats["failure"], - skipped=self.stats["skipped"], - tests=numtests, + errors=str(self.stats["error"]), + failures=str(self.stats["failure"]), + skipped=str(self.stats["skipped"]), + tests=str(numtests), time="%.3f" % suite_time_delta, timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(), hostname=platform.node(), @@ -666,12 +685,12 @@ class LogXML: def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None: terminalreporter.write_sep("-", "generated xml file: {}".format(self.logfile)) - def add_global_property(self, name, value): + def add_global_property(self, name: str, value: str) -> None: __tracebackhide__ = True _check_record_param_type("name", name) self.global_properties.append((name, bin_xml_escape(value))) - def _get_global_properties_node(self): + def _get_global_properties_node(self) -> Union[py.xml.Tag, str]: """Return a Junit node containing custom properties, if any. """ if self.global_properties: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 4fdf1df74..eaa48e5de 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -1,7 +1,6 @@ import os import warnings from functools import lru_cache -from typing import Any from typing import Callable from typing import Dict from typing import Iterable @@ -618,7 +617,7 @@ class Item(Node): #: user properties is a list of tuples (name, value) that holds user #: defined properties for this test. - self.user_properties = [] # type: List[Tuple[str, Any]] + self.user_properties = [] # type: List[Tuple[str, object]] def runtest(self) -> None: raise NotImplementedError("runtest must be implemented by Item subclass") From 01797e6370a8a3858ee7e3a914021f7dee8318f2 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 041/140] Type annotate _pytest.debugging (a bit) --- .pre-commit-config.yaml | 2 +- src/_pytest/debugging.py | 26 ++++++++++++++++---------- src/_pytest/faulthandler.py | 2 +- src/_pytest/hookspec.py | 6 ++++-- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f379968e..dc3717204 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: args: [--remove] - id: check-yaml - id: debug-statements - exclude: _pytest/debugging.py + exclude: _pytest/(debugging|hookspec).py language_version: python3 - repo: https://gitlab.com/pycqa/flake8 rev: 3.8.2 diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 423b20ce3..3001db4ec 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -2,6 +2,9 @@ import argparse import functools import sys +from typing import Generator +from typing import Tuple +from typing import Union from _pytest import outcomes from _pytest.compat import TYPE_CHECKING @@ -15,10 +18,11 @@ from _pytest.nodes import Node from _pytest.reports import BaseReport if TYPE_CHECKING: + from _pytest.capture import CaptureManager from _pytest.runner import CallInfo -def _validate_usepdb_cls(value): +def _validate_usepdb_cls(value: str) -> Tuple[str, str]: """Validate syntax of --pdbcls option.""" try: modname, classname = value.split(":") @@ -70,7 +74,7 @@ def pytest_configure(config: Config) -> None: # NOTE: not using pytest_unconfigure, since it might get called although # pytest_configure was not (if another plugin raises UsageError). - def fin(): + def fin() -> None: ( pdb.set_trace, pytestPDB._pluginmanager, @@ -90,13 +94,13 @@ class pytestPDB: _wrapped_pdb_cls = None @classmethod - def _is_capturing(cls, capman): + def _is_capturing(cls, capman: "CaptureManager") -> Union[str, bool]: if capman: return capman.is_capturing() return False @classmethod - def _import_pdb_cls(cls, capman): + def _import_pdb_cls(cls, capman: "CaptureManager"): if not cls._config: import pdb @@ -135,10 +139,12 @@ class pytestPDB: return wrapped_cls @classmethod - def _get_pdb_wrapper_class(cls, pdb_cls, capman): + def _get_pdb_wrapper_class(cls, pdb_cls, capman: "CaptureManager"): import _pytest.config - class PytestPdbWrapper(pdb_cls): + # Type ignored because mypy doesn't support "dynamic" + # inheritance like this. + class PytestPdbWrapper(pdb_cls): # type: ignore[valid-type,misc] # noqa: F821 _pytest_capman = capman _continued = False @@ -257,7 +263,7 @@ class pytestPDB: return _pdb @classmethod - def set_trace(cls, *args, **kwargs): + def set_trace(cls, *args, **kwargs) -> None: """Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing.""" frame = sys._getframe().f_back _pdb = cls._init_pdb("set_trace", *args, **kwargs) @@ -276,14 +282,14 @@ class PdbInvoke: sys.stdout.write(err) _enter_pdb(node, call.excinfo, report) - def pytest_internalerror(self, excrepr, excinfo): + def pytest_internalerror(self, excrepr, excinfo) -> None: tb = _postmortem_traceback(excinfo) post_mortem(tb) class PdbTrace: @hookimpl(hookwrapper=True) - def pytest_pyfunc_call(self, pyfuncitem): + def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]: wrap_pytest_function_for_tracing(pyfuncitem) yield @@ -358,7 +364,7 @@ def _postmortem_traceback(excinfo): return excinfo._excinfo[2] -def post_mortem(t): +def post_mortem(t) -> None: p = pytestPDB._init_pdb("post_mortem") p.reset() p.interaction(None, t) diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index 79936b78f..0d969840b 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -99,7 +99,7 @@ class FaultHandlerHooks: yield @pytest.hookimpl(tryfirst=True) - def pytest_enter_pdb(self): + def pytest_enter_pdb(self) -> None: """Cancel any traceback dumping due to timeout before entering pdb. """ import faulthandler diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 99f646bd6..bcc38f472 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -15,7 +15,9 @@ from .deprecated import WARNING_CAPTURED_HOOK from _pytest.compat import TYPE_CHECKING if TYPE_CHECKING: + import pdb import warnings + from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PytestPluginManager @@ -773,7 +775,7 @@ def pytest_exception_interact( """ -def pytest_enter_pdb(config: "Config", pdb): +def pytest_enter_pdb(config: "Config", pdb: "pdb.Pdb") -> None: """ called upon pdb.set_trace(), can be used by plugins to take special action just before the python debugger enters in interactive mode. @@ -782,7 +784,7 @@ def pytest_enter_pdb(config: "Config", pdb): """ -def pytest_leave_pdb(config: "Config", pdb): +def pytest_leave_pdb(config: "Config", pdb: "pdb.Pdb") -> None: """ called when leaving pdb (e.g. with continue after pdb.set_trace()). Can be used by plugins to take special action just after the python From f8bb61ae5b87f8a432ae77f1ffa207046682e23f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 042/140] Type annotate _pytest.warnings --- src/_pytest/hookspec.py | 7 ++++--- src/_pytest/warnings.py | 21 +++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index bcc38f472..18a9fb39a 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -17,6 +17,7 @@ from _pytest.compat import TYPE_CHECKING if TYPE_CHECKING: import pdb import warnings + from typing_extensions import Literal from _pytest.config import Config from _pytest.config import ExitCode @@ -675,8 +676,8 @@ def pytest_terminal_summary( @hookspec(historic=True, warn_on_impl=WARNING_CAPTURED_HOOK) def pytest_warning_captured( warning_message: "warnings.WarningMessage", - when: str, - item, + when: "Literal['config', 'collect', 'runtest']", + item: "Optional[Item]", location: Optional[Tuple[str, int, str]], ) -> None: """(**Deprecated**) Process a warning captured by the internal pytest warnings plugin. @@ -710,7 +711,7 @@ def pytest_warning_captured( @hookspec(historic=True) def pytest_warning_recorded( warning_message: "warnings.WarningMessage", - when: str, + when: "Literal['config', 'collect', 'runtest']", nodeid: str, location: Optional[Tuple[str, int, str]], ) -> None: diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 622cbb806..5cedba244 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -4,6 +4,7 @@ import warnings from contextlib import contextmanager from functools import lru_cache from typing import Generator +from typing import Optional from typing import Tuple import pytest @@ -15,7 +16,8 @@ from _pytest.nodes import Item from _pytest.terminal import TerminalReporter if TYPE_CHECKING: - from typing_extensions import Type + from typing import Type + from typing_extensions import Literal @lru_cache(maxsize=50) @@ -79,7 +81,12 @@ def pytest_configure(config: Config) -> None: @contextmanager -def catch_warnings_for_item(config, ihook, when, item): +def catch_warnings_for_item( + config: Config, + ihook, + when: "Literal['config', 'collect', 'runtest']", + item: Optional[Item], +) -> Generator[None, None, None]: """ Context manager that catches warnings generated in the contained execution block. @@ -133,11 +140,11 @@ def catch_warnings_for_item(config, ihook, when, item): ) -def warning_record_to_str(warning_message): +def warning_record_to_str(warning_message: warnings.WarningMessage) -> str: """Convert a warnings.WarningMessage to a string.""" warn_msg = warning_message.message msg = warnings.formatwarning( - warn_msg, + str(warn_msg), warning_message.category, warning_message.filename, warning_message.lineno, @@ -175,7 +182,7 @@ def pytest_terminal_summary( @pytest.hookimpl(hookwrapper=True) -def pytest_sessionfinish(session): +def pytest_sessionfinish(session: Session) -> Generator[None, None, None]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None @@ -183,7 +190,7 @@ def pytest_sessionfinish(session): yield -def _issue_warning_captured(warning, hook, stacklevel): +def _issue_warning_captured(warning: Warning, hook, stacklevel: int) -> None: """ This function should be used instead of calling ``warnings.warn`` directly when we are in the "configure" stage: at this point the actual options might not have been set, so we manually trigger the pytest_warning_recorded @@ -196,8 +203,6 @@ def _issue_warning_captured(warning, hook, stacklevel): with warnings.catch_warnings(record=True) as records: warnings.simplefilter("always", type(warning)) 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( From 1bd7d025d9fbd48673e02d9a570431280f494c1e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 043/140] Type annotate more of _pytest.fixtures --- src/_pytest/fixtures.py | 124 ++++++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 42 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 1a9178743..7b87fc456 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -57,8 +57,9 @@ if TYPE_CHECKING: from _pytest import nodes from _pytest.main import Session - from _pytest.python import Metafunc from _pytest.python import CallSpec2 + from _pytest.python import Function + from _pytest.python import Metafunc _Scope = Literal["session", "package", "module", "class", "function"] @@ -189,29 +190,32 @@ def add_funcarg_pseudo_fixture_def( arg2fixturedefs[argname] = [node._name2pseudofixturedef[argname]] else: fixturedef = FixtureDef( - fixturemanager, - "", - argname, - get_direct_param_fixture_func, - arg2scope[argname], - valuelist, - False, - False, + fixturemanager=fixturemanager, + baseid="", + argname=argname, + func=get_direct_param_fixture_func, + scope=arg2scope[argname], + params=valuelist, + unittest=False, + ids=None, ) arg2fixturedefs[argname] = [fixturedef] if node is not None: node._name2pseudofixturedef[argname] = fixturedef -def getfixturemarker(obj): +def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: """ return fixturemarker or None if it doesn't exist or raised exceptions.""" try: - return getattr(obj, "_pytestfixturefunction", None) + fixturemarker = getattr( + obj, "_pytestfixturefunction", None + ) # type: Optional[FixtureFunctionMarker] except TEST_OUTCOME: # some objects raise errors like request (from flask import request) # we don't expect them to be fixture functions return None + return fixturemarker # Parametrized fixture key, helper alias for code below. @@ -334,7 +338,7 @@ def reorder_items_atscope( return items_done -def fillfixtures(function) -> None: +def fillfixtures(function: "Function") -> None: """ fill missing funcargs for a test function. """ warnings.warn(FILLFUNCARGS, stacklevel=2) try: @@ -344,6 +348,7 @@ def fillfixtures(function) -> None: # with the oejskit plugin. It uses classes with funcargs # and we thus have to work a bit to allow this. fm = function.session._fixturemanager + assert function.parent is not None fi = fm.getfixtureinfo(function.parent, function.obj, None) function._fixtureinfo = fi request = function._request = FixtureRequest(function) @@ -866,7 +871,7 @@ def fail_fixturefunc(fixturefunc, msg: str) -> "NoReturn": fail(msg + ":\n\n" + str(source.indent()) + "\n" + location, pytrace=False) -def call_fixture_func(fixturefunc, request: FixtureRequest, kwargs) -> object: +def call_fixture_func(fixturefunc, request: FixtureRequest, kwargs): yieldctx = is_generator(fixturefunc) if yieldctx: generator = fixturefunc(**kwargs) @@ -896,9 +901,15 @@ def _teardown_yield_fixture(fixturefunc, it) -> None: ) -def _eval_scope_callable(scope_callable, fixture_name: str, config: Config) -> str: +def _eval_scope_callable( + scope_callable: "Callable[[str, Config], _Scope]", + fixture_name: str, + config: Config, +) -> "_Scope": try: - result = scope_callable(fixture_name=fixture_name, config=config) + # Type ignored because there is no typing mechanism to specify + # keyword arguments, currently. + result = scope_callable(fixture_name=fixture_name, config=config) # type: ignore[call-arg] # noqa: F821 except Exception: raise TypeError( "Error evaluating {} while defining fixture '{}'.\n" @@ -924,10 +935,15 @@ class FixtureDef: baseid, argname: str, func, - scope: str, + scope: "Union[_Scope, Callable[[str, Config], _Scope]]", params: Optional[Sequence[object]], unittest: bool = False, - ids=None, + ids: Optional[ + Union[ + Tuple[Union[None, str, float, int, bool], ...], + Callable[[object], Optional[object]], + ] + ] = None, ) -> None: self._fixturemanager = fixturemanager self.baseid = baseid or "" @@ -935,16 +951,15 @@ class FixtureDef: self.func = func self.argname = argname if callable(scope): - scope = _eval_scope_callable(scope, argname, fixturemanager.config) + scope_ = _eval_scope_callable(scope, argname, fixturemanager.config) + else: + scope_ = scope self.scopenum = scope2index( - scope or "function", + scope_ or "function", descr="Fixture '{}'".format(func.__name__), where=baseid, ) - # The cast is verified by scope2index. - # (Some of the type annotations below are supposed to be inferred, - # but mypy 0.761 has some trouble without them.) - self.scope = cast("_Scope", scope) # type: _Scope + self.scope = scope_ self.params = params # type: Optional[Sequence[object]] self.argnames = getfuncargnames( func, name=argname, is_method=unittest @@ -1068,9 +1083,21 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request: SubRequest) -> object: return result -def _ensure_immutable_ids(ids): +def _ensure_immutable_ids( + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[object], Optional[object]], + ] + ], +) -> Optional[ + Union[ + Tuple[Union[None, str, float, int, bool], ...], + Callable[[object], Optional[object]], + ] +]: if ids is None: - return + return None if callable(ids): return ids return tuple(ids) @@ -1102,10 +1129,16 @@ def wrap_function_to_error_out_if_called_directly(function, fixture_marker): class FixtureFunctionMarker: scope = attr.ib() params = attr.ib(converter=attr.converters.optional(tuple)) - autouse = attr.ib(default=False) - # Ignore type because of https://github.com/python/mypy/issues/6172. - ids = attr.ib(default=None, converter=_ensure_immutable_ids) # type: ignore - name = attr.ib(default=None) + autouse = attr.ib(type=bool, default=False) + ids = attr.ib( + type=Union[ + Tuple[Union[None, str, float, int, bool], ...], + Callable[[object], Optional[object]], + ], + default=None, + converter=_ensure_immutable_ids, + ) + name = attr.ib(type=Optional[str], default=None) def __call__(self, function): if inspect.isclass(function): @@ -1133,12 +1166,17 @@ class FixtureFunctionMarker: def fixture( fixture_function=None, - *args, - scope="function", + *args: Any, + scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = "function", params=None, - autouse=False, - ids=None, - name=None + autouse: bool = False, + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[object], Optional[object]], + ] + ] = None, + name: Optional[str] = None ): """Decorator to mark a fixture factory function. @@ -1343,7 +1381,7 @@ class FixtureManager: ] # type: List[Tuple[str, List[str]]] session.config.pluginmanager.register(self, "funcmanage") - def _get_direct_parametrize_args(self, node) -> List[str]: + def _get_direct_parametrize_args(self, node: "nodes.Node") -> List[str]: """This function returns all the direct parametrization arguments of a node, so we don't mistake them for fixtures @@ -1362,7 +1400,9 @@ class FixtureManager: return parametrize_argnames - def getfixtureinfo(self, node, func, cls, funcargs: bool = True) -> FuncFixtureInfo: + def getfixtureinfo( + self, node: "nodes.Node", func, cls, funcargs: bool = True + ) -> FuncFixtureInfo: if funcargs and not getattr(node, "nofuncargs", False): argnames = getfuncargnames(func, name=node.name, cls=cls) else: @@ -1526,12 +1566,12 @@ class FixtureManager: obj = get_real_method(obj, holderobj) fixture_def = FixtureDef( - self, - nodeid, - name, - obj, - marker.scope, - marker.params, + fixturemanager=self, + baseid=nodeid, + argname=name, + func=obj, + scope=marker.scope, + params=marker.params, unittest=unittest, ids=marker.ids, ) From 8bcf1d6de1ba6cb0810a58f9949f9a8db26ad1de Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 044/140] Remove duplicated conversion of pytest.fixture() params argument The FixtureFunctionMarker attrs class already converts the params itself. When adding types, the previous converter composition causes some type error, but extracting it to a standalone function fixes the issue (a lambda is not supported by the mypy plugin, currently). --- src/_pytest/fixtures.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 7b87fc456..b0049f8cc 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1103,6 +1103,12 @@ def _ensure_immutable_ids( return tuple(ids) +def _params_converter( + params: Optional[Iterable[object]], +) -> Optional[Tuple[object, ...]]: + return tuple(params) if params is not None else None + + def wrap_function_to_error_out_if_called_directly(function, fixture_marker): """Wrap the given fixture function so we can raise an error about it being called directly, instead of used as an argument in a test function. @@ -1127,8 +1133,8 @@ def wrap_function_to_error_out_if_called_directly(function, fixture_marker): @attr.s(frozen=True) class FixtureFunctionMarker: - scope = attr.ib() - params = attr.ib(converter=attr.converters.optional(tuple)) + scope = attr.ib(type="Union[_Scope, Callable[[str, Config], _Scope]]") + params = attr.ib(type=Optional[Tuple[object, ...]], converter=_params_converter) autouse = attr.ib(type=bool, default=False) ids = attr.ib( type=Union[ @@ -1168,7 +1174,7 @@ def fixture( fixture_function=None, *args: Any, scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = "function", - params=None, + params: Optional[Iterable[object]] = None, autouse: bool = False, ids: Optional[ Union[ @@ -1274,9 +1280,6 @@ def fixture( warnings.warn(FIXTURE_POSITIONAL_ARGUMENTS, stacklevel=2) # End backward compatiblity. - if params is not None: - params = list(params) - fixture_marker = FixtureFunctionMarker( scope=scope, params=params, autouse=autouse, ids=ids, name=name, ) From 28338846884687cf57c4add215a84c3c75085ac1 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 045/140] Type annotate pytest.fixture and more improvements to _pytest.fixtures --- src/_pytest/fixtures.py | 126 ++++++++++++++++++++++++++++--------- testing/python/fixtures.py | 15 +++-- 2 files changed, 105 insertions(+), 36 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index b0049f8cc..8aa5d73a8 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -10,6 +10,8 @@ from typing import Any from typing import Callable from typing import cast from typing import Dict +from typing import Generator +from typing import Generic from typing import Iterable from typing import Iterator from typing import List @@ -17,6 +19,7 @@ from typing import Optional from typing import Sequence from typing import Set from typing import Tuple +from typing import TypeVar from typing import Union import attr @@ -37,6 +40,7 @@ from _pytest.compat import getlocation from _pytest.compat import is_generator from _pytest.compat import NOTSET from _pytest.compat import order_preserving_dict +from _pytest.compat import overload from _pytest.compat import safe_getattr from _pytest.compat import TYPE_CHECKING from _pytest.config import _PluggyPlugin @@ -64,13 +68,30 @@ if TYPE_CHECKING: _Scope = Literal["session", "package", "module", "class", "function"] -_FixtureCachedResult = Tuple[ - # The result. - Optional[object], - # Cache key. - object, - # Exc info if raised. - Optional[Tuple["Type[BaseException]", BaseException, TracebackType]], +# The value of the fixture -- return/yield of the fixture function (type variable). +_FixtureValue = TypeVar("_FixtureValue") +# The type of the fixture function (type variable). +_FixtureFunction = TypeVar("_FixtureFunction", bound=Callable[..., object]) +# The type of a fixture function (type alias generic in fixture value). +_FixtureFunc = Union[ + Callable[..., _FixtureValue], Callable[..., Generator[_FixtureValue, None, None]] +] +# The type of FixtureDef.cached_result (type alias generic in fixture value). +_FixtureCachedResult = Union[ + Tuple[ + # The result. + _FixtureValue, + # Cache key. + object, + None, + ], + Tuple[ + None, + # Cache key. + object, + # Exc info if raised. + Tuple["Type[BaseException]", BaseException, TracebackType], + ], ] @@ -871,9 +892,13 @@ def fail_fixturefunc(fixturefunc, msg: str) -> "NoReturn": fail(msg + ":\n\n" + str(source.indent()) + "\n" + location, pytrace=False) -def call_fixture_func(fixturefunc, request: FixtureRequest, kwargs): - yieldctx = is_generator(fixturefunc) - if yieldctx: +def call_fixture_func( + fixturefunc: "_FixtureFunc[_FixtureValue]", request: FixtureRequest, kwargs +) -> _FixtureValue: + if is_generator(fixturefunc): + fixturefunc = cast( + Callable[..., Generator[_FixtureValue, None, None]], fixturefunc + ) generator = fixturefunc(**kwargs) try: fixture_result = next(generator) @@ -884,6 +909,7 @@ def call_fixture_func(fixturefunc, request: FixtureRequest, kwargs): finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator) request.addfinalizer(finalizer) else: + fixturefunc = cast(Callable[..., _FixtureValue], fixturefunc) fixture_result = fixturefunc(**kwargs) return fixture_result @@ -926,7 +952,7 @@ def _eval_scope_callable( return result -class FixtureDef: +class FixtureDef(Generic[_FixtureValue]): """ A container for a factory definition. """ def __init__( @@ -934,7 +960,7 @@ class FixtureDef: fixturemanager: "FixtureManager", baseid, argname: str, - func, + func: "_FixtureFunc[_FixtureValue]", scope: "Union[_Scope, Callable[[str, Config], _Scope]]", params: Optional[Sequence[object]], unittest: bool = False, @@ -966,7 +992,7 @@ class FixtureDef: ) # type: Tuple[str, ...] self.unittest = unittest self.ids = ids - self.cached_result = None # type: Optional[_FixtureCachedResult] + self.cached_result = None # type: Optional[_FixtureCachedResult[_FixtureValue]] self._finalizers = [] # type: List[Callable[[], object]] def addfinalizer(self, finalizer: Callable[[], object]) -> None: @@ -996,7 +1022,7 @@ class FixtureDef: self.cached_result = None self._finalizers = [] - def execute(self, request: SubRequest): + def execute(self, request: SubRequest) -> _FixtureValue: # get required arguments and register our own finish() # with their finalization for argname in self.argnames: @@ -1008,14 +1034,15 @@ class FixtureDef: my_cache_key = self.cache_key(request) 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) + cache_key = self.cached_result[1] if my_cache_key is cache_key: - if err is not None: - _, val, tb = err + if self.cached_result[2] is not None: + _, val, tb = self.cached_result[2] raise val.with_traceback(tb) else: + result = self.cached_result[0] return result # we have a previous but differently parametrized fixture instance # so we need to tear it down before creating a new one @@ -1023,7 +1050,8 @@ class FixtureDef: assert self.cached_result is None hook = self._fixturemanager.session.gethookproxy(request.node.fspath) - return hook.pytest_fixture_setup(fixturedef=self, request=request) + result = hook.pytest_fixture_setup(fixturedef=self, request=request) + return result def cache_key(self, request: SubRequest) -> object: return request.param_index if not hasattr(request, "param") else request.param @@ -1034,7 +1062,9 @@ class FixtureDef: ) -def resolve_fixture_function(fixturedef: FixtureDef, request: FixtureRequest): +def resolve_fixture_function( + fixturedef: FixtureDef[_FixtureValue], request: FixtureRequest +) -> "_FixtureFunc[_FixtureValue]": """Gets the actual callable that can be called to obtain the fixture value, dealing with unittest-specific instances and bound methods. """ @@ -1042,7 +1072,7 @@ def resolve_fixture_function(fixturedef: FixtureDef, request: FixtureRequest): if fixturedef.unittest: if request.instance is not None: # bind the unbound method to the TestCase instance - fixturefunc = fixturedef.func.__get__(request.instance) + fixturefunc = fixturedef.func.__get__(request.instance) # type: ignore[union-attr] # noqa: F821 else: # the fixture function needs to be bound to the actual # request.instance so that code working with "fixturedef" behaves @@ -1051,16 +1081,18 @@ def resolve_fixture_function(fixturedef: FixtureDef, request: FixtureRequest): # handle the case where fixture is defined not in a test class, but some other class # (for example a plugin class with a fixture), see #2270 if hasattr(fixturefunc, "__self__") and not isinstance( - request.instance, fixturefunc.__self__.__class__ + request.instance, fixturefunc.__self__.__class__ # type: ignore[union-attr] # noqa: F821 ): return fixturefunc fixturefunc = getimfunc(fixturedef.func) if fixturefunc != fixturedef.func: - fixturefunc = fixturefunc.__get__(request.instance) + fixturefunc = fixturefunc.__get__(request.instance) # type: ignore[union-attr] # noqa: F821 return fixturefunc -def pytest_fixture_setup(fixturedef: FixtureDef, request: SubRequest) -> object: +def pytest_fixture_setup( + fixturedef: FixtureDef[_FixtureValue], request: SubRequest +) -> _FixtureValue: """ Execution of fixture setup. """ kwargs = {} for argname in fixturedef.argnames: @@ -1146,7 +1178,7 @@ class FixtureFunctionMarker: ) name = attr.ib(type=Optional[str], default=None) - def __call__(self, function): + def __call__(self, function: _FixtureFunction) -> _FixtureFunction: if inspect.isclass(function): raise ValueError("class fixtures not supported (maybe in the future)") @@ -1166,12 +1198,50 @@ class FixtureFunctionMarker: ), pytrace=False, ) - function._pytestfixturefunction = self + + # Type ignored because https://github.com/python/mypy/issues/2087. + function._pytestfixturefunction = self # type: ignore[attr-defined] # noqa: F821 return function +@overload def fixture( - fixture_function=None, + fixture_function: _FixtureFunction, + *, + scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[object], Optional[object]], + ] + ] = ..., + name: Optional[str] = ... +) -> _FixtureFunction: + raise NotImplementedError() + + +@overload # noqa: F811 +def fixture( # noqa: F811 + fixture_function: None = ..., + *, + scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[object], Optional[object]], + ] + ] = ..., + name: Optional[str] = None +) -> FixtureFunctionMarker: + raise NotImplementedError() + + +def fixture( # noqa: F811 + fixture_function: Optional[_FixtureFunction] = None, *args: Any, scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = "function", params: Optional[Iterable[object]] = None, @@ -1183,7 +1253,7 @@ def fixture( ] ] = None, name: Optional[str] = None -): +) -> Union[FixtureFunctionMarker, _FixtureFunction]: """Decorator to mark a fixture factory function. This decorator can be used, with or without parameters, to define a @@ -1317,7 +1387,7 @@ def yield_fixture( @fixture(scope="session") -def pytestconfig(request: FixtureRequest): +def pytestconfig(request: FixtureRequest) -> Config: """Session-scoped fixture that returns the :class:`_pytest.config.Config` object. Example:: diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 7fc87e387..353ce46cd 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -3799,7 +3799,7 @@ class TestScopeOrdering: request = FixtureRequest(items[0]) assert request.fixturenames == "m1 f1".split() - def test_func_closure_with_native_fixtures(self, testdir, monkeypatch): + def test_func_closure_with_native_fixtures(self, testdir, monkeypatch) -> None: """Sanity check that verifies the order returned by the closures and the actual fixture execution order: The execution order may differ because of fixture inter-dependencies. """ @@ -3849,9 +3849,8 @@ class TestScopeOrdering: ) testdir.runpytest() # actual fixture execution differs: dependent fixtures must be created first ("my_tmpdir") - assert ( - pytest.FIXTURE_ORDER == "s1 my_tmpdir_factory p1 m1 my_tmpdir f1 f2".split() - ) + FIXTURE_ORDER = pytest.FIXTURE_ORDER # type: ignore[attr-defined] # noqa: F821 + assert FIXTURE_ORDER == "s1 my_tmpdir_factory p1 m1 my_tmpdir f1 f2".split() def test_func_closure_module(self, testdir): testdir.makepyfile( @@ -4159,7 +4158,7 @@ def test_fixture_duplicated_arguments() -> None: """Raise error if there are positional and keyword arguments for the same parameter (#1682).""" with pytest.raises(TypeError) as excinfo: - @pytest.fixture("session", scope="session") + @pytest.fixture("session", scope="session") # type: ignore[call-overload] # noqa: F821 def arg(arg): pass @@ -4171,7 +4170,7 @@ def test_fixture_duplicated_arguments() -> None: with pytest.raises(TypeError) as excinfo: - @pytest.fixture( + @pytest.fixture( # type: ignore[call-overload] # noqa: F821 "function", ["p1"], True, @@ -4199,7 +4198,7 @@ def test_fixture_with_positionals() -> None: with pytest.warns(pytest.PytestDeprecationWarning) as warnings: - @pytest.fixture("function", [0], True) + @pytest.fixture("function", [0], True) # type: ignore[call-overload] # noqa: F821 def fixture_with_positionals(): pass @@ -4213,7 +4212,7 @@ def test_fixture_with_positionals() -> None: def test_fixture_with_too_many_positionals() -> None: with pytest.raises(TypeError) as excinfo: - @pytest.fixture("function", [0], True, ["id"], "name", "extra") + @pytest.fixture("function", [0], True, ["id"], "name", "extra") # type: ignore[call-overload] # noqa: F821 def fixture_with_positionals(): pass From c0af19d8ad30840a8ef3c0aedd436816fc86ad3a Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 046/140] Type annotate more of _pytest.terminal --- src/_pytest/terminal.py | 185 +++++++++++++++++++++++++--------------- 1 file changed, 114 insertions(+), 71 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 1b9601a22..b37828e5a 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -16,7 +16,9 @@ from typing import Generator from typing import List from typing import Mapping from typing import Optional +from typing import Sequence from typing import Set +from typing import TextIO from typing import Tuple from typing import Union @@ -37,11 +39,15 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser from _pytest.deprecated import TERMINALWRITER_WRITER +from _pytest.nodes import Item +from _pytest.nodes import Node from _pytest.reports import BaseReport from _pytest.reports import CollectReport from _pytest.reports import TestReport if TYPE_CHECKING: + from typing_extensions import Literal + from _pytest.main import Session @@ -69,7 +75,14 @@ class MoreQuietAction(argparse.Action): used to unify verbosity handling """ - def __init__(self, option_strings, dest, default=None, required=False, help=None): + def __init__( + self, + option_strings: Sequence[str], + dest: str, + default: object = None, + required: bool = False, + help: Optional[str] = None, + ) -> None: super().__init__( option_strings=option_strings, dest=dest, @@ -79,7 +92,13 @@ class MoreQuietAction(argparse.Action): help=help, ) - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Union[str, Sequence[object], None], + option_string: Optional[str] = None, + ) -> None: new_count = getattr(namespace, self.dest, 0) - 1 setattr(namespace, self.dest, new_count) # todo Deprecate config.quiet @@ -194,7 +213,7 @@ def pytest_configure(config: Config) -> None: def getreportopt(config: Config) -> str: - reportchars = config.option.reportchars + reportchars = config.option.reportchars # type: str old_aliases = {"F", "S"} reportopts = "" @@ -247,10 +266,12 @@ class WarningReport: message = attr.ib(type=str) nodeid = attr.ib(type=Optional[str], default=None) - fslocation = attr.ib(default=None) + fslocation = attr.ib( + type=Optional[Union[Tuple[str, int], py.path.local]], default=None + ) count_towards_summary = True - def get_location(self, config): + def get_location(self, config: Config) -> Optional[str]: """ Returns the more user-friendly information about the location of a warning, or None. @@ -270,13 +291,13 @@ class WarningReport: class TerminalReporter: - def __init__(self, config: Config, file=None) -> None: + def __init__(self, config: Config, file: Optional[TextIO] = None) -> None: import _pytest.config self.config = config self._numcollected = 0 self._session = None # type: Optional[Session] - self._showfspath = None + self._showfspath = None # type: Optional[bool] self.stats = {} # type: Dict[str, List[Any]] self._main_color = None # type: Optional[str] @@ -293,6 +314,7 @@ class TerminalReporter: self._progress_nodeids_reported = set() # type: Set[str] self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = None # type: Optional[float] + self._already_displayed_warnings = None # type: Optional[int] @property def writer(self) -> TerminalWriter: @@ -300,11 +322,11 @@ class TerminalReporter: return self._tw @writer.setter - def writer(self, value: TerminalWriter): + def writer(self, value: TerminalWriter) -> None: warnings.warn(TERMINALWRITER_WRITER, stacklevel=2) self._tw = value - def _determine_show_progress_info(self): + def _determine_show_progress_info(self) -> "Literal['progress', 'count', False]": """Return True if we should display progress information based on the current config""" # do not show progress if we are not capturing output (#3038) if self.config.getoption("capture", "no") == "no": @@ -312,38 +334,42 @@ class TerminalReporter: # do not show progress if we are showing fixture setup/teardown if self.config.getoption("setupshow", False): return False - cfg = self.config.getini("console_output_style") - if cfg in ("progress", "count"): - return cfg - return False + cfg = self.config.getini("console_output_style") # type: str + if cfg == "progress": + return "progress" + elif cfg == "count": + return "count" + else: + return False @property - def verbosity(self): - return self.config.option.verbose + def verbosity(self) -> int: + verbosity = self.config.option.verbose # type: int + return verbosity @property - def showheader(self): + def showheader(self) -> bool: return self.verbosity >= 0 @property - def showfspath(self): + def showfspath(self) -> bool: if self._showfspath is None: return self.verbosity >= 0 return self._showfspath @showfspath.setter - def showfspath(self, value): + def showfspath(self, value: Optional[bool]) -> None: self._showfspath = value @property - def showlongtestinfo(self): + def showlongtestinfo(self) -> bool: return self.verbosity > 0 - def hasopt(self, char): + def hasopt(self, char: str) -> bool: char = {"xfailed": "x", "skipped": "s"}.get(char, char) return char in self.reportchars - def write_fspath_result(self, nodeid, res, **markup): + def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None: fspath = self.config.rootdir.join(nodeid.split("::")[0]) # NOTE: explicitly check for None to work around py bug, and for less # overhead in general (https://github.com/pytest-dev/py/pull/207). @@ -356,7 +382,7 @@ class TerminalReporter: self._tw.write(fspath + " ") self._tw.write(res, flush=True, **markup) - def write_ensure_prefix(self, prefix, extra="", **kwargs): + def write_ensure_prefix(self, prefix, extra: str = "", **kwargs) -> None: if self.currentfspath != prefix: self._tw.line() self.currentfspath = prefix @@ -376,13 +402,13 @@ class TerminalReporter: def flush(self) -> None: self._tw.flush() - def write_line(self, line: Union[str, bytes], **markup) -> None: + def write_line(self, line: Union[str, bytes], **markup: bool) -> None: if not isinstance(line, str): line = str(line, errors="replace") self.ensure_newline() self._tw.line(line, **markup) - def rewrite(self, line, **markup): + def rewrite(self, line: str, **markup: bool) -> None: """ Rewinds the terminal cursor to the beginning and writes the given line. @@ -400,14 +426,20 @@ class TerminalReporter: line = str(line) self._tw.write("\r" + line + fill, **markup) - def write_sep(self, sep, title=None, **markup): + def write_sep( + self, + sep: str, + title: Optional[str] = None, + fullwidth: Optional[int] = None, + **markup: bool + ) -> None: self.ensure_newline() - self._tw.sep(sep, title, **markup) + self._tw.sep(sep, title, fullwidth, **markup) - def section(self, title, sep="=", **kw): + def section(self, title: str, sep: str = "=", **kw: bool) -> None: self._tw.sep(sep, title, **kw) - def line(self, msg, **kw): + def line(self, msg: str, **kw: bool) -> None: self._tw.line(msg, **kw) def _add_stats(self, category: str, items: List) -> None: @@ -421,7 +453,9 @@ class TerminalReporter: self.write_line("INTERNALERROR> " + line) return 1 - def pytest_warning_recorded(self, warning_message, nodeid): + def pytest_warning_recorded( + self, warning_message: warnings.WarningMessage, nodeid: str, + ) -> None: from _pytest.warnings import warning_record_to_str fslocation = warning_message.filename, warning_message.lineno @@ -440,10 +474,10 @@ class TerminalReporter: # which garbles our output if we use self.write_line self.write_line(msg) - def pytest_deselected(self, items): + def pytest_deselected(self, items) -> None: self._add_stats("deselected", items) - def pytest_runtest_logstart(self, nodeid, location): + def pytest_runtest_logstart(self, nodeid, location) -> None: # ensure that the path is printed before the # 1st test of a module starts running if self.showlongtestinfo: @@ -457,7 +491,9 @@ class TerminalReporter: def pytest_runtest_logreport(self, report: TestReport) -> None: self._tests_ran = True rep = report - res = self.config.hook.pytest_report_teststatus(report=rep, config=self.config) + res = self.config.hook.pytest_report_teststatus( + report=rep, config=self.config + ) # type: Tuple[str, str, str] category, letter, word = res if isinstance(word, tuple): word, markup = word @@ -504,10 +540,11 @@ class TerminalReporter: self.flush() @property - def _is_last_item(self): + def _is_last_item(self) -> bool: + assert self._session is not None return len(self._progress_nodeids_reported) == self._session.testscollected - def pytest_runtest_logfinish(self, nodeid): + def pytest_runtest_logfinish(self, nodeid) -> None: assert self._session if self.verbosity <= 0 and self._show_progress_info: if self._show_progress_info == "count": @@ -545,7 +582,7 @@ class TerminalReporter: ) return " [100%]" - def _write_progress_information_filling_space(self): + def _write_progress_information_filling_space(self) -> None: color, _ = self._get_main_color() msg = self._get_progress_information_message() w = self._width_of_current_line @@ -553,7 +590,7 @@ class TerminalReporter: self.write(msg.rjust(fill), flush=True, **{color: True}) @property - def _width_of_current_line(self): + def _width_of_current_line(self) -> int: """Return the width of current line, using the superior implementation of py-1.6 when available""" return self._tw.width_of_current_line @@ -575,7 +612,7 @@ class TerminalReporter: if self.isatty: self.report_collect() - def report_collect(self, final=False): + def report_collect(self, final: bool = False) -> None: if self.config.option.verbose < 0: return @@ -643,7 +680,9 @@ class TerminalReporter: ) self._write_report_lines_from_hooks(lines) - def _write_report_lines_from_hooks(self, lines) -> None: + def _write_report_lines_from_hooks( + self, lines: List[Union[str, List[str]]] + ) -> None: lines.reverse() for line in collapse(lines): self.write_line(line) @@ -685,7 +724,7 @@ class TerminalReporter: for rep in failed: rep.toterminal(self._tw) - def _printcollecteditems(self, items): + def _printcollecteditems(self, items: Sequence[Item]) -> None: # to print out items and their parent collectors # we take care to leave out Instances aka () # because later versions are going to get rid of them anyway @@ -701,7 +740,7 @@ class TerminalReporter: for item in items: self._tw.line(item.nodeid) return - stack = [] + stack = [] # type: List[Node] indent = "" for item in items: needed_collectors = item.listchain()[1:] # strip root node @@ -716,11 +755,8 @@ class TerminalReporter: indent = (len(stack) - 1) * " " self._tw.line("{}{}".format(indent, col)) if self.config.option.verbose >= 1: - try: - obj = col.obj # type: ignore - except AttributeError: - continue - doc = inspect.getdoc(obj) + obj = getattr(col, "obj", None) + doc = inspect.getdoc(obj) if obj else None if doc: for line in doc.splitlines(): self._tw.line("{}{}".format(indent + " ", line)) @@ -744,12 +780,12 @@ class TerminalReporter: terminalreporter=self, exitstatus=exitstatus, config=self.config ) if session.shouldfail: - self.write_sep("!", session.shouldfail, red=True) + self.write_sep("!", str(session.shouldfail), red=True) if exitstatus == ExitCode.INTERRUPTED: self._report_keyboardinterrupt() del self._keyboardinterrupt_memo elif session.shouldstop: - self.write_sep("!", session.shouldstop, red=True) + self.write_sep("!", str(session.shouldstop), red=True) self.summary_stats() @pytest.hookimpl(hookwrapper=True) @@ -770,7 +806,7 @@ class TerminalReporter: if hasattr(self, "_keyboardinterrupt_memo"): self._report_keyboardinterrupt() - def _report_keyboardinterrupt(self): + def _report_keyboardinterrupt(self) -> None: excrepr = self._keyboardinterrupt_memo msg = excrepr.reprcrash.message self.write_sep("!", msg) @@ -824,14 +860,14 @@ class TerminalReporter: # # summaries for sessionfinish # - def getreports(self, name): + def getreports(self, name: str): values = [] for x in self.stats.get(name, []): if not hasattr(x, "_pdbshown"): values.append(x) return values - def summary_warnings(self): + def summary_warnings(self) -> None: if self.hasopt("w"): all_warnings = self.stats.get( "warnings" @@ -839,7 +875,7 @@ class TerminalReporter: if not all_warnings: return - final = hasattr(self, "_already_displayed_warnings") + final = self._already_displayed_warnings is not None if final: warning_reports = all_warnings[self._already_displayed_warnings :] else: @@ -854,7 +890,7 @@ class TerminalReporter: for wr in warning_reports: reports_grouped_by_message.setdefault(wr.message, []).append(wr) - def collapsed_location_report(reports: List[WarningReport]): + def collapsed_location_report(reports: List[WarningReport]) -> str: locations = [] for w in reports: location = w.get_location(self.config) @@ -888,10 +924,10 @@ class TerminalReporter: self._tw.line() self._tw.line("-- Docs: https://docs.pytest.org/en/latest/warnings.html") - def summary_passes(self): + def summary_passes(self) -> None: if self.config.option.tbstyle != "no": if self.hasopt("P"): - reports = self.getreports("passed") + reports = self.getreports("passed") # type: List[TestReport] if not reports: return self.write_sep("=", "PASSES") @@ -903,9 +939,10 @@ class TerminalReporter: self._handle_teardown_sections(rep.nodeid) def _get_teardown_reports(self, nodeid: str) -> List[TestReport]: + reports = self.getreports("") return [ report - for report in self.getreports("") + for report in reports if report.when == "teardown" and report.nodeid == nodeid ] @@ -926,9 +963,9 @@ class TerminalReporter: content = content[:-1] self._tw.line(content) - def summary_failures(self): + def summary_failures(self) -> None: if self.config.option.tbstyle != "no": - reports = self.getreports("failed") + reports = self.getreports("failed") # type: List[BaseReport] if not reports: return self.write_sep("=", "FAILURES") @@ -943,9 +980,9 @@ class TerminalReporter: self._outrep_summary(rep) self._handle_teardown_sections(rep.nodeid) - def summary_errors(self): + def summary_errors(self) -> None: if self.config.option.tbstyle != "no": - reports = self.getreports("error") + reports = self.getreports("error") # type: List[BaseReport] if not reports: return self.write_sep("=", "ERRORS") @@ -958,7 +995,7 @@ class TerminalReporter: self.write_sep("_", msg, red=True, bold=True) self._outrep_summary(rep) - def _outrep_summary(self, rep): + def _outrep_summary(self, rep: BaseReport) -> None: rep.toterminal(self._tw) showcapture = self.config.option.showcapture if showcapture == "no": @@ -971,7 +1008,7 @@ class TerminalReporter: content = content[:-1] self._tw.line(content) - def summary_stats(self): + def summary_stats(self) -> None: if self.verbosity < -1: return @@ -1041,7 +1078,7 @@ class TerminalReporter: lines.append("{} {} {}".format(verbose_word, pos, reason)) def show_skipped(lines: List[str]) -> None: - skipped = self.stats.get("skipped", []) + skipped = self.stats.get("skipped", []) # type: List[CollectReport] fskips = _folded_skips(self.startdir, skipped) if skipped else [] if not fskips: return @@ -1125,12 +1162,14 @@ class TerminalReporter: return parts, main_color -def _get_pos(config, rep): +def _get_pos(config: Config, rep: BaseReport): nodeid = config.cwd_relative_nodeid(rep.nodeid) return nodeid -def _get_line_with_reprcrash_message(config, rep, termwidth): +def _get_line_with_reprcrash_message( + config: Config, rep: BaseReport, termwidth: int +) -> str: """Get summary line for a report, trying to add reprcrash message.""" verbose_word = rep._get_verbose_word(config) pos = _get_pos(config, rep) @@ -1143,7 +1182,8 @@ def _get_line_with_reprcrash_message(config, rep, termwidth): return line try: - msg = rep.longrepr.reprcrash.message + # Type ignored intentionally -- possible AttributeError expected. + msg = rep.longrepr.reprcrash.message # type: ignore[union-attr] # noqa: F821 except AttributeError: pass else: @@ -1166,9 +1206,12 @@ def _get_line_with_reprcrash_message(config, rep, termwidth): return line -def _folded_skips(startdir, skipped): - d = {} +def _folded_skips( + startdir: py.path.local, skipped: Sequence[CollectReport], +) -> List[Tuple[int, str, Optional[int], str]]: + d = {} # type: Dict[Tuple[str, Optional[int], str], List[CollectReport]] for event in skipped: + assert event.longrepr is not None assert len(event.longrepr) == 3, (event, event.longrepr) fspath, lineno, reason = event.longrepr # For consistency, report all fspaths in relative form. @@ -1182,13 +1225,13 @@ def _folded_skips(startdir, skipped): and "skip" in keywords and "pytestmark" not in keywords ): - key = (fspath, None, reason) + key = (fspath, None, reason) # type: Tuple[str, Optional[int], str] else: key = (fspath, lineno, reason) d.setdefault(key, []).append(event) - values = [] + values = [] # type: List[Tuple[int, str, Optional[int], str]] for key, events in d.items(): - values.append((len(events),) + key) + values.append((len(events), *key)) return values @@ -1201,7 +1244,7 @@ _color_for_type = { _color_for_type_default = "yellow" -def _make_plural(count, noun): +def _make_plural(count: int, noun: str) -> Tuple[int, str]: # No need to pluralize words such as `failed` or `passed`. if noun not in ["error", "warnings"]: return count, noun From 848ab00663c9daf8cd27ee92dec1005cd9633152 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 047/140] Type annotate `@pytest.mark.foo` --- src/_pytest/mark/structures.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 7ae7d5d4f..7abff9b7b 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -3,6 +3,7 @@ import inspect import typing import warnings from typing import Any +from typing import Callable from typing import Iterable from typing import List from typing import Mapping @@ -11,6 +12,7 @@ from typing import Optional from typing import Sequence from typing import Set from typing import Tuple +from typing import TypeVar from typing import Union import attr @@ -19,6 +21,7 @@ from .._code import getfslineno from ..compat import ascii_escaped from ..compat import NOTSET from ..compat import NotSetType +from ..compat import overload from ..compat import TYPE_CHECKING from _pytest.config import Config from _pytest.outcomes import fail @@ -240,6 +243,12 @@ class Mark: ) +# A generic parameter designating an object to which a Mark may +# be applied -- a test function (callable) or class. +# Note: a lambda is not allowed, but this can't be represented. +_Markable = TypeVar("_Markable", bound=Union[Callable[..., object], type]) + + @attr.s class MarkDecorator: """A decorator for applying a mark on test functions and classes. @@ -311,7 +320,20 @@ class MarkDecorator: mark = Mark(self.name, args, kwargs) return self.__class__(self.mark.combined_with(mark)) - def __call__(self, *args: object, **kwargs: object): + # Type ignored because the overloads overlap with an incompatible + # return type. Not much we can do about that. Thankfully mypy picks + # the first match so it works out even if we break the rules. + @overload + def __call__(self, arg: _Markable) -> _Markable: # type: ignore[misc] # noqa: F821 + raise NotImplementedError() + + @overload # noqa: F811 + def __call__( # noqa: F811 + self, *args: object, **kwargs: object + ) -> "MarkDecorator": + raise NotImplementedError() + + def __call__(self, *args: object, **kwargs: object): # noqa: F811 """Call the MarkDecorator.""" if args and not kwargs: func = args[0] From 71dfdca4df6961460653c265026e194fbcaebef2 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:16 +0300 Subject: [PATCH 048/140] Enable check_untyped_defs mypy option for src/ This option checks even functions which are not annotated. It's a good step to ensure that existing type annotation are correct. In a Pareto fashion, the last few holdouts are always the ugliest, beware. --- setup.cfg | 3 +++ src/_pytest/capture.py | 8 +++++--- src/_pytest/config/__init__.py | 6 ++++-- src/_pytest/fixtures.py | 6 ++++-- src/_pytest/nodes.py | 36 ++++++++++++++++++---------------- src/_pytest/pytester.py | 2 ++ src/_pytest/python.py | 26 +++++++++++++++++++++--- src/_pytest/python_api.py | 4 ++-- src/_pytest/recwarn.py | 5 +++-- 9 files changed, 65 insertions(+), 31 deletions(-) diff --git a/setup.cfg b/setup.cfg index a7dd6d1c3..a42ae68ae 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,3 +98,6 @@ strict_equality = True warn_redundant_casts = True warn_return_any = True warn_unused_configs = True + +[mypy-_pytest.*] +check_untyped_defs = True diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index bcc16ceb6..98ba878b3 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -519,10 +519,11 @@ class MultiCapture: def pop_outerr_to_orig(self): """ pop current snapshot out/err capture and flush to orig streams. """ out, err = self.readouterr() + # TODO: Fix type ignores. if out: - self.out.writeorg(out) + self.out.writeorg(out) # type: ignore[union-attr] # noqa: F821 if err: - self.err.writeorg(err) + self.err.writeorg(err) # type: ignore[union-attr] # noqa: F821 return out, err def suspend_capturing(self, in_: bool = False) -> None: @@ -542,7 +543,8 @@ class MultiCapture: if self.err: self.err.resume() if self._in_suspended: - self.in_.resume() + # TODO: Fix type ignore. + self.in_.resume() # type: ignore[union-attr] # noqa: F821 self._in_suspended = False def stop_capturing(self) -> None: diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index ff6aee744..27083900d 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -974,7 +974,7 @@ class Config: self._mark_plugins_for_rewrite(hook) _warn_about_missing_assertion(mode) - def _mark_plugins_for_rewrite(self, hook): + def _mark_plugins_for_rewrite(self, hook) -> None: """ Given an importhook, mark for rewrite any top-level modules or packages in the distribution package for @@ -989,7 +989,9 @@ class Config: package_files = ( str(file) for dist in importlib_metadata.distributions() - if any(ep.group == "pytest11" for ep in dist.entry_points) + # Type ignored due to missing stub: + # https://github.com/python/typeshed/pull/3795 + if any(ep.group == "pytest11" for ep in dist.entry_points) # type: ignore for file in dist.files or [] ) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 8aa5d73a8..fa7e3e1df 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -721,7 +721,9 @@ class FixtureRequest: # this might also be a non-function Item despite its attribute name return self._pyfuncitem if scope == "package": - node = get_scope_package(self._pyfuncitem, self._fixturedef) + # FIXME: _fixturedef is not defined on FixtureRequest (this class), + # but on FixtureRequest (a subclass). + node = get_scope_package(self._pyfuncitem, self._fixturedef) # type: ignore[attr-defined] # noqa: F821 else: node = get_scope_node(self._pyfuncitem, scope) if node is None and scope == "class": @@ -1158,7 +1160,7 @@ def wrap_function_to_error_out_if_called_directly(function, fixture_marker): # keep reference to the original function in our own custom attribute so we don't unwrap # further than this point and lose useful wrappings like @mock.patch (#3774) - result.__pytest_wrapped__ = _PytestWrapper(function) + result.__pytest_wrapped__ = _PytestWrapper(function) # type: ignore[attr-defined] # noqa: F821 return result diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index eaa48e5de..15f91343f 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -42,6 +42,8 @@ if TYPE_CHECKING: # Imported here due to circular import. from _pytest.main import Session + from _pytest.warning_types import PytestWarning + SEP = "/" @@ -118,9 +120,9 @@ class Node(metaclass=NodeMeta): def __init__( self, name: str, - parent: Optional["Node"] = None, + parent: "Optional[Node]" = None, config: Optional[Config] = None, - session: Optional["Session"] = None, + session: "Optional[Session]" = None, fspath: Optional[py.path.local] = None, nodeid: Optional[str] = None, ) -> None: @@ -201,7 +203,7 @@ class Node(metaclass=NodeMeta): def __repr__(self) -> str: return "<{} {}>".format(self.__class__.__name__, getattr(self, "name", None)) - def warn(self, warning): + def warn(self, warning: "PytestWarning") -> None: """Issue a warning for this item. Warnings will be displayed after the test session, unless explicitly suppressed @@ -226,11 +228,9 @@ class Node(metaclass=NodeMeta): ) ) path, lineno = get_fslocation_from_item(self) + assert lineno is not None warnings.warn_explicit( - warning, - category=None, - filename=str(path), - lineno=lineno + 1 if lineno is not None else None, + warning, category=None, filename=str(path), lineno=lineno + 1, ) # methods for ordering nodes @@ -417,24 +417,26 @@ class Node(metaclass=NodeMeta): def get_fslocation_from_item( - item: "Item", + node: "Node", ) -> Tuple[Union[str, py.path.local], Optional[int]]: - """Tries to extract the actual location from an item, depending on available attributes: + """Tries to extract the actual location from a node, depending on available attributes: - * "fslocation": a pair (path, lineno) - * "obj": a Python object that the item wraps. + * "location": a pair (path, lineno) + * "obj": a Python object that the node wraps. * "fspath": just a path :rtype: a tuple of (str|LocalPath, int) with filename and line number. """ - try: - return item.location[:2] - except AttributeError: - pass - obj = getattr(item, "obj", None) + # See Item.location. + location = getattr( + node, "location", None + ) # type: Optional[Tuple[str, Optional[int], str]] + if location is not None: + return location[:2] + obj = getattr(node, "obj", None) if obj is not None: return getfslineno(obj) - return getattr(item, "fspath", "unknown location"), -1 + return getattr(node, "fspath", "unknown location"), -1 class Collector(Node): diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 60df17b90..754ecc10f 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1169,8 +1169,10 @@ class Testdir: popen = subprocess.Popen(cmdargs, stdout=stdout, stderr=stderr, **kw) if stdin is Testdir.CLOSE_STDIN: + assert popen.stdin is not None popen.stdin.close() elif isinstance(stdin, bytes): + assert popen.stdin is not None popen.stdin.write(stdin) return popen diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 55ed2b164..41dd8b292 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -64,6 +64,7 @@ from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning if TYPE_CHECKING: + from typing import Type from typing_extensions import Literal from _pytest.fixtures import _Scope @@ -256,6 +257,18 @@ def pytest_pycollect_makeitem(collector: "PyCollector", name: str, obj): class PyobjMixin: _ALLOW_MARKERS = True + # Function and attributes that the mixin needs (for type-checking only). + if TYPE_CHECKING: + name = "" # type: str + parent = None # type: Optional[nodes.Node] + own_markers = [] # type: List[Mark] + + def getparent(self, cls: Type[nodes._NodeType]) -> Optional[nodes._NodeType]: + ... + + def listchain(self) -> List[nodes.Node]: + ... + @property def module(self): """Python module object this node was collected from (can be None).""" @@ -292,7 +305,10 @@ class PyobjMixin: def _getobj(self): """Gets the underlying Python object. May be overwritten by subclasses.""" - return getattr(self.parent.obj, self.name) + # TODO: Improve the type of `parent` such that assert/ignore aren't needed. + assert self.parent is not None + obj = self.parent.obj # type: ignore[attr-defined] # noqa: F821 + return getattr(obj, self.name) def getmodpath(self, stopatmodule=True, includemodule=False): """ return python path relative to the containing module. """ @@ -772,7 +788,10 @@ class Instance(PyCollector): # can be removed at node structure reorganization time def _getobj(self): - return self.parent.obj() + # TODO: Improve the type of `parent` such that assert/ignore aren't needed. + assert self.parent is not None + obj = self.parent.obj # type: ignore[attr-defined] # noqa: F821 + return obj() def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: self.session._fixturemanager.parsefactories(self) @@ -1527,7 +1546,8 @@ class Function(PyobjMixin, nodes.Item): return getimfunc(self.obj) def _getobj(self): - return getattr(self.parent.obj, self.originalname) + assert self.parent is not None + return getattr(self.parent.obj, self.originalname) # type: ignore[attr-defined] @property def _pyfuncitem(self): diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 29c8af7e2..abace3196 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -508,7 +508,7 @@ def approx(expected, rel=None, abs=None, nan_ok=False): __tracebackhide__ = True if isinstance(expected, Decimal): - cls = ApproxDecimal + cls = ApproxDecimal # type: Type[ApproxBase] elif isinstance(expected, Number): cls = ApproxScalar elif isinstance(expected, Mapping): @@ -534,7 +534,7 @@ def _is_numpy_array(obj): """ import sys - np = sys.modules.get("numpy") + np = sys.modules.get("numpy") # type: Any if np is not None: return isinstance(obj, np.ndarray) return False diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 58b6fbab9..57034be2a 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -136,8 +136,9 @@ class WarningsRecorder(warnings.catch_warnings): Adapted from `warnings.catch_warnings`. """ - def __init__(self): - super().__init__(record=True) + def __init__(self) -> None: + # Type ignored due to the way typeshed handles warnings.catch_warnings. + super().__init__(record=True) # type: ignore[call-arg] # noqa: F821 self._entered = False self._list = [] # type: List[warnings.WarningMessage] From 54ad048be7182018e70479bd3d9b88bcb6376c00 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:17 +0300 Subject: [PATCH 049/140] Enable check_untyped_defs mypy option for testing/ too --- setup.cfg | 4 +- src/_pytest/_code/code.py | 3 +- src/_pytest/logging.py | 12 +- src/_pytest/pytester.py | 2 +- src/_pytest/python_api.py | 1 + testing/code/test_excinfo.py | 87 +++-- testing/deprecated_test.py | 6 +- .../dataclasses/test_compare_dataclasses.py | 4 +- ...ompare_dataclasses_field_comparison_off.py | 4 +- .../test_compare_dataclasses_verbose.py | 4 +- .../test_compare_two_different_dataclasses.py | 8 +- testing/example_scripts/issue_519.py | 4 +- .../unittest/test_unittest_asyncio.py | 3 +- .../unittest/test_unittest_asynctest.py | 3 +- testing/io/test_saferepr.py | 12 +- testing/logging/test_formatter.py | 15 +- testing/logging/test_reporting.py | 13 +- testing/python/approx.py | 3 +- testing/python/collect.py | 30 +- testing/python/integration.py | 29 +- testing/python/raises.py | 32 +- testing/test_assertion.py | 90 +++-- testing/test_assertrewrite.py | 365 ++++++++++-------- testing/test_capture.py | 6 +- testing/test_collection.py | 3 +- testing/test_config.py | 40 +- testing/test_debugging.py | 8 +- testing/test_doctest.py | 8 +- testing/test_junitxml.py | 37 +- testing/test_mark.py | 17 +- testing/test_monkeypatch.py | 40 +- testing/test_nodes.py | 23 +- testing/test_pastebin.py | 7 +- testing/test_pluginmanager.py | 15 +- testing/test_reports.py | 16 +- testing/test_runner_xunit.py | 8 +- testing/test_skipping.py | 8 +- testing/test_terminal.py | 25 +- testing/test_tmpdir.py | 30 +- testing/test_unittest.py | 9 +- testing/test_warnings.py | 7 +- 41 files changed, 598 insertions(+), 443 deletions(-) diff --git a/setup.cfg b/setup.cfg index a42ae68ae..5dc778d99 100644 --- a/setup.cfg +++ b/setup.cfg @@ -91,6 +91,7 @@ formats = sdist.tgz,bdist_wheel [mypy] mypy_path = src +check_untyped_defs = True ignore_missing_imports = True no_implicit_optional = True show_error_codes = True @@ -98,6 +99,3 @@ strict_equality = True warn_redundant_casts = True warn_return_any = True warn_unused_configs = True - -[mypy-_pytest.*] -check_untyped_defs = True diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 7b17d7612..09b2c1af5 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -15,6 +15,7 @@ from typing import Dict from typing import Generic from typing import Iterable from typing import List +from typing import Mapping from typing import Optional from typing import Pattern from typing import Sequence @@ -728,7 +729,7 @@ class FormattedExcinfo: failindent = indentstr return lines - def repr_locals(self, locals: Dict[str, object]) -> Optional["ReprLocals"]: + def repr_locals(self, locals: Mapping[str, object]) -> Optional["ReprLocals"]: if self.showlocals: lines = [] keys = [loc for loc in locals if loc[0] != "@"] diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index ce3a18f03..c1f13b701 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -96,7 +96,7 @@ class PercentStyleMultiline(logging.PercentStyle): formats the message as if each line were logged separately. """ - def __init__(self, fmt: str, auto_indent: Union[int, str, bool]) -> None: + def __init__(self, fmt: str, auto_indent: Union[int, str, bool, None]) -> None: super().__init__(fmt) self._auto_indent = self._get_auto_indent(auto_indent) @@ -109,7 +109,7 @@ class PercentStyleMultiline(logging.PercentStyle): return tmp @staticmethod - def _get_auto_indent(auto_indent_option: Union[int, str, bool]) -> int: + def _get_auto_indent(auto_indent_option: Union[int, str, bool, None]) -> int: """Determines the current auto indentation setting Specify auto indent behavior (on/off/fixed) by passing in @@ -139,7 +139,9 @@ class PercentStyleMultiline(logging.PercentStyle): >0 (explicitly set indentation position). """ - if type(auto_indent_option) is int: + if auto_indent_option is None: + return 0 + elif type(auto_indent_option) is int: return int(auto_indent_option) elif type(auto_indent_option) is str: try: @@ -732,7 +734,9 @@ class _LiveLoggingStreamHandler(logging.StreamHandler): stream = None # type: TerminalReporter # type: ignore def __init__( - self, terminal_reporter: TerminalReporter, capture_manager: CaptureManager + self, + terminal_reporter: TerminalReporter, + capture_manager: Optional[CaptureManager], ) -> None: """ :param _pytest.terminal.TerminalReporter terminal_reporter: diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 754ecc10f..8df5992d6 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -401,7 +401,7 @@ def _sys_snapshot(): @pytest.fixture -def _config_for_test(): +def _config_for_test() -> Generator[Config, None, None]: from _pytest.config import get_config config = get_config() diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index abace3196..c185a0676 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -712,6 +712,7 @@ def raises( # noqa: F811 fail(message) +# This doesn't work with mypy for now. Use fail.Exception instead. raises.Exception = fail.Exception # type: ignore diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 08c0619e3..0ff00bcaa 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -4,6 +4,7 @@ import os import queue import sys import textwrap +from typing import Tuple from typing import Union import py @@ -14,6 +15,7 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import FormattedExcinfo from _pytest._io import TerminalWriter +from _pytest.compat import TYPE_CHECKING from _pytest.pytester import LineMatcher try: @@ -23,6 +25,9 @@ except ImportError: else: invalidate_import_caches = getattr(importlib, "invalidate_caches", None) +if TYPE_CHECKING: + from _pytest._code.code import _TracebackStyle + @pytest.fixture def limited_recursion_depth(): @@ -40,10 +45,11 @@ def test_excinfo_simple() -> None: assert info.type == ValueError -def test_excinfo_from_exc_info_simple(): +def test_excinfo_from_exc_info_simple() -> None: try: raise ValueError except ValueError as e: + assert e.__traceback__ is not None info = _pytest._code.ExceptionInfo.from_exc_info((type(e), e, e.__traceback__)) assert info.type == ValueError @@ -317,25 +323,25 @@ def test_excinfo_exconly(): assert msg.endswith("world") -def test_excinfo_repr_str(): - excinfo = pytest.raises(ValueError, h) - assert repr(excinfo) == "" - assert str(excinfo) == "" +def test_excinfo_repr_str() -> None: + excinfo1 = pytest.raises(ValueError, h) + assert repr(excinfo1) == "" + assert str(excinfo1) == "" class CustomException(Exception): def __repr__(self): return "custom_repr" - def raises(): + def raises() -> None: raise CustomException() - excinfo = pytest.raises(CustomException, raises) - assert repr(excinfo) == "" - assert str(excinfo) == "" + excinfo2 = pytest.raises(CustomException, raises) + assert repr(excinfo2) == "" + assert str(excinfo2) == "" -def test_excinfo_for_later(): - e = ExceptionInfo.for_later() +def test_excinfo_for_later() -> None: + e = ExceptionInfo[BaseException].for_later() assert "for raises" in repr(e) assert "for raises" in str(e) @@ -463,7 +469,7 @@ class TestFormattedExcinfo: assert lines[0] == "| def f(x):" assert lines[1] == " pass" - def test_repr_source_excinfo(self): + def test_repr_source_excinfo(self) -> None: """ check if indentation is right """ pr = FormattedExcinfo() excinfo = self.excinfo_from_exec( @@ -475,6 +481,7 @@ class TestFormattedExcinfo: ) pr = FormattedExcinfo() source = pr._getentrysource(excinfo.traceback[-1]) + assert source is not None lines = pr.get_source(source, 1, excinfo) assert lines == [" def f():", "> assert 0", "E AssertionError"] @@ -522,17 +529,18 @@ raise ValueError() assert repr.reprtraceback.reprentries[0].lines[0] == "> ???" assert repr.chain[0][0].reprentries[0].lines[0] == "> ???" - def test_repr_local(self): + def test_repr_local(self) -> None: p = FormattedExcinfo(showlocals=True) loc = {"y": 5, "z": 7, "x": 3, "@x": 2, "__builtins__": {}} reprlocals = p.repr_locals(loc) + assert reprlocals is not None assert reprlocals.lines assert reprlocals.lines[0] == "__builtins__ = " assert reprlocals.lines[1] == "x = 3" assert reprlocals.lines[2] == "y = 5" assert reprlocals.lines[3] == "z = 7" - def test_repr_local_with_error(self): + def test_repr_local_with_error(self) -> None: class ObjWithErrorInRepr: def __repr__(self): raise NotImplementedError @@ -540,11 +548,12 @@ raise ValueError() p = FormattedExcinfo(showlocals=True, truncate_locals=False) loc = {"x": ObjWithErrorInRepr(), "__builtins__": {}} reprlocals = p.repr_locals(loc) + assert reprlocals is not None assert reprlocals.lines assert reprlocals.lines[0] == "__builtins__ = " assert "[NotImplementedError() raised in repr()]" in reprlocals.lines[1] - def test_repr_local_with_exception_in_class_property(self): + def test_repr_local_with_exception_in_class_property(self) -> None: class ExceptionWithBrokenClass(Exception): # Type ignored because it's bypassed intentionally. @property # type: ignore @@ -558,23 +567,26 @@ raise ValueError() p = FormattedExcinfo(showlocals=True, truncate_locals=False) loc = {"x": ObjWithErrorInRepr(), "__builtins__": {}} reprlocals = p.repr_locals(loc) + assert reprlocals is not None assert reprlocals.lines assert reprlocals.lines[0] == "__builtins__ = " assert "[ExceptionWithBrokenClass() raised in repr()]" in reprlocals.lines[1] - def test_repr_local_truncated(self): + def test_repr_local_truncated(self) -> None: loc = {"l": [i for i in range(10)]} p = FormattedExcinfo(showlocals=True) truncated_reprlocals = p.repr_locals(loc) + assert truncated_reprlocals is not None assert truncated_reprlocals.lines assert truncated_reprlocals.lines[0] == "l = [0, 1, 2, 3, 4, 5, ...]" q = FormattedExcinfo(showlocals=True, truncate_locals=False) full_reprlocals = q.repr_locals(loc) + assert full_reprlocals is not None assert full_reprlocals.lines assert full_reprlocals.lines[0] == "l = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" - def test_repr_tracebackentry_lines(self, importasmod): + def test_repr_tracebackentry_lines(self, importasmod) -> None: mod = importasmod( """ def func1(): @@ -602,11 +614,12 @@ raise ValueError() assert not lines[4:] loc = repr_entry.reprfileloc + assert loc is not None assert loc.path == mod.__file__ assert loc.lineno == 3 # assert loc.message == "ValueError: hello" - def test_repr_tracebackentry_lines2(self, importasmod, tw_mock): + def test_repr_tracebackentry_lines2(self, importasmod, tw_mock) -> None: mod = importasmod( """ def func1(m, x, y, z): @@ -618,6 +631,7 @@ raise ValueError() entry = excinfo.traceback[-1] p = FormattedExcinfo(funcargs=True) reprfuncargs = p.repr_args(entry) + assert reprfuncargs is not None assert reprfuncargs.args[0] == ("m", repr("m" * 90)) assert reprfuncargs.args[1] == ("x", "5") assert reprfuncargs.args[2] == ("y", "13") @@ -625,13 +639,14 @@ raise ValueError() p = FormattedExcinfo(funcargs=True) repr_entry = p.repr_traceback_entry(entry) + assert repr_entry.reprfuncargs is not None assert repr_entry.reprfuncargs.args == reprfuncargs.args repr_entry.toterminal(tw_mock) assert tw_mock.lines[0] == "m = " + repr("m" * 90) assert tw_mock.lines[1] == "x = 5, y = 13" assert tw_mock.lines[2] == "z = " + repr("z" * 120) - def test_repr_tracebackentry_lines_var_kw_args(self, importasmod, tw_mock): + def test_repr_tracebackentry_lines_var_kw_args(self, importasmod, tw_mock) -> None: mod = importasmod( """ def func1(x, *y, **z): @@ -643,17 +658,19 @@ raise ValueError() entry = excinfo.traceback[-1] p = FormattedExcinfo(funcargs=True) reprfuncargs = p.repr_args(entry) + assert reprfuncargs is not None assert reprfuncargs.args[0] == ("x", repr("a")) assert reprfuncargs.args[1] == ("y", repr(("b",))) assert reprfuncargs.args[2] == ("z", repr({"c": "d"})) p = FormattedExcinfo(funcargs=True) repr_entry = p.repr_traceback_entry(entry) + assert repr_entry.reprfuncargs assert repr_entry.reprfuncargs.args == reprfuncargs.args repr_entry.toterminal(tw_mock) assert tw_mock.lines[0] == "x = 'a', y = ('b',), z = {'c': 'd'}" - def test_repr_tracebackentry_short(self, importasmod): + def test_repr_tracebackentry_short(self, importasmod) -> None: mod = importasmod( """ def func1(): @@ -668,6 +685,7 @@ raise ValueError() lines = reprtb.lines basename = py.path.local(mod.__file__).basename assert lines[0] == " func1()" + assert reprtb.reprfileloc is not None assert basename in str(reprtb.reprfileloc.path) assert reprtb.reprfileloc.lineno == 5 @@ -677,6 +695,7 @@ raise ValueError() lines = reprtb.lines assert lines[0] == ' raise ValueError("hello")' assert lines[1] == "E ValueError: hello" + assert reprtb.reprfileloc is not None assert basename in str(reprtb.reprfileloc.path) assert reprtb.reprfileloc.lineno == 3 @@ -716,7 +735,7 @@ raise ValueError() reprtb = p.repr_traceback(excinfo) assert len(reprtb.reprentries) == 3 - def test_traceback_short_no_source(self, importasmod, monkeypatch): + def test_traceback_short_no_source(self, importasmod, monkeypatch) -> None: mod = importasmod( """ def func1(): @@ -729,7 +748,7 @@ raise ValueError() from _pytest._code.code import Code monkeypatch.setattr(Code, "path", "bogus") - excinfo.traceback[0].frame.code.path = "bogus" + excinfo.traceback[0].frame.code.path = "bogus" # type: ignore[misc] # noqa: F821 p = FormattedExcinfo(style="short") reprtb = p.repr_traceback_entry(excinfo.traceback[-2]) lines = reprtb.lines @@ -742,7 +761,7 @@ raise ValueError() assert last_lines[0] == ' raise ValueError("hello")' assert last_lines[1] == "E ValueError: hello" - def test_repr_traceback_and_excinfo(self, importasmod): + def test_repr_traceback_and_excinfo(self, importasmod) -> None: mod = importasmod( """ def f(x): @@ -753,7 +772,8 @@ raise ValueError() ) excinfo = pytest.raises(ValueError, mod.entry) - for style in ("long", "short"): + styles = ("long", "short") # type: Tuple[_TracebackStyle, ...] + for style in styles: p = FormattedExcinfo(style=style) reprtb = p.repr_traceback(excinfo) assert len(reprtb.reprentries) == 2 @@ -765,10 +785,11 @@ raise ValueError() assert repr.chain[0][0] assert len(repr.chain[0][0].reprentries) == len(reprtb.reprentries) + assert repr.reprcrash is not None assert repr.reprcrash.path.endswith("mod.py") assert repr.reprcrash.message == "ValueError: 0" - def test_repr_traceback_with_invalid_cwd(self, importasmod, monkeypatch): + def test_repr_traceback_with_invalid_cwd(self, importasmod, monkeypatch) -> None: mod = importasmod( """ def f(x): @@ -787,7 +808,9 @@ raise ValueError() def raiseos(): nonlocal raised - if sys._getframe().f_back.f_code.co_name == "checked_call": + upframe = sys._getframe().f_back + assert upframe is not None + if upframe.f_code.co_name == "checked_call": # Only raise with expected calls, but not via e.g. inspect for # py38-windows. raised += 1 @@ -831,7 +854,7 @@ raise ValueError() assert tw_mock.lines[-1] == "content" assert tw_mock.lines[-2] == ("-", "title") - def test_repr_excinfo_reprcrash(self, importasmod): + def test_repr_excinfo_reprcrash(self, importasmod) -> None: mod = importasmod( """ def entry(): @@ -840,6 +863,7 @@ raise ValueError() ) excinfo = pytest.raises(ValueError, mod.entry) repr = excinfo.getrepr() + assert repr.reprcrash is not None assert repr.reprcrash.path.endswith("mod.py") assert repr.reprcrash.lineno == 3 assert repr.reprcrash.message == "ValueError" @@ -864,7 +888,7 @@ raise ValueError() assert reprtb.extraline == "!!! Recursion detected (same locals & position)" assert str(reprtb) - def test_reprexcinfo_getrepr(self, importasmod): + def test_reprexcinfo_getrepr(self, importasmod) -> None: mod = importasmod( """ def f(x): @@ -875,14 +899,15 @@ raise ValueError() ) excinfo = pytest.raises(ValueError, mod.entry) - for style in ("short", "long", "no"): + styles = ("short", "long", "no") # type: Tuple[_TracebackStyle, ...] + for style in styles: for showlocals in (True, False): repr = excinfo.getrepr(style=style, showlocals=showlocals) assert repr.reprtraceback.style == style assert isinstance(repr, ExceptionChainRepr) - for repr in repr.chain: - assert repr[0].style == style + for r in repr.chain: + assert r[0].style == style def test_reprexcinfo_unicode(self): from _pytest._code.code import TerminalRepr diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 93264f3fc..b5ad94861 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -103,7 +103,7 @@ def test_warn_about_imminent_junit_family_default_change(testdir, junit_family): result.stdout.fnmatch_lines([warning_msg]) -def test_node_direct_ctor_warning(): +def test_node_direct_ctor_warning() -> None: class MockConfig: pass @@ -112,8 +112,8 @@ def test_node_direct_ctor_warning(): 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 + nodes.Node(name="test", config=ms, session=ms, nodeid="None") # type: ignore + assert w[0].lineno == inspect.currentframe().f_lineno - 1 # type: ignore assert w[0].filename == __file__ diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_dataclasses.py index 82a685c63..d96c90a91 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses.py @@ -2,11 +2,11 @@ from dataclasses import dataclass from dataclasses import field -def test_dataclasses(): +def test_dataclasses() -> None: @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field() + field_b: str = field() left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "c") diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py index fa89e4a20..7479c66c1 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py @@ -2,11 +2,11 @@ from dataclasses import dataclass from dataclasses import field -def test_dataclasses_with_attribute_comparison_off(): +def test_dataclasses_with_attribute_comparison_off() -> None: @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field(compare=False) + field_b: str = field(compare=False) left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "c") diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py index 06634565b..4737ef904 100644 --- a/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py +++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py @@ -2,11 +2,11 @@ from dataclasses import dataclass from dataclasses import field -def test_dataclasses_verbose(): +def test_dataclasses_verbose() -> None: @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field() + field_b: str = field() left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "c") diff --git a/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py index 4c638e1fc..22e981e33 100644 --- a/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py @@ -2,18 +2,18 @@ from dataclasses import dataclass from dataclasses import field -def test_comparing_two_different_data_classes(): +def test_comparing_two_different_data_classes() -> None: @dataclass class SimpleDataObjectOne: field_a: int = field() - field_b: int = field() + field_b: str = field() @dataclass class SimpleDataObjectTwo: field_a: int = field() - field_b: int = field() + field_b: str = field() left = SimpleDataObjectOne(1, "b") right = SimpleDataObjectTwo(1, "c") - assert left != right + assert left != right # type: ignore[comparison-overlap] # noqa: F821 diff --git a/testing/example_scripts/issue_519.py b/testing/example_scripts/issue_519.py index 7199df820..52d5d3f55 100644 --- a/testing/example_scripts/issue_519.py +++ b/testing/example_scripts/issue_519.py @@ -1,4 +1,6 @@ import pprint +from typing import List +from typing import Tuple import pytest @@ -13,7 +15,7 @@ def pytest_generate_tests(metafunc): @pytest.fixture(scope="session") def checked_order(): - order = [] + order = [] # type: List[Tuple[str, str, str]] yield order pprint.pprint(order) diff --git a/testing/example_scripts/unittest/test_unittest_asyncio.py b/testing/example_scripts/unittest/test_unittest_asyncio.py index 76eebf74a..21b9d2cd9 100644 --- a/testing/example_scripts/unittest/test_unittest_asyncio.py +++ b/testing/example_scripts/unittest/test_unittest_asyncio.py @@ -1,7 +1,8 @@ +from typing import List from unittest import IsolatedAsyncioTestCase # type: ignore -teardowns = [] +teardowns = [] # type: List[None] class AsyncArguments(IsolatedAsyncioTestCase): diff --git a/testing/example_scripts/unittest/test_unittest_asynctest.py b/testing/example_scripts/unittest/test_unittest_asynctest.py index bddbe250a..47b5f3f6d 100644 --- a/testing/example_scripts/unittest/test_unittest_asynctest.py +++ b/testing/example_scripts/unittest/test_unittest_asynctest.py @@ -1,10 +1,11 @@ """Issue #7110""" import asyncio +from typing import List import asynctest -teardowns = [] +teardowns = [] # type: List[None] class Test(asynctest.TestCase): diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index f4ced8fac..6912a113f 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -25,7 +25,7 @@ def test_maxsize_error_on_instance(): assert s[0] == "(" and s[-1] == ")" -def test_exceptions(): +def test_exceptions() -> None: class BrokenRepr: def __init__(self, ex): self.ex = ex @@ -34,8 +34,8 @@ def test_exceptions(): raise self.ex class BrokenReprException(Exception): - __str__ = None - __repr__ = None + __str__ = None # type: ignore[assignment] # noqa: F821 + __repr__ = None # type: ignore[assignment] # noqa: F821 assert "Exception" in saferepr(BrokenRepr(Exception("broken"))) s = saferepr(BrokenReprException("really broken")) @@ -44,7 +44,7 @@ def test_exceptions(): none = None try: - none() + none() # type: ignore[misc] # noqa: F821 except BaseException as exc: exp_exc = repr(exc) obj = BrokenRepr(BrokenReprException("omg even worse")) @@ -136,10 +136,10 @@ def test_big_repr(): assert len(saferepr(range(1000))) <= len("[" + SafeRepr(0).maxlist * "1000" + "]") -def test_repr_on_newstyle(): +def test_repr_on_newstyle() -> None: class Function: def __repr__(self): - return "<%s>" % (self.name) + return "<%s>" % (self.name) # type: ignore[attr-defined] # noqa: F821 assert saferepr(Function()) diff --git a/testing/logging/test_formatter.py b/testing/logging/test_formatter.py index 85e949d7a..a90384a95 100644 --- a/testing/logging/test_formatter.py +++ b/testing/logging/test_formatter.py @@ -1,10 +1,11 @@ import logging +from typing import Any from _pytest._io import TerminalWriter from _pytest.logging import ColoredLevelFormatter -def test_coloredlogformatter(): +def test_coloredlogformatter() -> None: logfmt = "%(filename)-25s %(lineno)4d %(levelname)-8s %(message)s" record = logging.LogRecord( @@ -14,7 +15,7 @@ def test_coloredlogformatter(): lineno=10, msg="Test Message", args=(), - exc_info=False, + exc_info=None, ) class ColorConfig: @@ -35,7 +36,7 @@ def test_coloredlogformatter(): assert output == ("dummypath 10 INFO Test Message") -def test_multiline_message(): +def test_multiline_message() -> None: from _pytest.logging import PercentStyleMultiline logfmt = "%(filename)-25s %(lineno)4d %(levelname)-8s %(message)s" @@ -47,8 +48,8 @@ def test_multiline_message(): lineno=10, msg="Test Message line1\nline2", args=(), - exc_info=False, - ) + exc_info=None, + ) # type: Any # this is called by logging.Formatter.format record.message = record.getMessage() @@ -124,7 +125,7 @@ def test_multiline_message(): ) -def test_colored_short_level(): +def test_colored_short_level() -> None: logfmt = "%(levelname).1s %(message)s" record = logging.LogRecord( @@ -134,7 +135,7 @@ def test_colored_short_level(): lineno=10, msg="Test Message", args=(), - exc_info=False, + exc_info=None, ) class ColorConfig: diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 709df2b57..bbdf28b38 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -1,9 +1,12 @@ import io import os import re +from typing import cast import pytest +from _pytest.capture import CaptureManager from _pytest.pytester import Testdir +from _pytest.terminal import TerminalReporter def test_nothing_logged(testdir): @@ -808,7 +811,7 @@ def test_log_file_unicode(testdir): @pytest.mark.parametrize("has_capture_manager", [True, False]) -def test_live_logging_suspends_capture(has_capture_manager, request): +def test_live_logging_suspends_capture(has_capture_manager: bool, request) -> None: """Test that capture manager is suspended when we emitting messages for live logging. This tests the implementation calls instead of behavior because it is difficult/impossible to do it using @@ -835,8 +838,10 @@ def test_live_logging_suspends_capture(has_capture_manager, request): def section(self, *args, **kwargs): pass - out_file = DummyTerminal() - capture_manager = MockCaptureManager() if has_capture_manager else None + out_file = cast(TerminalReporter, DummyTerminal()) + capture_manager = ( + cast(CaptureManager, MockCaptureManager()) if has_capture_manager else None + ) handler = _LiveLoggingStreamHandler(out_file, capture_manager) handler.set_when("call") @@ -849,7 +854,7 @@ def test_live_logging_suspends_capture(has_capture_manager, request): assert MockCaptureManager.calls == ["enter disabled", "exit disabled"] else: assert MockCaptureManager.calls == [] - assert out_file.getvalue() == "\nsome message\n" + assert cast(io.StringIO, out_file).getvalue() == "\nsome message\n" def test_collection_live_logging(testdir): diff --git a/testing/python/approx.py b/testing/python/approx.py index 76d995773..8581475e1 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -428,10 +428,11 @@ class TestApprox: assert a12 != approx(a21) assert a21 != approx(a12) - def test_doctests(self, mocked_doctest_runner): + def test_doctests(self, mocked_doctest_runner) -> None: import doctest parser = doctest.DocTestParser() + assert approx.__doc__ is not None test = parser.get_doctest( approx.__doc__, {"approx": approx}, approx.__name__, None, None ) diff --git a/testing/python/collect.py b/testing/python/collect.py index cbc798ad8..7824ceff1 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1,6 +1,8 @@ import os import sys import textwrap +from typing import Any +from typing import Dict import _pytest._code import pytest @@ -698,7 +700,7 @@ class TestFunction: class TestSorting: - def test_check_equality(self, testdir): + def test_check_equality(self, testdir) -> None: modcol = testdir.getmodulecol( """ def test_pass(): pass @@ -720,10 +722,10 @@ class TestSorting: assert fn1 != fn3 for fn in fn1, fn2, fn3: - assert fn != 3 + 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 def test_allow_sane_sorting_for_decorators(self, testdir): @@ -1006,7 +1008,7 @@ class TestTracebackCutting: assert "INTERNALERROR>" not in out result.stdout.fnmatch_lines(["*ValueError: fail me*", "* 1 error in *"]) - def test_filter_traceback_generated_code(self): + def test_filter_traceback_generated_code(self) -> None: """test that filter_traceback() works with the fact that _pytest._code.code.Code.path attribute might return an str object. In this case, one of the entries on the traceback was produced by @@ -1017,17 +1019,18 @@ class TestTracebackCutting: from _pytest.python import filter_traceback try: - ns = {} + ns = {} # type: Dict[str, Any] exec("def foo(): raise ValueError", ns) ns["foo"]() except ValueError: _, _, tb = sys.exc_info() - tb = _pytest._code.Traceback(tb) - assert isinstance(tb[-1].path, str) - assert not filter_traceback(tb[-1]) + assert tb is not None + traceback = _pytest._code.Traceback(tb) + assert isinstance(traceback[-1].path, str) + assert not filter_traceback(traceback[-1]) - def test_filter_traceback_path_no_longer_valid(self, testdir): + def test_filter_traceback_path_no_longer_valid(self, testdir) -> None: """test that filter_traceback() works with the fact that _pytest._code.code.Code.path attribute might return an str object. In this case, one of the files in the traceback no longer exists. @@ -1049,10 +1052,11 @@ class TestTracebackCutting: except ValueError: _, _, tb = sys.exc_info() + assert tb is not None testdir.tmpdir.join("filter_traceback_entry_as_str.py").remove() - tb = _pytest._code.Traceback(tb) - assert isinstance(tb[-1].path, str) - assert filter_traceback(tb[-1]) + traceback = _pytest._code.Traceback(tb) + assert isinstance(traceback[-1].path, str) + assert filter_traceback(traceback[-1]) class TestReportInfo: diff --git a/testing/python/integration.py b/testing/python/integration.py index 3409b6446..537057484 100644 --- a/testing/python/integration.py +++ b/testing/python/integration.py @@ -1,10 +1,14 @@ +from typing import Any + import pytest from _pytest import python from _pytest import runner class TestOEJSKITSpecials: - def test_funcarg_non_pycollectobj(self, testdir, recwarn): # rough jstests usage + def test_funcarg_non_pycollectobj( + self, testdir, recwarn + ) -> None: # rough jstests usage testdir.makeconftest( """ import pytest @@ -28,13 +32,14 @@ class TestOEJSKITSpecials: ) # this hook finds funcarg factories rep = runner.collect_one_node(collector=modcol) - clscol = rep.result[0] + # TODO: Don't treat as Any. + clscol = rep.result[0] # type: Any clscol.obj = lambda arg1: None clscol.funcargs = {} pytest._fillfuncargs(clscol) assert clscol.funcargs["arg1"] == 42 - def test_autouse_fixture(self, testdir, recwarn): # rough jstests usage + def test_autouse_fixture(self, testdir, recwarn) -> None: # rough jstests usage testdir.makeconftest( """ import pytest @@ -61,20 +66,21 @@ class TestOEJSKITSpecials: ) # this hook finds funcarg factories rep = runner.collect_one_node(modcol) - clscol = rep.result[0] + # TODO: Don't treat as Any. + clscol = rep.result[0] # type: Any clscol.obj = lambda: None clscol.funcargs = {} pytest._fillfuncargs(clscol) assert not clscol.funcargs -def test_wrapped_getfslineno(): +def test_wrapped_getfslineno() -> None: def func(): pass def wrap(f): - func.__wrapped__ = f - func.patchings = ["qwe"] + func.__wrapped__ = f # type: ignore + func.patchings = ["qwe"] # type: ignore return func @wrap @@ -87,14 +93,14 @@ def test_wrapped_getfslineno(): class TestMockDecoration: - def test_wrapped_getfuncargnames(self): + def test_wrapped_getfuncargnames(self) -> None: from _pytest.compat import getfuncargnames def wrap(f): def func(): pass - func.__wrapped__ = f + func.__wrapped__ = f # type: ignore return func @wrap @@ -322,10 +328,11 @@ class TestReRunTests: ) -def test_pytestconfig_is_session_scoped(): +def test_pytestconfig_is_session_scoped() -> None: from _pytest.fixtures import pytestconfig - assert pytestconfig._pytestfixturefunction.scope == "session" + marker = pytestconfig._pytestfixturefunction # type: ignore + assert marker.scope == "session" class TestNoselikeTestAttribute: diff --git a/testing/python/raises.py b/testing/python/raises.py index 6c607464d..e55eb6f54 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -6,9 +6,9 @@ from _pytest.outcomes import Failed class TestRaises: - def test_check_callable(self): + def test_check_callable(self) -> None: with pytest.raises(TypeError, match=r".* must be callable"): - pytest.raises(RuntimeError, "int('qwe')") + pytest.raises(RuntimeError, "int('qwe')") # type: ignore[call-overload] # noqa: F821 def test_raises(self): excinfo = pytest.raises(ValueError, int, "qwe") @@ -18,19 +18,19 @@ class TestRaises: excinfo = pytest.raises(ValueError, int, "hello") assert "invalid literal" in str(excinfo.value) - def test_raises_callable_no_exception(self): + def test_raises_callable_no_exception(self) -> None: class A: def __call__(self): pass try: pytest.raises(ValueError, A()) - except pytest.raises.Exception: + except pytest.fail.Exception: pass - def test_raises_falsey_type_error(self): + def test_raises_falsey_type_error(self) -> None: with pytest.raises(TypeError): - with pytest.raises(AssertionError, match=0): + with pytest.raises(AssertionError, match=0): # type: ignore[call-overload] # noqa: F821 raise AssertionError("ohai") def test_raises_repr_inflight(self): @@ -126,23 +126,23 @@ class TestRaises: result = testdir.runpytest() result.stdout.fnmatch_lines(["*2 failed*"]) - def test_noclass(self): + def test_noclass(self) -> None: with pytest.raises(TypeError): - pytest.raises("wrong", lambda: None) + pytest.raises("wrong", lambda: None) # type: ignore[call-overload] # noqa: F821 - def test_invalid_arguments_to_raises(self): + def test_invalid_arguments_to_raises(self) -> None: with pytest.raises(TypeError, match="unknown"): - with pytest.raises(TypeError, unknown="bogus"): + with pytest.raises(TypeError, unknown="bogus"): # type: ignore[call-overload] # noqa: F821 raise ValueError() def test_tuple(self): with pytest.raises((KeyError, ValueError)): raise KeyError("oops") - def test_no_raise_message(self): + def test_no_raise_message(self) -> None: try: pytest.raises(ValueError, int, "0") - except pytest.raises.Exception as e: + except pytest.fail.Exception as e: assert e.msg == "DID NOT RAISE {}".format(repr(ValueError)) else: assert False, "Expected pytest.raises.Exception" @@ -150,7 +150,7 @@ class TestRaises: try: with pytest.raises(ValueError): pass - except pytest.raises.Exception as e: + except pytest.fail.Exception as e: assert e.msg == "DID NOT RAISE {}".format(repr(ValueError)) else: assert False, "Expected pytest.raises.Exception" @@ -252,7 +252,7 @@ class TestRaises: ): pytest.raises(ClassLooksIterableException, lambda: None) - def test_raises_with_raising_dunder_class(self): + def test_raises_with_raising_dunder_class(self) -> None: """Test current behavior with regard to exceptions via __class__ (#4284).""" class CrappyClass(Exception): @@ -262,12 +262,12 @@ class TestRaises: assert False, "via __class__" with pytest.raises(AssertionError) as excinfo: - with pytest.raises(CrappyClass()): + with pytest.raises(CrappyClass()): # type: ignore[call-overload] # noqa: F821 pass assert "via __class__" in excinfo.value.args[0] def test_raises_context_manager_with_kwargs(self): with pytest.raises(TypeError) as excinfo: - with pytest.raises(Exception, foo="bar"): + with pytest.raises(Exception, foo="bar"): # type: ignore[call-overload] # noqa: F821 pass assert "Unexpected keyword arguments" in str(excinfo.value) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 042aa7055..f28876edc 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -279,9 +279,9 @@ class TestImportHookInstallation: ] ) - def test_register_assert_rewrite_checks_types(self): + def test_register_assert_rewrite_checks_types(self) -> None: with pytest.raises(TypeError): - pytest.register_assert_rewrite(["pytest_tests_internal_non_existing"]) + pytest.register_assert_rewrite(["pytest_tests_internal_non_existing"]) # type: ignore pytest.register_assert_rewrite( "pytest_tests_internal_non_existing", "pytest_tests_internal_non_existing2" ) @@ -326,8 +326,10 @@ class TestAssert_reprcompare: def test_different_types(self): assert callequal([0, 1], "foo") is None - def test_summary(self): - summary = callequal([0, 1], [0, 2])[0] + def test_summary(self) -> None: + lines = callequal([0, 1], [0, 2]) + assert lines is not None + summary = lines[0] assert len(summary) < 65 def test_text_diff(self): @@ -337,21 +339,24 @@ class TestAssert_reprcompare: "+ spam", ] - def test_text_skipping(self): + def test_text_skipping(self) -> None: lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs") + assert lines is not None assert "Skipping" in lines[1] for line in lines: assert "a" * 50 not in line - def test_text_skipping_verbose(self): + def test_text_skipping_verbose(self) -> None: lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs", verbose=1) + assert lines is not None assert "- " + "a" * 50 + "eggs" in lines assert "+ " + "a" * 50 + "spam" in lines - def test_multiline_text_diff(self): + def test_multiline_text_diff(self) -> None: left = "foo\nspam\nbar" right = "foo\neggs\nbar" diff = callequal(left, right) + assert diff is not None assert "- eggs" in diff assert "+ spam" in diff @@ -376,8 +381,9 @@ class TestAssert_reprcompare: "+ b'spam'", ] - def test_list(self): + def test_list(self) -> None: expl = callequal([0, 1], [0, 2]) + assert expl is not None assert len(expl) > 1 @pytest.mark.parametrize( @@ -421,21 +427,25 @@ class TestAssert_reprcompare: ), ], ) - def test_iterable_full_diff(self, left, right, expected): + def test_iterable_full_diff(self, left, right, expected) -> None: """Test the full diff assertion failure explanation. When verbose is False, then just a -v notice to get the diff is rendered, when verbose is True, then ndiff of the pprint is returned. """ expl = callequal(left, right, verbose=0) + assert expl is not None assert expl[-1] == "Use -v to get the full diff" - expl = "\n".join(callequal(left, right, verbose=1)) - assert expl.endswith(textwrap.dedent(expected).strip()) + verbose_expl = callequal(left, right, verbose=1) + assert verbose_expl is not None + assert "\n".join(verbose_expl).endswith(textwrap.dedent(expected).strip()) - def test_list_different_lengths(self): + def test_list_different_lengths(self) -> None: expl = callequal([0, 1], [0, 1, 2]) + assert expl is not None assert len(expl) > 1 expl = callequal([0, 1, 2], [0, 1]) + assert expl is not None assert len(expl) > 1 def test_list_wrap_for_multiple_lines(self): @@ -545,27 +555,31 @@ class TestAssert_reprcompare: " }", ] - def test_dict(self): + def test_dict(self) -> None: expl = callequal({"a": 0}, {"a": 1}) + assert expl is not None assert len(expl) > 1 - def test_dict_omitting(self): + def test_dict_omitting(self) -> None: lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}) + assert lines is not None assert lines[1].startswith("Omitting 1 identical item") assert "Common items" not in lines for line in lines[1:]: assert "b" not in line - def test_dict_omitting_with_verbosity_1(self): + def test_dict_omitting_with_verbosity_1(self) -> None: """ Ensure differing items are visible for verbosity=1 (#1512) """ lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}, verbose=1) + assert lines is not None assert lines[1].startswith("Omitting 1 identical item") assert lines[2].startswith("Differing items") assert lines[3] == "{'a': 0} != {'a': 1}" assert "Common items" not in lines - def test_dict_omitting_with_verbosity_2(self): + def test_dict_omitting_with_verbosity_2(self) -> None: lines = callequal({"a": 0, "b": 1}, {"a": 1, "b": 1}, verbose=2) + assert lines is not None assert lines[1].startswith("Common items:") assert "Omitting" not in lines[1] assert lines[2] == "{'b': 1}" @@ -614,15 +628,17 @@ class TestAssert_reprcompare: "+ (1, 2, 3)", ] - def test_set(self): + def test_set(self) -> None: expl = callequal({0, 1}, {0, 2}) + assert expl is not None assert len(expl) > 1 - def test_frozenzet(self): + def test_frozenzet(self) -> None: expl = callequal(frozenset([0, 1]), {0, 2}) + assert expl is not None assert len(expl) > 1 - def test_Sequence(self): + def test_Sequence(self) -> None: # Test comparing with a Sequence subclass. class TestSequence(collections.abc.MutableSequence): def __init__(self, iterable): @@ -644,15 +660,18 @@ class TestAssert_reprcompare: pass expl = callequal(TestSequence([0, 1]), list([0, 2])) + assert expl is not None assert len(expl) > 1 - def test_list_tuples(self): + def test_list_tuples(self) -> None: expl = callequal([], [(1, 2)]) + assert expl is not None assert len(expl) > 1 expl = callequal([(1, 2)], []) + assert expl is not None assert len(expl) > 1 - def test_repr_verbose(self): + def test_repr_verbose(self) -> None: class Nums: def __init__(self, nums): self.nums = nums @@ -669,21 +688,25 @@ class TestAssert_reprcompare: assert callequal(nums_x, nums_y) is None expl = callequal(nums_x, nums_y, verbose=1) + assert expl is not None assert "+" + repr(nums_x) in expl assert "-" + repr(nums_y) in expl expl = callequal(nums_x, nums_y, verbose=2) + assert expl is not None assert "+" + repr(nums_x) in expl assert "-" + repr(nums_y) in expl - def test_list_bad_repr(self): + def test_list_bad_repr(self) -> None: class A: def __repr__(self): raise ValueError(42) expl = callequal([], [A()]) + assert expl is not None assert "ValueError" in "".join(expl) expl = callequal({}, {"1": A()}, verbose=2) + assert expl is not None assert expl[0].startswith("{} == <[ValueError") assert "raised in repr" in expl[0] assert expl[1:] == [ @@ -707,9 +730,10 @@ class TestAssert_reprcompare: expl = callequal(A(), "") assert not expl - def test_repr_no_exc(self): - expl = " ".join(callequal("foo", "bar")) - assert "raised in repr()" not in expl + def test_repr_no_exc(self) -> None: + expl = callequal("foo", "bar") + assert expl is not None + assert "raised in repr()" not in " ".join(expl) def test_unicode(self): assert callequal("£€", "£") == [ @@ -734,11 +758,12 @@ class TestAssert_reprcompare: def test_format_nonascii_explanation(self): assert util.format_explanation("λ") - def test_mojibake(self): + def test_mojibake(self) -> None: # issue 429 left = b"e" right = b"\xc3\xa9" expl = callequal(left, right) + assert expl is not None for line in expl: assert isinstance(line, str) msg = "\n".join(expl) @@ -791,7 +816,7 @@ class TestAssert_reprcompare_dataclass: class TestAssert_reprcompare_attrsclass: - def test_attrs(self): + def test_attrs(self) -> None: @attr.s class SimpleDataObject: field_a = attr.ib() @@ -801,12 +826,13 @@ class TestAssert_reprcompare_attrsclass: right = SimpleDataObject(1, "c") lines = callequal(left, right) + assert lines is not None assert lines[1].startswith("Omitting 1 identical item") assert "Matching attributes" not in lines for line in lines[1:]: assert "field_a" not in line - def test_attrs_verbose(self): + def test_attrs_verbose(self) -> None: @attr.s class SimpleDataObject: field_a = attr.ib() @@ -816,6 +842,7 @@ class TestAssert_reprcompare_attrsclass: right = SimpleDataObject(1, "c") lines = callequal(left, right, verbose=2) + assert lines is not None assert lines[1].startswith("Matching attributes:") assert "Omitting" not in lines[1] assert lines[2] == "['field_a']" @@ -824,12 +851,13 @@ class TestAssert_reprcompare_attrsclass: @attr.s class SimpleDataObject: field_a = attr.ib() - field_b = attr.ib(**{ATTRS_EQ_FIELD: False}) + field_b = attr.ib(**{ATTRS_EQ_FIELD: False}) # type: ignore left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "b") lines = callequal(left, right, verbose=2) + assert lines is not None assert lines[1].startswith("Matching attributes:") assert "Omitting" not in lines[1] assert lines[2] == "['field_a']" @@ -946,8 +974,8 @@ class TestTruncateExplanation: # to calculate that results have the expected length. LINES_IN_TRUNCATION_MSG = 2 - def test_doesnt_truncate_when_input_is_empty_list(self): - expl = [] + def test_doesnt_truncate_when_input_is_empty_list(self) -> None: + expl = [] # type: List[str] result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100) assert result == expl diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 212c631ef..3813993be 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -9,6 +9,13 @@ import sys import textwrap import zipfile from functools import partial +from typing import Dict +from typing import List +from typing import Mapping +from typing import Optional +from typing import Set + +import py import _pytest._code import pytest @@ -25,24 +32,26 @@ from _pytest.pathlib import Path from _pytest.pytester import Testdir -def rewrite(src): +def rewrite(src: str) -> ast.Module: tree = ast.parse(src) rewrite_asserts(tree, src.encode()) return tree -def getmsg(f, extra_ns=None, must_pass=False): +def getmsg( + f, extra_ns: Optional[Mapping[str, object]] = None, *, must_pass: bool = False +) -> Optional[str]: """Rewrite the assertions in f, run it, and get the failure message.""" src = "\n".join(_pytest._code.Code(f).source().lines) mod = rewrite(src) code = compile(mod, "", "exec") - ns = {} + ns = {} # type: Dict[str, object] if extra_ns is not None: ns.update(extra_ns) exec(code, ns) func = ns[f.__name__] try: - func() + func() # type: ignore[operator] # noqa: F821 except AssertionError: if must_pass: pytest.fail("shouldn't have raised") @@ -53,6 +62,7 @@ def getmsg(f, extra_ns=None, must_pass=False): else: if not must_pass: pytest.fail("function didn't raise at all") + return None class TestAssertionRewrite: @@ -98,10 +108,11 @@ class TestAssertionRewrite: assert imp.col_offset == 0 assert isinstance(m.body[3], ast.Expr) - def test_dont_rewrite(self): + def test_dont_rewrite(self) -> None: s = """'PYTEST_DONT_REWRITE'\nassert 14""" m = rewrite(s) assert len(m.body) == 2 + assert isinstance(m.body[1], ast.Assert) assert m.body[1].msg is None def test_dont_rewrite_plugin(self, testdir): @@ -145,28 +156,28 @@ class TestAssertionRewrite: monkeypatch.syspath_prepend(xdir) testdir.runpytest().assert_outcomes(passed=1) - def test_name(self, request): - def f(): + def test_name(self, request) -> None: + def f1() -> None: assert False - assert getmsg(f) == "assert False" + assert getmsg(f1) == "assert False" - def f(): + def f2() -> None: f = False assert f - assert getmsg(f) == "assert False" + assert getmsg(f2) == "assert False" - def f(): - assert a_global # noqa + def f3() -> None: + assert a_global # type: ignore[name-defined] # noqa - assert getmsg(f, {"a_global": False}) == "assert False" + assert getmsg(f3, {"a_global": False}) == "assert False" - def f(): - assert sys == 42 + def f4() -> None: + assert sys == 42 # type: ignore[comparison-overlap] # noqa: F821 verbose = request.config.getoption("verbose") - msg = getmsg(f, {"sys": sys}) + msg = getmsg(f4, {"sys": sys}) if verbose > 0: assert msg == ( "assert == 42\n" @@ -176,64 +187,74 @@ class TestAssertionRewrite: else: assert msg == "assert sys == 42" - def f(): - assert cls == 42 # noqa: F821 + def f5() -> None: + assert cls == 42 # type: ignore[name-defined] # noqa: F821 class X: pass - msg = getmsg(f, {"cls": X}).splitlines() + msg = getmsg(f5, {"cls": X}) + assert msg is not None + lines = msg.splitlines() if verbose > 1: - assert msg == ["assert {!r} == 42".format(X), " +{!r}".format(X), " -42"] + assert lines == [ + "assert {!r} == 42".format(X), + " +{!r}".format(X), + " -42", + ] elif verbose > 0: - assert msg == [ + assert lines == [ "assert .X'> == 42", " +{!r}".format(X), " -42", ] else: - assert msg == ["assert cls == 42"] + assert lines == ["assert cls == 42"] - def test_assertrepr_compare_same_width(self, request): + def test_assertrepr_compare_same_width(self, request) -> None: """Should use same width/truncation with same initial width.""" - def f(): + def f() -> None: assert "1234567890" * 5 + "A" == "1234567890" * 5 + "B" - msg = getmsg(f).splitlines()[0] + msg = getmsg(f) + assert msg is not None + line = msg.splitlines()[0] if request.config.getoption("verbose") > 1: - assert msg == ( + assert line == ( "assert '12345678901234567890123456789012345678901234567890A' " "== '12345678901234567890123456789012345678901234567890B'" ) else: - assert msg == ( + assert line == ( "assert '123456789012...901234567890A' " "== '123456789012...901234567890B'" ) - def test_dont_rewrite_if_hasattr_fails(self, request): + def test_dont_rewrite_if_hasattr_fails(self, request) -> None: class Y: """ A class whos getattr fails, but not with `AttributeError` """ def __getattr__(self, attribute_name): raise KeyError() - def __repr__(self): + def __repr__(self) -> str: return "Y" - def __init__(self): + def __init__(self) -> None: self.foo = 3 - def f(): - assert cls().foo == 2 # noqa + def f() -> None: + assert cls().foo == 2 # type: ignore[name-defined] # noqa: F821 # XXX: looks like the "where" should also be there in verbose mode?! - message = getmsg(f, {"cls": Y}).splitlines() + msg = getmsg(f, {"cls": Y}) + assert msg is not None + lines = msg.splitlines() if request.config.getoption("verbose") > 0: - assert message == ["assert 3 == 2", " +3", " -2"] + assert lines == ["assert 3 == 2", " +3", " -2"] else: - assert message == [ + assert lines == [ "assert 3 == 2", " + where 3 = Y.foo", " + where Y = cls()", @@ -314,145 +335,145 @@ class TestAssertionRewrite: assert result.ret == 1 result.stdout.fnmatch_lines(["*AssertionError: b'ohai!'", "*assert False"]) - def test_boolop(self): - def f(): + def test_boolop(self) -> None: + def f1() -> None: f = g = False assert f and g - assert getmsg(f) == "assert (False)" + assert getmsg(f1) == "assert (False)" - def f(): + def f2() -> None: f = True g = False assert f and g - assert getmsg(f) == "assert (True and False)" + assert getmsg(f2) == "assert (True and False)" - def f(): + def f3() -> None: f = False g = True assert f and g - assert getmsg(f) == "assert (False)" + assert getmsg(f3) == "assert (False)" - def f(): + def f4() -> None: f = g = False assert f or g - assert getmsg(f) == "assert (False or False)" + assert getmsg(f4) == "assert (False or False)" - def f(): + def f5() -> None: f = g = False assert not f and not g - getmsg(f, must_pass=True) + getmsg(f5, must_pass=True) - def x(): + def x() -> bool: return False - def f(): + def f6() -> None: assert x() and x() assert ( - getmsg(f, {"x": x}) + getmsg(f6, {"x": x}) == """assert (False) + where False = x()""" ) - def f(): + def f7() -> None: assert False or x() assert ( - getmsg(f, {"x": x}) + getmsg(f7, {"x": x}) == """assert (False or False) + where False = x()""" ) - def f(): + def f8() -> None: assert 1 in {} and 2 in {} - assert getmsg(f) == "assert (1 in {})" + assert getmsg(f8) == "assert (1 in {})" - def f(): + def f9() -> None: x = 1 y = 2 assert x in {1: None} and y in {} - assert getmsg(f) == "assert (1 in {1: None} and 2 in {})" + assert getmsg(f9) == "assert (1 in {1: None} and 2 in {})" - def f(): + def f10() -> None: f = True g = False assert f or g - getmsg(f, must_pass=True) + getmsg(f10, must_pass=True) - def f(): + def f11() -> None: f = g = h = lambda: True assert f() and g() and h() - getmsg(f, must_pass=True) + getmsg(f11, must_pass=True) - def test_short_circuit_evaluation(self): - def f(): - assert True or explode # noqa + def test_short_circuit_evaluation(self) -> None: + def f1() -> None: + assert True or explode # type: ignore[name-defined] # noqa: F821 - getmsg(f, must_pass=True) + getmsg(f1, must_pass=True) - def f(): + def f2() -> None: x = 1 assert x == 1 or x == 2 - getmsg(f, must_pass=True) + getmsg(f2, must_pass=True) - def test_unary_op(self): - def f(): + def test_unary_op(self) -> None: + def f1() -> None: x = True assert not x - assert getmsg(f) == "assert not True" + assert getmsg(f1) == "assert not True" - def f(): + def f2() -> None: x = 0 assert ~x + 1 - assert getmsg(f) == "assert (~0 + 1)" + assert getmsg(f2) == "assert (~0 + 1)" - def f(): + def f3() -> None: x = 3 assert -x + x - assert getmsg(f) == "assert (-3 + 3)" + assert getmsg(f3) == "assert (-3 + 3)" - def f(): + def f4() -> None: x = 0 assert +x + x - assert getmsg(f) == "assert (+0 + 0)" + assert getmsg(f4) == "assert (+0 + 0)" - def test_binary_op(self): - def f(): + def test_binary_op(self) -> None: + def f1() -> None: x = 1 y = -1 assert x + y - assert getmsg(f) == "assert (1 + -1)" + assert getmsg(f1) == "assert (1 + -1)" - def f(): + def f2() -> None: assert not 5 % 4 - assert getmsg(f) == "assert not (5 % 4)" + assert getmsg(f2) == "assert not (5 % 4)" - def test_boolop_percent(self): - def f(): + def test_boolop_percent(self) -> None: + def f1() -> None: assert 3 % 2 and False - assert getmsg(f) == "assert ((3 % 2) and False)" + assert getmsg(f1) == "assert ((3 % 2) and False)" - def f(): + def f2() -> None: assert False or 4 % 2 - assert getmsg(f) == "assert (False or (4 % 2))" + assert getmsg(f2) == "assert (False or (4 % 2))" def test_at_operator_issue1290(self, testdir): testdir.makepyfile( @@ -480,133 +501,133 @@ class TestAssertionRewrite: ) testdir.runpytest().assert_outcomes(passed=1) - def test_call(self): - def g(a=42, *args, **kwargs): + def test_call(self) -> None: + def g(a=42, *args, **kwargs) -> bool: return False ns = {"g": g} - def f(): + def f1() -> None: assert g() assert ( - getmsg(f, ns) + getmsg(f1, ns) == """assert False + where False = g()""" ) - def f(): + def f2() -> None: assert g(1) assert ( - getmsg(f, ns) + getmsg(f2, ns) == """assert False + where False = g(1)""" ) - def f(): + def f3() -> None: assert g(1, 2) assert ( - getmsg(f, ns) + getmsg(f3, ns) == """assert False + where False = g(1, 2)""" ) - def f(): + def f4() -> None: assert g(1, g=42) assert ( - getmsg(f, ns) + getmsg(f4, ns) == """assert False + where False = g(1, g=42)""" ) - def f(): + def f5() -> None: assert g(1, 3, g=23) assert ( - getmsg(f, ns) + getmsg(f5, ns) == """assert False + where False = g(1, 3, g=23)""" ) - def f(): + def f6() -> None: seq = [1, 2, 3] assert g(*seq) assert ( - getmsg(f, ns) + getmsg(f6, ns) == """assert False + where False = g(*[1, 2, 3])""" ) - def f(): + def f7() -> None: x = "a" assert g(**{x: 2}) assert ( - getmsg(f, ns) + getmsg(f7, ns) == """assert False + where False = g(**{'a': 2})""" ) - def test_attribute(self): + def test_attribute(self) -> None: class X: g = 3 ns = {"x": X} - def f(): - assert not x.g # noqa + def f1() -> None: + assert not x.g # type: ignore[name-defined] # noqa: F821 assert ( - getmsg(f, ns) + getmsg(f1, ns) == """assert not 3 + where 3 = x.g""" ) - def f(): - x.a = False # noqa - assert x.a # noqa + def f2() -> None: + x.a = False # type: ignore[name-defined] # noqa: F821 + assert x.a # type: ignore[name-defined] # noqa: F821 assert ( - getmsg(f, ns) + getmsg(f2, ns) == """assert False + where False = x.a""" ) - def test_comparisons(self): - def f(): + def test_comparisons(self) -> None: + def f1() -> None: a, b = range(2) assert b < a - assert getmsg(f) == """assert 1 < 0""" + assert getmsg(f1) == """assert 1 < 0""" - def f(): + def f2() -> None: a, b, c = range(3) assert a > b > c - assert getmsg(f) == """assert 0 > 1""" + assert getmsg(f2) == """assert 0 > 1""" - def f(): + def f3() -> None: a, b, c = range(3) assert a < b > c - assert getmsg(f) == """assert 1 > 2""" + assert getmsg(f3) == """assert 1 > 2""" - def f(): + def f4() -> None: a, b, c = range(3) assert a < b <= c - getmsg(f, must_pass=True) + getmsg(f4, must_pass=True) - def f(): + def f5() -> None: a, b, c = range(3) assert a < b assert b < c - getmsg(f, must_pass=True) + getmsg(f5, must_pass=True) def test_len(self, request): def f(): @@ -619,29 +640,29 @@ class TestAssertionRewrite: else: assert msg == "assert 10 == 11\n + where 10 = len([0, 1, 2, 3, 4, 5, ...])" - def test_custom_reprcompare(self, monkeypatch): - def my_reprcompare(op, left, right): + def test_custom_reprcompare(self, monkeypatch) -> None: + def my_reprcompare1(op, left, right) -> str: return "42" - monkeypatch.setattr(util, "_reprcompare", my_reprcompare) + monkeypatch.setattr(util, "_reprcompare", my_reprcompare1) - def f(): + def f1() -> None: assert 42 < 3 - assert getmsg(f) == "assert 42" + assert getmsg(f1) == "assert 42" - def my_reprcompare(op, left, right): + def my_reprcompare2(op, left, right) -> str: return "{} {} {}".format(left, op, right) - monkeypatch.setattr(util, "_reprcompare", my_reprcompare) + monkeypatch.setattr(util, "_reprcompare", my_reprcompare2) - def f(): + def f2() -> None: assert 1 < 3 < 5 <= 4 < 7 - assert getmsg(f) == "assert 5 <= 4" + assert getmsg(f2) == "assert 5 <= 4" - def test_assert_raising__bool__in_comparison(self): - def f(): + def test_assert_raising__bool__in_comparison(self) -> None: + def f() -> None: class A: def __bool__(self): raise ValueError(42) @@ -652,21 +673,25 @@ class TestAssertionRewrite: def __repr__(self): return "" - def myany(x): + def myany(x) -> bool: return False assert myany(A() < 0) - assert " < 0" in getmsg(f) + msg = getmsg(f) + assert msg is not None + assert " < 0" in msg - def test_formatchar(self): - def f(): - assert "%test" == "test" + def test_formatchar(self) -> None: + def f() -> None: + assert "%test" == "test" # type: ignore[comparison-overlap] # noqa: F821 - assert getmsg(f).startswith("assert '%test' == 'test'") + msg = getmsg(f) + assert msg is not None + assert msg.startswith("assert '%test' == 'test'") - def test_custom_repr(self, request): - def f(): + def test_custom_repr(self, request) -> None: + def f() -> None: class Foo: a = 1 @@ -676,14 +701,16 @@ class TestAssertionRewrite: f = Foo() assert 0 == f.a - lines = util._format_lines([getmsg(f)]) + msg = getmsg(f) + assert msg is not None + lines = util._format_lines([msg]) if request.config.getoption("verbose") > 0: assert lines == ["assert 0 == 1\n +0\n -1"] else: assert lines == ["assert 0 == 1\n + where 1 = \\n{ \\n~ \\n}.a"] - def test_custom_repr_non_ascii(self): - def f(): + def test_custom_repr_non_ascii(self) -> None: + def f() -> None: class A: name = "ä" @@ -694,6 +721,7 @@ class TestAssertionRewrite: assert not a.name msg = getmsg(f) + assert msg is not None assert "UnicodeDecodeError" not in msg assert "UnicodeEncodeError" not in msg @@ -895,6 +923,7 @@ def test_rewritten(): hook, "_warn_already_imported", lambda code, msg: warnings.append(msg) ) spec = hook.find_spec("test_remember_rewritten_modules") + assert spec is not None module = importlib.util.module_from_spec(spec) hook.exec_module(module) hook.mark_rewrite("test_remember_rewritten_modules") @@ -1007,7 +1036,7 @@ class TestAssertionRewriteHookDetails: result = testdir.runpytest_subprocess() result.assert_outcomes(passed=1) - def test_read_pyc(self, tmpdir): + def test_read_pyc(self, tmp_path: Path) -> None: """ Ensure that the `_read_pyc` can properly deal with corrupted pyc files. In those circumstances it should just give up instead of generating @@ -1016,18 +1045,18 @@ class TestAssertionRewriteHookDetails: import py_compile from _pytest.assertion.rewrite import _read_pyc - source = tmpdir.join("source.py") - pyc = source + "c" + source = tmp_path / "source.py" + pyc = Path(str(source) + "c") - source.write("def test(): pass") + source.write_text("def test(): pass") py_compile.compile(str(source), str(pyc)) - contents = pyc.read(mode="rb") + contents = pyc.read_bytes() strip_bytes = 20 # header is around 8 bytes, strip a little more assert len(contents) > strip_bytes - pyc.write(contents[:strip_bytes], mode="wb") + pyc.write_bytes(contents[:strip_bytes]) - assert _read_pyc(str(source), str(pyc)) is None # no error + assert _read_pyc(source, pyc) is None # no error def test_reload_is_same_and_reloads(self, testdir: Testdir) -> None: """Reloading a (collected) module after change picks up the change.""" @@ -1178,17 +1207,17 @@ def test_source_mtime_long_long(testdir, offset): assert result.ret == 0 -def test_rewrite_infinite_recursion(testdir, pytestconfig, monkeypatch): +def test_rewrite_infinite_recursion(testdir, pytestconfig, monkeypatch) -> None: """Fix infinite recursion when writing pyc files: if an import happens to be triggered when writing the pyc file, this would cause another call to the hook, which would trigger another pyc writing, which could trigger another import, and so on. (#3506)""" - from _pytest.assertion import rewrite + from _pytest.assertion import rewrite as rewritemod testdir.syspathinsert() testdir.makepyfile(test_foo="def test_foo(): pass") testdir.makepyfile(test_bar="def test_bar(): pass") - original_write_pyc = rewrite._write_pyc + original_write_pyc = rewritemod._write_pyc write_pyc_called = [] @@ -1199,7 +1228,7 @@ def test_rewrite_infinite_recursion(testdir, pytestconfig, monkeypatch): assert hook.find_spec("test_bar") is None return original_write_pyc(*args, **kwargs) - monkeypatch.setattr(rewrite, "_write_pyc", spy_write_pyc) + monkeypatch.setattr(rewritemod, "_write_pyc", spy_write_pyc) monkeypatch.setattr(sys, "dont_write_bytecode", False) hook = AssertionRewritingHook(pytestconfig) @@ -1212,14 +1241,14 @@ def test_rewrite_infinite_recursion(testdir, pytestconfig, monkeypatch): class TestEarlyRewriteBailout: @pytest.fixture - def hook(self, pytestconfig, monkeypatch, testdir): + def hook(self, pytestconfig, monkeypatch, testdir) -> AssertionRewritingHook: """Returns a patched AssertionRewritingHook instance so we can configure its initial paths and track if PathFinder.find_spec has been called. """ import importlib.machinery - self.find_spec_calls = [] - self.initial_paths = set() + self.find_spec_calls = [] # type: List[str] + self.initial_paths = set() # type: Set[py.path.local] class StubSession: _initialpaths = self.initial_paths @@ -1229,17 +1258,17 @@ class TestEarlyRewriteBailout: def spy_find_spec(name, path): self.find_spec_calls.append(name) - return importlib.machinery.PathFinder.find_spec(name, path) + return importlib.machinery.PathFinder.find_spec(name, path) # type: ignore hook = AssertionRewritingHook(pytestconfig) # use default patterns, otherwise we inherit pytest's testing config hook.fnpats[:] = ["test_*.py", "*_test.py"] monkeypatch.setattr(hook, "_find_spec", spy_find_spec) - hook.set_session(StubSession()) + hook.set_session(StubSession()) # type: ignore[arg-type] # noqa: F821 testdir.syspathinsert() return hook - def test_basic(self, testdir, hook): + def test_basic(self, testdir, hook: AssertionRewritingHook) -> None: """ Ensure we avoid calling PathFinder.find_spec when we know for sure a certain module will not be rewritten to optimize assertion rewriting (#3918). @@ -1272,7 +1301,9 @@ class TestEarlyRewriteBailout: assert hook.find_spec("foobar") is not None assert self.find_spec_calls == ["conftest", "test_foo", "foobar"] - def test_pattern_contains_subdirectories(self, testdir, hook): + def test_pattern_contains_subdirectories( + self, testdir, hook: AssertionRewritingHook + ) -> None: """If one of the python_files patterns contain subdirectories ("tests/**.py") we can't bailout early because we need to match with the full path, which can only be found by calling PathFinder.find_spec """ @@ -1515,17 +1546,17 @@ def test_get_assertion_exprs(src, expected): assert _get_assertion_exprs(src) == expected -def test_try_makedirs(monkeypatch, tmp_path): +def test_try_makedirs(monkeypatch, tmp_path: Path) -> None: from _pytest.assertion.rewrite import try_makedirs p = tmp_path / "foo" # create - assert try_makedirs(str(p)) + assert try_makedirs(p) assert p.is_dir() # already exist - assert try_makedirs(str(p)) + assert try_makedirs(p) # monkeypatch to simulate all error situations def fake_mkdir(p, exist_ok=False, *, exc): @@ -1533,25 +1564,25 @@ def test_try_makedirs(monkeypatch, tmp_path): raise exc monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=FileNotFoundError())) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=NotADirectoryError())) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=PermissionError())) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) err = OSError() err.errno = errno.EROFS monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err)) - assert not try_makedirs(str(p)) + assert not try_makedirs(p) # unhandled OSError should raise err = OSError() err.errno = errno.ECHILD monkeypatch.setattr(os, "makedirs", partial(fake_mkdir, exc=err)) with pytest.raises(OSError) as exc_info: - try_makedirs(str(p)) + try_makedirs(p) assert exc_info.value.errno == errno.ECHILD diff --git a/testing/test_capture.py b/testing/test_capture.py index 1301a0e69..9e5036a66 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -6,7 +6,9 @@ import sys import textwrap from io import UnsupportedOperation from typing import BinaryIO +from typing import cast from typing import Generator +from typing import TextIO import pytest from _pytest import capture @@ -1351,7 +1353,7 @@ def test_error_attribute_issue555(testdir): not sys.platform.startswith("win") and sys.version_info[:2] >= (3, 6), reason="only py3.6+ on windows", ) -def test_py36_windowsconsoleio_workaround_non_standard_streams(): +def test_py36_windowsconsoleio_workaround_non_standard_streams() -> None: """ Ensure _py36_windowsconsoleio_workaround function works with objects that do not implement the full ``io``-based stream protocol, for example execnet channels (#2666). @@ -1362,7 +1364,7 @@ def test_py36_windowsconsoleio_workaround_non_standard_streams(): def write(self, s): pass - stream = DummyStream() + stream = cast(TextIO, DummyStream()) _py36_windowsconsoleio_workaround(stream) diff --git a/testing/test_collection.py b/testing/test_collection.py index dfbfe9ba8..8e5d5aacc 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -634,13 +634,14 @@ class TestSession: class Test_getinitialnodes: - def test_global_file(self, testdir, tmpdir): + def test_global_file(self, testdir, tmpdir) -> None: x = tmpdir.ensure("x.py") with tmpdir.as_cwd(): config = testdir.parseconfigure(x) col = testdir.getnode(config, x) assert isinstance(col, pytest.Module) assert col.name == "x.py" + assert col.parent is not None assert col.parent.parent is None for col in col.listchain(): assert col.config is config diff --git a/testing/test_config.py b/testing/test_config.py index c102202ed..867012e93 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -2,6 +2,9 @@ import os import re import sys import textwrap +from typing import Dict +from typing import List +from typing import Sequence import py.path @@ -264,9 +267,9 @@ class TestConfigCmdlineParsing: class TestConfigAPI: - def test_config_trace(self, testdir): + def test_config_trace(self, testdir) -> None: config = testdir.parseconfig() - values = [] + values = [] # type: List[str] config.trace.root.setwriter(values.append) config.trace("hello") assert len(values) == 1 @@ -519,9 +522,9 @@ class TestConfigFromdictargs: assert config.option.capture == "no" assert config.args == args - def test_invocation_params_args(self, _sys_snapshot): + def test_invocation_params_args(self, _sys_snapshot) -> None: """Show that fromdictargs can handle args in their "orig" format""" - option_dict = {} + option_dict = {} # type: Dict[str, object] args = ["-vvvv", "-s", "a", "b"] config = Config.fromdictargs(option_dict, args) @@ -566,8 +569,8 @@ class TestConfigFromdictargs: assert config.inicfg.get("should_not_be_set") is None -def test_options_on_small_file_do_not_blow_up(testdir): - def runfiletest(opts): +def test_options_on_small_file_do_not_blow_up(testdir) -> None: + def runfiletest(opts: Sequence[str]) -> None: reprec = testdir.inline_run(*opts) passed, skipped, failed = reprec.countoutcomes() assert failed == 2 @@ -580,19 +583,16 @@ def test_options_on_small_file_do_not_blow_up(testdir): """ ) - for opts in ( - [], - ["-l"], - ["-s"], - ["--tb=no"], - ["--tb=short"], - ["--tb=long"], - ["--fulltrace"], - ["--traceconfig"], - ["-v"], - ["-v", "-v"], - ): - runfiletest(opts + [path]) + runfiletest([path]) + runfiletest(["-l", path]) + runfiletest(["-s", path]) + runfiletest(["--tb=no", path]) + runfiletest(["--tb=short", path]) + runfiletest(["--tb=long", path]) + runfiletest(["--fulltrace", path]) + runfiletest(["--traceconfig", path]) + runfiletest(["-v", path]) + runfiletest(["-v", "-v", path]) def test_preparse_ordering_with_setuptools(testdir, monkeypatch): @@ -1360,7 +1360,7 @@ def test_invocation_args(testdir): # args cannot be None with pytest.raises(TypeError): - Config.InvocationParams(args=None, plugins=None, dir=Path()) + Config.InvocationParams(args=None, plugins=None, dir=Path()) # type: ignore[arg-type] # noqa: F821 @pytest.mark.parametrize( diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 00af4a088..948b621f7 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -50,7 +50,7 @@ def custom_pdb_calls(): def interaction(self, *args): called.append("interaction") - _pytest._CustomPdb = _CustomPdb + _pytest._CustomPdb = _CustomPdb # type: ignore return called @@ -73,9 +73,9 @@ def custom_debugger_hook(): print("**CustomDebugger**") called.append("set_trace") - _pytest._CustomDebugger = _CustomDebugger + _pytest._CustomDebugger = _CustomDebugger # type: ignore yield called - del _pytest._CustomDebugger + del _pytest._CustomDebugger # type: ignore class TestPDB: @@ -895,7 +895,7 @@ class TestDebuggingBreakpoints: if sys.version_info >= (3, 7): assert SUPPORTS_BREAKPOINT_BUILTIN is True if sys.version_info.major == 3 and sys.version_info.minor == 5: - assert SUPPORTS_BREAKPOINT_BUILTIN is False + assert SUPPORTS_BREAKPOINT_BUILTIN is False # type: ignore[comparison-overlap] @pytest.mark.skipif( not SUPPORTS_BREAKPOINT_BUILTIN, reason="Requires breakpoint() builtin" diff --git a/testing/test_doctest.py b/testing/test_doctest.py index c3ba60deb..2b98b5267 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1,5 +1,7 @@ import inspect import textwrap +from typing import Callable +from typing import Optional import pytest from _pytest.compat import MODULE_NOT_FOUND_ERROR @@ -1477,7 +1479,9 @@ class Broken: @pytest.mark.parametrize( # pragma: no branch (lambdas are not called) "stop", [None, _is_mocked, lambda f: None, lambda f: False, lambda f: True] ) -def test_warning_on_unwrap_of_broken_object(stop): +def test_warning_on_unwrap_of_broken_object( + stop: Optional[Callable[[object], object]] +) -> None: bad_instance = Broken() assert inspect.unwrap.__module__ == "inspect" with _patch_unwrap_mock_aware(): @@ -1486,7 +1490,7 @@ def test_warning_on_unwrap_of_broken_object(stop): pytest.PytestWarning, match="^Got KeyError.* when unwrapping" ): with pytest.raises(KeyError): - inspect.unwrap(bad_instance, stop=stop) + inspect.unwrap(bad_instance, stop=stop) # type: ignore[arg-type] # noqa: F821 assert inspect.unwrap.__module__ == "inspect" diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 83e61e1d9..d7771cc97 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1,16 +1,22 @@ import os import platform from datetime import datetime +from typing import cast +from typing import List +from typing import Tuple from xml.dom import minidom import py import xmlschema import pytest +from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config from _pytest.junitxml import bin_xml_escape from _pytest.junitxml import LogXML from _pytest.pathlib import Path from _pytest.reports import BaseReport +from _pytest.reports import TestReport from _pytest.store import Store @@ -860,10 +866,13 @@ def test_mangle_test_address(): assert newnames == ["a.my.py.thing", "Class", "method", "[a-1-::]"] -def test_dont_configure_on_slaves(tmpdir): - gotten = [] +def test_dont_configure_on_slaves(tmpdir) -> None: + gotten = [] # type: List[object] class FakeConfig: + if TYPE_CHECKING: + slaveinput = None + def __init__(self): self.pluginmanager = self self.option = self @@ -877,7 +886,7 @@ def test_dont_configure_on_slaves(tmpdir): xmlpath = str(tmpdir.join("junix.xml")) register = gotten.append - fake_config = FakeConfig() + fake_config = cast(Config, FakeConfig()) from _pytest import junitxml junitxml.pytest_configure(fake_config) @@ -1089,18 +1098,18 @@ def test_double_colon_split_method_issue469(testdir, run_and_parse): node.assert_attr(name="test_func[double::colon]") -def test_unicode_issue368(testdir): +def test_unicode_issue368(testdir) -> None: path = testdir.tmpdir.join("test.xml") log = LogXML(str(path), None) ustr = "ВНИ!" class Report(BaseReport): longrepr = ustr - sections = [] + sections = [] # type: List[Tuple[str, str]] nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" - test_report = Report() + test_report = cast(TestReport, Report()) # hopefully this is not too brittle ... log.pytest_sessionstart() @@ -1113,7 +1122,7 @@ def test_unicode_issue368(testdir): node_reporter.append_skipped(test_report) test_report.longrepr = "filename", 1, "Skipped: 卡嘣嘣" node_reporter.append_skipped(test_report) - test_report.wasxfail = ustr + test_report.wasxfail = ustr # type: ignore[attr-defined] # noqa: F821 node_reporter.append_skipped(test_report) log.pytest_sessionfinish() @@ -1363,17 +1372,17 @@ def test_fancy_items_regression(testdir, run_and_parse): @parametrize_families -def test_global_properties(testdir, xunit_family): +def test_global_properties(testdir, xunit_family) -> None: path = testdir.tmpdir.join("test_global_properties.xml") log = LogXML(str(path), None, family=xunit_family) class Report(BaseReport): - sections = [] + sections = [] # type: List[Tuple[str, str]] nodeid = "test_node_id" log.pytest_sessionstart() - log.add_global_property("foo", 1) - log.add_global_property("bar", 2) + log.add_global_property("foo", "1") + log.add_global_property("bar", "2") log.pytest_sessionfinish() dom = minidom.parse(str(path)) @@ -1397,19 +1406,19 @@ def test_global_properties(testdir, xunit_family): assert actual == expected -def test_url_property(testdir): +def test_url_property(testdir) -> None: test_url = "http://www.github.com/pytest-dev" path = testdir.tmpdir.join("test_url_property.xml") log = LogXML(str(path), None) class Report(BaseReport): longrepr = "FooBarBaz" - sections = [] + sections = [] # type: List[Tuple[str, str]] nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" url = test_url - test_report = Report() + test_report = cast(TestReport, Report()) log.pytest_sessionstart() node_reporter = log._opentestcase(test_report) diff --git a/testing/test_mark.py b/testing/test_mark.py index c14f770da..cdd4df9dd 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -13,14 +13,14 @@ from _pytest.nodes import Node class TestMark: @pytest.mark.parametrize("attr", ["mark", "param"]) @pytest.mark.parametrize("modulename", ["py.test", "pytest"]) - def test_pytest_exists_in_namespace_all(self, attr, modulename): + def test_pytest_exists_in_namespace_all(self, attr: str, modulename: str) -> None: module = sys.modules[modulename] - assert attr in module.__all__ + assert attr in module.__all__ # type: ignore - def test_pytest_mark_notcallable(self): + def test_pytest_mark_notcallable(self) -> None: mark = Mark() with pytest.raises(TypeError): - mark() + mark() # type: ignore[operator] # noqa: F821 def test_mark_with_param(self): def some_function(abc): @@ -30,10 +30,11 @@ class TestMark: pass assert pytest.mark.foo(some_function) is some_function - assert pytest.mark.foo.with_args(some_function) is not some_function + marked_with_args = pytest.mark.foo.with_args(some_function) + assert marked_with_args is not some_function # type: ignore[comparison-overlap] # noqa: F821 assert pytest.mark.foo(SomeClass) is SomeClass - assert pytest.mark.foo.with_args(SomeClass) is not SomeClass + assert pytest.mark.foo.with_args(SomeClass) is not SomeClass # type: ignore[comparison-overlap] # noqa: F821 def test_pytest_mark_name_starts_with_underscore(self): mark = Mark() @@ -1044,9 +1045,9 @@ def test_markers_from_parametrize(testdir): result.assert_outcomes(passed=4) -def test_pytest_param_id_requires_string(): +def test_pytest_param_id_requires_string() -> None: with pytest.raises(TypeError) as excinfo: - pytest.param(id=True) + pytest.param(id=True) # type: ignore[arg-type] # noqa: F821 (msg,) = excinfo.value.args assert msg == "Expected id to be a string, got : True" diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 8c2fceb3f..1a3afbea9 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -2,6 +2,8 @@ import os import re import sys import textwrap +from typing import Dict +from typing import Generator import pytest from _pytest.compat import TYPE_CHECKING @@ -12,7 +14,7 @@ if TYPE_CHECKING: @pytest.fixture -def mp(): +def mp() -> Generator[MonkeyPatch, None, None]: cwd = os.getcwd() sys_path = list(sys.path) yield MonkeyPatch() @@ -20,14 +22,14 @@ def mp(): os.chdir(cwd) -def test_setattr(): +def test_setattr() -> None: class A: x = 1 monkeypatch = MonkeyPatch() pytest.raises(AttributeError, monkeypatch.setattr, A, "notexists", 2) monkeypatch.setattr(A, "y", 2, raising=False) - assert A.y == 2 + assert A.y == 2 # type: ignore monkeypatch.undo() assert not hasattr(A, "y") @@ -49,17 +51,17 @@ class TestSetattrWithImportPath: monkeypatch.setattr("os.path.abspath", lambda x: "hello2") assert os.path.abspath("123") == "hello2" - def test_string_expression_class(self, monkeypatch): + def test_string_expression_class(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("_pytest.config.Config", 42) import _pytest - assert _pytest.config.Config == 42 + assert _pytest.config.Config == 42 # type: ignore - def test_unicode_string(self, monkeypatch): + def test_unicode_string(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("_pytest.config.Config", 42) import _pytest - assert _pytest.config.Config == 42 + assert _pytest.config.Config == 42 # type: ignore monkeypatch.delattr("_pytest.config.Config") def test_wrong_target(self, monkeypatch): @@ -73,10 +75,10 @@ class TestSetattrWithImportPath: AttributeError, lambda: monkeypatch.setattr("os.path.qweqwe", None) ) - def test_unknown_attr_non_raising(self, monkeypatch): + def test_unknown_attr_non_raising(self, monkeypatch: MonkeyPatch) -> None: # https://github.com/pytest-dev/pytest/issues/746 monkeypatch.setattr("os.path.qweqwe", 42, raising=False) - assert os.path.qweqwe == 42 + assert os.path.qweqwe == 42 # type: ignore def test_delattr(self, monkeypatch): monkeypatch.delattr("os.path.abspath") @@ -123,8 +125,8 @@ def test_setitem(): assert d["x"] == 5 -def test_setitem_deleted_meanwhile(): - d = {} +def test_setitem_deleted_meanwhile() -> None: + d = {} # type: Dict[str, object] monkeypatch = MonkeyPatch() monkeypatch.setitem(d, "x", 2) del d["x"] @@ -148,8 +150,8 @@ def test_setenv_deleted_meanwhile(before): assert key not in os.environ -def test_delitem(): - d = {"x": 1} +def test_delitem() -> None: + d = {"x": 1} # type: Dict[str, object] monkeypatch = MonkeyPatch() monkeypatch.delitem(d, "x") assert "x" not in d @@ -241,7 +243,7 @@ def test_monkeypatch_plugin(testdir): assert tuple(res) == (1, 0, 0), res -def test_syspath_prepend(mp): +def test_syspath_prepend(mp: MonkeyPatch): old = list(sys.path) mp.syspath_prepend("world") mp.syspath_prepend("hello") @@ -253,7 +255,7 @@ def test_syspath_prepend(mp): assert sys.path == old -def test_syspath_prepend_double_undo(mp): +def test_syspath_prepend_double_undo(mp: MonkeyPatch): old_syspath = sys.path[:] try: mp.syspath_prepend("hello world") @@ -265,24 +267,24 @@ def test_syspath_prepend_double_undo(mp): sys.path[:] = old_syspath -def test_chdir_with_path_local(mp, tmpdir): +def test_chdir_with_path_local(mp: MonkeyPatch, tmpdir): mp.chdir(tmpdir) assert os.getcwd() == tmpdir.strpath -def test_chdir_with_str(mp, tmpdir): +def test_chdir_with_str(mp: MonkeyPatch, tmpdir): mp.chdir(tmpdir.strpath) assert os.getcwd() == tmpdir.strpath -def test_chdir_undo(mp, tmpdir): +def test_chdir_undo(mp: MonkeyPatch, tmpdir): cwd = os.getcwd() mp.chdir(tmpdir) mp.undo() assert os.getcwd() == cwd -def test_chdir_double_undo(mp, tmpdir): +def test_chdir_double_undo(mp: MonkeyPatch, tmpdir): mp.chdir(tmpdir.strpath) mp.undo() tmpdir.chdir() diff --git a/testing/test_nodes.py b/testing/test_nodes.py index 5bd31b342..e5d8ffd71 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -2,6 +2,7 @@ import py import pytest from _pytest import nodes +from _pytest.pytester import Testdir @pytest.mark.parametrize( @@ -17,19 +18,19 @@ from _pytest import nodes ("foo/bar", "foo/bar::TestBop", True), ), ) -def test_ischildnode(baseid, nodeid, expected): +def test_ischildnode(baseid: str, nodeid: str, expected: bool) -> None: result = nodes.ischildnode(baseid, nodeid) assert result is expected -def test_node_from_parent_disallowed_arguments(): +def test_node_from_parent_disallowed_arguments() -> None: with pytest.raises(TypeError, match="session is"): - nodes.Node.from_parent(None, session=None) + nodes.Node.from_parent(None, session=None) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="config is"): - nodes.Node.from_parent(None, config=None) + nodes.Node.from_parent(None, config=None) # type: ignore[arg-type] # noqa: F821 -def test_std_warn_not_pytestwarning(testdir): +def test_std_warn_not_pytestwarning(testdir: Testdir) -> None: items = testdir.getitems( """ def test(): @@ -40,24 +41,24 @@ def test_std_warn_not_pytestwarning(testdir): items[0].warn(UserWarning("some warning")) -def test__check_initialpaths_for_relpath(): +def test__check_initialpaths_for_relpath() -> None: """Ensure that it handles dirs, and does not always use dirname.""" cwd = py.path.local() - class FakeSession: + class FakeSession1: _initialpaths = [cwd] - assert nodes._check_initialpaths_for_relpath(FakeSession, cwd) == "" + assert nodes._check_initialpaths_for_relpath(FakeSession1, cwd) == "" sub = cwd.join("file") - class FakeSession: + class FakeSession2: _initialpaths = [cwd] - assert nodes._check_initialpaths_for_relpath(FakeSession, sub) == "file" + assert nodes._check_initialpaths_for_relpath(FakeSession2, sub) == "file" outside = py.path.local("/outside") - assert nodes._check_initialpaths_for_relpath(FakeSession, outside) is None + assert nodes._check_initialpaths_for_relpath(FakeSession2, outside) is None def test_failure_with_changed_cwd(testdir): diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index 86a42f9e8..0701641f8 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -1,10 +1,13 @@ +from typing import List +from typing import Union + import pytest class TestPasteCapture: @pytest.fixture - def pastebinlist(self, monkeypatch, request): - pastebinlist = [] + def pastebinlist(self, monkeypatch, request) -> List[Union[str, bytes]]: + pastebinlist = [] # type: List[Union[str, bytes]] plugin = request.config.pluginmanager.getplugin("pastebin") monkeypatch.setattr(plugin, "create_new_paste", pastebinlist.append) return pastebinlist diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 336f468a8..713687578 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -1,6 +1,7 @@ import os import sys import types +from typing import List import pytest from _pytest.config import ExitCode @@ -10,7 +11,7 @@ from _pytest.main import Session @pytest.fixture -def pytestpm(): +def pytestpm() -> PytestPluginManager: return PytestPluginManager() @@ -86,7 +87,7 @@ class TestPytestPluginInteractions: config.pluginmanager.register(A()) assert len(values) == 2 - def test_hook_tracing(self, _config_for_test): + def test_hook_tracing(self, _config_for_test) -> None: pytestpm = _config_for_test.pluginmanager # fully initialized with plugins saveindent = [] @@ -99,7 +100,7 @@ class TestPytestPluginInteractions: saveindent.append(pytestpm.trace.root.indent) raise ValueError() - values = [] + values = [] # type: List[str] pytestpm.trace.root.setwriter(values.append) undo = pytestpm.enable_tracing() try: @@ -215,20 +216,20 @@ class TestPytestPluginManager: assert pm.get_plugin("pytest_xyz") == mod assert pm.is_registered(mod) - def test_consider_module(self, testdir, pytestpm): + def test_consider_module(self, testdir, pytestpm: PytestPluginManager) -> None: testdir.syspathinsert() testdir.makepyfile(pytest_p1="#") testdir.makepyfile(pytest_p2="#") mod = types.ModuleType("temp") - mod.pytest_plugins = ["pytest_p1", "pytest_p2"] + mod.__dict__["pytest_plugins"] = ["pytest_p1", "pytest_p2"] pytestpm.consider_module(mod) assert pytestpm.get_plugin("pytest_p1").__name__ == "pytest_p1" assert pytestpm.get_plugin("pytest_p2").__name__ == "pytest_p2" - def test_consider_module_import_module(self, testdir, _config_for_test): + def test_consider_module_import_module(self, testdir, _config_for_test) -> None: pytestpm = _config_for_test.pluginmanager mod = types.ModuleType("x") - mod.pytest_plugins = "pytest_a" + mod.__dict__["pytest_plugins"] = "pytest_a" aplugin = testdir.makepyfile(pytest_a="#") reprec = testdir.make_hook_recorder(pytestpm) testdir.syspathinsert(aplugin.dirpath()) diff --git a/testing/test_reports.py b/testing/test_reports.py index 81778e27d..08ac014a4 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -32,7 +32,7 @@ class TestReportSerialization: assert test_b_call.outcome == "passed" assert test_b_call._to_json()["longrepr"] is None - def test_xdist_report_longrepr_reprcrash_130(self, testdir): + def test_xdist_report_longrepr_reprcrash_130(self, testdir) -> None: """Regarding issue pytest-xdist#130 This test came originally from test_remote.py in xdist (ca03269). @@ -50,6 +50,7 @@ class TestReportSerialization: rep.longrepr.sections.append(added_section) d = rep._to_json() a = TestReport._from_json(d) + assert a.longrepr is not None # Check assembled == rep assert a.__dict__.keys() == rep.__dict__.keys() for key in rep.__dict__.keys(): @@ -67,7 +68,7 @@ class TestReportSerialization: # Missing section attribute PR171 assert added_section in a.longrepr.sections - def test_reprentries_serialization_170(self, testdir): + def test_reprentries_serialization_170(self, testdir) -> None: """Regarding issue pytest-xdist#170 This test came originally from test_remote.py in xdist (ca03269). @@ -87,6 +88,7 @@ class TestReportSerialization: rep = reports[1] d = rep._to_json() a = TestReport._from_json(d) + assert a.longrepr is not None rep_entries = rep.longrepr.reprtraceback.reprentries a_entries = a.longrepr.reprtraceback.reprentries @@ -102,7 +104,7 @@ class TestReportSerialization: assert rep_entries[i].reprlocals.lines == a_entries[i].reprlocals.lines assert rep_entries[i].style == a_entries[i].style - def test_reprentries_serialization_196(self, testdir): + def test_reprentries_serialization_196(self, testdir) -> None: """Regarding issue pytest-xdist#196 This test came originally from test_remote.py in xdist (ca03269). @@ -122,6 +124,7 @@ class TestReportSerialization: rep = reports[1] d = rep._to_json() a = TestReport._from_json(d) + assert a.longrepr is not None rep_entries = rep.longrepr.reprtraceback.reprentries a_entries = a.longrepr.reprtraceback.reprentries @@ -157,6 +160,7 @@ class TestReportSerialization: assert newrep.failed == rep.failed assert newrep.skipped == rep.skipped if newrep.skipped and not hasattr(newrep, "wasxfail"): + assert newrep.longrepr is not None assert len(newrep.longrepr) == 3 assert newrep.outcome == rep.outcome assert newrep.when == rep.when @@ -316,7 +320,7 @@ 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) -> None: """Regression test for tracebacks without a reprcrash (#5971) This happens notably on exceptions raised by multiprocess.pool: the exception transfer @@ -367,7 +371,7 @@ class TestReportSerialization: reports = reprec.getreports("pytest_runtest_logreport") - def check_longrepr(longrepr): + def check_longrepr(longrepr) -> None: assert isinstance(longrepr, ExceptionChainRepr) assert len(longrepr.chain) == 2 entry1, entry2 = longrepr.chain @@ -378,6 +382,7 @@ class TestReportSerialization: assert "ValueError: value error" in str(tb2) assert fileloc1 is None + assert fileloc2 is not None assert fileloc2.message == "ValueError: value error" # 3 reports: setup/call/teardown: get the call report @@ -394,6 +399,7 @@ class TestReportSerialization: check_longrepr(loaded_report.longrepr) # for same reasons as previous test, ensure we don't blow up here + assert loaded_report.longrepr is not None loaded_report.longrepr.toterminal(tw_mock) def test_report_prevent_ConftestImportFailure_hiding_exception(self, testdir): diff --git a/testing/test_runner_xunit.py b/testing/test_runner_xunit.py index 0ff508d2c..1b5d97371 100644 --- a/testing/test_runner_xunit.py +++ b/testing/test_runner_xunit.py @@ -2,6 +2,8 @@ test correct setup/teardowns at module, class, and instance level """ +from typing import List + import pytest @@ -242,12 +244,12 @@ def test_setup_funcarg_setup_when_outer_scope_fails(testdir): @pytest.mark.parametrize("arg", ["", "arg"]) def test_setup_teardown_function_level_with_optional_argument( - testdir, monkeypatch, arg -): + testdir, monkeypatch, arg: str, +) -> None: """parameter to setup/teardown xunit-style functions parameter is now optional (#1728).""" import sys - trace_setups_teardowns = [] + trace_setups_teardowns = [] # type: List[str] monkeypatch.setattr( sys, "trace_setups_teardowns", trace_setups_teardowns, raising=False ) diff --git a/testing/test_skipping.py b/testing/test_skipping.py index f48e78364..a6f1a9c09 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -98,7 +98,7 @@ class TestEvaluator: expl = ev.getexplanation() assert expl == "condition: not hasattr(os, 'murks')" - def test_marked_skip_with_not_string(self, testdir): + def test_marked_skip_with_not_string(self, testdir) -> None: item = testdir.getitem( """ import pytest @@ -109,6 +109,7 @@ class TestEvaluator: ) ev = MarkEvaluator(item, "skipif") exc = pytest.raises(pytest.fail.Exception, ev.istrue) + assert exc.value.msg is not None assert ( """Failed: you need to specify reason=STRING when using booleans as conditions.""" in exc.value.msg @@ -869,7 +870,7 @@ def test_reportchars_all_error(testdir): result.stdout.fnmatch_lines(["ERROR*test_foo*"]) -def test_errors_in_xfail_skip_expressions(testdir): +def test_errors_in_xfail_skip_expressions(testdir) -> None: testdir.makepyfile( """ import pytest @@ -886,7 +887,8 @@ def test_errors_in_xfail_skip_expressions(testdir): ) result = testdir.runpytest() markline = " ^" - if hasattr(sys, "pypy_version_info") and sys.pypy_version_info < (6,): + pypy_version_info = getattr(sys, "pypy_version_info", None) + if pypy_version_info is not None and pypy_version_info < (6,): markline = markline[5:] elif sys.version_info >= (3, 8) or hasattr(sys, "pypy_version_info"): markline = markline[4:] diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 17fd29238..7d7c82ad6 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -6,6 +6,7 @@ import os import sys import textwrap from io import StringIO +from typing import cast from typing import Dict from typing import List from typing import Tuple @@ -17,9 +18,11 @@ import _pytest.config import _pytest.terminal import pytest from _pytest._io.wcwidth import wcswidth +from _pytest.config import Config from _pytest.config import ExitCode from _pytest.pytester import Testdir from _pytest.reports import BaseReport +from _pytest.reports import CollectReport from _pytest.terminal import _folded_skips from _pytest.terminal import _get_line_with_reprcrash_message from _pytest.terminal import _plugin_nameversions @@ -1043,17 +1046,17 @@ def test_color_yes_collection_on_non_atty(testdir, verbose): assert "collected 10 items" in result.stdout.str() -def test_getreportopt(): +def test_getreportopt() -> None: from _pytest.terminal import _REPORTCHARS_DEFAULT - class Config: + class FakeConfig: class Option: reportchars = _REPORTCHARS_DEFAULT disable_warnings = False option = Option() - config = Config() + config = cast(Config, FakeConfig()) assert _REPORTCHARS_DEFAULT == "fE" @@ -1994,7 +1997,7 @@ class TestProgressWithTeardown: output.stdout.re_match_lines([r"[\.E]{40} \s+ \[100%\]"]) -def test_skip_reasons_folding(): +def test_skip_reasons_folding() -> None: path = "xyz" lineno = 3 message = "justso" @@ -2003,28 +2006,28 @@ def test_skip_reasons_folding(): class X: pass - ev1 = X() + ev1 = cast(CollectReport, X()) ev1.when = "execute" ev1.skipped = True ev1.longrepr = longrepr - ev2 = X() + ev2 = cast(CollectReport, X()) ev2.when = "execute" ev2.longrepr = longrepr ev2.skipped = True # ev3 might be a collection report - ev3 = X() + ev3 = cast(CollectReport, X()) ev3.when = "collect" ev3.longrepr = longrepr ev3.skipped = True values = _folded_skips(py.path.local(), [ev1, ev2, ev3]) assert len(values) == 1 - num, fspath, lineno, reason = values[0] + num, fspath, lineno_, reason = values[0] assert num == 3 assert fspath == path - assert lineno == lineno + assert lineno_ == lineno assert reason == message @@ -2052,8 +2055,8 @@ def test_line_with_reprcrash(monkeypatch): def check(msg, width, expected): __tracebackhide__ = True if msg: - rep.longrepr.reprcrash.message = msg - actual = _get_line_with_reprcrash_message(config, rep(), width) + rep.longrepr.reprcrash.message = msg # type: ignore + actual = _get_line_with_reprcrash_message(config, rep(), width) # type: ignore assert actual == expected if actual != "{} {}".format(mocked_verbose_word, mocked_pos): diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 3316751fb..26a34c656 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -1,6 +1,8 @@ import os import stat import sys +from typing import Callable +from typing import List import attr @@ -263,10 +265,10 @@ class TestNumberedDir: lockfile.unlink() - def test_lock_register_cleanup_removal(self, tmp_path): + def test_lock_register_cleanup_removal(self, tmp_path: Path) -> None: lock = create_cleanup_lock(tmp_path) - registry = [] + registry = [] # type: List[Callable[..., None]] register_cleanup_lock_removal(lock, register=registry.append) (cleanup_func,) = registry @@ -285,7 +287,7 @@ class TestNumberedDir: assert not lock.exists() - def _do_cleanup(self, tmp_path): + def _do_cleanup(self, tmp_path: Path) -> None: self.test_make(tmp_path) cleanup_numbered_dir( root=tmp_path, @@ -367,7 +369,7 @@ class TestRmRf: assert not adir.is_dir() - def test_on_rm_rf_error(self, tmp_path): + def test_on_rm_rf_error(self, tmp_path: Path) -> None: adir = tmp_path / "dir" adir.mkdir() @@ -377,32 +379,32 @@ class TestRmRf: # unknown exception with pytest.warns(pytest.PytestWarning): - exc_info = (None, RuntimeError(), None) - on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) + exc_info1 = (None, RuntimeError(), None) + on_rm_rf_error(os.unlink, str(fn), exc_info1, start_path=tmp_path) assert fn.is_file() # we ignore FileNotFoundError - exc_info = (None, FileNotFoundError(), None) - assert not on_rm_rf_error(None, str(fn), exc_info, start_path=tmp_path) + exc_info2 = (None, FileNotFoundError(), None) + assert not on_rm_rf_error(None, str(fn), exc_info2, start_path=tmp_path) # unknown function with pytest.warns( pytest.PytestWarning, match=r"^\(rm_rf\) unknown function None when removing .*foo.txt:\nNone: ", ): - exc_info = (None, PermissionError(), None) - on_rm_rf_error(None, str(fn), exc_info, start_path=tmp_path) + exc_info3 = (None, PermissionError(), None) + on_rm_rf_error(None, str(fn), exc_info3, start_path=tmp_path) assert fn.is_file() # ignored function with pytest.warns(None) as warninfo: - exc_info = (None, PermissionError(), None) - on_rm_rf_error(os.open, str(fn), exc_info, start_path=tmp_path) + exc_info4 = (None, PermissionError(), None) + on_rm_rf_error(os.open, str(fn), exc_info4, start_path=tmp_path) assert fn.is_file() assert not [x.message for x in warninfo] - exc_info = (None, PermissionError(), None) - on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) + exc_info5 = (None, PermissionError(), None) + on_rm_rf_error(os.unlink, str(fn), exc_info5, start_path=tmp_path) assert not fn.is_file() diff --git a/testing/test_unittest.py b/testing/test_unittest.py index 74a36c41b..6ddc6186b 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1,4 +1,5 @@ import gc +from typing import List import pytest from _pytest.config import ExitCode @@ -1158,13 +1159,13 @@ def test_trace(testdir, monkeypatch): assert result.ret == 0 -def test_pdb_teardown_called(testdir, monkeypatch): +def test_pdb_teardown_called(testdir, monkeypatch) -> None: """Ensure tearDown() is always called when --pdb is given in the command-line. We delay the normal tearDown() calls when --pdb is given, so this ensures we are calling tearDown() eventually to avoid memory leaks when using --pdb. """ - teardowns = [] + teardowns = [] # type: List[str] monkeypatch.setattr( pytest, "test_pdb_teardown_called_teardowns", teardowns, raising=False ) @@ -1194,11 +1195,11 @@ def test_pdb_teardown_called(testdir, monkeypatch): @pytest.mark.parametrize("mark", ["@unittest.skip", "@pytest.mark.skip"]) -def test_pdb_teardown_skipped(testdir, monkeypatch, mark): +def test_pdb_teardown_skipped(testdir, monkeypatch, mark: str) -> None: """ With --pdb, setUp and tearDown should not be called for skipped tests. """ - tracked = [] + tracked = [] # type: List[str] monkeypatch.setattr(pytest, "test_pdb_teardown_skipped", tracked, raising=False) testdir.makepyfile( diff --git a/testing/test_warnings.py b/testing/test_warnings.py index ea7ab397d..e21ccf42a 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,5 +1,8 @@ import os import warnings +from typing import List +from typing import Optional +from typing import Tuple import pytest from _pytest.fixtures import FixtureRequest @@ -661,7 +664,9 @@ class TestStackLevel: @pytest.fixture def capwarn(self, testdir): class CapturedWarnings: - captured = [] + captured = ( + [] + ) # type: List[Tuple[warnings.WarningMessage, Optional[Tuple[str, int, str]]]] @classmethod def pytest_warning_recorded(cls, warning_message, when, nodeid, location): From 2b05faff0a0172dbc74b81f47528e56ad608839e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 May 2020 14:40:17 +0300 Subject: [PATCH 050/140] Improve types around repr_failure() --- src/_pytest/_code/code.py | 2 +- src/_pytest/doctest.py | 5 ++++- src/_pytest/nodes.py | 24 +++++++++++++++--------- src/_pytest/python.py | 6 +++++- src/_pytest/runner.py | 6 ++++-- src/_pytest/skipping.py | 3 ++- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 09b2c1af5..a40b23470 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -47,7 +47,7 @@ if TYPE_CHECKING: from typing_extensions import Literal from weakref import ReferenceType - _TracebackStyle = Literal["long", "short", "line", "no", "native", "value"] + _TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] class Code: diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index ab8085982..7aaacb481 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -300,7 +300,10 @@ class DoctestItem(pytest.Item): sys.stdout.write(out) sys.stderr.write(err) - def repr_failure(self, excinfo): + # TODO: Type ignored -- breaks Liskov Substitution. + def repr_failure( # type: ignore[override] # noqa: F821 + self, excinfo: ExceptionInfo[BaseException], + ) -> Union[str, TerminalRepr]: import doctest failures = ( diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 15f91343f..3757e0b27 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -17,9 +17,8 @@ import py import _pytest._code from _pytest._code import getfslineno -from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo -from _pytest._code.code import ReprExceptionInfo +from _pytest._code.code import TerminalRepr from _pytest.compat import cached_property from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING @@ -29,7 +28,6 @@ from _pytest.config import PytestPluginManager from _pytest.deprecated import NODE_USE_FROM_PARENT from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureLookupError -from _pytest.fixtures import FixtureLookupErrorRepr from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords @@ -43,6 +41,7 @@ if TYPE_CHECKING: # Imported here due to circular import. from _pytest.main import Session from _pytest.warning_types import PytestWarning + from _pytest._code.code import _TracebackStyle SEP = "/" @@ -355,8 +354,10 @@ class Node(metaclass=NodeMeta): pass def _repr_failure_py( - self, excinfo: ExceptionInfo[BaseException], style=None, - ) -> Union[str, ReprExceptionInfo, ExceptionChainRepr, FixtureLookupErrorRepr]: + self, + excinfo: ExceptionInfo[BaseException], + style: "Optional[_TracebackStyle]" = None, + ) -> TerminalRepr: if isinstance(excinfo.value, ConftestImportFailure): excinfo = ExceptionInfo(excinfo.value.excinfo) if isinstance(excinfo.value, fail.Exception): @@ -406,8 +407,10 @@ class Node(metaclass=NodeMeta): ) def repr_failure( - self, excinfo, style=None - ) -> Union[str, ReprExceptionInfo, ExceptionChainRepr, FixtureLookupErrorRepr]: + self, + excinfo: ExceptionInfo[BaseException], + style: "Optional[_TracebackStyle]" = None, + ) -> Union[str, TerminalRepr]: """ Return a representation of a collection or test failure. @@ -453,13 +456,16 @@ class Collector(Node): """ raise NotImplementedError("abstract") - def repr_failure(self, excinfo): + # TODO: This omits the style= parameter which breaks Liskov Substitution. + def repr_failure( # type: ignore[override] # noqa: F821 + self, excinfo: ExceptionInfo[BaseException] + ) -> Union[str, TerminalRepr]: """ Return a representation of a collection failure. :param excinfo: Exception information for the failure. """ - if excinfo.errisinstance(self.CollectError) and not self.config.getoption( + if isinstance(excinfo.value, self.CollectError) and not self.config.getoption( "fulltrace", False ): exc = excinfo.value diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 41dd8b292..4b716c616 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -60,6 +60,7 @@ from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.pathlib import parts +from _pytest.reports import TerminalRepr from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning @@ -1591,7 +1592,10 @@ class Function(PyobjMixin, nodes.Item): for entry in excinfo.traceback[1:-1]: entry.set_repr_style("short") - def repr_failure(self, excinfo, outerr=None): + # TODO: Type ignored -- breaks Liskov Substitution. + def repr_failure( # type: ignore[override] # noqa: F821 + self, excinfo: ExceptionInfo[BaseException], outerr: None = None + ) -> Union[str, TerminalRepr]: assert outerr is None, "XXX outerr usage is deprecated" style = self.config.getoption("tbstyle", "auto") if style == "auto": diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index f89b67399..3ca8d7ea4 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -2,6 +2,7 @@ import bdb import os import sys +from typing import Any from typing import Callable from typing import cast from typing import Dict @@ -256,7 +257,7 @@ class CallInfo(Generic[_T]): """ _result = attr.ib(type="Optional[_T]") - excinfo = attr.ib(type=Optional[ExceptionInfo]) + excinfo = attr.ib(type=Optional[ExceptionInfo[BaseException]]) start = attr.ib(type=float) stop = attr.ib(type=float) duration = attr.ib(type=float) @@ -313,7 +314,8 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> TestReport: def pytest_make_collect_report(collector: Collector) -> CollectReport: call = CallInfo.from_call(lambda: list(collector.collect()), "collect") - longrepr = None + # TODO: Better typing for longrepr. + longrepr = None # type: Optional[Any] if not call.excinfo: outcome = "passed" # type: Literal["passed", "skipped", "failed"] else: diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 54621f111..bbd4593fd 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -148,7 +148,8 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]): elif item.config.option.runxfail: pass # don't interfere - elif call.excinfo and call.excinfo.errisinstance(xfail.Exception): + elif call.excinfo and isinstance(call.excinfo.value, xfail.Exception): + assert call.excinfo.value.msg is not None rep.wasxfail = "reason: " + call.excinfo.value.msg rep.outcome = "skipped" elif evalxfail and not rep.skipped and evalxfail.wasvalid() and evalxfail.istrue(): From 19ad5889353c7f5f2b65cc2acd346b7a9e95dfcd Mon Sep 17 00:00:00 2001 From: Xinbin Huang Date: Fri, 5 Jun 2020 04:10:16 -0700 Subject: [PATCH 051/140] Add reference to builtin markers to doc (#7321) Co-authored-by: Bruno Oliveira --- doc/en/mark.rst | 11 ++++++++--- doc/en/start_doc_server.sh | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 doc/en/start_doc_server.sh diff --git a/doc/en/mark.rst b/doc/en/mark.rst index 3899dab88..6fb665fdf 100644 --- a/doc/en/mark.rst +++ b/doc/en/mark.rst @@ -4,14 +4,19 @@ Marking test functions with attributes ====================================== By using the ``pytest.mark`` helper you can easily set -metadata on your test functions. There are -some builtin markers, for example: +metadata on your test functions. You can find the full list of builtin markers +in the :ref:`API Reference`. Or you can list all the markers, including +builtin and custom, using the CLI - :code:`pytest --markers`. +Here are some of the builtin markers: + +* :ref:`usefixtures ` - use fixtures on a test function or class +* :ref:`filterwarnings ` - filter certain warnings of a test function * :ref:`skip ` - always skip a test function * :ref:`skipif ` - skip a test function if a certain condition is met * :ref:`xfail ` - produce an "expected failure" outcome if a certain condition is met -* :ref:`parametrize ` to perform multiple calls +* :ref:`parametrize ` - perform multiple calls to the same test function. It's easy to create custom markers or to apply markers diff --git a/doc/en/start_doc_server.sh b/doc/en/start_doc_server.sh new file mode 100644 index 000000000..f68677409 --- /dev/null +++ b/doc/en/start_doc_server.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +MY_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "${MY_DIR}"/_build/html || exit +python -m http.server 8000 From 1deaa743452acb147c0cf1f3629fc52599c28a1d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 5 Jun 2020 15:52:08 +0300 Subject: [PATCH 052/140] mark/expression: prevent creation of illegal Python identifiers This is rejected by Python DEBUG builds, as well as regular builds in future versions. --- src/_pytest/mark/expression.py | 10 ++++++++-- testing/test_mark_expression.py | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py index 04c73411a..73b7bf169 100644 --- a/src/_pytest/mark/expression.py +++ b/src/_pytest/mark/expression.py @@ -127,6 +127,12 @@ class Scanner: ) +# True, False and None are legal match expression identifiers, +# but illegal as Python identifiers. To fix this, this prefix +# is added to identifiers in the conversion to Python AST. +IDENT_PREFIX = "$" + + def expression(s: Scanner) -> ast.Expression: if s.accept(TokenType.EOF): ret = ast.NameConstant(False) # type: ast.expr @@ -161,7 +167,7 @@ def not_expr(s: Scanner) -> ast.expr: return ret ident = s.accept(TokenType.IDENT) if ident: - return ast.Name(ident.value, ast.Load()) + return ast.Name(IDENT_PREFIX + ident.value, ast.Load()) s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT)) @@ -172,7 +178,7 @@ class MatcherAdapter(Mapping[str, bool]): self.matcher = matcher def __getitem__(self, key: str) -> bool: - return self.matcher(key) + return self.matcher(key[len(IDENT_PREFIX) :]) def __iter__(self) -> Iterator[str]: raise NotImplementedError() diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py index 335888618..faca02d93 100644 --- a/testing/test_mark_expression.py +++ b/testing/test_mark_expression.py @@ -130,6 +130,7 @@ def test_syntax_errors(expr: str, column: int, message: str) -> None: "123.232", "True", "False", + "None", "if", "else", "while", From 2a3c21645e5c303a71694c0ff68d0a56c2d734d5 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 6 Jun 2020 02:38:18 -0400 Subject: [PATCH 053/140] Commit solution thus far, needs to be polished up pre PR --- doc/en/reference.rst | 10 +++++ src/_pytest/config/__init__.py | 33 +++++++++++++--- testing/test_config.py | 71 ++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 7348636a2..d84d9d405 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1604,3 +1604,13 @@ passed multiple times. The expected format is ``name=value``. For example:: [pytest] xfail_strict = True + + +.. confval:: required_plugins + + A space seperated list of plugins that must be present for pytest to run + + .. code-block:: ini + + [pytest] + require_plugins = pluginA pluginB pluginC diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 27083900d..83878a486 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -952,6 +952,12 @@ class Config: self._parser.extra_info["inifile"] = self.inifile self._parser.addini("addopts", "extra command line options", "args") self._parser.addini("minversion", "minimally required pytest version") + self._parser.addini( + "require_plugins", + "plugins that must be present for pytest to run", + type="args", + default=[], + ) self._override_ini = ns.override_ini or () def _consider_importhook(self, args: Sequence[str]) -> None: @@ -1035,7 +1041,8 @@ class Config: self.known_args_namespace = ns = self._parser.parse_known_args( args, namespace=copy.copy(self.option) ) - self._validatekeys() + self._validate_keys() + self._validate_plugins() if self.known_args_namespace.confcutdir is None and self.inifile: confcutdir = py.path.local(self.inifile).dirname self.known_args_namespace.confcutdir = confcutdir @@ -1078,12 +1085,26 @@ class Config: ) ) - def _validatekeys(self): + def _validate_keys(self) -> None: for key in sorted(self._get_unknown_ini_keys()): - message = "Unknown config ini key: {}\n".format(key) - if self.known_args_namespace.strict_config: - fail(message, pytrace=False) - sys.stderr.write("WARNING: {}".format(message)) + self._emit_warning_or_fail("Unknown config ini key: {}\n".format(key)) + + def _validate_plugins(self) -> None: + # so iterate over all required plugins and see if pluginmanager hasplugin + # NOTE: This also account for -p no: ( e.g: -p no:celery ) + # raise ValueError(self._parser._inidict['requiredplugins']) + # raise ValueError(self.getini("requiredplugins")) + # raise ValueError(self.pluginmanager.hasplugin('debugging')) + for plugin in self.getini("require_plugins"): + if not self.pluginmanager.hasplugin(plugin): + self._emit_warning_or_fail( + "Missing required plugin: {}\n".format(plugin) + ) + + def _emit_warning_or_fail(self, message: str) -> None: + if self.known_args_namespace.strict_config: + fail(message, pytrace=False) + sys.stderr.write("WARNING: {}".format(message)) def _get_unknown_ini_keys(self) -> List[str]: parser_inicfg = self._parser._inidict diff --git a/testing/test_config.py b/testing/test_config.py index 867012e93..f88a9a0ce 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -212,6 +212,77 @@ class TestParseIni: with pytest.raises(pytest.fail.Exception, match=exception_text): testdir.runpytest("--strict-config") + @pytest.mark.parametrize( + "ini_file_text, stderr_output, exception_text", + [ + ( + """ + [pytest] + require_plugins = fakePlugin1 fakePlugin2 + """, + [ + "WARNING: Missing required plugin: fakePlugin1", + "WARNING: Missing required plugin: fakePlugin2", + ], + "Missing required plugin: fakePlugin1", + ), + ( + """ + [pytest] + require_plugins = a monkeypatch z + """, + [ + "WARNING: Missing required plugin: a", + "WARNING: Missing required plugin: z", + ], + "Missing required plugin: a", + ), + ( + """ + [pytest] + require_plugins = a monkeypatch z + addopts = -p no:monkeypatch + """, + [ + "WARNING: Missing required plugin: a", + "WARNING: Missing required plugin: monkeypatch", + "WARNING: Missing required plugin: z", + ], + "Missing required plugin: a", + ), + ( + """ + [some_other_header] + require_plugins = wont be triggered + [pytest] + minversion = 5.0.0 + """, + [], + "", + ), + ( + """ + [pytest] + minversion = 5.0.0 + """, + [], + "", + ), + ], + ) + def test_missing_required_plugins( + self, testdir, ini_file_text, stderr_output, exception_text + ): + testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text)) + testdir.parseconfig() + + result = testdir.runpytest() + result.stderr.fnmatch_lines(stderr_output) + + if stderr_output: + with pytest.raises(pytest.fail.Exception, match=exception_text): + testdir.runpytest("--strict-config") + class TestConfigCmdlineParsing: def test_parsing_again_fails(self, testdir): From f760b105efa12ebc14adccda3c840ad3a61936ef Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 6 Jun 2020 11:05:32 -0400 Subject: [PATCH 054/140] Touchup pre-PR --- changelog/7305.feature.rst | 3 +++ doc/en/reference.rst | 22 ++++++++++++---------- src/_pytest/config/__init__.py | 5 ----- 3 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 changelog/7305.feature.rst diff --git a/changelog/7305.feature.rst b/changelog/7305.feature.rst new file mode 100644 index 000000000..cf5a48c6e --- /dev/null +++ b/changelog/7305.feature.rst @@ -0,0 +1,3 @@ +A new INI key `require_plugins` has been added that allows the user to specify a list of plugins required for pytest to run. + +The `--strict-config` flag can be used to treat these warnings as errors. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index d84d9d405..1f1f2c423 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1561,6 +1561,18 @@ passed multiple times. The expected format is ``name=value``. For example:: See :ref:`change naming conventions` for more detailed examples. +.. confval:: require_plugins + + A space separated list of plugins that must be present for pytest to run. + If any one of the plugins is not found, emit a warning. + If pytest is run with ``--strict-config`` exceptions are raised in place of warnings. + + .. code-block:: ini + + [pytest] + require_plugins = pluginA pluginB pluginC + + .. confval:: testpaths @@ -1604,13 +1616,3 @@ passed multiple times. The expected format is ``name=value``. For example:: [pytest] xfail_strict = True - - -.. confval:: required_plugins - - A space seperated list of plugins that must be present for pytest to run - - .. code-block:: ini - - [pytest] - require_plugins = pluginA pluginB pluginC diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 83878a486..7d077d297 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1090,11 +1090,6 @@ class Config: self._emit_warning_or_fail("Unknown config ini key: {}\n".format(key)) def _validate_plugins(self) -> None: - # so iterate over all required plugins and see if pluginmanager hasplugin - # NOTE: This also account for -p no: ( e.g: -p no:celery ) - # raise ValueError(self._parser._inidict['requiredplugins']) - # raise ValueError(self.getini("requiredplugins")) - # raise ValueError(self.pluginmanager.hasplugin('debugging')) for plugin in self.getini("require_plugins"): if not self.pluginmanager.hasplugin(plugin): self._emit_warning_or_fail( From 3f6b3e7faa49c891e0b3036f07873296a73c8618 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 6 Jun 2020 11:33:28 -0400 Subject: [PATCH 055/140] update help for --strict-config --- src/_pytest/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 1c1cda18b..fd39b6ad7 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -78,7 +78,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--strict-config", action="store_true", - help="invalid ini keys for the `pytest` section of the configuration file raise errors.", + help="any warnings encountered while parsing the `pytest` section of the configuration file raise errors.", ) group._addoption( "--strict-markers", From ceac6736d772c68ebfbde21dcabcd179068f8498 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 6 Jun 2020 19:17:40 -0300 Subject: [PATCH 056/140] Fix mention using --rootdir mention inside pytest.ini (not supported) (#6825) Co-authored-by: Ran Benita --- doc/en/customize.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/en/customize.rst b/doc/en/customize.rst index 9554ab7b5..35c851ebb 100644 --- a/doc/en/customize.rst +++ b/doc/en/customize.rst @@ -39,8 +39,9 @@ Here's a summary what ``pytest`` uses ``rootdir`` for: influence how modules are imported. See :ref:`pythonpath` for more details. The ``--rootdir=path`` command-line option can be used to force a specific directory. -The directory passed may contain environment variables when it is used in conjunction -with ``addopts`` in a ``pytest.ini`` file. +Note that contrary to other command-line options, ``--rootdir`` cannot be used with +:confval:`addopts` inside ``pytest.ini`` because the ``rootdir`` is used to *find* ``pytest.ini`` +already. Finding the ``rootdir`` ~~~~~~~~~~~~~~~~~~~~~~~ From 42deba59e7d6cfe596414d0beff6fafaa14b02a3 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 6 Jun 2020 22:34:15 -0400 Subject: [PATCH 057/140] Update documentation as suggested --- changelog/7305.feature.rst | 2 +- doc/en/reference.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/7305.feature.rst b/changelog/7305.feature.rst index cf5a48c6e..8e8ae85ae 100644 --- a/changelog/7305.feature.rst +++ b/changelog/7305.feature.rst @@ -1,3 +1,3 @@ -A new INI key `require_plugins` has been added that allows the user to specify a list of plugins required for pytest to run. +New `require_plugins` configuration option allows the user to specify a list of plugins required for pytest to run. The `--strict-config` flag can be used to treat these warnings as errors. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 1f1f2c423..dc82fe239 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1570,7 +1570,7 @@ passed multiple times. The expected format is ``name=value``. For example:: .. code-block:: ini [pytest] - require_plugins = pluginA pluginB pluginC + require_plugins = pytest-xdist pytest-mock .. confval:: testpaths From d2bb67bfdafcbadd39f9551a52635188f54954e0 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 7 Jun 2020 14:10:20 -0400 Subject: [PATCH 058/140] validate plugins before keys in config files --- src/_pytest/config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 7d077d297..d55a5cdd7 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1041,8 +1041,8 @@ class Config: self.known_args_namespace = ns = self._parser.parse_known_args( args, namespace=copy.copy(self.option) ) - self._validate_keys() self._validate_plugins() + self._validate_keys() if self.known_args_namespace.confcutdir is None and self.inifile: confcutdir = py.path.local(self.inifile).dirname self.known_args_namespace.confcutdir = confcutdir From 13add4df43eef412bf7369926345e62eca0624b1 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sun, 7 Jun 2020 15:37:50 -0400 Subject: [PATCH 059/140] documentation fixes --- changelog/7305.feature.rst | 2 +- doc/en/reference.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/7305.feature.rst b/changelog/7305.feature.rst index 8e8ae85ae..b8c0ca693 100644 --- a/changelog/7305.feature.rst +++ b/changelog/7305.feature.rst @@ -1,3 +1,3 @@ -New `require_plugins` configuration option allows the user to specify a list of plugins required for pytest to run. +New `require_plugins` configuration option allows the user to specify a list of plugins required for pytest to run. Warnings are raised if these plugins are not found when running pytest. The `--strict-config` flag can be used to treat these warnings as errors. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index dc82fe239..6b270796c 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1570,7 +1570,7 @@ passed multiple times. The expected format is ``name=value``. For example:: .. code-block:: ini [pytest] - require_plugins = pytest-xdist pytest-mock + require_plugins = html xdist .. confval:: testpaths From c17d50829f3173c85a9810520458a112971d551c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 8 Jun 2020 10:03:10 -0300 Subject: [PATCH 060/140] Add pyproject.toml support (#7247) --- .gitignore | 1 + changelog/1556.feature.rst | 17 +++ doc/en/customize.rst | 223 ++++++++++++++++++---------- doc/en/example/pythoncollection.rst | 7 +- doc/en/example/simple.rst | 44 ++++++ doc/en/reference.rst | 14 +- pyproject.toml | 43 ++++++ setup.cfg | 1 + src/_pytest/config/__init__.py | 51 ++++--- src/_pytest/config/findpaths.py | 140 ++++++++++------- src/_pytest/pytester.py | 7 + src/_pytest/terminal.py | 2 +- testing/test_config.py | 139 ++++++++++++++--- testing/test_findpaths.py | 110 ++++++++++++++ testing/test_terminal.py | 8 +- tox.ini | 42 ------ 16 files changed, 611 insertions(+), 238 deletions(-) create mode 100644 changelog/1556.feature.rst create mode 100644 testing/test_findpaths.py diff --git a/.gitignore b/.gitignore index 83b6dbe73..faea9eac0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ doc/*/_changelog_towncrier_draft.rst build/ dist/ *.egg-info +htmlcov/ issue/ env/ .env/ diff --git a/changelog/1556.feature.rst b/changelog/1556.feature.rst new file mode 100644 index 000000000..402e772e6 --- /dev/null +++ b/changelog/1556.feature.rst @@ -0,0 +1,17 @@ +pytest now supports ``pyproject.toml`` files for configuration. + +The configuration options is similar to the one available in other formats, but must be defined +in a ``[tool.pytest.ini_options]`` table to be picked up by pytest: + +.. code-block:: toml + + # pyproject.toml + [tool.pytest.ini_options] + minversion = "6.0" + addopts = "-ra -q" + testpaths = [ + "tests", + "integration", + ] + +More information can be found `in the docs `__. diff --git a/doc/en/customize.rst b/doc/en/customize.rst index 35c851ebb..e1f1b253b 100644 --- a/doc/en/customize.rst +++ b/doc/en/customize.rst @@ -14,15 +14,112 @@ configurations files by using the general help option: This will display command line and configuration file settings which were registered by installed plugins. -.. _rootdir: -.. _inifiles: +.. _`config file formats`: -Initialization: determining rootdir and inifile ------------------------------------------------ +Configuration file formats +-------------------------- + +Many :ref:`pytest settings ` can be set in a *configuration file*, which +by convention resides on the root of your repository or in your +tests folder. + +A quick example of the configuration files supported by pytest: + +pytest.ini +~~~~~~~~~~ + +``pytest.ini`` files take precedence over other files, even when empty. + +.. code-block:: ini + + # pytest.ini + [pytest] + minversion = 6.0 + addopts = -ra -q + testpaths = + tests + integration + + +pyproject.toml +~~~~~~~~~~~~~~ + +.. versionadded:: 6.0 + +``pyproject.toml`` are considered for configuration when they contain a ``tool.pytest.ini_options`` table. + +.. code-block:: toml + + # pyproject.toml + [tool.pytest.ini_options] + minversion = "6.0" + addopts = "-ra -q" + testpaths = [ + "tests", + "integration", + ] + +.. note:: + + One might wonder why ``[tool.pytest.ini_options]`` instead of ``[tool.pytest]`` as is the + case with other tools. + + The reason is that the pytest team intends to fully utilize the rich TOML data format + for configuration in the future, reserving the ``[tool.pytest]`` table for that. + The ``ini_options`` table is being used, for now, as a bridge between the existing + ``.ini`` configuration system and the future configuration format. + +tox.ini +~~~~~~~ + +``tox.ini`` files are the configuration files of the `tox `__ project, +and can also be used to hold pytest configuration if they have a ``[pytest]`` section. + +.. code-block:: ini + + # tox.ini + [pytest] + minversion = 6.0 + addopts = -ra -q + testpaths = + tests + integration + + +setup.cfg +~~~~~~~~~ + +``setup.cfg`` files are general purpose configuration files, used originally by `distutils `__, and can also be used to hold pytest configuration +if they have a ``[tool:pytest]`` section. + +.. code-block:: ini + + # setup.cfg + [tool:pytest] + minversion = 6.0 + addopts = -ra -q + testpaths = + tests + integration + +.. warning:: + + Usage of ``setup.cfg`` is not recommended unless for very simple use cases. ``.cfg`` + files use a different parser than ``pytest.ini`` and ``tox.ini`` which might cause hard to track + down problems. + When possible, it is recommended to use the latter files, or ``pyproject.toml``, to hold your + pytest configuration. + + +.. _rootdir: +.. _configfiles: + +Initialization: determining rootdir and configfile +-------------------------------------------------- pytest determines a ``rootdir`` for each test run which depends on the command line arguments (specified test files, paths) and on -the existence of *ini-files*. The determined ``rootdir`` and *ini-file* are +the existence of configuration files. The determined ``rootdir`` and ``configfile`` are printed as part of the pytest header during startup. Here's a summary what ``pytest`` uses ``rootdir`` for: @@ -48,48 +145,47 @@ Finding the ``rootdir`` Here is the algorithm which finds the rootdir from ``args``: -- determine the common ancestor directory for the specified ``args`` that are +- Determine the common ancestor directory for the specified ``args`` that are recognised as paths that exist in the file system. If no such paths are found, the common ancestor directory is set to the current working directory. -- look for ``pytest.ini``, ``tox.ini`` and ``setup.cfg`` files in the ancestor - directory and upwards. If one is matched, it becomes the ini-file and its - directory becomes the rootdir. +- Look for ``pytest.ini``, ``pyproject.toml``, ``tox.ini``, and ``setup.cfg`` files in the ancestor + directory and upwards. If one is matched, it becomes the ``configfile`` and its + directory becomes the ``rootdir``. -- if no ini-file was found, look for ``setup.py`` upwards from the common +- If no configuration file was found, look for ``setup.py`` upwards from the common ancestor directory to determine the ``rootdir``. -- if no ``setup.py`` was found, look for ``pytest.ini``, ``tox.ini`` and +- If no ``setup.py`` was found, look for ``pytest.ini``, ``pyproject.toml``, ``tox.ini``, and ``setup.cfg`` in each of the specified ``args`` and upwards. If one is - matched, it becomes the ini-file and its directory becomes the rootdir. + matched, it becomes the ``configfile`` and its directory becomes the ``rootdir``. -- if no ini-file was found, use the already determined common ancestor as root +- If no ``configfile`` was found, use the already determined common ancestor as root directory. This allows the use of pytest in structures that are not part of - a package and don't have any particular ini-file configuration. + a package and don't have any particular configuration file. If no ``args`` are given, pytest collects test below the current working -directory and also starts determining the rootdir from there. +directory and also starts determining the ``rootdir`` from there. -:warning: custom pytest plugin commandline arguments may include a path, as in - ``pytest --log-output ../../test.log args``. Then ``args`` is mandatory, - otherwise pytest uses the folder of test.log for rootdir determination - (see also `issue 1435 `_). - A dot ``.`` for referencing to the current working directory is also - possible. +Files will only be matched for configuration if: -Note that an existing ``pytest.ini`` file will always be considered a match, -whereas ``tox.ini`` and ``setup.cfg`` will only match if they contain a -``[pytest]`` or ``[tool:pytest]`` section, respectively. Options from multiple ini-files candidates are never -merged - the first one wins (``pytest.ini`` always wins, even if it does not -contain a ``[pytest]`` section). +* ``pytest.ini``: will always match and take precedence, even if empty. +* ``pyproject.toml``: contains a ``[tool.pytest.ini_options]`` table. +* ``tox.ini``: contains a ``[pytest]`` section. +* ``setup.cfg``: contains a ``[tool:pytest]`` section. -The ``config`` object will subsequently carry these attributes: +The files are considered in the order above. Options from multiple ``configfiles`` candidates +are never merged - the first match wins. + +The internal :class:`Config <_pytest.config.Config>` object (accessible via hooks or through the :fixture:`pytestconfig` fixture) +will subsequently carry these attributes: - ``config.rootdir``: the determined root directory, guaranteed to exist. -- ``config.inifile``: the determined ini-file, may be ``None``. +- ``config.inifile``: the determined ``configfile``, may be ``None`` (it is named ``inifile`` + for historical reasons). -The rootdir is used as a reference directory for constructing test +The ``rootdir`` is used as a reference directory for constructing test addresses ("nodeids") and can be used also by plugins for storing per-testrun information. @@ -100,75 +196,38 @@ Example: pytest path/to/testdir path/other/ will determine the common ancestor as ``path`` and then -check for ini-files as follows: +check for configuration files as follows: .. code-block:: text # first look for pytest.ini files path/pytest.ini - path/tox.ini # must also contain [pytest] section to match - path/setup.cfg # must also contain [tool:pytest] section to match + path/pyproject.toml # must contain a [tool.pytest.ini_options] table to match + path/tox.ini # must contain [pytest] section to match + path/setup.cfg # must contain [tool:pytest] section to match pytest.ini - ... # all the way down to the root + ... # all the way up to the root # now look for setup.py path/setup.py setup.py - ... # all the way down to the root + ... # all the way up to the root + + +.. warning:: + + Custom pytest plugin commandline arguments may include a path, as in + ``pytest --log-output ../../test.log args``. Then ``args`` is mandatory, + otherwise pytest uses the folder of test.log for rootdir determination + (see also `issue 1435 `_). + A dot ``.`` for referencing to the current working directory is also + possible. .. _`how to change command line options defaults`: .. _`adding default options`: - -How to change command line options defaults ------------------------------------------------- - -It can be tedious to type the same series of command line options -every time you use ``pytest``. For example, if you always want to see -detailed info on skipped and xfailed tests, as well as have terser "dot" -progress output, you can write it into a configuration file: - -.. code-block:: ini - - # content of pytest.ini or tox.ini - [pytest] - addopts = -ra -q - - # content of setup.cfg - [tool:pytest] - addopts = -ra -q - -Alternatively, you can set a ``PYTEST_ADDOPTS`` environment variable to add command -line options while the environment is in use: - -.. code-block:: bash - - export PYTEST_ADDOPTS="-v" - -Here's how the command-line is built in the presence of ``addopts`` or the environment variable: - -.. code-block:: text - - $PYTEST_ADDOPTS - -So if the user executes in the command-line: - -.. code-block:: bash - - pytest -m slow - -The actual command line executed is: - -.. code-block:: bash - - pytest -ra -q -v -m slow - -Note that as usual for other command-line applications, in case of conflicting options the last one wins, so the example -above will show verbose output because ``-v`` overwrites ``-q``. - - Builtin configuration file options ---------------------------------------------- diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index d8261a949..30d106ada 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -115,15 +115,13 @@ Changing naming conventions You can configure different naming conventions by setting the :confval:`python_files`, :confval:`python_classes` and -:confval:`python_functions` configuration options. +:confval:`python_functions` in your :ref:`configuration file `. Here is an example: .. code-block:: ini # content of pytest.ini # Example 1: have pytest look for "check" instead of "test" - # can also be defined in tox.ini or setup.cfg file, although the section - # name in setup.cfg files should be "tool:pytest" [pytest] python_files = check_*.py python_classes = Check @@ -165,8 +163,7 @@ You can check for multiple glob patterns by adding a space between the patterns: .. code-block:: ini # Example 2: have pytest look for files with "test" and "example" - # content of pytest.ini, tox.ini, or setup.cfg file (replace "pytest" - # with "tool:pytest" for setup.cfg) + # content of pytest.ini [pytest] python_files = test_*.py example_*.py diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 3282bbda5..d1a1ecdfc 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -3,6 +3,50 @@ Basic patterns and examples ========================================================== +How to change command line options defaults +------------------------------------------- + +It can be tedious to type the same series of command line options +every time you use ``pytest``. For example, if you always want to see +detailed info on skipped and xfailed tests, as well as have terser "dot" +progress output, you can write it into a configuration file: + +.. code-block:: ini + + # content of pytest.ini + [pytest] + addopts = -ra -q + + +Alternatively, you can set a ``PYTEST_ADDOPTS`` environment variable to add command +line options while the environment is in use: + +.. code-block:: bash + + export PYTEST_ADDOPTS="-v" + +Here's how the command-line is built in the presence of ``addopts`` or the environment variable: + +.. code-block:: text + + $PYTEST_ADDOPTS + +So if the user executes in the command-line: + +.. code-block:: bash + + pytest -m slow + +The actual command line executed is: + +.. code-block:: bash + + pytest -ra -q -v -m slow + +Note that as usual for other command-line applications, in case of conflicting options the last one wins, so the example +above will show verbose output because ``-v`` overwrites ``-q``. + + .. _request example: Pass different values to a test function, depending on command line options diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 7348636a2..326b3e52a 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1019,17 +1019,17 @@ UsageError Configuration Options --------------------- -Here is a list of builtin configuration options that may be written in a ``pytest.ini``, ``tox.ini`` or ``setup.cfg`` -file, usually located at the root of your repository. All options must be under a ``[pytest]`` section -(``[tool:pytest]`` for ``setup.cfg`` files). +Here is a list of builtin configuration options that may be written in a ``pytest.ini``, ``pyproject.toml``, ``tox.ini`` or ``setup.cfg`` +file, usually located at the root of your repository. To see each file format in details, see +:ref:`config file formats`. .. warning:: - Usage of ``setup.cfg`` is not recommended unless for very simple use cases. ``.cfg`` + Usage of ``setup.cfg`` is not recommended except for very simple use cases. ``.cfg`` files use a different parser than ``pytest.ini`` and ``tox.ini`` which might cause hard to track down problems. - When possible, it is recommended to use the latter files to hold your pytest configuration. + When possible, it is recommended to use the latter files, or ``pyproject.toml``, to hold your pytest configuration. -Configuration file options may be overwritten in the command-line by using ``-o/--override-ini``, which can also be +Configuration options may be overwritten in the command-line by using ``-o/--override-ini``, which can also be passed multiple times. The expected format is ``name=value``. For example:: pytest -o console_output_style=classic -o cache_dir=/tmp/mycache @@ -1057,8 +1057,6 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: cache_dir - - Sets a directory where stores content of cache plugin. Default directory is ``.pytest_cache`` which is created in :ref:`rootdir `. Directory may be relative or absolute path. If setting relative path, then directory is created diff --git a/pyproject.toml b/pyproject.toml index aa57762e7..493213d84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,49 @@ requires = [ ] build-backend = "setuptools.build_meta" +[tool.pytest.ini_options] +minversion = "2.0" +addopts = "-rfEX -p pytester --strict-markers" +python_files = ["test_*.py", "*_test.py", "testing/*/*.py"] +python_classes = ["Test", "Acceptance"] +python_functions = ["test"] +# NOTE: "doc" is not included here, but gets tested explicitly via "doctesting". +testpaths = ["testing"] +norecursedirs = ["testing/example_scripts"] +xfail_strict = true +filterwarnings = [ + "error", + "default:Using or importing the ABCs:DeprecationWarning:unittest2.*", + "default:the imp module is deprecated in favour of importlib:DeprecationWarning:nose.*", + "ignore:Module already imported so cannot be rewritten:pytest.PytestWarning", + # produced by python3.6/site.py itself (3.6.7 on Travis, could not trigger it with 3.6.8)." + "ignore:.*U.*mode is deprecated:DeprecationWarning:(?!(pytest|_pytest))", + # produced by pytest-xdist + "ignore:.*type argument to addoption.*:DeprecationWarning", + # produced by python >=3.5 on execnet (pytest-xdist) + "ignore:.*inspect.getargspec.*deprecated, use inspect.signature.*:DeprecationWarning", + # pytest's own futurewarnings + "ignore::pytest.PytestExperimentalApiWarning", + # Do not cause SyntaxError for invalid escape sequences in py37. + # Those are caught/handled by pyupgrade, and not easy to filter with the + # module being the filename (with .py removed). + "default:invalid escape sequence:DeprecationWarning", + # ignore use of unregistered marks, because we use many to test the implementation + "ignore::_pytest.warning_types.PytestUnknownMarkWarning", +] +pytester_example_dir = "testing/example_scripts" +markers = [ + # dummy markers for testing + "foo", + "bar", + "baz", + # conftest.py reorders tests moving slow ones to the end of the list + "slow", + # experimental mark for all tests using pexpect + "uses_pexpect", +] + + [tool.towncrier] package = "pytest" package_dir = "src" diff --git a/setup.cfg b/setup.cfg index 5dc778d99..8749334f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,6 +46,7 @@ install_requires = packaging pluggy>=0.12,<1.0 py>=1.5.0 + toml atomicwrites>=1.0;sys_platform=="win32" colorama;sys_platform=="win32" importlib-metadata>=0.12;python_version<"3.8" diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 27083900d..4b1d0bd2a 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -34,7 +34,6 @@ import _pytest.hookspec # the extension point definitions from .exceptions import PrintHelp from .exceptions import UsageError from .findpaths import determine_setup -from .findpaths import exists from _pytest._code import ExceptionInfo from _pytest._code import filter_traceback from _pytest._io import TerminalWriter @@ -450,7 +449,7 @@ class PytestPluginManager(PluginManager): if i != -1: path = path[:i] anchor = current.join(path, abs=1) - if exists(anchor): # we found some file object + if anchor.exists(): # we found some file object self._try_load_conftest(anchor) foundanchor = True if not foundanchor: @@ -1069,13 +1068,8 @@ class Config: if Version(minver) > Version(pytest.__version__): raise pytest.UsageError( - "%s:%d: requires pytest-%s, actual pytest-%s'" - % ( - self.inicfg.config.path, - self.inicfg.lineof("minversion"), - minver, - pytest.__version__, - ) + "%s: 'minversion' requires pytest-%s, actual pytest-%s'" + % (self.inifile, minver, pytest.__version__,) ) def _validatekeys(self): @@ -1123,7 +1117,7 @@ class Config: x.append(line) # modifies the cached list inline def getini(self, name: str): - """ return configuration value from an :ref:`ini file `. If the + """ return configuration value from an :ref:`ini file `. If the specified name hasn't been registered through a prior :py:func:`parser.addini <_pytest.config.argparsing.Parser.addini>` call (usually from a plugin), a ValueError is raised. """ @@ -1138,8 +1132,8 @@ class Config: description, type, default = self._parser._inidict[name] except KeyError: raise ValueError("unknown configuration value: {!r}".format(name)) - value = self._get_override_ini_value(name) - if value is None: + override_value = self._get_override_ini_value(name) + if override_value is None: try: value = self.inicfg[name] except KeyError: @@ -1148,18 +1142,35 @@ class Config: if type is None: return "" return [] + else: + value = override_value + # coerce the values based on types + # note: some coercions are only required if we are reading from .ini files, because + # the file format doesn't contain type information, but when reading from toml we will + # get either str or list of str values (see _parse_ini_config_from_pyproject_toml). + # for example: + # + # ini: + # a_line_list = "tests acceptance" + # in this case, we need to split the string to obtain a list of strings + # + # toml: + # a_line_list = ["tests", "acceptance"] + # in this case, we already have a list ready to use + # if type == "pathlist": - dp = py.path.local(self.inicfg.config.path).dirpath() - values = [] - for relpath in shlex.split(value): - values.append(dp.join(relpath, abs=True)) - return values + dp = py.path.local(self.inifile).dirpath() + input_values = shlex.split(value) if isinstance(value, str) else value + return [dp.join(x, abs=True) for x in input_values] elif type == "args": - return shlex.split(value) + return shlex.split(value) if isinstance(value, str) else value elif type == "linelist": - return [t for t in map(lambda x: x.strip(), value.split("\n")) if t] + if isinstance(value, str): + return [t for t in map(lambda x: x.strip(), value.split("\n")) if t] + else: + return value elif type == "bool": - return bool(_strtobool(value.strip())) + return bool(_strtobool(str(value).strip())) else: assert type is None return value diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2b252c4f4..796fa9b0a 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -1,13 +1,13 @@ import os -from typing import Any +from typing import Dict from typing import Iterable from typing import List from typing import Optional from typing import Tuple +from typing import Union +import iniconfig import py -from iniconfig import IniConfig -from iniconfig import ParseError from .exceptions import UsageError from _pytest.compat import TYPE_CHECKING @@ -17,52 +17,95 @@ if TYPE_CHECKING: from . import Config -def exists(path, ignore=OSError): +def _parse_ini_config(path: py.path.local) -> iniconfig.IniConfig: + """Parses the given generic '.ini' file using legacy IniConfig parser, returning + the parsed object. + + Raises UsageError if the file cannot be parsed. + """ try: - return path.check() - except ignore: - return False + return iniconfig.IniConfig(path) + except iniconfig.ParseError as exc: + raise UsageError(str(exc)) -def getcfg(args, config=None): +def load_config_dict_from_file( + filepath: py.path.local, +) -> Optional[Dict[str, Union[str, List[str]]]]: + """Loads pytest configuration from the given file path, if supported. + + Return None if the file does not contain valid pytest configuration. """ - Search the list of arguments for a valid ini-file for pytest, + + # configuration from ini files are obtained from the [pytest] section, if present. + if filepath.ext == ".ini": + iniconfig = _parse_ini_config(filepath) + + if "pytest" in iniconfig: + return dict(iniconfig["pytest"].items()) + else: + # "pytest.ini" files are always the source of configuration, even if empty + if filepath.basename == "pytest.ini": + return {} + + # '.cfg' files are considered if they contain a "[tool:pytest]" section + elif filepath.ext == ".cfg": + iniconfig = _parse_ini_config(filepath) + + if "tool:pytest" in iniconfig.sections: + return dict(iniconfig["tool:pytest"].items()) + elif "pytest" in iniconfig.sections: + # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that + # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). + fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) + + # '.toml' files are considered if they contain a [tool.pytest.ini_options] table + elif filepath.ext == ".toml": + import toml + + config = toml.load(filepath) + + result = config.get("tool", {}).get("pytest", {}).get("ini_options", None) + if result is not None: + # TOML supports richer data types than ini files (strings, arrays, floats, ints, etc), + # however we need to convert all scalar values to str for compatibility with the rest + # of the configuration system, which expects strings only. + def make_scalar(v: object) -> Union[str, List[str]]: + return v if isinstance(v, list) else str(v) + + return {k: make_scalar(v) for k, v in result.items()} + + return None + + +def locate_config( + args: Iterable[Union[str, py.path.local]] +) -> Tuple[ + Optional[py.path.local], Optional[py.path.local], Dict[str, Union[str, List[str]]], +]: + """ + Search in the list of arguments for a valid ini-file for pytest, and return a tuple of (rootdir, inifile, cfg-dict). - - note: config is optional and used only to issue warnings explicitly (#2891). """ - inibasenames = ["pytest.ini", "tox.ini", "setup.cfg"] + config_names = [ + "pytest.ini", + "pyproject.toml", + "tox.ini", + "setup.cfg", + ] args = [x for x in args if not str(x).startswith("-")] if not args: args = [py.path.local()] for arg in args: arg = py.path.local(arg) for base in arg.parts(reverse=True): - for inibasename in inibasenames: - p = base.join(inibasename) - if exists(p): - try: - iniconfig = IniConfig(p) - except ParseError as exc: - raise UsageError(str(exc)) - - if ( - inibasename == "setup.cfg" - and "tool:pytest" in iniconfig.sections - ): - return base, p, iniconfig["tool:pytest"] - elif "pytest" in iniconfig.sections: - if inibasename == "setup.cfg" and config is not None: - - fail( - CFG_PYTEST_SECTION.format(filename=inibasename), - pytrace=False, - ) - return base, p, iniconfig["pytest"] - elif inibasename == "pytest.ini": - # allowed to be empty - return base, p, {} - return None, None, None + for config_name in config_names: + p = base.join(config_name) + if p.isfile(): + ini_config = load_config_dict_from_file(p) + if ini_config is not None: + return base, p, ini_config + return None, None, {} def get_common_ancestor(paths: Iterable[py.path.local]) -> py.path.local: @@ -118,29 +161,16 @@ def determine_setup( args: List[str], rootdir_cmd_arg: Optional[str] = None, config: Optional["Config"] = None, -) -> Tuple[py.path.local, Optional[str], Any]: +) -> Tuple[py.path.local, Optional[str], Dict[str, Union[str, List[str]]]]: + rootdir = None dirs = get_dirs_from_args(args) if inifile: - iniconfig = IniConfig(inifile) - is_cfg_file = str(inifile).endswith(".cfg") - sections = ["tool:pytest", "pytest"] if is_cfg_file else ["pytest"] - for section in sections: - try: - inicfg = iniconfig[ - section - ] # type: Optional[py.iniconfig._SectionWrapper] - if is_cfg_file and section == "pytest" and config is not None: - fail( - CFG_PYTEST_SECTION.format(filename=str(inifile)), pytrace=False - ) - break - except KeyError: - inicfg = None + inicfg = load_config_dict_from_file(py.path.local(inifile)) or {} if rootdir_cmd_arg is None: rootdir = get_common_ancestor(dirs) else: ancestor = get_common_ancestor(dirs) - rootdir, inifile, inicfg = getcfg([ancestor], config=config) + rootdir, inifile, inicfg = locate_config([ancestor]) if rootdir is None and rootdir_cmd_arg is None: for possible_rootdir in ancestor.parts(reverse=True): if possible_rootdir.join("setup.py").exists(): @@ -148,7 +178,7 @@ def determine_setup( break else: if dirs != [ancestor]: - rootdir, inifile, inicfg = getcfg(dirs, config=config) + rootdir, inifile, inicfg = locate_config(dirs) if rootdir is None: if config is not None: cwd = config.invocation_dir diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 8df5992d6..2913c6065 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -688,6 +688,13 @@ class Testdir: p = self.makeini(source) return IniConfig(p)["pytest"] + def makepyprojecttoml(self, source): + """Write a pyproject.toml file with 'source' as contents. + + .. versionadded:: 6.0 + """ + return self.makefile(".toml", pyproject=source) + def makepyfile(self, *args, **kwargs): r"""Shortcut for .makefile() with a .py extension. Defaults to the test name with a '.py' extension, e.g test_foobar.py, overwriting diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index b37828e5a..9c2665fb8 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -691,7 +691,7 @@ class TerminalReporter: line = "rootdir: %s" % config.rootdir if config.inifile: - line += ", inifile: " + config.rootdir.bestrelpath(config.inifile) + line += ", configfile: " + config.rootdir.bestrelpath(config.inifile) testpaths = config.getini("testpaths") if testpaths and config.args == testpaths: diff --git a/testing/test_config.py b/testing/test_config.py index 867012e93..31dfd9fa3 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -18,7 +18,7 @@ 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.config.findpaths import locate_config from _pytest.pathlib import Path @@ -39,14 +39,14 @@ class TestParseIni: ) ) ) - _, _, cfg = getcfg([sub]) + _, _, cfg = locate_config([sub]) assert cfg["name"] == "value" config = testdir.parseconfigure(sub) assert config.inicfg["name"] == "value" def test_getcfg_empty_path(self): """correctly handle zero length arguments (a la pytest '')""" - getcfg([""]) + locate_config([""]) def test_setupcfg_uses_toolpytest_with_pytest(self, testdir): p1 = testdir.makepyfile("def test(): pass") @@ -61,7 +61,7 @@ class TestParseIni: % p1.basename, ) result = testdir.runpytest() - result.stdout.fnmatch_lines(["*, inifile: setup.cfg, *", "* 1 passed in *"]) + result.stdout.fnmatch_lines(["*, configfile: setup.cfg, *", "* 1 passed in *"]) assert result.ret == 0 def test_append_parse_args(self, testdir, tmpdir, monkeypatch): @@ -85,12 +85,14 @@ class TestParseIni: ".ini", tox=""" [pytest] - minversion=9.0 + minversion=999.0 """, ) result = testdir.runpytest() assert result.ret != 0 - result.stderr.fnmatch_lines(["*tox.ini:2*requires*9.0*actual*"]) + result.stderr.fnmatch_lines( + ["*tox.ini: 'minversion' requires pytest-999.0, actual pytest-*"] + ) @pytest.mark.parametrize( "section, name", @@ -110,6 +112,16 @@ class TestParseIni: config = testdir.parseconfig() assert config.getini("minversion") == "1.0" + def test_pyproject_toml(self, testdir): + testdir.makepyprojecttoml( + """ + [tool.pytest.ini_options] + minversion = "1.0" + """ + ) + config = testdir.parseconfig() + assert config.getini("minversion") == "1.0" + def test_toxini_before_lower_pytestini(self, testdir): sub = testdir.tmpdir.mkdir("sub") sub.join("tox.ini").write( @@ -251,6 +263,18 @@ class TestConfigCmdlineParsing: config = testdir.parseconfig("-c", "custom_tool_pytest_section.cfg") assert config.getini("custom") == "1" + testdir.makefile( + ".toml", + custom=""" + [tool.pytest.ini_options] + custom = 1 + value = [ + ] # this is here on purpose, as it makes this an invalid '.ini' file + """, + ) + config = testdir.parseconfig("-c", "custom.toml") + assert config.getini("custom") == "1" + def test_absolute_win32_path(self, testdir): temp_ini_file = testdir.makefile( ".ini", @@ -350,7 +374,7 @@ class TestConfigAPI: assert val == "hello" pytest.raises(ValueError, config.getini, "other") - def test_addini_pathlist(self, testdir): + def make_conftest_for_pathlist(self, testdir): testdir.makeconftest( """ def pytest_addoption(parser): @@ -358,20 +382,36 @@ class TestConfigAPI: parser.addini("abc", "abc value") """ ) + + def test_addini_pathlist_ini_files(self, testdir): + self.make_conftest_for_pathlist(testdir) p = testdir.makeini( """ [pytest] paths=hello world/sub.py """ ) + self.check_config_pathlist(testdir, p) + + def test_addini_pathlist_pyproject_toml(self, testdir): + self.make_conftest_for_pathlist(testdir) + p = testdir.makepyprojecttoml( + """ + [tool.pytest.ini_options] + paths=["hello", "world/sub.py"] + """ + ) + self.check_config_pathlist(testdir, p) + + def check_config_pathlist(self, testdir, config_path): config = testdir.parseconfig() values = config.getini("paths") assert len(values) == 2 - assert values[0] == p.dirpath("hello") - assert values[1] == p.dirpath("world/sub.py") + assert values[0] == config_path.dirpath("hello") + assert values[1] == config_path.dirpath("world/sub.py") pytest.raises(ValueError, config.getini, "other") - def test_addini_args(self, testdir): + def make_conftest_for_args(self, testdir): testdir.makeconftest( """ def pytest_addoption(parser): @@ -379,20 +419,35 @@ class TestConfigAPI: parser.addini("a2", "", "args", default="1 2 3".split()) """ ) + + def test_addini_args_ini_files(self, testdir): + self.make_conftest_for_args(testdir) testdir.makeini( """ [pytest] args=123 "123 hello" "this" - """ + """ ) + self.check_config_args(testdir) + + def test_addini_args_pyproject_toml(self, testdir): + self.make_conftest_for_args(testdir) + testdir.makepyprojecttoml( + """ + [tool.pytest.ini_options] + args = ["123", "123 hello", "this"] + """ + ) + self.check_config_args(testdir) + + def check_config_args(self, testdir): config = testdir.parseconfig() values = config.getini("args") - assert len(values) == 3 assert values == ["123", "123 hello", "this"] values = config.getini("a2") assert values == list("123") - def test_addini_linelist(self, testdir): + def make_conftest_for_linelist(self, testdir): testdir.makeconftest( """ def pytest_addoption(parser): @@ -400,6 +455,9 @@ class TestConfigAPI: parser.addini("a2", "", "linelist") """ ) + + def test_addini_linelist_ini_files(self, testdir): + self.make_conftest_for_linelist(testdir) testdir.makeini( """ [pytest] @@ -407,6 +465,19 @@ class TestConfigAPI: second line """ ) + self.check_config_linelist(testdir) + + def test_addini_linelist_pprojecttoml(self, testdir): + self.make_conftest_for_linelist(testdir) + testdir.makepyprojecttoml( + """ + [tool.pytest.ini_options] + xy = ["123 345", "second line"] + """ + ) + self.check_config_linelist(testdir) + + def check_config_linelist(self, testdir): config = testdir.parseconfig() values = config.getini("xy") assert len(values) == 2 @@ -832,7 +903,6 @@ def test_consider_args_after_options_for_rootdir(testdir, args): result.stdout.fnmatch_lines(["*rootdir: *myroot"]) -@pytest.mark.skipif("sys.platform == 'win32'") def test_toolongargs_issue224(testdir): result = testdir.runpytest("-m", "hello" * 500) assert result.ret == ExitCode.NO_TESTS_COLLECTED @@ -964,10 +1034,20 @@ class TestRootdir: assert get_common_ancestor([no_path]) == tmpdir assert get_common_ancestor([no_path.join("a")]) == tmpdir - @pytest.mark.parametrize("name", "setup.cfg tox.ini pytest.ini".split()) - def test_with_ini(self, tmpdir: py.path.local, name: str) -> None: + @pytest.mark.parametrize( + "name, contents", + [ + pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), + pytest.param( + "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" + ), + pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), + pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), + ], + ) + def test_with_ini(self, tmpdir: py.path.local, name: str, contents: str) -> None: inifile = tmpdir.join(name) - inifile.write("[pytest]\n" if name != "setup.cfg" else "[tool:pytest]\n") + inifile.write(contents) a = tmpdir.mkdir("a") b = a.mkdir("b") @@ -975,9 +1055,10 @@ class TestRootdir: rootdir, parsed_inifile, _ = determine_setup(None, args) assert rootdir == tmpdir assert parsed_inifile == inifile - rootdir, parsed_inifile, _ = determine_setup(None, [str(b), str(a)]) + rootdir, parsed_inifile, ini_config = determine_setup(None, [str(b), str(a)]) assert rootdir == tmpdir assert parsed_inifile == inifile + assert ini_config == {"x": "10"} @pytest.mark.parametrize("name", "setup.cfg tox.ini".split()) def test_pytestini_overrides_empty_other(self, tmpdir: py.path.local, name) -> None: @@ -1004,10 +1085,26 @@ class TestRootdir: assert inifile is None assert inicfg == {} - def test_with_specific_inifile(self, tmpdir: py.path.local) -> None: - inifile = tmpdir.ensure("pytest.ini") - rootdir, _, _ = determine_setup(str(inifile), [str(tmpdir)]) + @pytest.mark.parametrize( + "name, contents", + [ + # pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), + pytest.param( + "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" + ), + # pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), + # pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), + ], + ) + def test_with_specific_inifile( + self, tmpdir: py.path.local, name: str, contents: str + ) -> None: + p = tmpdir.ensure(name) + p.write(contents) + rootdir, inifile, ini_config = determine_setup(str(p), [str(tmpdir)]) assert rootdir == tmpdir + assert inifile == p + assert ini_config == {"x": "10"} def test_with_arg_outside_cwd_without_inifile(self, tmpdir, monkeypatch) -> None: monkeypatch.chdir(str(tmpdir)) diff --git a/testing/test_findpaths.py b/testing/test_findpaths.py new file mode 100644 index 000000000..3de2ea218 --- /dev/null +++ b/testing/test_findpaths.py @@ -0,0 +1,110 @@ +from textwrap import dedent + +import py + +import pytest +from _pytest.config.findpaths import get_common_ancestor +from _pytest.config.findpaths import load_config_dict_from_file + + +class TestLoadConfigDictFromFile: + def test_empty_pytest_ini(self, tmpdir): + """pytest.ini files are always considered for configuration, even if empty""" + fn = tmpdir.join("pytest.ini") + fn.write("") + assert load_config_dict_from_file(fn) == {} + + def test_pytest_ini(self, tmpdir): + """[pytest] section in pytest.ini files is read correctly""" + fn = tmpdir.join("pytest.ini") + fn.write("[pytest]\nx=1") + assert load_config_dict_from_file(fn) == {"x": "1"} + + def test_custom_ini(self, tmpdir): + """[pytest] section in any .ini file is read correctly""" + fn = tmpdir.join("custom.ini") + fn.write("[pytest]\nx=1") + assert load_config_dict_from_file(fn) == {"x": "1"} + + def test_custom_ini_without_section(self, tmpdir): + """Custom .ini files without [pytest] section are not considered for configuration""" + fn = tmpdir.join("custom.ini") + fn.write("[custom]") + assert load_config_dict_from_file(fn) is None + + def test_custom_cfg_file(self, tmpdir): + """Custom .cfg files without [tool:pytest] section are not considered for configuration""" + fn = tmpdir.join("custom.cfg") + fn.write("[custom]") + assert load_config_dict_from_file(fn) is None + + def test_valid_cfg_file(self, tmpdir): + """Custom .cfg files with [tool:pytest] section are read correctly""" + fn = tmpdir.join("custom.cfg") + fn.write("[tool:pytest]\nx=1") + assert load_config_dict_from_file(fn) == {"x": "1"} + + def test_unsupported_pytest_section_in_cfg_file(self, tmpdir): + """.cfg files with [pytest] section are no longer supported and should fail to alert users""" + fn = tmpdir.join("custom.cfg") + fn.write("[pytest]") + with pytest.raises(pytest.fail.Exception): + load_config_dict_from_file(fn) + + def test_invalid_toml_file(self, tmpdir): + """.toml files without [tool.pytest.ini_options] are not considered for configuration.""" + fn = tmpdir.join("myconfig.toml") + fn.write( + dedent( + """ + [build_system] + x = 1 + """ + ) + ) + assert load_config_dict_from_file(fn) is None + + def test_valid_toml_file(self, tmpdir): + """.toml files with [tool.pytest.ini_options] are read correctly, including changing + data types to str/list for compatibility with other configuration options.""" + fn = tmpdir.join("myconfig.toml") + fn.write( + dedent( + """ + [tool.pytest.ini_options] + x = 1 + y = 20.0 + values = ["tests", "integration"] + name = "foo" + """ + ) + ) + assert load_config_dict_from_file(fn) == { + "x": "1", + "y": "20.0", + "values": ["tests", "integration"], + "name": "foo", + } + + +class TestCommonAncestor: + def test_has_ancestor(self, tmpdir): + fn1 = tmpdir.join("foo/bar/test_1.py").ensure(file=1) + fn2 = tmpdir.join("foo/zaz/test_2.py").ensure(file=1) + assert get_common_ancestor([fn1, fn2]) == tmpdir.join("foo") + assert get_common_ancestor([py.path.local(fn1.dirname), fn2]) == tmpdir.join( + "foo" + ) + assert get_common_ancestor( + [py.path.local(fn1.dirname), py.path.local(fn2.dirname)] + ) == tmpdir.join("foo") + assert get_common_ancestor([fn1, py.path.local(fn2.dirname)]) == tmpdir.join( + "foo" + ) + + def test_single_dir(self, tmpdir): + assert get_common_ancestor([tmpdir]) == tmpdir + + def test_single_file(self, tmpdir): + fn = tmpdir.join("foo.py").ensure(file=1) + assert get_common_ancestor([fn]) == tmpdir diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 7d7c82ad6..e8402079b 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -706,10 +706,10 @@ class TestTerminalFunctional: result = testdir.runpytest() result.stdout.fnmatch_lines(["rootdir: *test_header0"]) - # with inifile + # with configfile testdir.makeini("""[pytest]""") result = testdir.runpytest() - result.stdout.fnmatch_lines(["rootdir: *test_header0, inifile: tox.ini"]) + result.stdout.fnmatch_lines(["rootdir: *test_header0, configfile: tox.ini"]) # with testpaths option, and not passing anything in the command-line testdir.makeini( @@ -720,12 +720,12 @@ class TestTerminalFunctional: ) result = testdir.runpytest() result.stdout.fnmatch_lines( - ["rootdir: *test_header0, inifile: tox.ini, testpaths: tests, gui"] + ["rootdir: *test_header0, configfile: tox.ini, testpaths: tests, gui"] ) # with testpaths option, passing directory in command-line: do not show testpaths then result = testdir.runpytest("tests") - result.stdout.fnmatch_lines(["rootdir: *test_header0, inifile: tox.ini"]) + result.stdout.fnmatch_lines(["rootdir: *test_header0, configfile: tox.ini"]) def test_showlocals(self, testdir): p1 = testdir.makepyfile( diff --git a/tox.ini b/tox.ini index 8e1a51ca7..affb4a7a9 100644 --- a/tox.ini +++ b/tox.ini @@ -152,48 +152,6 @@ deps = pypandoc commands = python scripts/publish-gh-release-notes.py {posargs} - -[pytest] -minversion = 2.0 -addopts = -rfEX -p pytester --strict-markers -rsyncdirs = tox.ini doc src testing -python_files = test_*.py *_test.py testing/*/*.py -python_classes = Test Acceptance -python_functions = test -# NOTE: "doc" is not included here, but gets tested explicitly via "doctesting". -testpaths = testing -norecursedirs = testing/example_scripts -xfail_strict=true -filterwarnings = - error - default:Using or importing the ABCs:DeprecationWarning:unittest2.* - default:the imp module is deprecated in favour of importlib:DeprecationWarning:nose.* - ignore:Module already imported so cannot be rewritten:pytest.PytestWarning - # produced by python3.6/site.py itself (3.6.7 on Travis, could not trigger it with 3.6.8). - ignore:.*U.*mode is deprecated:DeprecationWarning:(?!(pytest|_pytest)) - # produced by pytest-xdist - ignore:.*type argument to addoption.*:DeprecationWarning - # produced by python >=3.5 on execnet (pytest-xdist) - ignore:.*inspect.getargspec.*deprecated, use inspect.signature.*:DeprecationWarning - # pytest's own futurewarnings - ignore::pytest.PytestExperimentalApiWarning - # Do not cause SyntaxError for invalid escape sequences in py37. - # Those are caught/handled by pyupgrade, and not easy to filter with the - # module being the filename (with .py removed). - default:invalid escape sequence:DeprecationWarning - # ignore use of unregistered marks, because we use many to test the implementation - ignore::_pytest.warning_types.PytestUnknownMarkWarning -pytester_example_dir = testing/example_scripts -markers = - # dummy markers for testing - foo - bar - baz - # conftest.py reorders tests moving slow ones to the end of the list - slow - # experimental mark for all tests using pexpect - uses_pexpect - [flake8] max-line-length = 120 extend-ignore = E203 From 322190fd84e1b86d7b9a2d71f086445ca80c39b3 Mon Sep 17 00:00:00 2001 From: Fabio Zadrozny Date: Mon, 8 Jun 2020 10:56:40 -0300 Subject: [PATCH 061/140] Fix issue where working dir becomes wrong on subst drive on Windows. Fixes #5965 (#6523) Co-authored-by: Bruno Oliveira --- changelog/5965.breaking.rst | 9 ++++ src/_pytest/capture.py | 6 +-- src/_pytest/config/__init__.py | 6 +-- src/_pytest/fixtures.py | 2 +- src/_pytest/main.py | 1 - src/_pytest/pathlib.py | 9 ++++ testing/acceptance_test.py | 74 +++++++---------------------- testing/test_collection.py | 32 ++++--------- testing/test_conftest.py | 56 ++++++++++------------ testing/test_link_resolve.py | 85 ++++++++++++++++++++++++++++++++++ 10 files changed, 160 insertions(+), 120 deletions(-) create mode 100644 changelog/5965.breaking.rst create mode 100644 testing/test_link_resolve.py diff --git a/changelog/5965.breaking.rst b/changelog/5965.breaking.rst new file mode 100644 index 000000000..3ecb9486a --- /dev/null +++ b/changelog/5965.breaking.rst @@ -0,0 +1,9 @@ +symlinks are no longer resolved during collection and matching `conftest.py` files with test file paths. + +Resolving symlinks for the current directory and during collection was introduced as a bugfix in 3.9.0, but it actually is a new feature which had unfortunate consequences in Windows and surprising results in other platforms. + +The team decided to step back on resolving symlinks at all, planning to review this in the future with a more solid solution (see discussion in +`#6523 `__ for details). + +This might break test suites which made use of this feature; the fix is to create a symlink +for the entire test tree, and not only to partial files/tress as it was possible previously. diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 98ba878b3..041041284 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -123,9 +123,9 @@ def _py36_windowsconsoleio_workaround(stream: TextIO) -> None: return buffered = hasattr(stream.buffer, "raw") - raw_stdout = stream.buffer.raw if buffered else stream.buffer + raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined] - if not isinstance(raw_stdout, io._WindowsConsoleIO): + if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined] return def _reopen_stdio(f, mode): @@ -135,7 +135,7 @@ def _py36_windowsconsoleio_workaround(stream: TextIO) -> None: buffering = -1 return io.TextIOWrapper( - open(os.dup(f.fileno()), mode, buffering), + open(os.dup(f.fileno()), mode, buffering), # type: ignore[arg-type] f.encoding, f.errors, f.newlines, diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 4b1d0bd2a..c94ea2a93 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -232,7 +232,7 @@ def get_config(args=None, plugins=None): config = Config( pluginmanager, invocation_params=Config.InvocationParams( - args=args or (), plugins=plugins, dir=Path().resolve() + args=args or (), plugins=plugins, dir=Path.cwd() ), ) @@ -477,7 +477,7 @@ class PytestPluginManager(PluginManager): # and allow users to opt into looking into the rootdir parent # directories instead of requiring to specify confcutdir clist = [] - for parent in directory.realpath().parts(): + for parent in directory.parts(): if self._confcutdir and self._confcutdir.relto(parent): continue conftestpath = parent.join("conftest.py") @@ -798,7 +798,7 @@ class Config: if invocation_params is None: invocation_params = self.InvocationParams( - args=(), plugins=None, dir=Path().resolve() + args=(), plugins=None, dir=Path.cwd() ) self.option = argparse.Namespace() diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index fa7e3e1df..05f0ecb6a 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1496,7 +1496,7 @@ class FixtureManager: def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: nodeid = None try: - p = py.path.local(plugin.__file__).realpath() # type: ignore[attr-defined] # noqa: F821 + p = py.path.local(plugin.__file__) # type: ignore[attr-defined] # noqa: F821 except AttributeError: pass else: diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 1c1cda18b..84ee00881 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -665,7 +665,6 @@ class Session(nodes.FSCollector): "file or package not found: " + arg + " (missing __init__.py?)" ) raise UsageError("file not found: " + arg) - fspath = fspath.realpath() return (fspath, parts) def matchnodes( diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 6878965e0..69f490a1d 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -18,6 +18,7 @@ from typing import Set from typing import TypeVar from typing import Union +from _pytest.outcomes import skip from _pytest.warning_types import PytestWarning if sys.version_info[:2] >= (3, 6): @@ -397,3 +398,11 @@ def fnmatch_ex(pattern: str, path) -> bool: def parts(s: str) -> Set[str]: parts = s.split(sep) return {sep.join(parts[: i + 1]) or sep for i in range(len(parts))} + + +def symlink_or_skip(src, dst, **kwargs): + """Makes a symlink or skips the test in case symlinks are not supported.""" + try: + os.symlink(str(src), str(dst), **kwargs) + except OSError as e: + skip("symlinks not supported: {}".format(e)) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index e2df92d80..7dfd588a0 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1,6 +1,5 @@ import os import sys -import textwrap import types import attr @@ -9,6 +8,7 @@ import py import pytest from _pytest.compat import importlib_metadata from _pytest.config import ExitCode +from _pytest.pathlib import symlink_or_skip from _pytest.pytester import Testdir @@ -266,29 +266,6 @@ class TestGeneralUsage: assert result.ret != 0 assert "should be seen" in result.stdout.str() - @pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", - ) - def test_chdir(self, testdir): - testdir.tmpdir.join("py").mksymlinkto(py._pydir) - p = testdir.tmpdir.join("main.py") - p.write( - textwrap.dedent( - """\ - import sys, os - sys.path.insert(0, '') - import py - print(py.__file__) - print(py.__path__) - os.chdir(os.path.dirname(os.getcwd())) - print(py.log) - """ - ) - ) - result = testdir.runpython(p) - assert not result.ret - def test_issue109_sibling_conftests_not_loaded(self, testdir): sub1 = testdir.mkdir("sub1") sub2 = testdir.mkdir("sub2") @@ -762,19 +739,9 @@ class TestInvocationVariants: def test_cmdline_python_package_symlink(self, testdir, monkeypatch): """ - test --pyargs option with packages with path containing symlink can - have conftest.py in their package (#2985) + --pyargs with packages with path containing symlink can have conftest.py in + their package (#2985) """ - # dummy check that we can actually create symlinks: on Windows `os.symlink` is available, - # but normal users require special admin privileges to create symlinks. - if sys.platform == "win32": - try: - os.symlink( - str(testdir.tmpdir.ensure("tmpfile")), - str(testdir.tmpdir.join("tmpfile2")), - ) - except OSError as e: - pytest.skip(str(e.args[0])) monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) dirname = "lib" @@ -790,13 +757,13 @@ class TestInvocationVariants: "import pytest\n@pytest.fixture\ndef a_fixture():pass" ) - d_local = testdir.mkdir("local") - symlink_location = os.path.join(str(d_local), "lib") - os.symlink(str(d), symlink_location, target_is_directory=True) + d_local = testdir.mkdir("symlink_root") + symlink_location = d_local / "lib" + symlink_or_skip(d, symlink_location, target_is_directory=True) # The structure of the test directory is now: # . - # ├── local + # ├── symlink_root # │ └── lib -> ../lib # └── lib # └── foo @@ -807,32 +774,23 @@ class TestInvocationVariants: # └── test_bar.py # NOTE: the different/reversed ordering is intentional here. - search_path = ["lib", os.path.join("local", "lib")] + search_path = ["lib", os.path.join("symlink_root", "lib")] monkeypatch.setenv("PYTHONPATH", prepend_pythonpath(*search_path)) for p in search_path: monkeypatch.syspath_prepend(p) # module picked up in symlink-ed directory: - # It picks up local/lib/foo/bar (symlink) via sys.path. + # It picks up symlink_root/lib/foo/bar (symlink) via sys.path. result = testdir.runpytest("--pyargs", "-v", "foo.bar") testdir.chdir() assert result.ret == 0 - if hasattr(py.path.local, "mksymlinkto"): - result.stdout.fnmatch_lines( - [ - "lib/foo/bar/test_bar.py::test_bar PASSED*", - "lib/foo/bar/test_bar.py::test_other PASSED*", - "*2 passed*", - ] - ) - else: - result.stdout.fnmatch_lines( - [ - "*lib/foo/bar/test_bar.py::test_bar PASSED*", - "*lib/foo/bar/test_bar.py::test_other PASSED*", - "*2 passed*", - ] - ) + result.stdout.fnmatch_lines( + [ + "symlink_root/lib/foo/bar/test_bar.py::test_bar PASSED*", + "symlink_root/lib/foo/bar/test_bar.py::test_other PASSED*", + "*2 passed*", + ] + ) def test_cmdline_python_package_not_exists(self, testdir): result = testdir.runpytest("--pyargs", "tpkgwhatv") diff --git a/testing/test_collection.py b/testing/test_collection.py index 8e5d5aacc..6644881ea 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -3,12 +3,11 @@ import pprint import sys import textwrap -import py - import pytest from _pytest.config import ExitCode from _pytest.main import _in_venv from _pytest.main import Session +from _pytest.pathlib import symlink_or_skip from _pytest.pytester import Testdir @@ -1164,29 +1163,21 @@ def test_collect_pyargs_with_testpaths(testdir, monkeypatch): result.stdout.fnmatch_lines(["*1 passed in*"]) -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) def test_collect_symlink_file_arg(testdir): - """Test that collecting a direct symlink, where the target does not match python_files works (#4325).""" + """Collect a direct symlink works even if it does not match python_files (#4325).""" real = testdir.makepyfile( real=""" def test_nodeid(request): - assert request.node.nodeid == "real.py::test_nodeid" + assert request.node.nodeid == "symlink.py::test_nodeid" """ ) symlink = testdir.tmpdir.join("symlink.py") - symlink.mksymlinkto(real) + symlink_or_skip(real, symlink) result = testdir.runpytest("-v", symlink) - result.stdout.fnmatch_lines(["real.py::test_nodeid PASSED*", "*1 passed in*"]) + result.stdout.fnmatch_lines(["symlink.py::test_nodeid PASSED*", "*1 passed in*"]) assert result.ret == 0 -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) def test_collect_symlink_out_of_tree(testdir): """Test collection of symlink via out-of-tree rootdir.""" sub = testdir.tmpdir.join("sub") @@ -1204,7 +1195,7 @@ def test_collect_symlink_out_of_tree(testdir): out_of_tree = testdir.tmpdir.join("out_of_tree").ensure(dir=True) symlink_to_sub = out_of_tree.join("symlink_to_sub") - symlink_to_sub.mksymlinkto(sub) + symlink_or_skip(sub, symlink_to_sub) sub.chdir() result = testdir.runpytest("-vs", "--rootdir=%s" % sub, symlink_to_sub) result.stdout.fnmatch_lines( @@ -1270,22 +1261,19 @@ def test_collect_pkg_init_only(testdir): result.stdout.fnmatch_lines(["sub/__init__.py::test_init PASSED*", "*1 passed in*"]) -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) @pytest.mark.parametrize("use_pkg", (True, False)) def test_collect_sub_with_symlinks(use_pkg, testdir): + """Collection works with symlinked files and broken symlinks""" sub = testdir.mkdir("sub") if use_pkg: sub.ensure("__init__.py") - sub.ensure("test_file.py").write("def test_file(): pass") + sub.join("test_file.py").write("def test_file(): pass") # Create a broken symlink. - sub.join("test_broken.py").mksymlinkto("test_doesnotexist.py") + symlink_or_skip("test_doesnotexist.py", sub.join("test_broken.py")) # Symlink that gets collected. - sub.join("test_symlink.py").mksymlinkto("test_file.py") + symlink_or_skip("test_file.py", sub.join("test_symlink.py")) result = testdir.runpytest("-v", str(sub)) result.stdout.fnmatch_lines( diff --git a/testing/test_conftest.py b/testing/test_conftest.py index a07af60f6..0df303bc7 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -7,6 +7,7 @@ import pytest from _pytest.config import ExitCode from _pytest.config import PytestPluginManager from _pytest.pathlib import Path +from _pytest.pathlib import symlink_or_skip def ConftestWithSetinitial(path): @@ -190,16 +191,25 @@ def test_conftest_confcutdir(testdir): result.stdout.no_fnmatch_line("*warning: could not load initial*") -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) def test_conftest_symlink(testdir): - """Ensure that conftest.py is used for resolved symlinks.""" + """ + conftest.py discovery follows normal path resolution and does not resolve symlinks. + """ + # Structure: + # /real + # /real/conftest.py + # /real/app + # /real/app/tests + # /real/app/tests/test_foo.py + + # Links: + # /symlinktests -> /real/app/tests (running at symlinktests should fail) + # /symlink -> /real (running at /symlink should work) + real = testdir.tmpdir.mkdir("real") realtests = real.mkdir("app").mkdir("tests") - testdir.tmpdir.join("symlinktests").mksymlinkto(realtests) - testdir.tmpdir.join("symlink").mksymlinkto(real) + symlink_or_skip(realtests, testdir.tmpdir.join("symlinktests")) + symlink_or_skip(real, testdir.tmpdir.join("symlink")) testdir.makepyfile( **{ "real/app/tests/test_foo.py": "def test1(fixture): pass", @@ -216,38 +226,20 @@ def test_conftest_symlink(testdir): ), } ) + + # Should fail because conftest cannot be found from the link structure. result = testdir.runpytest("-vs", "symlinktests") - result.stdout.fnmatch_lines( - [ - "*conftest_loaded*", - "real/app/tests/test_foo.py::test1 fixture_used", - "PASSED", - ] - ) - assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["*fixture 'fixture' not found*"]) + assert result.ret == ExitCode.TESTS_FAILED # Should not cause "ValueError: Plugin already registered" (#4174). result = testdir.runpytest("-vs", "symlink") assert result.ret == ExitCode.OK - realtests.ensure("__init__.py") - result = testdir.runpytest("-vs", "symlinktests/test_foo.py::test1") - result.stdout.fnmatch_lines( - [ - "*conftest_loaded*", - "real/app/tests/test_foo.py::test1 fixture_used", - "PASSED", - ] - ) - assert result.ret == ExitCode.OK - -@pytest.mark.skipif( - not hasattr(py.path.local, "mksymlinkto"), - reason="symlink not available on this platform", -) def test_conftest_symlink_files(testdir): - """Check conftest.py loading when running in directory with symlinks.""" + """Symlinked conftest.py are found when pytest is executed in a directory with symlinked + files.""" real = testdir.tmpdir.mkdir("real") source = { "app/test_foo.py": "def test1(fixture): pass", @@ -271,7 +263,7 @@ def test_conftest_symlink_files(testdir): build = testdir.tmpdir.mkdir("build") build.mkdir("app") for f in source: - build.join(f).mksymlinkto(real.join(f)) + symlink_or_skip(real.join(f), build.join(f)) build.chdir() result = testdir.runpytest("-vs", "app/test_foo.py") result.stdout.fnmatch_lines(["*conftest_loaded*", "PASSED"]) diff --git a/testing/test_link_resolve.py b/testing/test_link_resolve.py new file mode 100644 index 000000000..3e9199dff --- /dev/null +++ b/testing/test_link_resolve.py @@ -0,0 +1,85 @@ +import os.path +import subprocess +import sys +import textwrap +from contextlib import contextmanager +from string import ascii_lowercase + +import py.path + +from _pytest import pytester + + +@contextmanager +def subst_path_windows(filename): + for c in ascii_lowercase[7:]: # Create a subst drive from H-Z. + c += ":" + if not os.path.exists(c): + drive = c + break + else: + raise AssertionError("Unable to find suitable drive letter for subst.") + + directory = filename.dirpath() + basename = filename.basename + + args = ["subst", drive, str(directory)] + subprocess.check_call(args) + assert os.path.exists(drive) + try: + filename = py.path.local(drive) / basename + yield filename + finally: + args = ["subst", "/D", drive] + subprocess.check_call(args) + + +@contextmanager +def subst_path_linux(filename): + directory = filename.dirpath() + basename = filename.basename + + target = directory / ".." / "sub2" + os.symlink(str(directory), str(target), target_is_directory=True) + try: + filename = target / basename + yield filename + finally: + # We don't need to unlink (it's all in the tempdir). + pass + + +def test_link_resolve(testdir: pytester.Testdir) -> None: + """ + See: https://github.com/pytest-dev/pytest/issues/5965 + """ + sub1 = testdir.mkpydir("sub1") + p = sub1.join("test_foo.py") + p.write( + textwrap.dedent( + """ + import pytest + def test_foo(): + raise AssertionError() + """ + ) + ) + + subst = subst_path_linux + if sys.platform == "win32": + subst = subst_path_windows + + with subst(p) as subst_p: + result = testdir.runpytest(str(subst_p), "-v") + # i.e.: Make sure that the error is reported as a relative path, not as a + # resolved path. + # See: https://github.com/pytest-dev/pytest/issues/5965 + stdout = result.stdout.str() + assert "sub1/test_foo.py" not in stdout + + # i.e.: Expect drive on windows because we just have drive:filename, whereas + # we expect a relative path on Linux. + expect = ( + "*{}*".format(subst_p) if sys.platform == "win32" else "*sub2/test_foo.py*" + ) + result.stdout.fnmatch_lines([expect]) From a76855912b599d53865c9019b10ae934875fbe04 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 8 Jun 2020 21:15:53 -0300 Subject: [PATCH 062/140] Introduce guidelines for closing stale issues/PRs (#7332) * Introduce guidelines for closing stale issues/PRs Close #7282 Co-authored-by: Anthony Sottile Co-authored-by: Zac Hatfield-Dodds Co-authored-by: Anthony Sottile Co-authored-by: Zac Hatfield-Dodds --- CONTRIBUTING.rst | 71 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 3d07db638..d5bd78144 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -289,7 +289,7 @@ Here is a simple overview, with pytest-specific bits: Writing Tests ----------------------------- +~~~~~~~~~~~~~ Writing tests for plugins or for pytest itself is often done using the `testdir fixture `_, as a "black-box" test. @@ -328,6 +328,19 @@ one file which looks like a good fit. For example, a regression test about a bug should go into ``test_cacheprovider.py``, given that this option is implemented in ``cacheprovider.py``. If in doubt, go ahead and open a PR with your best guess and we can discuss this over the code. +Joining the Development Team +---------------------------- + +Anyone who has successfully seen through a pull request which did not +require any extra work from the development team to merge will +themselves gain commit access if they so wish (if we forget to ask please send a friendly +reminder). This does not mean there is any change in your contribution workflow: +everyone goes through the same pull-request-and-review process and +no-one merges their own pull requests unless already approved. It does however mean you can +participate in the development process more fully since you can merge +pull requests from other contributors yourself after having reviewed +them. + Backporting bug fixes for the next patch release ------------------------------------------------ @@ -359,15 +372,49 @@ actual latest release). The procedure for this is: * Delete the PR body, it usually contains a duplicate commit message. -Joining the Development Team ----------------------------- +Handling stale issues/PRs +------------------------- -Anyone who has successfully seen through a pull request which did not -require any extra work from the development team to merge will -themselves gain commit access if they so wish (if we forget to ask please send a friendly -reminder). This does not mean there is any change in your contribution workflow: -everyone goes through the same pull-request-and-review process and -no-one merges their own pull requests unless already approved. It does however mean you can -participate in the development process more fully since you can merge -pull requests from other contributors yourself after having reviewed -them. +Stale issues/PRs are those where pytest contributors have asked for questions/changes +and the authors didn't get around to answer/implement them yet after a somewhat long time, or +the discussion simply died because people seemed to lose interest. + +There are many reasons why people don't answer questions or implement requested changes: +they might get busy, lose interest, or just forget about it, +but the fact is that this is very common in open source software. + +The pytest team really appreciates every issue and pull request, but being a high-volume project +with many issues and pull requests being submitted daily, we try to reduce the number of stale +issues and PRs by regularly closing them. When an issue/pull request is closed in this manner, +it is by no means a dismissal of the topic being tackled by the issue/pull request, but it +is just a way for us to clear up the queue and make the maintainers' work more manageable. Submitters +can always reopen the issue/pull request in their own time later if it makes sense. + +When to close +~~~~~~~~~~~~~ + +Here are a few general rules the maintainers use to decide when to close issues/PRs because +of lack of inactivity: + +* Issues labeled ``question`` or ``needs information``: closed after 14 days inactive. +* Issues labeled ``proposal``: closed after six months inactive. +* Pull requests: after one month, consider pinging the author, update linked issue, or consider closing. For pull requests which are nearly finished, the team should consider finishing it up and merging it. + +The above are **not hard rules**, but merely **guidelines**, and can be (and often are!) reviewed on a case-by-case basis. + +Closing pull requests +~~~~~~~~~~~~~~~~~~~~~ + +When closing a Pull Request, it needs to be acknowledge the time, effort, and interest demonstrated by the person which submitted it. As mentioned previously, it is not the intent of the team to dismiss stalled pull request entirely but to merely to clear up our queue, so a message like the one below is warranted when closing a pull request that went stale: + + Hi , + + First of all we would like to thank you for your time and effort on working on this, the pytest team deeply appreciates it. + + We noticed it has been awhile since you have updated this PR, however. pytest is a high activity project, with many issues/PRs being opened daily, so it is hard for us maintainers to track which PRs are ready for merging, for review, or need more attention. + + So for those reasons we think it is best to close the PR for now, but with the only intention to cleanup our queue, it is by no means a rejection of your changes. We still encourage you to re-open this PR (it is just a click of a button away) when you are ready to get back to it. + + Again we appreciate your time for working on this, and hope you might get back to this at a later time! + + From e78207c936c43478aa5d5531d7c0b90aa240c9e0 Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Tue, 9 Jun 2020 09:54:22 +0900 Subject: [PATCH 063/140] 7119: data loss with mistyped --basetemp (#7170) Co-authored-by: Bruno Oliveira Co-authored-by: Ran Benita --- AUTHORS | 1 + changelog/7119.improvement.rst | 2 ++ src/_pytest/main.py | 31 +++++++++++++++++++++++++++++++ testing/test_main.py | 23 +++++++++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 changelog/7119.improvement.rst diff --git a/AUTHORS b/AUTHORS index e1b195b9a..4c5ca41af 100644 --- a/AUTHORS +++ b/AUTHORS @@ -227,6 +227,7 @@ Pedro Algarvio Philipp Loose Pieter Mulder Piotr Banaszkiewicz +Prashant Anand Pulkit Goyal Punyashloka Biswal Quentin Pradet diff --git a/changelog/7119.improvement.rst b/changelog/7119.improvement.rst new file mode 100644 index 000000000..6cef98836 --- /dev/null +++ b/changelog/7119.improvement.rst @@ -0,0 +1,2 @@ +Exit with an error if the ``--basetemp`` argument is empty, the current working directory or parent directory of it. +This is done to protect against accidental data loss, as any directory passed to this argument is cleared. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 84ee00881..a95f2f2e7 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -1,4 +1,5 @@ """ core implementation of testing process: init, session, runtest loop. """ +import argparse import fnmatch import functools import importlib @@ -30,6 +31,7 @@ from _pytest.config import UsageError from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureManager from _pytest.outcomes import exit +from _pytest.pathlib import Path from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.runner import collect_one_node @@ -177,6 +179,7 @@ def pytest_addoption(parser: Parser) -> None: "--basetemp", dest="basetemp", default=None, + type=validate_basetemp, metavar="dir", help=( "base temporary directory for this test run." @@ -185,6 +188,34 @@ def pytest_addoption(parser: Parser) -> None: ) +def validate_basetemp(path: str) -> str: + # GH 7119 + msg = "basetemp must not be empty, the current working directory or any parent directory of it" + + # empty path + if not path: + raise argparse.ArgumentTypeError(msg) + + def is_ancestor(base: Path, query: Path) -> bool: + """ return True if query is an ancestor of base, else False.""" + if base == query: + return True + for parent in base.parents: + if parent == query: + return True + return False + + # check if path is an ancestor of cwd + if is_ancestor(Path.cwd(), Path(path).absolute()): + raise argparse.ArgumentTypeError(msg) + + # check symlinks for ancestors + if is_ancestor(Path.cwd().resolve(), Path(path).resolve()): + raise argparse.ArgumentTypeError(msg) + + return path + + def wrap_session( config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] ) -> Union[int, ExitCode]: diff --git a/testing/test_main.py b/testing/test_main.py index 07aca3a1e..ee8349a9f 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -1,7 +1,9 @@ +import argparse from typing import Optional import pytest from _pytest.config import ExitCode +from _pytest.main import validate_basetemp from _pytest.pytester import Testdir @@ -75,3 +77,24 @@ def test_wrap_session_exit_sessionfinish( assert result.ret == ExitCode.NO_TESTS_COLLECTED assert result.stdout.lines[-1] == "collected 0 items" assert result.stderr.lines == ["Exit: exit_pytest_sessionfinish"] + + +@pytest.mark.parametrize("basetemp", ["foo", "foo/bar"]) +def test_validate_basetemp_ok(tmp_path, basetemp, monkeypatch): + monkeypatch.chdir(str(tmp_path)) + validate_basetemp(tmp_path / basetemp) + + +@pytest.mark.parametrize("basetemp", ["", ".", ".."]) +def test_validate_basetemp_fails(tmp_path, basetemp, monkeypatch): + monkeypatch.chdir(str(tmp_path)) + msg = "basetemp must not be empty, the current working directory or any parent directory of it" + with pytest.raises(argparse.ArgumentTypeError, match=msg): + if basetemp: + basetemp = tmp_path / basetemp + validate_basetemp(basetemp) + + +def test_validate_basetemp_integration(testdir): + result = testdir.runpytest("--basetemp=.") + result.stderr.fnmatch_lines("*basetemp must not be*") From fcbaab8b0b89abc622dbfb7982cf9bd8c91ef301 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 8 Jun 2020 22:05:46 -0300 Subject: [PATCH 064/140] Allow tests to override "global" `log_level` (rebased) (#7340) Co-authored-by: Ruaridh Williamson --- AUTHORS | 1 + changelog/7133.improvement.rst | 1 + doc/en/logging.rst | 3 + src/_pytest/logging.py | 10 +++- testing/logging/test_fixture.py | 97 +++++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 changelog/7133.improvement.rst diff --git a/AUTHORS b/AUTHORS index 4c5ca41af..fdcd5b6e0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -245,6 +245,7 @@ Romain Dorgueil Roman Bolshakov Ronny Pfannschmidt Ross Lawley +Ruaridh Williamson Russel Winder Ryan Wooden Samuel Dion-Girardeau diff --git a/changelog/7133.improvement.rst b/changelog/7133.improvement.rst new file mode 100644 index 000000000..b537d3e5d --- /dev/null +++ b/changelog/7133.improvement.rst @@ -0,0 +1 @@ +``caplog.set_level()`` will now override any :confval:`log_level` set via the CLI or ``.ini``. diff --git a/doc/en/logging.rst b/doc/en/logging.rst index e6f91cdf7..52713854e 100644 --- a/doc/en/logging.rst +++ b/doc/en/logging.rst @@ -250,6 +250,9 @@ made in ``3.4`` after community feedback: * Log levels are no longer changed unless explicitly requested by the :confval:`log_level` configuration or ``--log-level`` command-line options. This allows users to configure logger objects themselves. + Setting :confval:`log_level` will set the level that is captured globally so if a specific test requires + a lower level than this, use the ``caplog.set_level()`` functionality otherwise that test will be prone to + failure. * :ref:`Live Logs ` is now disabled by default and can be enabled setting the :confval:`log_cli` configuration option to ``true``. When enabled, the verbosity is increased so logging for each test is visible. diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index c1f13b701..ef90c94e8 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -343,7 +343,7 @@ class LogCaptureFixture: """Creates a new funcarg.""" self._item = item # dict of log name -> log level - self._initial_log_levels = {} # type: Dict[Optional[str], int] + self._initial_logger_levels = {} # type: Dict[Optional[str], int] def _finalize(self) -> None: """Finalizes the fixture. @@ -351,7 +351,7 @@ class LogCaptureFixture: This restores the log levels changed by :meth:`set_level`. """ # restore log levels - for logger_name, level in self._initial_log_levels.items(): + for logger_name, level in self._initial_logger_levels.items(): logger = logging.getLogger(logger_name) logger.setLevel(level) @@ -430,8 +430,9 @@ class LogCaptureFixture: """ logger_obj = logging.getLogger(logger) # save the original log-level to restore it during teardown - self._initial_log_levels.setdefault(logger, logger_obj.level) + self._initial_logger_levels.setdefault(logger, logger_obj.level) logger_obj.setLevel(level) + self.handler.setLevel(level) @contextmanager def at_level( @@ -446,10 +447,13 @@ class LogCaptureFixture: logger_obj = logging.getLogger(logger) orig_level = logger_obj.level logger_obj.setLevel(level) + handler_orig_level = self.handler.level + self.handler.setLevel(level) try: yield finally: logger_obj.setLevel(orig_level) + self.handler.setLevel(handler_orig_level) @pytest.fixture diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index 657ffb4dd..3a3663464 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -138,3 +138,100 @@ def test_caplog_captures_for_all_stages(caplog, logging_during_setup_and_teardow # This reaches into private API, don't use this type of thing in real tests! assert set(caplog._item._store[catch_log_records_key]) == {"setup", "call"} + + +def test_ini_controls_global_log_level(testdir): + testdir.makepyfile( + """ + import pytest + import logging + def test_log_level_override(request, caplog): + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_level == logging.ERROR + logger = logging.getLogger('catchlog') + logger.warning("WARNING message won't be shown") + logger.error("ERROR message will be shown") + assert 'WARNING' not in caplog.text + assert 'ERROR' in caplog.text + """ + ) + testdir.makeini( + """ + [pytest] + log_level=ERROR + """ + ) + + result = testdir.runpytest() + # make sure that that we get a '0' exit code for the testsuite + assert result.ret == 0 + + +def test_caplog_can_override_global_log_level(testdir): + testdir.makepyfile( + """ + import pytest + import logging + def test_log_level_override(request, caplog): + logger = logging.getLogger('catchlog') + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_level == logging.WARNING + + logger.info("INFO message won't be shown") + + caplog.set_level(logging.INFO, logger.name) + + with caplog.at_level(logging.DEBUG, logger.name): + logger.debug("DEBUG message will be shown") + + logger.debug("DEBUG message won't be shown") + + with caplog.at_level(logging.CRITICAL, logger.name): + logger.warning("WARNING message won't be shown") + + logger.debug("DEBUG message won't be shown") + logger.info("INFO message will be shown") + + assert "message won't be shown" not in caplog.text + """ + ) + testdir.makeini( + """ + [pytest] + log_level=WARNING + """ + ) + + result = testdir.runpytest() + assert result.ret == 0 + + +def test_caplog_captures_despite_exception(testdir): + testdir.makepyfile( + """ + import pytest + import logging + def test_log_level_override(request, caplog): + logger = logging.getLogger('catchlog') + plugin = request.config.pluginmanager.getplugin('logging-plugin') + assert plugin.log_level == logging.WARNING + + logger.info("INFO message won't be shown") + + caplog.set_level(logging.INFO, logger.name) + + with caplog.at_level(logging.DEBUG, logger.name): + logger.debug("DEBUG message will be shown") + raise Exception() + """ + ) + testdir.makeini( + """ + [pytest] + log_level=WARNING + """ + ) + + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*DEBUG message will be shown*"]) + assert result.ret == 1 From 0b70300ba4c00f2fdab1415b33ac6b035418e648 Mon Sep 17 00:00:00 2001 From: piotrhm Date: Mon, 25 May 2020 14:18:57 +0200 Subject: [PATCH 065/140] Added requested modifications --- AUTHORS | 1 + changelog/6471.feature.rst | 1 + src/_pytest/terminal.py | 63 ++++++++++++++++++++++++++------------ 3 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 changelog/6471.feature.rst diff --git a/AUTHORS b/AUTHORS index fdcd5b6e0..821a7d8f4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -227,6 +227,7 @@ Pedro Algarvio Philipp Loose Pieter Mulder Piotr Banaszkiewicz +Piotr Helm Prashant Anand Pulkit Goyal Punyashloka Biswal diff --git a/changelog/6471.feature.rst b/changelog/6471.feature.rst new file mode 100644 index 000000000..bc2d7564e --- /dev/null +++ b/changelog/6471.feature.rst @@ -0,0 +1 @@ +New flags --no-header and --no-summary added. The first one disables header, the second one disables summary. \ No newline at end of file diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 9c2665fb8..2a98e4ceb 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -115,6 +115,20 @@ def pytest_addoption(parser: Parser) -> None: dest="verbose", help="increase verbosity.", ) + group._addoption( + "--no-header", + action="count", + default=0, + dest="no_header", + help="disable header", + ) + group._addoption( + "--no-summary", + action="count", + default=0, + dest="no_summary", + help="disable summary", + ) group._addoption( "-q", "--quiet", @@ -351,6 +365,14 @@ class TerminalReporter: def showheader(self) -> bool: return self.verbosity >= 0 + @property + def no_header(self) -> bool: + return self.config.option.no_header + + @property + def no_summary(self) -> bool: + return self.config.option.no_summary + @property def showfspath(self) -> bool: if self._showfspath is None: @@ -660,25 +682,26 @@ class TerminalReporter: return self.write_sep("=", "test session starts", bold=True) verinfo = platform.python_version() - msg = "platform {} -- Python {}".format(sys.platform, verinfo) - pypy_version_info = getattr(sys, "pypy_version_info", None) - if pypy_version_info: - verinfo = ".".join(map(str, pypy_version_info[:3])) - msg += "[pypy-{}-{}]".format(verinfo, pypy_version_info[3]) - msg += ", pytest-{}, py-{}, pluggy-{}".format( - pytest.__version__, py.__version__, pluggy.__version__ - ) - if ( - self.verbosity > 0 - or self.config.option.debug - or getattr(self.config.option, "pastebin", None) - ): - msg += " -- " + str(sys.executable) - self.write_line(msg) - lines = self.config.hook.pytest_report_header( - config=self.config, startdir=self.startdir - ) - self._write_report_lines_from_hooks(lines) + if self.no_header == 0: + msg = "platform {} -- Python {}".format(sys.platform, verinfo) + pypy_version_info = getattr(sys, "pypy_version_info", None) + if pypy_version_info: + verinfo = ".".join(map(str, pypy_version_info[:3])) + msg += "[pypy-{}-{}]".format(verinfo, pypy_version_info[3]) + msg += ", pytest-{}, py-{}, pluggy-{}".format( + pytest.__version__, py.__version__, pluggy.__version__ + ) + if ( + self.verbosity > 0 + or self.config.option.debug + or getattr(self.config.option, "pastebin", None) + ): + msg += " -- " + str(sys.executable) + self.write_line(msg) + lines = self.config.hook.pytest_report_header( + config=self.config, startdir=self.startdir + ) + self._write_report_lines_from_hooks(lines) def _write_report_lines_from_hooks( self, lines: List[Union[str, List[str]]] @@ -775,7 +798,7 @@ class TerminalReporter: ExitCode.USAGE_ERROR, ExitCode.NO_TESTS_COLLECTED, ) - if exitstatus in summary_exit_codes: + if exitstatus in summary_exit_codes and self.no_summary == 0: self.config.hook.pytest_terminal_summary( terminalreporter=self, exitstatus=exitstatus, config=self.config ) From 51fb11c1d1436fb438cfe4d014b34c46fc342b70 Mon Sep 17 00:00:00 2001 From: piotrhm Date: Mon, 25 May 2020 19:29:18 +0200 Subject: [PATCH 066/140] Added tests --- changelog/6471.feature.rst | 2 +- src/_pytest/terminal.py | 12 +++---- testing/test_terminal.py | 74 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/changelog/6471.feature.rst b/changelog/6471.feature.rst index bc2d7564e..ebc9a208d 100644 --- a/changelog/6471.feature.rst +++ b/changelog/6471.feature.rst @@ -1 +1 @@ -New flags --no-header and --no-summary added. The first one disables header, the second one disables summary. \ No newline at end of file +New flags --no-header and --no-summary added. The first one disables header, the second one disables summary. diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 2a98e4ceb..6a4a609b4 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -117,15 +117,15 @@ def pytest_addoption(parser: Parser) -> None: ) group._addoption( "--no-header", - action="count", - default=0, + action="store_true", + default=False, dest="no_header", help="disable header", ) group._addoption( "--no-summary", - action="count", - default=0, + action="store_true", + default=False, dest="no_summary", help="disable summary", ) @@ -682,7 +682,7 @@ class TerminalReporter: return self.write_sep("=", "test session starts", bold=True) verinfo = platform.python_version() - if self.no_header == 0: + if not self.no_header: msg = "platform {} -- Python {}".format(sys.platform, verinfo) pypy_version_info = getattr(sys, "pypy_version_info", None) if pypy_version_info: @@ -798,7 +798,7 @@ class TerminalReporter: ExitCode.USAGE_ERROR, ExitCode.NO_TESTS_COLLECTED, ) - if exitstatus in summary_exit_codes and self.no_summary == 0: + if exitstatus in summary_exit_codes and not self.no_summary: self.config.hook.pytest_terminal_summary( terminalreporter=self, exitstatus=exitstatus, config=self.config ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index e8402079b..8b8c246ba 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -698,6 +698,29 @@ class TestTerminalFunctional: if request.config.pluginmanager.list_plugin_distinfo(): result.stdout.fnmatch_lines(["plugins: *"]) + def test_no_header_trailer_info(self, testdir, request): + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + testdir.makepyfile( + """ + def test_passes(): + pass + """ + ) + result = testdir.runpytest("--no-header") + verinfo = ".".join(map(str, sys.version_info[:3])) + result.stdout.no_fnmatch_line( + "platform %s -- Python %s*pytest-%s*py-%s*pluggy-%s" + % ( + sys.platform, + verinfo, + pytest.__version__, + py.__version__, + pluggy.__version__, + ) + ) + if request.config.pluginmanager.list_plugin_distinfo(): + result.stdout.no_fnmatch_line("plugins: *") + def test_header(self, testdir): testdir.tmpdir.join("tests").ensure_dir() testdir.tmpdir.join("gui").ensure_dir() @@ -727,6 +750,36 @@ class TestTerminalFunctional: result = testdir.runpytest("tests") result.stdout.fnmatch_lines(["rootdir: *test_header0, configfile: tox.ini"]) + def test_no_header(self, testdir): + testdir.tmpdir.join("tests").ensure_dir() + testdir.tmpdir.join("gui").ensure_dir() + + # with testpaths option, and not passing anything in the command-line + testdir.makeini( + """ + [pytest] + testpaths = tests gui + """ + ) + result = testdir.runpytest("--no-header") + result.stdout.no_fnmatch_line( + "rootdir: *test_header0, inifile: tox.ini, testpaths: tests, gui" + ) + + # with testpaths option, passing directory in command-line: do not show testpaths then + result = testdir.runpytest("tests", "--no-header") + result.stdout.no_fnmatch_line("rootdir: *test_header0, inifile: tox.ini") + + def test_no_summary(self, testdir): + p1 = testdir.makepyfile( + """ + def test_no_summary(): + assert false + """ + ) + result = testdir.runpytest("--no-summary") + result.stdout.no_fnmatch_line("*= FAILURES =*") + def test_showlocals(self, testdir): p1 = testdir.makepyfile( """ @@ -1483,6 +1536,21 @@ def test_terminal_summary_warnings_header_once(testdir): assert stdout.count("=== warnings summary ") == 1 +@pytest.mark.filterwarnings("default") +def test_terminal_no_summary_warnings_header_once(testdir): + testdir.makepyfile( + """ + def test_failure(): + import warnings + warnings.warn("warning_from_" + "test") + assert 0 + """ + ) + result = testdir.runpytest("--no-summary") + result.stdout.no_fnmatch_line("*= warnings summary =*") + result.stdout.no_fnmatch_line("*= short test summary info =*") + + @pytest.fixture(scope="session") def tr() -> TerminalReporter: config = _pytest.config._prepareconfig() @@ -2130,6 +2198,12 @@ def test_collecterror(testdir): ) +def test_no_summary_collecterror(testdir): + p1 = testdir.makepyfile("raise SyntaxError()") + result = testdir.runpytest("-ra", "--no-summary", str(p1)) + result.stdout.no_fnmatch_line("*= ERRORS =*") + + def test_via_exec(testdir: Testdir) -> None: p1 = testdir.makepyfile("exec('def test_via_exec(): pass')") result = testdir.runpytest(str(p1), "-vv") From 5e0e12d69b2494135e35ef3dcba9434daa932914 Mon Sep 17 00:00:00 2001 From: piotrhm Date: Mon, 25 May 2020 19:36:57 +0200 Subject: [PATCH 067/140] Fixed linting --- testing/test_terminal.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 8b8c246ba..5b6c7109a 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -709,14 +709,14 @@ class TestTerminalFunctional: result = testdir.runpytest("--no-header") verinfo = ".".join(map(str, sys.version_info[:3])) result.stdout.no_fnmatch_line( - "platform %s -- Python %s*pytest-%s*py-%s*pluggy-%s" - % ( - sys.platform, - verinfo, - pytest.__version__, - py.__version__, - pluggy.__version__, - ) + "platform %s -- Python %s*pytest-%s*py-%s*pluggy-%s" + % ( + sys.platform, + verinfo, + pytest.__version__, + py.__version__, + pluggy.__version__, + ) ) if request.config.pluginmanager.list_plugin_distinfo(): result.stdout.no_fnmatch_line("plugins: *") From 2be1c61eb3a0c202df4ca9ee0d764b5bdaad2001 Mon Sep 17 00:00:00 2001 From: piotrhm Date: Mon, 25 May 2020 20:07:10 +0200 Subject: [PATCH 068/140] Fixed linting 2 --- testing/test_terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 5b6c7109a..47243335b 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -779,7 +779,7 @@ class TestTerminalFunctional: ) result = testdir.runpytest("--no-summary") result.stdout.no_fnmatch_line("*= FAILURES =*") - + def test_showlocals(self, testdir): p1 = testdir.makepyfile( """ From df562533ffc467dda8da94c1d87f0722851223eb Mon Sep 17 00:00:00 2001 From: piotrhm Date: Wed, 27 May 2020 12:38:21 +0200 Subject: [PATCH 069/140] Fixed test --- testing/test_terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 47243335b..f1481dce5 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -777,7 +777,7 @@ class TestTerminalFunctional: assert false """ ) - result = testdir.runpytest("--no-summary") + result = testdir.runpytest(p1, "--no-summary") result.stdout.no_fnmatch_line("*= FAILURES =*") def test_showlocals(self, testdir): From d5a8bf7c6cfed4950b758a5539fb229497b7dca8 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 8 Jun 2020 22:13:29 -0300 Subject: [PATCH 070/140] Improve CHANGELOG --- changelog/6471.feature.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog/6471.feature.rst b/changelog/6471.feature.rst index ebc9a208d..28457b9f5 100644 --- a/changelog/6471.feature.rst +++ b/changelog/6471.feature.rst @@ -1 +1,4 @@ -New flags --no-header and --no-summary added. The first one disables header, the second one disables summary. +New command-line flags: + +* `--no-header`: disables the initial header, including platform, version, and plugins. +* `--no-summary`: disables the final test summary, including warnings. From 357f9b6e839d6f7021904b28d974933aeb0f219b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 8 Jun 2020 22:23:28 -0300 Subject: [PATCH 071/140] Add type annotations --- src/_pytest/terminal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 6a4a609b4..4168f3122 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -367,11 +367,11 @@ class TerminalReporter: @property def no_header(self) -> bool: - return self.config.option.no_header + return bool(self.config.option.no_header) @property def no_summary(self) -> bool: - return self.config.option.no_summary + return bool(self.config.option.no_summary) @property def showfspath(self) -> bool: From 96d4e2f571c59a6b22bf1a68c665ff5c08be87ef Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Mon, 8 Jun 2020 20:37:50 -0400 Subject: [PATCH 072/140] Add documentation on closing issues --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++++ CONTRIBUTING.rst | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d189f7869..9408fceaf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,10 @@ Here is a quick checklist that should be present in PRs. - [ ] 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. +If this change fixes an issue, please: + +- [ ] Add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the [github docs](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for more information. + Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please: - [ ] Create a new changelog file in the `changelog` folder, with a name like `..rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/master/changelog/README.rst) for details. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index d5bd78144..5e309a317 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -418,3 +418,10 @@ When closing a Pull Request, it needs to be acknowledge the time, effort, and in Again we appreciate your time for working on this, and hope you might get back to this at a later time! + +Closing Issues +-------------- + +When a pull request is submitted to fix an issue, add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the `GitHub docs `_ for more information. + +When an issue is due to user error (e.g. misunderstanding of a functionality), please politely explain to the user why the issue raised is really a non-issue and ask them to close the issue if they have no further questions. If the original requestor is unresponsive, the issue will be handled as described in the section `Handling stale issues/PRs`_ above. From c471b382f5f8e060adcaca092ad3b676634e9025 Mon Sep 17 00:00:00 2001 From: Xinbin Huang Date: Mon, 8 Jun 2020 21:01:11 -0700 Subject: [PATCH 073/140] Remove start_doc_server.sh script --- doc/en/start_doc_server.sh | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 doc/en/start_doc_server.sh diff --git a/doc/en/start_doc_server.sh b/doc/en/start_doc_server.sh deleted file mode 100644 index f68677409..000000000 --- a/doc/en/start_doc_server.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -MY_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd "${MY_DIR}"/_build/html || exit -python -m http.server 8000 From bde0ebcda91637366d84fa2c526acb33663c9d22 Mon Sep 17 00:00:00 2001 From: piotrhm Date: Fri, 20 Mar 2020 15:38:55 +0100 Subject: [PATCH 074/140] Replace cleanup_numbered_dir with atexit.register --- AUTHORS | 1 + changelog/1120.bugfix.rst | 1 + src/_pytest/pathlib.py | 14 ++++++++------ 3 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 changelog/1120.bugfix.rst diff --git a/AUTHORS b/AUTHORS index fdcd5b6e0..821a7d8f4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -227,6 +227,7 @@ Pedro Algarvio Philipp Loose Pieter Mulder Piotr Banaszkiewicz +Piotr Helm Prashant Anand Pulkit Goyal Punyashloka Biswal diff --git a/changelog/1120.bugfix.rst b/changelog/1120.bugfix.rst new file mode 100644 index 000000000..96d9887d7 --- /dev/null +++ b/changelog/1120.bugfix.rst @@ -0,0 +1 @@ +Fix issue where directories from tmpdir are not removed properly when multiple instances of pytest are running in parallel. \ No newline at end of file diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 69f490a1d..8c68fe9e5 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -333,16 +333,18 @@ def make_numbered_dir_with_cleanup( try: p = make_numbered_dir(root, prefix) lock_path = create_cleanup_lock(p) - register_cleanup_lock_removal(lock_path) + register_cleanup_lock_removal(lock_path) except Exception as exc: e = exc else: consider_lock_dead_if_created_before = p.stat().st_mtime - lock_timeout - cleanup_numbered_dir( - root=root, - prefix=prefix, - keep=keep, - consider_lock_dead_if_created_before=consider_lock_dead_if_created_before, + # Register a cleanup for program exit + atexit.register( + cleanup_numbered_dir, + root, + prefix, + keep, + consider_lock_dead_if_created_before, ) return p assert e is not None From f0e47c1ed6b89a5111f5f0a48c600ad91de5b767 Mon Sep 17 00:00:00 2001 From: piotrhm Date: Fri, 20 Mar 2020 16:49:00 +0100 Subject: [PATCH 075/140] Fix typo --- src/_pytest/pathlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 8c68fe9e5..29d8c4dc9 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -333,7 +333,7 @@ def make_numbered_dir_with_cleanup( try: p = make_numbered_dir(root, prefix) lock_path = create_cleanup_lock(p) - register_cleanup_lock_removal(lock_path) + register_cleanup_lock_removal(lock_path) except Exception as exc: e = exc else: From e862643b3fb701db040ca000041915984ae64a22 Mon Sep 17 00:00:00 2001 From: piotrhm Date: Sun, 22 Mar 2020 11:28:24 +0100 Subject: [PATCH 076/140] Update 1120.bugfix.rst --- changelog/1120.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/1120.bugfix.rst b/changelog/1120.bugfix.rst index 96d9887d7..95e74fa75 100644 --- a/changelog/1120.bugfix.rst +++ b/changelog/1120.bugfix.rst @@ -1 +1 @@ -Fix issue where directories from tmpdir are not removed properly when multiple instances of pytest are running in parallel. \ No newline at end of file +Fix issue where directories from tmpdir are not removed properly when multiple instances of pytest are running in parallel. From e2e7f15b719f480c4d2a3aea028c55f2dc3f0b75 Mon Sep 17 00:00:00 2001 From: ibriquem Date: Tue, 2 Jun 2020 15:38:41 +0200 Subject: [PATCH 077/140] Make dataclasses/attrs comparison recursive, fixes #4675 --- changelog/4675.bugfix.rst | 1 + src/_pytest/assertion/util.py | 48 ++++++----- .../test_compare_recursive_dataclasses.py | 34 ++++++++ testing/test_assertion.py | 80 +++++++++++++++++++ 4 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 changelog/4675.bugfix.rst create mode 100644 testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py diff --git a/changelog/4675.bugfix.rst b/changelog/4675.bugfix.rst new file mode 100644 index 000000000..9f857622f --- /dev/null +++ b/changelog/4675.bugfix.rst @@ -0,0 +1 @@ +Make dataclasses/attrs comparison recursive. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 7d525aa4c..c2f0431d4 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -148,26 +148,7 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ explanation = None try: if op == "==": - if istext(left) and istext(right): - explanation = _diff_text(left, right, verbose) - else: - if issequence(left) and issequence(right): - explanation = _compare_eq_sequence(left, right, verbose) - elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, verbose) - elif isdict(left) and isdict(right): - explanation = _compare_eq_dict(left, right, verbose) - elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): - type_fn = (isdatacls, isattrs) - explanation = _compare_eq_cls(left, right, verbose, type_fn) - elif verbose > 0: - explanation = _compare_eq_verbose(left, right) - if isiterable(left) and isiterable(right): - expl = _compare_eq_iterable(left, right, verbose) - if explanation is not None: - explanation.extend(expl) - else: - explanation = expl + explanation = _compare_eq_any(left, right, verbose) elif op == "not in": if istext(left) and istext(right): explanation = _notin_text(left, right, verbose) @@ -187,6 +168,28 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ return [summary] + explanation +def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: + explanation = [] # type: List[str] + if istext(left) and istext(right): + explanation = _diff_text(left, right, verbose) + else: + if issequence(left) and issequence(right): + explanation = _compare_eq_sequence(left, right, verbose) + elif isset(left) and isset(right): + explanation = _compare_eq_set(left, right, verbose) + elif isdict(left) and isdict(right): + explanation = _compare_eq_dict(left, right, verbose) + elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): + type_fn = (isdatacls, isattrs) + explanation = _compare_eq_cls(left, right, verbose, type_fn) + elif verbose > 0: + explanation = _compare_eq_verbose(left, right) + if isiterable(left) and isiterable(right): + expl = _compare_eq_iterable(left, right, verbose) + explanation.extend(expl) + return explanation + + def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: """Return the explanation for the diff between text. @@ -439,7 +442,10 @@ def _compare_eq_cls( explanation += ["Differing attributes:"] for field in diff: explanation += [ - ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)) + ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)), + "", + "Drill down into differing attribute %s:" % field, + *_compare_eq_any(getattr(left, field), getattr(right, field), verbose), ] return explanation diff --git a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py new file mode 100644 index 000000000..98385379e --- /dev/null +++ b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from dataclasses import field + + +@dataclass +class SimpleDataObject: + field_a: int = field() + field_b: int = field() + + +@dataclass +class ComplexDataObject2: + field_a: SimpleDataObject = field() + field_b: SimpleDataObject = field() + + +@dataclass +class ComplexDataObject: + field_a: SimpleDataObject = field() + field_b: ComplexDataObject2 = field() + + +def test_recursive_dataclasses(): + + left = ComplexDataObject( + SimpleDataObject(1, "b"), + ComplexDataObject2(SimpleDataObject(1, "b"), SimpleDataObject(2, "c"),), + ) + right = ComplexDataObject( + SimpleDataObject(1, "b"), + ComplexDataObject2(SimpleDataObject(1, "b"), SimpleDataObject(3, "c"),), + ) + + assert left == right diff --git a/testing/test_assertion.py b/testing/test_assertion.py index f28876edc..4b1df89c9 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -781,6 +781,48 @@ class TestAssert_reprcompare_dataclass: "*Omitting 1 identical items, use -vv to show*", "*Differing attributes:*", "*field_b: 'b' != 'c'*", + "*- c*", + "*+ b*", + ] + ) + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") + def test_recursive_dataclasses(self, testdir): + p = testdir.copy_example("dataclasses/test_compare_recursive_dataclasses.py") + result = testdir.runpytest(p) + result.assert_outcomes(failed=1, passed=0) + result.stdout.fnmatch_lines( + [ + "*Omitting 1 identical items, use -vv to show*", + "*Differing attributes:*", + "*field_b: ComplexDataObject2(*SimpleDataObject(field_a=2, field_b='c')) != ComplexDataObject2(*SimpleDataObject(field_a=3, field_b='c'))*", # noqa + "*Drill down into differing attribute field_b:*", + "*Omitting 1 identical items, use -vv to show*", + "*Differing attributes:*", + "*Full output truncated*", + ] + ) + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") + def test_recursive_dataclasses_verbose(self, testdir): + p = testdir.copy_example("dataclasses/test_compare_recursive_dataclasses.py") + result = testdir.runpytest(p, "-vv") + result.assert_outcomes(failed=1, passed=0) + result.stdout.fnmatch_lines( + [ + "*Matching attributes:*", + "*['field_a']*", + "*Differing attributes:*", + "*field_b: ComplexDataObject2(*SimpleDataObject(field_a=2, field_b='c')) != ComplexDataObject2(*SimpleDataObject(field_a=3, field_b='c'))*", # noqa + "*Matching attributes:*", + "*['field_a']*", + "*Differing attributes:*", + "*field_b: SimpleDataObject(field_a=2, field_b='c') " + "!= SimpleDataObject(field_a=3, field_b='c')*", + "*Matching attributes:*", + "*['field_b']*", + "*Differing attributes:*", + "*field_a: 2 != 3", ] ) @@ -832,6 +874,44 @@ class TestAssert_reprcompare_attrsclass: for line in lines[1:]: assert "field_a" not in line + def test_attrs_recursive(self) -> None: + @attr.s + class OtherDataObject: + field_c = attr.ib() + field_d = attr.ib() + + @attr.s + class SimpleDataObject: + field_a = attr.ib() + field_b = attr.ib() + + left = SimpleDataObject(OtherDataObject(1, "a"), "b") + right = SimpleDataObject(OtherDataObject(1, "b"), "b") + + lines = callequal(left, right) + assert "Matching attributes" not in lines + for line in lines[1:]: + assert "field_b:" not in line + assert "field_c:" not in line + + def test_attrs_recursive_verbose(self) -> None: + @attr.s + class OtherDataObject: + field_c = attr.ib() + field_d = attr.ib() + + @attr.s + class SimpleDataObject: + field_a = attr.ib() + field_b = attr.ib() + + left = SimpleDataObject(OtherDataObject(1, "a"), "b") + right = SimpleDataObject(OtherDataObject(1, "b"), "b") + + lines = callequal(left, right) + assert "field_d: 'a' != 'b'" in lines + print("\n".join(lines)) + def test_attrs_verbose(self) -> None: @attr.s class SimpleDataObject: From 09988f3ed1aec29a94f3ac662ef11e99fe1ffafb Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 3 Jun 2020 16:06:22 +0300 Subject: [PATCH 078/140] Update testing/test_assertion.py --- testing/test_assertion.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 4b1df89c9..fcfcf430d 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -817,8 +817,7 @@ class TestAssert_reprcompare_dataclass: "*Matching attributes:*", "*['field_a']*", "*Differing attributes:*", - "*field_b: SimpleDataObject(field_a=2, field_b='c') " - "!= SimpleDataObject(field_a=3, field_b='c')*", + "*field_b: SimpleDataObject(field_a=2, field_b='c') != SimpleDataObject(field_a=3, field_b='c')*", # noqa "*Matching attributes:*", "*['field_b']*", "*Differing attributes:*", From 5a78df4bd059d4c6103217ba9146dcf9d08f989c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 9 Jun 2020 14:43:04 -0300 Subject: [PATCH 079/140] Update CHANGELOG --- changelog/4675.bugfix.rst | 1 - changelog/4675.improvement.rst | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 changelog/4675.bugfix.rst create mode 100644 changelog/4675.improvement.rst diff --git a/changelog/4675.bugfix.rst b/changelog/4675.bugfix.rst deleted file mode 100644 index 9f857622f..000000000 --- a/changelog/4675.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Make dataclasses/attrs comparison recursive. diff --git a/changelog/4675.improvement.rst b/changelog/4675.improvement.rst new file mode 100644 index 000000000..d26e24da2 --- /dev/null +++ b/changelog/4675.improvement.rst @@ -0,0 +1 @@ +Rich comparision for dataclasses and `attrs`-classes is now recursive. From c229d6f46ffc77c21ee8773cd341d25d4f8291ba Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 9 Jun 2020 14:48:49 -0300 Subject: [PATCH 080/140] Fix mypy checks --- .../dataclasses/test_compare_recursive_dataclasses.py | 2 +- testing/test_assertion.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py index 98385379e..516e36e5c 100644 --- a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py @@ -5,7 +5,7 @@ from dataclasses import field @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field() + field_b: str = field() @dataclass diff --git a/testing/test_assertion.py b/testing/test_assertion.py index fcfcf430d..ae5e75dbf 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -888,6 +888,7 @@ class TestAssert_reprcompare_attrsclass: right = SimpleDataObject(OtherDataObject(1, "b"), "b") lines = callequal(left, right) + assert lines is not None assert "Matching attributes" not in lines for line in lines[1:]: assert "field_b:" not in line @@ -908,8 +909,8 @@ class TestAssert_reprcompare_attrsclass: right = SimpleDataObject(OtherDataObject(1, "b"), "b") lines = callequal(left, right) + assert lines is not None assert "field_d: 'a' != 'b'" in lines - print("\n".join(lines)) def test_attrs_verbose(self) -> None: @attr.s From 10cee92955f9fbd5c39ba2b02e7d8d206458c0eb Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 9 Jun 2020 14:58:57 -0300 Subject: [PATCH 081/140] Fix typo --- changelog/4675.improvement.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/4675.improvement.rst b/changelog/4675.improvement.rst index d26e24da2..c90cd3591 100644 --- a/changelog/4675.improvement.rst +++ b/changelog/4675.improvement.rst @@ -1 +1 @@ -Rich comparision for dataclasses and `attrs`-classes is now recursive. +Rich comparison for dataclasses and `attrs`-classes is now recursive. From 95cb7fb676405fe9281252b68bc80f5de747a4de Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Wed, 10 Jun 2020 00:44:22 -0400 Subject: [PATCH 082/140] review feedback --- changelog/7305.feature.rst | 4 +-- doc/en/reference.rst | 7 ++--- src/_pytest/config/__init__.py | 22 ++++++++++---- testing/test_config.py | 54 +++++++++++++--------------------- 4 files changed, 41 insertions(+), 46 deletions(-) diff --git a/changelog/7305.feature.rst b/changelog/7305.feature.rst index b8c0ca693..25978a396 100644 --- a/changelog/7305.feature.rst +++ b/changelog/7305.feature.rst @@ -1,3 +1 @@ -New `require_plugins` configuration option allows the user to specify a list of plugins required for pytest to run. Warnings are raised if these plugins are not found when running pytest. - -The `--strict-config` flag can be used to treat these warnings as errors. +New `required_plugins` configuration option allows the user to specify a list of plugins required for pytest to run. An error is raised if any required plugins are not found when running pytest. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 6b270796c..f58881d02 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1561,16 +1561,15 @@ passed multiple times. The expected format is ``name=value``. For example:: See :ref:`change naming conventions` for more detailed examples. -.. confval:: require_plugins +.. confval:: required_plugins A space separated list of plugins that must be present for pytest to run. - If any one of the plugins is not found, emit a warning. - If pytest is run with ``--strict-config`` exceptions are raised in place of warnings. + If any one of the plugins is not found, emit a error. .. code-block:: ini [pytest] - require_plugins = html xdist + required_plugins = pytest-html pytest-xdist .. confval:: testpaths diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index d55a5cdd7..483bc617f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -953,7 +953,7 @@ class Config: self._parser.addini("addopts", "extra command line options", "args") self._parser.addini("minversion", "minimally required pytest version") self._parser.addini( - "require_plugins", + "required_plugins", "plugins that must be present for pytest to run", type="args", default=[], @@ -1090,11 +1090,21 @@ class Config: self._emit_warning_or_fail("Unknown config ini key: {}\n".format(key)) def _validate_plugins(self) -> None: - for plugin in self.getini("require_plugins"): - if not self.pluginmanager.hasplugin(plugin): - self._emit_warning_or_fail( - "Missing required plugin: {}\n".format(plugin) - ) + plugin_info = self.pluginmanager.list_plugin_distinfo() + plugin_dist_names = [ + "{dist.project_name}".format(dist=dist) for _, dist in plugin_info + ] + + required_plugin_list = [] + for plugin in sorted(self.getini("required_plugins")): + if plugin not in plugin_dist_names: + required_plugin_list.append(plugin) + + if required_plugin_list: + fail( + "Missing required plugins: {}".format(", ".join(required_plugin_list)), + pytrace=False, + ) def _emit_warning_or_fail(self, message: str) -> None: if self.known_args_namespace.strict_config: diff --git a/testing/test_config.py b/testing/test_config.py index f88a9a0ce..ab7f50ee5 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -213,51 +213,36 @@ class TestParseIni: testdir.runpytest("--strict-config") @pytest.mark.parametrize( - "ini_file_text, stderr_output, exception_text", + "ini_file_text, exception_text", [ ( """ [pytest] - require_plugins = fakePlugin1 fakePlugin2 + required_plugins = fakePlugin1 fakePlugin2 """, - [ - "WARNING: Missing required plugin: fakePlugin1", - "WARNING: Missing required plugin: fakePlugin2", - ], - "Missing required plugin: fakePlugin1", + "Missing required plugins: fakePlugin1, fakePlugin2", ), ( """ [pytest] - require_plugins = a monkeypatch z + required_plugins = a pytest-xdist z """, - [ - "WARNING: Missing required plugin: a", - "WARNING: Missing required plugin: z", - ], - "Missing required plugin: a", + "Missing required plugins: a, z", ), ( """ [pytest] - require_plugins = a monkeypatch z - addopts = -p no:monkeypatch + required_plugins = a q j b c z """, - [ - "WARNING: Missing required plugin: a", - "WARNING: Missing required plugin: monkeypatch", - "WARNING: Missing required plugin: z", - ], - "Missing required plugin: a", + "Missing required plugins: a, b, c, j, q, z", ), ( """ [some_other_header] - require_plugins = wont be triggered + required_plugins = wont be triggered [pytest] minversion = 5.0.0 """, - [], "", ), ( @@ -265,23 +250,21 @@ class TestParseIni: [pytest] minversion = 5.0.0 """, - [], "", ), ], ) - def test_missing_required_plugins( - self, testdir, ini_file_text, stderr_output, exception_text - ): + def test_missing_required_plugins(self, testdir, ini_file_text, exception_text): + pytest.importorskip("xdist") + testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text)) - testdir.parseconfig() + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") - result = testdir.runpytest() - result.stderr.fnmatch_lines(stderr_output) - - if stderr_output: + if exception_text: with pytest.raises(pytest.fail.Exception, match=exception_text): - testdir.runpytest("--strict-config") + testdir.parseconfig() + else: + testdir.parseconfig() class TestConfigCmdlineParsing: @@ -681,6 +664,7 @@ def test_preparse_ordering_with_setuptools(testdir, monkeypatch): class Dist: files = () + metadata = {"name": "foo"} entry_points = (EntryPoint(),) def my_dists(): @@ -711,6 +695,7 @@ def test_setuptools_importerror_issue1479(testdir, monkeypatch): class Distribution: version = "1.0" files = ("foo.txt",) + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -735,6 +720,7 @@ def test_importlib_metadata_broken_distribution(testdir, monkeypatch): class Distribution: version = "1.0" files = None + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -760,6 +746,7 @@ def test_plugin_preparse_prevents_setuptools_loading(testdir, monkeypatch, block class Distribution: version = "1.0" files = ("foo.txt",) + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -791,6 +778,7 @@ def test_disable_plugin_autoload(testdir, monkeypatch, parse_args, should_load): return sys.modules[self.name] class Distribution: + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) files = () From e36d5c05c69c6ba82d4a5517389493a614137939 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 10 Jun 2020 13:08:27 +0200 Subject: [PATCH 083/140] doc: Explain indirect parametrization and markers for fixtures --- changelog/7345.doc.rst | 1 + doc/en/example/parametrize.rst | 24 ++++++++++++++++++++++++ doc/en/fixture.rst | 31 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 changelog/7345.doc.rst diff --git a/changelog/7345.doc.rst b/changelog/7345.doc.rst new file mode 100644 index 000000000..4c7234f41 --- /dev/null +++ b/changelog/7345.doc.rst @@ -0,0 +1 @@ +Explain indirect parametrization and markers for fixtures diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index 9500af0d3..0b61a19bc 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -351,6 +351,30 @@ And then when we run the test: The first invocation with ``db == "DB1"`` passed while the second with ``db == "DB2"`` failed. Our ``db`` fixture function has instantiated each of the DB values during the setup phase while the ``pytest_generate_tests`` generated two according calls to the ``test_db_initialized`` during the collection phase. +Indirect parametrization +--------------------------------------------------- + +Using the ``indirect=True`` parameter when parametrizing a test allows to +parametrize a test with a fixture receiving the values before passing them to a +test: + +.. code-block:: python + + import pytest + + + @pytest.fixture + def fixt(request): + return request.param * 3 + + + @pytest.mark.parametrize("fixt", ["a", "b"], indirect=True) + def test_indirect(fixt): + assert len(fixt) == 3 + +This can be used, for example, to do more expensive setup at test run time in +the fixture, rather than having to run those setup steps at collection time. + .. regendoc:wipe Apply indirect on particular arguments diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index 925a4b559..b529996ed 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -665,6 +665,37 @@ Running it: voila! The ``smtp_connection`` fixture function picked up our mail server name from the module namespace. +.. _`using-markers`: + +Using markers to pass data to fixtures +------------------------------------------------------------- + +Using the :py:class:`request ` object, a fixture can also access +markers which are applied to a test function. This can be useful to pass data +into a fixture from a test: + +.. code-block:: python + + import pytest + + + @pytest.fixture + def fixt(request): + marker = request.node.get_closest_marker("fixt_data") + if marker is None: + # Handle missing marker in some way... + data = None + else: + data = marker.args[0] + + # Do something with the data + return data + + + @pytest.mark.fixt_data(42) + def test_fixt(fixt): + assert fixt == 42 + .. _`fixture-factory`: Factories as fixtures From c18afb59f50d280c34e1a7a1fd4a49831952d860 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Wed, 10 Jun 2020 19:09:24 -0400 Subject: [PATCH 084/140] final touches --- src/_pytest/config/__init__.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 483bc617f..16bf75b6e 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1090,19 +1090,21 @@ class Config: self._emit_warning_or_fail("Unknown config ini key: {}\n".format(key)) def _validate_plugins(self) -> None: + required_plugins = sorted(self.getini("required_plugins")) + if not required_plugins: + return + plugin_info = self.pluginmanager.list_plugin_distinfo() - plugin_dist_names = [ - "{dist.project_name}".format(dist=dist) for _, dist in plugin_info - ] + plugin_dist_names = [dist.project_name for _, dist in plugin_info] - required_plugin_list = [] - for plugin in sorted(self.getini("required_plugins")): + missing_plugins = [] + for plugin in required_plugins: if plugin not in plugin_dist_names: - required_plugin_list.append(plugin) + missing_plugins.append(plugin) - if required_plugin_list: + if missing_plugins: fail( - "Missing required plugins: {}".format(", ".join(required_plugin_list)), + "Missing required plugins: {}".format(", ".join(missing_plugins)), pytrace=False, ) From 68572179cb6296fd856c75e7395b5a578d12901f Mon Sep 17 00:00:00 2001 From: Martin Michlmayr Date: Thu, 11 Jun 2020 16:22:47 +0800 Subject: [PATCH 085/140] doc: Fix typos and cosmetic issues --- doc/en/assert.rst | 2 +- doc/en/fixture.rst | 6 +++--- doc/en/parametrize.rst | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/en/assert.rst b/doc/en/assert.rst index 5ece98e96..a3a34a9c6 100644 --- a/doc/en/assert.rst +++ b/doc/en/assert.rst @@ -98,7 +98,7 @@ and if you need to have access to the actual exception info you may use: f() assert "maximum recursion" in str(excinfo.value) -``excinfo`` is a ``ExceptionInfo`` instance, which is a wrapper around +``excinfo`` is an ``ExceptionInfo`` instance, which is a wrapper around the actual exception raised. The main attributes of interest are ``.type``, ``.value`` and ``.traceback``. diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index b529996ed..a4e262c2f 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -179,7 +179,7 @@ In the failure traceback we see that the test function was called with a function. The test function fails on our deliberate ``assert 0``. Here is the exact protocol used by ``pytest`` to call the test function this way: -1. pytest :ref:`finds ` the ``test_ehlo`` because +1. pytest :ref:`finds ` the test ``test_ehlo`` because of the ``test_`` prefix. The test function needs a function argument named ``smtp_connection``. A matching fixture function is discovered by looking for a fixture-marked function named ``smtp_connection``. @@ -859,7 +859,7 @@ be used with ``-k`` to select specific cases to run, and they will also identify the specific case when one is failing. Running pytest with ``--collect-only`` will show the generated IDs. -Numbers, strings, booleans and None will have their usual string +Numbers, strings, booleans and ``None`` will have their usual string representation used in the test ID. For other objects, pytest will make a string based on the argument name. It is possible to customise the string used in a test ID for a certain fixture value by using the @@ -898,7 +898,7 @@ the string used in a test ID for a certain fixture value by using the The above shows how ``ids`` can be either a list of strings to use or a function which will be called with the fixture value and then has to return a string to use. In the latter case if the function -return ``None`` then pytest's auto-generated ID will be used. +returns ``None`` then pytest's auto-generated ID will be used. Running the above tests results in the following test IDs being used: diff --git a/doc/en/parametrize.rst b/doc/en/parametrize.rst index 29223e28e..1e356ebb3 100644 --- a/doc/en/parametrize.rst +++ b/doc/en/parametrize.rst @@ -133,7 +133,7 @@ Let's run this: ======================= 2 passed, 1 xfailed in 0.12s ======================= The one parameter set which caused a failure previously now -shows up as an "xfailed (expected to fail)" test. +shows up as an "xfailed" (expected to fail) test. In case the values provided to ``parametrize`` result in an empty list - for example, if they're dynamically generated by some function - the behaviour of From 57415e68ee6355325410e2326039262f7c605360 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Thu, 11 Jun 2020 16:55:25 -0400 Subject: [PATCH 086/140] Update changelog/7305.feature.rst Co-authored-by: Ran Benita --- changelog/7305.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/7305.feature.rst b/changelog/7305.feature.rst index 25978a396..96b7f72ee 100644 --- a/changelog/7305.feature.rst +++ b/changelog/7305.feature.rst @@ -1 +1 @@ -New `required_plugins` configuration option allows the user to specify a list of plugins required for pytest to run. An error is raised if any required plugins are not found when running pytest. +New ``required_plugins`` configuration option allows the user to specify a list of plugins required for pytest to run. An error is raised if any required plugins are not found when running pytest. From ab331c906e9047383eee11f4929b7edefe82b63e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 11 Jun 2020 16:47:59 -0300 Subject: [PATCH 087/140] Suppress errors while removing tmpdir's lock files Fix #5456 --- changelog/5456.bugfix.rst | 2 ++ src/_pytest/pathlib.py | 13 +++++++++---- testing/test_pathlib.py | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 changelog/5456.bugfix.rst diff --git a/changelog/5456.bugfix.rst b/changelog/5456.bugfix.rst new file mode 100644 index 000000000..176807570 --- /dev/null +++ b/changelog/5456.bugfix.rst @@ -0,0 +1,2 @@ +Fix a possible race condition when trying to remove lock files used to control access to folders +created by ``tmp_path`` and ``tmpdir``. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 29d8c4dc9..98ec936a1 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,4 +1,5 @@ import atexit +import contextlib import fnmatch import itertools import os @@ -290,10 +291,14 @@ def ensure_deletable(path: Path, consider_lock_dead_if_created_before: float) -> return False else: if lock_time < consider_lock_dead_if_created_before: - lock.unlink() - return True - else: - return False + # wa want to ignore any errors while trying to remove the lock such as: + # - PermissionDenied, like the file permissions have changed since the lock creation + # - FileNotFoundError, in case another pytest process got here first. + # and any other cause of failure. + with contextlib.suppress(OSError): + lock.unlink() + return True + return False def try_cleanup(path: Path, consider_lock_dead_if_created_before: float) -> None: diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 03bed26ec..acc963199 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1,9 +1,11 @@ import os.path import sys +import unittest.mock import py import pytest +from _pytest.pathlib import ensure_deletable from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import get_extended_length_path_str from _pytest.pathlib import get_lock_path @@ -113,3 +115,22 @@ def test_get_extended_length_path_str(): assert get_extended_length_path_str(r"\\share\foo") == r"\\?\UNC\share\foo" assert get_extended_length_path_str(r"\\?\UNC\share\foo") == r"\\?\UNC\share\foo" assert get_extended_length_path_str(r"\\?\c:\foo") == r"\\?\c:\foo" + + +def test_suppress_error_removing_lock(tmp_path): + """ensure_deletable should not raise an exception if the lock file cannot be removed (#5456)""" + path = tmp_path / "dir" + path.mkdir() + lock = get_lock_path(path) + lock.touch() + mtime = lock.stat().st_mtime + + with unittest.mock.patch.object(Path, "unlink", side_effect=OSError): + assert not ensure_deletable( + path, consider_lock_dead_if_created_before=mtime + 30 + ) + assert lock.is_file() + + # check now that we can remove the lock file in normal circumstances + assert ensure_deletable(path, consider_lock_dead_if_created_before=mtime + 30) + assert not lock.is_file() From 2c8e356174d9760a28f5ff6a3b5754417d41b7bc Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Fri, 12 Jun 2020 08:27:55 -0400 Subject: [PATCH 088/140] rename _emit_warning_or_fail to _warn_or_fail_if_strict and fix a doc typo --- doc/en/reference.rst | 2 +- src/_pytest/config/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index f58881d02..2fab4160c 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1564,7 +1564,7 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: required_plugins A space separated list of plugins that must be present for pytest to run. - If any one of the plugins is not found, emit a error. + If any one of the plugins is not found, emit an error. .. code-block:: ini diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 16bf75b6e..07985df2d 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1087,7 +1087,7 @@ class Config: def _validate_keys(self) -> None: for key in sorted(self._get_unknown_ini_keys()): - self._emit_warning_or_fail("Unknown config ini key: {}\n".format(key)) + self._warn_or_fail_if_strict("Unknown config ini key: {}\n".format(key)) def _validate_plugins(self) -> None: required_plugins = sorted(self.getini("required_plugins")) @@ -1108,7 +1108,7 @@ class Config: pytrace=False, ) - def _emit_warning_or_fail(self, message: str) -> None: + def _warn_or_fail_if_strict(self, message: str) -> None: if self.known_args_namespace.strict_config: fail(message, pytrace=False) sys.stderr.write("WARNING: {}".format(message)) From 564b2f707dd558d10974268ab5a5494de2f90238 Mon Sep 17 00:00:00 2001 From: Zac-HD Date: Fri, 12 Jun 2020 22:13:52 +1000 Subject: [PATCH 089/140] Finish deprecation of "slave" --- changelog/7356.trivial.rst | 1 + doc/en/announce/release-2.3.5.rst | 2 +- doc/en/announce/release-2.6.1.rst | 2 +- doc/en/changelog.rst | 6 +++--- doc/en/funcarg_compare.rst | 2 +- src/_pytest/cacheprovider.py | 4 ++-- src/_pytest/junitxml.py | 12 ++++++------ src/_pytest/pastebin.py | 2 +- src/_pytest/reports.py | 10 +++++----- src/_pytest/resultlog.py | 4 ++-- testing/test_junitxml.py | 8 ++++---- testing/test_resultlog.py | 4 ++-- 12 files changed, 29 insertions(+), 28 deletions(-) create mode 100644 changelog/7356.trivial.rst diff --git a/changelog/7356.trivial.rst b/changelog/7356.trivial.rst new file mode 100644 index 000000000..d280e2291 --- /dev/null +++ b/changelog/7356.trivial.rst @@ -0,0 +1 @@ +Remove last internal uses of deprecated "slave" term from old pytest-xdist. diff --git a/doc/en/announce/release-2.3.5.rst b/doc/en/announce/release-2.3.5.rst index 465dd826e..d68780a24 100644 --- a/doc/en/announce/release-2.3.5.rst +++ b/doc/en/announce/release-2.3.5.rst @@ -46,7 +46,7 @@ Changes between 2.3.4 and 2.3.5 - Issue 265 - integrate nose setup/teardown with setupstate so it doesn't try to teardown if it did not setup -- issue 271 - don't write junitxml on slave nodes +- issue 271 - don't write junitxml on worker nodes - Issue 274 - don't try to show full doctest example when doctest does not know the example location diff --git a/doc/en/announce/release-2.6.1.rst b/doc/en/announce/release-2.6.1.rst index fba6f2993..85d986164 100644 --- a/doc/en/announce/release-2.6.1.rst +++ b/doc/en/announce/release-2.6.1.rst @@ -32,7 +32,7 @@ Changes 2.6.1 purely the nodeid. The line number is still shown in failure reports. Thanks Floris Bruynooghe. -- fix issue437 where assertion rewriting could cause pytest-xdist slaves +- fix issue437 where assertion rewriting could cause pytest-xdist worker nodes to collect different tests. Thanks Bruno Oliveira. - fix issue555: add "errors" attribute to capture-streams to satisfy diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index 1a298072b..2806fb6a3 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -6159,7 +6159,7 @@ time or change existing behaviors in order to make them less surprising/more use purely the nodeid. The line number is still shown in failure reports. Thanks Floris Bruynooghe. -- fix issue437 where assertion rewriting could cause pytest-xdist slaves +- fix issue437 where assertion rewriting could cause pytest-xdist worker nodes to collect different tests. Thanks Bruno Oliveira. - fix issue555: add "errors" attribute to capture-streams to satisfy @@ -6706,7 +6706,7 @@ Bug fixes: - Issue 265 - integrate nose setup/teardown with setupstate so it doesn't try to teardown if it did not setup -- issue 271 - don't write junitxml on slave nodes +- issue 271 - don't write junitxml on worker nodes - Issue 274 - don't try to show full doctest example when doctest does not know the example location @@ -7588,7 +7588,7 @@ Bug fixes: - fix assert reinterpreation that sees a call containing "keyword=..." - fix issue66: invoke pytest_sessionstart and pytest_sessionfinish - hooks on slaves during dist-testing, report module/session teardown + hooks on worker nodes during dist-testing, report module/session teardown hooks correctly. - fix issue65: properly handle dist-testing if no diff --git a/doc/en/funcarg_compare.rst b/doc/en/funcarg_compare.rst index af7030165..4350c98b6 100644 --- a/doc/en/funcarg_compare.rst +++ b/doc/en/funcarg_compare.rst @@ -170,7 +170,7 @@ several problems: 1. in distributed testing the master process would setup test resources that are never needed because it only co-ordinates the test run - activities of the slave processes. + activities of the worker processes. 2. if you only perform a collection (with "--collect-only") resource-setup will still be executed. diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index af7d57a24..9baee1d4e 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -341,7 +341,7 @@ class LFPlugin: def pytest_sessionfinish(self, session: Session) -> None: config = self.config - if config.getoption("cacheshow") or hasattr(config, "slaveinput"): + if config.getoption("cacheshow") or hasattr(config, "workerinput"): return assert config.cache is not None @@ -386,7 +386,7 @@ class NFPlugin: def pytest_sessionfinish(self) -> None: config = self.config - if config.getoption("cacheshow") or hasattr(config, "slaveinput"): + if config.getoption("cacheshow") or hasattr(config, "workerinput"): return if config.getoption("collectonly"): diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 47ba89d38..e62bc5235 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -427,8 +427,8 @@ def pytest_addoption(parser: Parser) -> None: def pytest_configure(config: Config) -> None: xmlpath = config.option.xmlpath - # prevent opening xmllog on slave nodes (xdist) - if xmlpath and not hasattr(config, "slaveinput"): + # prevent opening xmllog on worker nodes (xdist) + if xmlpath and not hasattr(config, "workerinput"): junit_family = config.getini("junit_family") if not junit_family: _issue_warning_captured(deprecated.JUNIT_XML_DEFAULT_FAMILY, config.hook, 2) @@ -506,17 +506,17 @@ class LogXML: def finalize(self, report: TestReport) -> None: nodeid = getattr(report, "nodeid", report) # local hack to handle xdist report order - slavenode = getattr(report, "node", None) - reporter = self.node_reporters.pop((nodeid, slavenode)) + workernode = getattr(report, "node", None) + reporter = self.node_reporters.pop((nodeid, workernode)) if reporter is not None: reporter.finalize() def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter: nodeid = getattr(report, "nodeid", report) # type: Union[str, TestReport] # local hack to handle xdist report order - slavenode = getattr(report, "node", None) + workernode = getattr(report, "node", None) - key = nodeid, slavenode + key = nodeid, workernode if key in self.node_reporters: # TODO: breaks for --dist=each diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 7e6bbf50c..a3432c7a1 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -33,7 +33,7 @@ def pytest_configure(config: Config) -> None: if config.option.pastebin == "all": tr = config.pluginmanager.getplugin("terminalreporter") # if no terminal reporter plugin is present, nothing we can do here; - # this can happen when this function executes in a slave node + # this can happen when this function executes in a worker node # when using pytest-xdist, for example if tr is not None: # pastebin file will be utf-8 encoded binary file diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 7462cea0b..6a408354b 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -38,13 +38,13 @@ if TYPE_CHECKING: from _pytest.runner import CallInfo -def getslaveinfoline(node): +def getworkerinfoline(node): try: - return node._slaveinfocache + return node._workerinfocache except AttributeError: - d = node.slaveinfo + d = node.workerinfo ver = "%s.%s.%s" % d["version_info"][:3] - node._slaveinfocache = s = "[{}] {} -- Python {} {}".format( + node._workerinfocache = s = "[{}] {} -- Python {} {}".format( d["id"], d["sysplatform"], ver, d["executable"] ) return s @@ -71,7 +71,7 @@ class BaseReport: def toterminal(self, out) -> None: if hasattr(self, "node"): - out.line(getslaveinfoline(self.node)) + out.line(getworkerinfoline(self.node)) longrepr = self.longrepr if longrepr is None: diff --git a/src/_pytest/resultlog.py b/src/_pytest/resultlog.py index c2b0cf556..c870ef08e 100644 --- a/src/_pytest/resultlog.py +++ b/src/_pytest/resultlog.py @@ -29,8 +29,8 @@ def pytest_addoption(parser: Parser) -> None: def pytest_configure(config: Config) -> None: resultlog = config.option.resultlog - # prevent opening resultlog on slave nodes (xdist) - if resultlog and not hasattr(config, "slaveinput"): + # prevent opening resultlog on worker nodes (xdist) + if resultlog and not hasattr(config, "workerinput"): dirname = os.path.dirname(os.path.abspath(resultlog)) if not os.path.isdir(dirname): os.makedirs(dirname) diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index d7771cc97..f8a6a295f 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -866,12 +866,12 @@ def test_mangle_test_address(): assert newnames == ["a.my.py.thing", "Class", "method", "[a-1-::]"] -def test_dont_configure_on_slaves(tmpdir) -> None: +def test_dont_configure_on_workers(tmpdir) -> None: gotten = [] # type: List[object] class FakeConfig: if TYPE_CHECKING: - slaveinput = None + workerinput = None def __init__(self): self.pluginmanager = self @@ -891,7 +891,7 @@ def test_dont_configure_on_slaves(tmpdir) -> None: junitxml.pytest_configure(fake_config) assert len(gotten) == 1 - FakeConfig.slaveinput = None + FakeConfig.workerinput = None junitxml.pytest_configure(fake_config) assert len(gotten) == 1 @@ -1250,7 +1250,7 @@ def test_record_fixtures_xunit2(testdir, fixture_name, run_and_parse): def test_random_report_log_xdist(testdir, monkeypatch, run_and_parse): - """xdist calls pytest_runtest_logreport as they are executed by the slaves, + """xdist calls pytest_runtest_logreport as they are executed by the workers, with nodes from several nodes overlapping, so junitxml must cope with that to produce correct reports. #1064 """ diff --git a/testing/test_resultlog.py b/testing/test_resultlog.py index bad575e3d..8fc93d25c 100644 --- a/testing/test_resultlog.py +++ b/testing/test_resultlog.py @@ -177,7 +177,7 @@ def test_makedir_for_resultlog(testdir, LineMatcher): LineMatcher(lines).fnmatch_lines([". *:test_pass"]) -def test_no_resultlog_on_slaves(testdir): +def test_no_resultlog_on_workers(testdir): config = testdir.parseconfig("-p", "resultlog", "--resultlog=resultlog") assert resultlog_key not in config._store @@ -186,7 +186,7 @@ def test_no_resultlog_on_slaves(testdir): pytest_unconfigure(config) assert resultlog_key not in config._store - config.slaveinput = {} + config.workerinput = {} pytest_configure(config) assert resultlog_key not in config._store pytest_unconfigure(config) From f84ffd974719dd2500968fe44e5d63c9562812e8 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 8 Jun 2020 21:21:58 +0300 Subject: [PATCH 090/140] Remove unused type: ignores Not needed since update from mypy 0.770 -> 0.780. --- src/_pytest/cacheprovider.py | 3 +-- src/_pytest/capture.py | 8 +++----- src/_pytest/config/__init__.py | 4 +--- src/_pytest/mark/structures.py | 4 +--- testing/test_assertrewrite.py | 2 +- 5 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 9baee1d4e..305a122e9 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -464,8 +464,7 @@ def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: @pytest.hookimpl(tryfirst=True) def pytest_configure(config: Config) -> None: - # Type ignored: pending mechanism to store typed objects scoped to config. - config.cache = Cache.for_config(config) # type: ignore # noqa: F821 + config.cache = Cache.for_config(config) config.pluginmanager.register(LFPlugin(config), "lfplugin") config.pluginmanager.register(NFPlugin(config), "nfplugin") diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 041041284..6009e1f67 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -519,11 +519,10 @@ class MultiCapture: def pop_outerr_to_orig(self): """ pop current snapshot out/err capture and flush to orig streams. """ out, err = self.readouterr() - # TODO: Fix type ignores. if out: - self.out.writeorg(out) # type: ignore[union-attr] # noqa: F821 + self.out.writeorg(out) if err: - self.err.writeorg(err) # type: ignore[union-attr] # noqa: F821 + self.err.writeorg(err) return out, err def suspend_capturing(self, in_: bool = False) -> None: @@ -543,8 +542,7 @@ class MultiCapture: if self.err: self.err.resume() if self._in_suspended: - # TODO: Fix type ignore. - self.in_.resume() # type: ignore[union-attr] # noqa: F821 + self.in_.resume() self._in_suspended = False def stop_capturing(self) -> None: diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c94ea2a93..daccdc6a1 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -988,9 +988,7 @@ class Config: package_files = ( str(file) for dist in importlib_metadata.distributions() - # Type ignored due to missing stub: - # https://github.com/python/typeshed/pull/3795 - if any(ep.group == "pytest11" for ep in dist.entry_points) # type: ignore + if any(ep.group == "pytest11" for ep in dist.entry_points) for file in dist.files or [] ) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 7abff9b7b..3d512816c 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -66,9 +66,7 @@ def get_empty_parameterset_mark( fs, lineno, ) - # Type ignored because MarkDecorator.__call__() is a bit tough to - # annotate ATM. - return mark(reason=reason) # type: ignore[no-any-return] # noqa: F723 + return mark(reason=reason) class ParameterSet( diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 3813993be..38893deb9 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1258,7 +1258,7 @@ class TestEarlyRewriteBailout: def spy_find_spec(name, path): self.find_spec_calls.append(name) - return importlib.machinery.PathFinder.find_spec(name, path) # type: ignore + return importlib.machinery.PathFinder.find_spec(name, path) hook = AssertionRewritingHook(pytestconfig) # use default patterns, otherwise we inherit pytest's testing config From b4f046b7777c7f7f45fbb18ac02100cd5459a02e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 7 Jun 2020 12:44:11 +0300 Subject: [PATCH 091/140] monkeypatch: add type annotations --- src/_pytest/monkeypatch.py | 95 +++++++++++++++++++++++++++---------- testing/test_monkeypatch.py | 80 ++++++++++++++++--------------- 2 files changed, 113 insertions(+), 62 deletions(-) diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 9d802a625..09f1ac36e 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -4,17 +4,29 @@ import re import sys import warnings from contextlib import contextmanager +from typing import Any from typing import Generator +from typing import List +from typing import MutableMapping +from typing import Optional +from typing import Tuple +from typing import TypeVar +from typing import Union import pytest +from _pytest.compat import overload from _pytest.fixtures import fixture from _pytest.pathlib import Path RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$") +K = TypeVar("K") +V = TypeVar("V") + + @fixture -def monkeypatch(): +def monkeypatch() -> Generator["MonkeyPatch", None, None]: """The returned ``monkeypatch`` fixture provides these helper methods to modify objects, dictionaries or os.environ:: @@ -37,7 +49,7 @@ def monkeypatch(): mpatch.undo() -def resolve(name): +def resolve(name: str) -> object: # simplified from zope.dottedname parts = name.split(".") @@ -66,7 +78,7 @@ def resolve(name): return found -def annotated_getattr(obj, name, ann): +def annotated_getattr(obj: object, name: str, ann: str) -> object: try: obj = getattr(obj, name) except AttributeError: @@ -78,7 +90,7 @@ def annotated_getattr(obj, name, ann): return obj -def derive_importpath(import_path, raising): +def derive_importpath(import_path: str, raising: bool) -> Tuple[str, object]: if not isinstance(import_path, str) or "." not in import_path: raise TypeError( "must be absolute import path string, not {!r}".format(import_path) @@ -91,7 +103,7 @@ def derive_importpath(import_path, raising): class Notset: - def __repr__(self): + def __repr__(self) -> str: return "" @@ -102,11 +114,13 @@ class MonkeyPatch: """ Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes. """ - def __init__(self): - self._setattr = [] - self._setitem = [] - self._cwd = None - self._savesyspath = None + def __init__(self) -> None: + self._setattr = [] # type: List[Tuple[object, str, object]] + self._setitem = ( + [] + ) # type: List[Tuple[MutableMapping[Any, Any], object, object]] + self._cwd = None # type: Optional[str] + self._savesyspath = None # type: Optional[List[str]] @contextmanager def context(self) -> Generator["MonkeyPatch", None, None]: @@ -133,7 +147,25 @@ class MonkeyPatch: finally: m.undo() - def setattr(self, target, name, value=notset, raising=True): + @overload + def setattr( + self, target: str, name: object, value: Notset = ..., raising: bool = ..., + ) -> None: + raise NotImplementedError() + + @overload # noqa: F811 + def setattr( # noqa: F811 + self, target: object, name: str, value: object, raising: bool = ..., + ) -> None: + raise NotImplementedError() + + def setattr( # noqa: F811 + self, + target: Union[str, object], + name: Union[object, str], + value: object = notset, + raising: bool = True, + ) -> None: """ Set attribute value on target, memorizing the old value. By default raise AttributeError if the attribute did not exist. @@ -150,7 +182,7 @@ class MonkeyPatch: __tracebackhide__ = True import inspect - if value is notset: + if isinstance(value, Notset): if not isinstance(target, str): raise TypeError( "use setattr(target, name, value) or " @@ -159,6 +191,13 @@ class MonkeyPatch: ) value = name name, target = derive_importpath(target, raising) + else: + if not isinstance(name, str): + raise TypeError( + "use setattr(target, name, value) with name being a string or " + "setattr(target, value) with target being a dotted " + "import string" + ) oldval = getattr(target, name, notset) if raising and oldval is notset: @@ -170,7 +209,12 @@ class MonkeyPatch: self._setattr.append((target, name, oldval)) setattr(target, name, value) - def delattr(self, target, name=notset, raising=True): + def delattr( + self, + target: Union[object, str], + name: Union[str, Notset] = notset, + raising: bool = True, + ) -> None: """ Delete attribute ``name`` from ``target``, by default raise AttributeError it the attribute did not previously exist. @@ -184,7 +228,7 @@ class MonkeyPatch: __tracebackhide__ = True import inspect - if name is notset: + if isinstance(name, Notset): if not isinstance(target, str): raise TypeError( "use delattr(target, name) or " @@ -204,12 +248,12 @@ class MonkeyPatch: self._setattr.append((target, name, oldval)) delattr(target, name) - def setitem(self, dic, name, value): + def setitem(self, dic: MutableMapping[K, V], name: K, value: V) -> None: """ Set dictionary entry ``name`` to value. """ self._setitem.append((dic, name, dic.get(name, notset))) dic[name] = value - def delitem(self, dic, name, raising=True): + def delitem(self, dic: MutableMapping[K, V], name: K, raising: bool = True) -> None: """ Delete ``name`` from dict. Raise KeyError if it doesn't exist. If ``raising`` is set to False, no exception will be raised if the @@ -222,7 +266,7 @@ class MonkeyPatch: self._setitem.append((dic, name, dic.get(name, notset))) del dic[name] - def setenv(self, name, value, prepend=None): + def setenv(self, name: str, value: str, prepend: Optional[str] = None) -> None: """ Set environment variable ``name`` to ``value``. If ``prepend`` is a character, read the current environment variable value and prepend the ``value`` adjoined with the ``prepend`` character.""" @@ -241,16 +285,17 @@ class MonkeyPatch: value = value + prepend + os.environ[name] self.setitem(os.environ, name, value) - def delenv(self, name, raising=True): + def delenv(self, name: str, raising: bool = True) -> None: """ Delete ``name`` from the environment. Raise KeyError if it does not exist. If ``raising`` is set to False, no exception will be raised if the environment variable is missing. """ - self.delitem(os.environ, name, raising=raising) + environ = os.environ # type: MutableMapping[str, str] + self.delitem(environ, name, raising=raising) - def syspath_prepend(self, path): + def syspath_prepend(self, path) -> None: """ Prepend ``path`` to ``sys.path`` list of import locations. """ from pkg_resources import fixup_namespace_packages @@ -272,7 +317,7 @@ class MonkeyPatch: invalidate_caches() - def chdir(self, path): + def chdir(self, path) -> None: """ Change the current working directory to the specified path. Path can be a string or a py.path.local object. """ @@ -286,7 +331,7 @@ class MonkeyPatch: else: os.chdir(path) - def undo(self): + def undo(self) -> None: """ Undo previous changes. This call consumes the undo stack. Calling it a second time has no effect unless you do more monkeypatching after the undo call. @@ -306,14 +351,14 @@ class MonkeyPatch: else: delattr(obj, name) self._setattr[:] = [] - for dictionary, name, value in reversed(self._setitem): + for dictionary, key, value in reversed(self._setitem): if value is notset: try: - del dictionary[name] + del dictionary[key] except KeyError: pass # was already deleted, so we have the desired state else: - dictionary[name] = value + dictionary[key] = value self._setitem[:] = [] if self._savesyspath is not None: sys.path[:] = self._savesyspath diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 1a3afbea9..509e72599 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -5,9 +5,12 @@ import textwrap from typing import Dict from typing import Generator +import py + import pytest from _pytest.compat import TYPE_CHECKING from _pytest.monkeypatch import MonkeyPatch +from _pytest.pytester import Testdir if TYPE_CHECKING: from typing import Type @@ -45,9 +48,12 @@ def test_setattr() -> None: monkeypatch.undo() # double-undo makes no modification assert A.x == 5 + with pytest.raises(TypeError): + monkeypatch.setattr(A, "y") # type: ignore[call-overload] + class TestSetattrWithImportPath: - def test_string_expression(self, monkeypatch): + def test_string_expression(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("os.path.abspath", lambda x: "hello2") assert os.path.abspath("123") == "hello2" @@ -64,30 +70,31 @@ class TestSetattrWithImportPath: assert _pytest.config.Config == 42 # type: ignore monkeypatch.delattr("_pytest.config.Config") - def test_wrong_target(self, monkeypatch): - pytest.raises(TypeError, lambda: monkeypatch.setattr(None, None)) + def test_wrong_target(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(TypeError): + monkeypatch.setattr(None, None) # type: ignore[call-overload] - def test_unknown_import(self, monkeypatch): - pytest.raises(ImportError, lambda: monkeypatch.setattr("unkn123.classx", None)) + def test_unknown_import(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(ImportError): + monkeypatch.setattr("unkn123.classx", None) - def test_unknown_attr(self, monkeypatch): - pytest.raises( - AttributeError, lambda: monkeypatch.setattr("os.path.qweqwe", None) - ) + def test_unknown_attr(self, monkeypatch: MonkeyPatch) -> None: + with pytest.raises(AttributeError): + monkeypatch.setattr("os.path.qweqwe", None) def test_unknown_attr_non_raising(self, monkeypatch: MonkeyPatch) -> None: # https://github.com/pytest-dev/pytest/issues/746 monkeypatch.setattr("os.path.qweqwe", 42, raising=False) assert os.path.qweqwe == 42 # type: ignore - def test_delattr(self, monkeypatch): + def test_delattr(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.delattr("os.path.abspath") assert not hasattr(os.path, "abspath") monkeypatch.undo() assert os.path.abspath -def test_delattr(): +def test_delattr() -> None: class A: x = 1 @@ -107,7 +114,7 @@ def test_delattr(): assert A.x == 1 -def test_setitem(): +def test_setitem() -> None: d = {"x": 1} monkeypatch = MonkeyPatch() monkeypatch.setitem(d, "x", 2) @@ -135,7 +142,7 @@ def test_setitem_deleted_meanwhile() -> None: @pytest.mark.parametrize("before", [True, False]) -def test_setenv_deleted_meanwhile(before): +def test_setenv_deleted_meanwhile(before: bool) -> None: key = "qwpeoip123" if before: os.environ[key] = "world" @@ -167,10 +174,10 @@ def test_delitem() -> None: assert d == {"hello": "world", "x": 1} -def test_setenv(): +def test_setenv() -> None: monkeypatch = MonkeyPatch() with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 2) + monkeypatch.setenv("XYZ123", 2) # type: ignore[arg-type] import os assert os.environ["XYZ123"] == "2" @@ -178,7 +185,7 @@ def test_setenv(): assert "XYZ123" not in os.environ -def test_delenv(): +def test_delenv() -> None: name = "xyz1234" assert name not in os.environ monkeypatch = MonkeyPatch() @@ -208,31 +215,28 @@ class TestEnvironWarnings: VAR_NAME = "PYTEST_INTERNAL_MY_VAR" - def test_setenv_non_str_warning(self, monkeypatch): + def test_setenv_non_str_warning(self, monkeypatch: MonkeyPatch) -> None: value = 2 msg = ( "Value of environment variable PYTEST_INTERNAL_MY_VAR type should be str, " "but got 2 (type: int); converted to str implicitly" ) with pytest.warns(pytest.PytestWarning, match=re.escape(msg)): - monkeypatch.setenv(str(self.VAR_NAME), value) + monkeypatch.setenv(str(self.VAR_NAME), value) # type: ignore[arg-type] -def test_setenv_prepend(): +def test_setenv_prepend() -> None: import os monkeypatch = MonkeyPatch() - with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 2, prepend="-") - assert os.environ["XYZ123"] == "2" - with pytest.warns(pytest.PytestWarning): - monkeypatch.setenv("XYZ123", 3, prepend="-") + monkeypatch.setenv("XYZ123", "2", prepend="-") + monkeypatch.setenv("XYZ123", "3", prepend="-") assert os.environ["XYZ123"] == "3-2" monkeypatch.undo() assert "XYZ123" not in os.environ -def test_monkeypatch_plugin(testdir): +def test_monkeypatch_plugin(testdir: Testdir) -> None: reprec = testdir.inline_runsource( """ def test_method(monkeypatch): @@ -243,7 +247,7 @@ def test_monkeypatch_plugin(testdir): assert tuple(res) == (1, 0, 0), res -def test_syspath_prepend(mp: MonkeyPatch): +def test_syspath_prepend(mp: MonkeyPatch) -> None: old = list(sys.path) mp.syspath_prepend("world") mp.syspath_prepend("hello") @@ -255,7 +259,7 @@ def test_syspath_prepend(mp: MonkeyPatch): assert sys.path == old -def test_syspath_prepend_double_undo(mp: MonkeyPatch): +def test_syspath_prepend_double_undo(mp: MonkeyPatch) -> None: old_syspath = sys.path[:] try: mp.syspath_prepend("hello world") @@ -267,24 +271,24 @@ def test_syspath_prepend_double_undo(mp: MonkeyPatch): sys.path[:] = old_syspath -def test_chdir_with_path_local(mp: MonkeyPatch, tmpdir): +def test_chdir_with_path_local(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir) assert os.getcwd() == tmpdir.strpath -def test_chdir_with_str(mp: MonkeyPatch, tmpdir): +def test_chdir_with_str(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir.strpath) assert os.getcwd() == tmpdir.strpath -def test_chdir_undo(mp: MonkeyPatch, tmpdir): +def test_chdir_undo(mp: MonkeyPatch, tmpdir: py.path.local) -> None: cwd = os.getcwd() mp.chdir(tmpdir) mp.undo() assert os.getcwd() == cwd -def test_chdir_double_undo(mp: MonkeyPatch, tmpdir): +def test_chdir_double_undo(mp: MonkeyPatch, tmpdir: py.path.local) -> None: mp.chdir(tmpdir.strpath) mp.undo() tmpdir.chdir() @@ -292,7 +296,7 @@ def test_chdir_double_undo(mp: MonkeyPatch, tmpdir): assert os.getcwd() == tmpdir.strpath -def test_issue185_time_breaks(testdir): +def test_issue185_time_breaks(testdir: Testdir) -> None: testdir.makepyfile( """ import time @@ -310,7 +314,7 @@ def test_issue185_time_breaks(testdir): ) -def test_importerror(testdir): +def test_importerror(testdir: Testdir) -> None: p = testdir.mkpydir("package") p.join("a.py").write( textwrap.dedent( @@ -360,7 +364,7 @@ def test_issue156_undo_staticmethod(Sample: "Type[Sample]") -> None: assert Sample.hello() -def test_undo_class_descriptors_delattr(): +def test_undo_class_descriptors_delattr() -> None: class SampleParent: @classmethod def hello(_cls): @@ -387,7 +391,7 @@ def test_undo_class_descriptors_delattr(): assert original_world == SampleChild.world -def test_issue1338_name_resolving(): +def test_issue1338_name_resolving() -> None: pytest.importorskip("requests") monkeypatch = MonkeyPatch() try: @@ -396,7 +400,7 @@ def test_issue1338_name_resolving(): monkeypatch.undo() -def test_context(): +def test_context() -> None: monkeypatch = MonkeyPatch() import functools @@ -408,7 +412,9 @@ def test_context(): assert inspect.isclass(functools.partial) -def test_syspath_prepend_with_namespace_packages(testdir, monkeypatch): +def test_syspath_prepend_with_namespace_packages( + testdir: Testdir, monkeypatch: MonkeyPatch +) -> None: for dirname in "hello", "world": d = testdir.mkdir(dirname) ns = d.mkdir("ns_pkg") From 7081ed19b892a799e1dea99a7922cab79fefc1df Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 7 Jun 2020 13:05:32 +0300 Subject: [PATCH 092/140] hookspec: type annotate pytest_keyboard_interrupt --- src/_pytest/capture.py | 2 +- src/_pytest/hookspec.py | 6 +++++- src/_pytest/terminal.py | 14 +++++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 6009e1f67..f2beb8d8d 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -749,7 +749,7 @@ class CaptureManager: yield @pytest.hookimpl(tryfirst=True) - def pytest_keyboard_interrupt(self, excinfo): + def pytest_keyboard_interrupt(self) -> None: self.stop_global_capturing() @pytest.hookimpl(tryfirst=True) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 18a9fb39a..a6decb03a 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: import warnings from typing_extensions import Literal + from _pytest.code import ExceptionInfo from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PytestPluginManager @@ -30,6 +31,7 @@ if TYPE_CHECKING: from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.nodes import Node + from _pytest.outcomes import Exit from _pytest.python import Function from _pytest.python import Metafunc from _pytest.python import Module @@ -761,7 +763,9 @@ def pytest_internalerror(excrepr, excinfo): """ called for internal errors. """ -def pytest_keyboard_interrupt(excinfo): +def pytest_keyboard_interrupt( + excinfo: "ExceptionInfo[Union[KeyboardInterrupt, Exit]]", +) -> None: """ called for keyboard interrupt. """ diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 9c2665fb8..da8c5fc9e 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -30,6 +30,9 @@ from more_itertools import collapse import pytest from _pytest import nodes from _pytest import timing +from _pytest._code import ExceptionInfo +from _pytest._code.code import ExceptionChainRepr +from _pytest._code.code import ReprExceptionInfo from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth from _pytest.compat import order_preserving_dict @@ -315,6 +318,9 @@ class TerminalReporter: self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = None # type: Optional[float] self._already_displayed_warnings = None # type: Optional[int] + self._keyboardinterrupt_memo = ( + None + ) # type: Optional[Union[ReprExceptionInfo, ExceptionChainRepr]] @property def writer(self) -> TerminalWriter: @@ -783,7 +789,7 @@ class TerminalReporter: self.write_sep("!", str(session.shouldfail), red=True) if exitstatus == ExitCode.INTERRUPTED: self._report_keyboardinterrupt() - del self._keyboardinterrupt_memo + self._keyboardinterrupt_memo = None elif session.shouldstop: self.write_sep("!", str(session.shouldstop), red=True) self.summary_stats() @@ -799,15 +805,17 @@ class TerminalReporter: # Display any extra warnings from teardown here (if any). self.summary_warnings() - def pytest_keyboard_interrupt(self, excinfo) -> None: + def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None: self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) def pytest_unconfigure(self) -> None: - if hasattr(self, "_keyboardinterrupt_memo"): + if self._keyboardinterrupt_memo is not None: self._report_keyboardinterrupt() def _report_keyboardinterrupt(self) -> None: excrepr = self._keyboardinterrupt_memo + assert excrepr is not None + assert excrepr.reprcrash is not None msg = excrepr.reprcrash.message self.write_sep("!", msg) if "KeyboardInterrupt" in msg: From 0256cb3aae7221909244d187ed912716fcc5aa5e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 8 Jun 2020 16:08:46 +0300 Subject: [PATCH 093/140] hookspec: type annotate pytest_internalerror Also switch to using ExceptionRepr instead of `Union[ReprExceptionInfo, ExceptionChainRepr]` which is somewhat annoying and less future proof. --- src/_pytest/_code/code.py | 5 +++++ src/_pytest/capture.py | 2 +- src/_pytest/config/__init__.py | 9 +++++++-- src/_pytest/debugging.py | 14 ++++++++++---- src/_pytest/hookspec.py | 11 +++++++++-- src/_pytest/junitxml.py | 3 ++- src/_pytest/resultlog.py | 9 +++++---- src/_pytest/terminal.py | 11 ++++------- 8 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index a40b23470..121ef3a9b 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -928,8 +928,13 @@ class TerminalRepr: raise NotImplementedError() +# This class is abstract -- only subclasses are instantiated. @attr.s(**{ATTRS_EQ_FIELD: False}) # type: ignore class ExceptionRepr(TerminalRepr): + # Provided by in subclasses. + reprcrash = None # type: Optional[ReprFileLocation] + reprtraceback = None # type: ReprTraceback + def __attrs_post_init__(self): self.sections = [] # type: List[Tuple[str, str, str]] diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index f2beb8d8d..daded6395 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -753,7 +753,7 @@ class CaptureManager: self.stop_global_capturing() @pytest.hookimpl(tryfirst=True) - def pytest_internalerror(self, excinfo): + def pytest_internalerror(self) -> None: self.stop_global_capturing() diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index daccdc6a1..2ae9ac849 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -48,6 +48,7 @@ from _pytest.warning_types import PytestConfigWarning if TYPE_CHECKING: from typing import Type + from _pytest._code.code import _TracebackStyle from .argparsing import Argument @@ -893,9 +894,13 @@ class Config: return self - def notify_exception(self, excinfo, option=None): + def notify_exception( + self, + excinfo: ExceptionInfo[BaseException], + option: Optional[argparse.Namespace] = None, + ) -> None: if option and getattr(option, "fulltrace", False): - style = "long" + style = "long" # type: _TracebackStyle else: style = "native" excrepr = excinfo.getrepr( diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 3001db4ec..0567927c0 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -2,11 +2,13 @@ import argparse import functools import sys +import types from typing import Generator from typing import Tuple from typing import Union from _pytest import outcomes +from _pytest._code import ExceptionInfo from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import ConftestImportFailure @@ -280,9 +282,10 @@ class PdbInvoke: out, err = capman.read_global_capture() sys.stdout.write(out) sys.stdout.write(err) + assert call.excinfo is not None _enter_pdb(node, call.excinfo, report) - def pytest_internalerror(self, excrepr, excinfo) -> None: + def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None: tb = _postmortem_traceback(excinfo) post_mortem(tb) @@ -320,7 +323,9 @@ def maybe_wrap_pytest_function_for_tracing(pyfuncitem): wrap_pytest_function_for_tracing(pyfuncitem) -def _enter_pdb(node: Node, excinfo, rep: BaseReport) -> BaseReport: +def _enter_pdb( + node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport +) -> BaseReport: # XXX we re-use the TerminalReporter's terminalwriter # because this seems to avoid some encoding related troubles # for not completely clear reasons. @@ -349,7 +354,7 @@ def _enter_pdb(node: Node, excinfo, rep: BaseReport) -> BaseReport: return rep -def _postmortem_traceback(excinfo): +def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType: from doctest import UnexpectedException if isinstance(excinfo.value, UnexpectedException): @@ -361,10 +366,11 @@ def _postmortem_traceback(excinfo): # Use the underlying exception instead: return excinfo.value.excinfo[2] else: + assert excinfo._excinfo is not None return excinfo._excinfo[2] -def post_mortem(t) -> None: +def post_mortem(t: types.TracebackType) -> None: p = pytestPDB._init_pdb("post_mortem") p.reset() p.interaction(None, t) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index a6decb03a..1c1726d53 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: import warnings from typing_extensions import Literal + from _pytest._code.code import ExceptionRepr from _pytest.code import ExceptionInfo from _pytest.config import Config from _pytest.config import ExitCode @@ -759,8 +760,14 @@ def pytest_doctest_prepare_content(content): # ------------------------------------------------------------------------- -def pytest_internalerror(excrepr, excinfo): - """ called for internal errors. """ +def pytest_internalerror( + excrepr: "ExceptionRepr", excinfo: "ExceptionInfo[BaseException]", +) -> Optional[bool]: + """Called for internal errors. + + Return True to suppress the fallback handling of printing an + INTERNALERROR message directly to sys.stderr. + """ def pytest_keyboard_interrupt( diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index e62bc5235..86e8fcf38 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -26,6 +26,7 @@ import pytest from _pytest import deprecated from _pytest import nodes from _pytest import timing +from _pytest._code.code import ExceptionRepr from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import filename_arg @@ -642,7 +643,7 @@ class LogXML: else: reporter.append_collect_skipped(report) - def pytest_internalerror(self, excrepr) -> None: + def pytest_internalerror(self, excrepr: ExceptionRepr) -> None: reporter = self.node_reporter("internal") reporter.attrs.update(classname="pytest", name="internal") reporter._add_simple(Junit.error, "internal error", excrepr) diff --git a/src/_pytest/resultlog.py b/src/_pytest/resultlog.py index c870ef08e..cd6824abf 100644 --- a/src/_pytest/resultlog.py +++ b/src/_pytest/resultlog.py @@ -5,6 +5,7 @@ import os import py +from _pytest._code.code import ExceptionRepr from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.reports import CollectReport @@ -99,9 +100,9 @@ class ResultLog: longrepr = "%s:%d: %s" % report.longrepr # type: ignore self.log_outcome(report, code, longrepr) - def pytest_internalerror(self, excrepr): - reprcrash = getattr(excrepr, "reprcrash", None) - path = getattr(reprcrash, "path", None) - if path is None: + def pytest_internalerror(self, excrepr: ExceptionRepr) -> None: + if excrepr.reprcrash is not None: + path = excrepr.reprcrash.path + else: path = "cwd:%s" % py.path.local() self.write_log_entry(path, "!", str(excrepr)) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index da8c5fc9e..e89776109 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -31,8 +31,7 @@ import pytest from _pytest import nodes from _pytest import timing from _pytest._code import ExceptionInfo -from _pytest._code.code import ExceptionChainRepr -from _pytest._code.code import ReprExceptionInfo +from _pytest._code.code import ExceptionRepr from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth from _pytest.compat import order_preserving_dict @@ -318,9 +317,7 @@ class TerminalReporter: self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = None # type: Optional[float] self._already_displayed_warnings = None # type: Optional[int] - self._keyboardinterrupt_memo = ( - None - ) # type: Optional[Union[ReprExceptionInfo, ExceptionChainRepr]] + self._keyboardinterrupt_memo = None # type: Optional[ExceptionRepr] @property def writer(self) -> TerminalWriter: @@ -454,10 +451,10 @@ class TerminalReporter: if set_main_color: self._set_main_color() - def pytest_internalerror(self, excrepr): + def pytest_internalerror(self, excrepr: ExceptionRepr) -> bool: for line in str(excrepr).split("\n"): self.write_line("INTERNALERROR> " + line) - return 1 + return True def pytest_warning_recorded( self, warning_message: warnings.WarningMessage, nodeid: str, From 1cf9405075d889dadae8f31de8b5715f959bcdf9 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 12 Jun 2020 15:52:22 +0300 Subject: [PATCH 094/140] Fix some type errors around py.path.local These errors are found using a typed version of py.path.local. --- src/_pytest/assertion/rewrite.py | 8 +++++--- src/_pytest/cacheprovider.py | 2 +- src/_pytest/config/__init__.py | 10 +++++----- src/_pytest/config/findpaths.py | 15 +++++++++------ src/_pytest/logging.py | 2 +- src/_pytest/main.py | 8 ++++---- src/_pytest/nodes.py | 2 +- src/_pytest/python.py | 6 +++--- src/_pytest/terminal.py | 4 ++-- testing/acceptance_test.py | 5 +++-- testing/test_config.py | 3 ++- 11 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index cec0c5501..e77b1b0b8 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -23,6 +23,8 @@ from typing import Set from typing import Tuple from typing import Union +import py + from _pytest._io.saferepr import saferepr from _pytest._version import version from _pytest.assertion import util @@ -177,10 +179,10 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) """ if self.session is not None and not self._session_paths_checked: self._session_paths_checked = True - for path in self.session._initialpaths: + for initial_path in self.session._initialpaths: # Make something as c:/projects/my_project/path.py -> # ['c:', 'projects', 'my_project', 'path.py'] - parts = str(path).split(os.path.sep) + parts = str(initial_path).split(os.path.sep) # add 'path' to basenames to be checked. self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0]) @@ -213,7 +215,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader) return True if self.session is not None: - if self.session.isinitpath(fn): + if self.session.isinitpath(py.path.local(fn)): state.trace( "matched test file (was specified on cmdline): {!r}".format(fn) ) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 305a122e9..967272ca6 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -495,7 +495,7 @@ def pytest_report_header(config: Config) -> Optional[str]: # starting with .., ../.. if sensible try: - displaypath = cachedir.relative_to(config.rootdir) + displaypath = cachedir.relative_to(str(config.rootdir)) except ValueError: displaypath = cachedir return "cachedir: {}".format(displaypath) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 2ae9ac849..e154f162b 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -308,10 +308,9 @@ class PytestPluginManager(PluginManager): self._dirpath2confmods = {} # type: Dict[Any, List[object]] # Maps a py.path.local to a module object. self._conftestpath2mod = {} # type: Dict[Any, object] - self._confcutdir = None + self._confcutdir = None # type: Optional[py.path.local] self._noconftest = False - # Set of py.path.local's. - self._duplicatepaths = set() # type: Set[Any] + self._duplicatepaths = set() # type: Set[py.path.local] self.add_hookspecs(_pytest.hookspec) self.register(self) @@ -945,13 +944,12 @@ class Config: ns, unknown_args = self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) - r = determine_setup( + self.rootdir, self.inifile, self.inicfg = determine_setup( ns.inifilename, ns.file_or_dir + unknown_args, rootdir_cmd_arg=ns.rootdir or None, config=self, ) - self.rootdir, self.inifile, self.inicfg = r self._parser.extra_info["rootdir"] = self.rootdir self._parser.extra_info["inifile"] = self.inifile self._parser.addini("addopts", "extra command line options", "args") @@ -1162,6 +1160,8 @@ class Config: # in this case, we already have a list ready to use # if type == "pathlist": + # TODO: This assert is probably not valid in all cases. + assert self.inifile is not None dp = py.path.local(self.inifile).dirpath() input_values = shlex.split(value) if isinstance(value, str) else value return [dp.join(x, abs=True) for x in input_values] diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 796fa9b0a..ae8c5f47f 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -63,7 +63,7 @@ def load_config_dict_from_file( elif filepath.ext == ".toml": import toml - config = toml.load(filepath) + config = toml.load(str(filepath)) result = config.get("tool", {}).get("pytest", {}).get("ini_options", None) if result is not None: @@ -161,16 +161,18 @@ def determine_setup( args: List[str], rootdir_cmd_arg: Optional[str] = None, config: Optional["Config"] = None, -) -> Tuple[py.path.local, Optional[str], Dict[str, Union[str, List[str]]]]: +) -> Tuple[py.path.local, Optional[py.path.local], Dict[str, Union[str, List[str]]]]: rootdir = None dirs = get_dirs_from_args(args) if inifile: - inicfg = load_config_dict_from_file(py.path.local(inifile)) or {} + inipath_ = py.path.local(inifile) + inipath = inipath_ # type: Optional[py.path.local] + inicfg = load_config_dict_from_file(inipath_) or {} if rootdir_cmd_arg is None: rootdir = get_common_ancestor(dirs) else: ancestor = get_common_ancestor(dirs) - rootdir, inifile, inicfg = locate_config([ancestor]) + rootdir, inipath, inicfg = locate_config([ancestor]) if rootdir is None and rootdir_cmd_arg is None: for possible_rootdir in ancestor.parts(reverse=True): if possible_rootdir.join("setup.py").exists(): @@ -178,7 +180,7 @@ def determine_setup( break else: if dirs != [ancestor]: - rootdir, inifile, inicfg = locate_config(dirs) + rootdir, inipath, inicfg = locate_config(dirs) if rootdir is None: if config is not None: cwd = config.invocation_dir @@ -196,4 +198,5 @@ def determine_setup( rootdir ) ) - return rootdir, inifile, inicfg or {} + assert rootdir is not None + return rootdir, inipath, inicfg or {} diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index ef90c94e8..04bf74b6c 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -586,7 +586,7 @@ class LoggingPlugin: fpath = Path(fname) if not fpath.is_absolute(): - fpath = Path(self._config.rootdir, fpath) + fpath = Path(str(self._config.rootdir), fpath) if not fpath.parent.exists(): fpath.parent.mkdir(exist_ok=True, parents=True) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index a95f2f2e7..096df12dc 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -439,7 +439,7 @@ class Session(nodes.FSCollector): ) # 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] + self._collection_pkg_roots = {} # type: Dict[str, Package] self._bestrelpathcache = _bestrelpath_cache( config.rootdir @@ -601,7 +601,7 @@ class Session(nodes.FSCollector): col = self._collectfile(pkginit, handle_dupes=False) if col: if isinstance(col[0], Package): - self._collection_pkg_roots[parent] = col[0] + self._collection_pkg_roots[str(parent)] = col[0] # always store a list in the cache, matchnodes expects it self._collection_node_cache1[col[0].fspath] = [col[0]] @@ -623,8 +623,8 @@ class Session(nodes.FSCollector): for x in self._collectfile(pkginit): yield x if isinstance(x, Package): - self._collection_pkg_roots[dirpath] = x - if dirpath in self._collection_pkg_roots: + self._collection_pkg_roots[str(dirpath)] = x + if str(dirpath) in self._collection_pkg_roots: # Do not collect packages here. continue diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 3757e0b27..c6c77f529 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -393,7 +393,7 @@ class Node(metaclass=NodeMeta): # It will be better to just always display paths relative to invocation_dir, but # this requires a lot of plumbing (#6428). try: - abspath = Path(os.getcwd()) != Path(self.config.invocation_dir) + abspath = Path(os.getcwd()) != Path(str(self.config.invocation_dir)) except OSError: abspath = True diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 4b716c616..c52771057 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -656,7 +656,7 @@ class Package(Module): parts_ = parts(path.strpath) if any( - pkg_prefix in parts_ and pkg_prefix.join("__init__.py") != path + str(pkg_prefix) in parts_ and pkg_prefix.join("__init__.py") != path for pkg_prefix in pkg_prefixes ): continue @@ -1332,7 +1332,7 @@ def _show_fixtures_per_test(config, session): def get_best_relpath(func): loc = getlocation(func, curdir) - return curdir.bestrelpath(loc) + return curdir.bestrelpath(py.path.local(loc)) def write_fixture(fixture_def): argname = fixture_def.argname @@ -1406,7 +1406,7 @@ def _showfixtures_main(config: Config, session: Session) -> None: ( len(fixturedef.baseid), fixturedef.func.__module__, - curdir.bestrelpath(loc), + curdir.bestrelpath(py.path.local(loc)), fixturedef.argname, fixturedef, ) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index e89776109..8c2a30739 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -380,9 +380,9 @@ class TerminalReporter: if self.currentfspath is not None and self._show_progress_info: self._write_progress_information_filling_space() self.currentfspath = fspath - fspath = self.startdir.bestrelpath(fspath) + relfspath = self.startdir.bestrelpath(fspath) self._tw.line() - self._tw.write(fspath + " ") + self._tw.write(relfspath + " ") self._tw.write(res, flush=True, **markup) def write_ensure_prefix(self, prefix, extra: str = "", **kwargs) -> None: diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 7dfd588a0..686fe1b98 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -580,8 +580,9 @@ class TestInvocationVariants: assert res.ret == 0 res.stdout.fnmatch_lines(["*1 passed*"]) - def test_equivalence_pytest_pytest(self): - assert pytest.main == py.test.cmdline.main + def test_equivalence_pytest_pydottest(self) -> None: + # Type ignored because `py.test` is not and will not be typed. + assert pytest.main == py.test.cmdline.main # type: ignore[attr-defined] def test_invoke_with_invalid_type(self): with pytest.raises( diff --git a/testing/test_config.py b/testing/test_config.py index 31dfd9fa3..fc128dd25 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -629,13 +629,14 @@ class TestConfigFromdictargs: ) with cwd.ensure(dir=True).as_cwd(): config = Config.fromdictargs(option_dict, ()) + inipath = py.path.local(inifile) assert config.args == [str(cwd)] assert config.option.inifilename == inifile assert config.option.capture == "no" # this indicates this is the file used for getting configuration values - assert config.inifile == inifile + assert config.inifile == inipath assert config.inicfg.get("name") == "value" assert config.inicfg.get("should_not_be_set") is None From a5ab7c19fb44cc5177faea95a8f2322af6f2b415 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 12 Jun 2020 16:26:33 +0300 Subject: [PATCH 095/140] config: reject minversion if it's a list instead of a single string Fixes: src/_pytest/config/__init__.py:1071: error: Argument 1 to "Version" has incompatible type "Union[str, List[str]]"; expected "str" [arg-type] --- src/_pytest/config/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index e154f162b..7dff52c67 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1067,6 +1067,11 @@ class Config: # Imported lazily to improve start-up time. from packaging.version import Version + if not isinstance(minver, str): + raise pytest.UsageError( + "%s: 'minversion' must be a single value" % self.inifile + ) + if Version(minver) > Version(pytest.__version__): raise pytest.UsageError( "%s: 'minversion' requires pytest-%s, actual pytest-%s'" From caa984c02905e06932f68e8b5295f30f270263f3 Mon Sep 17 00:00:00 2001 From: Ram Rachum Date: Thu, 11 Jun 2020 20:26:45 +0300 Subject: [PATCH 096/140] Fix exception causes in config/__init__.py --- src/_pytest/config/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c94ea2a93..a0d36c55a 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -641,7 +641,7 @@ class PytestPluginManager(PluginManager): except ImportError as e: raise ImportError( 'Error importing plugin "{}": {}'.format(modname, str(e.args[0])) - ).with_traceback(e.__traceback__) + ).with_traceback(e.__traceback__) from e except Skipped as e: from _pytest.warnings import _issue_warning_captured @@ -1197,12 +1197,12 @@ class Config: for ini_config in self._override_ini: try: key, user_ini_value = ini_config.split("=", 1) - except ValueError: + except ValueError as e: raise UsageError( "-o/--override-ini expects option=value style (got: {!r}).".format( ini_config ) - ) + ) from e else: if key == name: value = user_ini_value From 6f8633cc17b6a54329febf81c76157a0d6d39621 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 13 Jun 2020 02:47:15 -0400 Subject: [PATCH 097/140] add in solution barring documentation --- .pre-commit-config.yaml | 12 +++---- src/_pytest/config/__init__.py | 21 ++++++++++--- testing/test_config.py | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc3717204..be4b16d61 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,12 +42,12 @@ repos: - id: setup-cfg-fmt # TODO: when upgrading setup-cfg-fmt this can be removed args: [--max-py-version=3.9] -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.780 # NOTE: keep this in sync with setup.cfg. - hooks: - - id: mypy - files: ^(src/|testing/) - args: [] + #- repo: https://github.com/pre-commit/mirrors-mypy + #rev: v0.780 # NOTE: keep this in sync with setup.cfg. + #hooks: + #- id: mypy + #files: ^(src/|testing/) + #args: [] - repo: local hooks: - id: rst diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6e26bf15c..887744a03 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1088,13 +1088,26 @@ class Config: if not required_plugins: return + # Imported lazily to improve start-up time. + from packaging.version import Version + from packaging.requirements import InvalidRequirement, Requirement + plugin_info = self.pluginmanager.list_plugin_distinfo() - plugin_dist_names = [dist.project_name for _, dist in plugin_info] + plugin_dist_info = {dist.project_name: dist.version for _, dist in plugin_info} missing_plugins = [] - for plugin in required_plugins: - if plugin not in plugin_dist_names: - missing_plugins.append(plugin) + for required_plugin in required_plugins: + spec = None + try: + spec = Requirement(required_plugin) + except InvalidRequirement: + missing_plugins.append(required_plugin) + continue + + if spec.name not in plugin_dist_info: + missing_plugins.append(required_plugin) + elif Version(plugin_dist_info[spec.name]) not in spec.specifier: + missing_plugins.append(required_plugin) if missing_plugins: fail( diff --git a/testing/test_config.py b/testing/test_config.py index a10ac41dd..420fa06f8 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -250,6 +250,63 @@ class TestParseIni: ), ( """ + [pytest] + required_plugins = pytest-xdist + """, + "", + ), + ( + """ + [pytest] + required_plugins = pytest-xdist==1.32.0 + """, + "", + ), + ( + """ + [pytest] + required_plugins = pytest-xdist~=1.32.0 pytest-xdist==1.32.0 pytest-xdist!=0.0.1 pytest-xdist<=99.99.0 + pytest-xdist>=1.32.0 pytest-xdist<9.9.9 pytest-xdist>1.30.0 pytest-xdist===1.32.0 + """, + "", + ), + ( + """ + [pytest] + required_plugins = pytest-xdist>9.9.9 pytest-xdist==1.32.0 pytest-xdist==8.8.8 + """, + "Missing required plugins: pytest-xdist==8.8.8, pytest-xdist>9.9.9", + ), + ( + """ + [pytest] + required_plugins = pytest-xdist==aegsrgrsgs + """, + "Missing required plugins: pytest-xdist==aegsrgrsgs", + ), + ( + """ + [pytest] + required_plugins = pytest-xdist==-1 + """, + "Missing required plugins: pytest-xdist==-1", + ), + ( + """ + [pytest] + required_plugins = pytest-xdist== pytest-xdist<= + """, + "Missing required plugins: pytest-xdist<=, pytest-xdist==", + ), + ( + """ + [pytest] + required_plugins = pytest-xdist= pytest-xdist< + """, + "Missing required plugins: pytest-xdist<, pytest-xdist=", + ), + ( + """ [some_other_header] required_plugins = wont be triggered [pytest] From 31512197851e556f5ed8bb964d69eef6398294e4 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 13 Jun 2020 10:24:44 -0300 Subject: [PATCH 098/140] assertoutcomes() only accepts plural forms Fix #6505 --- changelog/6505.breaking.rst | 20 ++++++++++++++++++++ src/_pytest/pytester.py | 37 ++++++++++++++++++++++++++++--------- testing/python/fixtures.py | 2 +- testing/test_pytester.py | 33 +++++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 changelog/6505.breaking.rst diff --git a/changelog/6505.breaking.rst b/changelog/6505.breaking.rst new file mode 100644 index 000000000..164b69485 --- /dev/null +++ b/changelog/6505.breaking.rst @@ -0,0 +1,20 @@ +``Testdir.run().parseoutcomes()`` now always returns the parsed nouns in plural form. + +Originally ``parseoutcomes()`` would always returns the nouns in plural form, but a change +meant to improve the terminal summary by using singular form single items (``1 warning`` or ``1 error``) +caused an unintended regression by changing the keys returned by ``parseoutcomes()``. + +Now the API guarantees to always return the plural form, so calls like this: + +.. code-block:: python + + result = testdir.runpytest() + result.assert_outcomes(error=1) + +Need to be changed to: + + +.. code-block:: python + + result = testdir.runpytest() + result.assert_outcomes(errors=1) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 2913c6065..cf3dbd201 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -452,28 +452,47 @@ class RunResult: ) def parseoutcomes(self) -> Dict[str, int]: - """Return a dictionary of outcomestring->num from parsing the terminal + """Return a dictionary of outcome noun -> count from parsing the terminal output that the test process produced. + The returned nouns will always be in plural form:: + + ======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ==== + + Will return ``{"failed": 1, "passed": 1, "warnings": 1, "errors": 1}`` """ - for line in reversed(self.outlines): + return self.parse_summary_nouns(self.outlines) + + @classmethod + def parse_summary_nouns(cls, lines) -> Dict[str, int]: + """Extracts the nouns from a pytest terminal summary line. + + It always returns the plural noun for consistency:: + + ======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ==== + + Will return ``{"failed": 1, "passed": 1, "warnings": 1, "errors": 1}`` + """ + for line in reversed(lines): if rex_session_duration.search(line): outcomes = rex_outcome.findall(line) ret = {noun: int(count) for (count, noun) in outcomes} break else: raise ValueError("Pytest terminal summary report not found") - if "errors" in ret: - assert "error" not in ret - ret["error"] = ret.pop("errors") - return ret + + to_plural = { + "warning": "warnings", + "error": "errors", + } + return {to_plural.get(k, k): v for k, v in ret.items()} def assert_outcomes( self, passed: int = 0, skipped: int = 0, failed: int = 0, - error: int = 0, + errors: int = 0, xpassed: int = 0, xfailed: int = 0, ) -> None: @@ -487,7 +506,7 @@ class RunResult: "passed": d.get("passed", 0), "skipped": d.get("skipped", 0), "failed": d.get("failed", 0), - "error": d.get("error", 0), + "errors": d.get("errors", 0), "xpassed": d.get("xpassed", 0), "xfailed": d.get("xfailed", 0), } @@ -495,7 +514,7 @@ class RunResult: "passed": passed, "skipped": skipped, "failed": failed, - "error": error, + "errors": errors, "xpassed": xpassed, "xfailed": xfailed, } diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 353ce46cd..e4351a816 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4342,6 +4342,6 @@ def test_yield_fixture_with_no_value(testdir): ) expected = "E ValueError: custom did not yield a value" result = testdir.runpytest() - result.assert_outcomes(error=1) + result.assert_outcomes(errors=1) result.stdout.fnmatch_lines([expected]) assert result.ret == ExitCode.TESTS_FAILED diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 1d3321455..d0afb40b0 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -763,9 +763,38 @@ def test_testdir_outcomes_with_multiple_errors(testdir): """ ) result = testdir.runpytest(str(p1)) - result.assert_outcomes(error=2) + result.assert_outcomes(errors=2) - assert result.parseoutcomes() == {"error": 2} + assert result.parseoutcomes() == {"errors": 2} + + +def test_parse_summary_line_always_plural(): + """Parsing summaries always returns plural nouns (#6505)""" + lines = [ + "some output 1", + "some output 2", + "======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ====", + "done.", + ] + assert pytester.RunResult.parse_summary_nouns(lines) == { + "errors": 1, + "failed": 1, + "passed": 1, + "warnings": 1, + } + + lines = [ + "some output 1", + "some output 2", + "======= 1 failed, 1 passed, 2 warnings, 2 errors in 0.13s ====", + "done.", + ] + assert pytester.RunResult.parse_summary_nouns(lines) == { + "errors": 2, + "failed": 1, + "passed": 1, + "warnings": 2, + } def test_makefile_joins_absolute_path(testdir: Testdir) -> None: From f8a8bdbeb050533615be7ef517a0b5ca027bb5f4 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 13 Jun 2020 09:55:55 -0400 Subject: [PATCH 099/140] remove pre-commit change --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be4b16d61..dc3717204 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,12 +42,12 @@ repos: - id: setup-cfg-fmt # TODO: when upgrading setup-cfg-fmt this can be removed args: [--max-py-version=3.9] - #- repo: https://github.com/pre-commit/mirrors-mypy - #rev: v0.780 # NOTE: keep this in sync with setup.cfg. - #hooks: - #- id: mypy - #files: ^(src/|testing/) - #args: [] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.780 # NOTE: keep this in sync with setup.cfg. + hooks: + - id: mypy + files: ^(src/|testing/) + args: [] - repo: local hooks: - id: rst From 8a022c0ce3c76176961e07137100bee4d07f7572 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 13 Jun 2020 09:57:13 -0400 Subject: [PATCH 100/140] test to make sure precommit is fixed --- src/_pytest/config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 116383a73..a124b8f5f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1101,7 +1101,7 @@ class Config: plugin_info = self.pluginmanager.list_plugin_distinfo() plugin_dist_info = {dist.project_name: dist.version for _, dist in plugin_info} - missing_plugins = [] + missing_plugins = ["a"] for required_plugin in required_plugins: spec = None try: From ab6dacf1d1e1ff0c5be70a3c5f48e63168168721 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 13 Jun 2020 11:29:01 -0300 Subject: [PATCH 101/140] Introduce --import-mode=importlib (#7246) Fix #5821 Co-authored-by: Ran Benita --- changelog/7245.feature.rst | 14 ++ doc/en/goodpractices.rst | 23 +++- doc/en/pythonpath.rst | 64 ++++++++- src/_pytest/_code/code.py | 5 +- src/_pytest/compat.py | 36 +++++ src/_pytest/config/__init__.py | 40 ++++-- src/_pytest/doctest.py | 7 +- src/_pytest/main.py | 8 ++ src/_pytest/nodes.py | 4 +- src/_pytest/pathlib.py | 138 +++++++++++++++++++ src/_pytest/python.py | 15 +- testing/acceptance_test.py | 5 +- testing/python/collect.py | 10 +- testing/python/fixtures.py | 4 +- testing/test_collection.py | 80 +++++++++++ testing/test_compat.py | 59 ++++++++ testing/test_conftest.py | 42 +++--- testing/test_pathlib.py | 242 ++++++++++++++++++++++++++++++++- testing/test_pluginmanager.py | 8 +- 19 files changed, 734 insertions(+), 70 deletions(-) create mode 100644 changelog/7245.feature.rst diff --git a/changelog/7245.feature.rst b/changelog/7245.feature.rst new file mode 100644 index 000000000..05c3a6c04 --- /dev/null +++ b/changelog/7245.feature.rst @@ -0,0 +1,14 @@ +New ``--import-mode=importlib`` option that uses `importlib `__ to import test modules. + +Traditionally pytest used ``__import__`` while changing ``sys.path`` to import test modules (which +also changes ``sys.modules`` as a side-effect), which works but has a number of drawbacks, like requiring test modules +that don't live in packages to have unique names (as they need to reside under a unique name in ``sys.modules``). + +``--import-mode=importlib`` uses more fine grained import mechanisms from ``importlib`` which don't +require pytest to change ``sys.path`` or ``sys.modules`` at all, eliminating much of the drawbacks +of the previous mode. + +We intend to make ``--import-mode=importlib`` the default in future versions, so users are encouraged +to try the new mode and provide feedback (both positive or negative) in issue `#7245 `__. + +You can read more about this option in `the documentation `__. diff --git a/doc/en/goodpractices.rst b/doc/en/goodpractices.rst index 16b41eda4..ee5674fd6 100644 --- a/doc/en/goodpractices.rst +++ b/doc/en/goodpractices.rst @@ -91,7 +91,8 @@ This has the following benefits: See :ref:`pytest vs python -m pytest` for more information about the difference between calling ``pytest`` and ``python -m pytest``. -Note that using this scheme your test files must have **unique names**, because +Note that this scheme has a drawback if you are using ``prepend`` :ref:`import mode ` +(which is the default): your test files must have **unique names**, because ``pytest`` will import them as *top-level* modules since there are no packages to derive a full package name from. In other words, the test files in the example above will be imported as ``test_app`` and ``test_view`` top-level modules by adding ``tests/`` to @@ -118,9 +119,12 @@ Now pytest will load the modules as ``tests.foo.test_view`` and ``tests.bar.test you to have modules with the same name. But now this introduces a subtle problem: in order to load the test modules from the ``tests`` directory, pytest prepends the root of the repository to ``sys.path``, which adds the side-effect that now ``mypkg`` is also importable. + This is problematic if you are using a tool like `tox`_ to test your package in a virtual environment, because you want to test the *installed* version of your package, not the local code from the repository. +.. _`src-layout`: + In this situation, it is **strongly** suggested to use a ``src`` layout where application root package resides in a sub-directory of your root: @@ -145,6 +149,15 @@ sub-directory of your root: This layout prevents a lot of common pitfalls and has many benefits, which are better explained in this excellent `blog post by Ionel Cristian Mărieș `_. +.. note:: + The new ``--import-mode=importlib`` (see :ref:`import-modes`) doesn't have + any of the drawbacks above because ``sys.path`` and ``sys.modules`` are not changed when importing + test modules, so users that run + into this issue are strongly encouraged to try it and report if the new option works well for them. + + The ``src`` directory layout is still strongly recommended however. + + Tests as part of application code ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -190,8 +203,8 @@ Note that this layout also works in conjunction with the ``src`` layout mentione .. note:: - If ``pytest`` finds an "a/b/test_module.py" test file while - recursing into the filesystem it determines the import name + In ``prepend`` and ``append`` import-modes, if pytest finds a ``"a/b/test_module.py"`` + test file while recursing into the filesystem it determines the import name as follows: * determine ``basedir``: this is the first "upward" (towards the root) @@ -212,6 +225,10 @@ Note that this layout also works in conjunction with the ``src`` layout mentione from each other and thus deriving a canonical import name helps to avoid surprises such as a test module getting imported twice. + With ``--import-mode=importlib`` things are less convoluted because + pytest doesn't need to change ``sys.path`` or ``sys.modules``, making things + much less surprising. + .. _`virtualenv`: https://pypi.org/project/virtualenv/ .. _`buildout`: http://www.buildout.org/ diff --git a/doc/en/pythonpath.rst b/doc/en/pythonpath.rst index f2c86fab9..b8f4de9d9 100644 --- a/doc/en/pythonpath.rst +++ b/doc/en/pythonpath.rst @@ -3,11 +3,65 @@ pytest import mechanisms and ``sys.path``/``PYTHONPATH`` ======================================================== -Here's a list of scenarios where pytest may need to change ``sys.path`` in order -to import test modules or ``conftest.py`` files. +.. _`import-modes`: + +Import modes +------------ + +pytest as a testing framework needs to import test modules and ``conftest.py`` files for execution. + +Importing files in Python (at least until recently) is a non-trivial processes, often requiring +changing `sys.path `__. Some aspects of the +import process can be controlled through the ``--import-mode`` command-line flag, which can assume +these values: + +* ``prepend`` (default): the directory path containing each module will be inserted into the *beginning* + of ``sys.path`` if not already there, and then imported with the `__import__ `__ builtin. + + This requires test module names to be unique when the test directory tree is not arranged in + packages, because the modules will put in ``sys.modules`` after importing. + + This is the classic mechanism, dating back from the time Python 2 was still supported. + +* ``append``: the directory containing each module is appended to the end of ``sys.path`` if not already + there, and imported with ``__import__``. + + This better allows to run test modules against installed versions of a package even if the + package under test has the same import root. For example: + + :: + + testing/__init__.py + testing/test_pkg_under_test.py + pkg_under_test/ + + the tests will run against the installed version + of ``pkg_under_test`` when ``--import-mode=append`` is used whereas + with ``prepend`` they would pick up the local version. This kind of confusion is why + we advocate for using :ref:`src ` layouts. + + Same as ``prepend``, requires test module names to be unique when the test directory tree is + not arranged in packages, because the modules will put in ``sys.modules`` after importing. + +* ``importlib``: new in pytest-6.0, this mode uses `importlib `__ to import test modules. This gives full control over the import process, and doesn't require + changing ``sys.path`` or ``sys.modules`` at all. + + For this reason this doesn't require test module names to be unique at all, but also makes test + modules non-importable by each other. This was made possible in previous modes, for tests not residing + in Python packages, because of the side-effects of changing ``sys.path`` and ``sys.modules`` + mentioned above. Users which require this should turn their tests into proper packages instead. + + We intend to make ``importlib`` the default in future releases. + +``prepend`` and ``append`` import modes scenarios +------------------------------------------------- + +Here's a list of scenarios when using ``prepend`` or ``append`` import modes where pytest needs to +change ``sys.path`` in order to import test modules or ``conftest.py`` files, and the issues users +might encounter because of that. Test modules / ``conftest.py`` files inside packages ----------------------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Consider this file and directory layout:: @@ -28,8 +82,6 @@ When executing: pytest root/ - - pytest will find ``foo/bar/tests/test_foo.py`` and realize it is part of a package given that there's an ``__init__.py`` file in the same folder. It will then search upwards until it can find the last folder which still contains an ``__init__.py`` file in order to find the package *root* (in @@ -44,7 +96,7 @@ and allow test modules to have duplicated names. This is also discussed in detai :ref:`test discovery`. Standalone test modules / ``conftest.py`` files ------------------------------------------------ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Consider this file and directory layout:: diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 121ef3a9b..65e5aa6d5 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1204,7 +1204,10 @@ _PY_DIR = py.path.local(py.__file__).dirpath() def filter_traceback(entry: TracebackEntry) -> bool: - """Return True if a TracebackEntry instance should be removed from tracebacks: + """Return True if a TracebackEntry instance should be included in tracebacks. + + We hide traceback entries of: + * dynamically generated code (no code to show up for it); * internal traceback from pytest or its internal libraries, py and pluggy. """ diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 84f9609a7..cd7dca719 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -33,6 +33,7 @@ else: if TYPE_CHECKING: + from typing import NoReturn from typing import Type from typing_extensions import Final @@ -401,3 +402,38 @@ else: from collections import OrderedDict order_preserving_dict = OrderedDict + + +# Perform exhaustiveness checking. +# +# Consider this example: +# +# MyUnion = Union[int, str] +# +# def handle(x: MyUnion) -> int { +# if isinstance(x, int): +# return 1 +# elif isinstance(x, str): +# return 2 +# else: +# raise Exception('unreachable') +# +# Now suppose we add a new variant: +# +# MyUnion = Union[int, str, bytes] +# +# After doing this, we must remember ourselves to go and update the handle +# function to handle the new variant. +# +# With `assert_never` we can do better: +# +# // throw new Error('unreachable'); +# return assert_never(x) +# +# Now, if we forget to handle the new variant, the type-checker will emit a +# compile-time error, instead of the runtime error we would have gotten +# previously. +# +# This also work for Enums (if you use `is` to compare) and Literals. +def assert_never(value: "NoReturn") -> "NoReturn": + assert False, "Unhandled value: {} ({})".format(value, type(value).__name__) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index b77968110..400acb51f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -41,6 +41,7 @@ from _pytest.compat import importlib_metadata from _pytest.compat import TYPE_CHECKING from _pytest.outcomes import fail from _pytest.outcomes import Skipped +from _pytest.pathlib import import_path from _pytest.pathlib import Path from _pytest.store import Store from _pytest.warning_types import PytestConfigWarning @@ -98,6 +99,15 @@ class ConftestImportFailure(Exception): ) +def filter_traceback_for_conftest_import_failure(entry) -> bool: + """filters tracebacks entries which point to pytest internals or importlib. + + Make a special case for importlib because we use it to import test modules and conftest files + in _pytest.pathlib.import_path. + """ + return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep) + + def main(args=None, plugins=None) -> Union[int, ExitCode]: """ return exit code, after performing an in-process test run. @@ -115,7 +125,9 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]: tw.line( "ImportError while loading conftest '{e.path}'.".format(e=e), red=True ) - exc_info.traceback = exc_info.traceback.filter(filter_traceback) + exc_info.traceback = exc_info.traceback.filter( + filter_traceback_for_conftest_import_failure + ) exc_repr = ( exc_info.getrepr(style="short", chain=False) if exc_info.traceback @@ -450,21 +462,21 @@ class PytestPluginManager(PluginManager): path = path[:i] anchor = current.join(path, abs=1) if anchor.exists(): # we found some file object - self._try_load_conftest(anchor) + self._try_load_conftest(anchor, namespace.importmode) foundanchor = True if not foundanchor: - self._try_load_conftest(current) + self._try_load_conftest(current, namespace.importmode) - def _try_load_conftest(self, anchor): - self._getconftestmodules(anchor) + def _try_load_conftest(self, anchor, importmode): + self._getconftestmodules(anchor, importmode) # let's also consider test* subdirs if anchor.check(dir=1): for x in anchor.listdir("test*"): if x.check(dir=1): - self._getconftestmodules(x) + self._getconftestmodules(x, importmode) @lru_cache(maxsize=128) - def _getconftestmodules(self, path): + def _getconftestmodules(self, path, importmode): if self._noconftest: return [] @@ -482,13 +494,13 @@ class PytestPluginManager(PluginManager): continue conftestpath = parent.join("conftest.py") if conftestpath.isfile(): - mod = self._importconftest(conftestpath) + mod = self._importconftest(conftestpath, importmode) clist.append(mod) self._dirpath2confmods[directory] = clist return clist - def _rget_with_confmod(self, name, path): - modules = self._getconftestmodules(path) + def _rget_with_confmod(self, name, path, importmode): + modules = self._getconftestmodules(path, importmode) for mod in reversed(modules): try: return mod, getattr(mod, name) @@ -496,7 +508,7 @@ class PytestPluginManager(PluginManager): continue raise KeyError(name) - def _importconftest(self, conftestpath): + def _importconftest(self, conftestpath, importmode): # Use a resolved Path object as key to avoid loading the same conftest twice # with build systems that create build directories containing # symlinks to actual files. @@ -512,7 +524,7 @@ class PytestPluginManager(PluginManager): _ensure_removed_sysmodule(conftestpath.purebasename) try: - mod = conftestpath.pyimport() + mod = import_path(conftestpath, mode=importmode) except Exception as e: raise ConftestImportFailure(conftestpath, sys.exc_info()) from e @@ -1213,7 +1225,9 @@ class Config: def _getconftest_pathlist(self, name, path): try: - mod, relroots = self.pluginmanager._rget_with_confmod(name, path) + mod, relroots = self.pluginmanager._rget_with_confmod( + name, path, self.getoption("importmode") + ) except KeyError: return None modpath = py.path.local(mod.__file__).dirpath() diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 7aaacb481..181c66b95 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -33,6 +33,7 @@ from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest from _pytest.outcomes import OutcomeException +from _pytest.pathlib import import_path from _pytest.python_api import approx from _pytest.warning_types import PytestWarning @@ -530,10 +531,12 @@ class DoctestModule(pytest.Module): ) if self.fspath.basename == "conftest.py": - module = self.config.pluginmanager._importconftest(self.fspath) + module = self.config.pluginmanager._importconftest( + self.fspath, self.config.getoption("importmode") + ) else: try: - module = self.fspath.pyimport() + module = import_path(self.fspath) except ImportError: if self.config.getvalue("doctest_ignore_import_errors"): pytest.skip("unable to import module %r" % self.fspath) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 2ec9046b0..b7a3a958a 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -173,6 +173,14 @@ def pytest_addoption(parser: Parser) -> None: default=False, help="Don't ignore tests in a local virtualenv directory", ) + group.addoption( + "--import-mode", + default="prepend", + choices=["prepend", "append", "importlib"], + dest="importmode", + help="prepend/append to sys.path when importing test modules and conftest files, " + "default is to prepend.", + ) group = parser.getgroup("debugconfig", "test session debugging and configuration") group.addoption( diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index c6c77f529..4c7aa1bcd 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -547,7 +547,9 @@ class FSCollector(Collector): # check if we have the common case of running # hooks with all conftest.py files pm = self.config.pluginmanager - my_conftestmodules = pm._getconftestmodules(fspath) + my_conftestmodules = pm._getconftestmodules( + fspath, self.config.getoption("importmode") + ) remove_mods = pm._conftest_plugins.difference(my_conftestmodules) if remove_mods: # one or more conftests are not in use at this fspath diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 98ec936a1..66ae9a51d 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,24 +1,31 @@ import atexit import contextlib import fnmatch +import importlib.util import itertools import os import shutil import sys import uuid import warnings +from enum import Enum from functools import partial from os.path import expanduser from os.path import expandvars from os.path import isabs from os.path import sep from posixpath import sep as posix_sep +from types import ModuleType from typing import Iterable from typing import Iterator +from typing import Optional from typing import Set from typing import TypeVar from typing import Union +import py + +from _pytest.compat import assert_never from _pytest.outcomes import skip from _pytest.warning_types import PytestWarning @@ -413,3 +420,134 @@ def symlink_or_skip(src, dst, **kwargs): os.symlink(str(src), str(dst), **kwargs) except OSError as e: skip("symlinks not supported: {}".format(e)) + + +class ImportMode(Enum): + """Possible values for `mode` parameter of `import_path`""" + + prepend = "prepend" + append = "append" + importlib = "importlib" + + +class ImportPathMismatchError(ImportError): + """Raised on import_path() if there is a mismatch of __file__'s. + + This can happen when `import_path` is called multiple times with different filenames that has + the same basename but reside in packages + (for example "/tests1/test_foo.py" and "/tests2/test_foo.py"). + """ + + +def import_path( + p: Union[str, py.path.local, Path], + *, + mode: Union[str, ImportMode] = ImportMode.prepend +) -> ModuleType: + """ + Imports and returns a module from the given path, which can be a file (a module) or + a directory (a package). + + The import mechanism used is controlled by the `mode` parameter: + + * `mode == ImportMode.prepend`: the directory containing the module (or package, taking + `__init__.py` files into account) will be put at the *start* of `sys.path` before + being imported with `__import__. + + * `mode == ImportMode.append`: same as `prepend`, but the directory will be appended + to the end of `sys.path`, if not already in `sys.path`. + + * `mode == ImportMode.importlib`: uses more fine control mechanisms provided by `importlib` + to import the module, which avoids having to use `__import__` and muck with `sys.path` + at all. It effectively allows having same-named test modules in different places. + + :raise ImportPathMismatchError: if after importing the given `path` and the module `__file__` + are different. Only raised in `prepend` and `append` modes. + """ + mode = ImportMode(mode) + + path = Path(p) + + if not path.exists(): + raise ImportError(path) + + if mode is ImportMode.importlib: + module_name = path.stem + + for meta_importer in sys.meta_path: + spec = meta_importer.find_spec(module_name, [str(path.parent)]) + if spec is not None: + break + else: + spec = importlib.util.spec_from_file_location(module_name, str(path)) + + if spec is None: + raise ImportError( + "Can't find module {} at location {}".format(module_name, str(path)) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + return mod + + pkg_path = resolve_package_path(path) + if pkg_path is not None: + pkg_root = pkg_path.parent + names = list(path.with_suffix("").relative_to(pkg_root).parts) + if names[-1] == "__init__": + names.pop() + module_name = ".".join(names) + else: + pkg_root = path.parent + module_name = path.stem + + # change sys.path permanently: restoring it at the end of this function would cause surprising + # problems because of delayed imports: for example, a conftest.py file imported by this function + # might have local imports, which would fail at runtime if we restored sys.path. + if mode is ImportMode.append: + if str(pkg_root) not in sys.path: + sys.path.append(str(pkg_root)) + elif mode is ImportMode.prepend: + if str(pkg_root) != sys.path[0]: + sys.path.insert(0, str(pkg_root)) + else: + assert_never(mode) + + importlib.import_module(module_name) + + mod = sys.modules[module_name] + if path.name == "__init__.py": + return mod + + ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "") + if ignore != "1": + module_file = mod.__file__ + if module_file.endswith((".pyc", ".pyo")): + module_file = module_file[:-1] + if module_file.endswith(os.path.sep + "__init__.py"): + module_file = module_file[: -(len(os.path.sep + "__init__.py"))] + + try: + is_same = os.path.samefile(str(path), module_file) + except FileNotFoundError: + is_same = False + + if not is_same: + raise ImportPathMismatchError(module_name, module_file, path) + + return mod + + +def resolve_package_path(path: Path) -> Optional[Path]: + """Return the Python package path by looking for the last + directory upwards which still contains an __init__.py. + Return None if it can not be determined. + """ + result = None + for parent in itertools.chain((path,), path.parents): + if parent.is_dir(): + if not parent.joinpath("__init__.py").is_file(): + break + if not parent.name.isidentifier(): + break + result = parent + return result diff --git a/src/_pytest/python.py b/src/_pytest/python.py index c52771057..bf45b8830 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -59,6 +59,8 @@ from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail from _pytest.outcomes import skip +from _pytest.pathlib import import_path +from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import parts from _pytest.reports import TerminalRepr from _pytest.warning_types import PytestCollectionWarning @@ -115,15 +117,6 @@ def pytest_addoption(parser: Parser) -> None: "side effects(use at your own risk)", ) - group.addoption( - "--import-mode", - default="prepend", - choices=["prepend", "append"], - dest="importmode", - help="prepend/append to sys.path when importing test modules, " - "default is to prepend.", - ) - def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.showfixtures: @@ -557,10 +550,10 @@ class Module(nodes.File, PyCollector): # we assume we are only called once per module importmode = self.config.getoption("--import-mode") try: - mod = self.fspath.pyimport(ensuresyspath=importmode) + mod = import_path(self.fspath, mode=importmode) except SyntaxError: raise self.CollectError(ExceptionInfo.from_current().getrepr(style="short")) - except self.fspath.ImportMismatchError as e: + except ImportPathMismatchError as e: raise self.CollectError( "import file mismatch:\n" "imported module %r has this __file__ attribute:\n" diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 686fe1b98..d8f7a501a 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -147,7 +147,8 @@ class TestGeneralUsage: else: assert loaded == ["myplugin1", "myplugin2", "mycov"] - def test_assertion_magic(self, testdir): + @pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"]) + def test_assertion_rewrite(self, testdir, import_mode): p = testdir.makepyfile( """ def test_this(): @@ -155,7 +156,7 @@ class TestGeneralUsage: assert x """ ) - result = testdir.runpytest(p) + result = testdir.runpytest(p, "--import-mode={}".format(import_mode)) result.stdout.fnmatch_lines(["> assert x", "E assert 0"]) assert result.ret == 1 diff --git a/testing/python/collect.py b/testing/python/collect.py index 7824ceff1..e98a21f1c 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1,4 +1,3 @@ -import os import sys import textwrap from typing import Any @@ -109,11 +108,10 @@ class TestModule: assert result.ret == 2 stdout = result.stdout.str() - for name in ("_pytest", os.path.join("py", "_path")): - if verbose == 2: - assert name in stdout - else: - assert name not in stdout + if verbose == 2: + assert "_pytest" in stdout + else: + assert "_pytest" not in stdout def test_show_traceback_import_error_unicode(self, testdir): """Check test modules collected which raise ImportError with unicode messages diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 353ce46cd..a34478675 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1894,7 +1894,9 @@ class TestAutouseManagement: reprec = testdir.inline_run("-v", "-s", confcut) reprec.assertoutcome(passed=8) config = reprec.getcalls("pytest_unconfigure")[0].config - values = config.pluginmanager._getconftestmodules(p)[0].values + values = config.pluginmanager._getconftestmodules(p, importmode="prepend")[ + 0 + ].values assert values == ["fin_a1", "fin_a2", "fin_b1", "fin_b2"] * 2 def test_scope_ordering(self, testdir): diff --git a/testing/test_collection.py b/testing/test_collection.py index 6644881ea..d7a9b0439 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1342,3 +1342,83 @@ def test_fscollector_from_parent(tmpdir, request): parent=request.session, fspath=tmpdir / "foo", x=10 ) assert collector.x == 10 + + +class TestImportModeImportlib: + def test_collect_duplicate_names(self, testdir): + """--import-mode=importlib can import modules with same names that are not in packages.""" + testdir.makepyfile( + **{ + "tests_a/test_foo.py": "def test_foo1(): pass", + "tests_b/test_foo.py": "def test_foo2(): pass", + } + ) + result = testdir.runpytest("-v", "--import-mode=importlib") + result.stdout.fnmatch_lines( + [ + "tests_a/test_foo.py::test_foo1 *", + "tests_b/test_foo.py::test_foo2 *", + "* 2 passed in *", + ] + ) + + def test_conftest(self, testdir): + """Directory containing conftest modules are not put in sys.path as a side-effect of + importing them.""" + tests_dir = testdir.tmpdir.join("tests") + testdir.makepyfile( + **{ + "tests/conftest.py": "", + "tests/test_foo.py": """ + import sys + def test_check(): + assert r"{tests_dir}" not in sys.path + """.format( + tests_dir=tests_dir + ), + } + ) + result = testdir.runpytest("-v", "--import-mode=importlib") + result.stdout.fnmatch_lines(["* 1 passed in *"]) + + def setup_conftest_and_foo(self, testdir): + """Setup a tests folder to be used to test if modules in that folder can be imported + due to side-effects of --import-mode or not.""" + testdir.makepyfile( + **{ + "tests/conftest.py": "", + "tests/foo.py": """ + def foo(): return 42 + """, + "tests/test_foo.py": """ + def test_check(): + from foo import foo + assert foo() == 42 + """, + } + ) + + def test_modules_importable_as_side_effect(self, testdir): + """In import-modes `prepend` and `append`, we are able to import modules from folders + containing conftest.py files due to the side effect of changing sys.path.""" + self.setup_conftest_and_foo(testdir) + result = testdir.runpytest("-v", "--import-mode=prepend") + result.stdout.fnmatch_lines(["* 1 passed in *"]) + + def test_modules_not_importable_as_side_effect(self, testdir): + """In import-mode `importlib`, modules in folders containing conftest.py are not + importable, as don't change sys.path or sys.modules as side effect of importing + the conftest.py file. + """ + self.setup_conftest_and_foo(testdir) + result = testdir.runpytest("-v", "--import-mode=importlib") + exc_name = ( + "ModuleNotFoundError" if sys.version_info[:2] > (3, 5) else "ImportError" + ) + result.stdout.fnmatch_lines( + [ + "*{}: No module named 'foo'".format(exc_name), + "tests?test_foo.py:2: {}".format(exc_name), + "* 1 failed in *", + ] + ) diff --git a/testing/test_compat.py b/testing/test_compat.py index 45468b5f8..5debe87a3 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -1,16 +1,23 @@ +import enum import sys from functools import partial from functools import wraps +from typing import Union import pytest from _pytest.compat import _PytestWrapper +from _pytest.compat import assert_never from _pytest.compat import cached_property from _pytest.compat import get_real_func from _pytest.compat import is_generator from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass +from _pytest.compat import TYPE_CHECKING from _pytest.outcomes import OutcomeException +if TYPE_CHECKING: + from typing_extensions import Literal + def test_is_generator(): def zap(): @@ -205,3 +212,55 @@ def test_cached_property() -> None: assert ncalls == 1 assert c2.prop == 2 assert c1.prop == 1 + + +def test_assert_never_union() -> None: + x = 10 # type: Union[int, str] + + if isinstance(x, int): + pass + else: + with pytest.raises(AssertionError): + assert_never(x) # type: ignore[arg-type] + + if isinstance(x, int): + pass + elif isinstance(x, str): + pass + else: + assert_never(x) + + +def test_assert_never_enum() -> None: + E = enum.Enum("E", "a b") + x = E.a # type: E + + if x is E.a: + pass + else: + with pytest.raises(AssertionError): + assert_never(x) # type: ignore[arg-type] + + if x is E.a: + pass + elif x is E.b: + pass + else: + assert_never(x) + + +def test_assert_never_literal() -> None: + x = "a" # type: Literal["a", "b"] + + if x == "a": + pass + else: + with pytest.raises(AssertionError): + assert_never(x) # type: ignore[arg-type] + + if x == "a": + pass + elif x == "b": + pass + else: + assert_never(x) diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 0df303bc7..724e6f464 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -23,6 +23,7 @@ def conftest_setinitial(conftest, args, confcutdir=None): self.confcutdir = str(confcutdir) self.noconftest = False self.pyargs = False + self.importmode = "prepend" conftest._set_initial_conftests(Namespace()) @@ -43,35 +44,38 @@ class TestConftestValueAccessGlobal: def test_basic_init(self, basedir): conftest = PytestPluginManager() p = basedir.join("adir") - assert conftest._rget_with_confmod("a", p)[1] == 1 + assert conftest._rget_with_confmod("a", p, importmode="prepend")[1] == 1 def test_immediate_initialiation_and_incremental_are_the_same(self, basedir): conftest = PytestPluginManager() assert not len(conftest._dirpath2confmods) - conftest._getconftestmodules(basedir) + conftest._getconftestmodules(basedir, importmode="prepend") snap1 = len(conftest._dirpath2confmods) assert snap1 == 1 - conftest._getconftestmodules(basedir.join("adir")) + conftest._getconftestmodules(basedir.join("adir"), importmode="prepend") assert len(conftest._dirpath2confmods) == snap1 + 1 - conftest._getconftestmodules(basedir.join("b")) + conftest._getconftestmodules(basedir.join("b"), importmode="prepend") assert len(conftest._dirpath2confmods) == snap1 + 2 def test_value_access_not_existing(self, basedir): conftest = ConftestWithSetinitial(basedir) with pytest.raises(KeyError): - conftest._rget_with_confmod("a", basedir) + conftest._rget_with_confmod("a", basedir, importmode="prepend") def test_value_access_by_path(self, basedir): conftest = ConftestWithSetinitial(basedir) adir = basedir.join("adir") - assert conftest._rget_with_confmod("a", adir)[1] == 1 - assert conftest._rget_with_confmod("a", adir.join("b"))[1] == 1.5 + assert conftest._rget_with_confmod("a", adir, importmode="prepend")[1] == 1 + assert ( + conftest._rget_with_confmod("a", adir.join("b"), importmode="prepend")[1] + == 1.5 + ) def test_value_access_with_confmod(self, basedir): startdir = basedir.join("adir", "b") startdir.ensure("xx", dir=True) conftest = ConftestWithSetinitial(startdir) - mod, value = conftest._rget_with_confmod("a", startdir) + mod, value = conftest._rget_with_confmod("a", startdir, importmode="prepend") assert value == 1.5 path = py.path.local(mod.__file__) assert path.dirpath() == basedir.join("adir", "b") @@ -91,7 +95,7 @@ def test_doubledash_considered(testdir): conf.ensure("conftest.py") conftest = PytestPluginManager() conftest_setinitial(conftest, [conf.basename, conf.basename]) - values = conftest._getconftestmodules(conf) + values = conftest._getconftestmodules(conf, importmode="prepend") assert len(values) == 1 @@ -114,13 +118,13 @@ def test_conftest_global_import(testdir): import py, pytest from _pytest.config import PytestPluginManager conf = PytestPluginManager() - mod = conf._importconftest(py.path.local("conftest.py")) + mod = conf._importconftest(py.path.local("conftest.py"), importmode="prepend") assert mod.x == 3 import conftest assert conftest is mod, (conftest, mod) subconf = py.path.local().ensure("sub", "conftest.py") subconf.write("y=4") - mod2 = conf._importconftest(subconf) + mod2 = conf._importconftest(subconf, importmode="prepend") assert mod != mod2 assert mod2.y == 4 import conftest @@ -136,17 +140,17 @@ def test_conftestcutdir(testdir): p = testdir.mkdir("x") conftest = PytestPluginManager() conftest_setinitial(conftest, [testdir.tmpdir], confcutdir=p) - values = conftest._getconftestmodules(p) + values = conftest._getconftestmodules(p, importmode="prepend") assert len(values) == 0 - values = conftest._getconftestmodules(conf.dirpath()) + values = conftest._getconftestmodules(conf.dirpath(), importmode="prepend") assert len(values) == 0 assert conf not in conftest._conftestpath2mod # but we can still import a conftest directly - conftest._importconftest(conf) - values = conftest._getconftestmodules(conf.dirpath()) + conftest._importconftest(conf, importmode="prepend") + values = conftest._getconftestmodules(conf.dirpath(), importmode="prepend") assert values[0].__file__.startswith(str(conf)) # and all sub paths get updated properly - values = conftest._getconftestmodules(p) + values = conftest._getconftestmodules(p, importmode="prepend") assert len(values) == 1 assert values[0].__file__.startswith(str(conf)) @@ -155,7 +159,7 @@ def test_conftestcutdir_inplace_considered(testdir): conf = testdir.makeconftest("") conftest = PytestPluginManager() conftest_setinitial(conftest, [conf.dirpath()], confcutdir=conf.dirpath()) - values = conftest._getconftestmodules(conf.dirpath()) + values = conftest._getconftestmodules(conf.dirpath(), importmode="prepend") assert len(values) == 1 assert values[0].__file__.startswith(str(conf)) @@ -340,13 +344,13 @@ def test_conftest_import_order(testdir, monkeypatch): ct2 = sub.join("conftest.py") ct2.write("") - def impct(p): + def impct(p, importmode): return p conftest = PytestPluginManager() conftest._confcutdir = testdir.tmpdir monkeypatch.setattr(conftest, "_importconftest", impct) - assert conftest._getconftestmodules(sub) == [ct1, ct2] + assert conftest._getconftestmodules(sub, importmode="prepend") == [ct1, ct2] def test_fixture_dependency(testdir): diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index acc963199..126e1718e 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1,6 +1,7 @@ import os.path import sys import unittest.mock +from textwrap import dedent import py @@ -9,11 +10,14 @@ from _pytest.pathlib import ensure_deletable from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import get_extended_length_path_str from _pytest.pathlib import get_lock_path +from _pytest.pathlib import import_path +from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import maybe_delete_a_numbered_dir from _pytest.pathlib import Path +from _pytest.pathlib import resolve_package_path -class TestPort: +class TestFNMatcherPort: """Test that our port of py.common.FNMatcher (fnmatch_ex) produces the same results as the original py.path.local.fnmatch method. """ @@ -79,6 +83,242 @@ class TestPort: assert not match(pattern, path) +class TestImportPath: + """ + + Most of the tests here were copied from py lib's tests for "py.local.path.pyimport". + + Having our own pyimport-like function is inline with removing py.path dependency in the future. + """ + + @pytest.yield_fixture(scope="session") + def path1(self, tmpdir_factory): + path = tmpdir_factory.mktemp("path") + self.setuptestfs(path) + yield path + assert path.join("samplefile").check() + + def setuptestfs(self, path): + # print "setting up test fs for", repr(path) + samplefile = path.ensure("samplefile") + samplefile.write("samplefile\n") + + execfile = path.ensure("execfile") + execfile.write("x=42") + + execfilepy = path.ensure("execfile.py") + execfilepy.write("x=42") + + d = {1: 2, "hello": "world", "answer": 42} + path.ensure("samplepickle").dump(d) + + sampledir = path.ensure("sampledir", dir=1) + sampledir.ensure("otherfile") + + otherdir = path.ensure("otherdir", dir=1) + otherdir.ensure("__init__.py") + + module_a = otherdir.ensure("a.py") + module_a.write("from .b import stuff as result\n") + module_b = otherdir.ensure("b.py") + module_b.write('stuff="got it"\n') + module_c = otherdir.ensure("c.py") + module_c.write( + dedent( + """ + import py; + import otherdir.a + value = otherdir.a.result + """ + ) + ) + module_d = otherdir.ensure("d.py") + module_d.write( + dedent( + """ + import py; + from otherdir import a + value2 = a.result + """ + ) + ) + + def test_smoke_test(self, path1): + obj = import_path(path1.join("execfile.py")) + assert obj.x == 42 # type: ignore[attr-defined] + assert obj.__name__ == "execfile" + + def test_renamed_dir_creates_mismatch(self, tmpdir, monkeypatch): + p = tmpdir.ensure("a", "test_x123.py") + import_path(p) + tmpdir.join("a").move(tmpdir.join("b")) + with pytest.raises(ImportPathMismatchError): + import_path(tmpdir.join("b", "test_x123.py")) + + # Errors can be ignored. + monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "1") + import_path(tmpdir.join("b", "test_x123.py")) + + # PY_IGNORE_IMPORTMISMATCH=0 does not ignore error. + monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "0") + with pytest.raises(ImportPathMismatchError): + import_path(tmpdir.join("b", "test_x123.py")) + + def test_messy_name(self, tmpdir): + # http://bitbucket.org/hpk42/py-trunk/issue/129 + path = tmpdir.ensure("foo__init__.py") + module = import_path(path) + assert module.__name__ == "foo__init__" + + def test_dir(self, tmpdir): + p = tmpdir.join("hello_123") + p_init = p.ensure("__init__.py") + m = import_path(p) + assert m.__name__ == "hello_123" + m = import_path(p_init) + assert m.__name__ == "hello_123" + + def test_a(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("a.py")) + assert mod.result == "got it" # type: ignore[attr-defined] + assert mod.__name__ == "otherdir.a" + + def test_b(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("b.py")) + assert mod.stuff == "got it" # type: ignore[attr-defined] + assert mod.__name__ == "otherdir.b" + + def test_c(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("c.py")) + assert mod.value == "got it" # type: ignore[attr-defined] + + def test_d(self, path1): + otherdir = path1.join("otherdir") + mod = import_path(otherdir.join("d.py")) + assert mod.value2 == "got it" # type: ignore[attr-defined] + + def test_import_after(self, tmpdir): + tmpdir.ensure("xxxpackage", "__init__.py") + mod1path = tmpdir.ensure("xxxpackage", "module1.py") + mod1 = import_path(mod1path) + assert mod1.__name__ == "xxxpackage.module1" + from xxxpackage import module1 + + assert module1 is mod1 + + def test_check_filepath_consistency(self, monkeypatch, tmpdir): + name = "pointsback123" + ModuleType = type(os) + p = tmpdir.ensure(name + ".py") + for ending in (".pyc", ".pyo"): + mod = ModuleType(name) + pseudopath = tmpdir.ensure(name + ending) + mod.__file__ = str(pseudopath) + monkeypatch.setitem(sys.modules, name, mod) + newmod = import_path(p) + assert mod == newmod + monkeypatch.undo() + mod = ModuleType(name) + pseudopath = tmpdir.ensure(name + "123.py") + mod.__file__ = str(pseudopath) + monkeypatch.setitem(sys.modules, name, mod) + with pytest.raises(ImportPathMismatchError) as excinfo: + import_path(p) + modname, modfile, orig = excinfo.value.args + assert modname == name + assert modfile == pseudopath + assert orig == p + assert issubclass(ImportPathMismatchError, ImportError) + + def test_issue131_on__init__(self, tmpdir): + # __init__.py files may be namespace packages, and thus the + # __file__ of an imported module may not be ourselves + # see issue + p1 = tmpdir.ensure("proja", "__init__.py") + p2 = tmpdir.ensure("sub", "proja", "__init__.py") + m1 = import_path(p1) + m2 = import_path(p2) + assert m1 == m2 + + def test_ensuresyspath_append(self, tmpdir): + root1 = tmpdir.mkdir("root1") + file1 = root1.ensure("x123.py") + assert str(root1) not in sys.path + import_path(file1, mode="append") + assert str(root1) == sys.path[-1] + assert str(root1) not in sys.path[:-1] + + def test_invalid_path(self, tmpdir): + with pytest.raises(ImportError): + import_path(tmpdir.join("invalid.py")) + + @pytest.fixture + def simple_module(self, tmpdir): + fn = tmpdir.join("mymod.py") + fn.write( + dedent( + """ + def foo(x): return 40 + x + """ + ) + ) + return fn + + def test_importmode_importlib(self, simple_module): + """importlib mode does not change sys.path""" + module = import_path(simple_module, mode="importlib") + assert module.foo(2) == 42 # type: ignore[attr-defined] + assert simple_module.dirname not in sys.path + + def test_importmode_twice_is_different_module(self, simple_module): + """importlib mode always returns a new module""" + module1 = import_path(simple_module, mode="importlib") + module2 = import_path(simple_module, mode="importlib") + assert module1 is not module2 + + def test_no_meta_path_found(self, simple_module, monkeypatch): + """Even without any meta_path should still import module""" + monkeypatch.setattr(sys, "meta_path", []) + module = import_path(simple_module, mode="importlib") + assert module.foo(2) == 42 # type: ignore[attr-defined] + + # mode='importlib' fails if no spec is found to load the module + import importlib.util + + monkeypatch.setattr( + importlib.util, "spec_from_file_location", lambda *args: None + ) + with pytest.raises(ImportError): + import_path(simple_module, mode="importlib") + + +def test_resolve_package_path(tmp_path): + pkg = tmp_path / "pkg1" + pkg.mkdir() + (pkg / "__init__.py").touch() + (pkg / "subdir").mkdir() + (pkg / "subdir/__init__.py").touch() + assert resolve_package_path(pkg) == pkg + assert resolve_package_path(pkg.joinpath("subdir", "__init__.py")) == pkg + + +def test_package_unimportable(tmp_path): + pkg = tmp_path / "pkg1-1" + pkg.mkdir() + pkg.joinpath("__init__.py").touch() + subdir = pkg.joinpath("subdir") + subdir.mkdir() + pkg.joinpath("subdir/__init__.py").touch() + assert resolve_package_path(subdir) == subdir + xyz = subdir.joinpath("xyz.py") + xyz.touch() + assert resolve_package_path(xyz) == subdir + assert not resolve_package_path(pkg) + + def test_access_denied_during_cleanup(tmp_path, monkeypatch): """Ensure that deleting a numbered dir does not fail because of OSErrors (#4262).""" path = tmp_path / "temp-1" diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 713687578..fc327e6c0 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -37,7 +37,7 @@ class TestPytestPluginInteractions: pm.hook.pytest_addhooks.call_historic( kwargs=dict(pluginmanager=config.pluginmanager) ) - config.pluginmanager._importconftest(conf) + config.pluginmanager._importconftest(conf, importmode="prepend") # print(config.pluginmanager.get_plugins()) res = config.hook.pytest_myhook(xyz=10) assert res == [11] @@ -64,7 +64,7 @@ class TestPytestPluginInteractions: default=True) """ ) - config.pluginmanager._importconftest(p) + config.pluginmanager._importconftest(p, importmode="prepend") assert config.option.test123 def test_configure(self, testdir): @@ -129,10 +129,10 @@ class TestPytestPluginInteractions: conftest1 = testdir.tmpdir.join("tests/conftest.py") conftest2 = testdir.tmpdir.join("tests/subdir/conftest.py") - config.pluginmanager._importconftest(conftest1) + config.pluginmanager._importconftest(conftest1, importmode="prepend") ihook_a = session.gethookproxy(testdir.tmpdir.join("tests")) assert ihook_a is not None - config.pluginmanager._importconftest(conftest2) + config.pluginmanager._importconftest(conftest2, importmode="prepend") ihook_b = session.gethookproxy(testdir.tmpdir.join("tests")) assert ihook_a is not ihook_b From 320625527a1372395f1117e635278050532cbbfe Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Sat, 13 Jun 2020 11:22:18 -0400 Subject: [PATCH 102/140] Add more tests and docs --- changelog/7346.feature.rst | 1 + doc/en/reference.rst | 4 +++- src/_pytest/config/__init__.py | 2 +- testing/test_config.py | 18 +++++++++--------- 4 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 changelog/7346.feature.rst diff --git a/changelog/7346.feature.rst b/changelog/7346.feature.rst new file mode 100644 index 000000000..fef0bbb78 --- /dev/null +++ b/changelog/7346.feature.rst @@ -0,0 +1 @@ +Version information as defined by `PEP 440 `_ may now be included when providing plugins to the ``required_plugins`` configuration option. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index bf3d1fbbb..d5580ad65 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1562,12 +1562,14 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: required_plugins A space separated list of plugins that must be present for pytest to run. + Plugins can be listed with or without version specifiers directly following + their name. Whitespace between different version specifiers is not allowed. If any one of the plugins is not found, emit an error. .. code-block:: ini [pytest] - required_plugins = pytest-html pytest-xdist + required_plugins = pytest-django>=3.0.0,<4.0.0 pytest-html pytest-xdist>=1.0.0 .. confval:: testpaths diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index a124b8f5f..116383a73 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1101,7 +1101,7 @@ class Config: plugin_info = self.pluginmanager.list_plugin_distinfo() plugin_dist_info = {dist.project_name: dist.version for _, dist in plugin_info} - missing_plugins = ["a"] + missing_plugins = [] for required_plugin in required_plugins: spec = None try: diff --git a/testing/test_config.py b/testing/test_config.py index f8c1c879e..d59d641f6 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -265,6 +265,13 @@ class TestParseIni: ( """ [pytest] + required_plugins = pytest-xdist>1.0.0,<2.0.0 + """, + "", + ), + ( + """ + [pytest] required_plugins = pytest-xdist~=1.32.0 pytest-xdist==1.32.0 pytest-xdist!=0.0.1 pytest-xdist<=99.99.0 pytest-xdist>=1.32.0 pytest-xdist<9.9.9 pytest-xdist>1.30.0 pytest-xdist===1.32.0 """, @@ -280,16 +287,9 @@ class TestParseIni: ( """ [pytest] - required_plugins = pytest-xdist==aegsrgrsgs + required_plugins = pytest-xdist==aegsrgrsgs pytest-xdist==-1 pytest-xdist>2.1.1,>3.0.0 """, - "Missing required plugins: pytest-xdist==aegsrgrsgs", - ), - ( - """ - [pytest] - required_plugins = pytest-xdist==-1 - """, - "Missing required plugins: pytest-xdist==-1", + "Missing required plugins: pytest-xdist==-1, pytest-xdist==aegsrgrsgs, pytest-xdist>2.1.1,>3.0.0", ), ( """ From 03d0a10e3a52a089dc4614757a11a931e29b2eaf Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 13 Jun 2020 20:50:24 +0300 Subject: [PATCH 103/140] The final Python 2.7 was released in April The final Python 2.7.18 release was on 20 Apr 2020. https://mail.python.org/archives/list/python-dev@python.org/thread/OFCIETIXLX34X7FVK5B5WPZH22HXV342/#OFCIETIXLX34X7FVK5B5WPZH22HXV342 --- doc/en/py27-py34-deprecation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/py27-py34-deprecation.rst b/doc/en/py27-py34-deprecation.rst index f2d6b540d..f23f2062b 100644 --- a/doc/en/py27-py34-deprecation.rst +++ b/doc/en/py27-py34-deprecation.rst @@ -9,7 +9,7 @@ In case of Python 2 and 3, the difference between the languages makes it even mo because many new Python 3 features cannot be used in a Python 2/3 compatible code base. Python 2.7 EOL has been reached `in 2020 `__, with -the last release planned for mid-April, 2020. +the last release made in April, 2020. Python 3.4 EOL has been reached `in 2019 `__, with the last release made in March, 2019. From 25064eba7a742bc1bbcef370e2e0f44f40bc0bde Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 13 Jun 2020 14:15:54 +0300 Subject: [PATCH 104/140] pytest.collect: type annotate (backward compat module) This is just to satisfy typing coverage. --- src/pytest/collect.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pytest/collect.py b/src/pytest/collect.py index 73c9d35a0..ec9c2d8b4 100644 --- a/src/pytest/collect.py +++ b/src/pytest/collect.py @@ -1,6 +1,8 @@ import sys import warnings from types import ModuleType +from typing import Any +from typing import List import pytest from _pytest.deprecated import PYTEST_COLLECT_MODULE @@ -20,15 +22,15 @@ COLLECT_FAKEMODULE_ATTRIBUTES = [ class FakeCollectModule(ModuleType): - def __init__(self): + def __init__(self) -> None: super().__init__("pytest.collect") self.__all__ = list(COLLECT_FAKEMODULE_ATTRIBUTES) self.__pytest = pytest - def __dir__(self): + def __dir__(self) -> List[str]: return dir(super()) + self.__all__ - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: if name not in self.__all__: raise AttributeError(name) warnings.warn(PYTEST_COLLECT_MODULE.format(name=name), stacklevel=2) From bb7b3af9b95bafe633a2a5136e78dcf1d686c4de Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 13 Jun 2020 22:28:55 +0300 Subject: [PATCH 105/140] hookspec: fix return type annotation of pytest_runtest_makereport --- src/_pytest/hookspec.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 1c1726d53..4469f5078 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -437,7 +437,9 @@ def pytest_runtest_teardown(item: "Item", nextitem: "Optional[Item]") -> None: @hookspec(firstresult=True) -def pytest_runtest_makereport(item: "Item", call: "CallInfo[None]") -> Optional[object]: +def pytest_runtest_makereport( + item: "Item", call: "CallInfo[None]" +) -> Optional["TestReport"]: """ return a :py:class:`_pytest.runner.TestReport` object for the given :py:class:`pytest.Item <_pytest.main.Item>` and :py:class:`_pytest.runner.CallInfo`. From 314d00968a0e5d3532fde41297ffa884b6d548bb Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 14 Jun 2020 12:49:05 +0300 Subject: [PATCH 106/140] hookspec: type annotate pytest_runtest_log{start,finish} --- src/_pytest/hookspec.py | 8 ++++++-- src/_pytest/logging.py | 4 ++-- src/_pytest/terminal.py | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 4469f5078..a893517aa 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -396,7 +396,9 @@ def pytest_runtest_protocol( Stops at first non-None result, see :ref:`firstresult` """ -def pytest_runtest_logstart(nodeid, location): +def pytest_runtest_logstart( + nodeid: str, location: Tuple[str, Optional[int], str] +) -> None: """ signal the start of running a single test item. This hook will be called **before** :func:`pytest_runtest_setup`, :func:`pytest_runtest_call` and @@ -407,7 +409,9 @@ def pytest_runtest_logstart(nodeid, location): """ -def pytest_runtest_logfinish(nodeid, location): +def pytest_runtest_logfinish( + nodeid: str, location: Tuple[str, Optional[int], str] +) -> None: """ signal the complete finish of running a single test item. This hook will be called **after** :func:`pytest_runtest_setup`, :func:`pytest_runtest_call` and diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 04bf74b6c..8755e5611 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -653,12 +653,12 @@ class LoggingPlugin: yield # run all the tests @pytest.hookimpl - def pytest_runtest_logstart(self): + def pytest_runtest_logstart(self) -> None: self.log_cli_handler.reset() self.log_cli_handler.set_when("start") @pytest.hookimpl - def pytest_runtest_logreport(self): + def pytest_runtest_logreport(self) -> None: self.log_cli_handler.set_when("logreport") def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, None]: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index f98c9b8ab..6a58260e9 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -502,7 +502,9 @@ class TerminalReporter: def pytest_deselected(self, items) -> None: self._add_stats("deselected", items) - def pytest_runtest_logstart(self, nodeid, location) -> None: + def pytest_runtest_logstart( + self, nodeid: str, location: Tuple[str, Optional[int], str] + ) -> None: # ensure that the path is printed before the # 1st test of a module starts running if self.showlongtestinfo: @@ -569,7 +571,7 @@ class TerminalReporter: assert self._session is not None return len(self._progress_nodeids_reported) == self._session.testscollected - def pytest_runtest_logfinish(self, nodeid) -> None: + def pytest_runtest_logfinish(self, nodeid: str) -> None: assert self._session if self.verbosity <= 0 and self._show_progress_info: if self._show_progress_info == "count": From bb878a2b13508e88ace7718759065de56a63aac5 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 14 Jun 2020 13:35:59 +0300 Subject: [PATCH 107/140] runner: don't try to teardown previous items from pytest_runtest_setup While working on improving the documentation of the `pytest_runtest_setup` hook, I came up with this text: > Called to perform the setup phase of the test item. > > The default implementation runs ``setup()`` on item and all of its > parents (which haven't been setup yet). This includes obtaining the > values of fixtures required by the item (which haven't been obtained > yet). But upon closer inspection I noticed this line at the start of `SetupState.prepare` (which is what does the actual work for `pytest_runtest_setup`): self._teardown_towards(needed_collectors) which implies that the setup phase of one item might trigger teardowns of *previous* items. This complicates the simple explanation. It also seems like a completely undesirable thing to do, because it breaks isolation between tests -- e.g. a failed teardown of one item shouldn't cause the failure of some other items just because it happens to run after it. So the first thing I tried was to remove that line and see if anything breaks -- nothing did. At least pytest's own test suite runs fine. So maybe it's just dead code? --- src/_pytest/runner.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 3ca8d7ea4..b6c89dce5 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -409,16 +409,15 @@ class SetupState: raise exc def prepare(self, colitem) -> None: - """ setup objects along the collector chain to the test-method - and teardown previously setup objects.""" - needed_collectors = colitem.listchain() - self._teardown_towards(needed_collectors) + """Setup objects along the collector chain to the test-method.""" # check if the last collection node has raised an error for col in self.stack: if hasattr(col, "_prepare_exc"): exc = col._prepare_exc # type: ignore[attr-defined] # noqa: F821 raise exc + + needed_collectors = colitem.listchain() for col in needed_collectors[len(self.stack) :]: self.stack.append(col) try: From 5e35c86a376e48453749ebc1997fb60b6262ce19 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 14 Jun 2020 16:52:09 +0300 Subject: [PATCH 108/140] doc/reference: refer to function public names instead of internal _pytest names This way e.g. a :py:func:`pytest.exit` cross-reference works properly. --- doc/en/changelog.rst | 4 ++-- doc/en/reference.rst | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index 2806fb6a3..40b5ee690 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -287,7 +287,7 @@ Bug Fixes - `#6646 `_: 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. -- `#6660 `_: :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. +- `#6660 `_: :py:func:`pytest.exit` is handled when emitted from the :func:`pytest_sessionfinish <_pytest.hookspec.pytest_sessionfinish>` hook. This includes quitting from a debugger. - `#6752 `_: When :py:func:`pytest.raises` is used as a function (as opposed to a context manager), @@ -399,7 +399,7 @@ Improvements - `#6231 `_: Improve check for misspelling of :ref:`pytest.mark.parametrize ref`. -- `#6257 `_: Handle :py:func:`_pytest.outcomes.exit` being used via :py:func:`~_pytest.hookspec.pytest_internalerror`, e.g. when quitting pdb from post mortem. +- `#6257 `_: Handle :py:func:`pytest.exit` being used via :py:func:`~_pytest.hookspec.pytest_internalerror`, e.g. when quitting pdb from post mortem. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index bf3d1fbbb..d21cdd3e9 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -15,41 +15,41 @@ Functions pytest.approx ~~~~~~~~~~~~~ -.. autofunction:: _pytest.python_api.approx +.. autofunction:: pytest.approx pytest.fail ~~~~~~~~~~~ **Tutorial**: :ref:`skipping` -.. autofunction:: _pytest.outcomes.fail +.. autofunction:: pytest.fail pytest.skip ~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.skip(msg, [allow_module_level=False]) +.. autofunction:: pytest.skip(msg, [allow_module_level=False]) .. _`pytest.importorskip ref`: pytest.importorskip ~~~~~~~~~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.importorskip +.. autofunction:: pytest.importorskip pytest.xfail ~~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.xfail +.. autofunction:: pytest.xfail pytest.exit ~~~~~~~~~~~ -.. autofunction:: _pytest.outcomes.exit +.. autofunction:: pytest.exit pytest.main ~~~~~~~~~~~ -.. autofunction:: _pytest.config.main +.. autofunction:: pytest.main pytest.param ~~~~~~~~~~~~ From 2a38ca8a0cc9f59266874ca3cf3c0c8b080fd42e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 14 Jun 2020 16:02:25 +0300 Subject: [PATCH 109/140] doc/reference: add CollectReport CollectReport appears in several hooks, so we should document it. It's runtest equivalent TestReport is already documented. --- doc/en/reference.rst | 8 ++++++++ src/_pytest/reports.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index d21cdd3e9..bc501c1ad 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -762,6 +762,14 @@ Collector :members: :show-inheritance: +CollectReport +~~~~~~~~~~~~~ + +.. autoclass:: _pytest.runner.CollectReport() + :members: + :show-inheritance: + :inherited-members: + Config ~~~~~~ diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 6a408354b..8b213ed13 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -335,6 +335,8 @@ class TestReport(BaseReport): class CollectReport(BaseReport): + """Collection report object.""" + when = "collect" def __init__( @@ -346,11 +348,24 @@ class CollectReport(BaseReport): sections: Iterable[Tuple[str, str]] = (), **extra ) -> None: + #: normalized collection node id self.nodeid = nodeid + + #: test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome + + #: None or a failure representation. self.longrepr = longrepr + + #: The collected items and collection nodes. self.result = result or [] + + #: list of pairs ``(str, str)`` of extra information which needs to + #: marshallable. Used by pytest to add captured text + #: from ``stdout`` and ``stderr``, but may be used by other plugins + #: to add arbitrary information to reports. self.sections = list(sections) + self.__dict__.update(extra) @property From da1124eb9834a6a6839eeb05b6adc75513735598 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 14 Jun 2020 12:42:09 +0300 Subject: [PATCH 110/140] hookspec: improve runtest hooks documentation --- doc/en/reference.rst | 11 ++-- src/_pytest/hookspec.py | 128 ++++++++++++++++++++++++++++------------ 2 files changed, 95 insertions(+), 44 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index bc501c1ad..d09aecafb 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -644,8 +644,8 @@ Initialization hooks called for plugins and ``conftest.py`` files. .. autofunction:: pytest_plugin_registered -Test running hooks -~~~~~~~~~~~~~~~~~~ +Test running (runtest) hooks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ All runtest related hooks receive a :py:class:`pytest.Item <_pytest.main.Item>` object. @@ -664,9 +664,6 @@ in :py:mod:`_pytest.pdb` which interacts with :py:mod:`_pytest.capture` and its input/output capturing in order to immediately drop into interactive debugging when a test failure occurs. -The :py:mod:`_pytest.terminal` reported specifically uses -the reporting hook to print information about a test run. - .. autofunction:: pytest_pyfunc_call Collection hooks @@ -765,7 +762,7 @@ Collector CollectReport ~~~~~~~~~~~~~ -.. autoclass:: _pytest.runner.CollectReport() +.. autoclass:: _pytest.reports.CollectReport() :members: :show-inheritance: :inherited-members: @@ -889,7 +886,7 @@ Session TestReport ~~~~~~~~~~ -.. autoclass:: _pytest.runner.TestReport() +.. autoclass:: _pytest.reports.TestReport() :members: :show-inheritance: :inherited-members: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index a893517aa..eba6f5ba9 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -361,18 +361,28 @@ def pytest_make_parametrize_id( # ------------------------------------------------------------------------- -# generic runtest related hooks +# runtest related hooks # ------------------------------------------------------------------------- @hookspec(firstresult=True) def pytest_runtestloop(session: "Session") -> Optional[object]: - """ called for performing the main runtest loop - (after collection finished). + """Performs the main runtest loop (after collection finished). - Stops at first non-None result, see :ref:`firstresult` + The default hook implementation performs the runtest protocol for all items + collected in the session (``session.items``), unless the collection failed + or the ``collectonly`` pytest option is set. - :param _pytest.main.Session session: the pytest session object + If at any point :py:func:`pytest.exit` is called, the loop is + terminated immediately. + + If at any point ``session.shouldfail`` or ``session.shouldstop`` are set, the + loop is terminated after the runtest protocol for the current item is finished. + + :param _pytest.main.Session session: The pytest session object. + + Stops at first non-None result, see :ref:`firstresult`. + The return value is not used, but only stops further processing. """ @@ -380,60 +390,91 @@ def pytest_runtestloop(session: "Session") -> Optional[object]: def pytest_runtest_protocol( item: "Item", nextitem: "Optional[Item]" ) -> Optional[object]: - """ implements the runtest_setup/call/teardown protocol for - the given test item, including capturing exceptions and calling - reporting hooks. + """Performs the runtest protocol for a single test item. - :arg item: test item for which the runtest protocol is performed. + The default runtest protocol is this (see individual hooks for full details): - :arg nextitem: the scheduled-to-be-next test item (or None if this - is the end my friend). This argument is passed on to - :py:func:`pytest_runtest_teardown`. + - ``pytest_runtest_logstart(nodeid, location)`` - :return boolean: True if no further hook implementations should be invoked. + - Setup phase: + - ``call = pytest_runtest_setup(item)`` (wrapped in ``CallInfo(when="setup")``) + - ``report = pytest_runtest_makereport(item, call)`` + - ``pytest_runtest_logreport(report)`` + - ``pytest_exception_interact(call, report)`` if an interactive exception occurred + - Call phase, if the the setup passed and the ``setuponly`` pytest option is not set: + - ``call = pytest_runtest_call(item)`` (wrapped in ``CallInfo(when="call")``) + - ``report = pytest_runtest_makereport(item, call)`` + - ``pytest_runtest_logreport(report)`` + - ``pytest_exception_interact(call, report)`` if an interactive exception occurred - Stops at first non-None result, see :ref:`firstresult` """ + - Teardown phase: + - ``call = pytest_runtest_teardown(item, nextitem)`` (wrapped in ``CallInfo(when="teardown")``) + - ``report = pytest_runtest_makereport(item, call)`` + - ``pytest_runtest_logreport(report)`` + - ``pytest_exception_interact(call, report)`` if an interactive exception occurred + + - ``pytest_runtest_logfinish(nodeid, location)`` + + :arg item: Test item for which the runtest protocol is performed. + + :arg nextitem: The scheduled-to-be-next test item (or None if this is the end my friend). + + Stops at first non-None result, see :ref:`firstresult`. + The return value is not used, but only stops further processing. + """ def pytest_runtest_logstart( nodeid: str, location: Tuple[str, Optional[int], str] ) -> None: - """ signal the start of running a single test item. + """Called at the start of running the runtest protocol for a single item. - This hook will be called **before** :func:`pytest_runtest_setup`, :func:`pytest_runtest_call` and - :func:`pytest_runtest_teardown` hooks. + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. - :param str nodeid: full id of the item - :param location: a triple of ``(filename, linenum, testname)`` + :param str nodeid: Full node ID of the item. + :param location: A triple of ``(filename, lineno, testname)``. """ def pytest_runtest_logfinish( nodeid: str, location: Tuple[str, Optional[int], str] ) -> None: - """ signal the complete finish of running a single test item. + """Called at the end of running the runtest protocol for a single item. - This hook will be called **after** :func:`pytest_runtest_setup`, :func:`pytest_runtest_call` and - :func:`pytest_runtest_teardown` hooks. + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. - :param str nodeid: full id of the item - :param location: a triple of ``(filename, linenum, testname)`` + :param str nodeid: Full node ID of the item. + :param location: A triple of ``(filename, lineno, testname)``. """ def pytest_runtest_setup(item: "Item") -> None: - """ called before ``pytest_runtest_call(item)``. """ + """Called to perform the setup phase for a test item. + + The default implementation runs ``setup()`` on ``item`` and all of its + parents (which haven't been setup yet). This includes obtaining the + values of fixtures required by the item (which haven't been obtained + yet). + """ def pytest_runtest_call(item: "Item") -> None: - """ called to execute the test ``item``. """ + """Called to run the test for test item (the call phase). + + The default implementation calls ``item.runtest()``. + """ def pytest_runtest_teardown(item: "Item", nextitem: "Optional[Item]") -> None: - """ called after ``pytest_runtest_call``. + """Called to perform the teardown phase for a test item. - :arg nextitem: the scheduled-to-be-next test item (None if no further + The default implementation runs the finalizers and calls ``teardown()`` + on ``item`` and all of its parents (which need to be torn down). This + includes running the teardown phase of fixtures required by the item (if + they go out of scope). + + :arg nextitem: The scheduled-to-be-next test item (None if no further test item is scheduled). This argument can be used to perform exact teardowns, i.e. calling just enough finalizers so that nextitem only needs to call setup-functions. @@ -444,16 +485,23 @@ def pytest_runtest_teardown(item: "Item", nextitem: "Optional[Item]") -> None: def pytest_runtest_makereport( item: "Item", call: "CallInfo[None]" ) -> Optional["TestReport"]: - """ return a :py:class:`_pytest.runner.TestReport` object - for the given :py:class:`pytest.Item <_pytest.main.Item>` and - :py:class:`_pytest.runner.CallInfo`. + """Called to create a :py:class:`_pytest.reports.TestReport` for each of + the setup, call and teardown runtest phases of a test item. - Stops at first non-None result, see :ref:`firstresult` """ + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. + + :param CallInfo[None] call: The ``CallInfo`` for the phase. + + Stops at first non-None result, see :ref:`firstresult`. + """ def pytest_runtest_logreport(report: "TestReport") -> None: - """ process a test setup/call/teardown report relating to - the respective phase of executing a test. """ + """Process the :py:class:`_pytest.reports.TestReport` produced for each + of the setup, call and teardown runtest phases of an item. + + See :func:`pytest_runtest_protocol` for a description of the runtest protocol. + """ @hookspec(firstresult=True) @@ -785,11 +833,17 @@ def pytest_keyboard_interrupt( def pytest_exception_interact( node: "Node", call: "CallInfo[object]", report: "Union[CollectReport, TestReport]" ) -> None: - """called when an exception was raised which can potentially be + """Called when an exception was raised which can potentially be interactively handled. - This hook is only called if an exception was raised - that is not an internal exception like ``skip.Exception``. + May be called during collection (see :py:func:`pytest_make_collect_report`), + in which case ``report`` is a :py:class:`_pytest.reports.CollectReport`. + + May be called during runtest of an item (see :py:func:`pytest_runtest_protocol`), + in which case ``report`` is a :py:class:`_pytest.reports.TestReport`. + + This hook is not called if the exception that was raised is an internal + exception like ``skip.Exception``. """ From 33804fd9b79c27aac4acd71617694fe1ef0fd01b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 14 Jun 2020 16:31:55 +0300 Subject: [PATCH 111/140] doc/reference: move "Collection hooks" before "Test running hooks" Collection occurs before test running, so it seems more logical. --- doc/en/reference.rst | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index d09aecafb..11788ee6c 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -644,28 +644,6 @@ Initialization hooks called for plugins and ``conftest.py`` files. .. autofunction:: pytest_plugin_registered -Test running (runtest) hooks -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -All runtest related hooks receive a :py:class:`pytest.Item <_pytest.main.Item>` object. - -.. autofunction:: pytest_runtestloop -.. autofunction:: pytest_runtest_protocol -.. autofunction:: pytest_runtest_logstart -.. autofunction:: pytest_runtest_logfinish -.. autofunction:: pytest_runtest_setup -.. autofunction:: pytest_runtest_call -.. autofunction:: pytest_runtest_teardown -.. autofunction:: pytest_runtest_makereport - -For deeper understanding you may look at the default implementation of -these hooks in :py:mod:`_pytest.runner` and maybe also -in :py:mod:`_pytest.pdb` which interacts with :py:mod:`_pytest.capture` -and its input/output capturing in order to immediately drop -into interactive debugging when a test failure occurs. - -.. autofunction:: pytest_pyfunc_call - Collection hooks ~~~~~~~~~~~~~~~~ @@ -691,6 +669,28 @@ items, delete or otherwise amend the test items: .. autofunction:: pytest_collection_finish +Test running (runtest) hooks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +All runtest related hooks receive a :py:class:`pytest.Item <_pytest.main.Item>` object. + +.. autofunction:: pytest_runtestloop +.. autofunction:: pytest_runtest_protocol +.. autofunction:: pytest_runtest_logstart +.. autofunction:: pytest_runtest_logfinish +.. autofunction:: pytest_runtest_setup +.. autofunction:: pytest_runtest_call +.. autofunction:: pytest_runtest_teardown +.. autofunction:: pytest_runtest_makereport + +For deeper understanding you may look at the default implementation of +these hooks in :py:mod:`_pytest.runner` and maybe also +in :py:mod:`_pytest.pdb` which interacts with :py:mod:`_pytest.capture` +and its input/output capturing in order to immediately drop +into interactive debugging when a test failure occurs. + +.. autofunction:: pytest_pyfunc_call + Reporting hooks ~~~~~~~~~~~~~~~ From c27550731d01beb81b8841213dd2f107a82bd6e0 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 15 Jun 2020 17:14:48 +0300 Subject: [PATCH 112/140] Require py>=1.8.2 so we can rely on correct hash() of py.path.local on Windows See https://github.com/pytest-dev/py/blob/1.8.2/CHANGELOG#L4. Fixes #7357. --- changelog/7357.trivial.rst | 1 + setup.cfg | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog/7357.trivial.rst diff --git a/changelog/7357.trivial.rst b/changelog/7357.trivial.rst new file mode 100644 index 000000000..f0f9d035d --- /dev/null +++ b/changelog/7357.trivial.rst @@ -0,0 +1 @@ +py>=1.8.2 is now required. diff --git a/setup.cfg b/setup.cfg index 8749334f8..3e5cfa1f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,7 +45,7 @@ install_requires = more-itertools>=4.0.0 packaging pluggy>=0.12,<1.0 - py>=1.5.0 + py>=1.8.2 toml atomicwrites>=1.0;sys_platform=="win32" colorama;sys_platform=="win32" From a67c553beb0253f30262b4e44a1a929f316aebc7 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 16 Jun 2020 05:39:36 -0400 Subject: [PATCH 113/140] Disable caching when evaluating expressions in marks (#7373) --- changelog/7360.bugfix.rst | 2 ++ src/_pytest/mark/evaluate.py | 21 +++++---------------- testing/test_mark.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 changelog/7360.bugfix.rst diff --git a/changelog/7360.bugfix.rst b/changelog/7360.bugfix.rst new file mode 100644 index 000000000..b84ce4614 --- /dev/null +++ b/changelog/7360.bugfix.rst @@ -0,0 +1,2 @@ +Fix possibly incorrect evaluation of string expressions passed to ``pytest.mark.skipif`` and ``pytest.mark.xfail``, +in rare circumstances where the exact same string is used but refers to different global values. diff --git a/src/_pytest/mark/evaluate.py b/src/_pytest/mark/evaluate.py index 759191668..eb9903a59 100644 --- a/src/_pytest/mark/evaluate.py +++ b/src/_pytest/mark/evaluate.py @@ -10,25 +10,14 @@ from typing import Optional from ..outcomes import fail from ..outcomes import TEST_OUTCOME from .structures import Mark -from _pytest.config import Config from _pytest.nodes import Item -from _pytest.store import StoreKey -evalcache_key = StoreKey[Dict[str, Any]]() +def compiled_eval(expr: str, d: Dict[str, object]) -> Any: + import _pytest._code - -def cached_eval(config: Config, expr: str, d: Dict[str, object]) -> Any: - default = {} # type: Dict[str, object] - evalcache = config._store.setdefault(evalcache_key, default) - try: - return evalcache[expr] - except KeyError: - import _pytest._code - - exprcode = _pytest._code.compile(expr, mode="eval") - evalcache[expr] = x = eval(exprcode, d) - return x + exprcode = _pytest._code.compile(expr, mode="eval") + return eval(exprcode, d) class MarkEvaluator: @@ -98,7 +87,7 @@ class MarkEvaluator: self.expr = expr if isinstance(expr, str): d = self._getglobals() - result = cached_eval(self.item.config, expr, d) + result = compiled_eval(expr, d) else: if "reason" not in mark.kwargs: # XXX better be checked at collection time diff --git a/testing/test_mark.py b/testing/test_mark.py index cdd4df9dd..f261c8922 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -706,6 +706,36 @@ class TestFunctional: reprec = testdir.inline_run() reprec.assertoutcome(skipped=1) + def test_reevaluate_dynamic_expr(self, testdir): + """#7360""" + py_file1 = testdir.makepyfile( + test_reevaluate_dynamic_expr1=""" + import pytest + + skip = True + + @pytest.mark.skipif("skip") + def test_should_skip(): + assert True + """ + ) + py_file2 = testdir.makepyfile( + test_reevaluate_dynamic_expr2=""" + import pytest + + skip = False + + @pytest.mark.skipif("skip") + def test_should_not_skip(): + assert True + """ + ) + + file_name1 = os.path.basename(py_file1.strpath) + file_name2 = os.path.basename(py_file2.strpath) + reprec = testdir.inline_run(file_name1, file_name2) + reprec.assertoutcome(passed=1, skipped=1) + class TestKeywordSelection: def test_select_simple(self, testdir): From ab19148c2a3d915fab6231a9f9053d771bc63eaf Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Tue, 16 Jun 2020 20:59:58 -0400 Subject: [PATCH 114/140] fix changelog file name for issue 4049 fix --- changelog/{7255.feature.rst => 4049.feature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/{7255.feature.rst => 4049.feature.rst} (100%) diff --git a/changelog/7255.feature.rst b/changelog/4049.feature.rst similarity index 100% rename from changelog/7255.feature.rst rename to changelog/4049.feature.rst From 4cc4ebf3c9f79eb9d7484c763b11dea3ddff4b82 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Thu, 18 Jun 2020 11:58:41 -0400 Subject: [PATCH 115/140] Don't treat ini keys defined in conftest.py as invalid (#7384) --- src/_pytest/config/__init__.py | 2 +- testing/test_config.py | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 4ff6ce707..31b73a2c9 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1054,7 +1054,6 @@ class Config: args, namespace=copy.copy(self.option) ) self._validate_plugins() - self._validate_keys() if self.known_args_namespace.confcutdir is None and self.inifile: confcutdir = py.path.local(self.inifile).dirname self.known_args_namespace.confcutdir = confcutdir @@ -1077,6 +1076,7 @@ class Config: ) else: raise + self._validate_keys() def _checkversion(self): import pytest diff --git a/testing/test_config.py b/testing/test_config.py index d59d641f6..c9eea7a16 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -203,6 +203,15 @@ class TestParseIni: """ [pytest] minversion = 5.0.0 + """, + [], + [], + "", + ), + ( + """ + [pytest] + conftest_ini_key = 1 """, [], [], @@ -213,16 +222,25 @@ class TestParseIni: def test_invalid_ini_keys( self, testdir, ini_file_text, invalid_keys, stderr_output, exception_text ): + testdir.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("conftest_ini_key", "") + """ + ) testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text)) + config = testdir.parseconfig() assert sorted(config._get_unknown_ini_keys()) == sorted(invalid_keys) result = testdir.runpytest() result.stderr.fnmatch_lines(stderr_output) - if stderr_output: + if exception_text: with pytest.raises(pytest.fail.Exception, match=exception_text): testdir.runpytest("--strict-config") + else: + testdir.runpytest("--strict-config") @pytest.mark.parametrize( "ini_file_text, exception_text", From a1f841d5d26364b45de70f5a61a03adecc6b5462 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 19 Jun 2020 13:33:53 +0300 Subject: [PATCH 116/140] skipping: use pytest_runtest_call instead of pytest_pyfunc_call `@pytest.mark.xfail` is meant to work with arbitrary items, and there is a test `test_mark_xfail_item` which verifies this. However, the code for some reason uses `pytest_pyfunc_call` for the call phase check, which only works for Function items. The test mentioned above only passed "accidentally" because the `pytest_runtest_makereport` hook also runs a `evalxfail.istrue()` which triggers and evaluation, but conceptually it shouldn't do that. Change to `pytest_runtest_call` to make the xfail checking properly generic. --- src/_pytest/skipping.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index bbd4593fd..4e4b5a3c4 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -10,7 +10,6 @@ from _pytest.nodes import Item from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail -from _pytest.python import Function from _pytest.reports import BaseReport from _pytest.runner import CallInfo from _pytest.store import StoreKey @@ -103,12 +102,12 @@ def pytest_runtest_setup(item: Item) -> None: @hookimpl(hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem: Function): - check_xfail_no_run(pyfuncitem) +def pytest_runtest_call(item: Item): + check_xfail_no_run(item) outcome = yield passed = outcome.excinfo is None if passed: - check_strict_xfail(pyfuncitem) + check_strict_xfail(item) def check_xfail_no_run(item: Item) -> None: @@ -120,14 +119,14 @@ def check_xfail_no_run(item: Item) -> None: xfail("[NOTRUN] " + evalxfail.getexplanation()) -def check_strict_xfail(pyfuncitem: Function) -> None: +def check_strict_xfail(item: Item) -> None: """check xfail(strict=True) for the given PASSING test""" - evalxfail = pyfuncitem._store[evalxfail_key] + evalxfail = item._store[evalxfail_key] if evalxfail.istrue(): - strict_default = pyfuncitem.config.getini("xfail_strict") + strict_default = item.config.getini("xfail_strict") is_strict_xfail = evalxfail.get("strict", strict_default) if is_strict_xfail: - del pyfuncitem._store[evalxfail_key] + del item._store[evalxfail_key] explanation = evalxfail.getexplanation() fail("[XPASS(strict)] " + explanation, pytrace=False) From 6072c9950d76a20da5397547932557842b84e078 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 19 Jun 2020 13:33:54 +0300 Subject: [PATCH 117/140] skipping: move MarkEvaluator from _pytest.mark.evaluate to _pytest.skipping This type was actually in `_pytest.skipping` previously, but was moved to `_pytest.mark.evaluate` in cf40c0743c565ed25bc14753e2350e010b39025a. I think the previous location was more appropriate, because the `MarkEvaluator` is not a generic mark facility, it is explicitly and exclusively used by the `skipif` and `xfail` marks to evaluate their particular set of arguments. So it is better to put it in the plugin code. Putting `skipping` related functionality into the core `_pytest.mark` module also causes some import cycles which we can avoid. --- src/_pytest/mark/evaluate.py | 124 --------------------------------- src/_pytest/skipping.py | 131 +++++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 130 deletions(-) delete mode 100644 src/_pytest/mark/evaluate.py diff --git a/src/_pytest/mark/evaluate.py b/src/_pytest/mark/evaluate.py deleted file mode 100644 index eb9903a59..000000000 --- a/src/_pytest/mark/evaluate.py +++ /dev/null @@ -1,124 +0,0 @@ -import os -import platform -import sys -import traceback -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -from ..outcomes import fail -from ..outcomes import TEST_OUTCOME -from .structures import Mark -from _pytest.nodes import Item - - -def compiled_eval(expr: str, d: Dict[str, object]) -> Any: - import _pytest._code - - exprcode = _pytest._code.compile(expr, mode="eval") - return eval(exprcode, d) - - -class MarkEvaluator: - def __init__(self, item: Item, name: str) -> None: - self.item = item - self._marks = None # type: Optional[List[Mark]] - self._mark = None # type: Optional[Mark] - self._mark_name = name - - def __bool__(self) -> bool: - # don't cache here to prevent staleness - return bool(self._get_marks()) - - def wasvalid(self) -> bool: - return not hasattr(self, "exc") - - def _get_marks(self) -> List[Mark]: - return list(self.item.iter_markers(name=self._mark_name)) - - def invalidraise(self, exc) -> Optional[bool]: - raises = self.get("raises") - if not raises: - return None - return not isinstance(exc, raises) - - def istrue(self) -> bool: - try: - return self._istrue() - except TEST_OUTCOME: - self.exc = sys.exc_info() - if isinstance(self.exc[1], SyntaxError): - # TODO: Investigate why SyntaxError.offset is Optional, and if it can be None here. - assert self.exc[1].offset is not None - msg = [" " * (self.exc[1].offset + 4) + "^"] - msg.append("SyntaxError: invalid syntax") - else: - msg = traceback.format_exception_only(*self.exc[:2]) - fail( - "Error evaluating %r expression\n" - " %s\n" - "%s" % (self._mark_name, self.expr, "\n".join(msg)), - pytrace=False, - ) - - def _getglobals(self) -> Dict[str, object]: - d = {"os": os, "sys": sys, "platform": platform, "config": self.item.config} - if hasattr(self.item, "obj"): - d.update(self.item.obj.__globals__) # type: ignore[attr-defined] # noqa: F821 - return d - - def _istrue(self) -> bool: - if hasattr(self, "result"): - result = getattr(self, "result") # type: bool - return result - self._marks = self._get_marks() - - if self._marks: - self.result = False - for mark in self._marks: - self._mark = mark - if "condition" not in mark.kwargs: - args = mark.args - else: - args = (mark.kwargs["condition"],) - - for expr in args: - self.expr = expr - if isinstance(expr, str): - d = self._getglobals() - result = compiled_eval(expr, d) - else: - if "reason" not in mark.kwargs: - # XXX better be checked at collection time - msg = ( - "you need to specify reason=STRING " - "when using booleans as conditions." - ) - fail(msg) - result = bool(expr) - if result: - self.result = True - self.reason = mark.kwargs.get("reason", None) - self.expr = expr - return self.result - - if not args: - self.result = True - self.reason = mark.kwargs.get("reason", None) - return self.result - return False - - def get(self, attr, default=None): - if self._mark is None: - return default - return self._mark.kwargs.get(attr, default) - - def getexplanation(self): - expl = getattr(self, "reason", None) or self.get("reason", None) - if not expl: - if not hasattr(self, "expr"): - return "" - else: - return "condition: " + str(self.expr) - return expl diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 4e4b5a3c4..ee6b40daa 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -1,25 +1,28 @@ """ support for skip/xfail functions and markers. """ +import os +import platform +import sys +import traceback +from typing import Any +from typing import Dict +from typing import List from typing import Optional from typing import Tuple from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser -from _pytest.mark.evaluate import MarkEvaluator +from _pytest.mark.structures import Mark from _pytest.nodes import Item from _pytest.outcomes import fail from _pytest.outcomes import skip +from _pytest.outcomes import TEST_OUTCOME from _pytest.outcomes import xfail from _pytest.reports import BaseReport from _pytest.runner import CallInfo from _pytest.store import StoreKey -skipped_by_mark_key = StoreKey[bool]() -evalxfail_key = StoreKey[MarkEvaluator]() -unexpectedsuccess_key = StoreKey[str]() - - def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( @@ -79,6 +82,122 @@ def pytest_configure(config: Config) -> None: ) +def compiled_eval(expr: str, d: Dict[str, object]) -> Any: + import _pytest._code + + exprcode = _pytest._code.compile(expr, mode="eval") + return eval(exprcode, d) + + +class MarkEvaluator: + def __init__(self, item: Item, name: str) -> None: + self.item = item + self._marks = None # type: Optional[List[Mark]] + self._mark = None # type: Optional[Mark] + self._mark_name = name + + def __bool__(self) -> bool: + # don't cache here to prevent staleness + return bool(self._get_marks()) + + def wasvalid(self) -> bool: + return not hasattr(self, "exc") + + def _get_marks(self) -> List[Mark]: + return list(self.item.iter_markers(name=self._mark_name)) + + def invalidraise(self, exc) -> Optional[bool]: + raises = self.get("raises") + if not raises: + return None + return not isinstance(exc, raises) + + def istrue(self) -> bool: + try: + return self._istrue() + except TEST_OUTCOME: + self.exc = sys.exc_info() + if isinstance(self.exc[1], SyntaxError): + # TODO: Investigate why SyntaxError.offset is Optional, and if it can be None here. + assert self.exc[1].offset is not None + msg = [" " * (self.exc[1].offset + 4) + "^"] + msg.append("SyntaxError: invalid syntax") + else: + msg = traceback.format_exception_only(*self.exc[:2]) + fail( + "Error evaluating %r expression\n" + " %s\n" + "%s" % (self._mark_name, self.expr, "\n".join(msg)), + pytrace=False, + ) + + def _getglobals(self) -> Dict[str, object]: + d = {"os": os, "sys": sys, "platform": platform, "config": self.item.config} + if hasattr(self.item, "obj"): + d.update(self.item.obj.__globals__) # type: ignore[attr-defined] # noqa: F821 + return d + + def _istrue(self) -> bool: + if hasattr(self, "result"): + result = getattr(self, "result") # type: bool + return result + self._marks = self._get_marks() + + if self._marks: + self.result = False + for mark in self._marks: + self._mark = mark + if "condition" not in mark.kwargs: + args = mark.args + else: + args = (mark.kwargs["condition"],) + + for expr in args: + self.expr = expr + if isinstance(expr, str): + d = self._getglobals() + result = compiled_eval(expr, d) + else: + if "reason" not in mark.kwargs: + # XXX better be checked at collection time + msg = ( + "you need to specify reason=STRING " + "when using booleans as conditions." + ) + fail(msg) + result = bool(expr) + if result: + self.result = True + self.reason = mark.kwargs.get("reason", None) + self.expr = expr + return self.result + + if not args: + self.result = True + self.reason = mark.kwargs.get("reason", None) + return self.result + return False + + def get(self, attr, default=None): + if self._mark is None: + return default + return self._mark.kwargs.get(attr, default) + + def getexplanation(self): + expl = getattr(self, "reason", None) or self.get("reason", None) + if not expl: + if not hasattr(self, "expr"): + return "" + else: + return "condition: " + str(self.expr) + return expl + + +skipped_by_mark_key = StoreKey[bool]() +evalxfail_key = StoreKey[MarkEvaluator]() +unexpectedsuccess_key = StoreKey[str]() + + @hookimpl(tryfirst=True) def pytest_runtest_setup(item: Item) -> None: # Check if skip or skipif are specified as pytest marks From dd446bee5eb2d3ab0976309803dc77821eeac93e Mon Sep 17 00:00:00 2001 From: Ram Rachum Date: Fri, 19 Jun 2020 12:53:44 +0300 Subject: [PATCH 118/140] Fix exception causes all over the codebase --- AUTHORS | 1 + changelog/7383.bugfix.rst | 1 + src/_pytest/_code/source.py | 2 +- src/_pytest/config/__init__.py | 8 ++++---- src/_pytest/config/argparsing.py | 4 ++-- src/_pytest/config/findpaths.py | 2 +- src/_pytest/debugging.py | 6 +++--- src/_pytest/fixtures.py | 4 ++-- src/_pytest/logging.py | 4 ++-- src/_pytest/monkeypatch.py | 6 +++--- src/_pytest/python.py | 22 ++++++++++++---------- src/_pytest/warnings.py | 4 ++-- testing/test_config.py | 4 ++-- testing/test_runner.py | 4 ++-- 14 files changed, 38 insertions(+), 34 deletions(-) create mode 100644 changelog/7383.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 821a7d8f4..e40237682 100644 --- a/AUTHORS +++ b/AUTHORS @@ -233,6 +233,7 @@ Pulkit Goyal Punyashloka Biswal Quentin Pradet Ralf Schmitt +Ram Rachum Ralph Giles Ran Benita Raphael Castaneda diff --git a/changelog/7383.bugfix.rst b/changelog/7383.bugfix.rst new file mode 100644 index 000000000..d43106880 --- /dev/null +++ b/changelog/7383.bugfix.rst @@ -0,0 +1 @@ +Fixed exception causes all over the codebase, i.e. use `raise new_exception from old_exception` when wrapping an exception. diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index 3f732792f..2ccbaf657 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -215,7 +215,7 @@ class Source: newex.offset = ex.offset newex.lineno = ex.lineno newex.text = ex.text - raise newex + raise newex from ex else: if flag & ast.PyCF_ONLY_AST: assert isinstance(co, ast.AST) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 31b73a2c9..b4a5a70ad 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1189,8 +1189,8 @@ class Config: def _getini(self, name: str) -> Any: try: description, type, default = self._parser._inidict[name] - except KeyError: - raise ValueError("unknown configuration value: {!r}".format(name)) + except KeyError as e: + raise ValueError("unknown configuration value: {!r}".format(name)) from e override_value = self._get_override_ini_value(name) if override_value is None: try: @@ -1286,14 +1286,14 @@ class Config: if val is None and skip: raise AttributeError(name) return val - except AttributeError: + except AttributeError as e: if default is not notset: return default if skip: import pytest pytest.skip("no {!r} option found".format(name)) - raise ValueError("no option named {!r}".format(name)) + raise ValueError("no option named {!r}".format(name)) from e def getvalue(self, name, path=None): """ (deprecated, use getoption()) """ diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 985a3fd1c..084ce16e5 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -265,9 +265,9 @@ class Argument: else: try: self.dest = self._short_opts[0][1:] - except IndexError: + except IndexError as e: self.dest = "???" # Needed for the error repr. - raise ArgumentError("need a long or short option", self) + raise ArgumentError("need a long or short option", self) from e def names(self) -> List[str]: return self._short_opts + self._long_opts diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index ae8c5f47f..08a71122d 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -26,7 +26,7 @@ def _parse_ini_config(path: py.path.local) -> iniconfig.IniConfig: try: return iniconfig.IniConfig(path) except iniconfig.ParseError as exc: - raise UsageError(str(exc)) + raise UsageError(str(exc)) from exc def load_config_dict_from_file( diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 0567927c0..63126cbe0 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -28,10 +28,10 @@ def _validate_usepdb_cls(value: str) -> Tuple[str, str]: """Validate syntax of --pdbcls option.""" try: modname, classname = value.split(":") - except ValueError: + except ValueError as e: raise argparse.ArgumentTypeError( "{!r} is not in the format 'modname:classname'".format(value) - ) + ) from e return (modname, classname) @@ -130,7 +130,7 @@ class pytestPDB: value = ":".join((modname, classname)) raise UsageError( "--pdbcls: could not import {!r}: {}".format(value, exc) - ) + ) from exc else: import pdb diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 05f0ecb6a..4b2c6a774 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -938,13 +938,13 @@ def _eval_scope_callable( # Type ignored because there is no typing mechanism to specify # keyword arguments, currently. result = scope_callable(fixture_name=fixture_name, config=config) # type: ignore[call-arg] # noqa: F821 - except Exception: + except Exception as e: raise TypeError( "Error evaluating {} while defining fixture '{}'.\n" "Expected a function with the signature (*, fixture_name, config)".format( scope_callable, fixture_name ) - ) + ) from e if not isinstance(result, str): fail( "Expected {} to return a 'str' while defining fixture '{}', but it returned:\n" diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 8755e5611..a06dc1ab5 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -487,13 +487,13 @@ def get_log_level_for_setting(config: Config, *setting_names: str) -> Optional[i log_level = log_level.upper() try: return int(getattr(logging, log_level, log_level)) - except ValueError: + except ValueError as e: # Python logging does not recognise this as a logging level raise pytest.UsageError( "'{}' is not recognized as a logging level name for " "'{}'. Please consider passing the " "logging level num instead.".format(log_level, setting_name) - ) + ) from e # run after terminalreporter/capturemanager are configured diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 09f1ac36e..2e5cca526 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -73,7 +73,7 @@ def resolve(name: str) -> object: if expected == used: raise else: - raise ImportError("import error in {}: {}".format(used, ex)) + raise ImportError("import error in {}: {}".format(used, ex)) from ex found = annotated_getattr(found, part, used) return found @@ -81,12 +81,12 @@ def resolve(name: str) -> object: def annotated_getattr(obj: object, name: str, ann: str) -> object: try: obj = getattr(obj, name) - except AttributeError: + except AttributeError as e: raise AttributeError( "{!r} object at {} has no attribute {!r}".format( type(obj).__name__, ann, name ) - ) + ) from e return obj diff --git a/src/_pytest/python.py b/src/_pytest/python.py index bf45b8830..f3c42f421 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -551,8 +551,10 @@ class Module(nodes.File, PyCollector): importmode = self.config.getoption("--import-mode") try: mod = import_path(self.fspath, mode=importmode) - except SyntaxError: - raise self.CollectError(ExceptionInfo.from_current().getrepr(style="short")) + except SyntaxError as e: + raise self.CollectError( + ExceptionInfo.from_current().getrepr(style="short") + ) from e except ImportPathMismatchError as e: raise self.CollectError( "import file mismatch:\n" @@ -562,8 +564,8 @@ class Module(nodes.File, PyCollector): " %s\n" "HINT: remove __pycache__ / .pyc files and/or use a " "unique basename for your test file modules" % e.args - ) - except ImportError: + ) from e + except ImportError as e: exc_info = ExceptionInfo.from_current() if self.config.getoption("verbose") < 2: exc_info.traceback = exc_info.traceback.filter(filter_traceback) @@ -578,7 +580,7 @@ class Module(nodes.File, PyCollector): "Hint: make sure your test modules/packages have valid Python names.\n" "Traceback:\n" "{traceback}".format(fspath=self.fspath, traceback=formatted_tb) - ) + ) from e except _pytest.runner.Skipped as e: if e.allow_module_level: raise @@ -587,7 +589,7 @@ class Module(nodes.File, PyCollector): "To decorate a test function, use the @pytest.mark.skip " "or @pytest.mark.skipif decorators instead, and to skip a " "module use `pytestmark = pytest.mark.{skip,skipif}." - ) + ) from e self.config.pluginmanager.consider_module(mod) return mod @@ -836,8 +838,8 @@ class CallSpec2: def getparam(self, name: str) -> object: try: return self.params[name] - except KeyError: - raise ValueError(name) + except KeyError as e: + raise ValueError(name) from e @property def id(self) -> str: @@ -1074,8 +1076,8 @@ class Metafunc: except TypeError: try: iter(ids) - except TypeError: - raise TypeError("ids must be a callable or an iterable") + except TypeError as e: + raise TypeError("ids must be a callable or an iterable") from e num_ids = len(parameters) # num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849 diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 5cedba244..f92350b20 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -48,8 +48,8 @@ def _parse_filter( lineno = int(lineno_) if lineno < 0: raise ValueError - except (ValueError, OverflowError): - raise warnings._OptionError("invalid lineno {!r}".format(lineno_)) + except (ValueError, OverflowError) as e: + raise warnings._OptionError("invalid lineno {!r}".format(lineno_)) from e else: lineno = 0 return (action, message, category, module, lineno) diff --git a/testing/test_config.py b/testing/test_config.py index c9eea7a16..4e64a6928 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1778,5 +1778,5 @@ def test_conftest_import_error_repr(tmpdir): ): try: raise RuntimeError("some error") - except Exception: - raise ConftestImportFailure(path, sys.exc_info()) + except Exception as e: + raise ConftestImportFailure(path, sys.exc_info()) from e diff --git a/testing/test_runner.py b/testing/test_runner.py index 9c19ded0e..474ff4df8 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -534,8 +534,8 @@ def test_outcomeexception_passes_except_Exception() -> None: with pytest.raises(outcomes.OutcomeException): try: raise outcomes.OutcomeException("test") - except Exception: - raise NotImplementedError() + except Exception as e: + raise NotImplementedError from e def test_pytest_exit() -> None: From 3e6fe92b7ea3c120e8024a970bf37a7c6c137714 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 19 Jun 2020 13:33:55 +0300 Subject: [PATCH 119/140] skipping: refactor skipif/xfail mark evaluation Previously, skipif/xfail marks were evaluated using a `MarkEvaluator` class. I found this class very difficult to understand. Instead of `MarkEvaluator`, rewrite using straight functions which are hopefully easier to follow. I tried to keep the semantics exactly as before, except improving a few error messages. --- src/_pytest/skipping.py | 344 ++++++++++++++++++++------------------- testing/test_skipping.py | 140 ++++++++-------- 2 files changed, 251 insertions(+), 233 deletions(-) diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index ee6b40daa..894eda499 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -3,12 +3,13 @@ import os import platform import sys import traceback -from typing import Any -from typing import Dict -from typing import List from typing import Optional from typing import Tuple +import attr + +import _pytest._code +from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser @@ -16,12 +17,14 @@ from _pytest.mark.structures import Mark from _pytest.nodes import Item from _pytest.outcomes import fail from _pytest.outcomes import skip -from _pytest.outcomes import TEST_OUTCOME from _pytest.outcomes import xfail from _pytest.reports import BaseReport from _pytest.runner import CallInfo from _pytest.store import StoreKey +if TYPE_CHECKING: + from typing import Type + def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") @@ -64,17 +67,16 @@ def pytest_configure(config: Config) -> None: ) config.addinivalue_line( "markers", - "skipif(condition): skip the given test function if eval(condition) " - "results in a True value. Evaluation happens within the " - "module global context. Example: skipif('sys.platform == \"win32\"') " - "skips the test if we are on the win32 platform. see " - "https://docs.pytest.org/en/latest/skipping.html", + "skipif(condition, ..., *, reason=...): " + "skip the given test function if any of the conditions evaluate to True. " + "Example: skipif(sys.platform == 'win32') skips the test if we are on the win32 platform. " + "see https://docs.pytest.org/en/latest/skipping.html", ) config.addinivalue_line( "markers", - "xfail(condition, reason=None, run=True, raises=None, strict=False): " - "mark the test function as an expected failure if eval(condition) " - "has a True value. Optionally specify a reason for better reporting " + "xfail(condition, ..., *, reason=..., run=True, raises=None, strict=xfail_strict): " + "mark the test function as an expected failure if any of the conditions " + "evaluate to True. Optionally specify a reason for better reporting " "and run=False if you don't even want to execute the test function. " "If only specific exception(s) are expected, you can list them in " "raises, and if the test fails in other ways, it will be reported as " @@ -82,179 +84,191 @@ def pytest_configure(config: Config) -> None: ) -def compiled_eval(expr: str, d: Dict[str, object]) -> Any: - import _pytest._code +def evaluate_condition(item: Item, mark: Mark, condition: object) -> Tuple[bool, str]: + """Evaluate a single skipif/xfail condition. - exprcode = _pytest._code.compile(expr, mode="eval") - return eval(exprcode, d) + If an old-style string condition is given, it is eval()'d, otherwise the + condition is bool()'d. If this fails, an appropriately formatted pytest.fail + is raised. - -class MarkEvaluator: - def __init__(self, item: Item, name: str) -> None: - self.item = item - self._marks = None # type: Optional[List[Mark]] - self._mark = None # type: Optional[Mark] - self._mark_name = name - - def __bool__(self) -> bool: - # don't cache here to prevent staleness - return bool(self._get_marks()) - - def wasvalid(self) -> bool: - return not hasattr(self, "exc") - - def _get_marks(self) -> List[Mark]: - return list(self.item.iter_markers(name=self._mark_name)) - - def invalidraise(self, exc) -> Optional[bool]: - raises = self.get("raises") - if not raises: - return None - return not isinstance(exc, raises) - - def istrue(self) -> bool: + Returns (result, reason). The reason is only relevant if the result is True. + """ + # String condition. + if isinstance(condition, str): + globals_ = { + "os": os, + "sys": sys, + "platform": platform, + "config": item.config, + } + if hasattr(item, "obj"): + globals_.update(item.obj.__globals__) # type: ignore[attr-defined] try: - return self._istrue() - except TEST_OUTCOME: - self.exc = sys.exc_info() - if isinstance(self.exc[1], SyntaxError): - # TODO: Investigate why SyntaxError.offset is Optional, and if it can be None here. - assert self.exc[1].offset is not None - msg = [" " * (self.exc[1].offset + 4) + "^"] - msg.append("SyntaxError: invalid syntax") - else: - msg = traceback.format_exception_only(*self.exc[:2]) - fail( - "Error evaluating %r expression\n" - " %s\n" - "%s" % (self._mark_name, self.expr, "\n".join(msg)), - pytrace=False, + condition_code = _pytest._code.compile(condition, mode="eval") + result = eval(condition_code, globals_) + except SyntaxError as exc: + msglines = [ + "Error evaluating %r condition" % mark.name, + " " + condition, + " " + " " * (exc.offset or 0) + "^", + "SyntaxError: invalid syntax", + ] + fail("\n".join(msglines), pytrace=False) + except Exception as exc: + msglines = [ + "Error evaluating %r condition" % mark.name, + " " + condition, + *traceback.format_exception_only(type(exc), exc), + ] + fail("\n".join(msglines), pytrace=False) + + # Boolean condition. + else: + try: + result = bool(condition) + except Exception as exc: + msglines = [ + "Error evaluating %r condition as a boolean" % mark.name, + *traceback.format_exception_only(type(exc), exc), + ] + fail("\n".join(msglines), pytrace=False) + + reason = mark.kwargs.get("reason", None) + if reason is None: + if isinstance(condition, str): + reason = "condition: " + condition + else: + # XXX better be checked at collection time + msg = ( + "Error evaluating %r: " % mark.name + + "you need to specify reason=STRING when using booleans as conditions." ) + fail(msg, pytrace=False) - def _getglobals(self) -> Dict[str, object]: - d = {"os": os, "sys": sys, "platform": platform, "config": self.item.config} - if hasattr(self.item, "obj"): - d.update(self.item.obj.__globals__) # type: ignore[attr-defined] # noqa: F821 - return d - - def _istrue(self) -> bool: - if hasattr(self, "result"): - result = getattr(self, "result") # type: bool - return result - self._marks = self._get_marks() - - if self._marks: - self.result = False - for mark in self._marks: - self._mark = mark - if "condition" not in mark.kwargs: - args = mark.args - else: - args = (mark.kwargs["condition"],) - - for expr in args: - self.expr = expr - if isinstance(expr, str): - d = self._getglobals() - result = compiled_eval(expr, d) - else: - if "reason" not in mark.kwargs: - # XXX better be checked at collection time - msg = ( - "you need to specify reason=STRING " - "when using booleans as conditions." - ) - fail(msg) - result = bool(expr) - if result: - self.result = True - self.reason = mark.kwargs.get("reason", None) - self.expr = expr - return self.result - - if not args: - self.result = True - self.reason = mark.kwargs.get("reason", None) - return self.result - return False - - def get(self, attr, default=None): - if self._mark is None: - return default - return self._mark.kwargs.get(attr, default) - - def getexplanation(self): - expl = getattr(self, "reason", None) or self.get("reason", None) - if not expl: - if not hasattr(self, "expr"): - return "" - else: - return "condition: " + str(self.expr) - return expl + return result, reason +@attr.s(slots=True, frozen=True) +class Skip: + """The result of evaluate_skip_marks().""" + + reason = attr.ib(type=str) + + +def evaluate_skip_marks(item: Item) -> Optional[Skip]: + """Evaluate skip and skipif marks on item, returning Skip if triggered.""" + for mark in item.iter_markers(name="skipif"): + if "condition" not in mark.kwargs: + conditions = mark.args + else: + conditions = (mark.kwargs["condition"],) + + # Unconditional. + if not conditions: + reason = mark.kwargs.get("reason", "") + return Skip(reason) + + # If any of the conditions are true. + for condition in conditions: + result, reason = evaluate_condition(item, mark, condition) + if result: + return Skip(reason) + + for mark in item.iter_markers(name="skip"): + if "reason" in mark.kwargs: + reason = mark.kwargs["reason"] + elif mark.args: + reason = mark.args[0] + else: + reason = "unconditional skip" + return Skip(reason) + + return None + + +@attr.s(slots=True, frozen=True) +class Xfail: + """The result of evaluate_xfail_marks().""" + + reason = attr.ib(type=str) + run = attr.ib(type=bool) + strict = attr.ib(type=bool) + raises = attr.ib(type=Optional[Tuple["Type[BaseException]", ...]]) + + +def evaluate_xfail_marks(item: Item) -> Optional[Xfail]: + """Evaluate xfail marks on item, returning Xfail if triggered.""" + for mark in item.iter_markers(name="xfail"): + run = mark.kwargs.get("run", True) + strict = mark.kwargs.get("strict", item.config.getini("xfail_strict")) + raises = mark.kwargs.get("raises", None) + if "condition" not in mark.kwargs: + conditions = mark.args + else: + conditions = (mark.kwargs["condition"],) + + # Unconditional. + if not conditions: + reason = mark.kwargs.get("reason", "") + return Xfail(reason, run, strict, raises) + + # If any of the conditions are true. + for condition in conditions: + result, reason = evaluate_condition(item, mark, condition) + if result: + return Xfail(reason, run, strict, raises) + + return None + + +# Whether skipped due to skip or skipif marks. skipped_by_mark_key = StoreKey[bool]() -evalxfail_key = StoreKey[MarkEvaluator]() +# Saves the xfail mark evaluation. Can be refreshed during call if None. +xfailed_key = StoreKey[Optional[Xfail]]() unexpectedsuccess_key = StoreKey[str]() @hookimpl(tryfirst=True) def pytest_runtest_setup(item: Item) -> None: - # Check if skip or skipif are specified as pytest marks item._store[skipped_by_mark_key] = False - eval_skipif = MarkEvaluator(item, "skipif") - if eval_skipif.istrue(): - item._store[skipped_by_mark_key] = True - skip(eval_skipif.getexplanation()) - for skip_info in item.iter_markers(name="skip"): + skipped = evaluate_skip_marks(item) + if skipped: item._store[skipped_by_mark_key] = True - if "reason" in skip_info.kwargs: - skip(skip_info.kwargs["reason"]) - elif skip_info.args: - skip(skip_info.args[0]) - else: - skip("unconditional skip") + skip(skipped.reason) - item._store[evalxfail_key] = MarkEvaluator(item, "xfail") - check_xfail_no_run(item) + if not item.config.option.runxfail: + item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) + if xfailed and not xfailed.run: + xfail("[NOTRUN] " + xfailed.reason) @hookimpl(hookwrapper=True) def pytest_runtest_call(item: Item): - check_xfail_no_run(item) + if not item.config.option.runxfail: + xfailed = item._store.get(xfailed_key, None) + if xfailed is None: + item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) + if xfailed and not xfailed.run: + xfail("[NOTRUN] " + xfailed.reason) + outcome = yield passed = outcome.excinfo is None + if passed: - check_strict_xfail(item) - - -def check_xfail_no_run(item: Item) -> None: - """check xfail(run=False)""" - if not item.config.option.runxfail: - evalxfail = item._store[evalxfail_key] - if evalxfail.istrue(): - if not evalxfail.get("run", True): - xfail("[NOTRUN] " + evalxfail.getexplanation()) - - -def check_strict_xfail(item: Item) -> None: - """check xfail(strict=True) for the given PASSING test""" - evalxfail = item._store[evalxfail_key] - if evalxfail.istrue(): - strict_default = item.config.getini("xfail_strict") - is_strict_xfail = evalxfail.get("strict", strict_default) - if is_strict_xfail: - del item._store[evalxfail_key] - explanation = evalxfail.getexplanation() - fail("[XPASS(strict)] " + explanation, pytrace=False) + xfailed = item._store.get(xfailed_key, None) + if xfailed is None: + item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) + if xfailed and xfailed.strict: + del item._store[xfailed_key] + fail("[XPASS(strict)] " + xfailed.reason, pytrace=False) @hookimpl(hookwrapper=True) def pytest_runtest_makereport(item: Item, call: CallInfo[None]): outcome = yield rep = outcome.get_result() - evalxfail = item._store.get(evalxfail_key, None) + xfailed = item._store.get(xfailed_key, None) # unittest special case, see setting of unexpectedsuccess_key if unexpectedsuccess_key in item._store and rep.when == "call": reason = item._store[unexpectedsuccess_key] @@ -263,30 +277,27 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]): else: rep.longrepr = "Unexpected success" rep.outcome = "failed" - elif item.config.option.runxfail: pass # don't interfere elif call.excinfo and isinstance(call.excinfo.value, xfail.Exception): assert call.excinfo.value.msg is not None rep.wasxfail = "reason: " + call.excinfo.value.msg rep.outcome = "skipped" - elif evalxfail and not rep.skipped and evalxfail.wasvalid() and evalxfail.istrue(): + elif not rep.skipped and xfailed: if call.excinfo: - if evalxfail.invalidraise(call.excinfo.value): + raises = xfailed.raises + if raises is not None and not isinstance(call.excinfo.value, raises): rep.outcome = "failed" else: rep.outcome = "skipped" - rep.wasxfail = evalxfail.getexplanation() + rep.wasxfail = xfailed.reason elif call.when == "call": - strict_default = item.config.getini("xfail_strict") - is_strict_xfail = evalxfail.get("strict", strict_default) - explanation = evalxfail.getexplanation() - if is_strict_xfail: + if xfailed.strict: rep.outcome = "failed" - rep.longrepr = "[XPASS(strict)] {}".format(explanation) + rep.longrepr = "[XPASS(strict)] " + xfailed.reason else: rep.outcome = "passed" - rep.wasxfail = explanation + rep.wasxfail = xfailed.reason elif ( item._store.get(skipped_by_mark_key, True) and rep.skipped @@ -301,9 +312,6 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]): rep.longrepr = str(filename), line + 1, reason -# called by terminalreporter progress reporting - - def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: if hasattr(report, "wasxfail"): if report.skipped: diff --git a/testing/test_skipping.py b/testing/test_skipping.py index a6f1a9c09..0b1c0b49b 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -2,68 +2,74 @@ import sys import pytest from _pytest.runner import runtestprotocol -from _pytest.skipping import MarkEvaluator +from _pytest.skipping import evaluate_skip_marks +from _pytest.skipping import evaluate_xfail_marks from _pytest.skipping import pytest_runtest_setup -class TestEvaluator: +class TestEvaluation: def test_no_marker(self, testdir): item = testdir.getitem("def test_func(): pass") - evalskipif = MarkEvaluator(item, "skipif") - assert not evalskipif - assert not evalskipif.istrue() + skipped = evaluate_skip_marks(item) + assert not skipped - def test_marked_no_args(self, testdir): + def test_marked_xfail_no_args(self, testdir): item = testdir.getitem( """ import pytest - @pytest.mark.xyz + @pytest.mark.xfail def test_func(): pass """ ) - ev = MarkEvaluator(item, "xyz") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "" - assert not ev.get("run", False) + xfailed = evaluate_xfail_marks(item) + assert xfailed + assert xfailed.reason == "" + assert xfailed.run + + def test_marked_skipif_no_args(self, testdir): + item = testdir.getitem( + """ + import pytest + @pytest.mark.skipif + def test_func(): + pass + """ + ) + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "" def test_marked_one_arg(self, testdir): item = testdir.getitem( """ import pytest - @pytest.mark.xyz("hasattr(os, 'sep')") + @pytest.mark.skipif("hasattr(os, 'sep')") def test_func(): pass """ ) - ev = MarkEvaluator(item, "xyz") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: hasattr(os, 'sep')" + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: hasattr(os, 'sep')" def test_marked_one_arg_with_reason(self, testdir): item = testdir.getitem( """ import pytest - @pytest.mark.xyz("hasattr(os, 'sep')", attr=2, reason="hello world") + @pytest.mark.skipif("hasattr(os, 'sep')", attr=2, reason="hello world") def test_func(): pass """ ) - ev = MarkEvaluator(item, "xyz") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "hello world" - assert ev.get("attr") == 2 + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "hello world" def test_marked_one_arg_twice(self, testdir): lines = [ """@pytest.mark.skipif("not hasattr(os, 'murks')")""", - """@pytest.mark.skipif("hasattr(os, 'murks')")""", + """@pytest.mark.skipif(condition="hasattr(os, 'murks')")""", ] for i in range(0, 2): item = testdir.getitem( @@ -76,11 +82,9 @@ class TestEvaluator: """ % (lines[i], lines[(i + 1) % 2]) ) - ev = MarkEvaluator(item, "skipif") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: not hasattr(os, 'murks')" + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: not hasattr(os, 'murks')" def test_marked_one_arg_twice2(self, testdir): item = testdir.getitem( @@ -92,13 +96,11 @@ class TestEvaluator: pass """ ) - ev = MarkEvaluator(item, "skipif") - assert ev - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: not hasattr(os, 'murks')" + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: not hasattr(os, 'murks')" - def test_marked_skip_with_not_string(self, testdir) -> None: + def test_marked_skipif_with_boolean_without_reason(self, testdir) -> None: item = testdir.getitem( """ import pytest @@ -107,14 +109,34 @@ class TestEvaluator: pass """ ) - ev = MarkEvaluator(item, "skipif") - exc = pytest.raises(pytest.fail.Exception, ev.istrue) - assert exc.value.msg is not None + with pytest.raises(pytest.fail.Exception) as excinfo: + evaluate_skip_marks(item) + assert excinfo.value.msg is not None assert ( - """Failed: you need to specify reason=STRING when using booleans as conditions.""" - in exc.value.msg + """Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions.""" + in excinfo.value.msg ) + def test_marked_skipif_with_invalid_boolean(self, testdir) -> None: + item = testdir.getitem( + """ + import pytest + + class InvalidBool: + def __bool__(self): + raise TypeError("INVALID") + + @pytest.mark.skipif(InvalidBool(), reason="xxx") + def test_func(): + pass + """ + ) + with pytest.raises(pytest.fail.Exception) as excinfo: + evaluate_skip_marks(item) + assert excinfo.value.msg is not None + assert "Error evaluating 'skipif' condition as a boolean" in excinfo.value.msg + assert "INVALID" in excinfo.value.msg + def test_skipif_class(self, testdir): (item,) = testdir.getitems( """ @@ -126,10 +148,9 @@ class TestEvaluator: """ ) item.config._hackxyz = 3 - ev = MarkEvaluator(item, "skipif") - assert ev.istrue() - expl = ev.getexplanation() - assert expl == "condition: config._hackxyz" + skipped = evaluate_skip_marks(item) + assert skipped + assert skipped.reason == "condition: config._hackxyz" class TestXFail: @@ -895,10 +916,10 @@ def test_errors_in_xfail_skip_expressions(testdir) -> None: result.stdout.fnmatch_lines( [ "*ERROR*test_nameerror*", - "*evaluating*skipif*expression*", + "*evaluating*skipif*condition*", "*asd*", "*ERROR*test_syntax*", - "*evaluating*xfail*expression*", + "*evaluating*xfail*condition*", " syntax error", markline, "SyntaxError: invalid syntax", @@ -924,25 +945,12 @@ def test_xfail_skipif_with_globals(testdir): result.stdout.fnmatch_lines(["*SKIP*x == 3*", "*XFAIL*test_boolean*", "*x == 3*"]) -def test_direct_gives_error(testdir): - testdir.makepyfile( - """ - import pytest - @pytest.mark.skipif(True) - def test_skip1(): - pass - """ - ) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*1 error*"]) - - def test_default_markers(testdir): result = testdir.runpytest("--markers") result.stdout.fnmatch_lines( [ - "*skipif(*condition)*skip*", - "*xfail(*condition, reason=None, run=True, raises=None, strict=False)*expected failure*", + "*skipif(condition, ..., [*], reason=...)*skip*", + "*xfail(condition, ..., [*], reason=..., run=True, raises=None, strict=xfail_strict)*expected failure*", ] ) @@ -1137,7 +1145,9 @@ def test_mark_xfail_item(testdir): class MyItem(pytest.Item): nodeid = 'foo' def setup(self): - marker = pytest.mark.xfail(True, reason="Expected failure") + marker = pytest.mark.xfail("1 == 2", reason="Expected failure - false") + self.add_marker(marker) + marker = pytest.mark.xfail(True, reason="Expected failure - true") self.add_marker(marker) def runtest(self): assert False From c9737ae914891027da5f0bd39494dd51a3b3f19f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 20 Jun 2020 15:59:29 +0300 Subject: [PATCH 120/140] skipping: simplify xfail handling during call phase There is no need to do the XPASS check here, pytest_runtest_makereport already handled that (the current handling there is dead code). All the hook needs to do is refresh the xfail evaluation if needed, and check the NOTRUN condition again. --- src/_pytest/skipping.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 894eda499..7fc43fce1 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -3,6 +3,7 @@ import os import platform import sys import traceback +from typing import Generator from typing import Optional from typing import Tuple @@ -244,24 +245,16 @@ def pytest_runtest_setup(item: Item) -> None: @hookimpl(hookwrapper=True) -def pytest_runtest_call(item: Item): +def pytest_runtest_call(item: Item) -> Generator[None, None, None]: + xfailed = item._store.get(xfailed_key, None) + if xfailed is None: + item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) + if not item.config.option.runxfail: - xfailed = item._store.get(xfailed_key, None) - if xfailed is None: - item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) if xfailed and not xfailed.run: xfail("[NOTRUN] " + xfailed.reason) - outcome = yield - passed = outcome.excinfo is None - - if passed: - xfailed = item._store.get(xfailed_key, None) - if xfailed is None: - item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item) - if xfailed and xfailed.strict: - del item._store[xfailed_key] - fail("[XPASS(strict)] " + xfailed.reason, pytrace=False) + yield @hookimpl(hookwrapper=True) From 7d8d1b4440028660c81ca242968df89e8c6b896e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 21 Jun 2020 20:14:45 +0300 Subject: [PATCH 121/140] skipping: better links in --markers output Suggested by Bruno. --- src/_pytest/skipping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 7fc43fce1..7bd975e5a 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -71,7 +71,7 @@ def pytest_configure(config: Config) -> None: "skipif(condition, ..., *, reason=...): " "skip the given test function if any of the conditions evaluate to True. " "Example: skipif(sys.platform == 'win32') skips the test if we are on the win32 platform. " - "see https://docs.pytest.org/en/latest/skipping.html", + "See https://docs.pytest.org/en/stable/reference.html#pytest-mark-skipif", ) config.addinivalue_line( "markers", @@ -81,7 +81,7 @@ def pytest_configure(config: Config) -> None: "and run=False if you don't even want to execute the test function. " "If only specific exception(s) are expected, you can list them in " "raises, and if the test fails in other ways, it will be reported as " - "a true failure. See https://docs.pytest.org/en/latest/skipping.html", + "a true failure. See https://docs.pytest.org/en/stable/reference.html#pytest-mark-xfail", ) From b3fb5a2d47743a09c551555da22da27ce9e73f41 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 16 Jun 2020 21:16:04 +0300 Subject: [PATCH 122/140] Type annotate pytest.mark.* builtin marks --- src/_pytest/mark/structures.py | 100 +++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 3d512816c..2d756bb42 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -46,11 +46,19 @@ def get_empty_parameterset_mark( ) -> "MarkDecorator": from ..nodes import Collector + fs, lineno = getfslineno(func) + reason = "got empty parameter set %r, function %s at %s:%d" % ( + argnames, + func.__name__, + fs, + lineno, + ) + requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION) if requested_mark in ("", None, "skip"): - mark = MARK_GEN.skip + mark = MARK_GEN.skip(reason=reason) elif requested_mark == "xfail": - mark = MARK_GEN.xfail(run=False) + mark = MARK_GEN.xfail(reason=reason, run=False) elif requested_mark == "fail_at_collect": f_name = func.__name__ _, lineno = getfslineno(func) @@ -59,14 +67,7 @@ def get_empty_parameterset_mark( ) else: raise LookupError(requested_mark) - fs, lineno = getfslineno(func) - reason = "got empty parameter set %r, function %s at %s:%d" % ( - argnames, - func.__name__, - fs, - lineno, - ) - return mark(reason=reason) + return mark class ParameterSet( @@ -379,6 +380,76 @@ def store_mark(obj, mark: Mark) -> None: obj.pytestmark = get_unpacked_marks(obj) + [mark] +# Typing for builtin pytest marks. This is cheating; it gives builtin marks +# special privilege, and breaks modularity. But practicality beats purity... +if TYPE_CHECKING: + from _pytest.fixtures import _Scope + + class _SkipMarkDecorator(MarkDecorator): + @overload # type: ignore[override,misc] + def __call__(self, arg: _Markable) -> _Markable: + raise NotImplementedError() + + @overload # noqa: F811 + def __call__(self, reason: str = ...) -> "MarkDecorator": # noqa: F811 + raise NotImplementedError() + + class _SkipifMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, + condition: Union[str, bool] = ..., + *conditions: Union[str, bool], + reason: str = ... + ) -> MarkDecorator: + raise NotImplementedError() + + class _XfailMarkDecorator(MarkDecorator): + @overload # type: ignore[override,misc] + def __call__(self, arg: _Markable) -> _Markable: + raise NotImplementedError() + + @overload # noqa: F811 + def __call__( # noqa: F811 + self, + condition: Union[str, bool] = ..., + *conditions: Union[str, bool], + reason: str = ..., + run: bool = ..., + raises: Union[BaseException, Tuple[BaseException, ...]] = ..., + strict: bool = ... + ) -> MarkDecorator: + raise NotImplementedError() + + class _ParametrizeMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, + argnames: Union[str, List[str], Tuple[str, ...]], + argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], + *, + indirect: Union[bool, Sequence[str]] = ..., + ids: Optional[ + Union[ + Iterable[Union[None, str, float, int, bool]], + Callable[[object], Optional[object]], + ] + ] = ..., + scope: Optional[_Scope] = ... + ) -> MarkDecorator: + raise NotImplementedError() + + class _UsefixturesMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, *fixtures: str + ) -> MarkDecorator: + raise NotImplementedError() + + class _FilterwarningsMarkDecorator(MarkDecorator): + def __call__( # type: ignore[override] + self, *filters: str + ) -> MarkDecorator: + raise NotImplementedError() + + class MarkGenerator: """Factory for :class:`MarkDecorator` objects - exposed as a ``pytest.mark`` singleton instance. @@ -397,6 +468,15 @@ class MarkGenerator: _config = None # type: Optional[Config] _markers = set() # type: Set[str] + # See TYPE_CHECKING above. + if TYPE_CHECKING: + skip = None # type: _SkipMarkDecorator + skipif = None # type: _SkipifMarkDecorator + xfail = None # type: _XfailMarkDecorator + parametrize = None # type: _ParametrizeMarkDecorator + usefixtures = None # type: _UsefixturesMarkDecorator + filterwarnings = None # type: _FilterwarningsMarkDecorator + def __getattr__(self, name: str) -> MarkDecorator: if name[0] == "_": raise AttributeError("Marker name must NOT start with underscore") From 4655b7998540d47e6f8dd783c82b37588719556d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 21 Jun 2020 00:34:41 +0300 Subject: [PATCH 123/140] config: improve typing --- src/_pytest/_code/__init__.py | 2 + src/_pytest/_code/code.py | 2 +- src/_pytest/config/__init__.py | 185 ++++++++++++++++++++------------- src/_pytest/helpconfig.py | 6 +- src/_pytest/hookspec.py | 2 +- src/_pytest/logging.py | 12 ++- src/_pytest/pathlib.py | 2 +- src/_pytest/pytester.py | 8 +- src/_pytest/tmpdir.py | 3 +- testing/acceptance_test.py | 4 +- testing/code/test_excinfo.py | 2 +- testing/test_config.py | 6 +- 12 files changed, 143 insertions(+), 91 deletions(-) diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index 38019298c..76963c0eb 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -6,6 +6,7 @@ from .code import Frame from .code import getfslineno from .code import getrawcode from .code import Traceback +from .code import TracebackEntry from .source import compile_ as compile from .source import Source @@ -17,6 +18,7 @@ __all__ = [ "getfslineno", "getrawcode", "Traceback", + "TracebackEntry", "compile", "Source", ] diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 65e5aa6d5..e548bceb7 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -213,7 +213,7 @@ class TracebackEntry: return source.getstatement(self.lineno) @property - def path(self): + def path(self) -> Union[py.path.local, str]: """ path to the source code """ return self.frame.code.path diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index b4a5a70ad..ac7afcd56 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -15,10 +15,13 @@ from typing import Any from typing import Callable from typing import Dict from typing import IO +from typing import Iterable +from typing import Iterator from typing import List from typing import Optional from typing import Sequence from typing import Set +from typing import TextIO from typing import Tuple from typing import Union @@ -42,6 +45,7 @@ from _pytest.compat import TYPE_CHECKING from _pytest.outcomes import fail from _pytest.outcomes import Skipped from _pytest.pathlib import import_path +from _pytest.pathlib import ImportMode from _pytest.pathlib import Path from _pytest.store import Store from _pytest.warning_types import PytestConfigWarning @@ -50,6 +54,7 @@ if TYPE_CHECKING: from typing import Type from _pytest._code.code import _TracebackStyle + from _pytest.terminal import TerminalReporter from .argparsing import Argument @@ -88,18 +93,24 @@ class ExitCode(enum.IntEnum): class ConftestImportFailure(Exception): - def __init__(self, path, excinfo): + def __init__( + self, + path: py.path.local, + excinfo: Tuple["Type[Exception]", Exception, TracebackType], + ) -> None: super().__init__(path, excinfo) self.path = path - self.excinfo = excinfo # type: Tuple[Type[Exception], Exception, TracebackType] + self.excinfo = excinfo - def __str__(self): + def __str__(self) -> str: return "{}: {} (from {})".format( self.excinfo[0].__name__, self.excinfo[1], self.path ) -def filter_traceback_for_conftest_import_failure(entry) -> bool: +def filter_traceback_for_conftest_import_failure( + entry: _pytest._code.TracebackEntry, +) -> bool: """filters tracebacks entries which point to pytest internals or importlib. Make a special case for importlib because we use it to import test modules and conftest files @@ -108,7 +119,10 @@ def filter_traceback_for_conftest_import_failure(entry) -> bool: return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep) -def main(args=None, plugins=None) -> Union[int, ExitCode]: +def main( + args: Optional[List[str]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> Union[int, ExitCode]: """ return exit code, after performing an in-process test run. :arg args: list of command line arguments. @@ -177,7 +191,7 @@ class cmdline: # compatibility namespace main = staticmethod(main) -def filename_arg(path, optname): +def filename_arg(path: str, optname: str) -> str: """ Argparse type validator for filename arguments. :path: path of filename @@ -188,7 +202,7 @@ def filename_arg(path, optname): return path -def directory_arg(path, optname): +def directory_arg(path: str, optname: str) -> str: """Argparse type validator for directory arguments. :path: path of directory @@ -239,13 +253,16 @@ builtin_plugins = set(default_plugins) builtin_plugins.add("pytester") -def get_config(args=None, plugins=None): +def get_config( + args: Optional[List[str]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> "Config": # subsequent calls to main will create a fresh instance pluginmanager = PytestPluginManager() config = Config( pluginmanager, invocation_params=Config.InvocationParams( - args=args or (), plugins=plugins, dir=Path.cwd() + args=args or (), plugins=plugins, dir=Path.cwd(), ), ) @@ -255,10 +272,11 @@ def get_config(args=None, plugins=None): for spec in default_plugins: pluginmanager.import_plugin(spec) + return config -def get_plugin_manager(): +def get_plugin_manager() -> "PytestPluginManager": """ Obtain a new instance of the :py:class:`_pytest.config.PytestPluginManager`, with default plugins @@ -271,8 +289,9 @@ def get_plugin_manager(): def _prepareconfig( - args: Optional[Union[py.path.local, List[str]]] = None, plugins=None -): + args: Optional[Union[py.path.local, List[str]]] = None, + plugins: Optional[Sequence[Union[str, _PluggyPlugin]]] = None, +) -> "Config": if args is None: args = sys.argv[1:] elif isinstance(args, py.path.local): @@ -290,9 +309,10 @@ def _prepareconfig( pluginmanager.consider_pluginarg(plugin) else: pluginmanager.register(plugin) - return pluginmanager.hook.pytest_cmdline_parse( + config = pluginmanager.hook.pytest_cmdline_parse( pluginmanager=pluginmanager, args=args ) + return config except BaseException: config._ensure_unconfigure() raise @@ -313,13 +333,11 @@ class PytestPluginManager(PluginManager): super().__init__("pytest") # The objects are module objects, only used generically. - self._conftest_plugins = set() # type: Set[object] + self._conftest_plugins = set() # type: Set[types.ModuleType] - # state related to local conftest plugins - # Maps a py.path.local to a list of module objects. - self._dirpath2confmods = {} # type: Dict[Any, List[object]] - # Maps a py.path.local to a module object. - self._conftestpath2mod = {} # type: Dict[Any, object] + # State related to local conftest plugins. + self._dirpath2confmods = {} # type: Dict[py.path.local, List[types.ModuleType]] + self._conftestpath2mod = {} # type: Dict[Path, types.ModuleType] self._confcutdir = None # type: Optional[py.path.local] self._noconftest = False self._duplicatepaths = set() # type: Set[py.path.local] @@ -328,7 +346,7 @@ class PytestPluginManager(PluginManager): self.register(self) if os.environ.get("PYTEST_DEBUG"): err = sys.stderr # type: IO[str] - encoding = getattr(err, "encoding", "utf8") + encoding = getattr(err, "encoding", "utf8") # type: str try: err = open( os.dup(err.fileno()), mode=err.mode, buffering=1, encoding=encoding, @@ -343,7 +361,7 @@ class PytestPluginManager(PluginManager): # Used to know when we are importing conftests after the pytest_configure stage self._configured = False - def parse_hookimpl_opts(self, plugin, name): + def parse_hookimpl_opts(self, plugin: _PluggyPlugin, name: str): # pytest hooks are always prefixed with pytest_ # so we avoid accessing possibly non-readable attributes # (see issue #1073) @@ -372,7 +390,7 @@ class PytestPluginManager(PluginManager): opts.setdefault(name, hasattr(method, name) or name in known_marks) return opts - def parse_hookspec_opts(self, module_or_class, name): + def parse_hookspec_opts(self, module_or_class, name: str): opts = super().parse_hookspec_opts(module_or_class, name) if opts is None: method = getattr(module_or_class, name) @@ -389,7 +407,9 @@ class PytestPluginManager(PluginManager): } return opts - def register(self, plugin: _PluggyPlugin, name: Optional[str] = None): + def register( + self, plugin: _PluggyPlugin, name: Optional[str] = None + ) -> Optional[str]: if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS: warnings.warn( PytestConfigWarning( @@ -399,8 +419,8 @@ class PytestPluginManager(PluginManager): ) ) ) - return - ret = super().register(plugin, name) + return None + ret = super().register(plugin, name) # type: Optional[str] if ret: self.hook.pytest_plugin_registered.call_historic( kwargs=dict(plugin=plugin, manager=self) @@ -410,11 +430,12 @@ class PytestPluginManager(PluginManager): self.consider_module(plugin) return ret - def getplugin(self, name): + def getplugin(self, name: str): # support deprecated naming because plugins (xdist e.g.) use it - return self.get_plugin(name) + plugin = self.get_plugin(name) # type: Optional[_PluggyPlugin] + return plugin - def hasplugin(self, name): + def hasplugin(self, name: str) -> bool: """Return True if the plugin with the given name is registered.""" return bool(self.get_plugin(name)) @@ -436,7 +457,7 @@ class PytestPluginManager(PluginManager): # # internal API for local conftest plugin handling # - def _set_initial_conftests(self, namespace): + def _set_initial_conftests(self, namespace: argparse.Namespace) -> None: """ load initial conftest files given a preparsed "namespace". As conftest files may add their own command line options which have arguments ('--my-opt somepath') we might get some @@ -454,8 +475,8 @@ class PytestPluginManager(PluginManager): self._using_pyargs = namespace.pyargs testpaths = namespace.file_or_dir foundanchor = False - for path in testpaths: - path = str(path) + for testpath in testpaths: + path = str(testpath) # remove node-id syntax i = path.find("::") if i != -1: @@ -467,7 +488,9 @@ class PytestPluginManager(PluginManager): if not foundanchor: self._try_load_conftest(current, namespace.importmode) - def _try_load_conftest(self, anchor, importmode): + def _try_load_conftest( + self, anchor: py.path.local, importmode: Union[str, ImportMode] + ) -> None: self._getconftestmodules(anchor, importmode) # let's also consider test* subdirs if anchor.check(dir=1): @@ -476,7 +499,9 @@ class PytestPluginManager(PluginManager): self._getconftestmodules(x, importmode) @lru_cache(maxsize=128) - def _getconftestmodules(self, path, importmode): + def _getconftestmodules( + self, path: py.path.local, importmode: Union[str, ImportMode], + ) -> List[types.ModuleType]: if self._noconftest: return [] @@ -499,7 +524,9 @@ class PytestPluginManager(PluginManager): self._dirpath2confmods[directory] = clist return clist - def _rget_with_confmod(self, name, path, importmode): + def _rget_with_confmod( + self, name: str, path: py.path.local, importmode: Union[str, ImportMode], + ) -> Tuple[types.ModuleType, Any]: modules = self._getconftestmodules(path, importmode) for mod in reversed(modules): try: @@ -508,7 +535,9 @@ class PytestPluginManager(PluginManager): continue raise KeyError(name) - def _importconftest(self, conftestpath, importmode): + def _importconftest( + self, conftestpath: py.path.local, importmode: Union[str, ImportMode], + ) -> types.ModuleType: # Use a resolved Path object as key to avoid loading the same conftest twice # with build systems that create build directories containing # symlinks to actual files. @@ -526,7 +555,9 @@ class PytestPluginManager(PluginManager): try: mod = import_path(conftestpath, mode=importmode) except Exception as e: - raise ConftestImportFailure(conftestpath, sys.exc_info()) from e + assert e.__traceback__ is not None + exc_info = (type(e), e, e.__traceback__) + raise ConftestImportFailure(conftestpath, exc_info) from e self._check_non_top_pytest_plugins(mod, conftestpath) @@ -542,7 +573,9 @@ class PytestPluginManager(PluginManager): self.consider_conftest(mod) return mod - def _check_non_top_pytest_plugins(self, mod, conftestpath): + def _check_non_top_pytest_plugins( + self, mod: types.ModuleType, conftestpath: py.path.local, + ) -> None: if ( hasattr(mod, "pytest_plugins") and self._configured @@ -564,7 +597,9 @@ class PytestPluginManager(PluginManager): # # - def consider_preparse(self, args, *, exclude_only: bool = False) -> None: + def consider_preparse( + self, args: Sequence[str], *, exclude_only: bool = False + ) -> None: i = 0 n = len(args) while i < n: @@ -585,7 +620,7 @@ class PytestPluginManager(PluginManager): continue self.consider_pluginarg(parg) - def consider_pluginarg(self, arg) -> None: + def consider_pluginarg(self, arg: str) -> None: if arg.startswith("no:"): name = arg[3:] if name in essential_plugins: @@ -610,7 +645,7 @@ class PytestPluginManager(PluginManager): del self._name2plugin["pytest_" + name] self.import_plugin(arg, consider_entry_points=True) - def consider_conftest(self, conftestmodule) -> None: + def consider_conftest(self, conftestmodule: types.ModuleType) -> None: self.register(conftestmodule, name=conftestmodule.__file__) def consider_env(self) -> None: @@ -619,7 +654,7 @@ class PytestPluginManager(PluginManager): def consider_module(self, mod: types.ModuleType) -> None: self._import_plugin_specs(getattr(mod, "pytest_plugins", [])) - def _import_plugin_specs(self, spec): + def _import_plugin_specs(self, spec) -> None: plugins = _get_plugin_specs_as_list(spec) for import_spec in plugins: self.import_plugin(import_spec) @@ -636,7 +671,6 @@ class PytestPluginManager(PluginManager): assert isinstance(modname, str), ( "module name as text required, got %r" % modname ) - modname = str(modname) if self.is_blocked(modname) or self.get_plugin(modname) is not None: return @@ -668,7 +702,7 @@ class PytestPluginManager(PluginManager): self.register(mod, modname) -def _get_plugin_specs_as_list(specs): +def _get_plugin_specs_as_list(specs) -> List[str]: """ Parses a list of "plugin specs" and returns a list of plugin names. @@ -688,7 +722,7 @@ def _get_plugin_specs_as_list(specs): return [] -def _ensure_removed_sysmodule(modname): +def _ensure_removed_sysmodule(modname: str) -> None: try: del sys.modules[modname] except KeyError: @@ -703,7 +737,7 @@ class Notset: notset = Notset() -def _iter_rewritable_modules(package_files): +def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]: """ Given an iterable of file names in a source distribution, return the "names" that should be marked for assertion rewrite (for example the package "pytest_mock/__init__.py" should @@ -766,6 +800,10 @@ def _iter_rewritable_modules(package_files): yield from _iter_rewritable_modules(new_package_files) +def _args_converter(args: Iterable[str]) -> Tuple[str, ...]: + return tuple(args) + + class Config: """ Access to configuration values, pluginmanager and plugin hooks. @@ -793,9 +831,9 @@ class Config: Plugins accessing ``InvocationParams`` must be aware of that. """ - args = attr.ib(converter=tuple) + args = attr.ib(type=Tuple[str, ...], converter=_args_converter) """tuple of command-line arguments as passed to ``pytest.main()``.""" - plugins = attr.ib() + plugins = attr.ib(type=Optional[Sequence[Union[str, _PluggyPlugin]]]) """list of extra plugins, might be `None`.""" dir = attr.ib(type=Path) """directory where ``pytest.main()`` was invoked from.""" @@ -855,7 +893,7 @@ class Config: """Backward compatibility""" return py.path.local(str(self.invocation_params.dir)) - def add_cleanup(self, func) -> None: + def add_cleanup(self, func: Callable[[], None]) -> None: """ Add a function to be called when the config object gets out of use (usually coninciding with pytest_unconfigure).""" self._cleanup.append(func) @@ -876,12 +914,15 @@ class Config: fin = self._cleanup.pop() fin() - def get_terminal_writer(self): - return self.pluginmanager.get_plugin("terminalreporter")._tw + def get_terminal_writer(self) -> TerminalWriter: + terminalreporter = self.pluginmanager.get_plugin( + "terminalreporter" + ) # type: TerminalReporter + return terminalreporter._tw def pytest_cmdline_parse( self, pluginmanager: PytestPluginManager, args: List[str] - ) -> object: + ) -> "Config": try: self.parse(args) except UsageError: @@ -923,7 +964,7 @@ class Config: sys.stderr.write("INTERNALERROR> %s\n" % line) sys.stderr.flush() - def cwd_relative_nodeid(self, nodeid): + def cwd_relative_nodeid(self, nodeid: str) -> str: # nodeid's are relative to the rootpath, compute relative to cwd if self.invocation_dir != self.rootdir: fullpath = self.rootdir.join(nodeid) @@ -931,7 +972,7 @@ class Config: return nodeid @classmethod - def fromdictargs(cls, option_dict, args): + def fromdictargs(cls, option_dict, args) -> "Config": """ constructor usable for subprocesses. """ config = get_config(args) config.option.__dict__.update(option_dict) @@ -949,7 +990,7 @@ class Config: setattr(self.option, opt.dest, opt.default) @hookimpl(trylast=True) - def pytest_load_initial_conftests(self, early_config): + def pytest_load_initial_conftests(self, early_config: "Config") -> None: self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) def _initini(self, args: Sequence[str]) -> None: @@ -1078,7 +1119,7 @@ class Config: raise self._validate_keys() - def _checkversion(self): + def _checkversion(self) -> None: import pytest minver = self.inicfg.get("minversion", None) @@ -1167,7 +1208,7 @@ class Config: except PrintHelp: pass - def addinivalue_line(self, name, line): + def addinivalue_line(self, name: str, line: str) -> None: """ add a line to an ini-file option. The option must have been declared but might not yet be set in which case the line becomes the the first line in its value. """ @@ -1186,7 +1227,7 @@ class Config: self._inicache[name] = val = self._getini(name) return val - def _getini(self, name: str) -> Any: + def _getini(self, name: str): try: description, type, default = self._parser._inidict[name] except KeyError as e: @@ -1231,12 +1272,14 @@ class Config: else: return value elif type == "bool": - return bool(_strtobool(str(value).strip())) + return _strtobool(str(value).strip()) else: assert type is None return value - def _getconftest_pathlist(self, name, path): + def _getconftest_pathlist( + self, name: str, path: py.path.local + ) -> Optional[List[py.path.local]]: try: mod, relroots = self.pluginmanager._rget_with_confmod( name, path, self.getoption("importmode") @@ -1244,7 +1287,7 @@ class Config: except KeyError: return None modpath = py.path.local(mod.__file__).dirpath() - values = [] + values = [] # type: List[py.path.local] for relroot in relroots: if not isinstance(relroot, py.path.local): relroot = relroot.replace("/", py.path.local.sep) @@ -1295,16 +1338,16 @@ class Config: pytest.skip("no {!r} option found".format(name)) raise ValueError("no option named {!r}".format(name)) from e - def getvalue(self, name, path=None): + def getvalue(self, name: str, path=None): """ (deprecated, use getoption()) """ return self.getoption(name) - def getvalueorskip(self, name, path=None): + def getvalueorskip(self, name: str, path=None): """ (deprecated, use getoption(skip=True)) """ return self.getoption(name, skip=True) -def _assertion_supported(): +def _assertion_supported() -> bool: try: assert False except AssertionError: @@ -1313,7 +1356,7 @@ def _assertion_supported(): return False -def _warn_about_missing_assertion(mode): +def _warn_about_missing_assertion(mode) -> None: if not _assertion_supported(): if mode == "plain": sys.stderr.write( @@ -1331,12 +1374,14 @@ def _warn_about_missing_assertion(mode): ) -def create_terminal_writer(config: Config, *args, **kwargs) -> TerminalWriter: +def create_terminal_writer( + config: Config, file: Optional[TextIO] = None +) -> TerminalWriter: """Create a TerminalWriter instance configured according to the options in the config object. Every code which requires a TerminalWriter object and has access to a config object should use this function. """ - tw = TerminalWriter(*args, **kwargs) + tw = TerminalWriter(file=file) if config.option.color == "yes": tw.hasmarkup = True if config.option.color == "no": @@ -1344,8 +1389,8 @@ def create_terminal_writer(config: Config, *args, **kwargs) -> TerminalWriter: return tw -def _strtobool(val): - """Convert a string representation of truth to true (1) or false (0). +def _strtobool(val: str) -> bool: + """Convert a string representation of truth to True or False. True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if @@ -1355,8 +1400,8 @@ def _strtobool(val): """ val = val.lower() if val in ("y", "yes", "t", "true", "on", "1"): - return 1 + return True elif val in ("n", "no", "f", "false", "off", "0"): - return 0 + return False else: raise ValueError("invalid truth value {!r}".format(val)) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 06e0954cf..24952852b 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -96,7 +96,7 @@ def pytest_addoption(parser: Parser) -> None: @pytest.hookimpl(hookwrapper=True) def pytest_cmdline_parse(): outcome = yield - config = outcome.get_result() + config = outcome.get_result() # type: Config if config.option.debug: path = os.path.abspath("pytestdebug.log") debugfile = open(path, "w") @@ -124,7 +124,7 @@ def pytest_cmdline_parse(): config.add_cleanup(unset_tracing) -def showversion(config): +def showversion(config: Config) -> None: if config.option.version > 1: sys.stderr.write( "This is pytest version {}, imported from {}\n".format( @@ -224,7 +224,7 @@ def showhelp(config: Config) -> None: conftest_options = [("pytest_plugins", "list of plugin names to load")] -def getpluginversioninfo(config): +def getpluginversioninfo(config: Config) -> List[str]: lines = [] plugininfo = config.pluginmanager.list_plugin_distinfo() if plugininfo: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index eba6f5ba9..c05b60791 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -143,7 +143,7 @@ def pytest_configure(config: "Config") -> None: @hookspec(firstresult=True) def pytest_cmdline_parse( pluginmanager: "PytestPluginManager", args: List[str] -) -> Optional[object]: +) -> Optional["Config"]: """return initialized config object, parsing the specified args. Stops at first non-None result, see :ref:`firstresult` diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index a06dc1ab5..57aa14f27 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -141,9 +141,14 @@ class PercentStyleMultiline(logging.PercentStyle): if auto_indent_option is None: return 0 - elif type(auto_indent_option) is int: + elif isinstance(auto_indent_option, bool): + if auto_indent_option: + return -1 + else: + return 0 + elif isinstance(auto_indent_option, int): return int(auto_indent_option) - elif type(auto_indent_option) is str: + elif isinstance(auto_indent_option, str): try: return int(auto_indent_option) except ValueError: @@ -153,9 +158,6 @@ class PercentStyleMultiline(logging.PercentStyle): return -1 except ValueError: return 0 - elif type(auto_indent_option) is bool: - if auto_indent_option: - return -1 return 0 diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 66ae9a51d..dd7443f07 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -466,7 +466,7 @@ def import_path( """ mode = ImportMode(mode) - path = Path(p) + path = Path(str(p)) if not path.exists(): raise ImportError(path) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index cf3dbd201..fd4c10577 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1054,7 +1054,7 @@ class Testdir: args.append("--basetemp=%s" % self.tmpdir.dirpath("basetemp")) return args - def parseconfig(self, *args: Union[str, py.path.local]) -> Config: + def parseconfig(self, *args) -> Config: """Return a new pytest Config instance from given commandline args. This invokes the pytest bootstrapping code in _pytest.config to create @@ -1070,14 +1070,14 @@ class Testdir: import _pytest.config - config = _pytest.config._prepareconfig(args, self.plugins) # type: Config + config = _pytest.config._prepareconfig(args, self.plugins) # type: ignore[arg-type] # we don't know what the test will do with this half-setup config # object and thus we make sure it gets unconfigured properly in any # case (otherwise capturing could still be active, for example) self.request.addfinalizer(config._ensure_unconfigure) return config - def parseconfigure(self, *args): + def parseconfigure(self, *args) -> Config: """Return a new pytest configured Config instance. This returns a new :py:class:`_pytest.config.Config` instance like @@ -1318,7 +1318,7 @@ class Testdir: Returns a :py:class:`RunResult`. """ __tracebackhide__ = True - p = make_numbered_dir(root=Path(self.tmpdir), prefix="runpytest-") + p = make_numbered_dir(root=Path(str(self.tmpdir)), prefix="runpytest-") args = ("--basetemp=%s" % p,) + args plugins = [x for x in self.plugins if isinstance(x, str)] if plugins: diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 199c7c937..f6d1799ad 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -13,6 +13,7 @@ from .pathlib import LOCK_TIMEOUT from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup from .pathlib import Path +from _pytest.config import Config from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch @@ -135,7 +136,7 @@ def get_user() -> Optional[str]: return None -def pytest_configure(config) -> None: +def pytest_configure(config: Config) -> None: """Create a TempdirFactory and attach it to the config object. This is to comply with existing plugins which expect the handler to be diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index d8f7a501a..66c2bf0bf 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -585,11 +585,11 @@ class TestInvocationVariants: # Type ignored because `py.test` is not and will not be typed. assert pytest.main == py.test.cmdline.main # type: ignore[attr-defined] - def test_invoke_with_invalid_type(self): + def test_invoke_with_invalid_type(self) -> None: with pytest.raises( TypeError, match="expected to be a list of strings, got: '-h'" ): - pytest.main("-h") + pytest.main("-h") # type: ignore[arg-type] def test_invoke_with_path(self, tmpdir, capsys): retcode = pytest.main(tmpdir) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 0ff00bcaa..75c937612 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -372,7 +372,7 @@ def test_excinfo_no_python_sourcecode(tmpdir): for item in excinfo.traceback: print(item) # XXX: for some reason jinja.Template.render is printed in full item.source # shouldn't fail - if item.path.basename == "test.txt": + if isinstance(item.path, py.path.local) and item.path.basename == "test.txt": assert str(item.source) == "{{ h()}}:" diff --git a/testing/test_config.py b/testing/test_config.py index 4e64a6928..c1e4471b9 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1778,5 +1778,7 @@ def test_conftest_import_error_repr(tmpdir): ): try: raise RuntimeError("some error") - except Exception as e: - raise ConftestImportFailure(path, sys.exc_info()) from e + except Exception as exc: + assert exc.__traceback__ is not None + exc_info = (type(exc), exc, exc.__traceback__) + raise ConftestImportFailure(path, exc_info) from exc From 04a6d378234e3c72055c7e90084b1a2d36d3f89d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 22 Jun 2020 15:07:50 +0300 Subject: [PATCH 124/140] nodes: fix string possibly stored in Node.keywords instead of MarkDecorator This mistake was introduced in 7259c453d6c1dba6727cd328e6db5635ccf5821c. --- src/_pytest/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 4c7aa1bcd..24e466586 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -276,7 +276,7 @@ class Node(metaclass=NodeMeta): marker_ = getattr(MARK_GEN, marker) else: raise ValueError("is not a string or pytest.mark.* Marker") - self.keywords[marker_.name] = marker + self.keywords[marker_.name] = marker_ if append: self.own_markers.append(marker_.mark) else: From 8994e1e3a17bd625e0c258d0a402062542908fe3 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 23 Jun 2020 11:38:21 +0300 Subject: [PATCH 125/140] config: make _get_plugin_specs_as_list a little clearer and more general --- src/_pytest/config/__init__.py | 41 +++++++++++++++++++--------------- testing/test_config.py | 17 ++++++-------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index ac7afcd56..717743e79 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1,5 +1,6 @@ """ command line options, ini-file and conftest.py processing. """ import argparse +import collections.abc import contextlib import copy import enum @@ -654,7 +655,9 @@ class PytestPluginManager(PluginManager): def consider_module(self, mod: types.ModuleType) -> None: self._import_plugin_specs(getattr(mod, "pytest_plugins", [])) - def _import_plugin_specs(self, spec) -> None: + def _import_plugin_specs( + self, spec: Union[None, types.ModuleType, str, Sequence[str]] + ) -> None: plugins = _get_plugin_specs_as_list(spec) for import_spec in plugins: self.import_plugin(import_spec) @@ -702,24 +705,26 @@ class PytestPluginManager(PluginManager): self.register(mod, modname) -def _get_plugin_specs_as_list(specs) -> List[str]: - """ - Parses a list of "plugin specs" and returns a list of plugin names. - - Plugin specs can be given as a list of strings separated by "," or already as a list/tuple in - which case it is returned as a list. Specs can also be `None` in which case an - empty list is returned. - """ - if specs is not None and not isinstance(specs, types.ModuleType): - if isinstance(specs, str): - specs = specs.split(",") if specs else [] - if not isinstance(specs, (list, tuple)): - raise UsageError( - "Plugin specs must be a ','-separated string or a " - "list/tuple of strings for plugin names. Given: %r" % specs - ) +def _get_plugin_specs_as_list( + specs: Union[None, types.ModuleType, str, Sequence[str]] +) -> List[str]: + """Parse a plugins specification into a list of plugin names.""" + # None means empty. + if specs is None: + return [] + # Workaround for #3899 - a submodule which happens to be called "pytest_plugins". + if isinstance(specs, types.ModuleType): + return [] + # Comma-separated list. + if isinstance(specs, str): + return specs.split(",") if specs else [] + # Direct specification. + if isinstance(specs, collections.abc.Sequence): return list(specs) - return [] + raise UsageError( + "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %r" + % specs + ) def _ensure_removed_sysmodule(modname: str) -> None: diff --git a/testing/test_config.py b/testing/test_config.py index c1e4471b9..bc0da93a5 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -11,6 +11,7 @@ import py.path import _pytest._code import pytest from _pytest.compat import importlib_metadata +from _pytest.config import _get_plugin_specs_as_list from _pytest.config import _iter_rewritable_modules from _pytest.config import Config from _pytest.config import ConftestImportFailure @@ -1115,21 +1116,17 @@ def test_load_initial_conftest_last_ordering(_config_for_test): assert [x.function.__module__ for x in values] == expected -def test_get_plugin_specs_as_list(): - from _pytest.config import _get_plugin_specs_as_list - - def exp_match(val): +def test_get_plugin_specs_as_list() -> None: + def exp_match(val: object) -> str: return ( - "Plugin specs must be a ','-separated string" - " or a list/tuple of strings for plugin names. Given: {}".format( - re.escape(repr(val)) - ) + "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %s" + % re.escape(repr(val)) ) with pytest.raises(pytest.UsageError, match=exp_match({"foo"})): - _get_plugin_specs_as_list({"foo"}) + _get_plugin_specs_as_list({"foo"}) # type: ignore[arg-type] with pytest.raises(pytest.UsageError, match=exp_match({})): - _get_plugin_specs_as_list(dict()) + _get_plugin_specs_as_list(dict()) # type: ignore[arg-type] assert _get_plugin_specs_as_list(None) == [] assert _get_plugin_specs_as_list("") == [] From 617bf8be5b0d5fa59dfb72a27c66f4f5f54f7e26 Mon Sep 17 00:00:00 2001 From: David Diaz Barquero Date: Tue, 23 Jun 2020 10:03:46 -0600 Subject: [PATCH 126/140] Add details to error message for junit (#7390) Co-authored-by: Bruno Oliveira --- changelog/7385.improvement.rst | 13 +++++++++++++ src/_pytest/junitxml.py | 12 +++++++++--- testing/test_junitxml.py | 12 +++++++----- 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 changelog/7385.improvement.rst diff --git a/changelog/7385.improvement.rst b/changelog/7385.improvement.rst new file mode 100644 index 000000000..c02fee5da --- /dev/null +++ b/changelog/7385.improvement.rst @@ -0,0 +1,13 @@ +``--junitxml`` now includes the exception cause in the ``message`` XML attribute for failures during setup and teardown. + +Previously: + +.. code-block:: xml + + + +Now: + +.. code-block:: xml + + diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 86e8fcf38..4df7535de 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -236,10 +236,16 @@ class _NodeReporter: self._add_simple(Junit.skipped, "collection skipped", report.longrepr) def append_error(self, report: TestReport) -> None: - if report.when == "teardown": - msg = "test teardown failure" + assert report.longrepr is not None + if getattr(report.longrepr, "reprcrash", None) is not None: + reason = report.longrepr.reprcrash.message else: - msg = "test setup failure" + reason = str(report.longrepr) + + if report.when == "teardown": + msg = 'failed on teardown with "{}"'.format(reason) + else: + msg = 'failed on setup with "{}"'.format(reason) self._add_simple(Junit.error, msg, report.longrepr) def append_skipped(self, report: TestReport) -> None: diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index f8a6a295f..5e5826b23 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -266,7 +266,7 @@ class TestPython: @pytest.fixture def arg(request): - raise ValueError() + raise ValueError("Error reason") def test_function(arg): pass """ @@ -278,7 +278,7 @@ class TestPython: tnode = node.find_first_by_tag("testcase") tnode.assert_attr(classname="test_setup_error", name="test_function") fnode = tnode.find_first_by_tag("error") - fnode.assert_attr(message="test setup failure") + fnode.assert_attr(message='failed on setup with "ValueError: Error reason"') assert "ValueError" in fnode.toxml() @parametrize_families @@ -290,7 +290,7 @@ class TestPython: @pytest.fixture def arg(): yield - raise ValueError() + raise ValueError('Error reason') def test_function(arg): pass """ @@ -301,7 +301,7 @@ class TestPython: tnode = node.find_first_by_tag("testcase") tnode.assert_attr(classname="test_teardown_error", name="test_function") fnode = tnode.find_first_by_tag("error") - fnode.assert_attr(message="test teardown failure") + fnode.assert_attr(message='failed on teardown with "ValueError: Error reason"') assert "ValueError" in fnode.toxml() @parametrize_families @@ -328,7 +328,9 @@ class TestPython: fnode = first.find_first_by_tag("failure") fnode.assert_attr(message="Exception: Call Exception") snode = second.find_first_by_tag("error") - snode.assert_attr(message="test teardown failure") + snode.assert_attr( + message='failed on teardown with "Exception: Teardown Exception"' + ) @parametrize_families def test_skip_contains_name_reason(self, testdir, run_and_parse, xunit_family): From 6cbbd2d90b6c3cd964df214bbcd7212b3450e74d Mon Sep 17 00:00:00 2001 From: Daniel <61800298+ffe4@users.noreply.github.com> Date: Tue, 23 Jun 2020 22:38:11 +0200 Subject: [PATCH 127/140] Fix typo in examples/markers.rst --- 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 e791f489d..1fd10101c 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -639,7 +639,7 @@ Automatically adding markers based on test names .. regendoc:wipe -If you a test suite where test function names indicate a certain +If you have a test suite where test function names indicate a certain type of test, you can implement a hook that automatically defines markers so that you can use the ``-m`` option with it. Let's look at this test module: From 474973afa401ea8bf177d89025022d5ea3801c4d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 24 Jun 2020 15:42:07 +0300 Subject: [PATCH 128/140] CONTRIBUTING: sync changelog types The got out of date with the actual ones we use. --- CONTRIBUTING.rst | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5e309a317..9ff854ffa 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -173,8 +173,10 @@ Short version The test environments above are usually enough to cover most cases locally. #. Write a ``changelog`` entry: ``changelog/2574.bugfix.rst``, use issue id number - and one of ``bugfix``, ``removal``, ``feature``, ``vendor``, ``doc`` or - ``trivial`` for the issue type. + and one of ``feature``, ``improvement``, ``bugfix``, ``doc``, ``deprecation``, + ``breaking``, ``vendor`` or ``trivial`` for the issue type. + + #. Unless your change is a trivial or a documentation fix (e.g., a typo or reword of a small section) please add yourself to the ``AUTHORS`` file, in alphabetical order. @@ -274,8 +276,9 @@ Here is a simple overview, with pytest-specific bits: #. Create a new changelog entry in ``changelog``. The file should be named ``..rst``, where *issueid* is the number of the issue related to the change and *type* is one of - ``bugfix``, ``removal``, ``feature``, ``vendor``, ``doc`` or ``trivial``. You may not create a - changelog entry if the change doesn't affect the documented behaviour of Pytest. + ``feature``, ``improvement``, ``bugfix``, ``doc``, ``deprecation``, ``breaking``, ``vendor`` + or ``trivial``. You may skip creating the changelog entry if the change doesn't affect the + documented behaviour of pytest. #. Add yourself to ``AUTHORS`` file if not there yet, in alphabetical order. From f00bec2a12a585eee245284c8eac86edc27e661f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 14:05:46 +0300 Subject: [PATCH 129/140] Replace yield_fixture -> fixture in internal code `yield_fixture` is a deprecated alias to `fixture`. --- src/_pytest/fixtures.py | 4 +--- src/_pytest/recwarn.py | 4 ++-- testing/example_scripts/issue_519.py | 4 ++-- testing/python/fixtures.py | 5 ++--- testing/test_doctest.py | 2 +- testing/test_pathlib.py | 2 +- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 4b2c6a774..9423df7e4 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -924,9 +924,7 @@ def _teardown_yield_fixture(fixturefunc, it) -> None: except StopIteration: pass else: - fail_fixturefunc( - fixturefunc, "yield_fixture function has more than one 'yield'" - ) + fail_fixturefunc(fixturefunc, "fixture function has more than one 'yield'") def _eval_scope_callable( diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 57034be2a..eed79c3fd 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -13,14 +13,14 @@ from typing import Union from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING -from _pytest.fixtures import yield_fixture +from _pytest.fixtures import fixture from _pytest.outcomes import fail if TYPE_CHECKING: from typing import Type -@yield_fixture +@fixture def recwarn(): """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. diff --git a/testing/example_scripts/issue_519.py b/testing/example_scripts/issue_519.py index 52d5d3f55..021dada49 100644 --- a/testing/example_scripts/issue_519.py +++ b/testing/example_scripts/issue_519.py @@ -33,13 +33,13 @@ def checked_order(): ] -@pytest.yield_fixture(scope="module") +@pytest.fixture(scope="module") def fix1(request, arg1, checked_order): checked_order.append((request.node.name, "fix1", arg1)) yield "fix1-" + arg1 -@pytest.yield_fixture(scope="function") +@pytest.fixture(scope="function") def fix2(request, fix1, arg2, checked_order): checked_order.append((request.node.name, "fix2", arg2)) yield "fix2-" + arg2 + fix1 diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index e14385144..3efbbe107 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1315,7 +1315,7 @@ class TestFixtureUsages: DB_INITIALIZED = None - @pytest.yield_fixture(scope="session", autouse=True) + @pytest.fixture(scope="session", autouse=True) def db(): global DB_INITIALIZED DB_INITIALIZED = True @@ -2960,8 +2960,7 @@ class TestFixtureMarker: """ import pytest - @pytest.yield_fixture(params=[object(), object()], - ids=['alpha', 'beta']) + @pytest.fixture(params=[object(), object()], ids=['alpha', 'beta']) def fix(request): yield request.param diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 2b98b5267..9ef9417cd 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1176,7 +1176,7 @@ class TestDoctestAutoUseFixtures: import pytest import sys - @pytest.yield_fixture(autouse=True, scope='session') + @pytest.fixture(autouse=True, scope='session') def myfixture(): assert not hasattr(sys, 'pytest_session_data') sys.pytest_session_data = 1 diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 126e1718e..d9d3894f9 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -91,7 +91,7 @@ class TestImportPath: Having our own pyimport-like function is inline with removing py.path dependency in the future. """ - @pytest.yield_fixture(scope="session") + @pytest.fixture(scope="session") def path1(self, tmpdir_factory): path = tmpdir_factory.mktemp("path") self.setuptestfs(path) From 4d813fdf5e258c634a428d5a8b14e3f4364f4bc1 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 14:08:47 +0300 Subject: [PATCH 130/140] recwarn: improve return type annotation of non-contextmanager pytest.warns It returns the return value of the function. --- src/_pytest/recwarn.py | 11 +++++++---- testing/test_recwarn.py | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index eed79c3fd..13622e95d 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -9,6 +9,7 @@ from typing import List from typing import Optional from typing import Pattern from typing import Tuple +from typing import TypeVar from typing import Union from _pytest.compat import overload @@ -20,6 +21,9 @@ if TYPE_CHECKING: from typing import Type +T = TypeVar("T") + + @fixture def recwarn(): """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. @@ -67,11 +71,10 @@ def warns( @overload # noqa: F811 def warns( # noqa: F811 expected_warning: Optional[Union["Type[Warning]", Tuple["Type[Warning]", ...]]], - func: Callable, + func: Callable[..., T], *args: Any, - match: Optional[Union[str, "Pattern"]] = ..., **kwargs: Any -) -> Union[Any]: +) -> T: raise NotImplementedError() @@ -97,7 +100,7 @@ def warns( # noqa: F811 ... warnings.warn("my warning", RuntimeWarning) In the context manager form you may use the keyword argument ``match`` to assert - that the exception matches a text or regex:: + that the warning matches a text or regex:: >>> with warns(UserWarning, match='must be 0 or None'): ... warnings.warn("value must be 0 or None", UserWarning) diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index 1d445d1bf..f61f8586f 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -370,13 +370,14 @@ class TestWarns: @pytest.mark.filterwarnings("ignore") def test_can_capture_previously_warned(self) -> None: - def f(): + def f() -> int: warnings.warn(UserWarning("ohai")) return 10 assert f() == 10 assert pytest.warns(UserWarning, f) == 10 assert pytest.warns(UserWarning, f) == 10 + assert pytest.warns(UserWarning, f) != "10" # type: ignore[comparison-overlap] def test_warns_context_manager_with_kwargs(self) -> None: with pytest.raises(TypeError) as excinfo: From 653c83e127ab4826de456d796ac98b6aebee2f39 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 14:13:41 +0300 Subject: [PATCH 131/140] recwarn: type annotate recwarn fixture --- src/_pytest/recwarn.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 13622e95d..3a75e21a3 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -4,6 +4,7 @@ import warnings from types import TracebackType from typing import Any from typing import Callable +from typing import Generator from typing import Iterator from typing import List from typing import Optional @@ -25,7 +26,7 @@ T = TypeVar("T") @fixture -def recwarn(): +def recwarn() -> Generator["WarningsRecorder", None, None]: """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. See http://docs.python.org/library/warnings.html for information From 142d8963e6e24990dba28e1278fd0db15fe6e832 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 14:30:24 +0300 Subject: [PATCH 132/140] recwarn: type annotate pytest.deprecated_call Also improve its documentation. --- src/_pytest/recwarn.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 3a75e21a3..49bb909cc 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -38,9 +38,26 @@ def recwarn() -> Generator["WarningsRecorder", None, None]: yield wrec -def deprecated_call(func=None, *args, **kwargs): - """context manager that can be used to ensure a block of code triggers a - ``DeprecationWarning`` or ``PendingDeprecationWarning``:: +@overload +def deprecated_call( + *, match: Optional[Union[str, "Pattern"]] = ... +) -> "WarningsRecorder": + raise NotImplementedError() + + +@overload # noqa: F811 +def deprecated_call( # noqa: F811 + func: Callable[..., T], *args: Any, **kwargs: Any +) -> T: + raise NotImplementedError() + + +def deprecated_call( # noqa: F811 + func: Optional[Callable] = None, *args: Any, **kwargs: Any +) -> Union["WarningsRecorder", Any]: + """Assert that code produces a ``DeprecationWarning`` or ``PendingDeprecationWarning``. + + This function can be used as a context manager:: >>> import warnings >>> def api_call_v2(): @@ -50,9 +67,15 @@ def deprecated_call(func=None, *args, **kwargs): >>> with deprecated_call(): ... assert api_call_v2() == 200 - ``deprecated_call`` can also be used by passing a function and ``*args`` and ``*kwargs``, - in which case it will ensure calling ``func(*args, **kwargs)`` produces one of the warnings - types above. + It can also be used by passing a function and ``*args`` and ``**kwargs``, + in which case it will ensure calling ``func(*args, **kwargs)`` produces one of + the warnings types above. The return value is the return value of the function. + + In the context manager form you may use the keyword argument ``match`` to assert + that the warning matches a text or regex. + + The context manager produces a list of :class:`warnings.WarningMessage` objects, + one for each warning raised. """ __tracebackhide__ = True if func is not None: From 8f8f4723790dd035e32309239e0916929a5b0d67 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 15:02:04 +0300 Subject: [PATCH 133/140] python_api: type annotate some parts of pytest.approx() --- src/_pytest/python_api.py | 41 ++++++++++++++++---------------- testing/python/approx.py | 49 ++++++++++++++++++++++----------------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index c185a0676..e30471995 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -33,7 +33,7 @@ if TYPE_CHECKING: BASE_TYPE = (type, STRING_TYPES) -def _non_numeric_type_error(value, at): +def _non_numeric_type_error(value, at: Optional[str]) -> TypeError: at_str = " at {}".format(at) if at else "" return TypeError( "cannot make approximate comparisons to non-numeric values: {!r} {}".format( @@ -55,7 +55,7 @@ class ApproxBase: __array_ufunc__ = None __array_priority__ = 100 - def __init__(self, expected, rel=None, abs=None, nan_ok=False): + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: __tracebackhide__ = True self.expected = expected self.abs = abs @@ -63,10 +63,10 @@ class ApproxBase: self.nan_ok = nan_ok self._check_type() - def __repr__(self): + def __repr__(self) -> str: raise NotImplementedError - def __eq__(self, actual): + def __eq__(self, actual) -> bool: return all( a == self._approx_scalar(x) for a, x in self._yield_comparisons(actual) ) @@ -74,10 +74,10 @@ class ApproxBase: # Ignore type because of https://github.com/python/mypy/issues/4266. __hash__ = None # type: ignore - def __ne__(self, actual): + def __ne__(self, actual) -> bool: return not (actual == self) - def _approx_scalar(self, x): + def _approx_scalar(self, x) -> "ApproxScalar": return ApproxScalar(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) def _yield_comparisons(self, actual): @@ -87,7 +87,7 @@ class ApproxBase: """ raise NotImplementedError - def _check_type(self): + def _check_type(self) -> None: """ Raise a TypeError if the expected value is not a valid type. """ @@ -111,11 +111,11 @@ class ApproxNumpy(ApproxBase): Perform approximate comparisons where the expected value is numpy array. """ - def __repr__(self): + def __repr__(self) -> str: list_scalars = _recursive_list_map(self._approx_scalar, self.expected.tolist()) return "approx({!r})".format(list_scalars) - def __eq__(self, actual): + def __eq__(self, actual) -> bool: import numpy as np # self.expected is supposed to always be an array here @@ -154,12 +154,12 @@ class ApproxMapping(ApproxBase): numeric values (the keys can be anything). """ - def __repr__(self): + def __repr__(self) -> str: return "approx({!r})".format( {k: self._approx_scalar(v) for k, v in self.expected.items()} ) - def __eq__(self, actual): + def __eq__(self, actual) -> bool: if set(actual.keys()) != set(self.expected.keys()): return False @@ -169,7 +169,7 @@ class ApproxMapping(ApproxBase): for k in self.expected.keys(): yield actual[k], self.expected[k] - def _check_type(self): + def _check_type(self) -> None: __tracebackhide__ = True for key, value in self.expected.items(): if isinstance(value, type(self.expected)): @@ -185,7 +185,7 @@ class ApproxSequencelike(ApproxBase): numbers. """ - def __repr__(self): + def __repr__(self) -> str: seq_type = type(self.expected) if seq_type not in (tuple, list, set): seq_type = list @@ -193,7 +193,7 @@ class ApproxSequencelike(ApproxBase): seq_type(self._approx_scalar(x) for x in self.expected) ) - def __eq__(self, actual): + def __eq__(self, actual) -> bool: if len(actual) != len(self.expected): return False return ApproxBase.__eq__(self, actual) @@ -201,7 +201,7 @@ class ApproxSequencelike(ApproxBase): def _yield_comparisons(self, actual): return zip(actual, self.expected) - def _check_type(self): + def _check_type(self) -> None: __tracebackhide__ = True for index, x in enumerate(self.expected): if isinstance(x, type(self.expected)): @@ -223,7 +223,7 @@ class ApproxScalar(ApproxBase): DEFAULT_ABSOLUTE_TOLERANCE = 1e-12 # type: Union[float, Decimal] DEFAULT_RELATIVE_TOLERANCE = 1e-6 # type: Union[float, Decimal] - def __repr__(self): + def __repr__(self) -> str: """ Return a string communicating both the expected value and the tolerance for the comparison being made, e.g. '1.0 ± 1e-6', '(3+4j) ± 5e-6 ∠ ±180°'. @@ -245,7 +245,7 @@ class ApproxScalar(ApproxBase): return "{} ± {}".format(self.expected, vetted_tolerance) - def __eq__(self, actual): + def __eq__(self, actual) -> bool: """ Return true if the given value is equal to the expected value within the pre-specified tolerance. @@ -275,7 +275,8 @@ class ApproxScalar(ApproxBase): return False # Return true if the two numbers are within the tolerance. - return abs(self.expected - actual) <= self.tolerance + result = abs(self.expected - actual) <= self.tolerance # type: bool + return result # Ignore type because of https://github.com/python/mypy/issues/4266. __hash__ = None # type: ignore @@ -337,7 +338,7 @@ class ApproxDecimal(ApproxScalar): DEFAULT_RELATIVE_TOLERANCE = Decimal("1e-6") -def approx(expected, rel=None, abs=None, nan_ok=False): +def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: """ Assert that two numbers (or two sets of numbers) are equal to each other within some tolerance. @@ -527,7 +528,7 @@ def approx(expected, rel=None, abs=None, nan_ok=False): return cls(expected, rel, abs, nan_ok) -def _is_numpy_array(obj): +def _is_numpy_array(obj: object) -> bool: """ Return true if the given object is a numpy array. Make a special effort to avoid importing numpy unless it's really necessary. diff --git a/testing/python/approx.py b/testing/python/approx.py index 8581475e1..db67fe5aa 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -3,6 +3,7 @@ from decimal import Decimal from fractions import Fraction from operator import eq from operator import ne +from typing import Optional import pytest from pytest import approx @@ -121,18 +122,22 @@ class TestApprox: assert a == approx(x, rel=5e-1, abs=0.0) assert a != approx(x, rel=5e-2, abs=0.0) - def test_negative_tolerance(self): + @pytest.mark.parametrize( + ("rel", "abs"), + [ + (-1e100, None), + (None, -1e100), + (1e100, -1e100), + (-1e100, 1e100), + (-1e100, -1e100), + ], + ) + def test_negative_tolerance( + self, rel: Optional[float], abs: Optional[float] + ) -> None: # Negative tolerances are not allowed. - illegal_kwargs = [ - dict(rel=-1e100), - dict(abs=-1e100), - dict(rel=1e100, abs=-1e100), - dict(rel=-1e100, abs=1e100), - dict(rel=-1e100, abs=-1e100), - ] - for kwargs in illegal_kwargs: - with pytest.raises(ValueError): - 1.1 == approx(1, **kwargs) + with pytest.raises(ValueError): + 1.1 == approx(1, rel, abs) def test_inf_tolerance(self): # Everything should be equal if the tolerance is infinite. @@ -143,19 +148,21 @@ class TestApprox: assert a == approx(x, rel=0.0, abs=inf) assert a == approx(x, rel=inf, abs=inf) - def test_inf_tolerance_expecting_zero(self): + def test_inf_tolerance_expecting_zero(self) -> None: # If the relative tolerance is zero but the expected value is infinite, # the actual tolerance is a NaN, which should be an error. - illegal_kwargs = [dict(rel=inf, abs=0.0), dict(rel=inf, abs=inf)] - for kwargs in illegal_kwargs: - with pytest.raises(ValueError): - 1 == approx(0, **kwargs) + with pytest.raises(ValueError): + 1 == approx(0, rel=inf, abs=0.0) + with pytest.raises(ValueError): + 1 == approx(0, rel=inf, abs=inf) - def test_nan_tolerance(self): - illegal_kwargs = [dict(rel=nan), dict(abs=nan), dict(rel=nan, abs=nan)] - for kwargs in illegal_kwargs: - with pytest.raises(ValueError): - 1.1 == approx(1, **kwargs) + def test_nan_tolerance(self) -> None: + with pytest.raises(ValueError): + 1.1 == approx(1, rel=nan) + with pytest.raises(ValueError): + 1.1 == approx(1, abs=nan) + with pytest.raises(ValueError): + 1.1 == approx(1, rel=nan, abs=nan) def test_reasonable_defaults(self): # Whatever the defaults are, they should work for numbers close to 1 From 97a11726e2bcfdf2fcbcb38c5cb859257bc48f71 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 15:15:08 +0300 Subject: [PATCH 134/140] freeze_support: type annotate --- src/_pytest/freeze_support.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/_pytest/freeze_support.py b/src/_pytest/freeze_support.py index 9d35d9afc..63c14eceb 100644 --- a/src/_pytest/freeze_support.py +++ b/src/_pytest/freeze_support.py @@ -2,9 +2,13 @@ Provides a function to report all internal modules for using freezing tools pytest """ +import types +from typing import Iterator +from typing import List +from typing import Union -def freeze_includes(): +def freeze_includes() -> List[str]: """ Returns a list of module names used by pytest that should be included by cx_freeze. @@ -17,7 +21,9 @@ def freeze_includes(): return result -def _iter_all_modules(package, prefix=""): +def _iter_all_modules( + package: Union[str, types.ModuleType], prefix: str = "", +) -> Iterator[str]: """ Iterates over the names of all modules that can be found in the given package, recursively. @@ -29,10 +35,13 @@ def _iter_all_modules(package, prefix=""): import os import pkgutil - if type(package) is not str: - path, prefix = package.__path__[0], package.__name__ + "." - else: + if isinstance(package, str): path = package + else: + # Type ignored because typeshed doesn't define ModuleType.__path__ + # (only defined on packages). + package_path = package.__path__ # type: ignore[attr-defined] + path, prefix = package_path[0], package.__name__ + "." for _, name, is_package in pkgutil.iter_modules([path]): if is_package: for m in _iter_all_modules(os.path.join(path, name), prefix=name + "."): From 256a5d8b1458bcd0f73b1722423b07c03e450f5b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 15:38:54 +0300 Subject: [PATCH 135/140] hookspec: improve typing of some remaining hooks --- src/_pytest/hookspec.py | 92 +++++++++++++++++++++++------------------ src/_pytest/main.py | 8 ++-- src/_pytest/python.py | 2 +- src/_pytest/reports.py | 36 ++++++++++------ src/_pytest/terminal.py | 6 +-- src/_pytest/unittest.py | 2 +- 6 files changed, 82 insertions(+), 64 deletions(-) diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index c05b60791..1b4b09c85 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -1,5 +1,6 @@ """ hook specifications for pytest plugins, invoked from main.py and builtin plugins. """ from typing import Any +from typing import Dict from typing import List from typing import Mapping from typing import Optional @@ -37,7 +38,6 @@ if TYPE_CHECKING: from _pytest.python import Metafunc from _pytest.python import Module from _pytest.python import PyCollector - from _pytest.reports import BaseReport from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.runner import CallInfo @@ -172,7 +172,7 @@ def pytest_cmdline_preparse(config: "Config", args: List[str]) -> None: @hookspec(firstresult=True) -def pytest_cmdline_main(config: "Config") -> "Optional[Union[ExitCode, int]]": +def pytest_cmdline_main(config: "Config") -> Optional[Union["ExitCode", int]]: """ called for performing the main command line action. The default implementation will invoke the configure hooks and runtest_mainloop. @@ -206,7 +206,7 @@ def pytest_load_initial_conftests( @hookspec(firstresult=True) -def pytest_collection(session: "Session") -> Optional[Any]: +def pytest_collection(session: "Session") -> Optional[object]: """Perform the collection protocol for the given session. Stops at first non-None result, see :ref:`firstresult`. @@ -242,20 +242,21 @@ def pytest_collection_modifyitems( """ -def pytest_collection_finish(session: "Session"): - """ called after collection has been performed and modified. +def pytest_collection_finish(session: "Session") -> None: + """Called after collection has been performed and modified. :param _pytest.main.Session session: the pytest session object """ @hookspec(firstresult=True) -def pytest_ignore_collect(path, config: "Config"): - """ return True to prevent considering this path for collection. +def pytest_ignore_collect(path: py.path.local, config: "Config") -> Optional[bool]: + """Return True to prevent considering this path for collection. + This hook is consulted for all files and directories prior to calling more specific hooks. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. :param path: a :py:class:`py.path.local` - the path to analyze :param _pytest.config.Config config: pytest config object @@ -263,18 +264,19 @@ def pytest_ignore_collect(path, config: "Config"): @hookspec(firstresult=True, warn_on_impl=COLLECT_DIRECTORY_HOOK) -def pytest_collect_directory(path, parent): - """ called before traversing a directory for collection files. +def pytest_collect_directory(path: py.path.local, parent) -> Optional[object]: + """Called before traversing a directory for collection files. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. :param path: a :py:class:`py.path.local` - the path to analyze """ def pytest_collect_file(path: py.path.local, parent) -> "Optional[Collector]": - """ return collection Node or None for the given path. Any new node - needs to have the specified ``parent`` as a parent. + """Return collection Node or None for the given path. + + Any new node needs to have the specified ``parent`` as a parent. :param path: a :py:class:`py.path.local` - the path to collect """ @@ -287,16 +289,16 @@ def pytest_collectstart(collector: "Collector") -> None: """ collector starts collecting. """ -def pytest_itemcollected(item): - """ we just collected a test item. """ +def pytest_itemcollected(item: "Item") -> None: + """We just collected a test item.""" def pytest_collectreport(report: "CollectReport") -> None: """ collector finished collecting. """ -def pytest_deselected(items): - """ called for test items deselected, e.g. by keyword. """ +def pytest_deselected(items: Sequence["Item"]) -> None: + """Called for deselected test items, e.g. by keyword.""" @hookspec(firstresult=True) @@ -312,13 +314,14 @@ def pytest_make_collect_report(collector: "Collector") -> "Optional[CollectRepor @hookspec(firstresult=True) -def pytest_pycollect_makemodule(path: py.path.local, parent) -> "Optional[Module]": - """ return a Module collector or None for the given path. +def pytest_pycollect_makemodule(path: py.path.local, parent) -> Optional["Module"]: + """Return a Module collector or None for the given path. + This hook will be called for each matching test module path. The pytest_collect_file hook needs to be used if you want to create test modules for files that do not match as a test module. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. :param path: a :py:class:`py.path.local` - the path of module to collect """ @@ -326,11 +329,12 @@ def pytest_pycollect_makemodule(path: py.path.local, parent) -> "Optional[Module @hookspec(firstresult=True) def pytest_pycollect_makeitem( - collector: "PyCollector", name: str, obj -) -> "Union[None, Item, Collector, List[Union[Item, Collector]]]": - """ return custom item/collector for a python object in a module, or None. + collector: "PyCollector", name: str, obj: object +) -> Union[None, "Item", "Collector", List[Union["Item", "Collector"]]]: + """Return a custom item/collector for a Python object in a module, or None. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult`. + """ @hookspec(firstresult=True) @@ -466,7 +470,7 @@ def pytest_runtest_call(item: "Item") -> None: """ -def pytest_runtest_teardown(item: "Item", nextitem: "Optional[Item]") -> None: +def pytest_runtest_teardown(item: "Item", nextitem: Optional["Item"]) -> None: """Called to perform the teardown phase for a test item. The default implementation runs the finalizers and calls ``teardown()`` @@ -505,7 +509,9 @@ def pytest_runtest_logreport(report: "TestReport") -> None: @hookspec(firstresult=True) -def pytest_report_to_serializable(config: "Config", report: "BaseReport"): +def pytest_report_to_serializable( + config: "Config", report: Union["CollectReport", "TestReport"], +) -> Optional[Dict[str, Any]]: """ Serializes the given report object into a data structure suitable for sending over the wire, e.g. converted to JSON. @@ -513,7 +519,9 @@ def pytest_report_to_serializable(config: "Config", report: "BaseReport"): @hookspec(firstresult=True) -def pytest_report_from_serializable(config: "Config", data): +def pytest_report_from_serializable( + config: "Config", data: Dict[str, Any], +) -> Optional[Union["CollectReport", "TestReport"]]: """ Restores a report object previously serialized with pytest_report_to_serializable(). """ @@ -528,11 +536,11 @@ def pytest_report_from_serializable(config: "Config", data): def pytest_fixture_setup( fixturedef: "FixtureDef", request: "SubRequest" ) -> Optional[object]: - """ performs fixture setup execution. + """Performs fixture setup execution. - :return: The return value of the call to the fixture function + :return: The return value of the call to the fixture function. - Stops at first non-None result, see :ref:`firstresult` + Stops at first non-None result, see :ref:`firstresult`. .. note:: If the fixture function returns None, other implementations of @@ -555,7 +563,7 @@ def pytest_fixture_post_finalizer( def pytest_sessionstart(session: "Session") -> None: - """ called after the ``Session`` object has been created and before performing collection + """Called after the ``Session`` object has been created and before performing collection and entering the run test loop. :param _pytest.main.Session session: the pytest session object @@ -563,9 +571,9 @@ def pytest_sessionstart(session: "Session") -> None: def pytest_sessionfinish( - session: "Session", exitstatus: "Union[int, ExitCode]" + session: "Session", exitstatus: Union[int, "ExitCode"], ) -> None: - """ called after whole test run finished, right before returning the exit status to the system. + """Called after whole test run finished, right before returning the exit status to the system. :param _pytest.main.Session session: the pytest session object :param int exitstatus: the status which pytest will return to the system @@ -573,7 +581,7 @@ def pytest_sessionfinish( def pytest_unconfigure(config: "Config") -> None: - """ called before test process is exited. + """Called before test process is exited. :param _pytest.config.Config config: pytest config object """ @@ -587,7 +595,7 @@ def pytest_unconfigure(config: "Config") -> None: def pytest_assertrepr_compare( config: "Config", op: str, left: object, right: object ) -> Optional[List[str]]: - """return explanation for comparisons in failing assert expressions. + """Return explanation for comparisons in failing assert expressions. Return None for no custom explanation, otherwise return a list of strings. The strings will be joined by newlines but any newlines @@ -598,7 +606,7 @@ def pytest_assertrepr_compare( """ -def pytest_assertion_pass(item, lineno: int, orig: str, expl: str) -> None: +def pytest_assertion_pass(item: "Item", lineno: int, orig: str, expl: str) -> None: """ **(Experimental)** @@ -665,12 +673,12 @@ def pytest_report_header( def pytest_report_collectionfinish( - config: "Config", startdir: py.path.local, items: "Sequence[Item]" + config: "Config", startdir: py.path.local, items: Sequence["Item"], ) -> Union[str, List[str]]: """ .. versionadded:: 3.2 - return a string or list of strings to be displayed after collection has finished successfully. + Return a string or list of strings to be displayed after collection has finished successfully. These strings will be displayed after the standard "collected X items" message. @@ -689,7 +697,7 @@ def pytest_report_collectionfinish( @hookspec(firstresult=True) def pytest_report_teststatus( - report: "BaseReport", config: "Config" + report: Union["CollectReport", "TestReport"], config: "Config" ) -> Tuple[ str, str, Union[str, Mapping[str, bool]], ]: @@ -734,7 +742,7 @@ def pytest_terminal_summary( def pytest_warning_captured( warning_message: "warnings.WarningMessage", when: "Literal['config', 'collect', 'runtest']", - item: "Optional[Item]", + item: Optional["Item"], location: Optional[Tuple[str, int, str]], ) -> None: """(**Deprecated**) Process a warning captured by the internal pytest warnings plugin. @@ -831,7 +839,9 @@ def pytest_keyboard_interrupt( def pytest_exception_interact( - node: "Node", call: "CallInfo[object]", report: "Union[CollectReport, TestReport]" + node: "Node", + call: "CallInfo[object]", + report: Union["CollectReport", "TestReport"], ) -> None: """Called when an exception was raised which can potentially be interactively handled. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index b7a3a958a..98dabaf87 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -302,8 +302,8 @@ def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: return None -def pytest_collection(session: "Session") -> Sequence[nodes.Item]: - return session.perform_collect() +def pytest_collection(session: "Session") -> None: + session.perform_collect() def pytest_runtestloop(session: "Session") -> bool: @@ -343,9 +343,7 @@ def _in_venv(path: py.path.local) -> bool: return any([fname.basename in activates for fname in bindir.listdir()]) -def pytest_ignore_collect( - path: py.path.local, config: Config -) -> "Optional[Literal[True]]": +def pytest_ignore_collect(path: py.path.local, config: Config) -> Optional[bool]: ignore_paths = config._getconftest_pathlist("collect_ignore", path=path.dirpath()) ignore_paths = ignore_paths or [] excludeopt = config.getoption("ignore") diff --git a/src/_pytest/python.py b/src/_pytest/python.py index f3c42f421..7fbd59add 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -422,7 +422,7 @@ class PyCollector(PyobjMixin, nodes.Collector): return values def _makeitem( - self, name: str, obj + self, name: str, obj: object ) -> Union[ None, nodes.Item, nodes.Collector, List[Union[nodes.Item, nodes.Collector]] ]: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 8b213ed13..7aba0b024 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,6 +1,7 @@ from io import StringIO from pprint import pprint from typing import Any +from typing import Dict from typing import Iterable from typing import Iterator from typing import List @@ -69,7 +70,7 @@ class BaseReport: def __getattr__(self, key: str) -> Any: raise NotImplementedError() - def toterminal(self, out) -> None: + def toterminal(self, out: TerminalWriter) -> None: if hasattr(self, "node"): out.line(getworkerinfoline(self.node)) @@ -187,7 +188,7 @@ class BaseReport: ) return verbose - def _to_json(self): + def _to_json(self) -> Dict[str, Any]: """ This was originally the serialize_report() function from xdist (ca03269). @@ -199,7 +200,7 @@ class BaseReport: return _report_to_json(self) @classmethod - def _from_json(cls: "Type[_R]", reportdict) -> _R: + def _from_json(cls: "Type[_R]", reportdict: Dict[str, object]) -> _R: """ This was originally the serialize_report() function from xdist (ca03269). @@ -382,11 +383,13 @@ class CollectErrorRepr(TerminalRepr): def __init__(self, msg) -> None: self.longrepr = msg - def toterminal(self, out) -> None: + def toterminal(self, out: TerminalWriter) -> None: out.line(self.longrepr, red=True) -def pytest_report_to_serializable(report: BaseReport): +def pytest_report_to_serializable( + report: Union[CollectReport, TestReport] +) -> Optional[Dict[str, Any]]: if isinstance(report, (TestReport, CollectReport)): data = report._to_json() data["$report_type"] = report.__class__.__name__ @@ -394,7 +397,9 @@ def pytest_report_to_serializable(report: BaseReport): return None -def pytest_report_from_serializable(data) -> Optional[BaseReport]: +def pytest_report_from_serializable( + data: Dict[str, Any], +) -> Optional[Union[CollectReport, TestReport]]: if "$report_type" in data: if data["$report_type"] == "TestReport": return TestReport._from_json(data) @@ -406,7 +411,7 @@ def pytest_report_from_serializable(data) -> Optional[BaseReport]: return None -def _report_to_json(report: BaseReport): +def _report_to_json(report: BaseReport) -> Dict[str, Any]: """ This was originally the serialize_report() function from xdist (ca03269). @@ -414,7 +419,9 @@ def _report_to_json(report: BaseReport): serialization. """ - def serialize_repr_entry(entry: Union[ReprEntry, ReprEntryNative]): + def serialize_repr_entry( + entry: Union[ReprEntry, ReprEntryNative] + ) -> Dict[str, Any]: data = attr.asdict(entry) for key, value in data.items(): if hasattr(value, "__dict__"): @@ -422,25 +429,28 @@ def _report_to_json(report: BaseReport): entry_data = {"type": type(entry).__name__, "data": data} return entry_data - def serialize_repr_traceback(reprtraceback: ReprTraceback): + def serialize_repr_traceback(reprtraceback: ReprTraceback) -> Dict[str, Any]: result = attr.asdict(reprtraceback) result["reprentries"] = [ serialize_repr_entry(x) for x in reprtraceback.reprentries ] return result - def serialize_repr_crash(reprcrash: Optional[ReprFileLocation]): + def serialize_repr_crash( + reprcrash: Optional[ReprFileLocation], + ) -> Optional[Dict[str, Any]]: if reprcrash is not None: return attr.asdict(reprcrash) else: return None - def serialize_longrepr(rep): + def serialize_longrepr(rep: BaseReport) -> Dict[str, Any]: + assert rep.longrepr is not None result = { "reprcrash": serialize_repr_crash(rep.longrepr.reprcrash), "reprtraceback": serialize_repr_traceback(rep.longrepr.reprtraceback), "sections": rep.longrepr.sections, - } + } # type: Dict[str, Any] if isinstance(rep.longrepr, ExceptionChainRepr): result["chain"] = [] for repr_traceback, repr_crash, description in rep.longrepr.chain: @@ -473,7 +483,7 @@ def _report_to_json(report: BaseReport): return d -def _report_kwargs_from_json(reportdict): +def _report_kwargs_from_json(reportdict: Dict[str, Any]) -> Dict[str, Any]: """ This was originally the serialize_report() function from xdist (ca03269). diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 6a58260e9..9b10e5ffe 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -467,9 +467,9 @@ class TerminalReporter: def line(self, msg: str, **kw: bool) -> None: self._tw.line(msg, **kw) - def _add_stats(self, category: str, items: List) -> None: + def _add_stats(self, category: str, items: Sequence) -> None: set_main_color = category not in self.stats - self.stats.setdefault(category, []).extend(items[:]) + self.stats.setdefault(category, []).extend(items) if set_main_color: self._set_main_color() @@ -499,7 +499,7 @@ class TerminalReporter: # which garbles our output if we use self.write_line self.write_line(msg) - def pytest_deselected(self, items) -> None: + def pytest_deselected(self, items: Sequence[Item]) -> None: self._add_stats("deselected", items) def pytest_runtest_logstart( diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index a90b56c29..0e4a31311 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -44,7 +44,7 @@ if TYPE_CHECKING: def pytest_pycollect_makeitem( - collector: PyCollector, name: str, obj + collector: PyCollector, name: str, obj: object ) -> Optional["UnitTestCase"]: # has unittest been imported and is obj a subclass of its TestCase? try: From f382a6bb2084c8fb5a4e252ab7f3358752e27f67 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 25 Jun 2020 17:32:05 +0300 Subject: [PATCH 136/140] hookspec: remove unused hookspec pytest_doctest_prepare_content() It's been unused for 10 years at lest from bb50ec89a92f0623c9f8f5f29. --- changelog/7418.breaking.rst | 2 ++ src/_pytest/hookspec.py | 12 ------------ 2 files changed, 2 insertions(+), 12 deletions(-) create mode 100644 changelog/7418.breaking.rst diff --git a/changelog/7418.breaking.rst b/changelog/7418.breaking.rst new file mode 100644 index 000000000..23f60da37 --- /dev/null +++ b/changelog/7418.breaking.rst @@ -0,0 +1,2 @@ +Remove the `pytest_doctest_prepare_content` hook specification. This hook +hasn't been triggered by pytest for at least 10 years. diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 1b4b09c85..8c88b66cb 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -805,18 +805,6 @@ def pytest_warning_recorded( """ -# ------------------------------------------------------------------------- -# doctest hooks -# ------------------------------------------------------------------------- - - -@hookspec(firstresult=True) -def pytest_doctest_prepare_content(content): - """ return processed content for a given doctest - - Stops at first non-None result, see :ref:`firstresult` """ - - # ------------------------------------------------------------------------- # error handling and internal debugging hooks # ------------------------------------------------------------------------- From ba50ef33d323fce32f5a357c2449dcf9c3119d2d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 25 Jun 2020 17:32:34 +0200 Subject: [PATCH 137/140] Add open training at Workshoptage 2020 --- doc/en/talks.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/en/talks.rst b/doc/en/talks.rst index 26df77c29..50af51e71 100644 --- a/doc/en/talks.rst +++ b/doc/en/talks.rst @@ -2,6 +2,10 @@ Talks and Tutorials ========================== +.. sidebar:: Next Open Trainings + + - `"pytest: Test Driven Development (nicht nur) für Python" `_ (German) at the `CH Open Workshoptage `_, September 8 2020, HSLU Campus Rotkreuz (ZG), Switzerland. + .. _`funcargs`: funcargs.html Books From 1ae4182e1836000eb35a40ec4c3dbb2689e0c5ae Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 26 Jun 2020 15:50:19 +0300 Subject: [PATCH 138/140] testing: fix flaky tests on pypy3 due to resource warnings in stderr (#7405) --- testing/test_stepwise.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/testing/test_stepwise.py b/testing/test_stepwise.py index 3bc77857d..df66d798b 100644 --- a/testing/test_stepwise.py +++ b/testing/test_stepwise.py @@ -75,6 +75,16 @@ def broken_testdir(testdir): return testdir +def _strip_resource_warnings(lines): + # Strip unreliable ResourceWarnings, so no-output assertions on stderr can work. + # (https://github.com/pytest-dev/pytest/issues/5088) + return [ + x + for x in lines + if not x.startswith(("Exception ignored in:", "ResourceWarning")) + ] + + def test_run_without_stepwise(stepwise_testdir): result = stepwise_testdir.runpytest("-v", "--strict-markers", "--fail") @@ -88,7 +98,7 @@ def test_fail_and_continue_with_stepwise(stepwise_testdir): result = stepwise_testdir.runpytest( "-v", "--strict-markers", "--stepwise", "--fail" ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() # Make sure we stop after first failing test. @@ -98,7 +108,7 @@ def test_fail_and_continue_with_stepwise(stepwise_testdir): # "Fix" the test that failed in the last run and run it again. result = stepwise_testdir.runpytest("-v", "--strict-markers", "--stepwise") - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() # Make sure the latest failing test runs and then continues. @@ -116,7 +126,7 @@ def test_run_with_skip_option(stepwise_testdir): "--fail", "--fail-last", ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() # Make sure first fail is ignore and second fail stops the test run. @@ -129,7 +139,7 @@ def test_run_with_skip_option(stepwise_testdir): def test_fail_on_errors(error_testdir): result = error_testdir.runpytest("-v", "--strict-markers", "--stepwise") - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() assert "test_error ERROR" in stdout @@ -140,7 +150,7 @@ def test_change_testfile(stepwise_testdir): result = stepwise_testdir.runpytest( "-v", "--strict-markers", "--stepwise", "--fail", "test_a.py" ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() assert "test_fail_on_flag FAILED" in stdout @@ -150,7 +160,7 @@ def test_change_testfile(stepwise_testdir): result = stepwise_testdir.runpytest( "-v", "--strict-markers", "--stepwise", "test_b.py" ) - assert not result.stderr.str() + assert _strip_resource_warnings(result.stderr.lines) == [] stdout = result.stdout.str() assert "test_success PASSED" in stdout From 103bfd20d49089f042fa556c299800a17115d30c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 26 Jun 2020 17:08:14 +0200 Subject: [PATCH 139/140] Add webinar --- doc/en/talks.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/en/talks.rst b/doc/en/talks.rst index 50af51e71..253dfe78e 100644 --- a/doc/en/talks.rst +++ b/doc/en/talks.rst @@ -4,6 +4,7 @@ Talks and Tutorials .. sidebar:: Next Open Trainings + - `Free 1h webinar: "pytest: Test Driven Development für Python" `_ (German), online, August 18 2020. - `"pytest: Test Driven Development (nicht nur) für Python" `_ (German) at the `CH Open Workshoptage `_, September 8 2020, HSLU Campus Rotkreuz (ZG), Switzerland. .. _`funcargs`: funcargs.html From 03230b4002b0cf88a00b4a1fc6f15f77d99cda7e Mon Sep 17 00:00:00 2001 From: gdhameeja Date: Thu, 4 Jun 2020 01:38:11 +0530 Subject: [PATCH 140/140] Fix-6906: Added code-highlight option to disable highlighting optionally Co-authored-by: Ran Benita --- changelog/6906.feature.rst | 1 + src/_pytest/_io/terminalwriter.py | 3 ++- src/_pytest/config/__init__.py | 7 ++++++- src/_pytest/terminal.py | 6 ++++++ testing/io/test_terminalwriter.py | 21 +++++++++++++++++---- 5 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 changelog/6906.feature.rst diff --git a/changelog/6906.feature.rst b/changelog/6906.feature.rst new file mode 100644 index 000000000..3e1fe3ef1 --- /dev/null +++ b/changelog/6906.feature.rst @@ -0,0 +1 @@ +Added `--code-highlight` command line option to enable/disable code highlighting in terminal output. diff --git a/src/_pytest/_io/terminalwriter.py b/src/_pytest/_io/terminalwriter.py index a285cf4fc..70bb2e2dc 100644 --- a/src/_pytest/_io/terminalwriter.py +++ b/src/_pytest/_io/terminalwriter.py @@ -74,6 +74,7 @@ class TerminalWriter: self.hasmarkup = should_do_markup(file) self._current_line = "" self._terminal_width = None # type: Optional[int] + self.code_highlight = True @property def fullwidth(self) -> int: @@ -180,7 +181,7 @@ class TerminalWriter: def _highlight(self, source: str) -> str: """Highlight the given source code if we have markup support.""" - if not self.hasmarkup: + if not self.hasmarkup or not self.code_highlight: return source try: from pygments.formatters.terminal import TerminalFormatter diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 717743e79..bf5b78082 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1389,8 +1389,13 @@ def create_terminal_writer( tw = TerminalWriter(file=file) if config.option.color == "yes": tw.hasmarkup = True - if config.option.color == "no": + elif config.option.color == "no": tw.hasmarkup = False + + if config.option.code_highlight == "yes": + tw.code_highlight = True + elif config.option.code_highlight == "no": + tw.code_highlight = False return tw diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 6a58260e9..9dbd477ea 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -208,6 +208,12 @@ def pytest_addoption(parser: Parser) -> None: choices=["yes", "no", "auto"], help="color terminal output (yes/no/auto).", ) + group._addoption( + "--code-highlight", + default="yes", + choices=["yes", "no"], + help="Whether code should be highlighted (only if --color is also enabled)", + ) parser.addini( "console_output_style", diff --git a/testing/io/test_terminalwriter.py b/testing/io/test_terminalwriter.py index 0e9cdb64d..94cff307f 100644 --- a/testing/io/test_terminalwriter.py +++ b/testing/io/test_terminalwriter.py @@ -213,19 +213,32 @@ class TestTerminalWriterLineWidth: @pytest.mark.parametrize( - "has_markup, expected", + ("has_markup", "code_highlight", "expected"), [ pytest.param( - True, "{kw}assert{hl-reset} {number}0{hl-reset}\n", id="with markup" + True, + True, + "{kw}assert{hl-reset} {number}0{hl-reset}\n", + id="with markup and code_highlight", + ), + pytest.param( + True, False, "assert 0\n", id="with markup but no code_highlight", + ), + pytest.param( + False, True, "assert 0\n", id="without markup but with code_highlight", + ), + pytest.param( + False, False, "assert 0\n", id="neither markup nor code_highlight", ), - pytest.param(False, "assert 0\n", id="no markup"), ], ) -def test_code_highlight(has_markup, expected, color_mapping): +def test_code_highlight(has_markup, code_highlight, expected, color_mapping): f = io.StringIO() tw = terminalwriter.TerminalWriter(f) tw.hasmarkup = has_markup + tw.code_highlight = code_highlight tw._write_source(["assert 0"]) + assert f.getvalue().splitlines(keepends=True) == color_mapping.format([expected]) with pytest.raises(