Coverage for sygaldry / cache.py: 87%

84 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 19:06 -0400

1""" 

2Instance cache with hash conflict detection. 

3""" 

4 

5from __future__ import annotations 

6 

7__author__ = "Rohan B. Dalton" 

8 

9import hashlib 

10import json 

11from collections.abc import Callable 

12from dataclasses import dataclass 

13from pathlib import Path 

14from typing import Any, Optional 

15 

16from .errors import ConfigConflictError 

17 

18_NOT_FOUND = object() 

19 

20 

21def _normalize_for_hash(value: Any, *, _memo: dict[int, str] | None = None) -> Any: 

22 """ 

23 Normalize values into JSON-serializable structures for hashing. 

24 

25 :param value: Value to normalize. 

26 :type value: object 

27 :returns: JSON-serializable structure. 

28 :rtype: object 

29 """ 

30 _memo = _memo or dict() 

31 

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

46 

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

56 

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} 

77 

78 

79def _canonical_hash(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: 

80 """ 

81 Compute a canonical hash for args/kwargs. 

82 

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

94 

95 

96@dataclass(frozen=True) 

97class CacheEntry: 

98 """ 

99 Cached instance entry with hash for conflict detection. 

100 """ 

101 

102 value: Any 

103 spec_hash: str 

104 

105 

106class Instances: 

107 """ 

108 Per-factory cache keyed by (type_path, instance tag or spec hash). 

109 """ 

110 

111 def __init__(self) -> None: 

112 """ 

113 Initialize an empty cache. 

114 """ 

115 self._entries: dict[tuple[str, str | None, str | None], CacheEntry] = dict() 

116 

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 

126 

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. 

138 

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 

170 

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. 

183 

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 

210 

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. 

225 

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

247 

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 

258 

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 ) 

268 

269 

270if __name__ == "__main__": 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 pass