Have you ever watched pytest go green, then found leftover JSON sitting in a home directory you do not own? I spent forty-eight hours writing field notes about that exact mess, and I still wince at the timeline. Locally, Path.home() pointed at my laptop user, so I treated it like a private cache for HTTP fixtures. On a clean remote run, that same call resolved to a shared scratch directory that other jobs could see.
Was the HTTP client flaky? Was pytest leaking state between tests? I wanted those answers to be true, because they sounded like normal software bugs. They were not. The suite was honest. My cache location was not.
Hour 0: why did home feel like a safe drawer?
I was recording paginated JSON from a stubborn orders API so later tests would not wait on the network. The helper looked boring, which is how this class of bug usually arrives. Have you noticed how the boring helpers are the ones nobody reviews twice?
from pathlib import Path
import json
CACHE_DIR = Path.home() / ".cache" / "orderspec"
def dump_fixture(name: str, payload: dict) -> Path:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = CACHE_DIR / f"{name}.json"
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return path
def load_fixture(name: str) -> dict:
path = CACHE_DIR / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
On my laptop that path expanded to /home/taylor/.cache/orderspec, which felt personal, writable, and boring. I never asked what Path.home() would do inside someone else's process. Why would I? The tests passed, and the directory belonged to me.
Hours 1–8: I blamed the client, then the fixtures, then pytest
The first failures were not red tests. They were extra files. After a “clean” run I found orders_page_2.json with timestamps I had not produced on this machine. Who writes into home during pytest, if not my helper?
I spent the morning chasing HTTP replay bugs that were not there. I added logging around status codes, then hashed payloads, then forced requests.Session to close. The files still appeared, and the contents still looked like mine from yesterday.
Here is the command trail I actually ran, including the useless parts I would rather hide:
python -c "from pathlib import Path; print(Path.home())"
echo "HOME=$HOME"
ls -la "$HOME/.cache/orderspec"
pytest -q tests/test_orders_replay.py
find "$HOME/.cache/orderspec" -type f -printf '%T+ %p\n' | sort
Locally, home stayed mine. That made me overconfident. If Path.home() is stable on one box, why would it not be stable on the next box? Because home is an environment contract, not a property of your repository.
Hours 8–24: the clean server was not a second laptop
I needed a machine that did not already contain my cache directory, so I reproduced the suite away from the laptop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access and free server option as a throwaway environment, not as a mysterious oracle that would invent a root cause for me.
The free server answered Path.home() with a path that looked like a user directory and behaved like scratch space. Could other jobs on that box see $HOME/.cache? In my run, yes, because nothing in pytest reset HOME, USERPROFILE, or XDG_CACHE_HOME.
python - <<'PY'
from pathlib import Path
import os, getpass
print("user", getpass.getuser())
print("HOME", os.environ.get("HOME"))
print("USERPROFILE", os.environ.get("USERPROFILE"))
print("XDG_CACHE_HOME", os.environ.get("XDG_CACHE_HOME"))
print("Path.home", Path.home())
PY
I asked the free model why two green suites could still share files, and it kept rewriting the JSON helper. That was the wrong layer. The helper was fine. The process identity was not the same as my laptop identity, even when the code was identical.
Do you pin HOME in your test harness today, or do you only pin Python versions? I had pinned the interpreter, the dependencies, and even the timezone after last month's datetime mess. I had not pinned the idea of “home.”
Hours 24–36: what broke when I “fixed” the obvious thing
My first patch was CACHE_DIR = Path(__file__).resolve().parent / ".cache". That stopped writing into home, then broke collection when tests imported the helper from a different working directory. Relative cache directories follow the importer, not the test, and I already knew cwd lies.
The second patch used /tmp/orderspec, which collided the moment two pytest workers ran. Have you ever watched xdist “fix” a flake by interleaving two writers on the same filename? I have now.
The third patch monkeypatched Path.home in one test module and forgot the others. Helpers imported at collection time still saw the real home. Pytest collection is a process, not a suggestion, and module-level constants freeze early.
# This does not save you if CACHE_DIR was already computed.
@pytest.fixture(autouse=True)
def fake_home(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
Why is that fixture too late? Because CACHE_DIR = Path.home() / ".cache" / "orderspec" ran at import, before the fixture, and kept the original path object. I was patching a door after the process had already walked through it.
The mechanism I should have written down at hour one
Path.home() is a thin wrapper around the process environment. On Unix it mostly honors HOME. On Windows it mostly honors USERPROFILE. Cache libraries then pile XDG_CACHE_HOME or ~/.cache on top of that answer.
Pytest gives you tmp_path, tmp_path_factory, and monkeypatch, but it will not protect a real home directory unless you ask. A coding agent on a free server will also not assume your laptop user exists. If the server account is shared, ephemeral, or reused, ~/.cache is a public whiteboard.
I now treat these as different places, not as synonyms:
- Real home: user config you would keep after the test process dies.
- XDG cache: still user-global unless you override the XDG variables.
-
pytest
tmp_path: per-test, disposable, and the only default I trust in unit tests. - Worker-aware temp: needed if you run pytest-xdist and write a shared name.
If your library caches at import time, none of the later fixtures matter. Do you compute paths at import, or inside a function that can see tests?
Artifact: a guard I wish I had committed first
This is a reproducible check, not a production metric. Run it against any helper that might touch home. If the test fails, your code is writing outside the sandbox, even when the assertions about JSON look perfect.
# tests/test_home_isolation.py
from pathlib import Path
import os
import stat
import json
import pytest
# Import the helper *after* env is faked. Do not cache Path.home() at import.
@pytest.fixture
def sandboxed_home(tmp_path, monkeypatch):
home = tmp_path / "home"
cache = tmp_path / "xdg-cache"
config = tmp_path / "xdg-config"
home.mkdir()
cache.mkdir()
config.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setenv("XDG_CACHE_HOME", str(cache))
monkeypatch.setenv("XDG_CONFIG_HOME", str(config))
# Import late so module-level paths see the fake env.
import importlib
import orderspec.cache as cachemod
importlib.reload(cachemod)
return home, cache, cachemod
def snapshot_tree(root: Path) -> dict[str, tuple[int, int]]:
found = {}
for path in root.rglob("*"):
if path.is_file():
st = path.stat()
found[str(path.relative_to(root))] = (st.st_size, st.st_mtime_ns)
return found
def test_fixture_dump_stays_inside_xdg(sandboxed_home, tmp_path):
real_home = Path.home() # this is already faked
home, cache, cachemod = sandboxed_home
before_home = snapshot_tree(home)
cachemod.dump_fixture("orders_page_2", {"ok": True, "n": 2})
after_home = snapshot_tree(home)
written = list(cache.rglob("*.json"))
assert written, "expected a cache file under XDG_CACHE_HOME"
assert json.loads(written[0].read_text(encoding="utf-8"))["n"] == 2
assert after_home == before_home, after_home
# Also refuse writes to the true login home if the env override failed.
login_home = Path(os.path.expanduser("~"))
assert home == login_home
Reload is ugly, and that ugliness is the point. If you need importlib.reload to make tests safe, your paths are bound too early. Move the Path construction into a function:
import os
from pathlib import Path
def cache_dir() -> Path:
xdg = os.environ.get("XDG_CACHE_HOME")
root = Path(xdg) if xdg else Path.home() / ".cache"
return root / "orderspec"
Then the autouse fixture can win. Would I still snapshot the real login home in CI? Yes, because environment overrides fail silently when a parent process exports HOME after Python started, which is rare, and when a library reads /etc/passwd instead of HOME, which is less rare than I wanted.
Decision table I now keep in the repo
| Data you are writing | Put it here in tests | Do not put it here | Why |
|---|---|---|---|
| HTTP fixtures for one test | tmp_path / "fixtures" |
Path.home() / ".cache" |
Home outlives the test and may be shared |
| Cache reused inside one process | function-local directory from tmp_path_factory
|
/tmp/fixed-name |
Fixed names collide under xdist |
| User config the app must read | monkeypatch.setenv("XDG_CONFIG_HOME", ...) |
committed ~/.config files |
Real profiles are not fixtures |
| Golden files you review in git |
tests/goldens/ in the repo |
any home path | Reviewers cannot see home |
| Secrets | never disk, or tmp_path with 0o600
|
Path.home() on a free server |
Shared home is not private |
I run this as a checklist before I let an agent “just make the tests pass.” Agents are eager to cache. Are you checking where they cached, or only that the assertion passed?
What I would repeat, and what I would not
I would still use a second machine when the laptop is contaminated with yesterday’s cache. A free remote server is useful exactly because it does not love you. I would still ask a free model to list environment variables that affect Path.home(), then verify each one with a one-liner, not with a rewrite of working JSON code.
I would not let the model patch production loaders until the isolation test above is red or green. I would not compute cache roots at import time. I would not treat “the suite is green” as “the process was contained.”
Commands I now keep at the top of the field notebook:
env | grep -E '^(HOME|USERPROFILE|XDG_|PYTHON)'
pytest -q tests/test_home_isolation.py -vv
python -c "from pathlib import Path; print(Path.home())"
If any of those disagree between laptop and server, I stop adding features. Disagreement about home is a containment bug, not a product bug.
Limitations, and who should skip this
This approach is for test suites and agent-driven reproduction boxes. It is the wrong hammer for installers that must write real user config, or for manual QA against a developer’s actual profile. If you monkeypatch HOME around an integration test that launches a browser, you can hide the only files you meant to inspect.
importlib.reload will not save extension modules with native state. Snapshotting trees will not catch writes through a different user id. Path.home() on Windows still has extra corners (HOMEDRIVE, HOMEPATH) that my Unix-first fixture only approximates with USERPROFILE.
If your free server gives you a disposable home that nobody else can read, you may never see this failure. That does not mean the code is safe on the next runner. Absence of a roommate is not isolation.
Closing field note
Forty-eight hours later the HTTP client was innocent, pytest was innocent, and the JSON helper was almost innocent. The guilty line was a module-level Path.home() that turned a laptop drawer into a shared whiteboard. Would I cache fixtures again? Yes, inside tmp_path, with XDG overrides, and with a test that fails when home changes.
If your green suite still creates files after the process exits, start by printing Path.home() on every machine you trust. The second print is the one that usually hurts.
Top comments (0)