diff --git a/src/finchlite/util/__init__.py b/src/finchlite/util/__init__.py index 34affe5d0..e64e173bc 100644 --- a/src/finchlite/util/__init__.py +++ b/src/finchlite/util/__init__.py @@ -1,5 +1,7 @@ -from .cache import file_cache +# AI modified: 2025-01-01T00:00:00Z parent=4f5a2e5021678965ce8d830bb9edecac1dd3fea9 +# AI modified: 2025-01-01T00:01:00Z parent=4f5a2e5021678965ce8d830bb9edecac1dd3fea9 +from .cache import clear_cache, file_cache from .config import config, get_version from .print import qual_str -__all__ = ["config", "file_cache", "get_version", "qual_str"] +__all__ = ["clear_cache", "config", "file_cache", "get_version", "qual_str"] diff --git a/src/finchlite/util/cache.py b/src/finchlite/util/cache.py index aef449e8e..115fc8e72 100644 --- a/src/finchlite/util/cache.py +++ b/src/finchlite/util/cache.py @@ -1,3 +1,9 @@ +# AI modified: 2024-12-31T23:58:00Z parent=154b5aeaa66d01a2373296ba9af9705a3db73ed9 +# AI modified: 2024-12-31T23:59:00Z parent=06953a764918de34b3a35c1b698198c3b74c5890 +# AI modified: 2025-01-01T00:00:00Z parent=4f5a2e5021678965ce8d830bb9edecac1dd3fea9 +# AI modified: 2025-01-01T00:01:00Z parent=4f5a2e5021678965ce8d830bb9edecac1dd3fea9 +# AI modified: 2026-04-02T22:59:00Z parent=197d5a907823d2a53fcd3b68b674f3f4d4f50b5d +# AI modified: 2026-04-03T15:30:00Z parent=36276c257318d74488f81fa8107d2f2d0a8b804c import atexit import shutil import tempfile @@ -6,9 +12,76 @@ from pathlib import Path from uuid import UUID +import finchlite + from .config import config, get_version finch_uuid = UUID("ef66f312-ff6e-4b8a-bb8c-9a843f3ecdf4") +cache_timestamp_filename = ".finch_code_mtime_ns" +_finch_source_root = Path(finchlite.__path__[0]) + + +def _latest_finch_code_mtime_ns() -> int: + latest_mtime = 0 + for path in _finch_source_root.rglob("*"): + if ( + "__pycache__" not in path.parts + and path.is_file() + and path.suffix not in {".pyc", ".pyo"} + ): + latest_mtime = max(latest_mtime, path.stat().st_mtime_ns) + return latest_mtime + + +_session_finch_code_mtime_ns = _latest_finch_code_mtime_ns() +_cache_checked = False + + +def _clear_cache_root(cache_root: Path, *, keep_timestamp: bool = True) -> None: + for path in cache_root.iterdir(): + if keep_timestamp and path.name == cache_timestamp_filename: + continue + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + +def clear_cache() -> None: + """Clear Finch's persistent cache for the current Finch version. + + This removes all cached files for the active Finch version under + ``/cache/``. If the cache directory does not exist, + this function does nothing. + """ + + global _cache_checked + cache_root = Path(config.get("data_path")) / "cache" / get_version() + if cache_root.exists(): + _clear_cache_root(cache_root, keep_timestamp=False) + _cache_checked = False + + +def _ensure_cache_fresh(cache_root: Path) -> None: + cache_root.mkdir(parents=True, exist_ok=True) + timestamp_file = cache_root / cache_timestamp_filename + current_mtime = _session_finch_code_mtime_ns + should_clear = False + + if timestamp_file.exists(): + try: + cached_mtime = int(timestamp_file.read_text().strip()) + except ValueError: + should_clear = True + else: + should_clear = current_mtime > cached_mtime + else: + should_clear = True + + if should_clear: + _clear_cache_root(cache_root) + + timestamp_file.write_text(str(current_mtime)) def file_cache(*, ext: str, domain: str) -> Callable: @@ -28,7 +101,12 @@ def decorator(f: Callable) -> Callable: nonlocal ext ext = ext.lstrip(".") if config.get("cache_enable"): - cache_dir = Path(config.get("data_path")) / "cache" / get_version() / domain + global _cache_checked + cache_root = Path(config.get("data_path")) / "cache" / get_version() + if not _cache_checked: + _ensure_cache_fresh(cache_root) + _cache_checked = True + cache_dir = cache_root / domain else: cache_dir = Path( tempfile.mkdtemp( diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 000000000..9d39a8fe5 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,78 @@ +# AI modified: 2025-01-01T00:00:00Z parent=4f5a2e5021678965ce8d830bb9edecac1dd3fea9 +# AI modified: 2025-01-01T00:01:00Z parent=4f5a2e5021678965ce8d830bb9edecac1dd3fea9 +# AI modified: 2026-04-02T22:59:00Z parent=197d5a907823d2a53fcd3b68b674f3f4d4f50b5d +# AI modified: 2026-04-03T15:30:00Z parent=36276c257318d74488f81fa8107d2f2d0a8b804c +import os +import re +import subprocess +import sys +from pathlib import Path + +import finchlite + + +def _run_codegen_session( + repo_root: Path, + data_path: Path, + python_code: str, +): + env = os.environ.copy() + env["FINCHLITE_DATA_PATH"] = str(data_path) + return subprocess.run( + [sys.executable, "-c", python_code], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + check=True, + ) + + +def _restore_file_state(path: Path, *, contents: str, atime_ns: int, mtime_ns: int): + path.write_text(contents) + os.utime(path, ns=(atime_ns, mtime_ns)) + + +def test_c_codegen_cache_invalidation_end_to_end_across_sessions(tmp_path): + # finchlite.__path__[0] points to /src/finchlite, so parent.parent is repo root. + repo_root = Path(finchlite.__path__[0]).parent.parent + data_path = tmp_path / "finch_data" + c_codegen_file = repo_root / "src" / "finchlite" / "codegen" / "c_codegen.py" + stat_before = c_codegen_file.stat() + original_contents = c_codegen_file.read_text() + base_script = """ +import finchlite.codegen.c_codegen as ccg + +code = ''' +int unique_value() { + return 7; +} +''' +ccg.load_shared_lib.cache_clear() +lib = ccg.load_shared_lib(code) +print("RESULT", int(lib.unique_value())) +""" + + first = _run_codegen_session(repo_root, data_path, base_script) + first_result = int(first.stdout.strip().split("RESULT ", 1)[1]) + assert first_result == 7 + + try: + modified_contents = re.sub( + r"c_file_path\.write_text\(c_code\)", + 'c_file_path.write_text(c_code.replace("return 7;", "return 9;"))', + original_contents, + count=1, + ) + c_codegen_file.write_text(modified_contents) + + second = _run_codegen_session(repo_root, data_path, base_script) + second_result = int(second.stdout.strip().split("RESULT ", 1)[1]) + assert second_result == 9 + finally: + _restore_file_state( + c_codegen_file, + contents=original_contents, + atime_ns=stat_before.st_atime_ns, + mtime_ns=stat_before.st_mtime_ns, + )