Repository: pytest-dev/pytest
Pull request #13377: Fix parameter handling of non-static test methods
Author: login: purplezimmermann
Labels: name: bot:chronographer:provided
description: (automation) changelog entry is part of PR
Created: 2025-04-15T15:13:47Z | Merged: 2025-04-18T21:18:37Z

– by supporting positional-only self parameters in `_pytest.compat.getfuncargnames()`

Fixes #13376

_Tests & changelog entry will be added if PR is seen as useful ... :)_

<!--
Thanks for submitting a PR, your contribution is really appreciated!

Here is a quick checklist that should be present in PRs.

- [ ] Include documentation when adding new features.
- [ ] Include new tests or update existing tests when applicable.
- [X] Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.

If this change fixes an issue, please:

- [ ] Add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the [github docs](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for more information.

Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please:

- [ ] Create a new changelog file in the `changelog` folder, with a name like `<ISSUE NUMBER>.<TYPE>.rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/main/changelog/README.rst) for details.

  Write sentences in the **past or present tense**, examples:

  * *Improved verbose diff output with sequences.*
  * *Terminal summary statistics now use multiple colors.*

  Also make sure to end the sentence with a `.`.

- [ ] Add yourself to `AUTHORS` in alphabetical order.
-->


Linked issue #13376: Bug in _pytest.compat.getfuncargnames() corrupts fixture handling for test methods w/ positional-only self parameter
<!--
Thanks for submitting an issue!

Quick check-list while reporting bugs:
-->

- [x] a detailed description of the bug or problem you are having
- _Doesn't depend on specific packages_ [ ] output of `pip list` from the virtual environment you are using
- _Isn't related to specific Pytest versions_ [ ] pytest and operating system versions
- [x] minimal example if possible

### Basic example

**Passing:**

```python
import pytest

@pytest.fixture
def value_fixture():
    return 'value'

class TestClass:

    def test_value(self, value_fixture):
        assert value_fixture == 'value'
```

**Failing:**

```python
import pytest


@pytest.fixture
def value_fixture():
    return 'value'


class TestClass:

    def test_value(self, /, value_fixture):
        assert value_fixture == 'value'
```

* Result:

```python
pyfuncitem = <Function test_value>

    @hookimpl(trylast=True)
    def pytest_pyfunc_call(pyfuncitem: Function) -> object | None:
        testfunction = pyfuncitem.obj
        if is_async_function(testfunction):
            async_fail(pyfuncitem.nodeid)
        funcargs = pyfuncitem.funcargs
        testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames}
>       result = testfunction(**testargs)
                 ^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: TestClass.test_value() missing 1 required positional argument: 'value_fixture'
