Repository: smarie/python-pytest-cases (language: python)

Issue:
@fixture incompatible with pytest 8.0.0
pytest 8.0.0 just released earlier today, and it looks like there's an incompatibility with `pytest_cases.fixture`.

Minimal example:

```python
from pytest_cases import fixture


@fixture
def a():
    return "a"


def test_a(a):
    assert a == "a"
```

pytest 7.4.4, pytest-cases 3.8.2, Python 3.11.7 — Passes ✅ 

pytest 8.0.0, pytest-cases 3.8.2, Python 3.11.7 — Fails ❌ 

Traceback:

```
.venv/lib/python3.11/site-packages/_pytest/nodes.py:152: in _create
    return super().__call__(*k, **kw)
.venv/lib/python3.11/site-packages/_pytest/python.py:1801: in __init__
    fixtureinfo = fm.getfixtureinfo(self, self.obj, self.cls)
.venv/lib/python3.11/site-packages/_pytest/fixtures.py:1490: in getfixtureinfo
    names_closure, arg2fixturedefs = self.getfixtureclosure(
E   TypeError: getfixtureclosure() got an unexpected keyword argument 'initialnames'

During handling of the above exception, another exception occurred:
.venv/lib/python3.11/site-packages/pluggy/_hooks.py:501: in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
.venv/lib/python3.11/site-packages/pluggy/_manager.py:119: in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
.venv/lib/python3.11/site-packages/_pytest/python.py:277: in pytest_pycollect_makeitem
    return list(collector._genfunctions(name, obj))
.venv/lib/python3.11/site-packages/_pytest/python.py:486: in _genfunctions
    definition: FunctionDefinition = FunctionDefinition.from_parent(
.venv/lib/python3.11/site-packages/_pytest/python.py:1809: in from_parent
    return super().from_parent(parent=parent, **kw)
.venv/lib/python3.11/site-packages/_pytest/nodes.py:275: in from_parent
    return cls._create(parent=parent, **kw)
.venv/lib/python3.11/site-packages/_pytest/nodes.py:167: in _create
    return super().__call__(*k, **known_kw)
.venv/lib/python3.11/site-packages/_pytest/python.py:1801: in __init__
    fixtureinfo = fm.getfixtureinfo(self, self.obj, self.cls)
.venv/lib/python3.11/site-packages/_pytest/fixtures.py:1490: in getfixtureinfo
    names_closure, arg2fixturedefs = self.getfixtureclosure(
E   TypeError: getfixtureclosure() got an unexpected keyword argument 'initialnames'
```
@fixture incompatible with pytest 8.0.0
pytest 8.0.0 just released earlier today, and it looks like there's an incompatibility with `pytest_cases.fixture`.

Minimal example:

```python
from pytest_cases import fixture


@fixture
def a():
    return "a"


def test_a(a):
    assert a == "a"
```

pytest 7.4.4, pytest-cases 3.8.2, Python 3.11.7 — Passes ✅ 

pytest 8.0.0, pytest-cases 3.8.2, Python 3.11.7 — Fails ❌ 

Traceback:

```
.venv/lib/python3.11/site-packages/_pytest/nodes.py:152: in _create
    return super().__call__(*k, **kw)
.venv/lib/python3.11/site-packages/_pytest/python.py:1801: in __init__
    fixtureinfo = fm.getfixtureinfo(self, self.obj, self.cls)
.venv/lib/python3.11/site-packages/_pytest/fixtures.py:1490: in getfixtureinfo
    names_closure, arg2fixturedefs = self.getfixtureclosure(
E   TypeError: getfixtureclosure() got an unexpected keyword argument 'initialnames'

During handling of the above exception, another exception occurred:
.venv/lib/python3.11/site-packages/pluggy/_hooks.py:501: in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
.venv/lib/python3.11/site-packages/pluggy/_manager.py:119: in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
.venv/lib/python3.11/site-packages/_pytest/python.py:277: in pytest_pycollect_makeitem
    return list(collector._genfunctions(name, obj))
.venv/lib/python3.11/site-packages/_pytest/python.py:486: in _genfunctions
    definition: FunctionDefinition = FunctionDefinition.from_parent(
.venv/lib/python3.11/site-packages/_pytest/python.py:1809: in from_parent
    return super().from_parent(parent=parent, **kw)
.venv/lib/python3.11/site-packages/_pytest/nodes.py:275: in from_parent
    return cls._create(parent=parent, **kw)
.venv/lib/python3.11/site-packages/_pytest/nodes.py:167: in _create
    return super().__call__(*k, **known_kw)
.venv/lib/python3.11/site-packages/_pytest/python.py:1801: in __init__
    fixtureinfo = fm.getfixtureinfo(self, self.obj, self.cls)
.venv/lib/python3.11/site-packages/_pytest/fixtures.py:1490: in getfixtureinfo
    names_closure, arg2fixturedefs = self.getfixtureclosure(
E   TypeError: getfixtureclosure() got an unexpected keyword argument 'initialnames'
```


