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 changelog/8056.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``Item.location`` and ``TestReport.location`` now return an ``ItemLocation`` NamedTuple instead of a plain tuple, giving named access to ``path``, ``lineindex``, and ``testname``. A similar ``CodeLocation`` NamedTuple is used internally for fixture locations. Both types are tuple subclasses, so existing code that indexes or unpacks them is unaffected.
6 changes: 6 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,12 @@ Item
:members:
:show-inheritance:

ItemLocation
~~~~~~~~~~~~

.. autoclass:: pytest.ItemLocation
:members:

File
~~~~

Expand Down
72 changes: 66 additions & 6 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import sys
from typing import Any
from typing import Final
from typing import NamedTuple
from typing import NoReturn
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -72,18 +73,77 @@ def signature(obj: Callable[..., Any]) -> Signature:
return inspect.signature(obj)


def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str:
class CodeLocation(NamedTuple):
"""A source code location: file path and line number.

Converts to a ``path:lineno`` string representation.

The stored ``lineindex`` is 0-based; use the ``lineno`` property
for the conventional 1-based line number.
"""

path: Path
lineindex: int

@property
def lineno(self) -> int:
"""1-based line number for display."""
return self.lineindex + 1

def __str__(self) -> str:
return f"{self.path}:{self.lineno}"


class ItemLocation(NamedTuple):
"""Location of a test item: relative path, line index, and test name.

Returned by :attr:`Item.location <pytest.Item.location>` and stored
on :class:`TestReport <pytest.TestReport>`.

The stored ``lineindex`` is 0-based (matching ``reportinfo()``);
use the ``lineno`` property for the conventional 1-based number.
"""

path: str
lineindex: int | None
testname: str

@property
def lineno(self) -> int | None:
"""1-based line number for display, or *None*."""
return self.lineindex + 1 if self.lineindex is not None else None

def __str__(self) -> str:
if self.lineno is not None:
return f"{self.path}:{self.lineno}"
return self.path


def getlocation(
function,
relative_to: Path | None,
) -> CodeLocation:
"""Return the source location (file path, line number) of *function*.

:param function:
The function (or wrapped function) to locate.
:param relative_to:
If given, the returned path is made relative to this directory.
Only strict sub-paths are relativised; everything else keeps
its absolute path.
"""
function = get_real_func(function)
fn = Path(inspect.getfile(function))
lineno = function.__code__.co_firstlineno
if curdir is not None:
lineindex = function.__code__.co_firstlineno - 1

if relative_to is not None:
try:
relfn = fn.relative_to(curdir)
relfn = fn.relative_to(relative_to)
except ValueError:
pass
else:
return f"{relfn}:{lineno}"
return f"{fn}:{lineno}"
return CodeLocation(relfn, lineindex)
return CodeLocation(fn, lineindex)


