From 81563b1480772a90783b7d746bfd1378c4f3c44f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 23 Sep 2026 08:30:03 +0200 Subject: [PATCH 1/2] compat: return a CodeLocation from getlocation getlocation() returned a "path:lineno" string, which callers then parsed back into a path again (Path("file.py:12") in _pretty_fixture_path). It now returns a CodeLocation NamedTuple: the path and the 0-based lineindex, with a 1-based lineno property and a "path:lineno" str(). Paths in --fixtures and --fixtures-per-test stay relative to the invocation directory, including "../" for fixtures defined above it. Co-authored-by: Cursor Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 Co-Authored-By: Claude Opus 5.5 via Claude Code --- src/_pytest/compat.py | 47 +++++++++++++++++++++--- src/_pytest/fixtures.py | 18 +++++---- testing/python/show_fixtures_per_test.py | 31 ++++++++++++++++ 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 0e060f15d63..82553e69e40 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -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 @@ -72,18 +73,52 @@ 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}" + + +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: diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 30f44d44dfc..c106ea36ead 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -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" @@ -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, @@ -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]]: @@ -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 diff --git a/testing/python/show_fixtures_per_test.py b/testing/python/show_fixtures_per_test.py index 2362847f338..3a6b52534ee 100644 --- a/testing/python/show_fixtures_per_test.py +++ b/testing/python/show_fixtures_per_test.py @@ -1,5 +1,6 @@ from __future__ import annotations +from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester @@ -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", + ] + ) From 12e099b83f6adfd1b7fc1618e27cfdc4a25f19fa Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 23 Sep 2026 08:32:00 +0200 Subject: [PATCH 2/2] nodes: make Item.location and TestReport.location an ItemLocation ItemLocation is a NamedTuple of (path, lineindex, testname) replacing the bare tuple[str, int | None, str]. The stored lineindex stays 0-based as reportinfo() returns it; the lineno property gives the 1-based number and str() renders "path:lineno". Being a tuple subclass, existing code that indexes or unpacks a location is unaffected. TestReport coerces a plain tuple into an ItemLocation, including when a report is rebuilt from JSON. pytest-xdist (<= 3.x) sends the location of pytest_runtest_logstart/logfinish over execnet, which cannot serialize NamedTuple subclasses, so those hook calls and _report_to_json pass a plain tuple. ItemLocation is exported from pytest and documented in the reference. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 Co-authored-by: Anthropic Claude Opus 4.6 Co-Authored-By: Claude Opus 5.5 via Claude Code --- changelog/8056.misc.rst | 1 + doc/en/reference/reference.rst | 6 ++++++ src/_pytest/compat.py | 25 +++++++++++++++++++++++++ src/_pytest/nodes.py | 11 ++++++----- src/_pytest/reports.py | 31 ++++++++++++++++++++++--------- src/_pytest/runner.py | 8 ++++++-- src/pytest/__init__.py | 2 ++ testing/test_compat.py | 15 +++++++++++++++ testing/test_junitxml.py | 5 +++-- testing/typing_checks.py | 3 ++- 10 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 changelog/8056.misc.rst diff --git a/changelog/8056.misc.rst b/changelog/8056.misc.rst new file mode 100644 index 00000000000..09d31e820fb --- /dev/null +++ b/changelog/8056.misc.rst @@ -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. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 54074262401..e4631a1114b 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -888,6 +888,12 @@ Item :members: :show-inheritance: +ItemLocation +~~~~~~~~~~~~ + +.. autoclass:: pytest.ItemLocation + :members: + File ~~~~ diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 82553e69e40..2e25affa714 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -94,6 +94,31 @@ 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 ` and stored + on :class:`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, diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 2b510907122..f73665fad47 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -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 @@ -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 ` 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]) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 722f71909d4..028f5c3cac6 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -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 @@ -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 ) @@ -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] @@ -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 ` (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 @@ -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"" @@ -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 @@ -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 diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 27c5739845a..9e85ba3a61f 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -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 diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index f6ea24f95a1..e3b21d4e212 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -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 @@ -120,6 +121,7 @@ "Function", "HookRecorder", "Item", + "ItemLocation", "LineMatcher", "LogCaptureFixture", "Mark", diff --git a/testing/test_compat.py b/testing/test_compat.py index 2c86f06c9dd..2f114fd0023 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -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 @@ -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. diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 3b51495ac6b..c6d7c2685df 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -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 @@ -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()) @@ -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()) diff --git a/testing/typing_checks.py b/testing/typing_checks.py index 1179f3f5d19..10792a4d45c 100644 --- a/testing/typing_checks.py +++ b/testing/typing_checks.py @@ -12,6 +12,7 @@ from typing_extensions import assert_type +from _pytest.compat import ItemLocation import pytest from pytest import MonkeyPatch from pytest import ScopeName @@ -58,7 +59,7 @@ def check_raises_is_a_context_manager(val: bool) -> None: # Issue #12941. def check_testreport_attributes(report: TestReport) -> None: assert_type(report.when, Literal["setup", "call", "teardown"]) - assert_type(report.location, tuple[str, int | None, str]) + assert_type(report.location, ItemLocation) # Issue #14234.