Skip to content

cache_on_files: a disk cache that notices when input files change - #3

Merged
skjerns merged 7 commits into
mainfrom
cache-on-files
Aug 3, 2026
Merged

skjerns merged 7 commits into
mainfrom
cache-on-files

Conversation

@skjerns

@skjerns skjerns commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Why

joblib.Memory keys its cache on the arguments a function was called with. For a function that takes a file path that is not enough: the path stays the same while the file behind it changes, and the stale result is returned forever.

What

file_signature(file) — the os.stat fingerprint of a file (size, mtime, ctime, inode), or of a list of files, without reading the contents. The same fields hash_file(use_cache=True) already used inline, which now builds its cache key from this instead.

cache_on_files(*params, memory=None) — decorator that computes that fingerprint on every call and folds it into the joblib cache key, where the body ignores it:

@cache_on_files('log_file', memory=config.cache_dir)
def log_to_triggers(log_file, condition):
    ...

A parameter may hold a list of files (order is part of the key), and the cache invalidates if any one of them changes.

Without parameter names, every argument that points at an existing file is picked up automatically. A path that does not exist yet is left alone and hashed as the plain string it is, so the result computed while it was missing is not reused once it appears:

@cache_on_files()
def check(recording, log_files, strict=True):
    ...

It can also be chained on top of a joblib cache, to take the Memory and its options from there:

@cache_on_files('filename')
@memory.cache
def func(filename): ...

This is not a chain of two caches. The fingerprint has to take part in the key that joblib computes, and joblib only hashes the arguments of the function it wraps itself — so the MemorizedFunc below is unwrapped and rebuilt with the same Memory rather than called through. ignore= on the inner cache is refused with an assertion instead of silently no longer ignoring anything.

.uncached (the original function) and .cached (the MemorizedFunc, e.g. for .clear()) are attached to the result.

Three details that are easy to get wrong

All three were found by the tests, not by reading:

  1. @wraps on the cached function makes inspect follow __wrapped__ to the original signature, and joblib then rejects the extra argument. The identity is copied by hand instead — which is also why the function's source is hashed into the key explicitly, since joblib's own source-change invalidation would otherwise track the (never-changing) wrapper.
  2. joblib cannot map keyword-only parameters of a *args function, so the fingerprint and source hash are leading positional parameters.
  3. f(x) and f(x=x) are two separate cache entries unless the call is normalised to keyword form first.

Tests

43 new tests in tests/test_misc.py (TestFileSignature, TestCacheOnFiles, TestCacheOnFilesAuto, TestCacheOnFilesChained): invalidation on change, lists of files, positional vs keyword calls, two decorated functions not colliding in one cache dir, .uncached / .cached.clear(), auto-detection of non-paths and unusable strings, and the chained form landing in the right Memory.

pytest tests/test_misc.py → 92 passed, 2 failed. The two failures are in TestLongDfToArraySubsetColumns and are pre-existing — they fail on a clean main checkout too.

Note

The branch also carries the in-progress hash_file(use_cache=) and list_files changes that were sitting in the working tree; they live in the same regions of the same files, so they could not be separated into their own commit.

🤖 Generated with Claude Code

joblib.Memory keys its cache on the arguments a function was called
with. For a function that takes a file *path* that is not enough: the
path stays the same while the file behind it changes, and the stale
result is returned forever.

- file_signature(file): the os.stat fingerprint of a file (size, mtime,
  ctime, inode) or of a list of files, without reading the contents.
  hash_file(use_cache=True) now builds its cache key from it as well.
- cache_on_files(*params): decorator that computes that fingerprint for
  the declared parameters on every call and folds it into the joblib
  cache key, where the body ignores it. Without any parameter names,
  every argument that points at an existing file is picked up
  automatically; a path that does not exist yet is left alone and
  hashed as the plain string it is.
- can be chained on top of @memory.cache, which is then unwrapped and
  rebuilt with the same Memory, since a fingerprint can only take part
  in a key that joblib computes itself.

Three details that are easy to get wrong and are covered by tests:
@wraps on the cached function makes joblib follow __wrapped__ to the
original signature and reject the extra argument; joblib cannot map
keyword-only parameters of a *args function; and f(x) and f(x=x) are
two different cache entries unless the call is normalised first.

Also carries the in-progress hash_file(use_cache=) and list_files
changes that were sitting in the working tree, as they live in the same
regions of the same files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

carries the rename through the tests, which still imported the old name
and made the whole test file fail to collect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
skjerns and others added 5 commits August 3, 2026 13:59
Before, filecache selected ~/.cache/meg_utils/filecache when the caller
gave no memory. That is a decision of the application, not of the
library, and it hides a forgotten parameter: the results go to a
directory that nobody asked for.

Now the caller must give memory=..., or put the decorator on top of a
memory.cache. If there is neither, filecache raises a ValueError. The
error occurs at decoration time, so the problem shows at import.

Memory(None) stays permitted. It is a cache that does nothing, and it
lets an application switch the cache off with one value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Restore EPS output to savefig()'s vectors/ folder, dropped when PDF
  metadata support was added.
- Disable seaborn's default palette desaturation in tornadoplot() so
  low_colour/high_colour render as given.
- Add pypdf/pytest as declared test dependencies.
- Fix long_df_to_array subset-column tests to assert the (correct)
  ValueError on ambiguous, order-dependent collapsing instead of
  asserting the old silent-collapse behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pip install -e . / pip install pytest never picked up the pypdf
dependency added for test_vector_metadata_embedded_in_pdf, so CI kept
failing that test even after the local fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
push already fires on every branch (branches: ["**"]), so PRs from
branches in this repo were running the same workflow twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@skjerns
skjerns merged commit c2ef92a into main Aug 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant