From ce60cd9980a58ab79a6e8f7af87edfee42e1ad5f Mon Sep 17 00:00:00 2001 From: Jawaun Brown Date: Tue, 25 Aug 2026 19:04:28 -0400 Subject: [PATCH 1/2] tmpdir: write .origin sidecar to record session ownership Each pytest-of-/pytest-N/ session dir now contains a `.origin` file next to `.lock`, recording rootpath, pytest version, PID, and hostname. External cleanup tooling (workstation janitors, CI cleanup steps, editor temp sweeps) can attribute a session dir to its project by reading a file, instead of walking /proc or lsof. Writing is best-effort: any OSError during the write is swallowed so a read-only mount, permissions error, or full disk never breaks a test run. Refs #14935. --- changelog/14935.improvement.1.rst | 5 ++ src/_pytest/tmpdir.py | 49 ++++++++++++++++++++ testing/test_tmpdir.py | 76 +++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 changelog/14935.improvement.1.rst 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..6230b5ce738 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -11,12 +11,14 @@ from pathlib import Path import re from shutil import rmtree +import socket import stat import tempfile from typing import Any from typing import final from typing import Literal +import _pytest from .pathlib import cleanup_dead_symlinks from .pathlib import LOCK_TIMEOUT from .pathlib import make_numbered_dir @@ -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,48 @@ 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..f60d003111f 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,73 @@ 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) From 8ee0568ed48c118e9f6c139b7ff1f47694b8570d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:08:41 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/tmpdir.py | 6 ++---- testing/test_tmpdir.py | 8 ++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 6230b5ce738..9caa0f336c9 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -18,12 +18,12 @@ from typing import final from typing import Literal -import _pytest from .pathlib import cleanup_dead_symlinks from .pathlib import LOCK_TIMEOUT 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 @@ -277,9 +277,7 @@ def _write_origin_sidecar(basetemp: Path, rootpath: Path | None) -> None: if host: lines.append(f"host={host}") lines.append("") - (basetemp / ORIGIN_FILENAME).write_text( - "\n".join(lines), encoding="utf-8" - ) + (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.). diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index f60d003111f..2c5ca4eff9c 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -939,9 +939,7 @@ def test_stash_basetemp(tmp_path, tmp_path_factory, pytestconfig): assert int(data["pid"]) > 0 assert data["host"] - def test_origin_written_when_basetemp_given( - self, pytester: Pytester - ) -> None: + 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" @@ -952,9 +950,7 @@ def test_origin_written_when_basetemp_given( data = self._parse_origin(origin) assert data["rootpath"] == str(pytester.path) - def test_origin_write_failure_does_not_break_run( - self, pytester: Pytester - ) -> None: + 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(