def num_mock_patch_args(function) -> int:
Expand Down
18 changes: 10 additions & 8 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,9 @@ def _check_fixturedef_without_param(self, fixturedef: FixtureDef[object]) -> Non
source_path_str = str(source_path.relative_to(funcitem.config.rootpath))
except ValueError:
source_path_str = str(source_path)
location = getlocation(fixturedef.func, funcitem.config.rootpath)
location = getlocation(
fixturedef.func, relative_to=funcitem.config.rootpath
)
msg = (
"The requested fixture has no parameter defined for test:\n"
f" {funcitem.nodeid}\n\n"
Expand Down Expand Up @@ -1443,7 +1445,7 @@ def __call__(self, function: FixtureFunction) -> FixtureFunctionDefinition:

name = self.name or function.__name__
if name == "request":
location = getlocation(function)
location = getlocation(function, relative_to=None)
fail(
f"'request' is a reserved word for fixtures, use another name:\n {location}",
pytrace=False,
Expand Down Expand Up @@ -2409,13 +2411,13 @@ def show_fixtures_per_test(config: Config) -> int | ExitCode:
_PYTEST_DIR = Path(_pytest.__file__).parent


def _pretty_fixture_path(invocation_dir: Path, func) -> str:
loc = Path(getlocation(func, invocation_dir))
def _pretty_fixture_path(invocation_dir: Path, func: object) -> str:
location = getlocation(func, relative_to=None)
prefix = Path("...", "_pytest")
try:
return str(prefix / loc.relative_to(_PYTEST_DIR))
return f"{prefix / location.path.relative_to(_PYTEST_DIR)}:{location.lineno}"
except ValueError:
return bestrelpath(invocation_dir, loc)
return f"{bestrelpath(invocation_dir, location.path)}:{location.lineno}"


def _get_fixtures_per_test(test: nodes.Item) -> Iterator[FixtureDef[object]]:
Expand Down Expand Up @@ -2457,8 +2459,8 @@ def _show_fixtures_per_test(config: Config, session: Session) -> None:
verbose = config.get_verbosity()

def get_best_relpath(func) -> str:
loc = getlocation(func, invocation_dir)
return bestrelpath(invocation_dir, Path(loc))
location = getlocation(func, relative_to=None)
return f"{bestrelpath(invocation_dir, location.path)}:{location.lineno}"

def write_fixture(fixture_def: FixtureDef[object]) -> None:
argname = fixture_def.argname
Expand Down
11 changes: 6 additions & 5 deletions src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from _pytest._code.code import TerminalRepr
from _pytest._code.code import Traceback
from _pytest._code.code import TracebackStyle
from _pytest.compat import ItemLocation
from _pytest.compat import LEGACY_PATH
from _pytest.compat import signature
from _pytest.config import Config
Expand Down Expand Up @@ -784,14 +785,14 @@ def reportinfo(self) -> tuple[os.PathLike[str] | str, int | None, str]:
return self.path, None, ""

@cached_property
def location(self) -> tuple[str, int | None, str]:
def location(self) -> ItemLocation:
"""
Returns a tuple of ``(relfspath, lineno, testname)`` for this item
where ``relfspath`` is file path relative to ``config.rootpath``
and lineno is a 0-based line number.
Returns an :class:`ItemLocation <pytest.ItemLocation>` of ``(path, lineindex, testname)``
for this item where ``path`` is relative to ``config.rootpath``
and ``lineindex`` is a 0-based line number.
"""
location = self.reportinfo()
path = absolutepath(location[0])
relfspath = self.session._node_location_to_relpath(path)
assert type(location[2]) is str
return (relfspath, location[1], location[2])
return ItemLocation(relfspath, location[1], location[2])
31 changes: 22 additions & 9 deletions src/_pytest/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from _pytest._code.code import ReprTraceback
from _pytest._code.code import TerminalRepr
from _pytest._io import TerminalWriter
from _pytest.compat import ItemLocation
from _pytest.config import Config
from _pytest.nodeid import coerce_node_id
from _pytest.nodeid import NodeId
Expand Down Expand Up @@ -62,7 +63,7 @@ def getworkerinfoline(node):

class BaseReport:
when: str | None
location: tuple[str, int | None, str] | None
location: ItemLocation | None
longrepr: (
ExceptionInfo[BaseException] | tuple[str, int, str] | str | TerminalRepr | None
)
Expand Down Expand Up @@ -344,7 +345,7 @@ def id(self) -> NodeId:
def __init__(
self,
nodeid: str | NodeId,
location: tuple[str, int | None, str],
location: ItemLocation | tuple[str, int | None, str],
keywords: Mapping[str, Literal[1]],
outcome: Literal["passed", "failed", "skipped"],
longrepr: ExceptionInfo[BaseException]
Expand All @@ -363,12 +364,19 @@ def __init__(
#: Normalized collection nodeid.
self._id = coerce_node_id(nodeid)

#: A (filesystempath, lineno, domaininfo) tuple indicating the
#: actual location of a test item - it might be different from the
#: collected one e.g. if a method is inherited from a different module.
#: An :class:`ItemLocation <pytest.ItemLocation>` (filesystempath, lineindex, domaininfo)
#: indicating the actual location of a test item - it might be
#: different from the collected one e.g. if a method is inherited
#: from a different module.
#: The filesystempath may be relative to ``config.rootdir``.
#: The line number is 0-based.
self.location: tuple[str, int | None, str] = location
#: The line number (``lineindex``) is 0-based.
self.location: ItemLocation = (
location
if isinstance(location, ItemLocation)
else ItemLocation(*location)
if len(location) == 3
else location
)

#: The names in :attr:`Node.keywords <_pytest.nodes.Node.keywords>`
#: of the item, each mapping to ``1``: only the names survive into the
Expand Down Expand Up @@ -532,8 +540,8 @@ def __init__(
@property
def location( # type:ignore[override]
self,
) -> tuple[str, int | None, str] | None:
return (self.fspath, None, self.fspath)
) -> ItemLocation | None:
return ItemLocation(self.fspath, None, self.fspath)

def __repr__(self) -> str:
return f"<CollectReport {self.nodeid!r} lenresult={len(self.result)} outcome={self.outcome!r}>"
Expand Down Expand Up @@ -648,6 +656,8 @@ def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]:
d[name] = os.fspath(d[name])
elif name == "result":
d[name] = None # for now
if "location" in d and isinstance(d["location"], ItemLocation):
d["location"] = tuple(d["location"])
return d


Expand Down Expand Up @@ -731,4 +741,7 @@ def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None):
exception_info.addsection(*section)
reportdict["longrepr"] = exception_info

if "location" in reportdict and reportdict["location"] is not None:
reportdict["location"] = ItemLocation(*reportdict["location"])

return reportdict
8 changes: 6 additions & 2 deletions src/_pytest/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,13 @@ def pytest_sessionfinish(session: Session) -> None:

def pytest_runtest_protocol(item: Item, nextitem: Item | None) -> bool:
ihook = item.ihook
ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
# Plain tuple for backward compat: pytest-xdist (<= 3.x) sends location
# over execnet which cannot serialize NamedTuple subclasses.
# TODO: pass ItemLocation directly once xdist handles it (#8056).
location = tuple(item.location)
ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=location)
runtestprotocol(item, nextitem=nextitem)
ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=location)
return True


