From 78a1938243775e5b707a23f14ec06bc540dbd321 Mon Sep 17 00:00:00 2001 From: Freya Bruhin Date: Fri, 24 Jul 2026 10:20:08 +0200 Subject: [PATCH] Extend instance method fixture deprecation to all higher scopes The original deprecation in #14071 only targetted class-scoped fixtures, but the same issue exists if someone implements a module/package/session-scoped fixture as an instance method. Closes #14765 --- changelog/14774.deprecation.rst | 6 ++++ doc/en/deprecations.rst | 5 ++++ src/_pytest/deprecated.py | 2 +- src/_pytest/fixtures.py | 5 ++-- testing/deprecated_test.py | 50 ++++++++++++++++++++++++++------- testing/python/fixtures.py | 3 +- testing/test_conftest.py | 3 +- testing/test_legacypath.py | 3 +- testing/test_pathlib.py | 8 ++++-- 9 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 changelog/14774.deprecation.rst diff --git a/changelog/14774.deprecation.rst b/changelog/14774.deprecation.rst new file mode 100644 index 00000000000..fa9c6ca4cc3 --- /dev/null +++ b/changelog/14774.deprecation.rst @@ -0,0 +1,6 @@ +Similarly to the deprecation of class-scoped fixtures defined as instance methods in pytest 9.1, +*all* higher-scoped fixtures (``module``, ``package``, and ``session``) defined +as instance methods are now deprecated. + +They suffer from the same issue the original deprecation only fixed for +class-scoped fixtures, see :issue:`10819` and :issue:`14011`. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index feaeaa09736..005d50081f7 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -276,6 +276,11 @@ test class for each test method, while the fixture runs only once per class on a Using ``@classmethod`` ensures attributes are set on the class itself, making them accessible to all test methods. +.. deprecated:: 9.2 + +Similarly, using a scope higher than ``class`` (``module``, ``package`` or ``session``) +without using ``@classmethod`` (or ``@staticmethod``) is deprecated and will be +removed in pytest 10.0. .. _monkeypatch-fixup-namespace-packages: diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index f46b036affe..860ec406584 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -36,7 +36,7 @@ CLASS_FIXTURE_INSTANCE_METHOD = UnformattedWarning( PytestRemovedIn10Warning, - "Class-scoped fixtures defined as instance methods are deprecated.\n" + "{scope}-scoped fixtures defined as instance methods are deprecated.\n" "Instance attributes set in the {fixturename!r} fixture will NOT be visible to test methods,\n" "as each test gets a new instance while the fixture runs only once per class.\n" "Use a @classmethod decorator below @pytest.fixture and set attributes on cls instead.\n" diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 3f1964f2d06..288bb7b20af 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1284,7 +1284,7 @@ def __tracebackhide__(e): # as expected. instance = request.instance - if fixturedef._scope is Scope.Class: + if fixturedef._scope >= Scope.Class: # Check if fixture is an instance method (bound to instance, not class) if hasattr(fixturefunc, "__self__"): bound_to = fixturefunc.__self__ @@ -1293,7 +1293,8 @@ def __tracebackhide__(e): if not isinstance(bound_to, type): warnings.warn( CLASS_FIXTURE_INSTANCE_METHOD.format( - fixturename=request.fixturename + scope=fixturedef._scope.name.capitalize(), + fixturename=request.fixturename, ), stacklevel=2, ) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 8eed1fb3149..637a5c698b8 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -3,6 +3,7 @@ from _pytest import deprecated from _pytest.pytester import Pytester +from _pytest.scope import Scope import pytest from pytest import PytestDeprecationWarning @@ -87,13 +88,18 @@ def __init__(self, foo: int, *, _ispytest: bool = False) -> None: PrivateInit(10, _ispytest=True) -def test_class_scope_instance_method_is_deprecated(pytester: Pytester) -> None: +@pytest.mark.parametrize( + "scope", [Scope.Class, Scope.Module, Scope.Package, Scope.Session] +) +def test_higher_scope_instance_method_is_deprecated( + pytester: Pytester, scope: Scope +) -> None: pytester.makepyfile( - """ + f""" import pytest class TestClass: - @pytest.fixture(scope="class") + @pytest.fixture(scope="{scope.value}") def fix(self): self.attr = True @@ -104,20 +110,23 @@ def test_foo(self, fix): result = pytester.runpytest("-Werror::pytest.PytestRemovedIn10Warning") result.assert_outcomes(errors=1) result.stdout.fnmatch_lines( - [ - "*PytestRemovedIn10Warning: Class-scoped fixtures defined as instance methods*" - ] + ["*PytestRemovedIn10Warning: *-scoped fixtures defined as instance methods*"] ) -def test_class_scope_classmethod_fixture_not_deprecated(pytester: Pytester) -> None: - """A class-scoped fixture defined as @classmethod does NOT warn.""" +@pytest.mark.parametrize( + "scope", [Scope.Class, Scope.Module, Scope.Package, Scope.Session] +) +def test_higher_scope_classmethod_fixture_not_deprecated( + pytester: Pytester, scope: Scope +) -> None: + """A higher-scoped fixture defined as @classmethod does NOT warn.""" pytester.makepyfile( - """ + f""" import pytest class TestClass: - @pytest.fixture(scope="class") + @pytest.fixture(scope="{scope.value}") @classmethod def fix(cls): cls.attr = True @@ -130,6 +139,27 @@ def test_foo(self, fix): result.assert_outcomes(passed=1) +@pytest.mark.parametrize("scope", list(Scope)) +def test_staticmethod_fixture_not_deprecated(pytester: Pytester, scope: Scope) -> None: + """A fixture at any scope defined as @staticmethod does NOT warn.""" + pytester.makepyfile( + f""" + import pytest + + class TestClass: + @pytest.fixture(scope="{scope.value}") + @staticmethod + def fix(): + pass + + def test_foo(self, fix): + pass + """ + ) + result = pytester.runpytest("-Werror::pytest.PytestRemovedIn10Warning") + result.assert_outcomes(passed=1) + + class TestFixtureNodeidDeprecations: """Tests for deprecated baseid/nodeid string APIs in fixture registration. diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 69044226b6d..7327eab097c 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1292,7 +1292,8 @@ def test_1(arg): class TestRequestSessionScoped: @pytest.fixture(scope="session") - def session_request(self, request): + @staticmethod + def session_request(request): return request @pytest.mark.parametrize("name", ["path", "module"]) diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 38363a3ba1c..b5df08c7870 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -43,7 +43,8 @@ def conftest_setinitial( @pytest.mark.usefixtures("_sys_snapshot") class TestConftestValueAccessGlobal: @pytest.fixture(scope="module", params=["global", "inpackage"]) - def basedir(self, request, tmp_path_factory: TempPathFactory) -> Generator[Path]: + @staticmethod + def basedir(request, tmp_path_factory: TempPathFactory) -> Generator[Path]: tmp_path = tmp_path_factory.mktemp("basedir", numbered=True) tmp_path.joinpath("adir/b").mkdir(parents=True) tmp_path.joinpath("adir/conftest.py").write_text( diff --git a/testing/test_legacypath.py b/testing/test_legacypath.py index 3e71e6b190c..c80ec89a8e9 100644 --- a/testing/test_legacypath.py +++ b/testing/test_legacypath.py @@ -102,7 +102,8 @@ def test_fixturerequest_getmodulepath(pytester: pytest.Pytester) -> None: class TestFixtureRequestSessionScoped: @pytest.fixture(scope="session") - def session_request(self, request): + @staticmethod + def session_request(request): return request def test_session_scoped_unavailable_attributes(self, session_request): diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index bd85b7e8fb4..787d3e5e8b5 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -129,9 +129,10 @@ class TestImportPath: """ @pytest.fixture(scope="session") - def path1(self, tmp_path_factory: TempPathFactory) -> Generator[Path]: + @classmethod + def path1(cls, tmp_path_factory: TempPathFactory) -> Generator[Path]: path = tmp_path_factory.mktemp("path") - self.setuptestfs(path) + cls.setuptestfs(path) yield path assert path.joinpath("samplefile").exists() @@ -141,7 +142,8 @@ def preserve_sys(self): with unittest.mock.patch.object(sys, "path", list(sys.path)): yield - def setuptestfs(self, path: Path) -> None: + @staticmethod + def setuptestfs(path: Path) -> None: # print "setting up test fs for", repr(path) samplefile = path / "samplefile" samplefile.write_text("samplefile\n", encoding="utf-8")