continuo-contracts 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.
@@ -0,0 +1,10 @@
1
+ """Canonical JSON, the Python code projection, the content_hash folds and the fingerprint
2
+ field declaration that continuo and its producers share.
3
+
4
+ A producer's CI, continuo's topology-controller and the runtime harness import the same
5
+ functions, so they compute the same bytes and the same hashes for a node.
6
+ """
7
+
8
+ import importlib.metadata
9
+
10
+ __version__ = importlib.metadata.version("continuo-contracts")
@@ -0,0 +1,183 @@
1
+ """RFC 8785 canonical JSON and the text normaliser.
2
+
3
+ `canonical_json` writes a value as the byte string that RFC 8785 (the JSON Canonicalization
4
+ Scheme) defines, so two processes that hold the same value write the same bytes.
5
+ `normalise_text` makes a text independent of its line endings and of the whitespace around it.
6
+ """
7
+
8
+ import math
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Final
11
+
12
+ from continuo_contracts.errors import CanonicalJsonError
13
+
14
+ # The integers every JSON implementation reads back exactly: -(2**53 - 1) to 2**53 - 1.
15
+ _MAX_SAFE_INTEGER: Final = 2**53 - 1
16
+
17
+ # The deepest nesting `canonical_json` writes: the outermost list, tuple or mapping is level 1, a
18
+ # container inside it is level 2, and so on. A container on a deeper level is refused, so the
19
+ # result does not depend on how deep the running interpreter can recurse. A scalar has no level
20
+ # of its own: a scalar inside a container on level `MAX_DEPTH` is written.
21
+ MAX_DEPTH: Final = 100
22
+
23
+ # ECMA-262 Number::toString writes plain digits while the decimal exponent n of a value
24
+ # 0.d * 10**n lies in -6 < n <= 21, and the exponent form outside that range.
25
+ _MIN_PLAIN_EXPONENT: Final = -6
26
+ _MAX_PLAIN_EXPONENT: Final = 21
27
+
28
+ # The only escapes RFC 8785 writes: the quote, the backslash and the control characters.
29
+ # Every other code point is written as it is.
30
+ _STRING_ESCAPES: Final = {
31
+ **{code: f"\\u{code:04x}" for code in range(0x20)},
32
+ 0x08: "\\b",
33
+ 0x09: "\\t",
34
+ 0x0A: "\\n",
35
+ 0x0C: "\\f",
36
+ 0x0D: "\\r",
37
+ 0x22: '\\"',
38
+ 0x5C: "\\\\",
39
+ }
40
+
41
+ # Space, tab, line feed, carriage return, vertical tab and form feed.
42
+ _ASCII_WHITESPACE: Final = " \t\n\r\x0b\x0c"
43
+
44
+
45
+ def canonical_json(value: object) -> bytes:
46
+ """Return `value` as RFC 8785 canonical JSON, UTF-8 encoded.
47
+
48
+ `None`, `bool`, `int` (within -(2**53 - 1) to 2**53 - 1), finite `float`, `str`, `list`,
49
+ `tuple` and `Mapping` with `str` keys are supported. Any other value, a number outside
50
+ the range above, NaN, an infinity, a lone surrogate, a container that contains itself and a
51
+ container nested deeper than `MAX_DEPTH` levels raise `CanonicalJsonError`, whose message
52
+ starts with the JSON path of the bad value (of the container, for the nesting).
53
+ """
54
+ out: list[str] = []
55
+ _write(value, "$", out, set())
56
+ return "".join(out).encode("utf-8")
57
+
58
+
59
+ def normalise_text(text: str) -> str:
60
+ """Return `text` with CRLF and CR turned into LF and ASCII whitespace stripped at both ends.
61
+
62
+ Only the six ASCII whitespace characters are stripped, so the result does not depend on
63
+ the Unicode tables of the Python that runs it. Nothing else changes.
64
+ """
65
+ return text.replace("\r\n", "\n").replace("\r", "\n").strip(_ASCII_WHITESPACE)
66
+
67
+
68
+ def _fail(path: str, reason: str) -> CanonicalJsonError:
69
+ return CanonicalJsonError(f"{path}: {reason}")
70
+
71
+
72
+ def _write(value: object, path: str, out: list[str], active: set[int]) -> None:
73
+ if value is None:
74
+ out.append("null")
75
+ elif isinstance(value, bool):
76
+ out.append("true" if value else "false")
77
+ elif isinstance(value, int):
78
+ out.append(_integer(int(value), path))
79
+ elif isinstance(value, float):
80
+ out.append(_float(float(value), path))
81
+ elif isinstance(value, str):
82
+ _check_encodable(value, path, "string")
83
+ out.append(_quote(value))
84
+ elif isinstance(value, list | tuple):
85
+ _write_array(value, path, out, active)
86
+ elif isinstance(value, Mapping):
87
+ _write_object(value, path, out, active)
88
+ else:
89
+ raise _fail(path, f"unsupported type {type(value).__name__}")
90
+
91
+
92
+ def _write_array(items: Sequence[object], path: str, out: list[str], active: set[int]) -> None:
93
+ _enter(items, path, active)
94
+ out.append("[")
95
+ for index, item in enumerate(items):
96
+ if index:
97
+ out.append(",")
98
+ _write(item, f"{path}[{index}]", out, active)
99
+ out.append("]")
100
+ active.remove(id(items))
101
+
102
+
103
+ def _write_object(
104
+ mapping: Mapping[object, object], path: str, out: list[str], active: set[int]
105
+ ) -> None:
106
+ _enter(mapping, path, active)
107
+ entries: list[tuple[str, object]] = []
108
+ for key, item in mapping.items():
109
+ if not isinstance(key, str):
110
+ raise _fail(path, f"object key of type {type(key).__name__} is not a string")
111
+ _check_encodable(key, path, "object key")
112
+ entries.append((key, item))
113
+ entries.sort(key=lambda entry: entry[0].encode("utf-16-be"))
114
+ out.append("{")
115
+ for index, (key, item) in enumerate(entries):
116
+ if index:
117
+ out.append(",")
118
+ out.append(_quote(key) + ":")
119
+ _write(item, f"{path}.{key}", out, active)
120
+ out.append("}")
121
+ active.remove(id(mapping))
122
+
123
+
124
+ def _enter(container: object, path: str, active: set[int]) -> None:
125
+ if id(container) in active:
126
+ raise _fail(path, "circular reference")
127
+ # `active` holds the containers from the root down to the parent of `container`, so the
128
+ # level of `container` is one more than its size.
129
+ if len(active) >= MAX_DEPTH:
130
+ raise _fail(
131
+ path, f"nesting is too deep, a container may be at most {MAX_DEPTH} levels down"
132
+ )
133
+ active.add(id(container))
134
+
135
+
136
+ def _check_encodable(text: str, path: str, what: str) -> None:
137
+ try:
138
+ text.encode("utf-8")
139
+ except UnicodeEncodeError:
140
+ raise _fail(path, f"{what} holds a lone surrogate") from None
141
+
142
+
143
+ def _quote(text: str) -> str:
144
+ return '"' + text.translate(_STRING_ESCAPES) + '"'
145
+
146
+
147
+ def _integer(number: int, path: str) -> str:
148
+ if abs(number) > _MAX_SAFE_INTEGER:
149
+ raise _fail(path, "integer is outside -(2**53 - 1) to 2**53 - 1")
150
+ return str(number)
151
+
152
+
153
+ def _float(number: float, path: str) -> str:
154
+ if not math.isfinite(number):
155
+ raise _fail(path, "NaN and the infinities are not JSON numbers")
156
+ if number == 0:
157
+ return "0"
158
+ if number < 0:
159
+ return "-" + _positive_float(-number)
160
+ return _positive_float(number)
161
+
162
+
163
+ def _positive_float(number: float) -> str:
164
+ # The shortest round-trip digits that repr() gives, as the digit string `digits` and the
165
+ # exponent n for which the value is 0.<digits> * 10**n.
166
+ mantissa, _, exponent = repr(number).partition("e")
167
+ whole, _, fraction = mantissa.partition(".")
168
+ all_digits = whole + fraction
169
+ n = len(whole) + (int(exponent) if exponent else 0)
170
+ significant = all_digits.lstrip("0")
171
+ n -= len(all_digits) - len(significant)
172
+ digits = significant.rstrip("0")
173
+ k = len(digits)
174
+ if k <= n <= _MAX_PLAIN_EXPONENT:
175
+ return digits + "0" * (n - k)
176
+ if 0 < n <= _MAX_PLAIN_EXPONENT:
177
+ return digits[:n] + "." + digits[n:]
178
+ if _MIN_PLAIN_EXPONENT < n <= 0:
179
+ return "0." + "0" * -n + digits
180
+ power = n - 1
181
+ sign = "+" if power >= 0 else "-"
182
+ tail = "." + digits[1:] if k > 1 else ""
183
+ return f"{digits[0]}{tail}e{sign}{abs(power)}"
@@ -0,0 +1,44 @@
1
+ """The errors this package defines."""
2
+
3
+
4
+ class ContractsError(ValueError):
5
+ """The base of the errors this package defines, each raised for an input it refuses.
6
+
7
+ Some errors are not a `ContractsError`. `Fingerprints.entries` raises a plain `ValueError`
8
+ for a section that is not in `SECTIONS`, and `classify` and `unclassified` (the functions and
9
+ the methods of `Fingerprints`) raise one for a family that is not in `FAMILIES`. `fold3` and
10
+ `fold4` raise `UnicodeEncodeError` for a part that UTF-8 cannot encode, such as a lone
11
+ surrogate. An argument of the wrong type can raise Python's own error, such as `TypeError` or
12
+ `AttributeError`, instead of a `ContractsError`.
13
+ """
14
+
15
+
16
+ class CanonicalJsonError(ContractsError):
17
+ """A value that RFC 8785 canonical JSON cannot represent.
18
+
19
+ The message starts with the JSON path of the offending value, then ``: `` and the reason.
20
+ """
21
+
22
+
23
+ class HashInputError(ContractsError):
24
+ """A hash input in a form this package refuses, such as a unit key that is empty or a unit
25
+ digest that is not 64 lowercase hex characters.
26
+
27
+ The message names the offending unit key.
28
+ """
29
+
30
+
31
+ class ProjectionError(ContractsError):
32
+ """A Python node that the code projection refuses, or an SQL type that is not supported.
33
+
34
+ The message of an error about a node starts with its relation, `<schema>.<table>`, then
35
+ `: ` and the reason. `canonical_sql_type` has no node, so its messages carry no relation.
36
+ """
37
+
38
+
39
+ class FingerprintsFileError(ContractsError):
40
+ """A fingerprints file in a form this package refuses.
41
+
42
+ The message names the location in the file, such as `families.dbt-model[3] (meta_other)`,
43
+ then `: ` and the reason.
44
+ """