From ce60cd9980a58ab79a6e8f7af87edfee42e1ad5f Mon Sep 17 00:00:00 2001 From: Jawaun Brown Date: Tue, 25 Aug 2026 19:04:28 -0400 Subject: [PATCH 1/3] 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 9fe76a68c8b54f1ff50c9093a8916e9fc52b860b Mon Sep 17 00:00:00 2001 From: Jawaun Brown Date: Tue, 25 Aug 2026 19:12:29 -0400 Subject: [PATCH 2/3] tmpdir: add tmp_path_layout ini for per-rootdir retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new ini option `tmp_path_layout` with values `"flat"` (the current default) and `"per-rootdir"`. Under `"per-rootdir"`, numbered directories are nested one level deeper under a stable token derived from the pytest `rootpath` (`-<8-char-hash>`), so `tmp_path_retention_count` applies per project instead of across every rootdir that shares a user account. Under `"flat"` layout, retention_count=3 with concurrent work in three git worktrees means each worktree effectively gets one slot, and any fourth run in one worktree evicts the oldest run of another. The per-rootdir layout gives each rootdir its own numbered sequence, so retention_count=3 means "3 most recent runs of this project" — which is what almost every user assumes it already means. Default is `"flat"` for one release for backwards compatibility. A follow-up will flip the default after user feedback. The slug is portable (`[A-Za-z0-9._-]`); the 8-char blake2b hash of the absolute rootpath disambiguates homonyms like `~/work/foo` vs `~/play/foo`. Refs #14935. Depends on #14936 (adds the `_rootpath` plumbing on `TempPathFactory` this PR needs). Rebase order: land the origin sidecar PR first. --- changelog/14935.feature.rst | 11 +++ src/_pytest/tmpdir.py | 55 +++++++++++++ testing/test_tmpdir.py | 150 ++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 changelog/14935.feature.rst diff --git a/changelog/14935.feature.rst b/changelog/14935.feature.rst new file mode 100644 index 00000000000..014303223bd --- /dev/null +++ b/changelog/14935.feature.rst @@ -0,0 +1,11 @@ +Added a new ``tmp_path_layout`` ini option controlling the directory +layout under ``pytest-of-/``. + +* ``"flat"`` (default): unchanged historical behaviour. +* ``"per-rootdir"``: numbered dirs are nested one level deeper, under a + stable token derived from the pytest ``rootpath`` + (``-<8-char-hash>``). ``tmp_path_retention_count`` then applies + per project, so runs in one checkout no longer evict scratch created + by runs in another. Particularly useful for users who work in multiple + git worktrees, multiple clones of the same repository, or several + unrelated projects under one user account. diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 6230b5ce738..1d66756ddae 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -7,6 +7,7 @@ from collections.abc import Generator from contextlib import ExitStack import dataclasses +import hashlib import os from pathlib import Path import re @@ -40,6 +41,7 @@ tmppath_result_key = StashKey[dict[str, bool]]() RetentionType = Literal["all", "failed", "none"] +LayoutType = Literal["flat", "per-rootdir"] @final @@ -56,6 +58,7 @@ class TempPathFactory: _retention_count: int _retention_policy: RetentionType _rootpath: Path | None + _layout: LayoutType def __init__( self, @@ -65,6 +68,7 @@ def __init__( trace, basetemp: Path | None = None, rootpath: Path | None = None, + layout: LayoutType = "flat", *, _ispytest: bool = False, ) -> None: @@ -81,6 +85,7 @@ def __init__( self._retention_policy = retention_policy self._basetemp = basetemp self._rootpath = rootpath + self._layout = layout # Register cleanups for session finish. Also called atexit as a last # resort if sessionfinish for some reason doesn't happen. self._exit_stack = ExitStack() @@ -104,6 +109,7 @@ def from_config( ) policy: RetentionType = config.getini("tmp_path_retention_policy") + layout: LayoutType = config.getini("tmp_path_layout") return cls( given_basetemp=config.option.basetemp, @@ -111,6 +117,7 @@ def from_config( retention_count=count, retention_policy=policy, rootpath=config.rootpath, + layout=layout, _ispytest=True, ) @@ -209,6 +216,14 @@ def getbasetemp(self) -> Path: rootdir_stat.st_mode & ~0o077, follow_symlinks=chmod_follow_symlinks, ) + # Per-rootdir layout: nest the numbered dirs one level deeper + # under a stable token derived from the rootpath. Retention then + # applies per project instead of across every rootdir a user has + # ever run pytest against. Opt-in via ``tmp_path_layout``. + if self._layout == "per-rootdir" and self._rootpath is not None: + token = _rootdir_token(self._rootpath) + rootdir = rootdir / token + rootdir.mkdir(mode=0o700, exist_ok=True) keep = self._retention_count if self._retention_policy == "none": keep = 0 @@ -247,6 +262,31 @@ def get_user() -> str | None: ORIGIN_FILENAME = ".origin" +_ROOTDIR_SLUG_MAX = 32 +_ROOTDIR_HASH_LEN = 8 + + +def _rootdir_slug(rootpath: Path) -> str: + """Return a portable, filesystem-safe slug for a rootpath's name.""" + name = rootpath.name or "root" + slug = "".join(c if c.isalnum() or c in "-_." else "-" for c in name) + slug = slug.strip("-.") or "root" + return slug[:_ROOTDIR_SLUG_MAX] + + +def _rootdir_token(rootpath: Path) -> str: + """Return a stable ``-`` token for a rootpath. + + The slug keeps the directory name human-readable in ``/tmp`` listings; the + hash disambiguates homonyms (``~/work/foo`` vs ``~/play/foo``) so two + projects with the same basename get distinct subdirectories. + """ + digest = hashlib.blake2b( + str(rootpath).encode("utf-8"), digest_size=_ROOTDIR_HASH_LEN // 2 + ).hexdigest() + return f"{_rootdir_slug(rootpath)}-{digest}" + + def _write_origin_sidecar(basetemp: Path, rootpath: Path | None) -> None: """Write an ``.origin`` file next to ``.lock`` recording who owns this session directory. @@ -315,6 +355,21 @@ def pytest_addoption(parser: Parser) -> None: default="all", ) + parser.addini( + "tmp_path_layout", + help=( + "Layout for numbered dirs under ``pytest-of-/``. " + "'flat' (default) keeps the historical shared pool, where " + "``tmp_path_retention_count`` applies across every project that " + "shares a user account. 'per-rootdir' nests numbered dirs under " + "a stable token derived from ``rootpath``, so retention becomes " + "scoped per project and runs in one checkout do not evict scratch " + "from another." + ), + type=LayoutType, + default="flat", + ) + @fixture(scope="session") def tmp_path_factory(request: FixtureRequest) -> TempPathFactory: diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index f60d003111f..1bda65f53ca 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -50,6 +50,8 @@ def getini(self, name): return 3 elif name == "tmp_path_retention_policy": return "all" + elif name == "tmp_path_layout": + return "flat" else: assert False @@ -975,3 +977,151 @@ def test_origin_write_is_swallowed(tmp_path, tmp_path_factory): """ ) pytester.runpytest_subprocess().assert_outcomes(passed=1) + + +class TestPerRootdirLayout: + """`tmp_path_layout = per-rootdir` scopes retention per project.""" + + def test_flat_is_default(self, tmp_path: Path, monkeypatch) -> None: + """No config change: numbered dirs sit directly under pytest-of-.""" + temproot = tmp_path / "temproot" + temproot.mkdir() + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(temproot)) + + fac = TempPathFactory( + given_basetemp=None, + retention_count=3, + retention_policy="all", + trace=lambda *a, **k: None, + rootpath=tmp_path, + _ispytest=True, + ) + base = fac.getbasetemp() + try: + assert base.parent.name.startswith("pytest-of-") + finally: + fac._exit_stack.close() + + def test_per_rootdir_nests_under_token( + self, tmp_path: Path, monkeypatch + ) -> None: + temproot = tmp_path / "temproot" + temproot.mkdir() + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(temproot)) + + from _pytest.tmpdir import _rootdir_token + + fac = TempPathFactory( + given_basetemp=None, + retention_count=3, + retention_policy="all", + trace=lambda *a, **k: None, + rootpath=tmp_path, + layout="per-rootdir", + _ispytest=True, + ) + base = fac.getbasetemp() + try: + expected = _rootdir_token(tmp_path) + assert base.parent.name == expected + assert base.parent.parent.name.startswith("pytest-of-") + finally: + fac._exit_stack.close() + + def test_per_rootdir_retention_is_scoped_per_project( + self, pytester: Pytester, tmp_path: Path, monkeypatch + ) -> None: + """Runs from project A do not evict project B's numbered dirs.""" + # Shared temproot both projects will write into. + temproot = tmp_path / "shared-temproot" + temproot.mkdir() + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(temproot)) + + from _pytest.tmpdir import _rootdir_token, TempPathFactory + + def _factory(rootpath: Path) -> TempPathFactory: + return TempPathFactory( + given_basetemp=None, + retention_count=1, + retention_policy="all", + trace=lambda *a, **k: None, + rootpath=rootpath, + layout="per-rootdir", + _ispytest=True, + ) + + proj_a = tmp_path / "proj-a" + proj_a.mkdir() + proj_b = tmp_path / "proj-b" + proj_b.mkdir() + + def _run_session(rootpath: Path) -> None: + # Emulate a full pytest session: build factory, materialize + # basetemp (registers cleanup), then close so the lock is + # released and retention cleanup fires. + fac = _factory(rootpath) + fac.getbasetemp() + fac._exit_stack.close() + + # Run three sessions in proj_a; retention_count=1 keeps only the + # newest one WITHIN proj_a's subtree. + for _ in range(3): + _run_session(proj_a) + # Run three sessions in proj_b — under flat layout these would + # rotate proj_a's dirs out. Under per-rootdir they must not. + for _ in range(3): + _run_session(proj_b) + + user_root = next(temproot.glob("pytest-of-*")) + a_dir = user_root / _rootdir_token(proj_a) + b_dir = user_root / _rootdir_token(proj_b) + + # Both projects still have their own numbered subtree; proj_a's + # scratch was NOT wiped by proj_b's runs. + assert a_dir.is_dir() + assert b_dir.is_dir() + a_runs = [p for p in a_dir.iterdir() if p.is_dir() and not p.is_symlink()] + b_runs = [p for p in b_dir.iterdir() if p.is_dir() and not p.is_symlink()] + # retention_count=1 → exactly one numbered dir per project. + assert len(a_runs) == 1, a_runs + assert len(b_runs) == 1, b_runs + + def test_rootdir_token_disambiguates_homonyms(self, tmp_path: Path) -> None: + """Same directory basename, different absolute paths → different tokens.""" + from _pytest.tmpdir import _rootdir_token + + a = tmp_path / "work" / "foo" + b = tmp_path / "play" / "foo" + a.mkdir(parents=True) + b.mkdir(parents=True) + assert _rootdir_token(a) != _rootdir_token(b) + # Slug portion is identical... + assert _rootdir_token(a).startswith("foo-") + assert _rootdir_token(b).startswith("foo-") + + def test_rootdir_token_sanitises_unsafe_names(self, tmp_path: Path) -> None: + from _pytest.tmpdir import _rootdir_token + + weird = tmp_path / "a b/c$d*e" + weird.mkdir(parents=True) + token = _rootdir_token(weird) + # No shell metacharacters left in the slug portion; only [A-Za-z0-9._-]. + slug = token.rsplit("-", 1)[0] + assert all(c.isalnum() or c in "._-" for c in slug), token + + def test_tmp_path_layout_invalid(self, pytester: Pytester) -> None: + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + tmp_path_layout = "hierarchical" + """ + ) + pytester.makepyfile("def test(): pass") + result = pytester.runpytest() + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + [ + "*ERROR: *config option 'tmp_path_layout' expects one of " + "'flat' | 'per-rootdir', got 'hierarchical'" + ] + ) From 678d561009647458be1e3b700795cb9b16e92bbc 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:13:29 +0000 Subject: [PATCH 3/3] [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 | 15 +++++---------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 1d66756ddae..d1682ba76c0 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -19,12 +19,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 @@ -317,9 +317,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 1bda65f53ca..3a8fd04bce5 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -941,9 +941,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" @@ -954,9 +952,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( @@ -1002,9 +998,7 @@ def test_flat_is_default(self, tmp_path: Path, monkeypatch) -> None: finally: fac._exit_stack.close() - def test_per_rootdir_nests_under_token( - self, tmp_path: Path, monkeypatch - ) -> None: + def test_per_rootdir_nests_under_token(self, tmp_path: Path, monkeypatch) -> None: temproot = tmp_path / "temproot" temproot.mkdir() monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(temproot)) @@ -1037,7 +1031,8 @@ def test_per_rootdir_retention_is_scoped_per_project( temproot.mkdir() monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(temproot)) - from _pytest.tmpdir import _rootdir_token, TempPathFactory + from _pytest.tmpdir import _rootdir_token + from _pytest.tmpdir import TempPathFactory def _factory(rootpath: Path) -> TempPathFactory: return TempPathFactory(