dj-hyperview 0.1.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. dj_hyperview/__init__.py +61 -0
  2. dj_hyperview/apps.py +16 -0
  3. dj_hyperview/cache.py +592 -0
  4. dj_hyperview/checks.py +177 -0
  5. dj_hyperview/conf.py +95 -0
  6. dj_hyperview/contrib/__init__.py +1 -0
  7. dj_hyperview/contrib/database/__init__.py +1 -0
  8. dj_hyperview/contrib/database/_config.py +18 -0
  9. dj_hyperview/contrib/database/_invalidation.py +28 -0
  10. dj_hyperview/contrib/database/admin.py +303 -0
  11. dj_hyperview/contrib/database/apps.py +18 -0
  12. dj_hyperview/contrib/database/migrations/0001_initial.py +40 -0
  13. dj_hyperview/contrib/database/migrations/0002_field_validators.py +38 -0
  14. dj_hyperview/contrib/database/migrations/__init__.py +0 -0
  15. dj_hyperview/contrib/database/models.py +45 -0
  16. dj_hyperview/contrib/database/querysets.py +127 -0
  17. dj_hyperview/contrib/database/services.py +289 -0
  18. dj_hyperview/contrib/database/signals.py +123 -0
  19. dj_hyperview/contrib/database/sources.py +85 -0
  20. dj_hyperview/contrib/database/validators.py +52 -0
  21. dj_hyperview/engine.py +162 -0
  22. dj_hyperview/exceptions.py +78 -0
  23. dj_hyperview/http.py +88 -0
  24. dj_hyperview/loaders.py +152 -0
  25. dj_hyperview/middleware.py +107 -0
  26. dj_hyperview/resolver.py +291 -0
  27. dj_hyperview/sources/__init__.py +11 -0
  28. dj_hyperview/sources/base.py +64 -0
  29. dj_hyperview/sources/filesystem.py +62 -0
  30. dj_hyperview/templatetags/__init__.py +1 -0
  31. dj_hyperview/templatetags/dj_hyperview.py +24 -0
  32. dj_hyperview/validation.py +200 -0
  33. dj_hyperview/views.py +12 -0
  34. dj_hyperview-0.1.0a3.dist-info/METADATA +259 -0
  35. dj_hyperview-0.1.0a3.dist-info/RECORD +36 -0
  36. dj_hyperview-0.1.0a3.dist-info/WHEEL +4 -0
