2Tests for the FunkyCache cache manager.
5from abc import ABC, abstractmethod
6from collections.abc import Callable, Iterator, Mapping, Sequence
7from collections import Counter
11from pathlib import Path
15from typing import Any, cast, TypeVar
19from funkycache import CannotOverwriteValueException, FunkyCache
20from funkycache.storage import CacheStorage, MemoryCacheStorage, SQLiteCacheStorage
23type PathModifier = Callable[[Path], Path]
26 pytest.param(lambda p: p.relative_to(Path.cwd()), id="relative"),
27 pytest.param(lambda p: p.resolve(), id="absolute"),
31class FunkyCacheTestSuite(ABC):
33 Tests for the properties of FunkyCache which should be independent
34 of the backing storage.
39 def cache(self, tmp_path: Path) -> Iterator[FunkyCache]:
41 Return an empty instance of FunkyCache.
44 def test_registered_function_is_cached(self, cache: FunkyCache) -> None:
46 When you register a function, it gets cached and only called once
49 # Count how many times the underlying function was called
50 calls = Counter[str]()
53 def initial(s: str) -> str:
57 # The first call is a cache miss; goes to the real function
58 assert initial("apple") == "A"
59 assert calls == {"apple": 1}
61 # The second call is a cache hit; skips the real function
62 assert initial("apple") == "A"
63 assert calls == {"apple": 1}
65 # Calling with a different input is a cache miss; goes to the real function
66 assert initial("banana") == "B"
67 assert calls == {"apple": 1, "banana": 1}
69 def test_cache_with_string_annotation(self, cache: FunkyCache) -> None:
71 You can register a function which uses a string as its
76 def initial(s: str) -> "str":
80 assert initial("apple") == "A"
82 def test_cache_iterator(self, cache: FunkyCache) -> None:
84 You can cache a function which returns an iterator.
86 # Count how many times the underlying function was called
87 calls = Counter[str]()
90 def numbers(max: int) -> Iterator[int]:
92 Numbers yields all the numbers between 1 and max.
94 calls[f"max={max}"] += 1
95 for i in itertools.count(start=1):
99 else: # pragma: no cover
102 # The first call is a cache miss; goes to the real function
103 assert list(numbers(max=3)) == [1, 2, 3]
104 assert calls == {"max=3": 1}
106 # The second call is a cache hit; skips the real function
107 assert list(numbers(max=3)) == [1, 2, 3]
108 assert calls == {"max=3": 1}
110 # Check that every instance of the iterator is independent
117 def test_infinite_iterator_isnt_cached(self, cache: FunkyCache) -> None:
119 You can register a cache on a function which returns an infinite
120 iterator, but the result won't be cached.
122 calls = Counter[str]()
125 def greet_forever(name: str) -> Iterator[str]:
127 for i in itertools.count():
128 yield f"Hello {name}! ({i})"
129 else: # pragma: no cover
132 # The first call is a cache miss
133 with pytest.warns(match="iterator returned more than 10,000 items"):
134 assert next(greet_forever("Alex")) == "Hello Alex! (0)"
135 assert calls == {"Alex": 1}
137 # The second call is also a cache miss, because the function was
139 with pytest.warns(match="iterator returned more than 10,000 items"):
140 assert next(greet_forever("Alex")) == "Hello Alex! (0)"
141 assert calls == {"Alex": 2}
143 # The cached items are combined with the rest of the iterator
144 with pytest.warns(match="iterator returned more than 10,000 items"):
145 assert list(itertools.islice(greet_forever("Alex"), 11000)) == [
146 f"Hello Alex! ({i})" for i in range(11000)
149 def test_per_function_caches_are_independent(self, cache: FunkyCache) -> None:
151 Different functions have different caches.
155 def double(n: int) -> int:
159 def triple(n: int) -> int:
162 assert double(2) == 4
163 assert triple(2) == 6
165 def test_double_registering_is_error(self, cache: FunkyCache) -> None:
167 Registering a function with the same canonical identifier
172 def double(n: int) -> int: # pragma: no cover
175 with pytest.raises(TypeError, match="Duplicate cache"):
178 def double(n: int) -> int: # pragma: no cover
181 def test_register_same_name_different_class(self, cache: FunkyCache) -> None:
183 Registering two functions with the same name is okay if one
184 of them is wrapped in a class.
188 def double(n: int) -> int: # pragma: no cover
193 def double(n: int) -> int: # pragma: no cover
196 # path_modifier captures all the semantically equivalent ways
197 # to pass a single path to notify_path_change.
198 @pytest.mark.parametrize(
201 pytest.param(lambda p: p, id="single-path"),
202 pytest.param(lambda p: str(p), id="single-str"),
203 pytest.param(lambda p: [p], id="list-path"),
204 pytest.param(lambda p: [str(p)], id="list-str"),
205 pytest.param(lambda p: (p,), id="tuple-path"),
206 pytest.param(lambda p: (str(p),), id="tuple-str"),
207 pytest.param(lambda p: {p}, id="s-path"),
208 pytest.param(lambda p: {str(p)}, id="s-str"),
211 def test_cache_invalidated_on_path_change(
215 path_modifier: Callable[[Path], Path | str | Sequence[Path | str]],
218 If a function depends on a path and the path changes, the cache
221 p = tmp_path / "greeting.txt"
222 p.write_text("hello world!")
224 # Count how many times the underlying function was called
225 calls = Counter[str]()
227 @cache.register(files=[p])
228 def initial(s: str) -> str:
232 # The first call is a cache miss; goes to the real function
233 assert initial("apple") == "A"
234 assert calls == {"apple": 1}
236 # The second call is a cache hit; skips the real function
237 assert initial("apple") == "A"
238 assert calls == {"apple": 1}
240 # Change the underlying file
241 p.write_text("bonjour monde")
242 cache.notify_path_change(path_modifier(p))
244 # The third call is a cache miss; goes to the real function
245 assert initial("apple") == "A"
246 assert calls == {"apple": 2}
248 def test_cache_not_invalidated_if_path_change_is_noop(
249 self, cache: FunkyCache, tmp_path: Path
252 If a function depends on a path and we get a notification that
253 the path changed, but it hasn't actually changed, we preserve
256 p = tmp_path / "greeting.txt"
257 p.write_text("hello world!")
259 # Count how many times the underlying function was called
260 calls = Counter[str]()
262 @cache.register(files=[p])
263 def initial(s: str) -> str:
267 # The first call is a cache miss; goes to the real function
268 assert initial("apple") == "A"
269 assert calls == {"apple": 1}
271 # The second call is a cache hit; skips the real function
272 assert initial("apple") == "A"
273 assert calls == {"apple": 1}
275 # Notify of a file change
276 cache.notify_path_change(p)
278 # The third call is a cache hit; skips the real function
279 assert initial("apple") == "A"
280 assert calls == {"apple": 1}
282 def test_depends_on_folder(self, cache: FunkyCache, tmp_path: Path) -> None:
284 If a function depends on a folder and a path under the folder
285 changes, the cache is invalided.
287 p = tmp_path / "messages/greeting.txt"
289 p.write_text("hello world!")
291 # Count how many times the underlying function was called
292 calls = Counter[str]()
294 @cache.register(files=[tmp_path / "messages"])
295 def initial(s: str) -> str:
299 # The first call is a cache miss; goes to the real function
300 assert initial("apple") == "A"
301 assert calls == {"apple": 1}
303 # The second call is a cache hit; skips the real function
304 assert initial("apple") == "A"
305 assert calls == {"apple": 1}
307 # Change the underlying file, and send a notification for the
309 p.write_text("bonjour monde")
310 cache.notify_path_change(tmp_path / "messages/greeting.txt")
312 # The third call is a cache miss; goes to the real function
313 assert initial("apple") == "A"
314 assert calls == {"apple": 2}
316 # Change the underlying file, and send a notification for the
318 p.write_text("hallo monde")
319 cache.notify_path_change(p)
321 # The fourth call is a cache miss; goes to the real function
322 assert initial("apple") == "A"
323 assert calls == {"apple": 3}
325 def test_unrelated_change_is_ignored(
326 self, cache: FunkyCache, tmp_path: Path
329 If a path changes but a function doesn't depend on it, the cache
332 p = tmp_path / "greeting.txt"
333 p.write_text("hello world!")
335 # Count how many times the underlying function was called
336 calls = Counter[str]()
339 def initial(s: str) -> str:
343 # The first call is a cache miss; goes to the real function
344 assert initial("apple") == "A"
345 assert calls == {"apple": 1}
347 # The second call is a cache hit; skips the real function
348 assert initial("apple") == "A"
349 assert calls == {"apple": 1}
351 # Notify the cache of a changed path
352 cache.notify_path_change(tmp_path)
354 # The third call is a cache hit; skips the real function
355 assert initial("apple") == "A"
356 assert calls == {"apple": 1}
358 def test_skips_function_with_no_source_file(self, cache: FunkyCache) -> None:
360 If a function doesn't have a discoverable source file, it can
361 still be registered for caching.
364 "def double(x: int) -> int: return x * 2", "<string>", "exec"
367 exec(code_obj, namespace)
368 double = namespace["double"]
370 assert inspect.getsourcefile(double) is None
372 with pytest.warns(UserWarning, match="unable to determine source file for"):
373 cache.register(double)
375 def test_function_can_depend_on_missing_file(
376 self, cache: FunkyCache, tmp_path: Path
379 A function can depend on a missing file. This allows a function
380 to register an interest in a file that doesn't exist yet, but
381 will be created later.
384 @cache.register(files=[tmp_path / "does_not_exist"])
385 def double(n: int) -> int:
388 assert double(5) == 10
390 @pytest.mark.parametrize("modify_depends_on", path_styles)
391 @pytest.mark.parametrize("modify_notify_path_change", path_styles)
392 def test_invalidation_handles_relative_paths(
396 modify_depends_on: PathModifier,
397 modify_notify_path_change: PathModifier,
400 The path invalidation logic can handle different combinations
401 of relative/absolute paths.
403 with contextlib.chdir(tmp_path):
404 p = tmp_path / "greeting.txt"
405 p.write_text("hello world!")
407 # Count how many times the underlying function was called
408 calls = Counter[str]()
410 @cache.register(files=[modify_depends_on(p)])
411 def initial(s: str) -> str:
415 # The first call is a cache miss; goes to the real function
416 assert initial("apple") == "A"
417 assert calls == {"apple": 1}
419 # The second call is a cache hit; skips the real function
420 assert initial("apple") == "A"
421 assert calls == {"apple": 1}
423 # Change the underlying file
424 p.write_text("bonjour monde")
425 cache.notify_path_change(modify_notify_path_change(p))
427 # The third call is a cache miss; goes to the real function
428 assert initial("apple") == "A"
429 assert calls == {"apple": 2}
437 Tests for FunkyCache that aren't based on the behaviour of
438 a specific storage implementation, but are tricky to test
439 across all implementations.
442 def test_cache_depends_on_python_file(self, tmp_path: Path) -> None:
444 The cache depends on the source file of the cached function.
446 p = tmp_path / "example.py"
449 cmd = ["python3", str(p)]
450 return subprocess.check_output(cmd, cwd=tmp_path, text=True).strip()
452 # Write the initial version of the file, and check the first call
453 # is a cache miss, subsequent calls are cache hits
456 from funkycache import FunkyCache
458 GREETING = "hello world"
460 cache = FunkyCache(storage="sqlite", root=".cache")
464 print("calling original function")
471 assert run() == "calling original function\nhello world"
472 assert run() == "hello world"
473 assert run() == "hello world"
475 # Update the file, and check the next call is a cache miss
476 new_text = p.read_text().replace('"hello world"', '"bonjour monde"')
477 p.write_text(new_text)
479 assert run() == "calling original function\nbonjour monde"
481 def test_cache_get_error_is_ignored(self) -> None:
483 An error reading from the cache is a warning and falls back
484 to the underlying function.
487 class BrokenGetCacheStorage[V](MemoryCacheStorage[V]):
488 def get(self, key: Mapping[str, Any]) -> V:
489 raise ValueError("BOOM!")
491 class BrokenGetFunkyCache(FunkyCache):
493 self, func_name: str, data_type: R, input_sig: str
494 ) -> CacheStorage[R]:
495 return BrokenGetCacheStorage[R]()
497 cache = BrokenGetFunkyCache(storage="memory")
500 def initial(s: str) -> str:
504 UserWarning, match="unexpected error looking up key in cache"
506 assert initial("apple") == "A"
508 def test_cache_set_error_is_ignored(self) -> None:
510 An error writing to the cache is a warning and falls back
511 to the underlying function.
514 class BrokenGetCacheStorage[V](MemoryCacheStorage[V]):
515 def set(self, key: Mapping[str, Any], value: V) -> None:
516 raise ValueError("BOOM!")
518 class BrokenGetFunkyCache(FunkyCache):
520 self, func_name: str, data_type: R, input_sig: str
521 ) -> CacheStorage[R]:
522 return BrokenGetCacheStorage[R]()
524 cache = BrokenGetFunkyCache(storage="memory")
527 def initial(s: str) -> str:
531 UserWarning, match="unexpected error inserting value into cache"
533 assert initial("apple") == "A"
535 def test_cache_overwrite_value_error_is_raised(self) -> None:
537 An error where you overwrite the value of an existing key
541 # Create a broken implementation of CacheStorage which always
542 # returns a KeyError when looking up keys.
543 class BrokenGetCacheStorage[V](MemoryCacheStorage[V]):
544 def get(self, key: Mapping[str, Any]) -> V:
547 class BrokenGetFunkyCache(FunkyCache):
549 self, func_name: str, data_type: R, input_sig: str
550 ) -> CacheStorage[R]:
551 return BrokenGetCacheStorage[R]()
553 cache = BrokenGetFunkyCache(storage="memory")
556 def get_token(n: int) -> str:
557 return secrets.token_hex(n)
559 assert len(get_token(16)) == 32
561 with pytest.raises(CannotOverwriteValueException):
564 def test_invalid_storage(self) -> None:
566 Passing an invalid storage type is a TypeError.
568 with pytest.raises(TypeError, match="Unrecognised storage"):
569 FunkyCache(storage="does_not_exist") # type: ignore
571 def test_cache_varies_based_on_static_values(self, tmp_path: Path) -> None:
573 The static values that a function depends on are used to distinguish
576 # Count how many times the underlying function was called
577 calls = Counter[str]()
580 c1 = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
582 @c1.register(values=["en"])
583 def greet(name: str) -> str:
585 return f"Hello {name}!"
587 assert greet("Alex") == "Hello Alex!"
588 assert calls == {"Alex": 1}
590 for kc in c1._known_caches.values():
591 cast(SQLiteCacheStorage, kc.storage).close()
593 c2 = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
595 @c2.register(values=["fr"])
596 def greet(name: str) -> str:
598 return f"Bonjour {name}!"
600 assert greet("Alex") == "Bonjour Alex!"
601 assert calls == {"Alex": 2}
604class TestMemoryFunkyCache(FunkyCacheTestSuite):
606 Tests for a FunkyCache which is backed by in-memory storage.
610 def cache(self, tmp_path: Path) -> Iterator[FunkyCache]:
612 Return an empty instance of FunkyCache.
614 yield FunkyCache(storage="memory")
616 def test_no_root_with_memory(self, tmp_path: Path) -> None:
618 You cannot construct an in-memory FunkyCache with a root.
621 TypeError, match="Do not specify a root path with memory storage"
623 FunkyCache(storage="memory", root=tmp_path / ".cache") # type: ignore
625 def test_warns_on_missing_return_signature(self, tmp_path: Path) -> None:
627 A function cached in memory warns if it doesn't have
630 cache = FunkyCache(storage="memory")
632 with pytest.warns(match="has no return annotation"):
635 def double(n: int): # pragma: no cover
638 def test_errors_on_missing_iterator_annotation(self, tmp_path: Path) -> None:
640 A generator cached in memory warns if a generator doesn't specify
643 cache = FunkyCache(storage="memory")
645 with pytest.warns(match="generator with an incomplete return annotation"):
648 def numbers(n: int) -> Iterator: # pragma: no cover
649 yield from itertools.count(n)
652class TestSQLiteFunkyCache(FunkyCacheTestSuite):
654 Tests for a FunkyCache which is backed by SQLite storage.
658 def cache(self, tmp_path: Path) -> Iterator[FunkyCache]:
660 Return an empty instance of FunkyCache.
662 yield FunkyCache(storage="sqlite", root=tmp_path / ".cache")
664 def test_stale_caches_are_removed(self, tmp_path: Path) -> None:
666 If the cache for a function is invalidated, the old SQLite
667 database gets cleaned up.
669 root = tmp_path / ".cache"
670 c = FunkyCache(storage="sqlite", root=root)
672 @c.register(files=[tmp_path / "greeting.txt"])
673 def double(n: int) -> int:
676 assert double(5) == 10
678 # After populating the cache, there's a single set of SQLite files
679 # in the .cache directory
680 stem1 = {p.stem for p in root.iterdir()}
681 assert len(stem1) == 1
683 # Invalidate the cache
684 p = tmp_path / "greeting.txt"
685 p.write_text("hello world")
686 c.notify_path_change(p)
688 # There should be a new set of SQLite files in the .cache directory
689 stem2 = {p.stem for p in root.iterdir()}
690 assert len(stem2) == 1
691 assert stem1 != stem2
693 def test_cache_depends_on_python_file(self, tmp_path: Path) -> None:
695 The cache depends on the source file of the cached function.
697 p = tmp_path / "example.py"
700 cmd = ["python3", str(p)]
701 return subprocess.check_output(cmd, cwd=tmp_path, text=True).strip()
703 # Write the initial version of the file, and check the first call
704 # is a cache miss, subsequent calls are cache hits
707 from funkycache import FunkyCache
709 GREETING = "hello world"
711 cache = FunkyCache(storage="sqlite", root=".cache")
715 print("calling original function")
722 assert run() == "calling original function\nhello world"
723 assert run() == "hello world"
724 assert run() == "hello world"
726 # Update the file, and check the next call is a cache miss
727 new_text = p.read_text().replace('"hello world"', '"bonjour monde"')
728 p.write_text(new_text)
730 assert run() == "calling original function\nbonjour monde"
732 def test_data_is_persistent(self, tmp_path: Path) -> None:
734 Data can be persisted to an SQLite cache on-disk.
736 # Create a new cache, and populate it with some data
737 cache1 = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
740 def initial(s: str) -> str:
743 assert initial("apple") == "A"
745 for kc in cache1._known_caches.values():
746 cast(SQLiteCacheStorage, kc.storage).close()
748 # Open a new instance of FunkyCache pointing at the same cache root,
749 # and check it retrieves the cached value.
750 cache2 = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
753 def initial(s: str) -> str: # pragma: no cover
754 assert 0, "should not be called"
756 assert initial("apple") == "A"
758 def test_errors_on_missing_return_signature(self, tmp_path: Path) -> None:
760 A function cached in SQLite must have a return signature.
762 cache = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
764 with pytest.raises(TypeError, match="has no return annotation"):
767 def double(n: int): # pragma: no cover
770 def test_errors_on_missing_iterator_annotation(self, tmp_path: Path) -> None:
772 A generator cached in SQLite must specify the generated type.
774 cache = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
777 TypeError, match="generator with an incomplete return annotation"
781 def numbers(n: int) -> Iterator: # pragma: no cover
782 yield from itertools.count(n)
784 @pytest.mark.parametrize("modify_root", path_styles)
785 @pytest.mark.parametrize("modify_depends_on", path_styles)
786 @pytest.mark.parametrize(
789 pytest.param(lambda root: root.parent, id="parent"),
790 pytest.param(lambda root: root, id="self"),
791 pytest.param(lambda root: root / "test.db", id="file"),
794 def test_function_cannot_depend_on_cache_root(
797 modify_root: PathModifier,
798 modify_depends_on: PathModifier,
799 depends_on: PathModifier,
802 A function cannot declare a dependency on the cache root or
803 any of the files inside it.
805 root = tmp_path / "example/.cache"
807 with contextlib.chdir(tmp_path):
808 cache = FunkyCache(storage="sqlite", root=modify_root(root))
810 files: list[Path | str] = [modify_depends_on(depends_on(root))]
813 ValueError, match="cannot depend on a file/folder inside the cache root"
816 @cache.register(files=files)
817 def double(n: int) -> int: # pragma: no cover
821def test_canonical_name_varies_by_path(tmp_path: Path) -> None:
823 The canonical name of functions in the top-level of a script
826 For these functions, func.__module__ is __main__, so we need a way
827 to distinguish them to avoid cache collisions.
829 (tmp_path / "s1.py").write_text(
831 from funkycache.manager import _canonical_name
833 def greet(name: str) -> str:
834 return f"Hello {name}!"
836 print(_canonical_name(greet))
840 (tmp_path / "s2.py").write_text(
842 from funkycache.manager import _canonical_name
844 def greet(name: str) -> str:
845 return f"Hello {name}!"
847 print(_canonical_name(greet))
851 name1 = subprocess.check_output(["python3", (tmp_path / "s1.py")])
852 name2 = subprocess.check_output(["python3", (tmp_path / "s2.py")])
854 assert name1 != name2