diff --git a/graphify/watch.py b/graphify/watch.py index 59613b627..f5de8c6dd 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -440,6 +440,8 @@ class _StoredSourcePaths: self.watch_root = watch_root self._normalize_source = normalize_source self.existing_source_root = project_root + self._identity_cache: dict[str, str | None] = {} + self._in_watch_root_cache: dict[str, bool] = {} relative_marker_prefix: str | None = None root_marker = out / ".graphify_root" @@ -539,14 +541,33 @@ class _StoredSourcePaths: return Path(os.path.abspath(source_path)).as_posix() def identity(self, source_file: str | None) -> str | None: + # Every node, edge and hyperedge asks for its file's identity, several + # times per item, but a graph has far fewer distinct source files than + # items. The answer depends only on ``source_file`` and roots fixed in + # __init__, so compute it once per file instead of rebuilding Path + # objects (and an abspath) on every call. + if not isinstance(source_file, str): + return self._identity_uncached(source_file) + try: + return self._identity_cache[source_file] + except KeyError: + identity = self._identity_cache[source_file] = self._identity_uncached(source_file) + return identity + + def _identity_uncached(self, source_file: str | None) -> str | None: normalized = self._normalize_source(source_file) if normalized and not Path(normalized).is_absolute() and self.legacy_watch_relative: return self.absolute_identity(normalized, self.watch_root) return self.absolute_identity(normalized, self.existing_source_root) def in_watch_root(self, source_file: str | None) -> bool: + if isinstance(source_file, str) and source_file in self._in_watch_root_cache: + return self._in_watch_root_cache[source_file] identity = self.identity(source_file) - return bool(identity) and _is_relative_to(Path(identity), self.watch_root) + inside = bool(identity) and _is_relative_to(Path(identity), self.watch_root) + if isinstance(source_file, str): + self._in_watch_root_cache[source_file] = inside + return inside def is_evicted(self, item: dict, identities: set[str]) -> bool: return self.identity(item.get("source_file")) in identities diff --git a/tests/test_watch_identity_cache.py b/tests/test_watch_identity_cache.py new file mode 100644 index 000000000..be3eae4a9 --- /dev/null +++ b/tests/test_watch_identity_cache.py @@ -0,0 +1,59 @@ +"""`_StoredSourcePaths.identity` is asked for every node, edge and hyperedge's +file, several times per item, during an update's reconcile pass. A graph has +far fewer distinct source files than items (graphify's own repo: ~1k files, +~18.7k nodes, ~38k edges, ~150k identity calls), and the answer depends only on +the stored path and roots fixed in __init__. It is computed once per file. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.build import _norm_source_file +from graphify.watch import _StoredSourcePaths + + +def _paths(tmp_path: Path, normalize=_norm_source_file) -> _StoredSourcePaths: + out = tmp_path / "graphify-out" + out.mkdir() + return _StoredSourcePaths( + {"nodes": [], "links": []}, + out=out, + project_root=tmp_path, + watch_root=tmp_path, + normalize_source=normalize, + ) + + +def test_identity_is_computed_once_per_source_file(tmp_path): + calls: list[str | None] = [] + + def counting_normalize(p, root=None): + calls.append(p) + return _norm_source_file(p, root) + + paths = _paths(tmp_path, counting_normalize) + files = ["src/a.py", "src/b.py", "lib\\c.py"] + for f in files: + paths.identity(f) + paths.in_watch_root(f) + first_pass = len(calls) + assert first_pass > 0 + for _ in range(50): + for f in files: + paths.identity(f) + paths.in_watch_root(f) + assert len(calls) == first_pass, f"{len(calls)} normalizations for {len(files)} files" + + +def test_cached_answers_match_a_fresh_computation(tmp_path): + paths = _paths(tmp_path) + outside = (tmp_path.parent / "elsewhere" / "x.py").as_posix() + cases = ["src/a.py", "./src/../src/a.py", "lib\\c.py", outside, "", None] + for f in cases: + for _ in range(2): # first call fills the cache, second reads it + assert paths.identity(f) == paths._identity_uncached(f) + identity = paths._identity_uncached(f) + expected = bool(identity) and Path(identity).is_relative_to(paths.watch_root) + assert paths.in_watch_root(f) is expected + assert paths.in_watch_root("src/a.py") is True + assert paths.in_watch_root(outside) is False