Coverage for sygaldry / cache.py: 87%
84 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-10 13:02 -0400
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-10 13:02 -0400
1"""
2Instance cache with hash conflict detection.
3"""
5from __future__ import annotations
7__author__ = "Rohan B. Dalton"
9import hashlib
10import json
11from collections.abc import Callable
12from dataclasses import dataclass
13from pathlib import Path
14from typing import Any, Optional
16from .errors import ConfigConflictError
18_NOT_FOUND = object()
21def _normalize_for_hash(value: Any, *, _memo: dict[int, str] | None = None) -> Any:
22 """
23 Normalize values into JSON-serializable structures for hashing.
25 :param value: Value to normalize.
26 :type value: object
27 :returns: JSON-serializable structure.
28 :rtype: object
29 """
30 _memo = _memo or dict()
32 if value is None or isinstance(value, (str, int, float, bool)):
33 return value
34 elif isinstance(value, Path): 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 return {"__path__": str(value)}
36 elif isinstance(value, bytes): 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 return {"__bytes__": value.hex()}
38 elif isinstance(value, (list, tuple)):
39 return [_normalize_for_hash(item, _memo=_memo) for item in value]
40 elif isinstance(value, dict):
41 entries = list()
42 for key, val in value.items():
43 normalized_key = _normalize_for_hash(key, _memo=_memo)
44 normalized_value = _normalize_for_hash(val, _memo=_memo)
45 entries.append((normalized_key, normalized_value))
47 entries.sort(
48 key=lambda pair: json.dumps(
49 pair[0],
50 sort_keys=True,
51 separators=(",", ":"),
52 ensure_ascii=True,
53 )
54 )
55 return {"__dict__": [[key, value] for key, value in entries]}
57 elif isinstance(value, set): 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 items = [_normalize_for_hash(item, _memo=_memo) for item in value]
59 items.sort(
60 key=lambda item: json.dumps(
61 item,
62 sort_keys=True,
63 separators=(",", ":"),
64 ensure_ascii=True,
65 )
66 )
67 return {"__set__": items}
68 else:
69 object_id = id(value)
70 token = _memo.get(object_id)
71 if token is None: 71 ↛ 76line 71 didn't jump to line 76 because the condition on line 71 was always true
72 module = value.__class__.__module__
73 name = value.__class__.__qualname__
74 token = f"{module}.{name}@{object_id}"
75 _memo[object_id] = token
76 return {"__object__": token}
79def _canonical_hash(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
80 """
81 Compute a canonical hash for args/kwargs.
83 :param args: Positional arguments for constructor.
84 :param kwargs: Keyword arguments for constructor.
85 :type args: tuple[Any, ...]
86 :type kwargs: dict[str, Any]
87 :returns: SHA-256 hash of canonical JSON payload.
88 :rtype: str
89 :raises ConfigConflictError: If the payload is not JSON-serializable.
90 """
91 payload = _normalize_for_hash({"args": args, "kwargs": kwargs})
92 encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
93 return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
96@dataclass(frozen=True)
97class CacheEntry:
98 """
99 Cached instance entry with hash for conflict detection.
100 """
102 value: Any
103 spec_hash: str
106class Instances:
107 """
108 Per-factory cache keyed by (type_path, instance tag or spec hash).
109 """
111 def __init__(self) -> None:
112 """
113 Initialize an empty cache.
114 """
115 self._entries: dict[tuple[str, str | None, str | None], CacheEntry] = dict()
117 @staticmethod
118 def _cache_key(
119 type_path: str,
120 instance: str | None,
121 spec_hash: str | None,
122 ) -> tuple[str, str | None, str | None]:
123 if instance is None:
124 return type_path, None, spec_hash
125 return type_path, instance, None
127 def get(
128 self,
129 type_path: str,
130 instance: str | None,
131 *,
132 expected_hash: str | None = None,
133 file_path: str | None = None,
134 config_path: str | None = None,
135 ) -> Any:
136 """
137 Return a cached instance if present.
139 :param type_path: Dotted type path used as cache key.
140 :param instance: Optional instance tag.
141 :param expected_hash: Optional hash to validate cache entry.
142 :param file_path: Source config file path, if known.
143 :param config_path: Dotted config path for context.
144 :type type_path: str
145 :type instance: str | None
146 :type expected_hash: str | None
147 :type file_path: str | None
148 :type config_path: str | None
149 :returns: Cached instance if found, or ``_NOT_FOUND`` sentinel.
150 :rtype: object
151 :raises ConfigConflictError: If the cached hash conflicts.
152 """
153 if instance is None and expected_hash is None: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 return _NOT_FOUND
155 key = self._cache_key(type_path, instance, expected_hash)
156 entry = self._entries.get(key)
157 if entry is None:
158 return _NOT_FOUND
159 if (
160 instance is not None
161 and expected_hash is not None
162 and entry.spec_hash != expected_hash
163 ):
164 raise ConfigConflictError(
165 f"Cache conflict for '{type_path}' with instance '{instance}'.",
166 file_path=file_path,
167 config_path=config_path,
168 )
169 return entry.value
171 def set(
172 self,
173 type_path: str,
174 instance: str | None,
175 value: Any,
176 *,
177 spec_hash: str,
178 file_path: str | None = None,
179 config_path: str | None = None,
180 ) -> Any:
181 """
182 Insert or validate a cache entry.
184 :param type_path: Dotted type path used as cache key.
185 :param instance: Optional instance tag.
186 :param value: Resolved object instance.
187 :param spec_hash: Hash for the constructor spec.
188 :param file_path: Source config file path, if known.
189 :param config_path: Dotted config path for context.
190 :type type_path: str
191 :type instance: str | None
192 :type value: object
193 :type spec_hash: str
194 :type file_path: str | None
195 :type config_path: str | None
196 :returns: The inserted instance.
197 :rtype: object
198 :raises ConfigConflictError: If an existing entry conflicts.
199 """
200 key = self._cache_key(type_path, instance, spec_hash)
201 entry = self._entries.get(key)
202 if instance is not None and entry is not None and entry.spec_hash != spec_hash: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 raise ConfigConflictError(
204 f"Cache conflict for '{type_path}' with instance '{instance}'.",
205 file_path=file_path,
206 config_path=config_path,
207 )
208 self._entries[key] = CacheEntry(value=value, spec_hash=spec_hash)
209 return value
211 def get_or_create(
212 self,
213 type_path: str,
214 instance: str | None,
215 args: tuple[Any, ...],
216 kwargs: dict[str, Any],
217 factory: Callable[[], Any],
218 *,
219 transient: bool = False,
220 file_path: str | None = None,
221 config_path: str | None = None,
222 ) -> Any:
223 """
224 Get or create a cached instance.
226 :param type_path: Dotted type path used as cache key.
227 :param instance: Optional instance tag.
228 :param args: Resolved positional args.
229 :param kwargs: Resolved keyword args.
230 :param factory: Callable that constructs the instance.
231 :param transient: If True, bypass caching.
232 :param file_path: Source config file path, if known.
233 :param config_path: Dotted config path for context.
234 :type type_path: str
235 :type instance: str | None
236 :type args: tuple[Any, ...]
237 :type kwargs: dict[str, Any]
238 :type factory: collections.abc.Callable[[], Any]
239 :type transient: bool
240 :type file_path: str | None
241 :type config_path: str | None
242 :returns: Cached or newly created instance.
243 :rtype: object
244 """
245 if transient:
246 return factory()
248 spec_hash = _canonical_hash(args, kwargs)
249 existing = self.get(
250 type_path,
251 instance,
252 expected_hash=spec_hash,
253 file_path=file_path,
254 config_path=config_path,
255 )
256 if existing is not _NOT_FOUND:
257 return existing
259 value = factory()
260 return self.set(
261 type_path,
262 instance,
263 value,
264 spec_hash=spec_hash,
265 file_path=file_path,
266 config_path=config_path,
267 )
270if __name__ == "__main__": 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 pass