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
5 changes: 5 additions & 0 deletions changelog/14935.improvement.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Each pytest session directory (``pytest-of-<user>/pytest-N``) now contains
a ``.origin`` sidecar recording the ``rootpath``, pytest version, PID, and
hostname of the session that created it. This lets external cleanup tooling
attribute a session directory to a specific project by reading a file,
rather than by inspecting live process state.
47 changes: 47 additions & 0 deletions src/_pytest/tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pathlib import Path
import re
from shutil import rmtree
import socket
import stat
import tempfile
from typing import Any
Expand All @@ -22,6 +23,7 @@
from .pathlib import make_numbered_dir
from .pathlib import make_numbered_dir_with_cleanup
from .pathlib import rm_rf
import _pytest
from _pytest.compat import get_user_id
from _pytest.config import Config
from _pytest.config import ExitCode
Expand Down Expand Up @@ -53,6 +55,7 @@ class TempPathFactory:
_basetemp: Path | None
_retention_count: int
_retention_policy: RetentionType
_rootpath: Path | None

def __init__(
self,
Expand All @@ -61,6 +64,7 @@ def __init__(
retention_policy: RetentionType,
trace,
basetemp: Path | None = None,
rootpath: Path | None = None,
*,
_ispytest: bool = False,
) -> None:
Expand All @@ -76,6 +80,7 @@ def __init__(
self._retention_count = retention_count
self._retention_policy = retention_policy
self._basetemp = basetemp
self._rootpath = rootpath
# Register cleanups for session finish. Also called atexit as a last
# resort if sessionfinish for some reason doesn't happen.
self._exit_stack = ExitStack()
Expand Down Expand Up @@ -105,6 +110,7 @@ def from_config(
trace=config.trace.get("tmpdir"),
retention_count=count,
retention_policy=policy,
rootpath=config.rootpath,
_ispytest=True,
)

Expand Down Expand Up @@ -221,6 +227,7 @@ def getbasetemp(self) -> Path:
self._exit_stack.callback(atexit.unregister, self._exit_stack.close)
assert basetemp is not None, basetemp
self._basetemp = basetemp
_write_origin_sidecar(basetemp, self._rootpath)
self._trace("new basetemp", basetemp)
return basetemp

Expand All @@ -237,6 +244,46 @@ def get_user() -> str | None:
return None


ORIGIN_FILENAME = ".origin"


def _write_origin_sidecar(basetemp: Path, rootpath: Path | None) -> None:
"""Write an ``.origin`` file next to ``.lock`` recording who owns this
session directory.

The file is a best-effort record used by external tooling (workstation
janitors, CI cleanup, editor temp sweeps) that needs to attribute a
session directory to a specific project without walking ``/proc`` or
``lsof``. Writing it must never fail a test run, so all errors are
swallowed.

Fields are ``key=value`` lines, newline-terminated, UTF-8:

- ``rootpath``: absolute pytest rootpath, if known.
- ``version``: pytest version string.
- ``pid``: PID of the process that created this basetemp.
- ``host``: hostname of the machine that created this basetemp.
"""
try:
lines = []
if rootpath is not None:
lines.append(f"rootpath={rootpath}")
lines.append(f"version={_pytest.__version__}")
lines.append(f"pid={os.getpid()}")
try:
host = socket.gethostname()
except OSError:
host = ""
if host:
lines.append(f"host={host}")
lines.append("")
(basetemp / ORIGIN_FILENAME).write_text("\n".join(lines), encoding="utf-8")
except OSError:
# Best-effort: never break a test run because a sidecar could not
# be written (read-only mount, permissions, full disk, etc.).
pass


def pytest_configure(config: Config) -> None:
"""Create a TempPathFactory and attach it to the config object.

Expand Down
72 changes: 72 additions & 0 deletions testing/test_tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ def getini(self, name):
def option(self):
return self

@property
def rootpath(self) -> Path:
# Any real path is fine for tests using TempPathFactory; the .origin
# sidecar just records what it is given.
return Path(os.getcwd())


class TestTmpPathHandler:
def test_mktemp(self, tmp_path: Path) -> None:
Expand Down Expand Up @@ -899,3 +905,69 @@ def test_tmp_path_retention_policy_invalid(pytester: Pytester) -> None:
"'all' | 'failed' | 'none', got 'compress'"
]
)


class TestOriginSidecar:
"""The .origin file records session ownership for external tooling."""

def _parse_origin(self, path):
data = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line:
continue
key, _, value = line.partition("=")
data[key] = value
return data

def test_origin_written_next_to_basetemp(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def test_stash_basetemp(tmp_path, tmp_path_factory, pytestconfig):
base = tmp_path_factory.getbasetemp()
pytestconfig.stash.setdefault('base', base)
(pytestconfig.rootpath / '_basetemp.txt').write_text(str(base))
"""
)
result = pytester.runpytest_subprocess()
result.assert_outcomes(passed=1)
base = Path((pytester.path / "_basetemp.txt").read_text().strip())
origin = base / ".origin"
assert origin.is_file(), f"missing {origin}"
data = self._parse_origin(origin)
assert data["rootpath"] == str(pytester.path)
assert data["version"] == pytest.__version__
assert int(data["pid"]) > 0
assert data["host"]

def test_origin_written_when_basetemp_given(self, pytester: Pytester) -> None:
"""--basetemp also gets a .origin so external tooling can attribute
it consistently regardless of who chose the path."""
mytemp = pytester.path / "mybasetemp"
pytester.makepyfile("def test_ok(tmp_path): pass")
pytester.runpytest(f"--basetemp={mytemp}").assert_outcomes(passed=1)
origin = mytemp / ".origin"
assert origin.is_file()
data = self._parse_origin(origin)
assert data["rootpath"] == str(pytester.path)

def test_origin_write_failure_does_not_break_run(self, pytester: Pytester) -> None:
"""A read-only basetemp must not fail the run — the sidecar write
is best-effort and every OSError is swallowed."""
pytester.makepyfile(
"""
def test_origin_write_is_swallowed(tmp_path, tmp_path_factory):
from _pytest import tmpdir as _t
base = tmp_path_factory.getbasetemp()
origin = base / _t.ORIGIN_FILENAME
# Make .origin read-only by removing write perms on its parent
# after the fact; then invoke the writer directly and prove
# it does not raise.
import os, stat
os.chmod(base, stat.S_IRUSR | stat.S_IXUSR)
try:
_t._write_origin_sidecar(base, None) # must not raise
finally:
os.chmod(base, stat.S_IRWXU)
"""
)
pytester.runpytest_subprocess().assert_outcomes(passed=1)
Loading