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 @@
Return type annotations are now shown in ``--fixtures`` and ``--fixtures-per-test``.
23 changes: 23 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,23 @@ 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:
if type(annotation) == type(None):
return "None"
if isinstance(annotation, str):
return annotation
if annotation.__module__ == "typing":
return str(annotation).replace("typing.", "")
return str(annotation.__name__)
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)
77 changes: 72 additions & 5 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
from pathlib import Path
import sys
import textwrap
from typing import Any
from typing import Callable

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 +3584,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 +3595,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 +3617,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 @@ -4563,6 +4583,53 @@ def test_1(self, myfix):
reprec.assertoutcome(passed=1)


class TestGetReturnAnnotation:
def test_primitive_return_type(self):
def six() -> int:
return 6

assert get_return_annotation(six) == "int"

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

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

def test_callable_return_type(self):
def callable_return() -> Callable[..., Any]:
return self.test_compound_return_type

assert get_return_annotation(callable_return) == "Callable[..., Any]"

def test_no_annotation(self):
def no_annotation():
return 6

assert get_return_annotation(no_annotation) == ""

def test_none_return_type(self):
def none_return() -> None:
pass

assert get_return_annotation(none_return) == "None"

def test_custom_class_return_type(self):
class T:
pass

def class_return() -> T:
return T()

assert get_return_annotation(class_return) == "T"

def test_enum_return_type(self):
def enum_return() -> ExitCode:
return ExitCode(0)

assert get_return_annotation(enum_return) == "ExitCode"


def test_call_fixture_function_error():
"""Check if an error is raised if a fixture function is called directly (#4545)"""

Expand Down
24 changes: 24 additions & 0 deletions testing/python/show_fixtures_per_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,30 @@ def test_args(arg2, arg3):
)


def test_show_return_annotation(pytester: Pytester) -> None:
p = pytester.makepyfile(
"""
import pytest
@pytest.fixture
def five() -> int:
return 5
def test_five(five):
pass
"""
)

result = pytester.runpytest("--fixtures-per-test", p)
assert result.ret == 0

result.stdout.fnmatch_lines(
[
"*fixtures used by test_five*",
"*(test_show_return_annotation.py:6)*",
"five -> int -- test_show_return_annotation.py:3",
]
)


def test_doctest_items(pytester: Pytester) -> None:
pytester.makepyfile(
'''
Expand Down
Loading