From ef4e938f8d681ba5e8959767e7b5f90207d60fbd Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Mon, 22 Jun 2026 11:36:02 -0400 Subject: [PATCH] use @classmethod per pytest guidance Fixed class-scoped pytest fixtures that were defined as instance methods using ``self``, which is deprecated as of pytest 9.1 and will be removed in pytest 10. Fixtures are now decorated with a compatibility ``@classmethod`` decorator and use ``cls`` as the first parameter. In the 2.0 branch, the approach is considerably more complicated as python 3.7, 3.8, 3.9 doesn't work with this pattern; since the warning appears in pytest 9.1 and not 9.0, which itself is only python 3.10+, we use a conditional classmethod wrapper. in the 2.1 branch, we can just use straight `@classmethod`. Fixes: #13392 Change-Id: I866de54e22569c9d55eb97dce165670994ec4a57 (cherry picked from commit cd7eeb34d3741c94b2f9476aec7f3386132a71d5) --- doc/build/changelog/unreleased_20/13392.rst | 8 +++++ lib/sqlalchemy/testing/__init__.py | 1 + lib/sqlalchemy/testing/config.py | 7 +++++ lib/sqlalchemy/testing/fixtures/mypy.py | 8 +++-- lib/sqlalchemy/testing/fixtures/orm.py | 4 +-- lib/sqlalchemy/testing/fixtures/sql.py | 4 +-- lib/sqlalchemy/testing/plugin/plugin_base.py | 4 +++ lib/sqlalchemy/testing/plugin/pytestplugin.py | 30 +++++++++++++++++++ test/aaa_profiling/test_misc.py | 3 +- test/ext/test_mutable.py | 3 +- 10 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 doc/build/changelog/unreleased_20/13392.rst diff --git a/doc/build/changelog/unreleased_20/13392.rst b/doc/build/changelog/unreleased_20/13392.rst new file mode 100644 index 0000000000..cd9089b07e --- /dev/null +++ b/doc/build/changelog/unreleased_20/13392.rst @@ -0,0 +1,8 @@ +.. change:: + :tags: bug, tests + :tickets: 13392 + + Fixed class-scoped pytest fixtures that were defined as instance methods + using ``self``, which is deprecated as of pytest 9.1 and will be removed in + pytest 10. Fixtures are now decorated with a compatibility ``@classmethod`` + decorator and use ``cls`` as the first parameter. diff --git a/lib/sqlalchemy/testing/__init__.py b/lib/sqlalchemy/testing/__init__.py index 41ddcedaff..388ec8a724 100644 --- a/lib/sqlalchemy/testing/__init__.py +++ b/lib/sqlalchemy/testing/__init__.py @@ -52,6 +52,7 @@ from .config import combinations from .config import combinations_list from .config import db from .config import fixture +from .config import fixture_classmethod from .config import requirements as requires from .config import skip_test from .config import Variation diff --git a/lib/sqlalchemy/testing/config.py b/lib/sqlalchemy/testing/config.py index 09ed89bfbc..c723911d15 100644 --- a/lib/sqlalchemy/testing/config.py +++ b/lib/sqlalchemy/testing/config.py @@ -80,6 +80,9 @@ else: def async_test(self, fn): return fn + def fixture_classmethod(self, fn): + return classmethod(fn) + # default fixture functions; these are replaced by plugin_base when # pytest runs _fixture_functions = _NullFixtureFunctions() @@ -432,3 +435,7 @@ def skip_test(msg): def async_test(fn): return _fixture_functions.async_test(fn) + + +def fixture_classmethod(fn): + return _fixture_functions.fixture_classmethod(fn) diff --git a/lib/sqlalchemy/testing/fixtures/mypy.py b/lib/sqlalchemy/testing/fixtures/mypy.py index baac539afb..299e2157a7 100644 --- a/lib/sqlalchemy/testing/fixtures/mypy.py +++ b/lib/sqlalchemy/testing/fixtures/mypy.py @@ -31,10 +31,12 @@ class MypyTest(TestBase): yield from self._cachedir() @config.fixture(scope="class") - def cachedir(self): - yield from self._cachedir() + @config.fixture_classmethod + def cachedir(cls): + yield from cls._cachedir() - def _cachedir(self): + @config.fixture_classmethod + def _cachedir(cls): # as of mypy 0.971 i think we need to keep mypy_path empty mypy_path = "" diff --git a/lib/sqlalchemy/testing/fixtures/orm.py b/lib/sqlalchemy/testing/fixtures/orm.py index 65693b719c..05050a8e4b 100644 --- a/lib/sqlalchemy/testing/fixtures/orm.py +++ b/lib/sqlalchemy/testing/fixtures/orm.py @@ -40,8 +40,8 @@ class MappedTest(ORMTest, TablesTest, assertions.AssertsExecutionResults): classes: Any = None @config.fixture(autouse=True, scope="class") - def _setup_tables_test_class(self): - cls = self.__class__ + @config.fixture_classmethod + def _setup_tables_test_class(cls): cls._init_class() if cls.classes is None: diff --git a/lib/sqlalchemy/testing/fixtures/sql.py b/lib/sqlalchemy/testing/fixtures/sql.py index 18ac4f554b..6f2087815c 100644 --- a/lib/sqlalchemy/testing/fixtures/sql.py +++ b/lib/sqlalchemy/testing/fixtures/sql.py @@ -53,8 +53,8 @@ class TablesTest(TestBase): sequences = None @config.fixture(autouse=True, scope="class") - def _setup_tables_test_class(self): - cls = self.__class__ + @config.fixture_classmethod + def _setup_tables_test_class(cls): cls._init_class() cls._setup_once_tables() diff --git a/lib/sqlalchemy/testing/plugin/plugin_base.py b/lib/sqlalchemy/testing/plugin/plugin_base.py index c4cabb33eb..1cab431f6a 100644 --- a/lib/sqlalchemy/testing/plugin/plugin_base.py +++ b/lib/sqlalchemy/testing/plugin/plugin_base.py @@ -819,6 +819,10 @@ class FixtureFunctions(abc.ABC): def add_to_marker(self): raise NotImplementedError() + @abc.abstractmethod + def fixture_classmethod(self, fn) -> Any: + raise NotImplementedError() + _fixture_fn_class = None diff --git a/lib/sqlalchemy/testing/plugin/pytestplugin.py b/lib/sqlalchemy/testing/plugin/pytestplugin.py index c430e76c10..56ba20c329 100644 --- a/lib/sqlalchemy/testing/plugin/pytestplugin.py +++ b/lib/sqlalchemy/testing/plugin/pytestplugin.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse import collections from functools import update_wrapper +from functools import wraps import inspect import itertools import operator @@ -679,6 +680,28 @@ def %(name)s%(grouped_args)s: class PytestFixtureFunctions(plugin_base.FixtureFunctions): + + def fixture_classmethod(self, fn): + """a conditional `@classmethod` decorator that we use only on py3.10 + on forward, for compatibility with pytest 9.1+.""" + + if pytest.version_tuple >= (9, 1): + return classmethod(fn) + else: + if inspect.isgeneratorfunction(fn): + + @wraps(fn) + def wrap(self, *args, **kw): + yield from fn(self.__class__, *args, **kw) + + else: + + @wraps(fn) + def wrap(self, *args, **kw): + return fn(self.__class__, *args, **kw) + + return wrap + def skip_test_exception(self, *arg, **kw): return pytest.skip.Exception(*arg, **kw) @@ -862,10 +885,17 @@ class PytestFixtureFunctions(plugin_base.FixtureFunctions): # now apply wrappers to the function, including fixture itself def wrap(fn): + is_classmethod = isinstance(fn, classmethod) + if is_classmethod: + fn = fn.__func__ + if config.any_async: fn = asyncio._maybe_async_wrapper(fn) # other wrappers may be added here + if is_classmethod: + fn = classmethod(fn) + # now apply FixtureFunctionMarker fn = fixture(fn) diff --git a/test/aaa_profiling/test_misc.py b/test/aaa_profiling/test_misc.py index c51648c677..60fb348dae 100644 --- a/test/aaa_profiling/test_misc.py +++ b/test/aaa_profiling/test_misc.py @@ -56,7 +56,8 @@ class CacheKeyTest(fixtures.TestBase): __requires__ = ("cpython", "python_profiling_backend") @testing.fixture(scope="class") - def mapping_fixture(self): + @testing.fixture_classmethod + def mapping_fixture(cls): # note in order to work nicely with "fixture" we are emerging # a whole new model of setup/teardown, since pytest "fixture" # sort of purposely works badly with setup/teardown diff --git a/test/ext/test_mutable.py b/test/ext/test_mutable.py index c72f182f8f..5ac05d1e59 100644 --- a/test/ext/test_mutable.py +++ b/test/ext/test_mutable.py @@ -1078,7 +1078,8 @@ class _MutableSetTestBase(_MutableSetTestFixture): class _MutableNoHashFixture: @testing.fixture(autouse=True, scope="class") - def set_class(self): + @testing.fixture_classmethod + def set_class(cls): global Foo _replace_foo = Foo -- 2.47.3