Discussion / hints:
I can confirm the issue.
Issue stems from this commit: https://github.com/pytest-dev/pytest/commit/385796ba494e7ae65d55892d4a358b371ac7a6b6

Running the example on the commit just before works fine. After this commit, I get the reported error.
Thanks a lot for reporting @jayqi ! For now inded the workaround is to use `pytest<8`.
I'll have a look in the upcoming weeks

Note: another user also mentioned that the symptom could be 

```
AttributeError: 'MiniMetaFunc' object has no attribute `_arg2fixturedefs`
```
I can confirm the issue.
Issue stems from this commit: https://github.com/pytest-dev/pytest/commit/385796ba494e7ae65d55892d4a358b371ac7a6b6

Running the example on the commit just before works fine. After this commit, I get the reported error.
Thanks a lot for reporting @jayqi ! For now inded the workaround is to use `pytest<8`.
I'll have a look in the upcoming weeks

Note: another user also mentioned that the symptom could be 

```
AttributeError: 'MiniMetaFunc' object has no attribute `_arg2fixturedefs`
```

Fix patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index b82d2fc1..491edbec 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,8 +1,10 @@
 # Changelog
 
-### 3.8.3 (in progress) - TBD
+### 3.8.3 - Support for `pytest` version 8
 
- - tbd
+ - Fixed compliance with pytest 8. Fixed [#330](https://github.com/smarie/python-pytest-cases/issues/330). PR
+   [#335](https://github.com/smarie/python-pytest-cases/pull/335) by [smarie](https://github.com/smarie) and 
+   [larsoner](https://github.com/larsoner).
 
 ### 3.8.2 - bugfixes and project improvements
 


Test patch:
diff --git a/src/pytest_cases/common_pytest.py b/src/pytest_cases/common_pytest.py
index a0826666..99c6014e 100644
--- a/src/pytest_cases/common_pytest.py
+++ b/src/pytest_cases/common_pytest.py
@@ -30,7 +30,8 @@
 from .common_others import get_function_host
 from .common_pytest_marks import make_marked_parameter_value, get_param_argnames_as_list, \
     get_pytest_parametrize_marks, get_pytest_usefixture_marks, PYTEST3_OR_GREATER, PYTEST6_OR_GREATER, \
-    PYTEST38_OR_GREATER, PYTEST34_OR_GREATER, PYTEST33_OR_GREATER, PYTEST32_OR_GREATER, PYTEST71_OR_GREATER
+    PYTEST38_OR_GREATER, PYTEST34_OR_GREATER, PYTEST33_OR_GREATER, PYTEST32_OR_GREATER, PYTEST71_OR_GREATER, \
+    PYTEST8_OR_GREATER
 from .common_pytest_lazy_values import is_lazy_value, is_lazy
 
 
@@ -554,6 +555,14 @@ def set_callspec_arg_scope_to_function(callspec, arg_name):
         callspec._arg2scopenum[arg_name] = get_pytest_function_scopeval()  # noqa
 
 
+def in_callspec_explicit_args(
+    callspec,  # type: CallSpec2
+    name  # type: str
+):  # type: (...) -> bool
+    """Return True if name is explicitly used in callspec args"""
+    return (name in callspec.params) or (not PYTEST8_OR_GREATER and name in callspec.funcargs)
+
+
 if PYTEST71_OR_GREATER:
     from _pytest.python import IdMaker  # noqa
 
@@ -653,14 +662,27 @@ def getfuncargnames(function, cls=None):
         return arg_names
 
 
+class FakeSession(object):
+    __slots__ = ('_fixturemanager',)
+
+    def __init__(self):
+        self._fixturemanager = None
+
+
 class MiniFuncDef(object):
-    __slots__ = ('nodeid',)
+    __slots__ = ('nodeid', 'session')
 
     def __init__(self, nodeid):
         self.nodeid = nodeid
+        if PYTEST8_OR_GREATER:
+            self.session = FakeSession()
 
 
 class MiniMetafunc(Metafunc):
+    """
+    A class to know what pytest *would* do for a given function in terms of callspec.
+    It is used in function `case_to_argvalues`
+    """
     # noinspection PyMissingConstructor
     def __init__(self, func):
         from .plugin import PYTEST_CONFIG  # late import to ensure config has been loaded by now
@@ -685,12 +707,18 @@ def __init__(self, func):
         self.fixturenames_not_in_sig = [f for f in get_pytest_usefixture_marks(func) if f not in self.fixturenames]
         if self.fixturenames_not_in_sig:
             self.fixturenames = tuple(self.fixturenames_not_in_sig + list(self.fixturenames))
+
+        if PYTEST8_OR_GREATER:
+            # dummy
+            self._arg2fixturedefs = dict()  # type: dict[str, Sequence["FixtureDef[Any]"]]
+
         # get parametrization marks
         self.pmarks = get_pytest_parametrize_marks(self.function)
         if self.is_parametrized:
             self.update_callspecs()
             # preserve order
-            self.required_fixtures = tuple(f for f in self.fixturenames if f not in self._calls[0].funcargs)
+            ref_names = self._calls[0].params if PYTEST8_OR_GREATER else self._calls[0].funcargs
+            self.required_fixtures = tuple(f for f in self.fixturenames if f not in ref_names)
         else:
             self.required_fixtures = self.fixturenames
 
@@ -773,8 +801,7 @@ def get_callspecs(func):
     Returns a list of pytest CallSpec objects corresponding to calls that should be made for this parametrized function.
     This mini-helper assumes no complex things (scope='function', indirect=False, no fixtures, no custom configuration)
 
-    :param func:
-    :return:
+    Note that this function is currently only used in tests.
     """
     meta = MiniMetafunc(func)
     # meta.update_callspecs()
diff --git a/src/pytest_cases/common_pytest_marks.py b/src/pytest_cases/common_pytest_marks.py
index 3dff960f..d4211514 100644
--- a/src/pytest_cases/common_pytest_marks.py
+++ b/src/pytest_cases/common_pytest_marks.py
@@ -43,6 +43,7 @@
 PYTEST6_OR_GREATER = PYTEST_VERSION >= Version('6.0.0')
 PYTEST7_OR_GREATER = PYTEST_VERSION >= Version('7.0.0')
 PYTEST71_OR_GREATER = PYTEST_VERSION >= Version('7.1.0')
+PYTEST8_OR_GREATER = PYTEST_VERSION >= Version('8.0.0')
 
 
 def get_param_argnames_as_list(argnames):
diff --git a/src/pytest_cases/plugin.py b/src/pytest_cases/plugin.py
index 457b8da4..00d3b985 100644
--- a/src/pytest_cases/plugin.py
+++ b/src/pytest_cases/plugin.py
@@ -28,9 +28,9 @@
 
 from .common_mini_six import string_types
 from .common_pytest_lazy_values import get_lazy_args
-from .common_pytest_marks import PYTEST35_OR_GREATER, PYTEST46_OR_GREATER, PYTEST37_OR_GREATER, PYTEST7_OR_GREATER
+from .common_pytest_marks import PYTEST35_OR_GREATER, PYTEST46_OR_GREATER, PYTEST37_OR_GREATER, PYTEST7_OR_GREATER, PYTEST8_OR_GREATER
 from .common_pytest import get_pytest_nodeid, get_pytest_function_scopeval, is_function_node, get_param_names, \
-    get_param_argnames_as_list, has_function_scope, set_callspec_arg_scope_to_function
+    get_param_argnames_as_list, has_function_scope, set_callspec_arg_scope_to_function, in_callspec_explicit_args
 
 from .fixture_core1_unions import NOT_USED, USED, is_fixture_union_params, UnionFixtureAlternative
 
@@ -41,7 +41,8 @@
 from .case_parametrizer_new import get_current_cases
 
 
-_DEBUG = False
+_DEBUG = True
+"""Note: this is a manual flag to turn when developing (do not forget to also call pytest with -s)"""
 
 
 # @pytest.hookimpl(hookwrapper=True, tryfirst=True)
@@ -753,7 +754,7 @@ def remove_all(self, values):
         self._update_fixture_defs()
 
 
-def getfixtureclosure(fm, fixturenames, parentnode, ignore_args=()):
+def _getfixtureclosure(fm, fixturenames, parentnode, ignore_args=()):
     """
     Replaces pytest's getfixtureclosure method to handle unions.
     """
@@ -764,7 +765,10 @@ def getfixtureclosure(fm, fixturenames, parentnode, ignore_args=()):
         # new argument "ignore_args" in 4.6+
         kwargs['ignore_args'] = ignore_args
 
-    if PYTEST37_OR_GREATER:
+    if PYTEST8_OR_GREATER:
+        # two outputs and sig change
+        ref_fixturenames, ref_arg2fixturedefs = fm.__class__.getfixtureclosure(fm, parentnode, fixturenames, **kwargs)
+    elif PYTEST37_OR_GREATER:
         # three outputs
         initial_names, ref_fixturenames, ref_arg2fixturedefs = \
             fm.__class__.getfixtureclosure(fm, fixturenames, parentnode, **kwargs)
@@ -781,12 +785,19 @@ def getfixtureclosure(fm, fixturenames, parentnode, ignore_args=()):
     assert set(super_closure) == set(ref_fixturenames)
     assert dict(arg2fixturedefs) == ref_arg2fixturedefs
 
-    if PYTEST37_OR_GREATER:
+    if PYTEST37_OR_GREATER and not PYTEST8_OR_GREATER:
         return _init_fixnames, super_closure, arg2fixturedefs
     else:
         return super_closure, arg2fixturedefs
 
 
+if PYTEST8_OR_GREATER:
+    def getfixtureclosure(fm, parentnode, initialnames, ignore_args):
+        return _getfixtureclosure(fm, fixturenames=initialnames, parentnode=parentnode, ignore_args=ignore_args)
+else:
+    getfixtureclosure = _getfixtureclosure
+
+
 def create_super_closure(fm,
                          parentnode,
                          fixturenames,
@@ -835,6 +846,11 @@ def _merge(new_items, into_list):
         # we cannot sort yet - merge the fixture names into the _init_fixnames
         _merge(fixturenames, _init_fixnames)
 
+    # Bugfix GH#330 in progress...
+    # TODO analyze why in the test "fixture_union_0simplest
+    #  the first node contains second, and the second contains first
+    # or TODO check the test for get_callspecs, it is maybe simpler
+
     # Finally create the closure
     fixture_defs_mgr = FixtureDefsCache(fm, parentnode)
     closure_tree = FixtureClosureNode(fixture_defs_mgr=fixture_defs_mgr)
@@ -1035,7 +1051,8 @@ def create_call_list_from_pending_parametrizations(self):
 
         if _DEBUG:
             print("\n".join(["%s[%s]: funcargs=%s, params=%s" % (get_pytest_nodeid(self.metafunc),
-                                                                 c.id, c.funcargs, c.params)
+                                                                 c.id, c.params if PYTEST8_OR_GREATER else c.funcargs,
+                                                                 c.params)
                              for c in calls]) + "\n")
 
         # clean EMPTY_ID set by @parametrize when there is at least a MultiParamsAlternative
@@ -1107,7 +1124,7 @@ def _cleanup_calls_list(metafunc,
 
         # A/ set to "not used" all parametrized fixtures that were not used in some branches
         for fixture, p_to_apply in pending_dct.items():
-            if fixture not in c.params and fixture not in c.funcargs:
+            if not in_callspec_explicit_args(c, fixture):
                 # parametrize with a single "not used" value and discard the id
                 if isinstance(p_to_apply, UnionParamz):
                     c_with_dummy = _parametrize_calls(metafunc, [c], p_to_apply.union_fixture_name, [NOT_USED],
@@ -1132,7 +1149,7 @@ def _cleanup_calls_list(metafunc,
         # For this we use a dirty hack: we add a parameter with they name in the callspec, it seems to be propagated
         # in the `request`. TODO is there a better way?
         for fixture_name in _not_always_used_func_scoped:
-            if fixture_name not in c.params and fixture_name not in c.funcargs:
+            if not in_callspec_explicit_args(c, fixture_name):
                 if not n.requires(fixture_name):
                     # explicitly add it as discarded by creating a parameter value for it.
                     c.params[fixture_name] = NOT_USED
diff --git a/tests/cases/issues/test_issue_126.py b/tests/cases/issues/test_issue_126.py
index 269d49e3..ce1b7265 100644
--- a/tests/cases/issues/test_issue_126.py
+++ b/tests/cases/issues/test_issue_126.py
@@ -2,12 +2,18 @@
 #          + All contributors to <https://github.com/smarie/python-pytest-cases>
 #
 # License: 3-clause BSD, <https://github.com/smarie/python-pytest-cases/blob/master/LICENSE>
+from packaging.version import Version
+
 import pytest
 
 from pytest_cases.common_pytest_marks import PYTEST3_OR_GREATER
 from pytest_cases import parametrize_with_cases
 
 
+PYTEST_VERSION = Version(pytest.__version__)
+PYTEST8_OR_GREATER = PYTEST_VERSION >= Version('8.0.0')
+
+
 @pytest.fixture()
 def dependent_fixture():
     return 0
@@ -66,7 +72,23 @@ def test_synthesis(module_results_dct):
     for host in (test_functionality, test_functionality_again, TestNested.test_functionality_again2):
         assert markers_dict[host] == (set(), set())
 
-    if PYTEST3_OR_GREATER:
+    if PYTEST8_OR_GREATER:
+        # in version 8 they added a smart suffix in case last char of id is already a numeric
+        assert list(module_results_dct) == [
+            'test_functionality[_requirement_1_0]',
+            'test_functionality[_requirement_2_0]',
+            'test_functionality[_requirement_1_1]',
+            'test_functionality[_requirement_2_1]',
+            'test_functionality_again[_requirement_1_0]',  # <- note: same fixtures than previously
+            'test_functionality_again[_requirement_2_0]',  # idem
+            'test_functionality_again[_requirement_1_1]',  # idem
+            'test_functionality_again[_requirement_2_1]',  # idem
+            'test_functionality_again2[_requirement_1_0]',  # idem
+            'test_functionality_again2[_requirement_2_0]',  # idem
+            'test_functionality_again2[_requirement_1_1]',  # idem
+            'test_functionality_again2[_requirement_2_1]'  # idem
+        ]
+    elif PYTEST3_OR_GREATER:
         assert list(module_results_dct) == [
             'test_functionality[_requirement_10]',
             'test_functionality[_requirement_20]',
diff --git a/tests/pytest_extension/parametrize_plus/test_getcallspecs.py b/tests/pytest_extension/parametrize_plus/test_getcallspecs.py
index 47cd41ad..466402d7 100644
--- a/tests/pytest_extension/parametrize_plus/test_getcallspecs.py
+++ b/tests/pytest_extension/parametrize_plus/test_getcallspecs.py
@@ -2,6 +2,8 @@
 #          + All contributors to <https://github.com/smarie/python-pytest-cases>
 #
 # License: 3-clause BSD, <https://github.com/smarie/python-pytest-cases/blob/master/LICENSE>
+from packaging.version import Version
+
 import pytest
 
 from pytest_cases import parametrize
@@ -9,6 +11,10 @@
 from pytest_cases.common_pytest_marks import has_pytest_param
 
 
+PYTEST_VERSION = Version(pytest.__version__)
+PYTEST8_OR_GREATER = PYTEST_VERSION >= Version('8.0.0')
+
+
 if not has_pytest_param:
     @pytest.mark.parametrize('new_style', [False, True])
     def test_getcallspecs(new_style):
@@ -48,10 +54,18 @@ def test_foo(a):
         calls = get_callspecs(test_foo)
 
         assert len(calls) == 2
-        assert calls[0].funcargs == dict(a=1)
+        if PYTEST8_OR_GREATER:
+            # funcargs disappears in version 8
+            assert calls[0].params == dict(a=1)
+        else:
+            assert calls[0].funcargs == dict(a=1)
         assert calls[0].id == 'a=1' if new_style else 'oh'
         assert calls[0].marks == []
 
-        assert calls[1].funcargs == dict(a='12')
+        if PYTEST8_OR_GREATER:
+            # funcargs disappears in version 8
+            assert calls[1].params == dict(a='12')
+        else:
+            assert calls[1].funcargs == dict(a='12')
         assert calls[1].id == 'a=12' if new_style else 'hey'
         assert calls[1].marks[0].name == 'skip'