From d4fb6ac9f7875b762a6bd1f179ef5df4997d6182 Mon Sep 17 00:00:00 2001 From: Tyler Smart Date: Wed, 16 Aug 2023 00:54:38 -0600 Subject: [PATCH 01/27] Fix doctest collection of `functools.cached_property` objects. --- AUTHORS | 1 + changelog/11237.bugfix.rst | 1 + src/_pytest/doctest.py | 18 +++++++++++++++++- testing/test_doctest.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 changelog/11237.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 313e507f2..18db7808f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -379,6 +379,7 @@ Tor Colvin Trevor Bekolay Tushar Sadhwani Tyler Goodlet +Tyler Smart Tzu-ping Chung Vasily Kuznetsov Victor Maryama diff --git a/changelog/11237.bugfix.rst b/changelog/11237.bugfix.rst new file mode 100644 index 000000000..d054fc18d --- /dev/null +++ b/changelog/11237.bugfix.rst @@ -0,0 +1 @@ +Fix doctest collection of `functools.cached_property` objects. diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index e6f666dda..e8bf92d95 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -1,5 +1,6 @@ """Discover and run doctests in modules and test files.""" import bdb +import functools import inspect import os import platform @@ -536,6 +537,21 @@ class DoctestModule(Module): tests, obj, name, module, source_lines, globs, seen ) + class CachedPropertyAwareDocTestFinder(MockAwareDocTestFinder): + def _from_module(self, module, object): + """Doctest code does not take into account `@cached_property`, + this is a hackish way to fix it. https://github.com/python/cpython/issues/107995 + + Wrap Doctest finder so that when it calls `_from_module` for + a cached_property it uses the underlying function instead of the + wrapped cached_property object. + """ + if isinstance(object, functools.cached_property): + object = object.func + + # Type ignored because this is a private function. + return super()._from_module(module, object) # type: ignore[misc] + if self.path.name == "conftest.py": module = self.config.pluginmanager._importconftest( self.path, @@ -555,7 +571,7 @@ class DoctestModule(Module): else: raise # Uses internal doctest module parsing mechanism. - finder = MockAwareDocTestFinder() + finder = CachedPropertyAwareDocTestFinder() optionflags = get_optionflags(self) runner = _get_runner( verbose=False, diff --git a/testing/test_doctest.py b/testing/test_doctest.py index f189e8645..f4d3155c4 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -482,6 +482,24 @@ class TestDoctests: reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(failed=1) + def test_doctest_cached_property(self, pytester: Pytester): + p = pytester.makepyfile( + """ + import functools + + class Foo: + @functools.cached_property + def foo(self): + ''' + >>> assert False, "Tacos!" + ''' + ... + """ + ) + result = pytester.runpytest(p, "--doctest-modules") + result.assert_outcomes(failed=1) + assert "Tacos!" in result.stdout.str() + def test_doctestmodule_external_and_issue116(self, pytester: Pytester): p = pytester.mkpydir("hello") p.joinpath("__init__.py").write_text( From ebd571bb18d2cf00ebc70db8090217fc62d00e98 Mon Sep 17 00:00:00 2001 From: Tyler Smart Date: Sat, 19 Aug 2023 12:04:59 -0600 Subject: [PATCH 02/27] Move _from_module override to pre-existsing DocTestFinder subclass --- src/_pytest/doctest.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index e8bf92d95..c99ccbbb1 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -537,14 +537,11 @@ class DoctestModule(Module): tests, obj, name, module, source_lines, globs, seen ) - class CachedPropertyAwareDocTestFinder(MockAwareDocTestFinder): def _from_module(self, module, object): - """Doctest code does not take into account `@cached_property`, - this is a hackish way to fix it. https://github.com/python/cpython/issues/107995 - - Wrap Doctest finder so that when it calls `_from_module` for - a cached_property it uses the underlying function instead of the - wrapped cached_property object. + """`cached_property` objects will are never considered a part + of the 'current module'. As such they are skipped by doctest. + Here we override `_from_module` to check the underlying + function instead. https://github.com/python/cpython/issues/107995 """ if isinstance(object, functools.cached_property): object = object.func @@ -571,7 +568,7 @@ class DoctestModule(Module): else: raise # Uses internal doctest module parsing mechanism. - finder = CachedPropertyAwareDocTestFinder() + finder = MockAwareDocTestFinder() optionflags = get_optionflags(self) runner = _get_runner( verbose=False, From 7a625481dae2380d8f9d1cf53f6a59a2977b49c8 Mon Sep 17 00:00:00 2001 From: Tyler Smart Date: Sat, 19 Aug 2023 22:20:40 -0600 Subject: [PATCH 03/27] PR suggestions --- src/_pytest/doctest.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index c99ccbbb1..fcd48f893 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -537,17 +537,19 @@ class DoctestModule(Module): tests, obj, name, module, source_lines, globs, seen ) - def _from_module(self, module, object): - """`cached_property` objects will are never considered a part - of the 'current module'. As such they are skipped by doctest. - Here we override `_from_module` to check the underlying - function instead. https://github.com/python/cpython/issues/107995 - """ - if isinstance(object, functools.cached_property): - object = object.func + if sys.version_info < (3, 13): - # Type ignored because this is a private function. - return super()._from_module(module, object) # type: ignore[misc] + def _from_module(self, module, object): + """`cached_property` objects are never considered a part + of the 'current module'. As such they are skipped by doctest. + Here we override `_from_module` to check the underlying + function instead. https://github.com/python/cpython/issues/107995 + """ + if isinstance(object, functools.cached_property): + object = object.func + + # Type ignored because this is a private function. + return super()._from_module(module, object) # type: ignore[misc] if self.path.name == "conftest.py": module = self.config.pluginmanager._importconftest( From a357c7abc84e0382894a9b53d88ebb7ed2680686 Mon Sep 17 00:00:00 2001 From: Tyler Smart Date: Sun, 20 Aug 2023 20:55:30 -0600 Subject: [PATCH 04/27] Ignore dip in branch coverage (since py3.13+ isn't tested in CI) --- src/_pytest/doctest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index fcd48f893..49f8c9bfe 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -551,6 +551,9 @@ class DoctestModule(Module): # Type ignored because this is a private function. return super()._from_module(module, object) # type: ignore[misc] + else: # pragma: no cover + pass + if self.path.name == "conftest.py": module = self.config.pluginmanager._importconftest( self.path, From 8032d212715108c5187e57b5fccdd2502e716410 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 Sep 2023 10:09:33 -0300 Subject: [PATCH 05/27] [pre-commit.ci] pre-commit autoupdate (#11389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/autoflake: v2.2.0 → v2.2.1](https://github.com/PyCQA/autoflake/compare/v2.2.0...v2.2.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82dc5dae7..509ac5212 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: exclude: _pytest/(debugging|hookspec).py language_version: python3 - repo: https://github.com/PyCQA/autoflake - rev: v2.2.0 + rev: v2.2.1 hooks: - id: autoflake name: autoflake From 8d815ca55b054c556e4bed7e5d3d51b67db43d3e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 2 Aug 2023 22:48:30 +0300 Subject: [PATCH 06/27] python: type some CallSpec2 fields as immutable Knowing that a field is immutable makes it easier to understand the code. --- src/_pytest/python.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index df1eb854d..879905486 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1123,9 +1123,9 @@ class CallSpec2: # arg name -> arg index. indices: Dict[str, int] = dataclasses.field(default_factory=dict) # Used for sorting parametrized resources. - _arg2scope: Dict[str, Scope] = dataclasses.field(default_factory=dict) + _arg2scope: Mapping[str, Scope] = dataclasses.field(default_factory=dict) # Parts which will be added to the item's name in `[..]` separated by "-". - _idlist: List[str] = dataclasses.field(default_factory=list) + _idlist: Sequence[str] = dataclasses.field(default_factory=tuple) # Marks which will be applied to the item. marks: List[Mark] = dataclasses.field(default_factory=list) @@ -1141,7 +1141,7 @@ class CallSpec2: ) -> "CallSpec2": params = self.params.copy() indices = self.indices.copy() - arg2scope = self._arg2scope.copy() + arg2scope = dict(self._arg2scope) for arg, val in zip(argnames, valset): if arg in params: raise ValueError(f"duplicate parametrization of {arg!r}") From d4872f5df75cc4a8a875fe783ae1e0307b2b8b47 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 2 Aug 2023 22:50:13 +0300 Subject: [PATCH 07/27] fixtures: tiny code cleanup --- src/_pytest/fixtures.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index c7fc28adb..88ee50adf 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -210,16 +210,14 @@ def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]] = {} items_by_argkey: Dict[Scope, Dict[FixtureArgKey, Deque[nodes.Item]]] = {} for scope in HIGH_SCOPES: - d: Dict[nodes.Item, Dict[FixtureArgKey, None]] = {} - argkeys_cache[scope] = d - item_d: Dict[FixtureArgKey, Deque[nodes.Item]] = defaultdict(deque) - items_by_argkey[scope] = item_d + scoped_argkeys_cache = argkeys_cache[scope] = {} + scoped_items_by_argkey = items_by_argkey[scope] = defaultdict(deque) for item in items: keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None) if keys: - d[item] = keys + scoped_argkeys_cache[item] = keys for key in keys: - item_d[key].append(item) + scoped_items_by_argkey[key].append(item) items_dict = dict.fromkeys(items, None) return list( reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, Scope.Session) From 82bd63d318dd46e877b51beaf80c9758603aa0b1 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 Sep 2023 14:49:34 +0300 Subject: [PATCH 08/27] doctest: add `fixturenames` field to `DoctestItem` The field is used in `_fillfixtures`, in preference to `request.fixturenames`, which also includes already-computed which is not needed. --- src/_pytest/doctest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index e6f666dda..c3dbf84c2 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -579,9 +579,11 @@ def _setup_fixtures(doctest_item: DoctestItem) -> TopRequest: doctest_item.funcargs = {} # type: ignore[attr-defined] fm = doctest_item.session._fixturemanager - doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined] + fixtureinfo = fm.getfixtureinfo( node=doctest_item, func=func, cls=None, funcargs=False ) + doctest_item._fixtureinfo = fixtureinfo # type: ignore[attr-defined] + doctest_item.fixturenames = fixtureinfo.names_closure # type: ignore[attr-defined] fixture_request = TopRequest(doctest_item, _ispytest=True) # type: ignore[arg-type] fixture_request._fillfixtures() return fixture_request From 65c01f531b45bd62c5a28a910645dbfb878d8017 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 Sep 2023 14:53:36 +0300 Subject: [PATCH 09/27] fixtures: use the item `fixturenames` in `request.fixturenames` `_pyfuncitem.fixturenames` is just an alias for `_pyfuncitem._fixtureinfo.names_closure` (at least in core pytest), so let's do the less abstraction-breaking thing. --- src/_pytest/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 88ee50adf..0fefaa0d0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -405,7 +405,7 @@ class FixtureRequest(abc.ABC): @property def fixturenames(self) -> List[str]: """Names of all active fixtures in this request.""" - result = list(self._pyfuncitem._fixtureinfo.names_closure) + result = list(self._pyfuncitem.fixturenames) result.extend(set(self._fixture_defs).difference(result)) return result From d2b5177dd666d034e982db1dd69e411fcff123dd Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 Sep 2023 15:01:05 +0300 Subject: [PATCH 10/27] fixtures: avoid some redundant work in `_fillfixtures` --- src/_pytest/fixtures.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 0fefaa0d0..7d3c8da11 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -685,7 +685,10 @@ class TopRequest(FixtureRequest): def _fillfixtures(self) -> None: item = self._pyfuncitem - fixturenames = getattr(item, "fixturenames", self.fixturenames) + fixturenames = getattr(item, "fixturenames", None) + if fixturenames is None: + # Mildly expensive so don't move into the getattr! + fixturenames = self.fixturenames for argname in fixturenames: if argname not in item.funcargs: item.funcargs[argname] = self.getfixturevalue(argname) From b8906b29a758faebba775b1ad544bba537c99d69 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 Sep 2023 15:13:15 +0300 Subject: [PATCH 11/27] fixtures: require `item.fixturenames` to exist in `_fillfixtures` I could find 2 plugins that would be broken by this (pytest-play and pytest-wdl), but they will be better served by just copying `_fillfixtures` instead of use the private function. --- src/_pytest/fixtures.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 7d3c8da11..dee34c348 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -685,11 +685,7 @@ class TopRequest(FixtureRequest): def _fillfixtures(self) -> None: item = self._pyfuncitem - fixturenames = getattr(item, "fixturenames", None) - if fixturenames is None: - # Mildly expensive so don't move into the getattr! - fixturenames = self.fixturenames - for argname in fixturenames: + for argname in item.fixturenames: if argname not in item.funcargs: item.funcargs[argname] = self.getfixturevalue(argname) From 574e0f45d908cd51f81538543e9aedcaeaac6900 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 1 Sep 2023 15:36:41 +0300 Subject: [PATCH 12/27] fixtures: avoid using the mildly expensive `fixturenames` property Avoid creating a list copy + 2 sets + a linear search through the list (in the common case). --- src/_pytest/fixtures.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index dee34c348..e692c9489 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -791,7 +791,10 @@ class SubRequest(FixtureRequest): # 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. - if fixturedef.argname not in self.fixturenames: + if ( + fixturedef.argname not in self._fixture_defs + and fixturedef.argname not in self._pyfuncitem.fixturenames + ): fixturedef.addfinalizer( functools.partial(self._fixturedef.finish, request=self) ) From bc71561ad9bb5d7a68f0d0da3a3ba79c5c327892 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 5 Sep 2023 08:59:44 +0300 Subject: [PATCH 13/27] python: avoid an Any --- src/_pytest/python.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 879905486..e8d55c929 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -473,7 +473,9 @@ class PyCollector(PyobjMixin, nodes.Collector): clscol = self.getparent(Class) cls = clscol and clscol.obj or None - definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) + definition: FunctionDefinition = FunctionDefinition.from_parent( + self, name=name, callobj=funcobj + ) fixtureinfo = definition._fixtureinfo # pytest_generate_tests impls call metafunc.parametrize() which fills From 194a782e3817ee9f4f77a7a61ec68d25b3b08250 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 5 Sep 2023 19:42:40 -0300 Subject: [PATCH 14/27] Fix import_path for packages (#11390) For packages, `import_path` receives the path to the package's `__init__.py` file, however module names (as they live in `sys.modules`) should not include the `__init__` part. For example, `app/core/__init__.py` should be imported as `app.core`, not as `app.core.__init__`. Fix #11306 --- changelog/11306.bugfix.rst | 1 + src/_pytest/pathlib.py | 4 ++++ testing/test_pathlib.py | 45 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 changelog/11306.bugfix.rst diff --git a/changelog/11306.bugfix.rst b/changelog/11306.bugfix.rst new file mode 100644 index 000000000..02e0957a9 --- /dev/null +++ b/changelog/11306.bugfix.rst @@ -0,0 +1 @@ +Fixed bug using ``--importmode=importlib`` which would cause package ``__init__.py`` files to be imported more than once in some cases. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 138a0bdb2..7ecd93fe5 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -623,6 +623,10 @@ def module_name_from_path(path: Path, root: Path) -> str: # Use the parts for the relative path to the root path. path_parts = relative_path.parts + # Module name for packages do not contain the __init__ file. + if path_parts[-1] == "__init__": + path_parts = path_parts[:-1] + return ".".join(path_parts) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 3d574e856..1ca641437 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -18,6 +18,7 @@ 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 ImportMode from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import insert_missing_modules from _pytest.pathlib import maybe_delete_a_numbered_dir @@ -585,6 +586,10 @@ class TestImportLibMode: result = module_name_from_path(Path("/home/foo/test_foo.py"), Path("/bar")) assert result == "home.foo.test_foo" + # Importing __init__.py files should return the package as module name. + result = module_name_from_path(tmp_path / "src/app/__init__.py", tmp_path) + assert result == "src.app" + def test_insert_missing_modules( self, monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: @@ -615,3 +620,43 @@ class TestImportLibMode: assert sorted(modules) == ["xxx", "xxx.tests", "xxx.tests.foo"] assert modules["xxx"].tests is modules["xxx.tests"] assert modules["xxx.tests"].foo is modules["xxx.tests.foo"] + + def test_importlib_package(self, monkeypatch: MonkeyPatch, tmp_path: Path): + """ + Importing a package using --importmode=importlib should not import the + package's __init__.py file more than once (#11306). + """ + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(tmp_path) + + package_name = "importlib_import_package" + tmp_path.joinpath(package_name).mkdir() + init = tmp_path.joinpath(f"{package_name}/__init__.py") + init.write_text( + dedent( + """ + from .singleton import Singleton + + instance = Singleton() + """ + ), + encoding="ascii", + ) + singleton = tmp_path.joinpath(f"{package_name}/singleton.py") + singleton.write_text( + dedent( + """ + class Singleton: + INSTANCES = [] + + def __init__(self) -> None: + self.INSTANCES.append(self) + if len(self.INSTANCES) > 1: + raise RuntimeError("Already initialized") + """ + ), + encoding="ascii", + ) + + mod = import_path(init, root=tmp_path, mode=ImportMode.importlib) + assert len(mod.instance.INSTANCES) == 1 From 3ce63bc76874b6b60377c8f001ceb03ed5c2f34f Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Wed, 6 Sep 2023 12:34:38 +0200 Subject: [PATCH 15/27] Improve plugin list disclaimer (#11397) Closes #11391 --- AUTHORS | 1 + changelog/11391.doc.rst | 1 + doc/en/reference/plugin_list.rst | 22 +++++++++++++++++----- scripts/update-plugin-list.py | 22 +++++++++++++++++----- 4 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 changelog/11391.doc.rst diff --git a/AUTHORS b/AUTHORS index 52157cc0a..b6bc02988 100644 --- a/AUTHORS +++ b/AUTHORS @@ -348,6 +348,7 @@ Simon Holesch Simon Kerr Skylar Downes Srinivas Reddy Thatiparthy +Stefaan Lippens Stefan Farmbauer Stefan Scherfke Stefan Zimmermann diff --git a/changelog/11391.doc.rst b/changelog/11391.doc.rst new file mode 100644 index 000000000..fff324af1 --- /dev/null +++ b/changelog/11391.doc.rst @@ -0,0 +1 @@ +Improved disclaimer on pytest plugin reference page to better indicate this is an automated, non-curated listing. diff --git a/doc/en/reference/plugin_list.rst b/doc/en/reference/plugin_list.rst index 7dae208a0..68eaf69ea 100644 --- a/doc/en/reference/plugin_list.rst +++ b/doc/en/reference/plugin_list.rst @@ -3,14 +3,26 @@ .. _plugin-list: -Plugin List -=========== +Pytest Plugin List +================== -PyPI projects that match "pytest-\*" are considered plugins and are listed -automatically together with a manually-maintained list in `the source -code `_. +Below is an automated compilation of ``pytest``` plugins available on `PyPI `_. +It includes PyPI projects whose names begin with "pytest-" and a handful of manually selected projects. Packages classified as inactive are excluded. +For detailed insights into how this list is generated, +please refer to `the update script `_. + +.. warning:: + + Please be aware that this list is not a curated collection of projects + and does not undergo a systematic review process. + It serves purely as an informational resource to aid in the discovery of ``pytest`` plugins. + + Do not presume any endorsement from the ``pytest`` project or its developers, + and always conduct your own quality assessment before incorporating any of these plugins into your own projects. + + .. The following conditional uses a different format for this list when creating a PDF, because otherwise the table gets far too wide for the page. diff --git a/scripts/update-plugin-list.py b/scripts/update-plugin-list.py index f345e1f7c..c5f918724 100644 --- a/scripts/update-plugin-list.py +++ b/scripts/update-plugin-list.py @@ -20,14 +20,26 @@ FILE_HEAD = r""" .. _plugin-list: -Plugin List -=========== +Pytest Plugin List +================== -PyPI projects that match "pytest-\*" are considered plugins and are listed -automatically together with a manually-maintained list in `the source -code `_. +Below is an automated compilation of ``pytest``` plugins available on `PyPI `_. +It includes PyPI projects whose names begin with "pytest-" and a handful of manually selected projects. Packages classified as inactive are excluded. +For detailed insights into how this list is generated, +please refer to `the update script `_. + +.. warning:: + + Please be aware that this list is not a curated collection of projects + and does not undergo a systematic review process. + It serves purely as an informational resource to aid in the discovery of ``pytest`` plugins. + + Do not presume any endorsement from the ``pytest`` project or its developers, + and always conduct your own quality assessment before incorporating any of these plugins into your own projects. + + .. The following conditional uses a different format for this list when creating a PDF, because otherwise the table gets far too wide for the page. From f6b6478868322a5958a7cf24886257e5990c6ced Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 6 Sep 2023 15:22:27 +0200 Subject: [PATCH 16/27] doc: Remove done training (#11399) --- doc/en/index.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/en/index.rst b/doc/en/index.rst index 73bf2e4d8..b9331eb9a 100644 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -2,7 +2,6 @@ .. sidebar:: Next Open Trainings - - `pytest: Professionelles Testen (nicht nur) für Python `_, at `Workshoptage 2023 `_, **September 5th**, `OST `_ Campus **Rapperswil, Switzerland** - `Professional Testing with Python `_, via `Python Academy `_, **March 5th to 7th 2024** (3 day in-depth training), **Leipzig, Germany / Remote** Also see :doc:`previous talks and blogposts `. From 333e4eba6b09f40f80aaeee6581a37c5af34aad7 Mon Sep 17 00:00:00 2001 From: Fraser Stark Date: Thu, 7 Sep 2023 16:11:59 +0100 Subject: [PATCH 17/27] Change PytestReturnNotNoneWarning to return a normal warning (#11211) Fixes #10465 --- AUTHORS | 1 + changelog/10465.deprecation.rst | 1 + changelog/11151.breaking.rst | 3 +-- src/_pytest/warning_types.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog/10465.deprecation.rst diff --git a/AUTHORS b/AUTHORS index 5ea751bbc..466779f6d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -143,6 +143,7 @@ Feng Ma Florian Bruhin Florian Dahlitz Floris Bruynooghe +Fraser Stark Gabriel Landau Gabriel Reis Garvit Shubham diff --git a/changelog/10465.deprecation.rst b/changelog/10465.deprecation.rst new file mode 100644 index 000000000..a715af5e6 --- /dev/null +++ b/changelog/10465.deprecation.rst @@ -0,0 +1 @@ +Test functions returning a value other than None will now issue a :class:`pytest.PytestWarning` instead of :class:`pytest.PytestRemovedIn8Warning`, meaning this will stay a warning instead of becoming an error in the future. diff --git a/changelog/11151.breaking.rst b/changelog/11151.breaking.rst index 2e86c5dfb..114a7d8e2 100644 --- a/changelog/11151.breaking.rst +++ b/changelog/11151.breaking.rst @@ -1,2 +1 @@ -Dropped support for Python 3.7, which `reached end-of-life on 2023-06-27 -`__. +Dropped support for Python 3.7, which `reached end-of-life on 2023-06-27 `__. diff --git a/src/_pytest/warning_types.py b/src/_pytest/warning_types.py index 31726e1ce..4219f1439 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -61,7 +61,7 @@ class PytestRemovedIn9Warning(PytestDeprecationWarning): __module__ = "pytest" -class PytestReturnNotNoneWarning(PytestRemovedIn8Warning): +class PytestReturnNotNoneWarning(PytestWarning): """Warning emitted when a test function is returning value other than None.""" __module__ = "pytest" From 28ccf476b91be32ffda303f0d7a8b57e475b465b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 7 Sep 2023 12:49:25 -0300 Subject: [PATCH 18/27] Fix crash when passing a very long cmdline argument (#11404) Fixes #11394 --- changelog/11394.bugfix.rst | 1 + src/_pytest/main.py | 3 ++- src/_pytest/pathlib.py | 11 +++++++++++ testing/test_main.py | 31 +++++++++++++++++++++++++++++++ testing/test_pathlib.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 changelog/11394.bugfix.rst diff --git a/changelog/11394.bugfix.rst b/changelog/11394.bugfix.rst new file mode 100644 index 000000000..aa89c81b0 --- /dev/null +++ b/changelog/11394.bugfix.rst @@ -0,0 +1 @@ +Fixed crash when parsing long command line arguments that might be interpreted as files. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index fd3836736..d979f3f50 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -36,6 +36,7 @@ from _pytest.outcomes import exit from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath from _pytest.pathlib import fnmatch_ex +from _pytest.pathlib import safe_exists from _pytest.pathlib import visit from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -888,7 +889,7 @@ def resolve_collection_argument( strpath = search_pypath(strpath) fspath = invocation_path / strpath fspath = absolutepath(fspath) - if not fspath.exists(): + if not safe_exists(fspath): msg = ( "module or package not found: {arg} (missing __init__.py?)" if as_pypath diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 7ecd93fe5..f44180f1a 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,5 +1,6 @@ import atexit import contextlib +import errno import fnmatch import importlib.util import itertools @@ -773,3 +774,13 @@ def bestrelpath(directory: Path, dest: Path) -> str: # Forward from base to dest. *reldest.parts, ) + + +def safe_exists(p: Path) -> bool: + """Like Path.exists(), but account for input arguments that might be too long (#11394).""" + try: + return p.exists() + except OSError as e: + if e.errno == errno.ENAMETOOLONG: + return False + raise diff --git a/testing/test_main.py b/testing/test_main.py index 715976267..3c8998c1a 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -262,3 +262,34 @@ def test_module_full_path_without_drive(pytester: Pytester) -> None: "* 1 passed in *", ] ) + + +def test_very_long_cmdline_arg(pytester: Pytester) -> None: + """ + Regression test for #11394. + + Note: we could not manage to actually reproduce the error with this code, we suspect + GitHub runners are configured to support very long paths, however decided to leave + the test in place in case this ever regresses in the future. + """ + pytester.makeconftest( + """ + import pytest + + def pytest_addoption(parser): + parser.addoption("--long-list", dest="long_list", action="store", default="all", help="List of things") + + @pytest.fixture(scope="module") + def specified_feeds(request): + list_string = request.config.getoption("--long-list") + return list_string.split(',') + """ + ) + pytester.makepyfile( + """ + def test_foo(specified_feeds): + assert len(specified_feeds) == 100_000 + """ + ) + result = pytester.runpytest("--long-list", ",".join(["helloworld"] * 100_000)) + result.stdout.fnmatch_lines("* 1 passed *") diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 1ca641437..8a9659aab 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1,3 +1,4 @@ +import errno import os.path import pickle import sys @@ -24,6 +25,7 @@ from _pytest.pathlib import insert_missing_modules from _pytest.pathlib import maybe_delete_a_numbered_dir from _pytest.pathlib import module_name_from_path from _pytest.pathlib import resolve_package_path +from _pytest.pathlib import safe_exists from _pytest.pathlib import symlink_or_skip from _pytest.pathlib import visit from _pytest.tmpdir import TempPathFactory @@ -660,3 +662,33 @@ class TestImportLibMode: mod = import_path(init, root=tmp_path, mode=ImportMode.importlib) assert len(mod.instance.INSTANCES) == 1 + + +def test_safe_exists(tmp_path: Path) -> None: + d = tmp_path.joinpath("some_dir") + d.mkdir() + assert safe_exists(d) is True + + f = tmp_path.joinpath("some_file") + f.touch() + assert safe_exists(f) is True + + # Use unittest.mock() as a context manager to have a very narrow + # patch lifetime. + p = tmp_path.joinpath("some long filename" * 100) + with unittest.mock.patch.object( + Path, + "exists", + autospec=True, + side_effect=OSError(errno.ENAMETOOLONG, "name too long"), + ): + assert safe_exists(p) is False + + with unittest.mock.patch.object( + Path, + "exists", + autospec=True, + side_effect=OSError(errno.EIO, "another kind of error"), + ): + with pytest.raises(OSError): + _ = safe_exists(p) From 5936a79fdbf69b7ba274df9bd005c8bad0e9f310 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 7 Sep 2023 15:44:47 -0300 Subject: [PATCH 19/27] Use _pytest.pathlib.safe_exists in get_dirs_from_args (#11407) Related to #11394 --- src/_pytest/config/__init__.py | 9 +++------ src/_pytest/config/findpaths.py | 9 +-------- src/_pytest/pathlib.py | 9 ++++----- testing/test_pathlib.py | 5 ++--- 4 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index cde230fdb..eb03b6338 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -59,6 +59,7 @@ from _pytest.pathlib import bestrelpath from _pytest.pathlib import import_path from _pytest.pathlib import ImportMode from _pytest.pathlib import resolve_package_path +from _pytest.pathlib import safe_exists from _pytest.stash import Stash from _pytest.warning_types import PytestConfigWarning from _pytest.warning_types import warn_explicit_for @@ -562,12 +563,8 @@ class PytestPluginManager(PluginManager): anchor = absolutepath(current / path) # Ensure we do not break if what appears to be an anchor - # is in fact a very long option (#10169). - try: - anchor_exists = anchor.exists() - except OSError: # pragma: no cover - anchor_exists = False - if anchor_exists: + # is in fact a very long option (#10169, #11394). + if safe_exists(anchor): self._try_load_conftest(anchor, importmode, rootpath) foundanchor = True if not foundanchor: diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 9c76947a4..fc30533b6 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -15,6 +15,7 @@ from .exceptions import UsageError from _pytest.outcomes import fail from _pytest.pathlib import absolutepath from _pytest.pathlib import commonpath +from _pytest.pathlib import safe_exists def _parse_ini_config(path: Path) -> iniconfig.IniConfig: @@ -147,14 +148,6 @@ def get_dirs_from_args(args: Iterable[str]) -> List[Path]: return path return path.parent - def safe_exists(path: Path) -> bool: - # This can throw on paths that contain characters unrepresentable at the OS level, - # or with invalid syntax on Windows (https://bugs.python.org/issue35306) - try: - return path.exists() - except OSError: - return False - # These look like paths but may not exist possible_paths = ( absolutepath(get_file_part_from_node_id(arg)) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index f44180f1a..63b1345b4 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,6 +1,5 @@ import atexit import contextlib -import errno import fnmatch import importlib.util import itertools @@ -780,7 +779,7 @@ def safe_exists(p: Path) -> bool: """Like Path.exists(), but account for input arguments that might be too long (#11394).""" try: return p.exists() - except OSError as e: - if e.errno == errno.ENAMETOOLONG: - return False - raise + except (ValueError, OSError): + # ValueError: stat: path too long for Windows + # OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect + return False diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 8a9659aab..678fd27fe 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -688,7 +688,6 @@ def test_safe_exists(tmp_path: Path) -> None: Path, "exists", autospec=True, - side_effect=OSError(errno.EIO, "another kind of error"), + side_effect=ValueError("name too long"), ): - with pytest.raises(OSError): - _ = safe_exists(p) + assert safe_exists(p) is False From 0a06db0729ef837fdbdfd25f34dbd9cc4fdb59c7 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 7 Sep 2023 16:10:19 -0300 Subject: [PATCH 20/27] Merge pull request #11408 from pytest-dev/release-7.4.2 (#11409) Prepare release 7.4.2 (cherry picked from commit b0c4775a28aebcd3d3d6394ebb36838df01f809d) --- changelog/11237.bugfix.rst | 1 - changelog/11306.bugfix.rst | 1 - changelog/11367.bugfix.rst | 1 - changelog/11391.doc.rst | 1 - changelog/11394.bugfix.rst | 1 - doc/en/announce/index.rst | 1 + doc/en/announce/release-7.4.2.rst | 18 ++++++++++++++++++ doc/en/builtin.rst | 2 +- doc/en/changelog.rst | 25 +++++++++++++++++++++++++ doc/en/example/reportingdemo.rst | 20 ++++++++++---------- doc/en/getting-started.rst | 2 +- 11 files changed, 56 insertions(+), 17 deletions(-) delete mode 100644 changelog/11237.bugfix.rst delete mode 100644 changelog/11306.bugfix.rst delete mode 100644 changelog/11367.bugfix.rst delete mode 100644 changelog/11391.doc.rst delete mode 100644 changelog/11394.bugfix.rst create mode 100644 doc/en/announce/release-7.4.2.rst diff --git a/changelog/11237.bugfix.rst b/changelog/11237.bugfix.rst deleted file mode 100644 index d054fc18d..000000000 --- a/changelog/11237.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix doctest collection of `functools.cached_property` objects. diff --git a/changelog/11306.bugfix.rst b/changelog/11306.bugfix.rst deleted file mode 100644 index 02e0957a9..000000000 --- a/changelog/11306.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed bug using ``--importmode=importlib`` which would cause package ``__init__.py`` files to be imported more than once in some cases. diff --git a/changelog/11367.bugfix.rst b/changelog/11367.bugfix.rst deleted file mode 100644 index dda40db0f..000000000 --- a/changelog/11367.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed bug where `user_properties` where not being saved in the JUnit XML file if a fixture failed during teardown. diff --git a/changelog/11391.doc.rst b/changelog/11391.doc.rst deleted file mode 100644 index fff324af1..000000000 --- a/changelog/11391.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Improved disclaimer on pytest plugin reference page to better indicate this is an automated, non-curated listing. diff --git a/changelog/11394.bugfix.rst b/changelog/11394.bugfix.rst deleted file mode 100644 index aa89c81b0..000000000 --- a/changelog/11394.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed crash when parsing long command line arguments that might be interpreted as files. diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 85dfa0894..39fdfc137 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-7.4.2 release-7.4.1 release-7.4.0 release-7.3.2 diff --git a/doc/en/announce/release-7.4.2.rst b/doc/en/announce/release-7.4.2.rst new file mode 100644 index 000000000..22191e7b4 --- /dev/null +++ b/doc/en/announce/release-7.4.2.rst @@ -0,0 +1,18 @@ +pytest-7.4.2 +======================================= + +pytest 7.4.2 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/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira + + +Happy testing, +The pytest Development Team diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index 0d673d042..405289444 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -105,7 +105,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a captured = capsys.readouterr() assert captured.out == "hello\n" - doctest_namespace [session scope] -- .../_pytest/doctest.py:737 + doctest_namespace [session scope] -- .../_pytest/doctest.py:757 Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index be7e7faba..ecfeeb662 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -28,6 +28,31 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 7.4.2 (2023-09-07) +========================= + +Bug Fixes +--------- + +- `#11237 `_: Fix doctest collection of `functools.cached_property` objects. + + +- `#11306 `_: Fixed bug using ``--importmode=importlib`` which would cause package ``__init__.py`` files to be imported more than once in some cases. + + +- `#11367 `_: Fixed bug where `user_properties` where not being saved in the JUnit XML file if a fixture failed during teardown. + + +- `#11394 `_: Fixed crash when parsing long command line arguments that might be interpreted as files. + + + +Improved Documentation +---------------------- + +- `#11391 `_: Improved disclaimer on pytest plugin reference page to better indicate this is an automated, non-curated listing. + + pytest 7.4.1 (2023-09-02) ========================= diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index d4d3d3ce2..cb59c4b42 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -554,13 +554,13 @@ Here is a nice run of several failures and how ``pytest`` presents things: E AssertionError: assert False E + where False = ('456') E + where = '123'.startswith - E + where '123' = .f at 0xdeadbeef0006>() - E + and '456' = .g at 0xdeadbeef0029>() + E + where '123' = .f at 0xdeadbeef0029>() + E + and '456' = .g at 0xdeadbeef002a>() failure_demo.py:235: AssertionError _____________________ TestMoreErrors.test_global_func ______________________ - self = + self = def test_global_func(self): > assert isinstance(globf(42), float) @@ -571,18 +571,18 @@ Here is a nice run of several failures and how ``pytest`` presents things: failure_demo.py:238: AssertionError _______________________ TestMoreErrors.test_instance _______________________ - self = + self = def test_instance(self): self.x = 6 * 7 > assert self.x != 42 E assert 42 != 42 - E + where 42 = .x + E + where 42 = .x failure_demo.py:242: AssertionError _______________________ TestMoreErrors.test_compare ________________________ - self = + self = def test_compare(self): > assert globf(10) < 5 @@ -592,7 +592,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: failure_demo.py:245: AssertionError _____________________ TestMoreErrors.test_try_finally ______________________ - self = + self = def test_try_finally(self): x = 1 @@ -603,7 +603,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: failure_demo.py:250: AssertionError ___________________ TestCustomAssertMsg.test_single_line ___________________ - self = + self = def test_single_line(self): class A: @@ -618,7 +618,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: failure_demo.py:261: AssertionError ____________________ TestCustomAssertMsg.test_multiline ____________________ - self = + self = def test_multiline(self): class A: @@ -637,7 +637,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: failure_demo.py:268: AssertionError ___________________ TestCustomAssertMsg.test_custom_repr ___________________ - self = + self = def test_custom_repr(self): class JSON: diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index c7a1ee540..f8f994473 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -22,7 +22,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - pytest 7.4.1 + pytest 7.4.2 .. _`simpletest`: From e5c81fa41aa437261009d2dcbed5f05bb2b86647 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 8 Sep 2023 07:22:16 -0300 Subject: [PATCH 21/27] Adjustments to the release process (#11410) As discussed in #11408: * Improve documentation for the release process. * Fix the description for the PRs created by the `prepare release pr` workflow. * Fix pushing tag in the `deploy` workflow. --- .github/workflows/deploy.yml | 2 ++ RELEASING.rst | 3 ++- scripts/prepare-release-pr.py | 12 +++++++++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dd3d5eeb4..b8a0a67a2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,8 +36,10 @@ jobs: timeout-minutes: 30 permissions: id-token: write + contents: write steps: - uses: actions/checkout@v3 + - name: Download Package uses: actions/download-artifact@v3 with: diff --git a/RELEASING.rst b/RELEASING.rst index 5d49fb5d6..08004a84c 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -134,7 +134,8 @@ Releasing Both automatic and manual processes described above follow the same steps from this point onward. #. After all tests pass and the PR has been approved, trigger the ``deploy`` job - in https://github.com/pytest-dev/pytest/actions/workflows/deploy.yml. + in https://github.com/pytest-dev/pytest/actions/workflows/deploy.yml, using the ``release-MAJOR.MINOR.PATCH`` branch + as source. This job will require approval from ``pytest-dev/core``, after which it will publish to PyPI and tag the repository. diff --git a/scripts/prepare-release-pr.py b/scripts/prepare-release-pr.py index 7a80de7ed..a0e5e4d7f 100644 --- a/scripts/prepare-release-pr.py +++ b/scripts/prepare-release-pr.py @@ -31,10 +31,16 @@ class InvalidFeatureRelease(Exception): SLUG = "pytest-dev/pytest" PR_BODY = """\ -Created automatically from manual trigger. +Created by the [prepare release pr](https://github.com/pytest-dev/pytest/actions/workflows/prepare-release-pr.yml) +workflow. -Once all builds pass and it has been **approved** by one or more maintainers, the build -can be released by pushing a tag `{version}` to this repository. +Once all builds pass and it has been **approved** by one or more maintainers, +start the [deploy](https://github.com/pytest-dev/pytest/actions/workflows/deploy.yml) workflow, using these parameters: + +* `Use workflow from`: `release-{version}`. +* `Release version`: `{version}`. + +After the `deploy` workflow has been approved by a core maintainer, the package will be uploaded to PyPI automatically. """ From 9c112755535400e6008c8a479a72b5f56d0687b0 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 4 Sep 2023 23:31:51 +0300 Subject: [PATCH 22/27] fixtures: change getfixtureclosure(ignore_args) to a set Only used for containment checks so a Set is more appropriate than a list. --- src/_pytest/fixtures.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e692c9489..205176f57 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -8,6 +8,7 @@ from collections import defaultdict from collections import deque from contextlib import suppress from pathlib import Path +from typing import AbstractSet from typing import Any from typing import Callable from typing import cast @@ -1382,7 +1383,7 @@ def pytest_addoption(parser: Parser) -> None: ) -def _get_direct_parametrize_args(node: nodes.Node) -> List[str]: +def _get_direct_parametrize_args(node: nodes.Node) -> Set[str]: """Return all direct parametrization arguments of a node, so we don't mistake them for fixtures. @@ -1391,14 +1392,13 @@ def _get_direct_parametrize_args(node: nodes.Node) -> List[str]: These things are done later as well when dealing with parametrization so this could be improved. """ - parametrize_argnames: List[str] = [] + parametrize_argnames: Set[str] = set() for marker in node.iter_markers(name="parametrize"): if not marker.kwargs.get("indirect", False): p_argnames, _ = ParameterSet._parse_parametrize_args( *marker.args, **marker.kwargs ) - parametrize_argnames.extend(p_argnames) - + parametrize_argnames.update(p_argnames) return parametrize_argnames @@ -1519,7 +1519,7 @@ class FixtureManager: self, fixturenames: Tuple[str, ...], parentnode: nodes.Node, - ignore_args: Sequence[str] = (), + ignore_args: AbstractSet[str], ) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]: # Collect the closure of all fixtures, starting with the given # fixturenames as the initial set. As we have to visit all From 48b03956482e2082191bb58d707f3c004f15aa68 Mon Sep 17 00:00:00 2001 From: Sadra Barikbin Date: Mon, 4 Sep 2023 23:44:49 +0300 Subject: [PATCH 23/27] fixtures: clean up getfixtureclosure() Some code cleanups - no functional changes. --- src/_pytest/fixtures.py | 51 +++++++++++++++++++++----------------- testing/python/fixtures.py | 8 ++++++ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 205176f57..80b965b81 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1402,6 +1402,12 @@ def _get_direct_parametrize_args(node: nodes.Node) -> Set[str]: return parametrize_argnames +def deduplicate_names(*seqs: Iterable[str]) -> Tuple[str, ...]: + """De-duplicate the sequence of names while keeping the original order.""" + # Ideally we would use a set, but it does not preserve insertion order. + return tuple(dict.fromkeys(name for seq in seqs for name in seq)) + + class FixtureManager: """pytest fixture definitions and information is stored and managed from this class. @@ -1476,14 +1482,18 @@ class FixtureManager: argnames = getfuncargnames(func, name=node.name, cls=cls) else: argnames = () + usefixturesnames = self._getusefixturesnames(node) + autousenames = self._getautousenames(node.nodeid) + initialnames = deduplicate_names(autousenames, usefixturesnames, argnames) - usefixtures = tuple( - arg for mark in node.iter_markers(name="usefixtures") for arg in mark.args - ) - initialnames = usefixtures + argnames - initialnames, names_closure, arg2fixturedefs = self.getfixtureclosure( - initialnames, node, ignore_args=_get_direct_parametrize_args(node) + direct_parametrize_args = _get_direct_parametrize_args(node) + + names_closure, arg2fixturedefs = self.getfixtureclosure( + parentnode=node, + initialnames=initialnames, + ignore_args=direct_parametrize_args, ) + return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs) def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: @@ -1515,12 +1525,17 @@ class FixtureManager: if basenames: yield from basenames + def _getusefixturesnames(self, node: nodes.Item) -> Iterator[str]: + """Return the names of usefixtures fixtures applicable to node.""" + for mark in node.iter_markers(name="usefixtures"): + yield from mark.args + def getfixtureclosure( self, - fixturenames: Tuple[str, ...], parentnode: nodes.Node, + initialnames: Tuple[str, ...], ignore_args: AbstractSet[str], - ) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]: + ) -> Tuple[List[str], Dict[str, Sequence[FixtureDef[Any]]]]: # 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 @@ -1529,19 +1544,7 @@ class FixtureManager: # (discovering matching fixtures for a given name/node is expensive). parentid = parentnode.nodeid - fixturenames_closure = list(self._getautousenames(parentid)) - - def merge(otherlist: Iterable[str]) -> None: - for arg in otherlist: - if arg not in fixturenames_closure: - fixturenames_closure.append(arg) - - merge(fixturenames) - - # At this point, fixturenames_closure contains what we call "initialnames", - # which is a set of fixturenames the function immediately requests. We - # need to return it as well, so save this. - initialnames = tuple(fixturenames_closure) + fixturenames_closure = list(initialnames) arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {} lastlen = -1 @@ -1555,7 +1558,9 @@ class FixtureManager: fixturedefs = self.getfixturedefs(argname, parentid) if fixturedefs: arg2fixturedefs[argname] = fixturedefs - merge(fixturedefs[-1].argnames) + for arg in fixturedefs[-1].argnames: + if arg not in fixturenames_closure: + fixturenames_closure.append(arg) def sort_by_scope(arg_name: str) -> Scope: try: @@ -1566,7 +1571,7 @@ class FixtureManager: return fixturedefs[-1]._scope fixturenames_closure.sort(key=sort_by_scope, reverse=True) - return initialnames, fixturenames_closure, arg2fixturedefs + return fixturenames_closure, arg2fixturedefs def pytest_generate_tests(self, metafunc: "Metafunc") -> None: """Generate new tests based on parametrized fixtures used by the given metafunc""" diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index a8f36cb9f..775056a8e 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest from _pytest.compat import getfuncargnames from _pytest.config import ExitCode +from _pytest.fixtures import deduplicate_names from _pytest.fixtures import TopRequest from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import get_public_names @@ -4531,3 +4532,10 @@ def test_yield_fixture_with_no_value(pytester: Pytester) -> None: result.assert_outcomes(errors=1) result.stdout.fnmatch_lines([expected]) assert result.ret == ExitCode.TESTS_FAILED + + +def test_deduplicate_names() -> None: + items = deduplicate_names("abacd") + assert items == ("a", "b", "c", "d") + items = deduplicate_names(items + ("g", "f", "g", "e", "b")) + assert items == ("a", "b", "c", "d", "g", "f", "e") From b3a981d3859f563fa6c24d8a30e1bf76030d2968 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 8 Sep 2023 10:23:14 +0300 Subject: [PATCH 24/27] fixtures: remove `getfixtureinfo(funcargs)` in favor of None `func` Since we already broke plugins using this (private) interface in this version (pytest-play, pytest-wdl), might as well do a cleanup. --- src/_pytest/doctest.py | 7 +------ src/_pytest/fixtures.py | 9 +++------ src/_pytest/python.py | 5 ++--- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 46a3daa72..97aa5d3dd 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -592,14 +592,9 @@ class DoctestModule(Module): def _setup_fixtures(doctest_item: DoctestItem) -> TopRequest: """Used by DoctestTextfile and DoctestItem to setup fixture information.""" - def func() -> None: - pass - doctest_item.funcargs = {} # type: ignore[attr-defined] fm = doctest_item.session._fixturemanager - fixtureinfo = fm.getfixtureinfo( - node=doctest_item, func=func, cls=None, funcargs=False - ) + fixtureinfo = fm.getfixtureinfo(node=doctest_item, func=None, cls=None) doctest_item._fixtureinfo = fixtureinfo # type: ignore[attr-defined] doctest_item.fixturenames = fixtureinfo.names_closure # type: ignore[attr-defined] fixture_request = TopRequest(doctest_item, _ispytest=True) # type: ignore[arg-type] diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 80b965b81..d56274629 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1460,13 +1460,12 @@ class FixtureManager: def getfixtureinfo( self, node: nodes.Item, - func: Callable[..., object], + func: Optional[Callable[..., object]], cls: Optional[type], - funcargs: bool = True, ) -> FuncFixtureInfo: """Calculate the :class:`FuncFixtureInfo` for an item. - If ``funcargs`` is false, or if the item sets an attribute + If ``func`` is None, or if the item sets an attribute ``nofuncargs = True``, then ``func`` is not examined at all. :param node: @@ -1475,10 +1474,8 @@ class FixtureManager: The item's function. :param cls: If the function is a method, the method's class. - :param funcargs: - Whether to look into func's parameters as fixture requests. """ - if funcargs and not getattr(node, "nofuncargs", False): + if func is not None and not getattr(node, "nofuncargs", False): argnames = getfuncargnames(func, name=node.name, cls=cls) else: argnames = () diff --git a/src/_pytest/python.py b/src/_pytest/python.py index e8d55c929..cbb82e390 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1800,9 +1800,8 @@ class Function(PyobjMixin, nodes.Item): self.keywords.update(keywords) if fixtureinfo is None: - fixtureinfo = self.session._fixturemanager.getfixtureinfo( - self, self.obj, self.cls, funcargs=True - ) + fm = self.session._fixturemanager + fixtureinfo = fm.getfixtureinfo(self, self.obj, self.cls) self._fixtureinfo: FuncFixtureInfo = fixtureinfo self.fixturenames = fixtureinfo.names_closure self._initrequest() From ab63ebb3dc07b89670b96ae97044f48406c44fa0 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 8 Sep 2023 10:52:08 +0300 Subject: [PATCH 25/27] doctest: inline `_setup_fixtures`, make more similar to `Function` There used to be two callers to `_setup_fixtures()`, now there's only one, so inline it and make `DoctestItem` more similar to `Function`. (Eventually we may want to generalize `TopRequest` from taking `Function` directly to some "fixture-supporting item", removing the remaining `type: ignore` here and allowing plugins to do it in a stable manner). --- src/_pytest/doctest.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 97aa5d3dd..bc930724b 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -261,8 +261,14 @@ class DoctestItem(Item): super().__init__(name, parent) self.runner = runner self.dtest = dtest + + # Stuff needed for fixture support. self.obj = None - self.fixture_request: Optional[TopRequest] = None + fm = self.session._fixturemanager + fixtureinfo = fm.getfixtureinfo(node=self, func=None, cls=None) + self._fixtureinfo = fixtureinfo + self.fixturenames = fixtureinfo.names_closure + self._initrequest() @classmethod def from_parent( # type: ignore @@ -277,11 +283,16 @@ class DoctestItem(Item): """The public named constructor.""" return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) + def _initrequest(self) -> None: + self.funcargs: Dict[str, object] = {} + self._request = TopRequest(self, _ispytest=True) # type: ignore[arg-type] + def setup(self) -> None: if self.dtest is not None: - self.fixture_request = _setup_fixtures(self) - globs = dict(getfixture=self.fixture_request.getfixturevalue) - for name, value in self.fixture_request.getfixturevalue( + self._request._fillfixtures() + + globs = dict(getfixture=self._request.getfixturevalue) + for name, value in self._request.getfixturevalue( "doctest_namespace" ).items(): globs[name] = value @@ -589,19 +600,6 @@ class DoctestModule(Module): ) -def _setup_fixtures(doctest_item: DoctestItem) -> TopRequest: - """Used by DoctestTextfile and DoctestItem to setup fixture information.""" - - doctest_item.funcargs = {} # type: ignore[attr-defined] - fm = doctest_item.session._fixturemanager - fixtureinfo = fm.getfixtureinfo(node=doctest_item, func=None, cls=None) - doctest_item._fixtureinfo = fixtureinfo # type: ignore[attr-defined] - doctest_item.fixturenames = fixtureinfo.names_closure # type: ignore[attr-defined] - fixture_request = TopRequest(doctest_item, _ispytest=True) # type: ignore[arg-type] - fixture_request._fillfixtures() - return fixture_request - - def _init_checker_class() -> Type["doctest.OutputChecker"]: import doctest import re From 2ed2e9208dfc39597c122a2c1ad884aab0559783 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 8 Sep 2023 11:00:09 +0300 Subject: [PATCH 26/27] doctest: remove unnecessary Optionals --- src/_pytest/doctest.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index bc930724b..f4a2d4bca 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -255,8 +255,8 @@ class DoctestItem(Item): self, name: str, parent: "Union[DoctestTextfile, DoctestModule]", - runner: Optional["doctest.DocTestRunner"] = None, - dtest: Optional["doctest.DocTest"] = None, + runner: "doctest.DocTestRunner", + dtest: "doctest.DocTest", ) -> None: super().__init__(name, parent) self.runner = runner @@ -288,19 +288,13 @@ class DoctestItem(Item): self._request = TopRequest(self, _ispytest=True) # type: ignore[arg-type] def setup(self) -> None: - if self.dtest is not None: - self._request._fillfixtures() - - globs = dict(getfixture=self._request.getfixturevalue) - for name, value in self._request.getfixturevalue( - "doctest_namespace" - ).items(): - globs[name] = value - self.dtest.globs.update(globs) + self._request._fillfixtures() + globs = dict(getfixture=self._request.getfixturevalue) + for name, value in self._request.getfixturevalue("doctest_namespace").items(): + globs[name] = value + 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: List["doctest.DocTestFailure"] = [] @@ -387,7 +381,6 @@ class DoctestItem(Item): return ReprFailDoctest(reprlocation_lines) def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: - assert self.dtest is not None return self.path, self.dtest.lineno, "[doctest] %s" % self.name From 6ad9499c9cb02846d22f6217dc54e70b2e459f2b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 8 Sep 2023 15:15:19 +0300 Subject: [PATCH 27/27] doctest: some missing type annotations --- src/_pytest/doctest.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index f4a2d4bca..a0125e93c 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -400,8 +400,8 @@ def _get_flag_lookup() -> Dict[str, int]: ) -def get_optionflags(parent): - optionflags_str = parent.config.getini("doctest_optionflags") +def get_optionflags(config: Config) -> int: + optionflags_str = config.getini("doctest_optionflags") flag_lookup_table = _get_flag_lookup() flag_acc = 0 for flag in optionflags_str: @@ -409,8 +409,8 @@ def get_optionflags(parent): return flag_acc -def _get_continue_on_failure(config): - continue_on_failure = config.getvalue("doctest_continue_on_failure") +def _get_continue_on_failure(config: Config) -> bool: + continue_on_failure: bool = config.getvalue("doctest_continue_on_failure") if continue_on_failure: # We need to turn off this if we use pdb since we should stop at # the first failure. @@ -433,7 +433,7 @@ class DoctestTextfile(Module): name = self.path.name globs = {"__name__": "__main__"} - optionflags = get_optionflags(self) + optionflags = get_optionflags(self.config) runner = _get_runner( verbose=False, @@ -578,7 +578,7 @@ class DoctestModule(Module): raise # Uses internal doctest module parsing mechanism. finder = MockAwareDocTestFinder() - optionflags = get_optionflags(self) + optionflags = get_optionflags(self.config) runner = _get_runner( verbose=False, optionflags=optionflags,