Coverage for src / metaclass_registry / cache.py: 96%
118 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-10 22:33 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-10 22:33 +0000
1"""
2Generic caching system for plugin registries.
4Provides unified caching for both function registries (Pattern B) and
5metaclass registries (Pattern A), eliminating code duplication and
6ensuring consistent cache behavior across the codebase.
8Architecture:
9- RegistryCacheManager: Generic cache manager for any registry type
10- Supports version validation, age-based invalidation, mtime checking
11- JSON-based serialization with custom serializers/deserializers
12- XDG-compliant cache locations
14Usage:
15 # For function registries
16 cache_mgr = RegistryCacheManager(
17 cache_name="scikit_image_functions",
18 version_getter=lambda: skimage.__version__,
19 serializer=serialize_function_metadata,
20 deserializer=deserialize_function_metadata
21 )
23 # For metaclass registries
24 cache_mgr = RegistryCacheManager(
25 cache_name="microscope_handlers",
26 version_getter=lambda: openhcs.__version__,
27 serializer=serialize_plugin_class,
28 deserializer=deserialize_plugin_class
29 )
30"""
32import json
33import logging
34import time
35from pathlib import Path
36from typing import Dict, Any, Optional, Callable, TypeVar, Generic
37from dataclasses import dataclass
39logger = logging.getLogger(__name__)
42def get_cache_file_path(cache_name: str) -> Path:
43 """
44 Get XDG-compliant cache file path.
46 Args:
47 cache_name: Name of the cache file
49 Returns:
50 Path to cache file in XDG cache directory
51 """
52 from . import _home
54 # Use XDG_CACHE_HOME if set, otherwise default to ~/.cache
55 import os
56 cache_home = os.environ.get('XDG_CACHE_HOME')
57 if not cache_home:
58 cache_home = Path(_home.get_home_dir()) / '.cache'
59 else:
60 cache_home = Path(cache_home)
62 # Create metaclass-registry subdirectory
63 cache_dir = cache_home / 'metaclass-registry'
64 cache_dir.mkdir(parents=True, exist_ok=True)
66 return cache_dir / cache_name
68T = TypeVar('T') # Generic type for cached items
71@dataclass
72class CacheConfig:
73 """Configuration for registry caching behavior."""
74 max_age_days: int = 7 # Maximum cache age before invalidation
75 check_mtimes: bool = False # Check file modification times
76 cache_version: str = "1.0" # Cache format version
79class RegistryCacheManager(Generic[T]):
80 """
81 Generic cache manager for plugin registries.
83 Handles caching, validation, and reconstruction of registry data
84 with support for version checking, age-based invalidation, and
85 custom serialization.
87 Type Parameters:
88 T: Type of items being cached (e.g., FunctionMetadata, Type[Plugin])
89 """
91 def __init__(
92 self,
93 cache_name: str,
94 version_getter: Callable[[], str],
95 serializer: Callable[[T], Dict[str, Any]],
96 deserializer: Callable[[Dict[str, Any]], T],
97 config: Optional[CacheConfig] = None
98 ):
99 """
100 Initialize cache manager.
102 Args:
103 cache_name: Name for the cache file (e.g., "microscope_handlers")
104 version_getter: Function that returns current version string
105 serializer: Function to serialize item to JSON-compatible dict
106 deserializer: Function to deserialize dict back to item
107 config: Optional cache configuration
108 """
109 self.cache_name = cache_name
110 self.version_getter = version_getter
111 self.serializer = serializer
112 self.deserializer = deserializer
113 self.config = config or CacheConfig()
114 self._cache_path = get_cache_file_path(f"{cache_name}.json")
116 def load_cache(self) -> Optional[Dict[str, T]]:
117 """
118 Load cached items with validation.
120 Returns:
121 Dictionary of cached items, or None if cache is invalid
122 """
123 if not self._cache_path.exists():
124 logger.debug(f"No cache found for {self.cache_name}")
125 return None
127 try:
128 with open(self._cache_path, 'r') as f:
129 cache_data = json.load(f)
130 except json.JSONDecodeError:
131 logger.warning(f"Corrupt cache file {self._cache_path}, rebuilding")
132 self._cache_path.unlink(missing_ok=True)
133 return None
135 # Validate cache version
136 if cache_data.get('cache_version') != self.config.cache_version:
137 logger.debug(f"Cache version mismatch for {self.cache_name}")
138 return None
140 # Validate library/package version
141 cached_version = cache_data.get('version', 'unknown')
142 current_version = self.version_getter()
143 if cached_version != current_version:
144 logger.info(
145 f"{self.cache_name} version changed "
146 f"({cached_version} → {current_version}) - cache invalid"
147 )
148 return None
150 # Validate cache age
151 cache_timestamp = cache_data.get('timestamp', 0)
152 cache_age_days = (time.time() - cache_timestamp) / (24 * 3600)
153 if cache_age_days > self.config.max_age_days:
154 logger.debug(
155 f"Cache for {self.cache_name} is {cache_age_days:.1f} days old - rebuilding"
156 )
157 return None
159 # Validate file mtimes if configured
160 if self.config.check_mtimes and 'file_mtimes' in cache_data:
161 if not self._validate_mtimes(cache_data['file_mtimes']):
162 logger.debug(f"File modifications detected for {self.cache_name}")
163 return None
165 # Deserialize items
166 items = {}
167 for key, item_data in cache_data.get('items', {}).items():
168 try:
169 items[key] = self.deserializer(item_data)
170 except Exception as e:
171 logger.warning(f"Failed to deserialize {key} from cache: {e}")
172 return None # Invalidate entire cache on any deserialization error
174 logger.info(f"✅ Loaded {len(items)} items from {self.cache_name} cache")
175 return items
177 def save_cache(
178 self,
179 items: Dict[str, T],
180 file_mtimes: Optional[Dict[str, float]] = None
181 ) -> None:
182 """
183 Save items to cache.
185 Args:
186 items: Dictionary of items to cache
187 file_mtimes: Optional dict of file paths to modification times
188 """
189 cache_data = {
190 'cache_version': self.config.cache_version,
191 'version': self.version_getter(),
192 'timestamp': time.time(),
193 'items': {}
194 }
196 # Add file mtimes if provided
197 if file_mtimes:
198 cache_data['file_mtimes'] = file_mtimes
200 # Serialize items
201 for key, item in items.items():
202 try:
203 cache_data['items'][key] = self.serializer(item)
204 except Exception as e:
205 logger.warning(f"Failed to serialize {key} for cache: {e}")
207 # Save to disk
208 try:
209 self._cache_path.parent.mkdir(parents=True, exist_ok=True)
210 with open(self._cache_path, 'w') as f:
211 json.dump(cache_data, f, indent=2)
212 logger.info(f"💾 Saved {len(items)} items to {self.cache_name} cache")
213 except Exception as e:
214 logger.warning(f"Failed to save {self.cache_name} cache: {e}")
216 def clear_cache(self) -> None:
217 """Clear the cache file."""
218 if self._cache_path.exists():
219 self._cache_path.unlink()
220 logger.info(f"🧹 Cleared {self.cache_name} cache")
222 def _validate_mtimes(self, cached_mtimes: Dict[str, float]) -> bool:
223 """
224 Validate that file modification times haven't changed.
226 Args:
227 cached_mtimes: Dictionary of file paths to cached mtimes
229 Returns:
230 True if all mtimes match, False if any file changed
231 """
232 for file_path, cached_mtime in cached_mtimes.items():
233 path = Path(file_path)
234 if not path.exists():
235 return False # File was deleted
237 current_mtime = path.stat().st_mtime
238 if abs(current_mtime - cached_mtime) > 1.0: # 1 second tolerance
239 return False # File was modified
241 return True
244# Serializers for metaclass registries (Pattern A)
246def serialize_plugin_class(plugin_class: type) -> Dict[str, Any]:
247 """
248 Serialize a plugin class to JSON-compatible dict.
250 Args:
251 plugin_class: Plugin class to serialize
253 Returns:
254 Dictionary with module and class name
255 """
256 return {
257 'module': plugin_class.__module__,
258 'class_name': plugin_class.__name__,
259 'qualname': plugin_class.__qualname__
260 }
263def deserialize_plugin_class(data: Dict[str, Any]) -> type:
264 """
265 Deserialize a plugin class from JSON-compatible dict.
267 Args:
268 data: Dictionary with module and class name
270 Returns:
271 Reconstructed plugin class
273 Raises:
274 ImportError: If module cannot be imported
275 AttributeError: If class not found in module
276 """
277 import importlib
279 module = importlib.import_module(data['module'])
280 plugin_class = getattr(module, data['class_name'])
281 return plugin_class
284def get_package_file_mtimes(package_path: str) -> Dict[str, float]:
285 """
286 Get modification times for all Python files in a package.
288 Args:
289 package_path: Package path (e.g., "openhcs.microscopes")
291 Returns:
292 Dictionary mapping file paths to modification times
293 """
294 import importlib
295 from pathlib import Path
297 try:
298 pkg = importlib.import_module(package_path)
299 pkg_dir = Path(pkg.__file__).parent
301 mtimes = {}
302 for py_file in pkg_dir.rglob("*.py"):
303 if not py_file.name.startswith('_'): # Skip __pycache__, etc.
304 mtimes[str(py_file)] = py_file.stat().st_mtime
306 return mtimes
307 except Exception as e:
308 logger.warning(f"Failed to get mtimes for {package_path}: {e}")
309 return {}