@@ -0,0 +1,61 @@
1
+ """Reusable Django infrastructure for Hyperview interfaces."""
2
+
3
+ from .cache import (
4
+ CACHE_MISS,
5
+ CacheEntry,
6
+ TemplateCache,
7
+ invalidate_templates,
8
+ template_cache_key,
9
+ )
10
+ from .engine import HyperviewEngine, render_template
11
+ from .exceptions import (
12
+ HyperviewConfigurationError,
13
+ HyperviewError,
14
+ InvalidTemplateName,
15
+ SourceUnavailable,
16
+ TemplateNotFound,
17
+ TemplateValidationError,
18
+ )
19
+ from .http import HYPERVIEW_MEDIA_TYPE, HyperviewResponse, HyperviewTemplateResponse
20
+ from .loaders import ResolverLoader
21
+ from .middleware import (
22
+ HYPERVIEW_VERSION_HEADER,
23
+ HyperviewMiddleware,
24
+ HyperviewRequestDetails,
25
+ detect_hyperview_request,
26
+ )
27
+ from .resolver import TemplateResolver, resolve_template
28
+ from .sources import FileSystemSource, ResolvedTemplate, TemplateSource
29
+ from .validation import validate_hxml
30
+ from .views import HyperviewTemplateView
31
+
32
+ __all__ = [
33
+ "HYPERVIEW_MEDIA_TYPE",
34
+ "HYPERVIEW_VERSION_HEADER",
35
+ "CACHE_MISS",
36
+ "CacheEntry",
37
+ "FileSystemSource",
38
+ "HyperviewEngine",
39
+ "HyperviewConfigurationError",
40
+ "HyperviewError",
41
+ "HyperviewMiddleware",
42
+ "HyperviewRequestDetails",
43
+ "HyperviewResponse",
44
+ "HyperviewTemplateResponse",
45
+ "HyperviewTemplateView",
46
+ "InvalidTemplateName",
47
+ "ResolvedTemplate",
48
+ "ResolverLoader",
49
+ "SourceUnavailable",
50
+ "TemplateCache",
51
+ "TemplateNotFound",
52
+ "TemplateResolver",
53
+ "TemplateSource",
54
+ "TemplateValidationError",
55
+ "detect_hyperview_request",
56
+ "invalidate_templates",
57
+ "resolve_template",
58
+ "render_template",
59
+ "template_cache_key",
60
+ "validate_hxml",
61
+ ]
dj_hyperview/apps.py ADDED
@@ -0,0 +1,16 @@
1
+ """Django application configuration for the core package."""
2
+
3
+ from importlib import import_module
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class DjHyperviewConfig(AppConfig):
9
+ """Register the reusable core application with Django."""
10
+
11
+ name = "dj_hyperview"
12
+ verbose_name = "Hyperview"
13
+
14
+ def ready(self) -> None:
15
+ """Load package system checks when the app registry is ready."""
16
+ import_module("dj_hyperview.checks")
dj_hyperview/cache.py ADDED
@@ -0,0 +1,592 @@
1
+ """Typed raw-template caching through Django's cache framework."""
2
+
3
+ import hashlib
4
+ import json
5
+ import secrets
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import asdict, dataclass
8
+
9
+ from django.conf import settings
10
+ from django.core.cache import caches
11
+ from django.core.cache.backends.base import BaseCache
12
+
13
+ from .conf import get_settings
14
+ from .exceptions import SourceUnavailable
15
+ from .sources import ResolvedTemplate, canonicalize_template_name
16
+
17
+ _ABSENT = object()
18
+ _FAILURE = object()
19
+ _BACKEND_FAILURE = "backend failure"
20
+ _INVALID_ALIAS = "invalid alias"
21
+ _FIELDS = {"name", "content", "origin", "source", "revision"}
22
+ _MISS_FIELDS = {"version", "state", "source", "name", "revision"}
23
+ _TEMPLATE_FIELDS = {"version", "state", "template"}
24
+ _RESOLVED_FIELDS = {"version", "state", "source", "name", "template"}
25
+ _RESOLVED_MISS_FIELDS = {"version", "state", "source", "name"}
26
+ _RESOLVER_REVISION = "@resolved"
27
+ _GENERATION_ATTEMPTS = 8
28
+ _GENERATION_DOMAIN = "dj-hyperview:generation:v2"
29
+ _CLAIM_DOMAIN = "dj-hyperview:generation-claim:v1"
30
+ _GENERATION_KINDS = {"r": "root", "s": "successor"}
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class CacheEntry:
35
+ """A present cache entry containing a template or an explicit miss."""
36
+
37
+ template: ResolvedTemplate | None
38
+
39
+ @property
40
+ def is_miss(self) -> bool:
41
+ """Report whether this entry represents a cached source miss.
42
+
43
+ Returns:
44
+ Whether the entry is an explicit source miss.
45
+ """
46
+ return self.template is None
47
+
48
+
49
+ CACHE_MISS = CacheEntry(None)
50
+
51
+
52
+ def _validate_timeout(value: object, minimum: int, label: str) -> None:
53
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
54
+ raise ValueError(f"Cache {label} must be an integer >= {minimum}")
55
+
56
+
57
+ def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
58
+ value = {}
59
+ for key, item in pairs:
60
+ if key in value:
61
+ raise ValueError("duplicate JSON key")
62
+ value[key] = item
63
+ return value
64
+
65
+
66
+ def _without_untrusted_exception[T](operation: Callable[[], T]) -> T | object:
67
+ try:
68
+ return operation()
69
+ except Exception: # Cache backends and serialized payloads are untrusted.
70
+ return _FAILURE
71
+
72
+
73
+ def _resolve_cache_alias(alias: object) -> tuple[BaseCache | None, str | None]:
74
+ if not isinstance(alias, str) or not alias or alias not in settings.CACHES:
75
+ return None, _INVALID_ALIAS
76
+ backend = _without_untrusted_exception(lambda: caches[alias])
77
+ if backend is _FAILURE:
78
+ return None, _BACKEND_FAILURE
79
+ if not isinstance(backend, BaseCache):
80
+ return None, _INVALID_ALIAS
81
+ return backend, None
82
+
83
+
84
+ def template_cache_key(namespace: str, source: str, name: str, revision: str) -> str:
85
+ """Return a backend-safe key for one raw template revision.
86
+
87
+ Args:
88
+ namespace: Cache namespace.
89
+ source: Source identity.
90
+ name: Canonical template name.
91
+ revision: Source revision.
92
+
93
+ Returns:
94
+ A deterministic backend-safe cache key.
95
+ """
96
+ components = json.dumps(
97
+ [namespace, source, name, revision],
98
+ ensure_ascii=False,
99
+ separators=(",", ":"),
100
+ ).encode(errors="surrogatepass")
101
+ return f"djhv:v1:{hashlib.sha256(components).hexdigest()}"
102
+
103
+
104
+ class TemplateCache:
105
+ """Store serialized raw template results in a configured Django cache."""
106
+
107
+ def __init__(
108
+ self,
109
+ namespace: str,
110
+ *,
111
+ alias: str = "default",
112
+ ttl: int = 300,
113
+ negative_ttl: int = 15,
114
+ ) -> None:
115
+ """Initialize a raw-template cache boundary.
116
+
117
+ Args:
118
+ namespace: Namespace isolating this consumer's entries.
119
+ alias: Configured Django cache alias.
120
+ ttl: Lifetime for cached template content.
121
+ negative_ttl: Lifetime for cached source misses.
122
+
123
+ Raises:
124
+ ValueError: If the namespace or a timeout is invalid.
125
+ SourceUnavailable: If the configured backend cannot be initialized.
126
+ """
127
+ if not isinstance(namespace, str) or not namespace:
128
+ raise ValueError("Cache namespace must be a non-empty string")
129
+ _validate_timeout(ttl, 1, "TTL")
130
+ _validate_timeout(negative_ttl, 0, "negative TTL")
131
+ backend, alias_error = _resolve_cache_alias(alias)
132
+ if alias_error is not None:
133
+ source = f"cache:{alias}" if alias_error == _BACKEND_FAILURE else "cache"
134
+ raise SourceUnavailable(source, alias_error)
135
+ self.namespace = namespace
136
+ self.alias = alias
137
+ self.ttl = ttl
138
+ self.negative_ttl = negative_ttl
139
+ self.backend = backend
140
+
141
+ @classmethod
142
+ def from_settings(cls, namespace: str) -> "TemplateCache":
143
+ """Create a template cache from current package settings.
144
+
145
+ Args:
146
+ namespace: Namespace isolating this package consumer's cache entries.
147
+
148
+ Returns:
149
+ A cache configured from current package settings.
150
+ """
151
+ config = get_settings().cache
152
+ return cls(
153
+ namespace,
154
+ alias=config.alias,
155
+ ttl=config.ttl,
156
+ negative_ttl=config.negative_ttl,
157
+ )
158
+
159
+ def key(self, source: str, name: str, revision: str) -> str:
160
+ """Return the backend-safe key for one cached template identity.
161
+
162
+ Args:
163
+ source: Stable source identity.
164
+ name: Canonical template name.
165
+ revision: Source or generation revision.
166
+
167
+ Returns:
168
+ A deterministic backend-safe cache key.
169
+ """
170
+ return template_cache_key(self.namespace, source, name, revision)
171
+
172
+ def _store(self, key: str, value: object, timeout: int | None) -> None:
173
+ result = _without_untrusted_exception(
174
+ lambda: self.backend.set(key, value, timeout=timeout)
175
+ )
176
+ if result is _FAILURE or result is False:
177
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
178
+
179
+ def _delete(self, key: str) -> None:
180
+ result = _without_untrusted_exception(lambda: self.backend.delete(key))
181
+ if result is not True:
182
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
183
+
184
+ def _generation_key(self, name: str) -> str:
185
+ return self.key("@generation", name, "@token")
186
+
187
+ def _claim_key(self, name: str, generation: str) -> str:
188
+ return self.key("@generation-claim", name, generation)
189
+
190
+ def _claim_value(self, name: str, generation: str, kind: str) -> str:
191
+ identity = json.dumps(
192
+ [_CLAIM_DOMAIN, self.namespace, name, generation, kind],
193
+ ensure_ascii=False,
194
+ separators=(",", ":"),
195
+ ).encode(errors="surrogatepass")
196
+ return json.dumps(
197
+ {
198
+ "version": 1,
199
+ "kind": kind,
200
+ "identity": hashlib.sha256(identity).hexdigest(),
201
+ },
202
+ separators=(",", ":"),
203
+ )
204
+
205
+ def _read_claim(self, name: str, generation: str) -> str:
206
+ kind = self._generation_kind(generation)
207
+ value = _without_untrusted_exception(
208
+ lambda: self.backend.get(self._claim_key(name, generation), _ABSENT)
209
+ )
210
+ if value is _FAILURE or value is _ABSENT:
211
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
212
+ if value == self._claim_value(name, generation, kind):
213
+ return kind
214
+ raise SourceUnavailable(f"cache:{self.alias}", "invalid payload")
215
+
216
+ def _candidate(self, name: str, current: str | None) -> str:
217
+ entropy = _without_untrusted_exception(lambda: secrets.token_hex(16))
218
+ if type(entropy) is not str:
219
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
220
+ material = json.dumps(
221
+ [_GENERATION_DOMAIN, self.namespace, name, current, entropy],
222
+ ensure_ascii=False,
223
+ separators=(",", ":"),
224
+ ).encode(errors="surrogatepass")
225
+ prefix = "r" if current is None else "s"
226
+ return prefix + hashlib.sha256(material).hexdigest()[:32]
227
+
228
+ def _claim_generation(self, name: str, current: str | None) -> str:
229
+ kind = "root" if current is None else "successor"
230
+ for _ in range(_GENERATION_ATTEMPTS):
231
+ candidate = self._candidate(name, current)
232
+ if self._generation_kind(candidate) != kind:
233
+ raise SourceUnavailable(f"cache:{self.alias}", "invalid payload")
234
+ claimed = _without_untrusted_exception(
235
+ lambda candidate=candidate: self.backend.add(
236
+ self._claim_key(name, candidate),
237
+ self._claim_value(name, candidate, kind),
238
+ timeout=None,
239
+ )
240
+ )
241
+ if claimed is True:
242
+ return candidate
243
+ if claimed is False:
244
+ self._read_claim(name, candidate)
245
+ continue
246
+ break
247
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
248
+
249
+ def _read_generation(self, name: str) -> str:
250
+ token = _without_untrusted_exception(
251
+ lambda: self.backend.get(self._generation_key(name), _ABSENT)
252
+ )
253
+ if token is _FAILURE or token is _ABSENT:
254
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
255
+ if not self._valid_generation(token):
256
+ raise SourceUnavailable(f"cache:{self.alias}", "invalid payload")
257
+ return token
258
+
259
+ @staticmethod
260
+ def _valid_generation(token: object) -> bool:
261
+ return (
262
+ type(token) is str
263
+ and len(token) == 33
264
+ and token[0] in _GENERATION_KINDS
265
+ and all(character in "0123456789abcdef" for character in token[1:])
266
+ )
267
+
268
+ def _generation_kind(self, token: object) -> str:
269
+ if not self._valid_generation(token):
270
+ raise SourceUnavailable(f"cache:{self.alias}", "invalid payload")
271
+ return _GENERATION_KINDS[token[0]]
272
+
273
+ def generation(self, name: str) -> str:
274
+ """Return the shared generation token for a canonical template name.
275
+
276
+ Args:
277
+ name: Canonical template name.
278
+
279
+ Returns:
280
+ The current shared generation token.
281
+
282
+ Raises:
283
+ SourceUnavailable: If the backend or generation payload is invalid.
284
+ """
285
+ key = self._generation_key(name)
286
+ token = _without_untrusted_exception(lambda: self.backend.get(key, _ABSENT))
287
+ if token is _ABSENT:
288
+ candidate = self._claim_generation(name, None)
289
+ added = _without_untrusted_exception(
290
+ lambda: self.backend.add(key, candidate, timeout=None)
291
+ )
292
+ token = (
293
+ candidate
294
+ if added is True
295
+ else _without_untrusted_exception(
296
+ lambda: self.backend.get(key, _ABSENT)
297
+ )
298
+ if added is False
299
+ else _FAILURE
300
+ )
301
+ if token is _FAILURE:
302
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
303
+ if not self._valid_generation(token):
304
+ raise SourceUnavailable(f"cache:{self.alias}", "invalid payload")
305
+ self._read_claim(name, token)
306
+ return token
307
+
308
+ def invalidate(self, name: str) -> None:
309
+ """Rotate a template generation without deleting backend-specific keys.
310
+
311
+ Args:
312
+ name: Canonical template name.
313
+
314
+ Raises:
315
+ SourceUnavailable: If generation rotation cannot be confirmed.
316
+ """
317
+ current = self.generation(name)
318
+ candidate = self._claim_generation(name, current)
319
+ self._store(self._generation_key(name), candidate, None)
320
+ confirmed = self._read_generation(name)
321
+ if confirmed != candidate:
322
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
323
+
324
+ def get(self, source: str, name: str, revision: str) -> CacheEntry | None:
325
+ """Read one exact raw-template cache entry.
326
+
327
+ Args:
328
+ source: Stable source identity.
329
+ name: Canonical template name.
330
+ revision: Exact source revision.
331
+
332
+ Returns:
333
+ The cached entry when present, otherwise absence.
334
+
335
+ Raises:
336
+ SourceUnavailable: If the backend or cached payload is invalid.
337
+ """
338
+ payload = _without_untrusted_exception(
339
+ lambda: self.backend.get(self.key(source, name, revision), _ABSENT)
340
+ )
341
+ if payload is _FAILURE:
342
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
343
+ if payload is _ABSENT:
344
+ return None
345
+ return self._decode(payload, source, name, revision)
346
+
347
+ def set(self, template: ResolvedTemplate) -> None:
348
+ """Store one resolved raw template.
349
+
350
+ Args:
351
+ template: Resolved raw template to cache.
352
+ """
353
+ payload = json.dumps(
354
+ {"version": 1, "state": "template", "template": asdict(template)},
355
+ ensure_ascii=False,
356
+ separators=(",", ":"),
357
+ )
358
+ self._store(
359
+ self.key(template.source, template.name, template.revision),
360
+ payload,
361
+ self.ttl,
362
+ )
363
+
364
+ def set_miss(self, source: str, name: str, revision: str) -> None:
365
+ """Store an explicit miss for one raw-template identity.
366
+
367
+ Args:
368
+ source: Stable source identity.
369
+ name: Canonical template name.
370
+ revision: Exact source revision.
371
+ """
372
+ payload = json.dumps(
373
+ {
374
+ "version": 1,
375
+ "state": "miss",
376
+ "source": source,
377
+ "name": name,
378
+ "revision": revision,
379
+ },
380
+ ensure_ascii=False,
381
+ separators=(",", ":"),
382
+ )
383
+ self._store(self.key(source, name, revision), payload, self.negative_ttl)
384
+
385
+ def get_resolved(
386
+ self, source: str, name: str, generation: str | None = None
387
+ ) -> CacheEntry | None:
388
+ """Return the latest raw result cached for one configured source.
389
+
390
+ Args:
391
+ source: Stable source identity.
392
+ name: Canonical template name.
393
+ generation: Optional shared generation token.
394
+
395
+ Returns:
396
+ The latest cached source result when present, otherwise absence.
397
+
398
+ Raises:
399
+ SourceUnavailable: If the backend or cached payload is invalid.
400
+ """
401
+ revision = self._resolved_revision(generation)
402
+ payload = _without_untrusted_exception(
403
+ lambda: self.backend.get(self.key(source, name, revision), _ABSENT)
404
+ )
405
+ if payload is _FAILURE:
406
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure")
407
+ if payload is _ABSENT:
408
+ return None
409
+ return self._decode_resolved(payload, source, name)
410
+
411
+ def set_resolved(
412
+ self,
413
+ source: str,
414
+ name: str,
415
+ template: ResolvedTemplate,
416
+ generation: str | None = None,
417
+ ) -> None:
418
+ """Store the latest resolved raw result for a configured source.
419
+
420
+ Args:
421
+ source: Stable source identity.
422
+ name: Canonical template name.
423
+ template: Resolved raw template to cache.
424
+ generation: Optional shared generation token.
425
+ """
426
+ payload = json.dumps(
427
+ {
428
+ "version": 1,
429
+ "state": "resolved",
430
+ "source": source,
431
+ "name": name,
432
+ "template": asdict(template),
433
+ },
434
+ ensure_ascii=False,
435
+ separators=(",", ":"),
436
+ )
437
+ self._set_resolved(source, name, payload, self.ttl, generation)
438
+
439
+ def set_resolved_miss(
440
+ self, source: str, name: str, generation: str | None = None
441
+ ) -> None:
442
+ """Store a latest-result miss for a configured source.
443
+
444
+ Args:
445
+ source: Stable source identity.
446
+ name: Canonical template name.
447
+ generation: Optional shared generation token.
448
+ """
449
+ payload = json.dumps(
450
+ {"version": 1, "state": "source-miss", "source": source, "name": name},
451
+ ensure_ascii=False,
452
+ separators=(",", ":"),
453
+ )
454
+ self._set_resolved(source, name, payload, self.negative_ttl, generation)
455
+
456
+ @staticmethod
457
+ def _resolved_revision(generation: str | None) -> str:
458
+ return _RESOLVER_REVISION if generation is None else f"@resolved:{generation}"
459
+
460
+ def _set_resolved(
461
+ self,
462
+ source: str,
463
+ name: str,
464
+ payload: str,
465
+ timeout: int,
466
+ generation: str | None,
467
+ ) -> None:
468
+ key = self.key(source, name, self._resolved_revision(generation))
469
+ self._store(key, payload, timeout)
470
+ if generation is not None:
471
+ self._confirm_publication(name, generation, key)
472
+
473
+ def _confirm_publication(self, name: str, generation: str, key: str) -> None:
474
+ try:
475
+ current = self._read_generation(name)
476
+ except SourceUnavailable:
477
+ self._preserve_claim_and_delete(name, generation, key)
478
+ raise SourceUnavailable(f"cache:{self.alias}", "backend failure") from None
479
+ if current != generation:
480
+ clean = self._discard_replaced_publication(key)
481
+ reason = "generation changed" if clean else "backend failure"
482
+ raise SourceUnavailable(f"cache:{self.alias}", reason)
483
+
484
+ def _preserve_claim_and_delete(self, name: str, generation: str, key: str) -> bool:
485
+ clean = True
486
+ try:
487
+ self._read_claim(name, generation)
488
+ except SourceUnavailable:
489
+ clean = False
490
+ try:
491
+ self._delete(key)
492
+ except SourceUnavailable:
493
+ clean = False
494
+ return clean
495
+
496
+ def _discard_replaced_publication(self, key: str) -> bool:
497
+ try:
498
+ self._delete(key)
499
+ except SourceUnavailable:
500
+ return False
501
+ return True
502
+
503
+ def _decode_resolved(self, payload: object, source: str, name: str) -> CacheEntry:
504
+ def decode() -> CacheEntry:
505
+ if not isinstance(payload, str):
506
+ raise TypeError
507
+ value = json.loads(payload, object_pairs_hook=_unique_object)
508
+ if type(value.get("version")) is not int or value["version"] != 1:
509
+ raise ValueError
510
+ if value.get("state") == "source-miss":
511
+ if (
512
+ set(value) != _RESOLVED_MISS_FIELDS
513
+ or not all(
514
+ isinstance(value[field], str) for field in ("source", "name")
515
+ )
516
+ or (value["source"], value["name"]) != (source, name)
517
+ ):
518
+ raise ValueError
519
+ return CACHE_MISS
520
+ if value.get("state") != "resolved" or set(value) != _RESOLVED_FIELDS:
521
+ raise ValueError
522
+ template = value["template"]
523
+ if (
524
+ not isinstance(template, dict)
525
+ or set(template) != _FIELDS
526
+ or not all(isinstance(item, str) for item in template.values())
527
+ or template["name"] != name
528
+ or (value["source"], value["name"]) != (source, name)
529
+ ):
530
+ raise ValueError
531
+ return CacheEntry(ResolvedTemplate(**template))
532
+
533
+ entry = _without_untrusted_exception(decode)
534
+ if entry is _FAILURE:
535
+ raise SourceUnavailable(f"cache:{self.alias}", "invalid payload")
536
+ return entry
537
+
538
+ def _decode(
539
+ self, payload: object, source: str, name: str, revision: str
540
+ ) -> CacheEntry:
541
+ def decode() -> CacheEntry:
542
+ if not isinstance(payload, str):
543
+ raise TypeError
544
+ value = json.loads(payload, object_pairs_hook=_unique_object)
545
+ if type(value.get("version")) is not int or value["version"] != 1:
546
+ raise ValueError
547
+ if value.get("state") == "miss":
548
+ if (
549
+ set(value) != _MISS_FIELDS
550
+ or not all(
551
+ isinstance(value[field], str)
552
+ for field in ("source", "name", "revision")
553
+ )
554
+ or (value["source"], value["name"], value["revision"])
555
+ != (source, name, revision)
556
+ ):
557
+ raise ValueError
558
+ return CACHE_MISS
559
+ if value.get("state") != "template" or set(value) != _TEMPLATE_FIELDS:
560
+ raise ValueError
561
+ template = value["template"]
562
+ if (
563
+ set(template) != _FIELDS
564
+ or not all(isinstance(item, str) for item in template.values())
565
+ or (template["source"], template["name"], template["revision"])
566
+ != (source, name, revision)
567
+ ):
568
+ raise ValueError
569
+ return CacheEntry(ResolvedTemplate(**template))
570
+
571
+ entry = _without_untrusted_exception(decode)
572
+ if entry is _FAILURE:
573
+ raise SourceUnavailable(f"cache:{self.alias}", "invalid payload")
574
+ return entry
575
+
576
+
577
+ def invalidate_templates(*names: str) -> None:
578
+ """Invalidate future cached lookups for canonical template names.
579
+
580
+ Args:
581
+ *names: Canonicalizable template names.
582
+ """
583
+ canonical = tuple(dict.fromkeys(canonicalize_template_name(name) for name in names))
584
+ if not canonical:
585
+ return
586
+ raw = getattr(settings, "HYPERVIEW", {})
587
+ if isinstance(raw, Mapping) and not raw.get("CACHE"):
588
+ return
589
+ config = get_settings().cache
590
+ cache = TemplateCache.from_settings(config.namespace)
591
+ for name in canonical:
592
+ cache.invalidate(name)