diff --git a/changelog/14877.improvement.rst b/changelog/14877.improvement.rst new file mode 100644 index 00000000000..1382ff5b15c --- /dev/null +++ b/changelog/14877.improvement.rst @@ -0,0 +1,9 @@ +Reduced per-session startup overhead in fixture discovery: plugins which declare +they define no fixtures via the new internal ``__pytest_no_fixtures__`` marker +(all fixture-less builtin plugins, the terminal reporter, and the config +objects) are no longer scanned attribute-by-attribute by +``FixtureManager.parsefactories`` on every ``Config`` build. This roughly +halves the number of attribute introspections during plugin registration +(e.g. 2258 to 1052 in a default session) and speeds up anything which builds +many configs, such as :fixture:`pytester`-based test suites. A meta test +guards that no holder carrying the marker actually defines a fixture. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a171633f320..dcca5ccb7ba 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from collections.abc import Generator import sys from typing import Any diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c7bd3e1afab..ae8f175e7fb 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -497,6 +497,10 @@ class PytestPluginManager(PluginManager): * ``conftest.py`` loading during start-up. """ + # This plugin defines no fixtures: opt out of the fixture-discovery scan + # in FixtureManager.parsefactories (#14877). + __pytest_no_fixtures__ = True + def __init__(self) -> None: from _pytest.assertion import DummyRewriteHook from _pytest.assertion import RewriteHook @@ -1118,6 +1122,10 @@ class Config: invocation. """ + # This object defines no fixtures: opt out of the fixture-discovery scan + # in FixtureManager.parsefactories (#14877). + __pytest_no_fixtures__ = True + @final @dataclasses.dataclass(frozen=True) class InvocationParams: diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index b256f83c8bf..afc0a457371 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -4,6 +4,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import argparse from collections.abc import Callable from collections.abc import Generator diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index 080cf583813..6677f40bd56 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -1,5 +1,10 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from collections.abc import Generator import os import sys diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 30f44d44dfc..b57dd20d33d 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2296,6 +2296,14 @@ def parsefactories( else: holderobj_tp = cast("type | types.ModuleType", holderobj) + # Skip the scan entirely for holders which declare that they define + # no fixtures. Walking dir() and validating every attribute of every + # registered plugin on each Config build is pure overhead for + # fixture-less plugins (#14877). The lookup is done safely, like the + # loop below, because the holder may be an arbitrary user object. + if safe_getattr(holderobj, "__pytest_no_fixtures__", False): + return + for name in dir(holderobj): # Read the raw __dict__ entry first so staticmethod/classmethod # wrappers are not hidden by descriptor binding. diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 8d9d44699c1..5fdce3e6de1 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import argparse from collections.abc import Generator from collections.abc import Sequence diff --git a/src/_pytest/legacypath.py b/src/_pytest/legacypath.py index e1fc38d19ed..43a9970adb9 100644 --- a/src/_pytest/legacypath.py +++ b/src/_pytest/legacypath.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import dataclasses from pathlib import Path import shlex diff --git a/src/_pytest/main.py b/src/_pytest/main.py index d43a68b4679..dee3534dc2a 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -2,6 +2,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import argparse from collections.abc import Callable from collections.abc import Iterable diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 996322d93f1..1de7047514a 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -2,6 +2,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import collections from collections.abc import Collection from collections.abc import Iterable diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index e6a1430220a..e39ceac800c 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from io import StringIO import tempfile from typing import IO diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 792d0d5b4f3..9ba21993e8e 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import abc from collections import Counter from collections import defaultdict diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 722f71909d4..7916593443c 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,6 +1,11 @@ # mypy: allow-untyped-defs from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from collections.abc import Iterable from collections.abc import Iterator from collections.abc import Mapping diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 27c5739845a..fc52d6db403 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import bdb from collections.abc import Callable import dataclasses diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 7e6b46bcdb4..f43cfbd2907 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -1,5 +1,10 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from collections.abc import Generator from _pytest._io.saferepr import saferepr diff --git a/src/_pytest/setupplan.py b/src/_pytest/setupplan.py index 4e124cce243..bce514d9183 100644 --- a/src/_pytest/setupplan.py +++ b/src/_pytest/setupplan.py @@ -1,5 +1,10 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config.argparsing import Parser diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index aa97e3b7dc6..db42f67cece 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from collections.abc import Generator from collections.abc import Mapping import dataclasses diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index c14b6fc250b..b451d7be6ff 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -1,5 +1,10 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import dataclasses from datetime import datetime from datetime import timedelta diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 023fdcaabb8..4a12b42e9ce 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -6,6 +6,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import argparse from collections import Counter from collections.abc import Callable @@ -385,6 +390,10 @@ def get_location(self, config: Config) -> str | None: @final class TerminalReporter: + # This plugin defines no fixtures: opt out of the fixture-discovery scan + # in FixtureManager.parsefactories (#14877). + __pytest_no_fixtures__ = True + def __init__(self, config: Config, file: TextIO | None = None) -> None: import _pytest.config diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py index 74640873259..d6aca448942 100644 --- a/src/_pytest/threadexception.py +++ b/src/_pytest/threadexception.py @@ -1,5 +1,10 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import collections from collections.abc import Callable import functools diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 2c9e0dcb80b..79c2fcbd500 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -3,6 +3,11 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from collections.abc import Callable from collections.abc import Generator from collections.abc import Iterable diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 32f1258038b..3dca2b12766 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -1,5 +1,10 @@ from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + import collections from collections.abc import Callable import functools diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 2c721d6e3c9..21f370af3cc 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -1,6 +1,11 @@ # mypy: allow-untyped-defs from __future__ import annotations + +# This plugin defines no fixtures: opt out of the fixture-discovery scan in +# FixtureManager.parsefactories (#14877). +__pytest_no_fixtures__ = True + from collections.abc import Generator from contextlib import contextmanager from typing import Literal diff --git a/testing/test_meta.py b/testing/test_meta.py index e7d836f7ace..1ee57a93d14 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -6,11 +6,19 @@ from __future__ import annotations +import importlib import pkgutil import subprocess import sys +import types import _pytest +from _pytest.compat import safe_getattr +from _pytest.compat import safe_isclass +from _pytest.config import Config +from _pytest.config import PytestPluginManager +from _pytest.fixtures import FixtureFunctionDefinition +from _pytest.terminal import TerminalReporter import pytest @@ -32,3 +40,59 @@ def test_no_warnings(module: str) -> None: "-c", f"__import__({module!r})", )) # fmt: on + + +def _opted_out_holders() -> list[object]: + """All holders carrying the ``__pytest_no_fixtures__`` opt-out marker.""" + holders: list[object] = [Config, PytestPluginManager, TerminalReporter] + for _, name, _ in pkgutil.walk_packages( + _pytest.__path__, prefix=_pytest.__name__ + "." + ): + module = importlib.import_module(name) + if safe_getattr(module, "__pytest_no_fixtures__", False): + holders.append(module) + return holders + + +def _discoverable_fixture_names(holder: object) -> list[str]: + """Names which ``FixtureManager.parsefactories`` would register as + fixtures on ``holder``. + + Mirrors the detection in ``parsefactories``: fixtures are looked up on the + module itself, or on the class for instance holders. + """ + if not safe_isclass(holder) and not isinstance(holder, types.ModuleType): + holder = type(holder) + return [ + name + for name in dir(holder) + if type(safe_getattr(holder, name, None)) is FixtureFunctionDefinition + ] + + +def test_no_fixtures_opt_out_holders_define_no_fixtures() -> None: + """Guard for the ``__pytest_no_fixtures__`` opt-out (see #14877). + + ``FixtureManager.parsefactories`` skips fixture discovery entirely for + holders carrying the marker, so any fixture defined on such a holder + would silently never be collected. Fail here if that ever happens. + """ + holders = _opted_out_holders() + assert holders, "expected some holders to carry __pytest_no_fixtures__" + offenders = { + getattr(holder, "__name__", repr(holder)): _discoverable_fixture_names(holder) + for holder in holders + } + offenders = {name: found for name, found in offenders.items() if found} + assert not offenders, ( + "holders marked __pytest_no_fixtures__ define fixtures which " + f"parsefactories would silently skip: {offenders}" + ) + + +def test_discoverable_fixture_names_supports_instance_holders() -> None: + """Instance holders resolve to their class, mirroring parsefactories.""" + holder_cls = type("Plugin", (), {"my_fixture": pytest.fixture(lambda: 1)}) + + assert _discoverable_fixture_names(holder_cls()) == ["my_fixture"] + assert _discoverable_fixture_names(holder_cls) == ["my_fixture"]