matview-cache 0.1.0__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.
matview/canon.py ADDED
@@ -0,0 +1,116 @@
1
+ """Key canonicalization, per `SPEC.md`.
2
+
3
+ This is half of the interoperability contract. Two implementations that disagree here
4
+ do not error; they miss on every lookup and look like an ordinary cold cache. Change
5
+ it only by bumping `fingerprint_spec_version`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Mapping
12
+ from typing import Any, Final
13
+
14
+ import rfc8785
15
+
16
+ from matview.errors import InvalidKey
17
+
18
+ __all__ = [
19
+ "RESERVED_KEY_FIELDS",
20
+ "Scalar",
21
+ "canonicalize_key",
22
+ "index_value",
23
+ "jcs",
24
+ "utf16_sort_key",
25
+ ]
26
+
27
+ Scalar = str | int | float | bool | None
28
+
29
+ #: ECMAScript's MAX_SAFE_INTEGER. JCS numbers are doubles, so this is the range in
30
+ #: which an int and an integral float are unambiguously the same number and render
31
+ #: identically. RFC 8785 serializers reject integers beyond it, so matview does too,
32
+ #: rather than letting the rejection surface from inside a dependency.
33
+ SAFE_INTEGER: Final = 2**53 - 1
34
+
35
+ #: Policy arguments to `get`, which the spec's own API mixes with key fields in one
36
+ #: `**kwargs`. A recipe parameter with one of these names would be silently swallowed
37
+ #: by the policy layer, so it is rejected at registration instead. See DECISIONS.md D18.
38
+ RESERVED_KEY_FIELDS: Final = frozenset({"on_miss", "force", "force_deps", "deadline", "part"})
39
+
40
+
41
+ def normalize_scalar(field: str, value: Any) -> Scalar:
42
+ """Validate and normalize one key field.
43
+
44
+ Key fields must be scalars. This is a restriction and it is deliberate: nested
45
+ params make canonicalization far harder to get right across languages.
46
+ """
47
+ if value is None or isinstance(value, (str, bool)):
48
+ return value # bool first: it is a subclass of int, and JCS renders it `true`.
49
+ if isinstance(value, int):
50
+ if abs(value) > SAFE_INTEGER:
51
+ raise InvalidKey(
52
+ f"key field {field!r} is {value}, outside the exact-integer range of a "
53
+ f"double (±(2^53 - 1)); no JCS implementation can render it identically"
54
+ )
55
+ return value
56
+ if isinstance(value, float):
57
+ if math.isnan(value) or math.isinf(value):
58
+ raise InvalidKey(f"key field {field!r} is {value!r}; NaN and infinities are rejected")
59
+ # An integral float within the safe range *is* the corresponding int, and the
60
+ # two must hash the same. Getting this wrong silently splits a cache in two.
61
+ if value.is_integer() and abs(value) <= SAFE_INTEGER:
62
+ return int(value)
63
+ return value
64
+ raise InvalidKey(
65
+ f"key field {field!r} has type {type(value).__name__}; key fields must be "
66
+ f"string, int, float, bool or null, with no nested structures"
67
+ )
68
+
69
+
70
+ def canonicalize_key(key: Mapping[str, Any]) -> dict[str, Scalar]:
71
+ """Normalize a key dict. Defaults must already have been filled in by the caller."""
72
+ out: dict[str, Scalar] = {}
73
+ for field, value in key.items():
74
+ if not isinstance(field, str):
75
+ raise InvalidKey(f"key field names must be strings, got {field!r}")
76
+ out[field] = normalize_scalar(field, value)
77
+ return out
78
+
79
+
80
+ def jcs(value: Any) -> bytes:
81
+ """Serialize with the JSON Canonicalization Scheme (RFC 8785).
82
+
83
+ Object keys sorted by UTF-16 code unit, no insignificant whitespace, UTF-8 output,
84
+ ES6 number rendering. Delegated rather than hand-rolled: the hard part is number
85
+ formatting, and getting that wrong shows up as a cold cache, not an exception.
86
+ See DECISIONS.md D3.
87
+ """
88
+ try:
89
+ return rfc8785.dumps(value)
90
+ except Exception as exc: # pragma: no cover - rfc8785 raises several types
91
+ raise InvalidKey(f"value is not JCS-serializable: {exc}") from exc
92
+
93
+
94
+ def utf16_sort_key(value: str | None) -> tuple[int, bytes]:
95
+ """Order strings by UTF-16 code unit, matching JCS, with `None` sorting first.
96
+
97
+ Comparing UTF-16BE encodings byte-wise is the same ordering as comparing the code
98
+ unit sequences numerically, surrogate pairs included. The leading int is what puts
99
+ a NULL part before every string part. See DECISIONS.md D15.
100
+ """
101
+ if value is None:
102
+ return (0, b"")
103
+ return (1, value.encode("utf-16-be"))
104
+
105
+
106
+ def index_value(value: Scalar) -> str:
107
+ """Render a key field for promotion into `artifact_key`.
108
+
109
+ The column is `TEXT NOT NULL`, but indexed fields may be ints, floats or bools, so
110
+ they are stored as their canonical JCS scalar rendering: `200` not `"200"`, `true`
111
+ not `"True"`. That keeps `query()` consistent with `key_json` and the promotion
112
+ lossless. See DECISIONS.md D8.
113
+ """
114
+ if isinstance(value, str):
115
+ return value
116
+ return jcs(value).decode("utf-8")