dialcache 0.26.0__tar.gz → 0.28.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.
- {dialcache-0.26.0 → dialcache-0.28.0}/.gitignore +1 -1
- {dialcache-0.26.0 → dialcache-0.28.0}/API-DESIGN.md +1 -1
- {dialcache-0.26.0 → dialcache-0.28.0}/PKG-INFO +9 -22
- {dialcache-0.26.0 → dialcache-0.28.0}/README.md +8 -21
- dialcache-0.28.0/dialcache/_callbacks.py +27 -0
- dialcache-0.28.0/dialcache/_shadow_log.py +324 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/cache.py +140 -58
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/config.py +120 -12
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/serializer.py +13 -5
- {dialcache-0.26.0 → dialcache-0.28.0}/pyproject.toml +1 -1
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/driver.py +63 -14
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/scenarios.py +1 -1
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/witness.py +9 -9
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/run_conformance.py +2 -2
- dialcache-0.28.0/tests/test_config.py +404 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_conformance.py +2 -2
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_integration_acceptance.py +3 -3
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_observer_recovery_boundaries.py +85 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_protocol_vectors.py +36 -7
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_redis_integration.py +5 -2
- dialcache-0.28.0/tests/test_shadow_log.py +826 -0
- dialcache-0.28.0/tests/test_shadow_mismatch_logging.py +797 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_validation_environment.py +1 -1
- dialcache-0.26.0/tests/test_config.py +0 -142
- {dialcache-0.26.0 → dialcache-0.28.0}/LICENSE +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/__init__.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/clock.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/context.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/errors.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/key.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/local.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/metrics.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/protocol.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/py.typed +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/dialcache/redis.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/__init__.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/coordinator.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/executor.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/schema.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/formal/simple_drivers.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/run_integration.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_cache.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_cached_keys.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_callback_cancellation.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_cluster_read_routing.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_context.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_dependency_cancellation.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_docs_examples.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_engine_review.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_local.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_protocol_native.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_redis_adapter.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_review_regressions.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_shadow_deadline_retention.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_typing.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.0}/tests/test_wire_suite.py +0 -0
- {dialcache-0.26.0 → dialcache-0.28.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.
|
|
3
|
+
Version: 0.28.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
|
|
|
@@ -48,6 +48,7 @@ python3 -m pip install './python[redis]'
|
|
|
48
48
|
For local-only caching, omit the `redis` extra. Zstandard is included for the
|
|
49
49
|
portable Redis payload format. The application owns the asyncio event loop and
|
|
50
50
|
any Redis client connections.
|
|
51
|
+
The library has no Node.js runtime dependency.
|
|
51
52
|
|
|
52
53
|
## A cached function
|
|
53
54
|
|
|
@@ -140,7 +141,7 @@ cache = DialCache(policy_provider=policy_provider)
|
|
|
140
141
|
Runtime replies are sparse: an omitted field inherits the operation default.
|
|
141
142
|
A whole reply of `None` inherits the complete operation policy. An explicit
|
|
142
143
|
`None` leaf is malformed and cannot silently inherit a valid setting. Python
|
|
143
|
-
snake_case names and the shared
|
|
144
|
+
snake_case names and the shared camelCase mapping names are accepted.
|
|
144
145
|
Policy objects snapshot their input maps so later mutation cannot alter an
|
|
145
146
|
already admitted invocation.
|
|
146
147
|
|
|
@@ -243,8 +244,6 @@ The Python API takes inspiration from [Galileo gcache](https://github.com/rungal
|
|
|
243
244
|
decorated functions, argument-based identity, explicit context managers, and
|
|
244
245
|
pluggable serializers. DialCache follows its own portable
|
|
245
246
|
specification for behavior and wire compatibility.
|
|
246
|
-
The [API design notes](https://github.com/lan17/DialCache/blob/main/python/API-DESIGN.md) record the source-reviewed gcache revision
|
|
247
|
-
and the native API choices made for this port.
|
|
248
247
|
|
|
249
248
|
This binding exposes awaitable operations. It does not introduce a global
|
|
250
249
|
singleton, implicitly run synchronous I/O in a thread pool, serialize with
|
|
@@ -253,21 +252,9 @@ randomly. Direct `put`, `delete`, and `flush` cache APIs from gcache are outside
|
|
|
253
252
|
DialCache's portable contract; writes come from successful source loads and
|
|
254
253
|
entity-level invalidation is explicit.
|
|
255
254
|
|
|
256
|
-
## Development
|
|
255
|
+
## Development
|
|
257
256
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
python/.venv/bin/python -m pip install -e './python[test,redis]'
|
|
263
|
-
python/.venv/bin/python -m pytest python/tests
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
The native tests cover Python API behavior, policy validation, scope lifetime,
|
|
267
|
-
local expiry, cancellation, and wire boundaries. Shared replay runs the real
|
|
268
|
-
Python API through the repository's Node coordinator. Its inputs and expected
|
|
269
|
-
observations come from the same Quint-generated histories used by the other
|
|
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)
|
|
273
|
-
for portable behavior versus native adapter obligations.
|
|
257
|
+
Run the checks from the repository root: `make check-python` runs the native tests,
|
|
258
|
+
`make formal-python` replays the generated corpus, and `make integration-python`
|
|
259
|
+
runs the real-server checks. [Validation and evidence](https://github.com/lan17/DialCache/blob/main/formal/guides/VALIDATION.md) lists the
|
|
260
|
+
prerequisites and the other lanes.
|
|
@@ -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
|
|
|
@@ -20,6 +20,7 @@ python3 -m pip install './python[redis]'
|
|
|
20
20
|
For local-only caching, omit the `redis` extra. Zstandard is included for the
|
|
21
21
|
portable Redis payload format. The application owns the asyncio event loop and
|
|
22
22
|
any Redis client connections.
|
|
23
|
+
The library has no Node.js runtime dependency.
|
|
23
24
|
|
|
24
25
|
## A cached function
|
|
25
26
|
|
|
@@ -112,7 +113,7 @@ cache = DialCache(policy_provider=policy_provider)
|
|
|
112
113
|
Runtime replies are sparse: an omitted field inherits the operation default.
|
|
113
114
|
A whole reply of `None` inherits the complete operation policy. An explicit
|
|
114
115
|
`None` leaf is malformed and cannot silently inherit a valid setting. Python
|
|
115
|
-
snake_case names and the shared
|
|
116
|
+
snake_case names and the shared camelCase mapping names are accepted.
|
|
116
117
|
Policy objects snapshot their input maps so later mutation cannot alter an
|
|
117
118
|
already admitted invocation.
|
|
118
119
|
|
|
@@ -215,8 +216,6 @@ The Python API takes inspiration from [Galileo gcache](https://github.com/rungal
|
|
|
215
216
|
decorated functions, argument-based identity, explicit context managers, and
|
|
216
217
|
pluggable serializers. DialCache follows its own portable
|
|
217
218
|
specification for behavior and wire compatibility.
|
|
218
|
-
The [API design notes](https://github.com/lan17/DialCache/blob/main/python/API-DESIGN.md) record the source-reviewed gcache revision
|
|
219
|
-
and the native API choices made for this port.
|
|
220
219
|
|
|
221
220
|
This binding exposes awaitable operations. It does not introduce a global
|
|
222
221
|
singleton, implicitly run synchronous I/O in a thread pool, serialize with
|
|
@@ -225,21 +224,9 @@ randomly. Direct `put`, `delete`, and `flush` cache APIs from gcache are outside
|
|
|
225
224
|
DialCache's portable contract; writes come from successful source loads and
|
|
226
225
|
entity-level invalidation is explicit.
|
|
227
226
|
|
|
228
|
-
## Development
|
|
227
|
+
## Development
|
|
229
228
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
python/.venv/bin/python -m pip install -e './python[test,redis]'
|
|
235
|
-
python/.venv/bin/python -m pytest python/tests
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
The native tests cover Python API behavior, policy validation, scope lifetime,
|
|
239
|
-
local expiry, cancellation, and wire boundaries. Shared replay runs the real
|
|
240
|
-
Python API through the repository's Node coordinator. Its inputs and expected
|
|
241
|
-
observations come from the same Quint-generated histories used by the other
|
|
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)
|
|
245
|
-
for portable behavior versus native adapter obligations.
|
|
229
|
+
Run the checks from the repository root: `make check-python` runs the native tests,
|
|
230
|
+
`make formal-python` replays the generated corpus, and `make integration-python`
|
|
231
|
+
runs the real-server checks. [Validation and evidence](https://github.com/lan17/DialCache/blob/main/formal/guides/VALIDATION.md) lists the
|
|
232
|
+
prerequisites and the other lanes.
|
|
@@ -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
|