From 365edf65fbafaf37c8aee4da5137d85438d70ac8 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Wed, 22 Apr 2026 17:06:51 -0600 Subject: [PATCH] conftest: fallback TMPDIR=/var/tmp for non-make pytest runs make test exports TMPDIR=/var/tmp at the shell, but direct invocations (.venv/bin/pytest, python -m pytest, uv run pytest) bypass the Makefile and leak test dirs into /tmp. Add a module-level prelude in the root conftest that sets TMPDIR and tempfile.tempdir to /var/tmp when TMPDIR is unset, with a one-time stderr notice pointing at `make test` and a visible degradation path when the target is not writable. Co-Authored-By: Claude Opus 4.7 (1M context) --- conftest.py | 51 ++++++++- tests/test_tmpdir_fallback.py | 193 ++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 tests/test_tmpdir_fallback.py diff --git a/conftest.py b/conftest.py index dfab6e8ef..85a3cc15a 100644 --- a/conftest.py +++ b/conftest.py @@ -9,10 +9,55 @@ leave the fixture tree dirty. from __future__ import annotations +import os import subprocess import sys +import tempfile from pathlib import Path +_TMPDIR_FALLBACK_NOTICE: str | None = None + + +def _apply_tmpdir_fallback() -> None: + """Route tmp dirs to /var/tmp when TMPDIR is not exported. + + make test sets TMPDIR=/var/tmp at the shell level (see Makefile). Direct + pytest invocations (.venv/bin/pytest, python -m pytest, uv run pytest) + bypass that. This prelude closes the gap by setting TMPDIR and + tempfile.tempdir at module-import time, before pytest builds its + tmp_path_factory. + """ + global _TMPDIR_FALLBACK_NOTICE + + if "TMPDIR" in os.environ: + return + + # Test-only override so unwritable-target branch can be exercised + # without chmod 000 /var/tmp. + target = os.environ.get("_SOLSTONE_TMPDIR_FALLBACK_TARGET", "/var/tmp") + + if not (os.path.isdir(target) and os.access(target, os.W_OK)): + if os.environ.get("_SOLSTONE_TMPDIR_FALLBACK_NOTIFIED") != "1": + _TMPDIR_FALLBACK_NOTICE = ( + f"solstone: pytest invoked without TMPDIR export and fallback " + f"target {target} is not writable; leaving TMPDIR unset.\n" + ) + os.environ["_SOLSTONE_TMPDIR_FALLBACK_NOTIFIED"] = "1" + return + + os.environ["TMPDIR"] = target + tempfile.tempdir = target + + if os.environ.get("_SOLSTONE_TMPDIR_FALLBACK_NOTIFIED") != "1": + _TMPDIR_FALLBACK_NOTICE = ( + f"solstone: pytest invoked without TMPDIR export; routing tmp dirs " + f"to {target}. Prefer 'make test' to set TMPDIR at the shell level.\n" + ) + os.environ["_SOLSTONE_TMPDIR_FALLBACK_NOTIFIED"] = "1" + + +_apply_tmpdir_fallback() + _FIXTURE_ROOT = "tests/fixtures" _BASELINE: set[tuple[str, str]] | None = None _GIT_AVAILABLE = True @@ -66,7 +111,11 @@ def _format_leak_message(new_entries: set[tuple[str, str]]) -> str: def pytest_sessionstart(session): - global _BASELINE, _GIT_AVAILABLE + global _BASELINE, _GIT_AVAILABLE, _TMPDIR_FALLBACK_NOTICE + + if _TMPDIR_FALLBACK_NOTICE is not None: + sys.stderr.write(_TMPDIR_FALLBACK_NOTICE) + _TMPDIR_FALLBACK_NOTICE = None repo_root = session.config.rootpath _BASELINE = _capture_status(repo_root) diff --git a/tests/test_tmpdir_fallback.py b/tests/test_tmpdir_fallback.py new file mode 100644 index 000000000..2e7eec805 --- /dev/null +++ b/tests/test_tmpdir_fallback.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc +"""Canary tests for the root conftest TMPDIR fallback prelude.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytest_plugins = ["pytester"] + +_ROOT_CONFTEST = Path(__file__).resolve().parent.parent / "conftest.py" +_NOTICE = ( + "solstone: pytest invoked without TMPDIR export; routing tmp dirs to " + "/var/tmp. Prefer 'make test' to set TMPDIR at the shell level.\n" +) + + +def _install_root_conftest(pytester: pytest.Pytester) -> None: + pytester.makepyfile(conftest=_ROOT_CONFTEST.read_text(encoding="utf-8")) + + +def _run_nested( + pytester: pytest.Pytester, + env_overrides: dict[str, str | None] | None = None, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.pop("TMPDIR", None) + env.pop("_SOLSTONE_TMPDIR_FALLBACK_NOTIFIED", None) + env.pop("_SOLSTONE_TMPDIR_FALLBACK_TARGET", None) + if env_overrides: + for key, value in env_overrides.items(): + if value is None: + env.pop(key, None) + else: + env[key] = value + return subprocess.run( + [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"], + cwd=pytester.path, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def test_prelude_redirects_to_var_tmp(pytester: pytest.Pytester) -> None: + _install_root_conftest(pytester) + pytester.makepyfile( + test_tmpdir=""" + import os + import tempfile + + def test_redirects_to_var_tmp(): + assert tempfile.gettempdir() == "/var/tmp" + assert os.environ["TMPDIR"] == "/var/tmp" + """ + ) + + result = _run_nested(pytester) + + assert result.returncode == 0, result.stderr + result.stdout + assert "1 passed" in result.stdout + + +def test_notice_present_when_tmpdir_unset(pytester: pytest.Pytester) -> None: + _install_root_conftest(pytester) + pytester.makepyfile( + test_notice=""" + def test_noop(): + assert True + """ + ) + + result = _run_nested(pytester) + combined = result.stderr + result.stdout + + assert result.returncode == 0, combined + assert _NOTICE in combined + + +def test_notice_absent_when_tmpdir_already_set(pytester: pytest.Pytester) -> None: + _install_root_conftest(pytester) + pytester.makepyfile( + test_notice=""" + import os + import tempfile + + def test_uses_existing_tmpdir(): + assert tempfile.gettempdir() == "/tmp" + assert os.environ["TMPDIR"] == "/tmp" + """ + ) + + result = _run_nested(pytester, {"TMPDIR": "/tmp"}) + combined = result.stderr + result.stdout + + assert result.returncode == 0, combined + assert "solstone: pytest invoked without TMPDIR" not in combined + + +def test_notice_single_fire_across_workers(pytester: pytest.Pytester) -> None: + _install_root_conftest(pytester) + pytester.makepyfile( + test_notice=""" + def test_noop(): + assert True + """ + ) + + first = _run_nested(pytester) + second = _run_nested(pytester, {"_SOLSTONE_TMPDIR_FALLBACK_NOTIFIED": "1"}) + combined = first.stderr + first.stdout + second.stderr + second.stdout + + assert first.returncode == 0, combined + assert second.returncode == 0, combined + assert combined.count(_NOTICE) == 1 + + +def test_unwritable_target_degrades_visibly( + pytester: pytest.Pytester, tmp_path: Path +) -> None: + _install_root_conftest(pytester) + pytester.makepyfile( + test_notice=""" + import os + + def test_tmpdir_stays_unset(): + assert os.environ.get("TMPDIR") is None + """ + ) + blocked = tmp_path / "blocked" + blocked.mkdir() + os.chmod(blocked, 0) + + try: + result = _run_nested( + pytester, {"_SOLSTONE_TMPDIR_FALLBACK_TARGET": str(blocked)} + ) + finally: + os.chmod(blocked, 0o700) + + combined = result.stderr + result.stdout + notice = ( + "solstone: pytest invoked without TMPDIR export and fallback target " + f"{blocked} is not writable; leaving TMPDIR unset.\n" + ) + + assert result.returncode == 0, combined + assert notice in combined + assert "routing tmp dirs to" not in combined + + +def test_subprocess_pytest_lands_in_var_tmp(pytester: pytest.Pytester) -> None: + _install_root_conftest(pytester) + test_path = pytester.makepyfile( + test_subprocess=""" + import os + import tempfile + + def test_redirects_in_fresh_subprocess(): + assert tempfile.gettempdir() == "/var/tmp" + assert os.environ["TMPDIR"] == "/var/tmp" + """ + ) + env = os.environ.copy() + env.pop("TMPDIR", None) + env.pop("_SOLSTONE_TMPDIR_FALLBACK_NOTIFIED", None) + env.pop("_SOLSTONE_TMPDIR_FALLBACK_TARGET", None) + + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + str(test_path), + ], + cwd=pytester.path, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert "1 passed" in result.stdout -- 2.51.2