Skip to main content

tests/test_manager.py

1"""
2Tests for the FunkyCache cache manager.
3"""
5from abc import ABC, abstractmethod
6from collections.abc import Callable, Iterator, Mapping, Sequence
7from collections import Counter
8import contextlib
9import inspect
10import itertools
11from pathlib import Path
12import secrets
13import subprocess
14import textwrap
15from typing import Any, cast, TypeVar
17import pytest
19from funkycache import CannotOverwriteValueException, FunkyCache
20from funkycache.storage import CacheStorage, MemoryCacheStorage, SQLiteCacheStorage
23type PathModifier = Callable[[Path], Path]
25path_styles = [
26 pytest.param(lambda p: p.relative_to(Path.cwd()), id="relative"),
27 pytest.param(lambda p: p.resolve(), id="absolute"),
31class FunkyCacheTestSuite(ABC):
32 """
33 Tests for the properties of FunkyCache which should be independent
34 of the backing storage.
35 """
37 @abstractmethod
38 @pytest.fixture
39 def cache(self, tmp_path: Path) -> Iterator[FunkyCache]:
40 """
41 Return an empty instance of FunkyCache.
42 """
44 def test_registered_function_is_cached(self, cache: FunkyCache) -> None:
45 """
46 When you register a function, it gets cached and only called once
47 per input.
48 """
49 # Count how many times the underlying function was called
50 calls = Counter[str]()
52 @cache.register
53 def initial(s: str) -> str:
54 calls[s] += 1
55 return s[0].upper()
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:
70 """
71 You can register a function which uses a string as its
72 return annotation.
73 """
75 @cache.register
76 def initial(s: str) -> "str":
77 return s[0].upper()
79 for _ in range(3):
80 assert initial("apple") == "A"
82 def test_cache_iterator(self, cache: FunkyCache) -> None:
83 """
84 You can cache a function which returns an iterator.
85 """
86 # Count how many times the underlying function was called
87 calls = Counter[str]()
89 @cache.register
90 def numbers(max: int) -> Iterator[int]:
91 """
92 Numbers yields all the numbers between 1 and max.
93 """
94 calls[f"max={max}"] += 1
95 for i in itertools.count(start=1):
96 if i > max:
97 break
98 yield i
99 else: # pragma: no cover
100 pass
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
111 n1 = numbers(max=3)
112 n2 = numbers(max=3)
113 assert next(n1) == 1
114 assert next(n1) == 2
115 assert next(n2) == 1
117 def test_infinite_iterator_isnt_cached(self, cache: FunkyCache) -> None:
118 """
119 You can register a cache on a function which returns an infinite
120 iterator, but the result won't be cached.
121 """
122 calls = Counter[str]()
124 @cache.register
125 def greet_forever(name: str) -> Iterator[str]:
126 calls[name] += 1
127 for i in itertools.count():
128 yield f"Hello {name}! ({i})"
129 else: # pragma: no cover
130 pass
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
138 # never cached
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)
147 ]
149 def test_per_function_caches_are_independent(self, cache: FunkyCache) -> None:
150 """
151 Different functions have different caches.
152 """
154 @cache.register
155 def double(n: int) -> int:
156 return n * 2
158 @cache.register
159 def triple(n: int) -> int:
160 return n * 3
162 assert double(2) == 4
163 assert triple(2) == 6
165 def test_double_registering_is_error(self, cache: FunkyCache) -> None:
166 """
167 Registering a function with the same canonical identifier
168 is an error.
169 """
171 @cache.register
172 def double(n: int) -> int: # pragma: no cover
173 return n * 2
175 with pytest.raises(TypeError, match="Duplicate cache"):
177 @cache.register
178 def double(n: int) -> int: # pragma: no cover
179 return n * 2
181 def test_register_same_name_different_class(self, cache: FunkyCache) -> None:
182 """
183 Registering two functions with the same name is okay if one
184 of them is wrapped in a class.
185 """
187 @cache.register
188 def double(n: int) -> int: # pragma: no cover
189 return n * 2
191 class WrapperClass:
192 @cache.register
193 def double(n: int) -> int: # pragma: no cover
194 return n * 2
196 # path_modifier captures all the semantically equivalent ways
197 # to pass a single path to notify_path_change.
198 @pytest.mark.parametrize(
199 "path_modifier",
200 [
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"),
209 ],
210 )
211 def test_cache_invalidated_on_path_change(
212 self,
213 cache: FunkyCache,
214 tmp_path: Path,
215 path_modifier: Callable[[Path], Path | str | Sequence[Path | str]],
216 ) -> None:
217 """
218 If a function depends on a path and the path changes, the cache
219 is invalidated.
220 """
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:
229 calls[s] += 1
230 return s[0].upper()
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
250 ) -> None:
251 """
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
254 the cache.
255 """
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:
264 calls[s] += 1
265 return s[0].upper()
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:
283 """
284 If a function depends on a folder and a path under the folder
285 changes, the cache is invalided.
286 """
287 p = tmp_path / "messages/greeting.txt"
288 p.parent.mkdir()
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:
296 calls[s] += 1
297 return s[0].upper()
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
308 # directory changing
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
317 # file changing
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
327 ) -> None:
328 """
329 If a path changes but a function doesn't depend on it, the cache
330 is left as-is.
331 """
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]()
338 @cache.register
339 def initial(s: str) -> str:
340 calls[s] += 1
341 return s[0].upper()
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:
359 """
360 If a function doesn't have a discoverable source file, it can
361 still be registered for caching.
362 """
363 code_obj = compile(
364 "def double(x: int) -> int: return x * 2", "<string>", "exec"
365 )
366 namespace = {}
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
377 ) -> None:
378 """
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.
382 """
384 @cache.register(files=[tmp_path / "does_not_exist"])
385 def double(n: int) -> int:
386 return n * 2
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(
393 self,
394 cache: FunkyCache,
395 tmp_path: Path,
396 modify_depends_on: PathModifier,
397 modify_notify_path_change: PathModifier,
398 ) -> None:
399 """
400 The path invalidation logic can handle different combinations
401 of relative/absolute paths.
402 """
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:
412 calls[s] += 1
413 return s[0].upper()
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}
432R = TypeVar("R")
435class TestFunkyCache:
436 """
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.
440 """
442 def test_cache_depends_on_python_file(self, tmp_path: Path) -> None:
443 """
444 The cache depends on the source file of the cached function.
445 """
446 p = tmp_path / "example.py"
448 def run():
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
454 p.write_text(
455 textwrap.dedent("""
456 from funkycache import FunkyCache
458 GREETING = "hello world"
460 cache = FunkyCache(storage="sqlite", root=".cache")
462 @cache.register
463 def greet() -> str:
464 print("calling original function")
465 return GREETING
467 print(greet())
468 """)
469 )
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:
482 """
483 An error reading from the cache is a warning and falls back
484 to the underlying function.
485 """
487 class BrokenGetCacheStorage[V](MemoryCacheStorage[V]):
488 def get(self, key: Mapping[str, Any]) -> V:
489 raise ValueError("BOOM!")
491 class BrokenGetFunkyCache(FunkyCache):
492 def _create_storage(
493 self, func_name: str, data_type: R, input_sig: str
494 ) -> CacheStorage[R]:
495 return BrokenGetCacheStorage[R]()
497 cache = BrokenGetFunkyCache(storage="memory")
499 @cache.register
500 def initial(s: str) -> str:
501 return s[0].upper()
503 with pytest.warns(
504 UserWarning, match="unexpected error looking up key in cache"
505 ):
506 assert initial("apple") == "A"
508 def test_cache_set_error_is_ignored(self) -> None:
509 """
510 An error writing to the cache is a warning and falls back
511 to the underlying function.
512 """
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):
519 def _create_storage(
520 self, func_name: str, data_type: R, input_sig: str
521 ) -> CacheStorage[R]:
522 return BrokenGetCacheStorage[R]()
524 cache = BrokenGetFunkyCache(storage="memory")
526 @cache.register
527 def initial(s: str) -> str:
528 return s[0].upper()
530 with pytest.warns(
531 UserWarning, match="unexpected error inserting value into cache"
532 ):
533 assert initial("apple") == "A"
535 def test_cache_overwrite_value_error_is_raised(self) -> None:
536 """
537 An error where you overwrite the value of an existing key
538 is re-raised.
539 """
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:
545 raise KeyError(key)
547 class BrokenGetFunkyCache(FunkyCache):
548 def _create_storage(
549 self, func_name: str, data_type: R, input_sig: str
550 ) -> CacheStorage[R]:
551 return BrokenGetCacheStorage[R]()
553 cache = BrokenGetFunkyCache(storage="memory")
555 @cache.register
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):
562 get_token(16)
564 def test_invalid_storage(self) -> None:
565 """
566 Passing an invalid storage type is a TypeError.
567 """
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:
572 """
573 The static values that a function depends on are used to distinguish
574 cache instances.
575 """
576 # Count how many times the underlying function was called
577 calls = Counter[str]()
579 for _ in range(3):
580 c1 = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
582 @c1.register(values=["en"])
583 def greet(name: str) -> str:
584 calls[name] += 1
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:
597 calls[name] += 1
598 return f"Bonjour {name}!"
600 assert greet("Alex") == "Bonjour Alex!"
601 assert calls == {"Alex": 2}
604class TestMemoryFunkyCache(FunkyCacheTestSuite):
605 """
606 Tests for a FunkyCache which is backed by in-memory storage.
607 """
609 @pytest.fixture
610 def cache(self, tmp_path: Path) -> Iterator[FunkyCache]:
611 """
612 Return an empty instance of FunkyCache.
613 """
614 yield FunkyCache(storage="memory")
616 def test_no_root_with_memory(self, tmp_path: Path) -> None:
617 """
618 You cannot construct an in-memory FunkyCache with a root.
619 """
620 with pytest.raises(
621 TypeError, match="Do not specify a root path with memory storage"
622 ):
623 FunkyCache(storage="memory", root=tmp_path / ".cache") # type: ignore
625 def test_warns_on_missing_return_signature(self, tmp_path: Path) -> None:
626 """
627 A function cached in memory warns if it doesn't have
628 a return signature.
629 """
630 cache = FunkyCache(storage="memory")
632 with pytest.warns(match="has no return annotation"):
634 @cache.register
635 def double(n: int): # pragma: no cover
636 return n * 2
638 def test_errors_on_missing_iterator_annotation(self, tmp_path: Path) -> None:
639 """
640 A generator cached in memory warns if a generator doesn't specify
641 the generated type.
642 """
643 cache = FunkyCache(storage="memory")
645 with pytest.warns(match="generator with an incomplete return annotation"):
647 @cache.register
648 def numbers(n: int) -> Iterator: # pragma: no cover
649 yield from itertools.count(n)
652class TestSQLiteFunkyCache(FunkyCacheTestSuite):
653 """
654 Tests for a FunkyCache which is backed by SQLite storage.
655 """
657 @pytest.fixture
658 def cache(self, tmp_path: Path) -> Iterator[FunkyCache]:
659 """
660 Return an empty instance of FunkyCache.
661 """
662 yield FunkyCache(storage="sqlite", root=tmp_path / ".cache")
664 def test_stale_caches_are_removed(self, tmp_path: Path) -> None:
665 """
666 If the cache for a function is invalidated, the old SQLite
667 database gets cleaned up.
668 """
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:
674 return n * 2
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:
694 """
695 The cache depends on the source file of the cached function.
696 """
697 p = tmp_path / "example.py"
699 def run():
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
705 p.write_text(
706 textwrap.dedent("""
707 from funkycache import FunkyCache
709 GREETING = "hello world"
711 cache = FunkyCache(storage="sqlite", root=".cache")
713 @cache.register
714 def greet() -> str:
715 print("calling original function")
716 return GREETING
718 print(greet())
719""")
720 )
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:
733 """
734 Data can be persisted to an SQLite cache on-disk.
735 """
736 # Create a new cache, and populate it with some data
737 cache1 = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
739 @cache1.register
740 def initial(s: str) -> str:
741 return s[0].upper()
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")
752 @cache2.register
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:
759 """
760 A function cached in SQLite must have a return signature.
761 """
762 cache = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
764 with pytest.raises(TypeError, match="has no return annotation"):
766 @cache.register
767 def double(n: int): # pragma: no cover
768 return n * 2
770 def test_errors_on_missing_iterator_annotation(self, tmp_path: Path) -> None:
771 """
772 A generator cached in SQLite must specify the generated type.
773 """
774 cache = FunkyCache(storage="sqlite", root=tmp_path / ".cache")
776 with pytest.raises(
777 TypeError, match="generator with an incomplete return annotation"
778 ):
780 @cache.register
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(
787 "depends_on",
788 [
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"),
792 ],
793 )
794 def test_function_cannot_depend_on_cache_root(
795 self,
796 tmp_path: Path,
797 modify_root: PathModifier,
798 modify_depends_on: PathModifier,
799 depends_on: PathModifier,
800 ) -> None:
801 """
802 A function cannot declare a dependency on the cache root or
803 any of the files inside it.
804 """
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))]
812 with pytest.raises(
813 ValueError, match="cannot depend on a file/folder inside the cache root"
814 ):
816 @cache.register(files=files)
817 def double(n: int) -> int: # pragma: no cover
818 return n * 2
821def test_canonical_name_varies_by_path(tmp_path: Path) -> None:
822 """
823 The canonical name of functions in the top-level of a script
824 varies by path.
826 For these functions, func.__module__ is __main__, so we need a way
827 to distinguish them to avoid cache collisions.
828 """
829 (tmp_path / "s1.py").write_text(
830 textwrap.dedent("""
831 from funkycache.manager import _canonical_name
833 def greet(name: str) -> str:
834 return f"Hello {name}!"
836 print(_canonical_name(greet))
837 """)
838 )
840 (tmp_path / "s2.py").write_text(
841 textwrap.dedent("""
842 from funkycache.manager import _canonical_name
844 def greet(name: str) -> str:
845 return f"Hello {name}!"
847 print(_canonical_name(greet))
848 """)
849 )
851 name1 = subprocess.check_output(["python3", (tmp_path / "s1.py")])
852 name2 = subprocess.check_output(["python3", (tmp_path / "s2.py")])
854 assert name1 != name2