Skip to main content

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")