bounded-embodiment 0.1.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.
- bounded_embodiment-0.1.0/.gitignore +15 -0
- bounded_embodiment-0.1.0/PKG-INFO +34 -0
- bounded_embodiment-0.1.0/README.md +17 -0
- bounded_embodiment-0.1.0/pyproject.toml +34 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/__init__.py +2 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/canonical.py +311 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/cli.py +399 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/commandspace.py +354 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/envelope/__init__.py +11 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/envelope/convert.py +221 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/envelope/geometry.py +261 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/envelope/load.py +384 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/envelope/model.py +226 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/findings.py +28 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/generator.py +689 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/profiles/ev03-illustrative-0.1.json +31 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/profiles/ev10-illustrative-0.1.json +23 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/profiles.py +62 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/rawdata.py +543 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/__init__.py +67 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/build.py +253 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/cli.py +150 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/derive.py +805 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/files.py +339 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/streams.py +326 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/verify.py +549 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/report/waypoints.py +164 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schema.py +203 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schemas/command-space-0.1.json +95 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schemas/envelope-0.1.json +151 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schemas/envelope-0.2.json +273 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schemas/gate-decision-0.1.json +54 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schemas/key-0.1.json +63 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schemas/test-report-0.1.json +951 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/schemas/waypoints-0.1.json +59 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/scoring/__init__.py +49 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/scoring/common.py +500 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/scoring/ev03.py +641 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/scoring/ev10.py +246 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/scoring/verdict.py +490 -0
- bounded_embodiment-0.1.0/src/bounded_embodiment/signing.py +430 -0
- bounded_embodiment-0.1.0/tests/_report_helpers.py +88 -0
- bounded_embodiment-0.1.0/tests/_scoring_synth.py +383 -0
- bounded_embodiment-0.1.0/tests/test_canonical.py +350 -0
- bounded_embodiment-0.1.0/tests/test_canonical_node.py +221 -0
- bounded_embodiment-0.1.0/tests/test_commandspace.py +290 -0
- bounded_embodiment-0.1.0/tests/test_core_cli.py +358 -0
- bounded_embodiment-0.1.0/tests/test_envelope_convert.py +270 -0
- bounded_embodiment-0.1.0/tests/test_envelope_geometry.py +361 -0
- bounded_embodiment-0.1.0/tests/test_envelope_load.py +354 -0
- bounded_embodiment-0.1.0/tests/test_envelope_schema.py +120 -0
- bounded_embodiment-0.1.0/tests/test_generator.py +704 -0
- bounded_embodiment-0.1.0/tests/test_no_float_sum.py +130 -0
- bounded_embodiment-0.1.0/tests/test_package_data.py +86 -0
- bounded_embodiment-0.1.0/tests/test_rcan_spec_parity.py +50 -0
- bounded_embodiment-0.1.0/tests/test_report_build.py +326 -0
- bounded_embodiment-0.1.0/tests/test_report_cli.py +212 -0
- bounded_embodiment-0.1.0/tests/test_report_schema.py +169 -0
- bounded_embodiment-0.1.0/tests/test_report_streams.py +259 -0
- bounded_embodiment-0.1.0/tests/test_report_verify.py +610 -0
- bounded_embodiment-0.1.0/tests/test_schema.py +276 -0
- bounded_embodiment-0.1.0/tests/test_scoring_ev03.py +867 -0
- bounded_embodiment-0.1.0/tests/test_scoring_ev10.py +289 -0
- bounded_embodiment-0.1.0/tests/test_scoring_golden.py +165 -0
- bounded_embodiment-0.1.0/tests/test_scoring_profiles.py +73 -0
- bounded_embodiment-0.1.0/tests/test_scoring_rawdata.py +324 -0
- bounded_embodiment-0.1.0/tests/test_signing.py +334 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bounded-embodiment
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Bounded Embodiment test method, executable: canonical JSON, signing, envelopes, the hostile command generator, scoring and report verification.
|
|
5
|
+
Project-URL: Repository, https://github.com/RobotRegistryFoundation/bounded-embodiment
|
|
6
|
+
Author: Craig Merry
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: cryptography>=42
|
|
13
|
+
Requires-Dist: jsonschema>=4.18
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# bounded-embodiment (core)
|
|
19
|
+
|
|
20
|
+
The core of the Bounded Embodiment test method's reference implementation: loaders for envelopes,
|
|
21
|
+
command spaces and waypoints; the command streams `be-hostile/1`, `be-control/1` and `be-waypoints/1`;
|
|
22
|
+
the scorers `ev03-tool-point/1` and `ev10-waypoints/1`; the signed test report; and `be verify`, which
|
|
23
|
+
recomputes a report's verdict from its raw data. Pure Python: `jsonschema` and `cryptography`, nothing
|
|
24
|
+
else.
|
|
25
|
+
|
|
26
|
+
Method v0.1 is a draft for comment with illustrative thresholds. A verdict is a finding about one run,
|
|
27
|
+
never a certification.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
be verify SUBMISSION_DIR --keys keys.json
|
|
31
|
+
be keygen --out lab.key.json --lab my-lab --contact lab@example.org
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Source, method text and documentation: https://github.com/RobotRegistryFoundation/bounded-embodiment
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# bounded-embodiment (core)
|
|
2
|
+
|
|
3
|
+
The core of the Bounded Embodiment test method's reference implementation: loaders for envelopes,
|
|
4
|
+
command spaces and waypoints; the command streams `be-hostile/1`, `be-control/1` and `be-waypoints/1`;
|
|
5
|
+
the scorers `ev03-tool-point/1` and `ev10-waypoints/1`; the signed test report; and `be verify`, which
|
|
6
|
+
recomputes a report's verdict from its raw data. Pure Python: `jsonschema` and `cryptography`, nothing
|
|
7
|
+
else.
|
|
8
|
+
|
|
9
|
+
Method v0.1 is a draft for comment with illustrative thresholds. A verdict is a finding about one run,
|
|
10
|
+
never a certification.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
be verify SUBMISSION_DIR --keys keys.json
|
|
14
|
+
be keygen --out lab.key.json --lab my-lab --contact lab@example.org
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Source, method text and documentation: https://github.com/RobotRegistryFoundation/bounded-embodiment
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bounded-embodiment"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The Bounded Embodiment test method, executable: canonical JSON, signing, envelopes, the hostile command generator, scoring and report verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Craig Merry" }]
|
|
13
|
+
dependencies = ["jsonschema>=4.18", "cryptography>=42"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Scientific/Engineering",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest>=8"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
be = "bounded_embodiment.cli:main"
|
|
25
|
+
bounded-embodiment = "bounded_embodiment.cli:main"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/RobotRegistryFoundation/bounded-embodiment"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/bounded_embodiment"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""RFC 8785 JSON Canonicalization Scheme (JCS), and a strict JSON loader.
|
|
2
|
+
|
|
3
|
+
``canonicalize(obj)`` returns the canonical UTF-8 bytes of a JSON value:
|
|
4
|
+
|
|
5
|
+
* no whitespace;
|
|
6
|
+
* object members sorted by the UTF-16 code units of their names;
|
|
7
|
+
* strings escaped exactly as ECMAScript ``JSON.stringify`` escapes them
|
|
8
|
+
(``\\" \\\\ \\b \\f \\n \\r \\t``, ``\\u00xx`` in lower-case hex for the
|
|
9
|
+
other C0 controls, everything else as literal UTF-8);
|
|
10
|
+
* numbers serialized as ECMAScript ``Number.prototype.toString`` does
|
|
11
|
+
(RFC 8785 section 3.2.2.3), so ``5e-05`` becomes ``0.00005``, ``1e21``
|
|
12
|
+
becomes ``1e+21`` and ``-0.0`` becomes ``0``.
|
|
13
|
+
|
|
14
|
+
Python ``int`` values are exact, so an integer above 2**53 in magnitude cannot
|
|
15
|
+
be carried by an IEEE 754 double without possibly changing it; it raises
|
|
16
|
+
``CanonicalError`` instead of being rounded. A ``float`` is already a double and
|
|
17
|
+
is serialized whatever its magnitude. NaN, the infinities, lone surrogates and
|
|
18
|
+
non-string object keys all raise ``CanonicalError``.
|
|
19
|
+
|
|
20
|
+
``loads_strict(data)`` parses JSON text and rejects what would make two
|
|
21
|
+
readers disagree about a document: duplicate object member names at any
|
|
22
|
+
depth, the non-JSON literals ``NaN``/``Infinity``/``-Infinity``, numbers that
|
|
23
|
+
overflow a double, lone surrogates, invalid UTF-8 and a leading byte order mark.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal, localcontext
|
|
35
|
+
|
|
36
|
+
__all__ = ["MAX_EXACT_INT", "CanonicalError", "canonicalize", "loads_strict", "sha256_jcs"]
|
|
37
|
+
|
|
38
|
+
MAX_EXACT_INT = 2**53
|
|
39
|
+
"""Largest integer magnitude ``canonicalize`` accepts for a Python ``int``."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CanonicalError(ValueError):
|
|
43
|
+
"""A value cannot be canonicalized, or JSON text is not acceptable."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- strings
|
|
47
|
+
|
|
48
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
49
|
+
_NEEDS_ESCAPE = re.compile('[\\x00-\\x1f"\\\\]')
|
|
50
|
+
_ESCAPES = {
|
|
51
|
+
'"': '\\"',
|
|
52
|
+
"\\": "\\\\",
|
|
53
|
+
"\b": "\\b",
|
|
54
|
+
"\f": "\\f",
|
|
55
|
+
"\n": "\\n",
|
|
56
|
+
"\r": "\\r",
|
|
57
|
+
"\t": "\\t",
|
|
58
|
+
}
|
|
59
|
+
for _code in range(0x20):
|
|
60
|
+
_ESCAPES.setdefault(chr(_code), f"\\u{_code:04x}")
|
|
61
|
+
del _code
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _escape_match(match: re.Match[str]) -> str:
|
|
65
|
+
return _ESCAPES[match.group()]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _string(s: str) -> str:
|
|
69
|
+
found = _SURROGATE.search(s)
|
|
70
|
+
if found is not None:
|
|
71
|
+
raise CanonicalError(
|
|
72
|
+
f"string contains a lone surrogate U+{ord(found.group()):04X} at index {found.start()}"
|
|
73
|
+
)
|
|
74
|
+
return '"' + _NEEDS_ESCAPE.sub(_escape_match, s) + '"'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _utf16_sort_key(name: str) -> bytes:
|
|
78
|
+
# Big-endian UTF-16 bytes compare exactly as sequences of UTF-16 code units.
|
|
79
|
+
return name.encode("utf-16-be")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------- numbers
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _shortest_digits_repr(x: float) -> tuple[str, int]:
|
|
86
|
+
"""Shortest round-trip digits of a positive finite double, via CPython's repr.
|
|
87
|
+
|
|
88
|
+
Returns ``(s, n)`` with ``s`` the decimal digits (no leading or trailing
|
|
89
|
+
zeros) and ``x == 0.s * 10**n``.
|
|
90
|
+
"""
|
|
91
|
+
text = float.__repr__(x)
|
|
92
|
+
mantissa, _, exp = text.partition("e")
|
|
93
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
94
|
+
digits = (int_part + frac_part).lstrip("0")
|
|
95
|
+
s = digits.rstrip("0")
|
|
96
|
+
point = (int(exp) if exp else 0) - len(frac_part) + (len(digits) - len(s))
|
|
97
|
+
return s, point + len(s)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _shortest_digits_decimal(x: float) -> tuple[str, int]:
|
|
101
|
+
"""The same as ``_shortest_digits_repr`` without relying on repr.
|
|
102
|
+
|
|
103
|
+
Used only where ``sys.float_repr_style`` is not ``"short"``. For each
|
|
104
|
+
precision k from 1 to 17 it takes the two k-digit decimals around x, keeps
|
|
105
|
+
those that read back as x, and picks the closer one (the even one on a
|
|
106
|
+
tie), which is the choice ECMAScript Number::toString specifies.
|
|
107
|
+
"""
|
|
108
|
+
exact = Decimal(x)
|
|
109
|
+
for k in range(1, 18):
|
|
110
|
+
candidates = []
|
|
111
|
+
for rounding in (ROUND_FLOOR, ROUND_CEILING):
|
|
112
|
+
with localcontext() as ctx:
|
|
113
|
+
ctx.prec = k
|
|
114
|
+
ctx.rounding = rounding
|
|
115
|
+
c = +exact
|
|
116
|
+
if float(c) == x and c not in candidates:
|
|
117
|
+
candidates.append(c)
|
|
118
|
+
if not candidates:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
def rank(c: Decimal, k: int = k) -> tuple[Decimal, int]:
|
|
122
|
+
with localcontext() as ctx:
|
|
123
|
+
ctx.prec = 2000 # exact: a double has at most 767 significant digits
|
|
124
|
+
distance = abs(c - exact)
|
|
125
|
+
significand = int(c.scaleb(k - 1 - c.adjusted()))
|
|
126
|
+
return (distance, significand % 2)
|
|
127
|
+
|
|
128
|
+
best = min(candidates, key=rank)
|
|
129
|
+
sign, digit_tuple, exponent = best.as_tuple()
|
|
130
|
+
s = "".join(map(str, digit_tuple)).lstrip("0")
|
|
131
|
+
stripped = s.rstrip("0")
|
|
132
|
+
point = int(exponent) + (len(s) - len(stripped))
|
|
133
|
+
return stripped, point + len(stripped)
|
|
134
|
+
raise AssertionError(f"no round-trip representation found for {x!r}") # pragma: no cover
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_shortest_digits = (
|
|
138
|
+
_shortest_digits_repr if sys.float_repr_style == "short" else _shortest_digits_decimal
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _format_positive(x: float) -> str:
|
|
143
|
+
s, n = _shortest_digits(x)
|
|
144
|
+
k = len(s)
|
|
145
|
+
if k <= n <= 21:
|
|
146
|
+
return s + "0" * (n - k)
|
|
147
|
+
if 0 < n <= 21:
|
|
148
|
+
return s[:n] + "." + s[n:]
|
|
149
|
+
if -6 < n <= 0:
|
|
150
|
+
return "0." + "0" * (-n) + s
|
|
151
|
+
e = n - 1
|
|
152
|
+
sign = "+" if e >= 0 else "-"
|
|
153
|
+
if k == 1:
|
|
154
|
+
return f"{s}e{sign}{abs(e)}"
|
|
155
|
+
return f"{s[0]}.{s[1:]}e{sign}{abs(e)}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _number(x: float) -> str:
|
|
159
|
+
"""ECMAScript Number::toString(x) for a finite double."""
|
|
160
|
+
if math.isnan(x) or math.isinf(x):
|
|
161
|
+
raise CanonicalError(f"{x!r} is not a JSON number")
|
|
162
|
+
if x == 0:
|
|
163
|
+
return "0" # also -0.0
|
|
164
|
+
if x < 0:
|
|
165
|
+
return "-" + _format_positive(-x)
|
|
166
|
+
return _format_positive(x)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _integer(n: int) -> str:
|
|
170
|
+
if n > MAX_EXACT_INT or n < -MAX_EXACT_INT:
|
|
171
|
+
raise CanonicalError(
|
|
172
|
+
f"integer {n} is larger than 2**53 in magnitude and cannot be represented "
|
|
173
|
+
"exactly as an IEEE 754 double"
|
|
174
|
+
)
|
|
175
|
+
return str(int(n))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------------------- values
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _serialize(value: object, out: list[str]) -> None:
|
|
182
|
+
if value is None:
|
|
183
|
+
out.append("null")
|
|
184
|
+
elif value is True:
|
|
185
|
+
out.append("true")
|
|
186
|
+
elif value is False:
|
|
187
|
+
out.append("false")
|
|
188
|
+
elif isinstance(value, str):
|
|
189
|
+
out.append(_string(value))
|
|
190
|
+
elif isinstance(value, int): # bool cannot be subclassed, so True/False never get here
|
|
191
|
+
out.append(_integer(value))
|
|
192
|
+
elif isinstance(value, float):
|
|
193
|
+
out.append(_number(float(value)))
|
|
194
|
+
elif isinstance(value, Mapping):
|
|
195
|
+
names = list(value.keys())
|
|
196
|
+
for name in names:
|
|
197
|
+
if not isinstance(name, str):
|
|
198
|
+
raise CanonicalError(f"object member name {name!r} is not a string")
|
|
199
|
+
try:
|
|
200
|
+
names.sort(key=_utf16_sort_key)
|
|
201
|
+
except UnicodeEncodeError:
|
|
202
|
+
bad = next(n for n in names if _SURROGATE.search(n))
|
|
203
|
+
raise CanonicalError(f"object member name {bad!r} contains a lone surrogate") from None
|
|
204
|
+
out.append("{")
|
|
205
|
+
for i, name in enumerate(names):
|
|
206
|
+
if i:
|
|
207
|
+
out.append(",")
|
|
208
|
+
out.append(_string(name))
|
|
209
|
+
out.append(":")
|
|
210
|
+
_serialize(value[name], out)
|
|
211
|
+
out.append("}")
|
|
212
|
+
elif isinstance(value, (list, tuple)):
|
|
213
|
+
out.append("[")
|
|
214
|
+
for i, item in enumerate(value):
|
|
215
|
+
if i:
|
|
216
|
+
out.append(",")
|
|
217
|
+
_serialize(item, out)
|
|
218
|
+
out.append("]")
|
|
219
|
+
else:
|
|
220
|
+
raise CanonicalError(f"{type(value).__name__} is not a JSON type")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def canonicalize(obj: object) -> bytes:
|
|
224
|
+
"""Return the RFC 8785 canonical UTF-8 bytes of a JSON-compatible value."""
|
|
225
|
+
out: list[str] = []
|
|
226
|
+
try:
|
|
227
|
+
_serialize(obj, out)
|
|
228
|
+
except RecursionError:
|
|
229
|
+
raise CanonicalError("value is nested too deeply, or contains a cycle") from None
|
|
230
|
+
return "".join(out).encode("utf-8")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def sha256_jcs(obj: object) -> str:
|
|
234
|
+
"""``"sha256:"`` followed by the lower-hex SHA-256 of ``canonicalize(obj)``."""
|
|
235
|
+
return "sha256:" + hashlib.sha256(canonicalize(obj)).hexdigest()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --------------------------------------------------------------------------- strict loading
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _reject_constant(name: str) -> object:
|
|
242
|
+
raise CanonicalError(f"{name} is not valid JSON")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_float(text: str) -> float:
|
|
246
|
+
value = float(text)
|
|
247
|
+
if math.isinf(value):
|
|
248
|
+
raise CanonicalError(f"number {text} is out of range for an IEEE 754 double")
|
|
249
|
+
return value
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _no_duplicates(pairs: list[tuple[str, object]]) -> dict:
|
|
253
|
+
obj: dict = {}
|
|
254
|
+
for name, value in pairs:
|
|
255
|
+
if name in obj:
|
|
256
|
+
raise CanonicalError(f"duplicate object member name {name!r}")
|
|
257
|
+
obj[name] = value
|
|
258
|
+
return obj
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _check_strings(root: object) -> None:
|
|
262
|
+
stack = [root]
|
|
263
|
+
while stack:
|
|
264
|
+
value = stack.pop()
|
|
265
|
+
if isinstance(value, str):
|
|
266
|
+
if _SURROGATE.search(value):
|
|
267
|
+
raise CanonicalError(f"string {value!r} contains a lone surrogate")
|
|
268
|
+
elif isinstance(value, dict):
|
|
269
|
+
for name, item in value.items():
|
|
270
|
+
if _SURROGATE.search(name):
|
|
271
|
+
raise CanonicalError(f"object member name {name!r} contains a lone surrogate")
|
|
272
|
+
stack.append(item)
|
|
273
|
+
elif isinstance(value, list):
|
|
274
|
+
stack.extend(value)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def loads_strict(data: bytes | str) -> object:
|
|
278
|
+
"""Parse JSON text strictly.
|
|
279
|
+
|
|
280
|
+
Bytes must be UTF-8. Rejects, with ``CanonicalError``: duplicate member
|
|
281
|
+
names at any depth (compared after unescaping), ``NaN``, ``Infinity``,
|
|
282
|
+
``-Infinity``, numbers that overflow a double, lone surrogates (escaped or
|
|
283
|
+
raw), a leading byte order mark, and anything that is not JSON. Integers
|
|
284
|
+
come back as ``int`` and other numbers as ``float``.
|
|
285
|
+
"""
|
|
286
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
287
|
+
try:
|
|
288
|
+
text = bytes(data).decode("utf-8")
|
|
289
|
+
except UnicodeDecodeError as exc:
|
|
290
|
+
raise CanonicalError(f"JSON text is not valid UTF-8: {exc}") from None
|
|
291
|
+
elif isinstance(data, str):
|
|
292
|
+
text = data
|
|
293
|
+
else:
|
|
294
|
+
raise TypeError(f"loads_strict expects bytes or str, not {type(data).__name__}")
|
|
295
|
+
if text.startswith("\ufeff"):
|
|
296
|
+
raise CanonicalError("JSON text starts with a byte order mark (U+FEFF); remove it")
|
|
297
|
+
try:
|
|
298
|
+
value = json.loads(
|
|
299
|
+
text,
|
|
300
|
+
object_pairs_hook=_no_duplicates,
|
|
301
|
+
parse_constant=_reject_constant,
|
|
302
|
+
parse_float=_parse_float,
|
|
303
|
+
)
|
|
304
|
+
except CanonicalError:
|
|
305
|
+
raise
|
|
306
|
+
except RecursionError:
|
|
307
|
+
raise CanonicalError("JSON text is nested too deeply") from None
|
|
308
|
+
except ValueError as exc:
|
|
309
|
+
raise CanonicalError(f"invalid JSON: {exc}") from None
|
|
310
|
+
_check_strings(value)
|
|
311
|
+
return value
|