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/.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
diff --git a/AUTHORS b/AUTHORS
index 52157cc0a..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
@@ -348,6 +349,7 @@ Simon Holesch
Simon Kerr
Skylar Downes
Srinivas Reddy Thatiparthy
+Stefaan Lippens
Stefan Farmbauer
Stefan Scherfke
Stefan Zimmermann
@@ -381,6 +383,7 @@ Tor Colvin
Trevor Bekolay
Tushar Sadhwani
Tyler Goodlet
+Tyler Smart
Tzu-ping Chung
Vasily Kuznetsov
Victor Maryama
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/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/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/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`:
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 `.
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/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.
"""
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.
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/doctest.py b/src/_pytest/doctest.py
index e6f666dda..a0125e93c 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
@@ -254,14 +255,20 @@ 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
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
@@ -276,19 +283,18 @@ 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(
- "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"] = []
@@ -375,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
@@ -395,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:
@@ -404,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.
@@ -428,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,
@@ -536,6 +541,23 @@ class DoctestModule(Module):
tests, obj, name, module, source_lines, globs, seen
)
+ if sys.version_info < (3, 13):
+
+ 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]
+
+ else: # pragma: no cover
+ pass
+
if self.path.name == "conftest.py":
module = self.config.pluginmanager._importconftest(
self.path,
@@ -556,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,
@@ -571,22 +593,6 @@ 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
- doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined]
- node=doctest_item, func=func, cls=None, funcargs=False
- )
- 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
diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py
index 873cb4a25..953b55e47 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
@@ -210,16 +211,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)
@@ -380,7 +379,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
@@ -660,8 +659,7 @@ class TopRequest(FixtureRequest):
def _fillfixtures(self) -> None:
item = self._pyfuncitem
- fixturenames = getattr(item, "fixturenames", self.fixturenames)
- for argname in fixturenames:
+ for argname in item.fixturenames:
if argname not in item.funcargs:
item.funcargs[argname] = self.getfixturevalue(argname)
@@ -767,7 +765,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)
)
@@ -1374,7 +1375,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.
@@ -1383,21 +1384,20 @@ 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
-def deduplicate_names(seq: Iterable[str]) -> Tuple[str, ...]:
+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(seq))
+ return tuple(dict.fromkeys(name for seq in seqs for name in seq))
class FixtureManager:
@@ -1452,13 +1452,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:
@@ -1467,34 +1466,26 @@ 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 = ()
+ 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 = deduplicate_names(
- tuple(self._getautousenames(node.nodeid)) + usefixtures + argnames
- )
+ direct_parametrize_args = _get_direct_parametrize_args(node)
names_closure, arg2fixturedefs = self.getfixtureclosure(
- node,
- initialnames,
- None,
- ignore_args=_get_direct_parametrize_args(node),
- )
- return FuncFixtureInfo(
- argnames,
- initialnames,
- names_closure,
- arg2fixturedefs,
+ parentnode=node,
+ initialnames=initialnames,
+ arg2fixturedefs=None,
+ ignore_args=direct_parametrize_args,
)
+ return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs)
+
def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
nodeid = None
try:
@@ -1524,12 +1515,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,
parentnode: nodes.Node,
initialnames: Tuple[str, ...],
arg2fixturedefs: Union[Dict[str, Sequence[FixtureDef[Any]]], None],
- ignore_args: Sequence[str] = (),
+ ignore_args: AbstractSet[str],
) -> Tuple[List[str], Dict[str, Sequence[FixtureDef[Any]]]]:
# Collect the closure of all fixtures, starting with the given
# initialnames containing function arguments, `usefixture` markers
@@ -1539,12 +1535,12 @@ class FixtureManager:
# not have to re-discover fixturedefs again for each fixturename
# (discovering matching fixtures for a given name/node is expensive).
- fixturenames_closure = initialnames
-
if arg2fixturedefs is None:
arg2fixturedefs = {}
- lastlen = -1
parentid = parentnode.nodeid
+ fixturenames_closure = list(initialnames)
+
+ lastlen = -1
while lastlen != len(fixturenames_closure):
lastlen = len(fixturenames_closure)
for argname in fixturenames_closure:
@@ -1557,9 +1553,9 @@ class FixtureManager:
else:
fixturedefs = arg2fixturedefs[argname]
if fixturedefs and not isinstance(fixturedefs[-1], IdentityFixture):
- fixturenames_closure = deduplicate_names(
- fixturenames_closure + arg2fixturedefs[argname][-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:
@@ -1569,10 +1565,8 @@ class FixtureManager:
else:
return fixturedefs[-1]._scope
- return (
- sorted(fixturenames_closure, key=sort_by_scope, reverse=True),
- arg2fixturedefs,
- )
+ fixturenames_closure.sort(key=sort_by_scope, reverse=True)
+ 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/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 138a0bdb2..63b1345b4 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)
@@ -769,3 +773,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 (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/src/_pytest/python.py b/src/_pytest/python.py
index f019672a3..f10e9f81c 100644
--- a/src/_pytest/python.py
+++ b/src/_pytest/python.py
@@ -475,7 +475,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
metafunc = Metafunc(
@@ -1128,9 +1130,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)
@@ -1146,7 +1148,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}")
@@ -1546,12 +1548,8 @@ class Metafunc:
def update_dependency_tree(self) -> None:
definition = self.definition
- (
- fixture_closure,
- _,
- ) = cast(
- nodes.Node, definition.parent
- ).session._fixturemanager.getfixtureclosure(
+ fm = cast(nodes.Node, definition.parent).session._fixturemanager
+ fixture_closure, _ = fm.getfixtureclosure(
definition,
definition._fixtureinfo.initialnames,
definition._fixtureinfo.name2fixturedefs,
@@ -1813,9 +1811,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()
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"
diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py
index 5d579b8af..aebcc55e8 100644
--- a/testing/python/fixtures.py
+++ b/testing/python/fixtures.py
@@ -4704,8 +4704,8 @@ def test_dont_recompute_dependency_tree_if_no_dynamic_parametrize(pytester: Pyte
reprec.assertoutcome(passed=5)
-def test_deduplicate_names(pytester: Pytester) -> None:
+def test_deduplicate_names() -> None:
items = deduplicate_names("abacd")
assert items == ("a", "b", "c", "d")
- items = deduplicate_names(items + ("g", "f", "g", "e", "b"))
+ items = deduplicate_names(items, ("g", "f", "g", "e", "b"))
assert items == ("a", "b", "c", "d", "g", "f", "e")
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(
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 3d574e856..678fd27fe 100644
--- a/testing/test_pathlib.py
+++ b/testing/test_pathlib.py
@@ -1,3 +1,4 @@
+import errno
import os.path
import pickle
import sys
@@ -18,11 +19,13 @@ 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
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
@@ -585,6 +588,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 +622,72 @@ 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
+
+
+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=ValueError("name too long"),
+ ):
+ assert safe_exists(p) is False