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

1""" 

2Cached file I/O for recipe and prompt JSON files. 

3 

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. 

6 

7Cache invalidation: Files are re-read if modified (mtime check). 

8""" 

9 

10import json 

11import os 

12import threading 

13import logging 

14from functools import lru_cache 

15 

16logger = logging.getLogger('hevolve_core') 

17 

18_file_cache = {} 

19_mtime_cache = {} 

20_cache_lock = threading.Lock() 

21 

22 

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) 

29 

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}") 

38 

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])) 

44 

45 # Cache miss or stale - read from disk 

46 with open(filepath, 'r') as f: 

47 data = json.load(f) 

48 

49 with _cache_lock: 

50 _file_cache[filepath] = data 

51 _mtime_cache[filepath] = current_mtime 

52 

53 # Return a copy 

54 return json.loads(json.dumps(data)) 

55 

56 

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}") 

72 

73 

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). 

76 

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) 

83 

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 

97 

98 

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) 

105 

106 atomic_json_write(filepath, data, indent=indent) 

107 

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 

115 

116 

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 }