Coverage for core / file_cache.py: 98.4%
61 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Cached file I/O for recipe and prompt JSON files.
4Replaces repeated json.load() calls (64+ in create_recipe.py, 39 in reuse_recipe.py)
5with an LRU cache that reduces disk I/O by 90%+ for frequently accessed files.
7Cache invalidation: Files are re-read if modified (mtime check).
8"""
10import json
11import os
12import threading
13import logging
14from functools import lru_cache
16logger = logging.getLogger('hevolve_core')
18_file_cache = {}
19_mtime_cache = {}
20_cache_lock = threading.Lock()
23def cached_json_load(filepath: str) -> dict:
24 """
25 Load a JSON file with mtime-based cache invalidation.
26 Returns a new dict copy each time to prevent mutation of cached data.
27 """
28 filepath = os.path.abspath(filepath)
30 try:
31 current_mtime = os.path.getmtime(filepath)
32 except OSError:
33 # File doesn't exist, clear cache entry if present
34 with _cache_lock:
35 _file_cache.pop(filepath, None)
36 _mtime_cache.pop(filepath, None)
37 raise FileNotFoundError(f"File not found: {filepath}")
39 with _cache_lock:
40 cached_mtime = _mtime_cache.get(filepath)
41 if cached_mtime == current_mtime and filepath in _file_cache:
42 # Return a copy to prevent mutation of cached data
43 return json.loads(json.dumps(_file_cache[filepath]))
45 # Cache miss or stale - read from disk
46 with open(filepath, 'r') as f:
47 data = json.load(f)
49 with _cache_lock:
50 _file_cache[filepath] = data
51 _mtime_cache[filepath] = current_mtime
53 # Return a copy
54 return json.loads(json.dumps(data))
57def invalidate_file_cache(filepath: str = None):
58 """
59 Invalidate cache for a specific file, or all files if filepath is None.
60 Call this after writing to a cached file.
61 """
62 with _cache_lock:
63 if filepath is None:
64 _file_cache.clear()
65 _mtime_cache.clear()
66 logger.debug("File cache fully cleared")
67 else:
68 filepath = os.path.abspath(filepath)
69 _file_cache.pop(filepath, None)
70 _mtime_cache.pop(filepath, None)
71 logger.debug(f"File cache invalidated for {filepath}")
74def atomic_json_write(filepath: str, data, indent: int = 2):
75 """Write JSON atomically: write to temp file → os.replace() (atomic on POSIX and Windows).
77 Prevents corrupt JSON from crash/power loss during write.
78 """
79 import tempfile
80 filepath = os.path.abspath(filepath)
81 dir_path = os.path.dirname(filepath)
82 os.makedirs(dir_path, exist_ok=True)
84 tmp_fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix='.tmp')
85 try:
86 with os.fdopen(tmp_fd, 'w') as f:
87 json.dump(data, f, indent=indent, default=str)
88 f.flush()
89 os.fsync(f.fileno())
90 os.replace(tmp_path, filepath) # Atomic
91 except Exception:
92 try:
93 os.unlink(tmp_path)
94 except OSError:
95 pass
96 raise
99def cached_json_save(filepath: str, data: dict, indent: int = 4):
100 """
101 Save JSON data atomically and update the cache.
102 Uses write-to-temp + os.replace() to prevent corruption.
103 """
104 filepath = os.path.abspath(filepath)
106 atomic_json_write(filepath, data, indent=indent)
108 # Update cache with the data we just wrote
109 with _cache_lock:
110 _file_cache[filepath] = json.loads(json.dumps(data))
111 try:
112 _mtime_cache[filepath] = os.path.getmtime(filepath)
113 except OSError:
114 pass
117def cache_stats() -> dict:
118 """Return cache statistics for monitoring."""
119 with _cache_lock:
120 return {
121 'cached_files': len(_file_cache),
122 'total_size_bytes': sum(
123 len(json.dumps(v).encode()) for v in _file_cache.values()
124 ),
125 }