Skip to content

PoC: cache policy and cache scopes (#1089) — AI-assisted, for hole-poking only - #14835

Draft
RonnyPfannschmidt wants to merge 12 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:cache-policy
Draft

PoC: cache policy and cache scopes (#1089) — AI-assisted, for hole-poking only#14835
RonnyPfannschmidt wants to merge 12 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:cache-policy

Conversation

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

Warning

This is a deliberately AI-assisted proof of concept, and it is not ready for deeper review.

I drove this with Claude Code over a long session — steering the design, rejecting several of its
proposals, and correcting it where it was wrong — but I have not yet given the diff the line-by-line
read I would want before asking anyone to spend real review time on it. Every commit carries a
Co-authored-by trailer.

What I actually want from this PR right now is hole-poking, for fun. Push on the design, the
naming, the on-disk layout, the edge cases. Please do not treat this as a review request and please
do not feel obliged to do a careful pass — if the shape is wrong I would rather find out now and throw
it away than have someone audit code that should not exist.

Closes #1089.


The idea

#1089 has been open since 2015. It never stalled on where to put the cache — pointing cache_dir at
~/.cache has always worked. It stalled on lifetime, per my own comment on the issue:

if the cache folder is outside of the working directory, its lifetime is unrelated to the working
directory […] imagine a unaware ci system that makes one folder per build could easily DOS the ci
server due to filling up ~/.cache of the ci user

So this PR is mostly not about the location. It is about two named concepts, and the location falls
out of them.

Cache scopes — how far a cached value travels

The thing that actually blocks a shared cache is that some cached values are not portable. --lf,
--nf and --sw state depends on what the interpreter in use actually collects. Today the only way to
express that is to move the whole cache directory — which is exactly what the TOX_ENV_DIR special case
in cacheprovider.py does:

cache_dir_default = ".pytest_cache"
if "TOX_ENV_DIR" in os.environ:
    cache_dir_default = os.path.join(os.environ["TOX_ENV_DIR"], cache_dir_default)

That multiplies entire cache trees, keyed off an environment variable belonging to one specific tool,
and it only ever helped tox — not nox, not a plain second virtualenv.

CacheScope names the property directly instead:

Scope Valid for
SHARED (default) the project, whatever runs it
PYTHON one implementation + major.minor
ENV one sys.prefix
config.cache.set("myplugin/collected", ids, scope=pytest.CacheScope.ENV)

Interpreter matching now happens inside one cache folder rather than by having many folders. A tox
run with six envs adds six small scopes instead of six full trees. SHARED stays at the existing v/
and d/ paths, so existing caches and third-party plugins need no migration; only pytest's own three
keys move, costing one benign invalidation.

Cache policy — where the directory lives

cache_policy = local | user. local is unchanged. user puts it in the platform user cache dir,
keyed by project, so nothing is written into the project at all. PYTEST_CACHE_POLICY sets it
machine-wide. cache_dir still wins over both and remains the escape hatch for anything the policies do
not name.

Because interpreter identity moved into scopes, the project key is the rootdir alone — so one project
gets one cache directory however many environments run it, and ephemeral environments (uv run --with …)
stop minting a tree per invocation.

The lifetime machinery, which is the actual point

Every cache directory gets a cache-info.json recording where it came from and which scopes it holds.
That makes the accumulation visible and collectable:

$ pytest --cache-list
user cache directory: /home/ronny/.cache/pytest

  DIRECTORY               SIZE      LAST USED  STATUS    ORIGIN
  myproject-1a2b3c4d5e6f  12.4 MiB  2 days     ok        /home/ronny/src/myproject
    env-venv-9f8e7d6c      1.1 MiB  2 days     ok        /home/ronny/src/myproject/.venv
    env-py312-0a1b2c3d   840.1 KiB  94 days    stale     /home/ronny/src/myproject/.tox/py312
  oldthing-9f8e7d6c5b4a  840.0 KiB  31 days    orphaned  /home/ronny/src/oldthing

2 directories, 3 scopes, 13.2 MiB total

$ pytest --cache-prune=stale      # drops the dead venv's state, keeps the project cache

--cache-prune requires a selector (all, orphaned, stale, or a glob), so a bare invocation can
never delete anything. Nothing is ever pruned automatically — a project on an unmounted volume looks
orphaned, which is exactly why.

TOX_ENV_DIR deprecation

Now redundant, so it is deprecated with a PytestRemovedIn10Warning. Nothing needs to replace it;
anyone who wants that location can write cache_dir = $TOX_ENV_DIR/.pytest_cache, which is the same
expression the default was built from and so resolves to the same directory by construction.

The commits

Twelve, each self-contained and independently green. The first four are enablers with no user-visible
behaviour change.

terminalwriter: add OSC 8 hyperlink support no callers yet
pytester: isolate the user-level cache directory latent bug: LOCALAPPDATA is not derived from USERPROFILE, so inner runs leaked to the real user cache on Windows always, and on Linux whenever XDG_CACHE_HOME was set
pathlib: resolve the user-level cache root behind an xdg extra platformdirs as an optional dep (pytest[xdg]), imported lazily
cacheprovider: funnel cache directory resolution through one function pure refactor
cacheprovider: introduce cache scopes no behaviour change; nothing passes a non-default scope yet
cacheprovider: pin lastfailed, nodeids and stepwise to the env scope the behaviour change the design is for
cacheprovider: record cache metadata
cacheprovider: add the cache_policy option
cacheprovider: deprecate the TOX_ENV_DIR cache_dir default
cacheprovider: add --cache-list
cacheprovider: add --cache-prune
docs: document cache policy, scopes and pruning

Nice side effect: test_stepwise.py's cache_dir = .cache workaround, added because "tox's cache
directory makes tests in this module flaky"
, is no longer needed and is removed.

Where I would start poking

Ordered by how much I would like to be argued with:

  1. Default scope is SHARED. Back-compat-safe, but arguably the wrong semantic default — a stale
    cross-interpreter entry is subtly wrong, an over-pinned one merely costs a recompute. Changing it
    later relocates third-party plugin data, so it wants settling before this could ever land.
  2. cache_dir + cache_policy is a two-option surface. The rule is one sentence — cache_dir
    always wins — but two knobs for one outcome is how we got TOX_ENV_DIR in the first place.
  3. cache_dir's ini default is now "" rather than ".pytest_cache", which is what makes "unset"
    detectable. In-tree only pytest_report_header read it expecting the old value; third-party plugins
    might too.
  4. The on-disk layouts/<scope-id>/{v,d}/, scope ids like env-venv-9f8e7d6c, project dirs like
    myproject-1a2b3c4d5e6f7a8b. All of it is a guess at what reads well in ls.
  5. Symlink resolution in the project key. Stops one project reached via two paths getting two caches;
    churns the key for rotating-symlink deploy layouts.
  6. --cache-prune deletes without confirmation, consistent with --cache-clear, with --cache-list
    as the preview. And nothing locks the cache, so pruning concurrently with a run using it can race.
  7. Epoch floats rather than ISO-8601 in the metadata, so a hand-edited timestamp cannot raise
    mid-listing.
  8. OSC 8 hyperlinks are allow-by-default with a small deny-list. tmux usually reports
    TERM=screen-* and so loses links; PYTEST_HYPERLINKS=1 forces them.

Notes

  • Needs Validate enum-valued configuration options with Literal types #14791 (Raise UsageError from getini for invalid configuration values) — without it an invalid
    cache_policy read at configure time surfaces as an INTERNALERROR. Found that the hard way; it is
    already on main.
  • New published surface: the xdg extra. Extras are effectively permanent, and xdg is only accurate on
    Linux even though the feature covers macOS and Windows.
  • user-policy tests skip rather than fail when platformdirs is absent, so a minimal-deps job stays
    green.

RonnyPfannschmidt and others added 12 commits August 5, 2026 11:37
Adds `TerminalWriter.hyperlink()` plus `write_link()`/`line_link()`, so paths
pytest prints can be made clickable.

The escapes are applied inside the shared `_write()` helper, after the
`_current_line` bookkeeping and after `markup()`, so they stay invisible to
`width_of_current_line`. `write()`/`line()` keep their exact signatures, since a
keyword-only `link` parameter would collide with every existing `**markup`
splat. `sep()` deliberately gets no hyperlink variant, as it computes fill
counts from `len(title)`.

Detection is allow-by-default with a deny-list: terminals without OSC 8 support
almost universally ignore unknown OSC sequences rather than printing them, and
an allow-list of known-good terminals would be permanently out of date.
`should_do_markup` already excludes files, pipes and NO_COLOR.

There are no callers yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pytester redirected HOME/USERPROFILE but nothing else, which is not enough to
keep inner runs away from the developer's real user cache:

- XDG_CACHE_HOME takes precedence over HOME on Linux, so it leaks whenever it
  is set in the outer environment;
- LOCALAPPDATA is not derived from USERPROFILE, so on Windows it always leaks.

XDG_CACHE_HOME is unset rather than redirected, so inner runs exercise the same
platform-native path real users get.

Prerequisite for the cache_policy work, which makes pytest actually read these
variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `pytest_user_cache_dir()` and `check_user_cache_root()`. Nothing calls
them yet; `cache_policy = user` will.

platformdirs is an optional dependency (`pytest[xdg]`) rather than a hard one,
since the feature it serves is opt-in, and it is imported lazily so a plain
install never touches it. PYTEST_CACHE_HOME is checked first, so the escape
hatch - and most of the test suite - works without the extra. The per-platform
conventions are delegated verbatim: taking the dependency is precisely so we
stop having opinions about them.

The hardening is deliberately weaker than TempPathFactory.getbasetemp's. That
guards a directory in world-writable, shared /tmp where name-squatting is a
real attack; the user cache home is neither shared nor world-writable, and two
of those checks would actively cause harm here - rejecting a symlinked root
would break pointing ~/.cache at another volume, and forcing 0o700 would fight
pytest-devGH-12308. Only ownership and a non-sticky world-writable root are rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure refactor, no behaviour change. `Cache.for_config` resolved the directory
via `Cache.cache_dir_from_config`, which is also public API third-party plugins
call; introducing `_resolve_cache_dir` gives both a single implementation to
share, so the two cannot drift once the location grows more than one possible
answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cached values are not all equally portable: `cache/lastfailed` and
`cache/nodeids` depend on what the interpreter in use actually collects, while
most plugin data does not care. Today the only way to express that is to move
the whole cache directory, which is what the TOX_ENV_DIR special case does -
multiplying entire cache trees, keyed off an environment variable belonging to
one specific tool.

CacheScope names the property directly, so interpreter matching happens inside
the one cache directory belonging to the project rather than by having many
directories. Scoped data lives under `s/<scope-id>/{v,d}/`; the shared scope
keeps the existing flat `v/` and `d/` layout, so existing caches and
third-party plugins need no migration.

`scope` is keyword-only and defaults to SHARED, so this commit changes no
behaviour - nothing passes a non-default scope yet.

PYTHON deliberately tracks major.minor only, so an in-place patch upgrade does
not invalidate the cache, and ENV uses sys.prefix rather than sys.base_prefix
because a venv is the unit users think in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Which tests an interpreter collects is not portable, so `--lf`, `--nf` and
`--sw` state must not be shared between environments. Now that CacheScope
exists, say so directly instead of relying on the cache directory having been
moved out from under us.

Running one project under several environments no longer has each run
overwrite the previous one's last-failed set. That is what the TOX_ENV_DIR
special case was for, and it now happens for every environment rather than
only for tox.

`--cache-show` learns to walk the scope directories too, reading values
through the path rather than Cache.get so that scopes belonging to other
environments are listed as well, tagged with the scope they came from.

Existing entries are not migrated; the first run after upgrading behaves as if
the cache were empty.

test_stepwise.py's `cache_dir = .cache` workaround, added because tox's cache
directory made the module flaky, is no longer needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes `cache-info.json` at the top level of every cache directory: where the
directory came from, when it was created and last used, and which scopes live
in it.

This is the backlink that makes a cache directory self-describing. Once a cache
can live outside the project - which is the point of the next commit - its
lifetime is no longer tied to the worktree's, so listing and pruning need
something on disk that says what a directory belongs to. Recording scopes as
well means a stale environment can be collected without touching the rest of
the project's cache.

Deliberate choices:

- Top level, so `--cache-show`'s globbing never sees it and `--cache-clear`
  preserves it, exactly as for README.md and CACHEDIR.TAG (pytest-devGH-6290). Clearing a
  cache must not make it anonymous.
- Not dot-prefixed: someone browsing a cache directory far from the project it
  belongs to needs to see what it is for.
- `origin` records paths unresolved, i.e. as the user sees them, since that is
  what gets displayed.
- Epoch floats rather than ISO-8601, so a hand-edited timestamp cannot raise
  mid-listing. Rendering them for humans is the listing's job.
- Unknown keys are preserved on rewrite, so a newer pytest's fields survive an
  older pytest touching the same directory.
- Write failures are silent, unlike `set()`: the metadata only feeds listing
  and pruning, and whatever made it fail will have made the value writes warn
  already. A second warning for one cause is noise.

Creation goes through _make_cachedir's existing temp-and-rename, so the file
appears atomically with the rest; refreshes use their own same-directory
temp-and-replace. Laziness is preserved - a run which never touches the cache
still creates nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Names where the cache directory lives, instead of making everyone spell out a
path: `local` (the default, unchanged) and `user` (the platform user cache
directory, keyed by project).

This is what pytest-devGH-1089 has been asking for since 2015. What blocked it was that a
cache outside the worktree no longer shares the worktree's lifetime; the
metadata from the previous commit is what makes such a directory collectable,
and the listing and pruning commands follow.

`cache_dir` keeps working and always wins - it is an explicit path, the policy
only decides the location when it is unset, and it stays the way to reach
anywhere the policies do not name. To make "unset" detectable its default
becomes empty rather than ".pytest_cache". `PYTEST_CACHE_POLICY` feeds the
*default* of `cache_policy`, exactly as TOX_ENV_DIR feeds `cache_dir`'s, so a
machine-wide opt-in still loses to an explicit setting.

The project key is the rootdir alone - not the interpreter, which now lives in
scopes - so one project gets one directory however many environments run it,
and ephemeral environments no longer mint a cache tree per invocation. It is
symlink-resolved so that reaching a project through two paths does not give it
two caches; both the digest and the readable label come from the same
normalised path. A directory name collision is detected via the full digest in
the metadata and falls back to a longer name.

pytest_report_header now compares the resolved path against the default rather
than the configured string against a hardcoded ".pytest_cache", which also
fixes setting cache_dir to the default explicitly forcing the line to show. The
path is hyperlinked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Now redundant: scope=ENV keeps --lf/--nf/--sw state apart between environments
without moving the cache directory, and it does so for every tool rather than
only for tox. Behaviour is unchanged this cycle, with a
PytestRemovedIn10Warning.

Nothing needs to replace it. Anyone who wants the location anyway can spell out
`cache_dir = $TOX_ENV_DIR/.pytest_cache`, which is the same expression the
default was built from - cache_dir has expanded environment variables for
years - so it resolves to the same directory by construction, whatever
TOX_ENV_DIR points at.

Two other things this shook out:

The legacy path is now consulted by the `local` policy rather than being the
default of `cache_dir`. As a cache_dir default it silently beat every policy,
since cache_dir takes precedence - so `cache_policy = user` would have done
nothing at all under tox, which is exactly the trap this series is removing.

The warning goes through Config.issue_config_time_warning rather than
warnings.warn, because warnings raised during pytest_configure escape the
reporter and would never have been seen.

`cache_policy = local` written out explicitly still gets the legacy path, since
`local` is the default value and there is nothing to tell the two apart. The
warning names the way out; there is a test documenting this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shows what has accumulated under the user-level cache root: size, age, origin
project, and the scopes each directory holds, with `orphaned` for a directory
whose project is gone and `stale` for a scope whose environment is gone. That
is the CI-DOS scenario from pytest-devGH-1089 made visible - and reduced from orphaned
trees to orphaned scopes, since one project now keeps one directory.

Uses the session-less short-circuit from helpconfig rather than wrap_session.
Without a Session, pytest_sessionfinish never fires, so LFPlugin/NFPlugin
cannot rewrite the very state being listed; that is structural rather than a
guard which would have to grow with every new flag. It also means listing works
without a collectable project, which matters for a command about the machine
rather than about this project.

--help is hoisted to the top of pytest_cmdline_main, which keeps the existing
`--cache-show --help` contract and extends it to the new flag for free.

A directory whose metadata is missing, unparsable or newer than we understand
is still listed, as `broken` - otherwise it would be invisible but undeletable.
Column widths are computed from the plain text before any link escapes are
applied, and ORIGIN is last so it can overflow rather than be truncated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the lifetime-management story pytest-devGH-1089 was blocked on since 2015: a
cache outside the worktree no longer shares the worktree's lifetime, so there
has to be a way to collect what accumulates.

Selectors are `all`, `orphaned` (project gone, or metadata unreadable), `stale`
and a glob over the directory name and origin path, repeatable. `stale` works
at *scope* level rather than directory level - it drops the state belonging to
a deleted virtualenv while leaving the project's cache alone, which is the
common maintenance case and the practical payoff of having scopes at all.

A selector is required; there is no default, so no bare invocation can delete
anything, and --cache-list is the preview. Removal is not interactive, matching
--cache-clear; failures are reported per entry and the command exits non-zero
rather than stopping.

`all` never removes the directory the invoking project would itself use. That
self-exclusion deliberately guards whole-directory removal only: a stale scope
can never be the one in use, since the running environment exists by
definition, and clearing out a deleted virtualenv of the project you are
standing in is the most likely reason to run this at all.

Nothing locks the cache, so pruning concurrently with a run using it can race.
rm_rf failures are reported rather than fatal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three new sections in the cache how-to - scopes, where the cache is stored, and
listing/pruning - plus the cache_policy confval, the PYTEST_CACHE_POLICY,
PYTEST_CACHE_HOME and PYTEST_HYPERLINKS environment variables, the two new
options, and CacheScope in the API reference.

The sample --cache-list output is a literal code block rather than regendoc
output, since it is full of machine-specific paths. The regendoc-captured
--help and ini listings are updated; nothing else moved, because the default
resolved location is unchanged.

One caveat is called out because it is not obvious and does bite: a project on
an unmounted volume looks `orphaned`, which is the main reason pruning is never
automatic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

configure .cache via $XDG_CACHE_DIR

1 participant