From 99e25a6702bcac2210f1732a4105995b23ccdbba Mon Sep 17 00:00:00 2001 From: Islam Elsayed Date: Sun, 11 Oct 2026 10:28:03 +0300 Subject: [PATCH] Show return type annotations in --fixtures and --fixtures-per-test A fixture with a return annotation is now listed as `answer -> int -- test_x.py:4`. On Python 3.14+ the annotation is read as a string with annotationlib, so names that only exist when checking types are shown instead of evaluated. On earlier versions it is formatted with inspect.formatannotation, relative to the fixture's module. Closes #13676 --- AUTHORS | 1 + changelog/13676.improvement.rst | 1 + src/_pytest/fixtures.py | 28 ++++++ testing/python/fixtures.py | 111 ++++++++++++++++++++++- testing/python/show_fixtures_per_test.py | 29 ++++++ 5 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 changelog/13676.improvement.rst diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..bd705bbff20 100644 --- a/AUTHORS +++ b/AUTHORS @@ -220,6 +220,7 @@ Ilya Abdolmanafi Ilya Konstantinov Ionuț Turturică Isaac Virshup +Islam Elsayed Israel Fruchter Israël Hallé Itxaso Aizpurua diff --git a/changelog/13676.improvement.rst b/changelog/13676.improvement.rst new file mode 100644 index 00000000000..fa9c6959c4f --- /dev/null +++ b/changelog/13676.improvement.rst @@ -0,0 +1 @@ +Added return type annotations in ``--fixtures`` and ``--fixtures-per-test``. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 20e43f89132..9c0c6147a03 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2466,6 +2466,8 @@ def write_fixture(fixture_def: FixtureDef[object]) -> None: return prettypath = _pretty_fixture_path(invocation_dir, fixture_def.func) tw.write(f"{argname}", green=True) + if return_annotation := _format_return_annotation(fixture_def.func): + tw.write(f" -> {return_annotation}", cyan=True) tw.write(f" -- {prettypath}", yellow=True) tw.write("\n") fixture_doc = inspect.getdoc(fixture_def.func) @@ -2535,6 +2537,8 @@ def _showfixtures_main(config: Config, session: Session) -> None: if verbose <= 0 and argname.startswith("_"): continue tw.write(f"{argname}", green=True) + if return_annotation := _format_return_annotation(fixturedef.func): + tw.write(f" -> {return_annotation}", cyan=True) if fixturedef.scope != "function": tw.write(f" [{fixturedef.scope} scope]", cyan=True) tw.write(f" -- {prettypath}", yellow=True) @@ -2549,6 +2553,30 @@ def _showfixtures_main(config: Config, session: Session) -> None: tw.line() +def _format_return_annotation(func: Callable[..., object]) -> str: + """Return the return annotation of a fixture function as text, or ``""`` + if it has none.""" + if sys.version_info >= (3, 14): + import annotationlib + + # Read the annotation as written instead of evaluating it, so names the + # fixture's module cannot resolve at runtime, such as imports made only + # under TYPE_CHECKING, are shown rather than raising. + annotations = annotationlib.get_annotations( + func, format=annotationlib.Format.STRING + ) + else: + annotations = inspect.get_annotations(func) + if "return" not in annotations: + return "" + annotation = annotations["return"] + if isinstance(annotation, str): + # Already as written: always on 3.14+, and before that with + # ``from __future__ import annotations``. + return annotation + return inspect.formatannotation(annotation, base_module=func.__module__) + + def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: for line in doc.split("\n"): tw.line(indent + line) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index cb2f1daab63..39966a47354 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -3894,9 +3894,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 -> TempPathFactory [[]session scope[]] -- .../_pytest/tmpdir.py:*", "*for the test session*", - "tmp_path -- .../_pytest/tmpdir.py:*", + "tmp_path -> Generator[[]Path[]] -- .../_pytest/tmpdir.py:*", "*temporary directory*", ] ) @@ -3905,9 +3905,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 -> TempPathFactory [[]session scope[]] -- .../_pytest/tmpdir.py:*", "*for the test session*", - "tmp_path -- .../_pytest/tmpdir.py:*", + "tmp_path -> Generator[[]Path[]] -- .../_pytest/tmpdir.py:*", "*temporary directory*", ] ) @@ -3927,7 +3927,7 @@ def arg1(): result = pytester.runpytest("--fixtures", p) result.stdout.fnmatch_lines( """ - *tmp_path -- * + *tmp_path -> Generator[[]Path[]] -- * *fixtures defined from* *arg1 -- test_show_fixtures_testmodule.py:5* *hello world* @@ -3935,6 +3935,107 @@ def arg1(): ) result.stdout.no_fnmatch_line("*arg0*") + def test_show_fixtures_return_annotation(self, pytester: Pytester) -> None: + # No ``from __future__ import annotations`` here, so before Python 3.14 + # the annotations are objects rather than strings. + p = pytester.makepyfile( + """ + import pytest + + class Answer: + pass + + @pytest.fixture + def number() -> int: + return 42 + + @pytest.fixture + def nothing() -> None: + pass + + @pytest.fixture + def answer() -> Answer: + return Answer() + + @pytest.fixture(scope="session") + def numbers() -> list[int]: + return [] + + @pytest.fixture + def plain(): + pass + """ + ) + result = pytester.runpytest("--fixtures", p) + result.stdout.fnmatch_lines_random( + [ + "number -> int -- test_show_fixtures_return_annotation.py:*", + "nothing -> None -- test_show_fixtures_return_annotation.py:*", + "answer -> Answer -- test_show_fixtures_return_annotation.py:*", + "numbers -> list[[]int[]] [[]session scope[]] -- test_show_fixtures_return_annotation.py:*", + "plain -- test_show_fixtures_return_annotation.py:*", + ] + ) + + def test_show_fixtures_return_annotation_unresolvable( + self, pytester: Pytester + ) -> None: + p = pytester.makepyfile( + """ + from __future__ import annotations + + from typing import TYPE_CHECKING + + import pytest + + if TYPE_CHECKING: + from decimal import Decimal + + @pytest.fixture + def price() -> Decimal: + ... + """ + ) + result = pytester.runpytest("--fixtures", p) + result.stdout.fnmatch_lines( + [ + "price -> Decimal -- test_show_fixtures_return_annotation_unresolvable.py:*" + ] + ) + assert result.ret == 0 + + @pytest.mark.skipif( + sys.version_info < (3, 14), + reason="annotations are evaluated lazily only on Python 3.14+", + ) + def test_show_fixtures_return_annotation_lazy_unresolvable( + self, pytester: Pytester + ) -> None: + # Without the __future__ import, Python 3.14 evaluates annotations + # lazily, and reading them must not evaluate a name that is only + # imported when checking types. + p = pytester.makepyfile( + """ + from typing import TYPE_CHECKING + + import pytest + + if TYPE_CHECKING: + from decimal import Decimal + + @pytest.fixture + def price() -> Decimal: + ... + """ + ) + result = pytester.runpytest("--fixtures", p) + result.stdout.fnmatch_lines( + [ + "price -> Decimal -- test_show_fixtures_return_annotation_lazy_unresolvable.py:*" + ] + ) + assert result.ret == 0 + @pytest.mark.parametrize("testmod", [True, False]) def test_show_fixtures_conftest(self, pytester: Pytester, testmod) -> None: pytester.makeconftest( diff --git a/testing/python/show_fixtures_per_test.py b/testing/python/show_fixtures_per_test.py index 2362847f338..08bebd59e20 100644 --- a/testing/python/show_fixtures_per_test.py +++ b/testing/python/show_fixtures_per_test.py @@ -38,6 +38,35 @@ def test_arg1(arg1): result.stdout.no_fnmatch_line("*_arg0*") +def test_fixtures_return_annotation(pytester: Pytester) -> None: + p = pytester.makepyfile( + ''' + import pytest + @pytest.fixture + def arg1() -> int: + """arg1 docstring""" + @pytest.fixture + def arg2(): + """arg2 docstring""" + def test_args(arg1, arg2): + pass + ''' + ) + + result = pytester.runpytest("--fixtures-per-test", p) + assert result.ret == 0 + + result.stdout.fnmatch_lines( + [ + "*fixtures used by test_args*", + "arg1 -> int -- test_fixtures_return_annotation.py:2", + " arg1 docstring", + "arg2 -- test_fixtures_return_annotation.py:5", + " arg2 docstring", + ] + ) + + def test_fixtures_in_conftest(pytester: Pytester) -> None: pytester.makeconftest( '''