Skip to content
1 change: 1 addition & 0 deletions changelog/13676.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added return type annotations in ``fixtures`` and ``fixtures-per-test``.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Added return type annotations in ``fixtures`` and ``fixtures-per-test``.
Added return type annotations in ``--fixtures`` and ``--fixtures-per-test``.

17 changes: 17 additions & 0 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1915,6 +1915,9 @@ def write_fixture(fixture_def: FixtureDef[object]) -> None:
return
prettypath = _pretty_fixture_path(invocation_dir, fixture_def.func)
tw.write(f"{argname}", green=True)
ret_annotation = get_return_annotation(fixture_def.func)
if ret_annotation:
tw.write(f" -> {ret_annotation}", cyan=True)
tw.write(f" -- {prettypath}", yellow=True)
tw.write("\n")
fixture_doc = inspect.getdoc(fixture_def.func)
Expand Down Expand Up @@ -1999,6 +2002,9 @@ def _showfixtures_main(config: Config, session: Session) -> None:
if verbose <= 0 and argname.startswith("_"):
continue
tw.write(f"{argname}", green=True)
ret_annotation = get_return_annotation(fixturedef.func)
if ret_annotation:
tw.write(f" -> {ret_annotation}", cyan=True)
if fixturedef.scope != "function":
tw.write(f" [{fixturedef.scope} scope]", cyan=True)
tw.write(f" -- {prettypath}", yellow=True)
Expand All @@ -2013,6 +2019,17 @@ def _showfixtures_main(config: Config, session: Session) -> None:
tw.line()


def get_return_annotation(fixture_func: Callable[..., Any]) -> str:
try:
sig = signature(fixture_func)
annotation = sig.return_annotation
if annotation is not sig.empty and annotation != inspect._empty:
return inspect.formatannotation(annotation).replace("'", "")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems odd to me, for various reasons:

  • inspect._empty is private so it shouldn't be used
  • inspect.formatannotation, while public, is undocumented so probably shouldn't be used either
  • Why remove ' from it, which is something inspect itself doesn't seem to do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that inspect._empty condition is actually redundant. I investigated the logic further and there were lots of inconsistent behaviors in the test (that's why I previously did replace("'", "")) and corner cases. However, the new solution (though a bit hacky) is the most general one and hopefully covering everything.

except (ValueError, TypeError):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would ValueError and TypeError happen here? The only thing that could happen I think is AttributeError, which already happens with e.g. -> None: as annotation.

That being said, None should definitively handled correctly by the code and tested properly as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeError happens when the argument is not a callable (e.g. a number). I doubt this case happens. ValueError happens when the passed argument is a callable but a signature can't be obtained (e.g. range). Also unlikely imo.
For -> None: on my local test didn't raise any error.

pass
return ""


def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None:
for line in doc.split("\n"):
tw.line(indent + line)
52 changes: 47 additions & 5 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from _pytest.compat import getfuncargnames
from _pytest.config import ExitCode
from _pytest.fixtures import deduplicate_names
from _pytest.fixtures import get_return_annotation
from _pytest.fixtures import TopRequest
from _pytest.monkeypatch import MonkeyPatch
from _pytest.pytester import get_public_names
Expand Down Expand Up @@ -3581,9 +3582,9 @@ def test_show_fixtures(self, pytester: Pytester) -> None:
result = pytester.runpytest("--fixtures")
result.stdout.fnmatch_lines(
[
"tmp_path_factory [[]session scope[]] -- .../_pytest/tmpdir.py:*",
"tmp_path_factory* [[]session scope[]] -- .../_pytest/tmpdir.py:*",
"*for the test session*",
"tmp_path -- .../_pytest/tmpdir.py:*",
"tmp_path* -- .../_pytest/tmpdir.py:*",
"*temporary directory*",
]
)
Expand All @@ -3592,9 +3593,9 @@ def test_show_fixtures_verbose(self, pytester: Pytester) -> None:
result = pytester.runpytest("--fixtures", "-v")
result.stdout.fnmatch_lines(
[
"tmp_path_factory [[]session scope[]] -- .../_pytest/tmpdir.py:*",
"tmp_path_factory* [[]session scope[]] -- .../_pytest/tmpdir.py:*",
"*for the test session*",
"tmp_path -- .../_pytest/tmpdir.py:*",
"tmp_path* -- .../_pytest/tmpdir.py:*",
"*temporary directory*",
]
)
Expand All @@ -3614,14 +3615,31 @@ def arg1():
result = pytester.runpytest("--fixtures", p)
result.stdout.fnmatch_lines(
"""
*tmp_path -- *
*tmp_path* -- *
*fixtures defined from*
*arg1 -- test_show_fixtures_testmodule.py:6*
*hello world*
"""
)
result.stdout.no_fnmatch_line("*arg0*")

def test_show_fixtures_return_annotation(self, pytester: Pytester) -> None:
p = pytester.makepyfile(
"""
import pytest
@pytest.fixture
def six() -> int:
return 6
"""
)
result = pytester.runpytest("--fixtures", p)
result.stdout.fnmatch_lines(
"""
*fixtures defined from*
*six -> int -- test_show_fixtures_return_annotation.py:3*
"""
)

@pytest.mark.parametrize("testmod", [True, False])
def test_show_fixtures_conftest(self, pytester: Pytester, testmod) -> None:
pytester.makeconftest(
Expand Down Expand Up @@ -5068,3 +5086,27 @@ def test_method(self, /, fix):
)
result = pytester.runpytest()
result.assert_outcomes(passed=1)


def test_get_return_annotation() -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe turn this into a test class and split the individual asserts into separate test functions? That way, if one of them fails, the rest of the tests will still run.

def six() -> int:
return 6

assert get_return_annotation(six) == "int"

def two_sixes() -> tuple[int, str]:
return (6, "six")

assert get_return_annotation(two_sixes) == "tuple[int, str]"

def no_annot():
return 6

assert get_return_annotation(no_annot) == ""

def none_return() -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this test happens to work (despite the bug above) because this module uses from __future__ import annotations, so the annotation already is 'None' rather than None.

Not sure how to best get around this. Maybe those tests should be in a separate module which deliberately doesn't use that?

Copy link
Contributor Author

@kianelbo kianelbo Aug 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On my local test (3.13) with or without from __future__ import annotations it worked the same. I might be very wrong but I think it behaves differently in different Python versions. Still, I added a exclusive check for none. Do you think moving it to another module is worth it? Seems too much for a humble helper function!

PS: The reason I didn't use `isinstance(annotation, types.NoneType) was that pylance was complaining.

pass

assert get_return_annotation(none_return) == "None"

assert get_return_annotation(range) == ""
Loading