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