```

### Reason

`_pytest.compat.getfuncargnames()` collects keyword-callable parameters as fixture-receiving arguments:

https://github.com/pytest-dev/pytest/blob/103b2b67b015b5f677eef471c3d34706b5efedaa/src/_pytest/compat.py#L134-L142

Then it removes the first one – supposedly `'self'` – if given `function` is a non-static method:

https://github.com/pytest-dev/pytest/blob/103b2b67b015b5f677eef471c3d34706b5efedaa/src/_pytest/compat.py#L149-L157

Yet, if `'self'` is defined as positional only, it wasn't contained in `arg_names` before removal ...




Diff:
diff --git a/changelog/13377.bugfix.rst b/changelog/13377.bugfix.rst
new file mode 100644
index 00000000000..15755481f7f
--- /dev/null
+++ b/changelog/13377.bugfix.rst
@@ -0,0 +1,12 @@
+Fixed handling of test methods with positional-only parameter syntax.
+
+Now, methods are supported that formally define ``self`` as positional-only
+and/or fixture parameters as keyword-only, e.g.:
+
+.. code-block:: python
+
+    class TestClass:
+
+        def test_method(self, /, *, fixture): ...
+
+Before, this caused an internal error in pytest.
diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py
index 2cbb17eca38..f113a2197f3 100644
--- a/src/_pytest/compat.py
+++ b/src/_pytest/compat.py
@@ -122,7 +122,7 @@ def getfuncargnames(
     # creates a tuple of the names of the parameters that don't have
     # defaults.
     try:
-        parameters = signature(function).parameters
+        parameters = signature(function).parameters.values()
     except (ValueError, TypeError) as e:
         from _pytest.outcomes import fail
 
@@ -133,7 +133,7 @@ def getfuncargnames(
 
     arg_names = tuple(
         p.name
-        for p in parameters.values()
+        for p in parameters
         if (
             p.kind is Parameter.POSITIONAL_OR_KEYWORD
             or p.kind is Parameter.KEYWORD_ONLY
@@ -144,9 +144,9 @@ def getfuncargnames(
         name = function.__name__
 
     # If this function should be treated as a bound method even though
-    # it's passed as an unbound method or function, remove the first
-    # parameter name.
-    if (
+    # it's passed as an unbound method or function, and its first parameter
+    # wasn't defined as positional only, remove the first parameter name.
+    if not any(p.kind is Parameter.POSITIONAL_ONLY for p in parameters) and (
         # Not using `getattr` because we don't want to resolve the staticmethod.
         # Not using `cls.__dict__` because we want to check the entire MRO.
         cls
diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py
index 32453739e8c..fb76fe6cf96 100644
--- a/testing/python/fixtures.py
+++ b/testing/python/fixtures.py
@@ -48,7 +48,23 @@ class A:
         def f(self, arg1, arg2="hello"):
             raise NotImplementedError()
 
+        def g(self, /, arg1, arg2="hello"):
+            raise NotImplementedError()
+
+        def h(self, *, arg1, arg2="hello"):
+            raise NotImplementedError()
+
+        def j(self, arg1, *, arg2, arg3="hello"):
+            raise NotImplementedError()
+
+        def k(self, /, arg1, *, arg2, arg3="hello"):
+            raise NotImplementedError()
+
     assert getfuncargnames(A().f) == ("arg1",)
+    assert getfuncargnames(A().g) == ("arg1",)
+    assert getfuncargnames(A().h) == ("arg1",)
+    assert getfuncargnames(A().j) == ("arg1", "arg2")
+    assert getfuncargnames(A().k) == ("arg1", "arg2")
 
 
 def test_getfuncargnames_staticmethod():
@@ -5033,3 +5049,22 @@ def test_foo(another_fixture):
     )
     result = pytester.runpytest()
     result.assert_outcomes(passed=1)
+
+
+def test_collect_positional_only(pytester: Pytester) -> None:
+    """Support the collection of tests with positional-only arguments (#13376)."""
+    pytester.makepyfile(
+        """
+        import pytest
+
+        class Test:
+            @pytest.fixture
+            def fix(self):
+                return 1
+
+            def test_method(self, /, fix):
+                assert fix == 1
+        """
+    )
+    result = pytester.runpytest()
+    result.assert_outcomes(passed=1)


Reviews:
user: login: RonnyPfannschmidt
body: Nice start 

We need to add a testcase and a changelog as well 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T18:43:57Z
user: login: RonnyPfannschmidt
body: this works, thanks!

optionally we might want to add a example of this to a integration test to demonstrate this works as a whole

i'll let someone else nitpick the change-log entry and do the merge
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T10:53:27Z
user: login: nicoddemus
body: Nice work!

I also think an integration test is worth having, one that uses `pytester` and shows the fix in a simple test case.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-16T12:02:27Z
user: login: webknjaz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T12:26:17Z
user: login: purplezimmermann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:07:16Z
user: login: webknjaz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T15:10:06Z
user: login: webknjaz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T15:12:22Z
user: login: webknjaz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T15:12:44Z
user: login: purplezimmermann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:13:08Z
user: login: webknjaz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T15:13:33Z
user: login: purplezimmermann
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:32:45Z
user: login: Tusenka
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-18T12:18:28Z
user: login: nicoddemus
body: Thanks!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-18T20:45:13Z

Review comments:
diff_hunk: @@ -144,9 +144,9 @@ def getfuncargnames(
         name = function.__name__
 
     # If this function should be treated as a bound method even though
-    # it's passed as an unbound method or function, remove the first
-    # parameter name.
-    if (
+    # it's passed as an unbound method or function, and it's first parameter
path: src/_pytest/compat.py
user: login: nicoddemus
body: ```suggestion
    # it's passed as an unbound method or function, and its first parameter
