Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ Ilya Abdolmanafi
Ilya Konstantinov
Ionuț Turturică
Isaac Virshup
Islam Elsayed
Israel Fruchter
Israël Hallé
Itxaso Aizpurua
Expand Down
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``.
28 changes: 28 additions & 0 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
111 changes: 106 additions & 5 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*",
]
)
Expand All @@ -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*",
]
)
Expand All @@ -3927,14 +3927,115 @@ 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*
"""
)
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(
Expand Down
29 changes: 29 additions & 0 deletions testing/python/show_fixtures_per_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'''
Expand Down
Loading