manager: ensure script functions get unique names
- ID
34264eb- date
2026-11-07 06:37:08+00:00- author
Alex Chan <alexc@tailscale.com>- parent
1ed59b4- message
manager: ensure script functions get unique names This avoids collisions where two functions at the top level of scripts have the same name, and reuse the same underlying database because they both get a canonical name of `__main__`.- changed files
2 files, 64 additions, 4 deletions
Changed files
src/funkycache/manager.py (12102 → 12972)
diff --git a/src/funkycache/manager.py b/src/funkycache/manager.py
index 30dd868..b8f8ae3 100644
--- a/src/funkycache/manager.py
+++ b/src/funkycache/manager.py
@@ -151,7 +151,7 @@ class FunkyCache:
#
# We must do this before creating the storage, or we could have
# a dangling, unclosed storage hanging around.
- func_name = _canonical_spec(func)
+ func_name = _canonical_name(func)
if func_name in self._known_caches:
raise TypeError(f"Duplicate cache for {func_name}")
@@ -302,7 +302,7 @@ def _return_type(func: F, storage: StorageType) -> Any:
return sig.return_annotation
-def _canonical_spec(func: F) -> str:
+def _canonical_name(func: F) -> str:
"""
Return a canonical identifier for this function.
@@ -310,10 +310,34 @@ def _canonical_spec(func: F) -> str:
and functions with the same name but in different files/classes
should get a different identifier.
"""
+ parts = []
+
+ # Get the name of the module where this function is defined.
+ # For example, _canonical_name.__module__ is `funkycache.manager`.
+ #
+ # If this is unavailable, __module__ is None.
module = getattr(func, "__module__", "__main__")
- qualname = getattr(func, "__qualname__", func.__name__)
+ parts.append(module or "<unknown>")
+
+ # If the function is in a script, __module__ is `__main__`, so
+ # include the XXH64 of the path to prevent collisions between
+ # identically-named functions in different script files.
+ #
+ # This code is tested, but coverage can't see it.
+ if module == "__main__": # pragma: no cover
+ parts.append(
+ xxhash.xxh64_hexdigest(
+ (inspect.getsourcefile(func) or "<unknown>").encode("utf8")
+ )
+ )
+
+ # Get the qualified name of the function.
+ #
+ # This is the "path" from a module's global scope to the function
+ # or method, which includes classes.
+ parts.append(getattr(func, "__qualname__", func.__name__))
- return f"{module}::{qualname}"
+ return "::".join(parts)
def _dep_paths(func: F, files: list[Path]) -> set[Path]:
tests/test_manager.py (23367 → 24324)
diff --git a/tests/test_manager.py b/tests/test_manager.py
index ef81a41..af49202 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -720,3 +720,39 @@ class TestSQLiteFunkyCache(FunkyCacheTestSuite):
@cache.register(files=files)
def double(n: int) -> int: # pragma: no cover
return n * 2
+
+
+def test_canonical_name_varies_by_path(tmp_path: Path) -> None:
+ """
+ The canonical name of functions in the top-level of a script
+ varies by path.
+
+ For these functions, func.__module__ is __main__, so we need a way
+ to distinguish them to avoid cache collisions.
+ """
+ (tmp_path / "s1.py").write_text(
+ textwrap.dedent("""
+ from funkycache.manager import _canonical_name
+
+ def greet(name: str) -> str:
+ return f"Hello {name}!"
+
+ print(_canonical_name(greet))
+ """)
+ )
+
+ (tmp_path / "s2.py").write_text(
+ textwrap.dedent("""
+ from funkycache.manager import _canonical_name
+
+ def greet(name: str) -> str:
+ return f"Hello {name}!"
+
+ print(_canonical_name(greet))
+ """)
+ )
+
+ name1 = subprocess.check_output(["python3", (tmp_path / "s1.py")])
+ name2 = subprocess.check_output(["python3", (tmp_path / "s2.py")])
+
+ assert name1 != name2