PoC: cache policy and cache scopes (#1089) — AI-assisted, for hole-poking only - #14835
Draft
RonnyPfannschmidt wants to merge 12 commits into
Draft
PoC: cache policy and cache scopes (#1089) — AI-assisted, for hole-poking only#14835RonnyPfannschmidt wants to merge 12 commits into
RonnyPfannschmidt wants to merge 12 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-bytrailer.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_dirat~/.cachehas always worked. It stalled on lifetime, per my own comment on the issue: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,--nfand--swstate depends on what the interpreter in use actually collects. Today the only way toexpress that is to move the whole cache directory — which is exactly what the
TOX_ENV_DIRspecial casein
cacheprovider.pydoes: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.
CacheScopenames the property directly instead:SHARED(default)PYTHONmajor.minorENVsys.prefixInterpreter 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.
SHAREDstays at the existingv/and
d/paths, so existing caches and third-party plugins need no migration; only pytest's own threekeys move, costing one benign invalidation.
Cache policy — where the directory lives
cache_policy = local | user.localis unchanged.userputs it in the platform user cache dir,keyed by project, so nothing is written into the project at all.
PYTEST_CACHE_POLICYsets itmachine-wide.
cache_dirstill wins over both and remains the escape hatch for anything the policies donot 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.jsonrecording where it came from and which scopes it holds.That makes the accumulation visible and collectable:
--cache-prunerequires a selector (all,orphaned,stale, or a glob), so a bare invocation cannever delete anything. Nothing is ever pruned automatically — a project on an unmounted volume looks
orphaned, which is exactly why.TOX_ENV_DIRdeprecationNow 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 sameexpression 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 supportpytester: isolate the user-level cache directoryLOCALAPPDATAis not derived fromUSERPROFILE, so inner runs leaked to the real user cache on Windows always, and on Linux wheneverXDG_CACHE_HOMEwas setpathlib: resolve the user-level cache root behind an xdg extraplatformdirsas an optional dep (pytest[xdg]), imported lazilycacheprovider: funnel cache directory resolution through one functioncacheprovider: introduce cache scopescacheprovider: pin lastfailed, nodeids and stepwise to the env scopecacheprovider: record cache metadatacacheprovider: add the cache_policy optioncacheprovider: deprecate the TOX_ENV_DIR cache_dir defaultcacheprovider: add --cache-listcacheprovider: add --cache-prunedocs: document cache policy, scopes and pruningNice side effect:
test_stepwise.py'scache_dir = .cacheworkaround, added because "tox's cachedirectory 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:
SHARED. Back-compat-safe, but arguably the wrong semantic default — a stalecross-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.
cache_dir+cache_policyis a two-option surface. The rule is one sentence —cache_diralways wins — but two knobs for one outcome is how we got
TOX_ENV_DIRin the first place.cache_dir's ini default is now""rather than".pytest_cache", which is what makes "unset"detectable. In-tree only
pytest_report_headerread it expecting the old value; third-party pluginsmight too.
s/<scope-id>/{v,d}/, scope ids likeenv-venv-9f8e7d6c, project dirs likemyproject-1a2b3c4d5e6f7a8b. All of it is a guess at what reads well inls.churns the key for rotating-symlink deploy layouts.
--cache-prunedeletes without confirmation, consistent with--cache-clear, with--cache-listas the preview. And nothing locks the cache, so pruning concurrently with a run using it can race.
mid-listing.
TERM=screen-*and so loses links;PYTEST_HYPERLINKS=1forces them.Notes
Raise UsageError from getini for invalid configuration values) — without it an invalidcache_policyread at configure time surfaces as anINTERNALERROR. Found that the hard way; it isalready on
main.xdgextra. Extras are effectively permanent, andxdgis only accurate onLinux even though the feature covers macOS and Windows.
user-policy tests skip rather than fail whenplatformdirsis absent, so a minimal-deps job staysgreen.