Skip to main content

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