```
created_at: 2025-04-16T11:56:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+Fixed ``_pytest.compat.getfuncargnames()`` to correctly handle
path: changelog/13377.bugfix.rst
user: login: nicoddemus
body: Can you please rewrite this from the POV of a pytest user, without mentioning the internals? Usually an example showing what did not work before, and that now does with the fix.



created_at: 2025-04-16T11:58:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+Fixed ``_pytest.compat.getfuncargnames()`` to correctly handle
path: changelog/13377.bugfix.rst
user: login: webknjaz
body: Additional advice: you can check the preview @ https://pytest--13377.org.readthedocs.build/en/13377/changelog.html and confirm visually that the style matches the surroundings. It may be helpful to see it among other notes in the context.
created_at: 2025-04-16T12:26:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+Fixed ``_pytest.compat.getfuncargnames()`` to correctly handle
path: changelog/13377.bugfix.rst
user: login: purplezimmermann
body: Thanks @webknjaz for the hint! –& @nicoddemus well, I've tried my best ;) I just have no talent for naming identifiers in example/pseudo code ... What do you think of the result?
created_at: 2025-04-16T15:07:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,2 @@
+Fixed ``_pytest.compat.getfuncargnames()`` to correctly handle
path: changelog/13377.bugfix.rst
user: login: webknjaz
body: Looks like it broke the docs build: https://app.readthedocs.org/projects/pytest/builds/27877058/#267820705--276
created_at: 2025-04-16T15:10:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,17 @@
+Fixed handling of test methods with special parameter syntax.
+
+Now, methods are supported that formally define ``self`` as positional-only
+and/or fixture parameters as keyword-only, e.g.:
+
+.. code-block:: python
+
+    class TestClass:
+
+        def test_method(self, /, *, fixture):
+            ...
+
+Before, this led to:
+
+.. code-block:: python
path: changelog/13377.bugfix.rst
user: login: webknjaz
body: ```suggestion
.. code-block:: python-traceback
```
or
```suggestion
.. code-block:: console
```
created_at: 2025-04-16T15:12:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+Fixed ``_pytest.compat.getfuncargnames()`` to correctly handle
path: changelog/13377.bugfix.rst
user: login: webknjaz
body: oh, I see you've fixed that already
created_at: 2025-04-16T15:12:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+Fixed ``_pytest.compat.getfuncargnames()`` to correctly handle
path: changelog/13377.bugfix.rst
user: login: purplezimmermann
body: ~Two hyphens fixed the build :)~ not really ...
created_at: 2025-04-16T15:13:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,2 @@
+Fixed ``_pytest.compat.getfuncargnames()`` to correctly handle
path: changelog/13377.bugfix.rst
user: login: webknjaz
body: now the linters don't like it..
created_at: 2025-04-16T15:13:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,17 @@
+Fixed handling of test methods with special parameter syntax.
+
+Now, methods are supported that formally define ``self`` as positional-only
+and/or fixture parameters as keyword-only, e.g.:
+
+.. code-block:: python
+
+    class TestClass:
+
+        def test_method(self, /, *, fixture):
+            ...
+
+Before, this led to:
+
+.. code-block:: python
path: changelog/13377.bugfix.rst
user: login: purplezimmermann
body: `python-console` did it :) – w/o any highlighting though ... 
created_at: 2025-04-16T15:32:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,7 +48,23 @@ class A:
         def f(self, arg1, arg2="hello"):
             raise NotImplementedError()
 
+        def g(self, /, arg1, arg2="hello"):
+            raise NotImplementedError()
+
+        def h(self, *, arg1, arg2="hello"):
+            raise NotImplementedError()
+
+        def j(self, arg1, *, arg2, arg3="hello"):
+            raise NotImplementedError()
+
+        def k(self, /, arg1, *, arg2, arg3="hello"):
+            raise NotImplementedError()
+
     assert getfuncargnames(A().f) == ("arg1",)
+    assert getfuncargnames(A().g) == ("arg1",)
+    assert getfuncargnames(A().h) == ("arg1",)
+    assert getfuncargnames(A().j) == ("arg1", "arg2")
+    assert getfuncargnames(A().k) == ("arg1", "arg2")
 
path: testing/python/fixtures.py
user: login: Tusenka
body: Why we couldn't add example here?
```
        def l(self, /, *, arg1, arg2="hello"):
            raise NotImplementedError()
