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.
- {dialcache-0.26.0 → dialcache-0.27.0}/API-DESIGN.md +1 -1
- {dialcache-0.26.0 → dialcache-0.27.0}/PKG-INFO +4 -4
- {dialcache-0.26.0 → dialcache-0.27.0}/README.md +3 -3
- dialcache-0.27.0/dialcache/_callbacks.py +27 -0
- dialcache-0.27.0/dialcache/_shadow_log.py +324 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/cache.py +140 -58
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/config.py +120 -12
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/serializer.py +13 -5
- {dialcache-0.26.0 → dialcache-0.27.0}/pyproject.toml +1 -1
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/driver.py +63 -14
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/scenarios.py +1 -1
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/witness.py +9 -9
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/run_conformance.py +2 -2
- dialcache-0.27.0/tests/test_config.py +404 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_conformance.py +2 -2
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_integration_acceptance.py +3 -3
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_observer_recovery_boundaries.py +85 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_protocol_vectors.py +36 -7
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_redis_integration.py +5 -2
- dialcache-0.27.0/tests/test_shadow_log.py +826 -0
- dialcache-0.27.0/tests/test_shadow_mismatch_logging.py +797 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_validation_environment.py +1 -1
- dialcache-0.26.0/tests/test_config.py +0 -142
- {dialcache-0.26.0 → dialcache-0.27.0}/.gitignore +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/LICENSE +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/__init__.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/clock.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/context.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/errors.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/key.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/local.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/metrics.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/protocol.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/py.typed +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/dialcache/redis.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/__init__.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/coordinator.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/executor.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/schema.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/formal/simple_drivers.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/run_integration.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_cache.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_cached_keys.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_callback_cancellation.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_cluster_read_routing.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_context.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_dependency_cancellation.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_docs_examples.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_engine_review.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_local.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_protocol_native.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_redis_adapter.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_review_regressions.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_shadow_deadline_retention.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_typing.py +0 -0
- {dialcache-0.26.0 → dialcache-0.27.0}/tests/test_wire_suite.py +0 -0
- {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.
|
|
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
|