manager: handle relative/absolute dependency paths
- ID
b02a8b7- date
2026-11-06 23:28:22+00:00- author
Alex Chan <alexc@tailscale.com>- parent
be4497c- message
manager: handle relative/absolute dependency paths Whether the caller supplies paths as relative or absolute, we should work out what they mean and whether to invalidate the cache. Resolve all paths to absolute before making decisions.- changed files
2 files, 65 additions, 22 deletions
Changed files
src/funkycache/manager.py (11692 → 11783)
diff --git a/src/funkycache/manager.py b/src/funkycache/manager.py
index 6ba8e77..17f4837 100644
--- a/src/funkycache/manager.py
+++ b/src/funkycache/manager.py
@@ -238,9 +238,10 @@ class FunkyCache:
"""
Invalidate cache that depend on this path and/or paths.
"""
+ # Convert input values to a list of absolute paths.
if isinstance(changed_paths, (Path, str)):
changed_paths = [changed_paths]
- changed_paths: list[Path] = [Path(p) for p in changed_paths]
+ changed_paths: list[Path] = [Path(p).resolve() for p in changed_paths]
for name, known_cache in self._known_caches.items():
# If this cache isn't affected by any of the paths that
@@ -309,13 +310,13 @@ def _canonical_spec(func: F) -> str:
def _dep_paths(func: F, files: list[Path]) -> set[Path]:
"""
- Get a list of all the paths this function depends on.
+ Get a list of all the absolute paths this function depends on.
"""
dep_paths: list[Path] = []
# 1. Include the source file of the function.
if func_file := inspect.getsourcefile(func):
- dep_paths.append(Path(func_file))
+ dep_paths.append(Path(func_file).resolve())
else:
warnings.warn(
f"unable to determine source file for {func}; "
@@ -324,7 +325,7 @@ def _dep_paths(func: F, files: list[Path]) -> set[Path]:
# 2. Include any explicitly declared files.
if files:
- dep_paths.extend(files)
+ dep_paths.extend([p.resolve() for p in files])
return set(dep_paths)
@@ -346,7 +347,7 @@ def _input_signature(dep_paths: set[Path], values: list[Any]) -> str:
# and a hash of the contents.
all_paths = []
for p in dep_paths:
- if p.is_file() or not p.exists(follow_symlinks=False):
+ if p.is_file() or not p.exists():
all_paths.append(p)
elif p.is_dir():
all_paths.extend(gp for gp in p.rglob("*") if gp.is_file())
tests/test_manager.py (20246 → 21772)
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 6b3b801..361a210 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -5,17 +5,18 @@ Tests for the FunkyCache cache manager.
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterator, Mapping, Sequence
from collections import Counter
+import contextlib
import inspect
from pathlib import Path
import secrets
import subprocess
import textwrap
-from typing import Any, TypeVar
+from typing import Any, cast, TypeVar
import pytest
from funkycache import CannotOverwriteValueException, FunkyCache
-from funkycache.storage import CacheStorage, MemoryCacheStorage
+from funkycache.storage import CacheStorage, MemoryCacheStorage, SQLiteCacheStorage
class FunkyCacheTestSuite(ABC):
@@ -294,21 +295,6 @@ class FunkyCacheTestSuite(ABC):
with pytest.warns(UserWarning, match="unable to determine source file for"):
cache.register(double)
- def test_function_cannot_depend_on_symlink(
- self, cache: FunkyCache, tmp_path: Path
- ) -> None:
- """
- A function cannot depend on a symlink.
- """
- p = tmp_path / "symlink"
- p.symlink_to(tmp_path / "greeting.txt")
-
- with pytest.raises(TypeError, match="Invalid path"):
-
- @cache.register(files=[p])
- def double(n: int) -> int: # pragma: no cover
- return n * 2
-
def test_function_can_depend_on_missing_file(
self, cache: FunkyCache, tmp_path: Path
) -> None:
@@ -324,6 +310,59 @@ class FunkyCacheTestSuite(ABC):
assert double(5) == 10
+ @pytest.mark.parametrize(
+ "modify_depends_on",
+ [
+ pytest.param(lambda p: p.relative_to(Path.cwd()), id="relative"),
+ pytest.param(lambda p: p.resolve(), id="absolute"),
+ ],
+ )
+ @pytest.mark.parametrize(
+ "modify_path_change",
+ [
+ pytest.param(lambda p: p.relative_to(Path.cwd()), id="relative"),
+ pytest.param(lambda p: p.resolve(), id="absolute"),
+ ],
+ )
+ def test_invalidation_handles_relative_paths(
+ self,
+ cache: FunkyCache,
+ tmp_path: Path,
+ modify_depends_on: Callable[[Path], Path],
+ modify_path_change: Callable[[Path], Path],
+ ) -> None:
+ """
+ The path invalidation logic can handle different combinations
+ of relative/absolute paths.
+ """
+ with contextlib.chdir(tmp_path):
+ p = tmp_path / "greeting.txt"
+ p.write_text("hello world!")
+
+ # Count how many times the underlying function was called
+ calls = Counter[str]()
+
+ @cache.register(files=[modify_depends_on(p)])
+ def initial(s: str) -> str:
+ calls[s] += 1
+ return s[0].upper()
+
+ # The first call is a cache miss; goes to the real function
+ assert initial("apple") == "A"
+ assert calls == {"apple": 1}
+
+ # The second call is a cache hit; skips the real function
+ assert initial("apple") == "A"
+ assert calls == {"apple": 1}
+
+ # Change the underlying file
+ p.write_text("bonjour monde")
+ cache.notify_path_change(modify_path_change(p))
+
+ # The third call is a cache miss; goes to the real function
+ assert initial("apple") == "A"
+ assert calls == {"apple": 2}
+
R = TypeVar("R")
@@ -613,6 +652,9 @@ class TestSQLiteFunkyCache(FunkyCacheTestSuite):
assert initial("apple") == "A"
+ for kc in cache1._known_caches.values():
+ cast(SQLiteCacheStorage, kc.storage).close()
+
# Open a new instance of FunkyCache pointing at the same cache root,
# and check it retrieves the cached value.
cache2 = FunkyCache(storage="sqlite", root=tmp_path / ".cache")