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.
- dj_hyperview/__init__.py +61 -0
- dj_hyperview/apps.py +16 -0
- dj_hyperview/cache.py +592 -0
- dj_hyperview/checks.py +177 -0
- dj_hyperview/conf.py +95 -0
- dj_hyperview/contrib/__init__.py +1 -0
- dj_hyperview/contrib/database/__init__.py +1 -0
- dj_hyperview/contrib/database/_config.py +18 -0
- dj_hyperview/contrib/database/_invalidation.py +28 -0
- dj_hyperview/contrib/database/admin.py +303 -0
- dj_hyperview/contrib/database/apps.py +18 -0
- dj_hyperview/contrib/database/migrations/0001_initial.py +40 -0
- dj_hyperview/contrib/database/migrations/0002_field_validators.py +38 -0
- dj_hyperview/contrib/database/migrations/__init__.py +0 -0
- dj_hyperview/contrib/database/models.py +45 -0
- dj_hyperview/contrib/database/querysets.py +127 -0
- dj_hyperview/contrib/database/services.py +289 -0
- dj_hyperview/contrib/database/signals.py +123 -0
- dj_hyperview/contrib/database/sources.py +85 -0
- dj_hyperview/contrib/database/validators.py +52 -0
- dj_hyperview/engine.py +162 -0
- dj_hyperview/exceptions.py +78 -0
- dj_hyperview/http.py +88 -0
- dj_hyperview/loaders.py +152 -0
- dj_hyperview/middleware.py +107 -0
- dj_hyperview/resolver.py +291 -0
- dj_hyperview/sources/__init__.py +11 -0
- dj_hyperview/sources/base.py +64 -0
- dj_hyperview/sources/filesystem.py +62 -0
- dj_hyperview/templatetags/__init__.py +1 -0
- dj_hyperview/templatetags/dj_hyperview.py +24 -0
- dj_hyperview/validation.py +200 -0
- dj_hyperview/views.py +12 -0
- dj_hyperview-0.1.0a3.dist-info/METADATA +259 -0
- dj_hyperview-0.1.0a3.dist-info/RECORD +36 -0
- dj_hyperview-0.1.0a3.dist-info/WHEEL +4 -0
dj_hyperview/__init__.py
ADDED
|
@@ -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)
|