Expand Down
2 changes: 2 additions & 0 deletions src/pytest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from _pytest.assertion import register_assert_rewrite
from _pytest.cacheprovider import Cache
from _pytest.capture import CaptureFixture
from _pytest.compat import ItemLocation
from _pytest.config import cmdline
from _pytest.config import Config
from _pytest.config import console_main
Expand Down Expand Up @@ -120,6 +121,7 @@
"Function",
"HookRecorder",
"Item",
"ItemLocation",
"LineMatcher",
"LogCaptureFixture",
"Mark",
Expand Down
31 changes: 31 additions & 0 deletions testing/python/show_fixtures_per_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from _pytest.monkeypatch import MonkeyPatch
from _pytest.pytester import Pytester


Expand Down Expand Up @@ -326,3 +327,33 @@ def test_indirectly_parametrized_fixture(indirectly):
" indirectly parametrized fixture",
]
)


def test_paths_relative_to_invocation_dir(
pytester: Pytester, monkeypatch: MonkeyPatch
) -> None:
"""A regression guard for #8056, which briefly showed an absolute path for
a fixture outside the invocation directory and a rootdir-relative header."""
pytester.makeini("[pytest]")
pytester.makeconftest(
"""
import pytest
@pytest.fixture
def shared():
pass
"""
)
sub = pytester.mkdir("sub")
sub.joinpath("test_sub.py").write_text(
"def test_it(shared):\n pass\n", encoding="utf-8"
)
monkeypatch.chdir(sub)
result = pytester.runpytest("--fixtures-per-test")
assert result.ret == 0
result.stdout.fnmatch_lines(
[
"*fixtures used by test_it*",
"*(test_sub.py:1)*",
"shared -- ../conftest.py:2",
]
)
15 changes: 15 additions & 0 deletions testing/test_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from _pytest.compat import assert_never
from _pytest.compat import deprecated
from _pytest.compat import get_real_func
from _pytest.compat import ItemLocation
from _pytest.compat import safe_getattr
from _pytest.compat import safe_isclass
from _pytest.outcomes import OutcomeException
Expand Down Expand Up @@ -192,6 +193,20 @@ def test_assert_never_literal() -> None:
assert_never(x)


def test_itemlocation_str_no_lineno() -> None:
"""ItemLocation.__str__ with lineindex=None omits the line number."""
loc = ItemLocation("tests/foo.py", None, "test_bar")
assert str(loc) == "tests/foo.py"
assert loc.lineno is None


def test_itemlocation_str_with_lineno() -> None:
"""ItemLocation.__str__ with a lineindex includes 1-based lineno."""
loc = ItemLocation("tests/foo.py", 9, "test_bar")
assert str(loc) == "tests/foo.py:10"
assert loc.lineno == 10


def test_deprecated() -> None:
# This test is mostly for coverage.

Expand Down
5 changes: 3 additions & 2 deletions testing/test_junitxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import xmlschema

from _pytest.compat import ItemLocation
from _pytest.config import Config
from _pytest.junitxml import _JunitDurationReport
from _pytest.junitxml import _JunitFamily
Expand Down Expand Up @@ -1270,7 +1271,7 @@ def test_unicode_issue368(pytester: Pytester) -> None:
class Report(BaseReport):
longrepr = ustr
sections: list[tuple[str, str]] = []
location = "tests/filename.py", 42, "TestClass.method"
location = ItemLocation("tests/filename.py", 42, "TestClass.method")
when = "teardown"

test_report = cast(TestReport, Report())
Expand Down Expand Up @@ -1615,7 +1616,7 @@ def test_url_property(pytester: Pytester) -> None:
class Report(BaseReport):
longrepr = "FooBarBaz"
sections: list[tuple[str, str]] = []
location = "tests/filename.py", 42, "TestClass.method"
location = ItemLocation("tests/filename.py", 42, "TestClass.method")
url = test_url

test_report = cast(TestReport, Report())
Expand Down
Loading
Loading