dialcache 0.26.0__tar.gz → 0.27.0__tar.gz

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 (57) hide show
  1. {dialcache-0.26.0 → dialcache-0.27.0}/API-DESIGN.md +1 -1
  2. {dialcache-0.26.0 → dialcache-0.27.0}/PKG-INFO +4 -4
  3. {dialcache-0.26.0 → dialcache-0.27.0}/README.md +3 -3
  4. dialcache-0.27.0/dialcache/_callbacks.py +27 -0
  5. dialcache-0.27.0/dialcache/_shadow_log.py +324 -0
  6. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/cache.py +140 -58
  7. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/config.py +120 -12
  8. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/serializer.py +13 -5
  9. {dialcache-0.26.0 → dialcache-0.27.0}/pyproject.toml +1 -1
  10. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/driver.py +63 -14
  11. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/scenarios.py +1 -1
  12. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/witness.py +9 -9
  13. {dialcache-0.26.0 → dialcache-0.27.0}/tests/run_conformance.py +2 -2
  14. dialcache-0.27.0/tests/test_config.py +404 -0
  15. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_conformance.py +2 -2
  16. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_integration_acceptance.py +3 -3
  17. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_observer_recovery_boundaries.py +85 -0
  18. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_protocol_vectors.py +36 -7
  19. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_redis_integration.py +5 -2
  20. dialcache-0.27.0/tests/test_shadow_log.py +826 -0
  21. dialcache-0.27.0/tests/test_shadow_mismatch_logging.py +797 -0
  22. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_validation_environment.py +1 -1
  23. dialcache-0.26.0/tests/test_config.py +0 -142
  24. {dialcache-0.26.0 → dialcache-0.27.0}/.gitignore +0 -0
  25. {dialcache-0.26.0 → dialcache-0.27.0}/LICENSE +0 -0
  26. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/__init__.py +0 -0
  27. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/clock.py +0 -0
  28. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/context.py +0 -0
  29. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/errors.py +0 -0
  30. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/key.py +0 -0
  31. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/local.py +0 -0
  32. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/metrics.py +0 -0
  33. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/protocol.py +0 -0
  34. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/py.typed +0 -0
  35. {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/redis.py +0 -0
  36. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/__init__.py +0 -0
  37. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/coordinator.py +0 -0
  38. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/executor.py +0 -0
  39. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/schema.py +0 -0
  40. {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/simple_drivers.py +0 -0
  41. {dialcache-0.26.0 → dialcache-0.27.0}/tests/run_integration.py +0 -0
  42. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_cache.py +0 -0
  43. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_cached_keys.py +0 -0
  44. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_callback_cancellation.py +0 -0
  45. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_cluster_read_routing.py +0 -0
  46. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_context.py +0 -0
  47. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_dependency_cancellation.py +0 -0
  48. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_docs_examples.py +0 -0
  49. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_engine_review.py +0 -0
  50. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_local.py +0 -0
  51. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_protocol_native.py +0 -0
  52. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_redis_adapter.py +0 -0
  53. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_review_regressions.py +0 -0
  54. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_shadow_deadline_retention.py +0 -0
  55. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_typing.py +0 -0
  56. {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_wire_suite.py +0 -0
  57. {dialcache-0.26.0 → dialcache-0.27.0}/tests/typing/cached.py +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Python binding was designed after reviewing [rungalileo/gcache at a688049](https://github.com/rungalileo/gcache/tree/a68804986782cb0b7e8b7dc6bfc34c718c177189), including `src/gcache/gcache.py`, `config.py`, `proto_serializer.py`, and the context, layer wrappers, local cache, Redis cache, and event-loop thread implementations under `_internal/`.
4
4
 
5
- DialCache's [Quint specification](../formal/SPEC.md) and TypeScript implementation define portable behavior. Gcache supplies useful Python interface ideas; its implementation is not a compatible DialCache backend.
5
+ DialCache's [Quint specification](../formal/guides/SPEC.md) and TypeScript implementation define portable behavior. Gcache supplies useful Python interface ideas; its implementation is not a compatible DialCache backend.
6
6
 
7
7
  The primary decorator style selects identity explicitly with
8
8
  `cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}}`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dialcache
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: Explicitly enabled async caching with runtime policies, coalescing, and tracked Redis invalidation.
5
5
  Project-URL: Repository, https://github.com/lan17/DialCache
6
6
  Project-URL: Documentation, https://github.com/lan17/DialCache/tree/main/python
@@ -31,7 +31,7 @@ Description-Content-Type: text/markdown
31
31
  An asyncio port of DialCache for Python 3.11 and later. Each use case declares
32
32
  its identity and policy; an enabled request scope opts into request memoization,
33
33
  local storage, Redis, and concurrent request sharing. The behavioral contract is
34
- the repository's [portable specification](https://github.com/lan17/DialCache/blob/main/formal/SPEC.md).
34
+ the repository's [portable specification](https://github.com/lan17/DialCache/blob/main/formal/guides/SPEC.md).
35
35
 
36
36
  Once the first PyPI release is available, install it with:
37
37
 
@@ -268,6 +268,6 @@ local expiry, cancellation, and wire boundaries. Shared replay runs the real
268
268
  Python API through the repository's Node coordinator. Its inputs and expected
269
269
  observations come from the same Quint-generated histories used by the other
270
270
  ports; Node is a development dependency, not a runtime dependency of the
271
- Python library. See [the porting guide](https://github.com/lan17/DialCache/blob/main/formal/PORTING.md) for the completion
272
- and settlement requirements and [the feature map](https://github.com/lan17/DialCache/blob/main/formal/FEATURE-COVERAGE.md)
271
+ Python library. See [the porting guide](https://github.com/lan17/DialCache/blob/main/formal/guides/PORTING.md) for the completion
272
+ and settlement requirements and [the feature map](https://github.com/lan17/DialCache/blob/main/formal/guides/FEATURE-COVERAGE.md)
273
273
  for portable behavior versus native adapter obligations.
@@ -3,7 +3,7 @@
3
3
  An asyncio port of DialCache for Python 3.11 and later. Each use case declares
4
4
  its identity and policy; an enabled request scope opts into request memoization,
5
5
  local storage, Redis, and concurrent request sharing. The behavioral contract is
6
- the repository's [portable specification](https://github.com/lan17/DialCache/blob/main/formal/SPEC.md).
6
+ the repository's [portable specification](https://github.com/lan17/DialCache/blob/main/formal/guides/SPEC.md).
7
7
 
8
8
  Once the first PyPI release is available, install it with:
9
9
 
@@ -240,6 +240,6 @@ local expiry, cancellation, and wire boundaries. Shared replay runs the real
240
240
  Python API through the repository's Node coordinator. Its inputs and expected
241
241
  observations come from the same Quint-generated histories used by the other
242
242
  ports; Node is a development dependency, not a runtime dependency of the
243
- Python library. See [the porting guide](https://github.com/lan17/DialCache/blob/main/formal/PORTING.md) for the completion
244
- and settlement requirements and [the feature map](https://github.com/lan17/DialCache/blob/main/formal/FEATURE-COVERAGE.md)
243
+ Python library. See [the porting guide](https://github.com/lan17/DialCache/blob/main/formal/guides/PORTING.md) for the completion
244
+ and settlement requirements and [the feature map](https://github.com/lan17/DialCache/blob/main/formal/guides/FEATURE-COVERAGE.md)
245
245
  for portable behavior versus native adapter obligations.
@@ -0,0 +1,27 @@
1
+ """Isolation for application callbacks whose contract is synchronous."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextvars
7
+ import inspect
8
+ from typing import Any
9
+
10
+
11
+ def discard_awaitable(value: Any) -> None:
12
+ """Fail closed on an awaitable a synchronous callback returned.
13
+
14
+ A rejected result must not start work or take ownership of an
15
+ application's existing task: a coroutine that has not started is closed,
16
+ and a future's exception is consumed without awaiting it.
17
+ """
18
+ if inspect.iscoroutine(value):
19
+ if inspect.getcoroutinestate(value) == inspect.CORO_CREATED:
20
+ value.close()
21
+ elif asyncio.isfuture(value):
22
+
23
+ def consume(done: asyncio.Future[Any]) -> None:
24
+ if not done.cancelled():
25
+ done.exception()
26
+
27
+ value.add_done_callback(consume, context=contextvars.Context())
@@ -0,0 +1,324 @@
1
+ """Bounded content of confirmed shadow-mismatch warnings.
2
+
3
+ Every field derives from one native JSON rendering of each compared value, so
4
+ a failure logs less, never more. The built-in difference computation walks the
5
+ two parsed renderings and reports RFC 6901 JSON Pointers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import inspect
12
+ import json
13
+ import math
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from ._callbacks import discard_awaitable
19
+ from .protocol import utf8_bytes
20
+ from .serializer import UNDEFINED, _native_json
21
+
22
+ KEY_MAX_BYTES = 2 * 1024
23
+ VALUE_MAX_BYTES = 8 * 1024
24
+ DIFF_MAX_BYTES = 8 * 1024
25
+ MAX_ENTRIES = 64
26
+ PATH_MAX_BYTES = 512
27
+ SIDE_MAX_BYTES = 1024
28
+ # Reference tokens in the deepest pointer the built-in walk reports.
29
+ MAX_DEPTH = 32
30
+ TRUNCATION_MARKER = "...[truncated]"
31
+ _MARKER_BYTES = TRUNCATION_MARKER.encode()
32
+
33
+ # One difference: a pointer, and each side under "cached" or "source" when the
34
+ # value has that side. A missing key is an absent side; None is JSON null.
35
+ Entry = dict[str, Any]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class MismatchLogPlan:
40
+ """The effective content leaves captured when a shadow job is admitted."""
41
+
42
+ value: bool
43
+ diff: str
44
+
45
+
46
+ def clamp_utf8(text: str, limit: int) -> str:
47
+ """Clamp to ``limit`` UTF-8 bytes with the marker, never splitting a code point."""
48
+ encoded = utf8_bytes(text)
49
+ if len(encoded) <= limit:
50
+ return text
51
+ return encoded[: limit - len(_MARKER_BYTES)].decode("utf-8", "ignore") + TRUNCATION_MARKER
52
+
53
+
54
+ def render_json(value: Any) -> str | None:
55
+ """The native JSON rendering ``JsonSerializer`` stores, or None for a value without one.
56
+
57
+ Lone surrogates are escaped, so the rendering is valid UTF-8. A top-level
58
+ ``UNDEFINED`` has none here: its stored sentinel is not JSON.
59
+ """
60
+ try:
61
+ return _native_json(value)
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ def render_shadow_mismatch_log(
67
+ plan: MismatchLogPlan,
68
+ cached: Any,
69
+ source: Any,
70
+ hook: Callable[[Any, Any], Any] | None = None,
71
+ ) -> tuple[dict[str, Any], str | None]:
72
+ """Build the content fields the plan requests for one confirmed mismatch.
73
+
74
+ Returns the fields, each requested one present and None when it failed
75
+ closed, and the failure: ``"hook_error"`` for the hook, which ranks above
76
+ ``"render_error"`` for JSON rendering, or None. Each side is rendered at
77
+ most once. A failed hook never falls back to the built-in walk. A
78
+ top-level ``UNDEFINED``, which a negative-cache entry stores, is an absent
79
+ side rather than a failure: its value field is None, and the walk reports
80
+ the other side whole at ``""``.
81
+ """
82
+ render_sides = plan.value or (plan.diff != "off" and hook is None)
83
+ cached_json = render_json(cached) if render_sides else None
84
+ source_json = render_json(source) if render_sides else None
85
+ diff_failed = False
86
+ render_failed = render_sides and (
87
+ (cached_json is None and cached is not UNDEFINED) or (source_json is None and source is not UNDEFINED)
88
+ )
89
+
90
+ fields: dict[str, Any] = {}
91
+ if plan.diff != "off":
92
+ with_values = plan.diff == "values"
93
+ diff: tuple[list[Entry], int] | None = None
94
+ if hook is not None:
95
+ diff = _run_hook(hook, cached, source)
96
+ diff_failed = diff is None
97
+ elif not render_failed:
98
+ diff = _walk_rendered(cached_json, source_json)
99
+ if diff is None:
100
+ fields["mismatchPaths"] = None
101
+ fields["mismatchPathCount"] = None
102
+ if with_values:
103
+ fields["diffJson"] = None
104
+ else:
105
+ entries, count = diff
106
+ diff_json = None
107
+ if with_values:
108
+ diff_json = _render_diff_json(entries)
109
+ render_failed = render_failed or diff_json is None
110
+ # The paths level never renders a side.
111
+ fields["mismatchPaths"] = [clamp_utf8(entry["path"], PATH_MAX_BYTES) for entry in entries]
112
+ fields["mismatchPathCount"] = count
113
+ if with_values:
114
+ fields["diffJson"] = diff_json
115
+
116
+ if plan.value:
117
+ fields["cachedValueJson"] = None if cached_json is None else clamp_utf8(cached_json, VALUE_MAX_BYTES)
118
+ fields["sourceValueJson"] = None if source_json is None else clamp_utf8(source_json, VALUE_MAX_BYTES)
119
+ return fields, "hook_error" if diff_failed else "render_error" if render_failed else None
120
+
121
+
122
+ def diff_shadow_mismatch_json(cached: Any, source: Any) -> tuple[list[Entry | None], int]:
123
+ """The built-in difference computation over two JSON values.
124
+
125
+ Objects recurse over the union of their member names in UTF-16 code-unit
126
+ order, and a member on one side only is reported with that side. Arrays of
127
+ equal length recurse by index; arrays of different length are reported once
128
+ at their own pointer. Any other differing pair, roots of different kinds
129
+ included, is reported with both sides, and a differing pair of containers
130
+ at ``MAX_DEPTH`` reference tokens is reported whole. Entries keep walk
131
+ order and are cut at ``MAX_ENTRIES``; the count is the uncut total. Each
132
+ pointer and side is clamped as in ``diffJson``.
133
+ """
134
+ entries, count = _walk(cached, source)
135
+ # JSON values always render, so every entry bounds.
136
+ return [_bound_entry(entry) for entry in entries], count
137
+
138
+
139
+ def _walk_rendered(cached_json: str | None, source_json: str | None) -> tuple[list[Entry], int]:
140
+ # None is an absent side; a side that failed to render never reaches here.
141
+ # Each string came from a successful native rendering, so this is the one
142
+ # boundary from arbitrary values to the closed JSON domain.
143
+ if cached_json is None:
144
+ return ([], 0) if source_json is None else ([{"path": "", "source": json.loads(source_json)}], 1)
145
+ if source_json is None:
146
+ return [{"path": "", "cached": json.loads(cached_json)}], 1
147
+ if cached_json == source_json:
148
+ return [], 0
149
+ return _walk(json.loads(cached_json), json.loads(source_json))
150
+
151
+
152
+ def _walk(cached: Any, source: Any) -> tuple[list[Entry], int]:
153
+ entries: list[Entry] = []
154
+ count = 0
155
+ # The escaped reference tokens down to the pair being walked. Python
156
+ # strings have no ropes, so a pointer is built only for a retained entry:
157
+ # copying the prefix for each member would cost members times its length.
158
+ tokens: list[str] = []
159
+
160
+ def report(**sides: Any) -> None:
161
+ nonlocal count
162
+ count += 1
163
+ if len(entries) < MAX_ENTRIES:
164
+ entries.append({"path": _clamped_pointer(tokens), **sides})
165
+
166
+ def walk(left: Any, right: Any) -> None:
167
+ kind = _kind(left)
168
+ if kind != _kind(right) or kind == "primitive":
169
+ if not _same_primitive(left, right):
170
+ report(cached=left, source=right)
171
+ return
172
+ if len(tokens) >= MAX_DEPTH:
173
+ if not _json_equal(left, right):
174
+ report(cached=left, source=right)
175
+ return
176
+ if kind == "array":
177
+ if len(left) != len(right):
178
+ report(cached=left, source=right)
179
+ return
180
+ for index, (item, other) in enumerate(zip(left, right)):
181
+ tokens.append(str(index))
182
+ walk(item, other)
183
+ tokens.pop()
184
+ return
185
+ for name in sorted(left.keys() | right.keys(), key=_utf16_order):
186
+ tokens.append(_escape_token(name))
187
+ if name not in right:
188
+ report(cached=left[name])
189
+ elif name not in left:
190
+ report(source=right[name])
191
+ else:
192
+ walk(left[name], right[name])
193
+ tokens.pop()
194
+
195
+ walk(cached, source)
196
+ return entries, count
197
+
198
+
199
+ def _clamped_pointer(tokens: list[str]) -> str:
200
+ # A token longer than the cap in code points is longer than it in UTF-8
201
+ # bytes, so cutting each token there keeps every byte the clamp keeps.
202
+ return clamp_utf8("".join(f"/{token[:PATH_MAX_BYTES]}" for token in tokens), PATH_MAX_BYTES)
203
+
204
+
205
+ def _json_equal(cached: Any, source: Any) -> bool:
206
+ # Structural equality for a depth-capped pair, iterative so that a subtree
207
+ # as deep as native JSON rendering allows cannot exhaust the stack.
208
+ pending = [(cached, source)]
209
+ while pending:
210
+ left, right = pending.pop()
211
+ kind = _kind(left)
212
+ if kind != _kind(right):
213
+ return False
214
+ if kind == "primitive":
215
+ if not _same_primitive(left, right):
216
+ return False
217
+ elif kind == "array":
218
+ if len(left) != len(right):
219
+ return False
220
+ pending.extend(zip(left, right))
221
+ else:
222
+ if len(left) != len(right):
223
+ return False
224
+ for name, value in left.items():
225
+ if name not in right:
226
+ return False
227
+ pending.append((value, right[name]))
228
+ return True
229
+
230
+
231
+ def _kind(value: Any) -> str:
232
+ if isinstance(value, list):
233
+ return "array"
234
+ return "object" if isinstance(value, dict) else "primitive"
235
+
236
+
237
+ def _same_primitive(left: Any, right: Any) -> bool:
238
+ # JSON equality, not Python's: True == 1 and False == 0 in Python.
239
+ if isinstance(left, bool) or isinstance(right, bool) or left is None or right is None:
240
+ return left is right
241
+ if isinstance(left, str) or isinstance(right, str):
242
+ return isinstance(left, str) and isinstance(right, str) and left == right
243
+ if isinstance(left, (int, float)) and isinstance(right, (int, float)):
244
+ # Numbers compare as IEEE doubles, as the other ports parse them, so
245
+ # precision beyond a double is not a difference.
246
+ return _as_double(left) == _as_double(right)
247
+ return False
248
+
249
+
250
+ def _as_double(number: int | float) -> float:
251
+ # float() rounds an int to nearest and raises exactly when IEEE rounding
252
+ # gives an infinity, the value TypeScript parses from the rendered digits.
253
+ # math.copysign would raise too: it converts the int to a float first.
254
+ try:
255
+ return float(number)
256
+ except OverflowError:
257
+ return math.inf if number > 0 else -math.inf
258
+
259
+
260
+ def _utf16_order(name: str) -> bytes:
261
+ # Big-endian code units compare as UTF-16 code units, the W02 key order.
262
+ return name.encode("utf-16-be", "surrogatepass")
263
+
264
+
265
+ def _escape_token(name: str) -> str:
266
+ return name.replace("~", "~0").replace("/", "~1")
267
+
268
+
269
+ def _run_hook(hook: Callable[[Any, Any], Any], cached: Any, source: Any) -> tuple[list[Entry], int] | None:
270
+ """Run the use case's hook under the comparator's synchronous discipline.
271
+
272
+ A raise, an awaitable or a result that is not a list returns None. Only
273
+ the kept members are read, inside the try, and a path is logged as its
274
+ str form: a hook that returns wrong entries gets wrong paths.
275
+ """
276
+ try:
277
+ result = hook(cached, source)
278
+ if inspect.isawaitable(result):
279
+ # The contract is synchronous: fail closed without driving it.
280
+ discard_awaitable(result)
281
+ return None
282
+ if not isinstance(result, list):
283
+ return None
284
+ entries: list[Entry] = []
285
+ for member in result[:MAX_ENTRIES]:
286
+ entry: Entry = {"path": str(member["path"])}
287
+ for side in ("cached", "source"):
288
+ if side in member and member[side] is not UNDEFINED:
289
+ entry[side] = member[side]
290
+ entries.append(entry)
291
+ return entries, len(result)
292
+ except (Exception, asyncio.CancelledError):
293
+ return None
294
+
295
+
296
+ def _render_diff_json(entries: list[Entry]) -> str | None:
297
+ # A side with no JSON rendering makes the whole field None, and later
298
+ # sides are not rendered.
299
+ bounded = []
300
+ for entry in entries:
301
+ next_entry = _bound_entry(entry)
302
+ if next_entry is None:
303
+ return None
304
+ bounded.append(next_entry)
305
+ return clamp_utf8(_native_json(bounded), DIFF_MAX_BYTES)
306
+
307
+
308
+ def _bound_entry(entry: Entry) -> Entry | None:
309
+ """Clamp the pointer, and keep each present side as JSON or as its clamped text.
310
+
311
+ One clamp rule for built-in and hook entries. None when a side has no JSON
312
+ rendering.
313
+ """
314
+ bounded: Entry = {"path": clamp_utf8(entry["path"], PATH_MAX_BYTES)}
315
+ for side in ("cached", "source"):
316
+ if side not in entry:
317
+ continue
318
+ text = render_json(entry[side])
319
+ if text is None:
320
+ return None
321
+ clamped = clamp_utf8(text, SIDE_MAX_BYTES)
322
+ # Parsing the one rendering keeps each side rendered once.
323
+ bounded[side] = json.loads(text) if clamped is text else clamped
324
+ return bounded