```
Is it redundant?
created_at: 2025-04-18T12:18:28Z
author_association: NONE
diff_hunk: @@ -0,0 +1,27 @@
+Fixed handling of test methods with special parameter syntax.
+
+Now, methods are supported that formally define ``self`` as positional-only
+and/or fixture parameters as keyword-only, e.g.:
+
+.. code-block:: python
+
+    class TestClass:
+
+        def test_method(self, /, *, fixture): ...
+
+Before, this led to:
+
+.. code-block:: python-console
+
+    pyfuncitem = <Function test_method>
+
+        @hookimpl(trylast=True)
+        def pytest_pyfunc_call(pyfuncitem: Function) -> object | None:
+            testfunction = pyfuncitem.obj
+            if is_async_function(testfunction):
+                async_fail(pyfuncitem.nodeid)
+            funcargs = pyfuncitem.funcargs
+            testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames}
+    >       result = testfunction(**testargs)
+                 ^^^^^^^^^^^^^^^^^^^^^^^^
+    E       TypeError: TestClass.test_method() missing 1 required positional argument: 'fixture'
path: changelog/13377.bugfix.rst
user: login: nicoddemus
body: This is good, but perhaps the full exception is a bit too much, how about:

```suggestion
    class TestClass:

        def test_method(self, /, *, fixture): ...

Before, this caused an internal error in pytest.
```
created_at: 2025-04-18T20:39:11Z
author_association: MEMBER

PR comments:
user: login: purplezimmermann
created_at: 2025-04-16T10:14:20Z
body: > We need to add a testcase and a changelog as well

Bitteschoen, @RonnyPfannschmidt 

I hope it will be soon released so I can gift all my test method signatures with my obsessive pedantry ;) 
author_association: CONTRIBUTOR
user: login: patchback[bot]
created_at: 2025-04-18T21:18:40Z
body: 
### Backport to 8.3.x: 💔 cherry-picking failed — conflicts found

❌ Failed to cleanly apply 61c204a0355c89946cb0f3202820c5a0ec85e13d on top of patchback/backports/8.3.x/61c204a0355c89946cb0f3202820c5a0ec85e13d/pr-13377



### Backporting merged PR #13377 into main

1. Ensure you have a local repo clone of your fork. Unless you cloned it
   from the upstream, this would be your `origin` remote.
2. Make sure you have an upstream repo added as a remote too. In these
   instructions you'll refer to it by the name `upstream`. If you don't
   have it, here's how you can add it:
   ```console
   $ git remote add upstream https://github.com/pytest-dev/pytest.git
   ```
3. Ensure you have the latest copy of upstream and prepare a branch
   that will hold the backported code:
   ```console
   $ git fetch upstream
   $ git checkout -b patchback/backports/8.3.x/61c204a0355c89946cb0f3202820c5a0ec85e13d/pr-13377 upstream/8.3.x
   ```
4. Now, cherry-pick PR #13377 contents into that branch:
   ```console
   $ git cherry-pick -x 61c204a0355c89946cb0f3202820c5a0ec85e13d
   ```
   If it'll yell at you with something like `fatal: Commit 61c204a0355c89946cb0f3202820c5a0ec85e13d is
   a merge but no -m option was given.`, add `-m 1` as follows instead:
   ```console
   $ git cherry-pick -m1 -x 61c204a0355c89946cb0f3202820c5a0ec85e13d
   ```
5. At this point, you'll probably encounter some merge conflicts. You must
   resolve them in to preserve the patch from PR #13377 as close to the
   original as possible.
6. Push this branch to your fork on GitHub:
   ```console
   $ git push origin patchback/backports/8.3.x/61c204a0355c89946cb0f3202820c5a0ec85e13d/pr-13377
   ```
7. Create a PR, ensure that the CI is green. If it's not — update it so that
   the tests and any other checks pass. This is it!
   Now relax and wait for the maintainers to process your pull request
   when they have some cycles to do reviews. Don't worry — they'll tell you if
   any improvements are necessary when the time comes!




<sub><sup>
--
🤖 @patchback
I'm built with [octomachinery](https://octomachinery.dev) and
my source is open — https://github.com/sanitizers/patchback-github-app.
</sup></sub>


author_association: NONE