diff --git a/changelog/14935.improvement.1.rst b/changelog/14935.improvement.1.rst new file mode 100644 index 00000000000..65be15e715c --- /dev/null +++ b/changelog/14935.improvement.1.rst @@ -0,0 +1,5 @@ +Each pytest session directory (``pytest-of-/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. diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 745a3c95670..9caa0f336c9 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -11,6 +11,7 @@ from pathlib import Path import re from shutil import rmtree +import socket import stat import tempfile from typing import Any @@ -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 @@ -53,6 +55,7 @@ class TempPathFactory: _basetemp: Path | None _retention_count: int _retention_policy: RetentionType + _rootpath: Path | None def __init__( self, @@ -61,6 +64,7 @@ def __init__( retention_policy: RetentionType, trace, basetemp: Path | None = None, + rootpath: Path | None = None, *, _ispytest: bool = False, ) -> None: @@ -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() @@ -105,6 +110,7 @@ def from_config( trace=config.trace.get("tmpdir"), retention_count=count, retention_policy=policy, + rootpath=config.rootpath, _ispytest=True, ) @@ -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 @@ -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. diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 0b33a74b926..2c5ca4eff9c 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -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: @@ -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)