datumesh 0.2.0__tar.gz → 0.2.1__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.
- {datumesh-0.2.0/src/datumesh.egg-info → datumesh-0.2.1}/PKG-INFO +6 -6
- {datumesh-0.2.0 → datumesh-0.2.1}/README.md +2 -2
- {datumesh-0.2.0 → datumesh-0.2.1}/pyproject.toml +4 -4
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/__init__.py +3 -3
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_json.py +36 -6
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_keys.py +7 -5
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_uri.py +16 -8
- {datumesh-0.2.0 → datumesh-0.2.1/src/datumesh.egg-info}/PKG-INFO +6 -6
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh.egg-info/SOURCES.txt +1 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_api.py +2 -2
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_conformance.py +1 -1
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_jcs.py +7 -2
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_keys.py +12 -0
- datumesh-0.2.1/tests/test_spec_0_2_1.py +198 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_uri.py +4 -3
- {datumesh-0.2.0 → datumesh-0.2.1}/LICENSE +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/setup.cfg +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_envelope.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_errors.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_lexical.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_model.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_ucd15.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_unicode.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_unitlist.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_units.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/_verify.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh/py.typed +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh.egg-info/dependency_links.txt +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh.egg-info/requires.txt +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/src/datumesh.egg-info/top_level.txt +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_bitflips.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_blob.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_envelope.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_unicode.py +0 -0
- {datumesh-0.2.0 → datumesh-0.2.1}/tests/test_units.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datumesh
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Verify Datumesh receipts (spec-0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Verify Datumesh receipts (spec-0.2.1): RFC 8785 canonical JSON, Ed25519 signatures, blobs, units, cell URIs, response envelopes, key documents.
|
|
5
5
|
Author: Datumesh
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://datumesh.com
|
|
8
8
|
Project-URL: Repository, https://github.com/datumesh-com/datumesh-verify-py
|
|
9
|
-
Project-URL: Specification (spec-0.2.
|
|
10
|
-
Keywords: datumesh,spec-0.2.
|
|
9
|
+
Project-URL: Specification (spec-0.2.1), https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.1/SPEC.md
|
|
10
|
+
Keywords: datumesh,spec-0.2.1,receipt,verification,ed25519,rfc8785,jcs,canonical-json
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
@@ -27,11 +27,11 @@ Dynamic: license-file
|
|
|
27
27
|
|
|
28
28
|
# datumesh
|
|
29
29
|
|
|
30
|
-
Verify [Datumesh](https://datumesh.com) receipts in Python. A receipt is a signed answer for one addressable data cell; this library checks its canonical form (RFC 8785 JSON) and its Ed25519 signature against the signer's published keys, and optionally the recomputation checks (function descriptor, input receipts, and a value you computed) and the content of a blob value, as the [Datumesh specification](https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.
|
|
30
|
+
Verify [Datumesh](https://datumesh.com) receipts in Python. A receipt is a signed answer for one addressable data cell; this library checks its canonical form (RFC 8785 JSON) and its Ed25519 signature against the signer's published keys, and optionally the recomputation checks (function descriptor, input receipts, and a value you computed) and the content of a blob value, as the [Datumesh specification](https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.1/SPEC.md) (revision `spec-0.2.1`) defines. It also checks a mesh's response envelope and resolves the unit of a cell's value.
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
33
|
pip install datumesh
|
|
34
|
-
git clone --depth 1 --branch spec-0.2.
|
|
34
|
+
git clone --depth 1 --branch spec-0.2.1 https://github.com/datumesh-com/datumesh-spec && cd datumesh-spec
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
```python
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# datumesh
|
|
2
2
|
|
|
3
|
-
Verify [Datumesh](https://datumesh.com) receipts in Python. A receipt is a signed answer for one addressable data cell; this library checks its canonical form (RFC 8785 JSON) and its Ed25519 signature against the signer's published keys, and optionally the recomputation checks (function descriptor, input receipts, and a value you computed) and the content of a blob value, as the [Datumesh specification](https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.
|
|
3
|
+
Verify [Datumesh](https://datumesh.com) receipts in Python. A receipt is a signed answer for one addressable data cell; this library checks its canonical form (RFC 8785 JSON) and its Ed25519 signature against the signer's published keys, and optionally the recomputation checks (function descriptor, input receipts, and a value you computed) and the content of a blob value, as the [Datumesh specification](https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.1/SPEC.md) (revision `spec-0.2.1`) defines. It also checks a mesh's response envelope and resolves the unit of a cell's value.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
pip install datumesh
|
|
7
|
-
git clone --depth 1 --branch spec-0.2.
|
|
7
|
+
git clone --depth 1 --branch spec-0.2.1 https://github.com/datumesh-com/datumesh-spec && cd datumesh-spec
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
```python
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "datumesh"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "Verify Datumesh receipts (spec-0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "Verify Datumesh receipts (spec-0.2.1): RFC 8785 canonical JSON, Ed25519 signatures, blobs, units, cell URIs, response envelopes, key documents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
authors = [{ name = "Datumesh" }]
|
|
14
|
-
keywords = ["datumesh", "spec-0.2.
|
|
14
|
+
keywords = ["datumesh", "spec-0.2.1", "receipt", "verification", "ed25519", "rfc8785", "jcs", "canonical-json"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 3 - Alpha",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
@@ -30,7 +30,7 @@ dependencies = ["pynacl>=1.5.0"]
|
|
|
30
30
|
[project.urls]
|
|
31
31
|
Homepage = "https://datumesh.com"
|
|
32
32
|
Repository = "https://github.com/datumesh-com/datumesh-verify-py"
|
|
33
|
-
"Specification (spec-0.2.
|
|
33
|
+
"Specification (spec-0.2.1)" = "https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.1/SPEC.md"
|
|
34
34
|
|
|
35
35
|
[tool.setuptools.packages.find]
|
|
36
36
|
where = ["src"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""datumesh: verify Datumesh receipts (signed answers for addressable data cells).
|
|
2
2
|
|
|
3
|
-
Implements the Datumesh protocol specification, revision ``spec-0.2.
|
|
3
|
+
Implements the Datumesh protocol specification, revision ``spec-0.2.1``
|
|
4
4
|
(https://github.com/datumesh-com/datumesh-spec): RFC 8785 canonical JSON,
|
|
5
5
|
SHA-256 hashes, Ed25519 receipt signatures, blob content, units, cell URIs,
|
|
6
6
|
response envelopes and key documents. Only :func:`fetch_keys` touches the network.
|
|
@@ -19,9 +19,9 @@ from ._keys import Key, KeysResult, fetch_keys, key_id, keys_from_response
|
|
|
19
19
|
from ._uri import format_uri, parse_uri, uri_to_https
|
|
20
20
|
from ._verify import VerifyResult, load, payload_bytes, verify
|
|
21
21
|
|
|
22
|
-
__version__ = "0.2.
|
|
22
|
+
__version__ = "0.2.1"
|
|
23
23
|
#: The specification revision this package conforms to.
|
|
24
|
-
SPEC_VERSION = "spec-0.2.
|
|
24
|
+
SPEC_VERSION = "spec-0.2.1"
|
|
25
25
|
#: Every literal unit listed in units.md (index units, ``index:<period>=<value>``, are a pattern and not listed).
|
|
26
26
|
UNITS = tuple(sorted(_units.LITERALS))
|
|
27
27
|
#: The currency codes of units.md 2.1.
|
|
@@ -4,12 +4,31 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import math
|
|
7
|
+
import re
|
|
7
8
|
from typing import Any, Dict, List, Tuple, Union
|
|
8
9
|
|
|
9
10
|
from ._errors import DatumeshError, fail
|
|
10
11
|
|
|
11
12
|
JSONText = Union[str, bytes, bytearray, memoryview]
|
|
12
13
|
|
|
14
|
+
#: SPEC 6.1: at most 64 levels of ``[`` and ``{`` open at once, counted outside strings.
|
|
15
|
+
MAX_DEPTH = 64
|
|
16
|
+
# A string (possibly unterminated) or a bracket; brackets inside strings are skipped with the string.
|
|
17
|
+
_DEPTH_TOKEN = re.compile(r'"(?:[^"\\]|\\.)*"?|[\[\]{}]', re.S)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _too_deep(text: str) -> bool:
|
|
21
|
+
depth = 0
|
|
22
|
+
for m in _DEPTH_TOKEN.finditer(text):
|
|
23
|
+
c = m.group()
|
|
24
|
+
if c in "[{":
|
|
25
|
+
depth += 1
|
|
26
|
+
if depth > MAX_DEPTH:
|
|
27
|
+
return True
|
|
28
|
+
elif c in "]}":
|
|
29
|
+
depth -= 1
|
|
30
|
+
return False
|
|
31
|
+
|
|
13
32
|
|
|
14
33
|
class _Duplicate(Exception):
|
|
15
34
|
def __init__(self, key: str) -> None:
|
|
@@ -49,7 +68,11 @@ def parse_json(data: JSONText, path: str = "$") -> Any:
|
|
|
49
68
|
``data`` is bytes (must be UTF-8 without a byte order mark) or str (must not
|
|
50
69
|
start with U+FEFF). Duplicate member names raise E_PARSE_DUPLICATE_KEY; every
|
|
51
70
|
other failure (syntax, lone surrogates, NaN/Infinity, numbers outside the
|
|
52
|
-
double range, invalid UTF-8) raises E_PARSE_JSON.
|
|
71
|
+
double range, invalid UTF-8, nesting deeper than 64) raises E_PARSE_JSON.
|
|
72
|
+
Numbers become floats. Failures are reported in the order of SPEC 6.1: byte
|
|
73
|
+
order mark, invalid UTF-8, nesting, then the first in text order of a syntax
|
|
74
|
+
error, NaN/Infinity, an overflowing number or a duplicate member (detected when
|
|
75
|
+
its object closes), and lone surrogates last.
|
|
53
76
|
"""
|
|
54
77
|
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
55
78
|
raw = bytes(data)
|
|
@@ -65,6 +88,8 @@ def parse_json(data: JSONText, path: str = "$") -> Any:
|
|
|
65
88
|
fail("E_PARSE_JSON", "byte order mark", path)
|
|
66
89
|
else:
|
|
67
90
|
raise TypeError(f"JSON text must be str or bytes, not {type(data).__name__}")
|
|
91
|
+
if _too_deep(text):
|
|
92
|
+
fail("E_PARSE_JSON", f"nesting deeper than {MAX_DEPTH} levels", path)
|
|
68
93
|
try:
|
|
69
94
|
value = json.loads(
|
|
70
95
|
text,
|
|
@@ -102,10 +127,15 @@ def number_texts(data: JSONText) -> Dict[str, str]:
|
|
|
102
127
|
|
|
103
128
|
def check_json_value(value: Any, path: str = "$") -> None:
|
|
104
129
|
"""Check that an already-parsed value is I-JSON: dict/list/str/float/int/bool/None only,
|
|
105
|
-
string keys, no lone surrogates, finite numbers within the double range
|
|
106
|
-
|
|
130
|
+
string keys, no lone surrogates, finite numbers within the double range, and at most
|
|
131
|
+
64 levels of arrays and objects (as its text would be, SPEC 6.1)."""
|
|
132
|
+
stack: List[Tuple[Any, str, int]] = [(value, path, 0)]
|
|
107
133
|
while stack:
|
|
108
|
-
v, p = stack.pop()
|
|
134
|
+
v, p, depth = stack.pop()
|
|
135
|
+
if isinstance(v, (dict, list)):
|
|
136
|
+
depth += 1
|
|
137
|
+
if depth > MAX_DEPTH:
|
|
138
|
+
fail("E_PARSE_JSON", f"nesting deeper than {MAX_DEPTH} levels", p)
|
|
109
139
|
if v is None or isinstance(v, bool):
|
|
110
140
|
continue
|
|
111
141
|
if isinstance(v, str):
|
|
@@ -125,10 +155,10 @@ def check_json_value(value: Any, path: str = "$") -> None:
|
|
|
125
155
|
fail("E_PARSE_JSON", f"member name {k!r} is not a string", p)
|
|
126
156
|
if _has_surrogate(k):
|
|
127
157
|
fail("E_PARSE_JSON", "lone surrogate in member name", p)
|
|
128
|
-
stack.append((item, f"{p}.{k}"))
|
|
158
|
+
stack.append((item, f"{p}.{k}", depth))
|
|
129
159
|
elif isinstance(v, list):
|
|
130
160
|
for i, item in enumerate(v):
|
|
131
|
-
stack.append((item, f"{p}[{i}]"))
|
|
161
|
+
stack.append((item, f"{p}[{i}]", depth))
|
|
132
162
|
else:
|
|
133
163
|
fail("E_PARSE_JSON", f"{type(v).__name__} is not a JSON value", p)
|
|
134
164
|
|
|
@@ -123,18 +123,20 @@ def keys_from_response(host: str, status: Optional[int], body: Union[str, bytes,
|
|
|
123
123
|
``status`` is the HTTP status, or None for a network, DNS, TLS or timeout
|
|
124
124
|
failure. Any status other than 200 (redirects and 404 included) is
|
|
125
125
|
E_KEY_UNAVAILABLE; a 200 whose body is not a valid key document for
|
|
126
|
-
``host
|
|
126
|
+
``host``, or any 200 for a host that is not a DNS name, is E_KEY_DOC.
|
|
127
|
+
Never raises.
|
|
127
128
|
"""
|
|
128
129
|
if not isinstance(host, str):
|
|
129
130
|
return KeysResult(False, "E_KEY_UNAVAILABLE", None, f"host must be a str, not {type(host).__name__}")
|
|
130
|
-
try:
|
|
131
|
-
lx.check_dns_name(host, "host")
|
|
132
|
-
except DatumeshError as e:
|
|
133
|
-
return KeysResult(False, "E_KEY_UNAVAILABLE", host, f"cannot retrieve keys for an invalid host: {e.detail}")
|
|
134
131
|
if status is None:
|
|
135
132
|
return KeysResult(False, "E_KEY_UNAVAILABLE", host, "network, DNS, TLS or timeout failure")
|
|
136
133
|
if isinstance(status, bool) or not isinstance(status, int) or status != 200:
|
|
137
134
|
return KeysResult(False, "E_KEY_UNAVAILABLE", host, f"HTTP status {status!r}")
|
|
135
|
+
try:
|
|
136
|
+
lx.check_dns_name(host, "host")
|
|
137
|
+
except DatumeshError as e:
|
|
138
|
+
# SPEC 9.3: a response offered for a host that is not a DNS name cannot satisfy 9.2.
|
|
139
|
+
return _doc_error(host, f"a document for a host that is not a DNS name cannot satisfy 9.2: {e.detail}")
|
|
138
140
|
if body is None:
|
|
139
141
|
return _doc_error(host, "200 response without a body")
|
|
140
142
|
if not isinstance(body, (str, bytes, bytearray, memoryview)):
|
|
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
6
6
|
|
|
7
7
|
from . import _lexical as lx
|
|
8
8
|
from ._errors import fail
|
|
9
|
+
from ._json import sort_key
|
|
9
10
|
from ._model import SLICE, validate_ref
|
|
10
11
|
|
|
11
12
|
SCHEME = "cell://"
|
|
@@ -48,10 +49,17 @@ def _encode(text: str) -> str:
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def _declared_types(signature: Any) -> Dict[str, str]:
|
|
51
|
-
|
|
52
|
-
if not isinstance(
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
"""SPEC 5.4 step 5: read the argument types; an unreadable signature is E_SCHEMA."""
|
|
53
|
+
if not isinstance(signature, dict):
|
|
54
|
+
fail("E_SCHEMA", "the signature is an object", "signature")
|
|
55
|
+
args = signature.get("args")
|
|
56
|
+
if not isinstance(args, dict):
|
|
57
|
+
fail("E_SCHEMA", "the signature has an args object", "signature" if "args" not in signature
|
|
58
|
+
else "signature.args")
|
|
59
|
+
for name in sorted(args, key=sort_key):
|
|
60
|
+
a = args[name]
|
|
61
|
+
if not isinstance(a, dict) or not isinstance(a.get("type"), str):
|
|
62
|
+
fail("E_SCHEMA", "an argument declaration is an object with a string type", f"signature.args.{name}")
|
|
55
63
|
return {name: a["type"] for name, a in args.items()}
|
|
56
64
|
|
|
57
65
|
|
|
@@ -61,12 +69,11 @@ def parse_uri(uri: str, signature: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
61
69
|
``signature`` is the ``signature`` member of the function's descriptor; it
|
|
62
70
|
supplies each argument's type. As SPEC 5.4 says, it is taken as given (it comes
|
|
63
71
|
from a descriptor already checked under section 7): only its argument types are
|
|
64
|
-
read, and a signature from which they cannot be read is
|
|
65
|
-
in the order of SPEC 5.4 and the first failure is raised as DatumeshError.
|
|
72
|
+
read, in step 5, and a signature from which they cannot be read is E_SCHEMA.
|
|
73
|
+
Checks run in the order of SPEC 5.4 and the first failure is raised as DatumeshError.
|
|
66
74
|
"""
|
|
67
75
|
if not isinstance(uri, str):
|
|
68
76
|
raise TypeError(f"uri must be a str, not {type(uri).__name__}")
|
|
69
|
-
declared = _declared_types(signature)
|
|
70
77
|
|
|
71
78
|
# 1. Outer grammar.
|
|
72
79
|
if not uri.startswith(SCHEME) or "#" in uri:
|
|
@@ -120,7 +127,8 @@ def parse_uri(uri: str, signature: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
120
127
|
if len(params) > lx.LIMIT_ARGS:
|
|
121
128
|
fail("E_CANON_LIMIT", f"more than {lx.LIMIT_ARGS} arguments", "uri.query")
|
|
122
129
|
|
|
123
|
-
# 5.
|
|
130
|
+
# 5. The signature (read, not checked: SPEC 5.4), then the arguments against it: unknown, then missing.
|
|
131
|
+
declared = _declared_types(signature)
|
|
124
132
|
present = {name for name, _ in params}
|
|
125
133
|
for name, _ in params:
|
|
126
134
|
if name not in declared:
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datumesh
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Verify Datumesh receipts (spec-0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Verify Datumesh receipts (spec-0.2.1): RFC 8785 canonical JSON, Ed25519 signatures, blobs, units, cell URIs, response envelopes, key documents.
|
|
5
5
|
Author: Datumesh
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://datumesh.com
|
|
8
8
|
Project-URL: Repository, https://github.com/datumesh-com/datumesh-verify-py
|
|
9
|
-
Project-URL: Specification (spec-0.2.
|
|
10
|
-
Keywords: datumesh,spec-0.2.
|
|
9
|
+
Project-URL: Specification (spec-0.2.1), https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.1/SPEC.md
|
|
10
|
+
Keywords: datumesh,spec-0.2.1,receipt,verification,ed25519,rfc8785,jcs,canonical-json
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
@@ -27,11 +27,11 @@ Dynamic: license-file
|
|
|
27
27
|
|
|
28
28
|
# datumesh
|
|
29
29
|
|
|
30
|
-
Verify [Datumesh](https://datumesh.com) receipts in Python. A receipt is a signed answer for one addressable data cell; this library checks its canonical form (RFC 8785 JSON) and its Ed25519 signature against the signer's published keys, and optionally the recomputation checks (function descriptor, input receipts, and a value you computed) and the content of a blob value, as the [Datumesh specification](https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.
|
|
30
|
+
Verify [Datumesh](https://datumesh.com) receipts in Python. A receipt is a signed answer for one addressable data cell; this library checks its canonical form (RFC 8785 JSON) and its Ed25519 signature against the signer's published keys, and optionally the recomputation checks (function descriptor, input receipts, and a value you computed) and the content of a blob value, as the [Datumesh specification](https://github.com/datumesh-com/datumesh-spec/blob/spec-0.2.1/SPEC.md) (revision `spec-0.2.1`) defines. It also checks a mesh's response envelope and resolves the unit of a cell's value.
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
33
|
pip install datumesh
|
|
34
|
-
git clone --depth 1 --branch spec-0.2.
|
|
34
|
+
git clone --depth 1 --branch spec-0.2.1 https://github.com/datumesh-com/datumesh-spec && cd datumesh-spec
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
```python
|
|
@@ -18,8 +18,8 @@ LINEAGE = next(v for v in vectors_of("receipt", True) if v["id"] == "receipt/com
|
|
|
18
18
|
|
|
19
19
|
class ApiTest(unittest.TestCase):
|
|
20
20
|
def test_constants(self) -> None:
|
|
21
|
-
self.assertEqual(datumesh.SPEC_VERSION, "spec-0.2.
|
|
22
|
-
self.assertEqual(datumesh.__version__, "0.2.
|
|
21
|
+
self.assertEqual(datumesh.SPEC_VERSION, "spec-0.2.1")
|
|
22
|
+
self.assertEqual(datumesh.__version__, "0.2.1")
|
|
23
23
|
self.assertEqual(len(datumesh.ERROR_CODES), 41)
|
|
24
24
|
self.assertEqual(len(set(datumesh.ERROR_CODES)), 41)
|
|
25
25
|
for code in ("E_CANON_UNIT", "E_CANON_BLOB", "E_BLOB_MISMATCH", "E_ENVELOPE"):
|
|
@@ -16,7 +16,7 @@ import datumesh
|
|
|
16
16
|
class ConformanceTest(unittest.TestCase):
|
|
17
17
|
def test_vector_count(self) -> None:
|
|
18
18
|
counts = collections.Counter(v["kind"] for v in load_vectors())
|
|
19
|
-
self.assertEqual(sum(counts.values()),
|
|
19
|
+
self.assertEqual(sum(counts.values()), 339, counts)
|
|
20
20
|
self.assertEqual(counts["unit"], 13, counts)
|
|
21
21
|
self.assertEqual(counts["envelope"], 50, counts)
|
|
22
22
|
# every vector's id is its path, and every kind is one the runner knows
|
|
@@ -103,7 +103,12 @@ class JcsTest(unittest.TestCase):
|
|
|
103
103
|
def test_jcs_vectors(self) -> None:
|
|
104
104
|
for v in vectors_of("jcs"):
|
|
105
105
|
with self.subTest(v["id"]):
|
|
106
|
-
|
|
106
|
+
if v["valid"]:
|
|
107
|
+
self.assertEqual(jcs(v["input"]).decode("utf-8"), v["canonical"])
|
|
108
|
+
else:
|
|
109
|
+
with self.assertRaises(DatumeshError) as cm:
|
|
110
|
+
jcs(v["input"])
|
|
111
|
+
self.assertEqual(cm.exception.code, v["error"])
|
|
107
112
|
|
|
108
113
|
def test_strict_parsing(self) -> None:
|
|
109
114
|
cases = {
|
|
@@ -123,7 +128,7 @@ class JcsTest(unittest.TestCase):
|
|
|
123
128
|
self.assertEqual(cm.exception.code, "E_PARSE_JSON")
|
|
124
129
|
# Underflow is not out of range: 1e-400 is the double 0.
|
|
125
130
|
self.assertEqual(jcs("[1e-400, -0, 0e999999]"), b"[0,0,0]")
|
|
126
|
-
self.assertEqual(jcs("[" *
|
|
131
|
+
self.assertEqual(jcs("[" * 64 + "]" * 64), b"[" * 64 + b"]" * 64) # SPEC 6.1: at most 64 levels
|
|
127
132
|
self.assertEqual(jcs('"\\u00e9\\/"'), '"é/"'.encode())
|
|
128
133
|
self.assertEqual(jcs("123456789012345678901234567890"), b"1.2345678901234568e+29")
|
|
129
134
|
|
|
@@ -282,6 +282,18 @@ class FetchKeysTest(unittest.TestCase):
|
|
|
282
282
|
with mock.patch.object(_keys, "_fetch_url", lambda *a, **k: (None, None)):
|
|
283
283
|
self.assertEqual(datumesh.fetch_keys(HOST).code, "E_KEY_UNAVAILABLE")
|
|
284
284
|
|
|
285
|
+
def test_response_for_a_host_that_is_not_a_dns_name(self) -> None:
|
|
286
|
+
# SPEC 9.3 (spec-0.2.1): a response offered for a host that is not a DNS name
|
|
287
|
+
# cannot satisfy 9.2, so a 200 is E_KEY_DOC whatever its body; any other status is still
|
|
288
|
+
# E_KEY_UNAVAILABLE. Confirmed against the reference tool.
|
|
289
|
+
for host in ("Rates.example.org", "localhost", "10.0.0.1", "rates.example.org:443", "Bad Host"):
|
|
290
|
+
doc = DOC_TEXT.replace(HOST, host)
|
|
291
|
+
for status, body, code in ((200, doc, "E_KEY_DOC"), (200, DOC_TEXT, "E_KEY_DOC"), (200, None, "E_KEY_DOC"),
|
|
292
|
+
(200, "x", "E_KEY_DOC"), (404, None, "E_KEY_UNAVAILABLE"),
|
|
293
|
+
(301, None, "E_KEY_UNAVAILABLE"), (None, None, "E_KEY_UNAVAILABLE")):
|
|
294
|
+
with self.subTest(host=host, status=status, body=str(body)[:10]):
|
|
295
|
+
self.assertEqual(keys_from_response(host, status, body).code, code)
|
|
296
|
+
|
|
285
297
|
def test_fetch_keys_invalid_host_without_network(self) -> None:
|
|
286
298
|
with mock.patch.object(_keys, "_fetch_url", side_effect=AssertionError("network used")):
|
|
287
299
|
for host in ("localhost", "Rates.example.org", "127.0.0.1", "a/b.example"):
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Statements written down in spec-0.2.1 (a text-only revision): each is tested here.
|
|
2
|
+
|
|
3
|
+
Codes were confirmed against the reference tool, used as a black box.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import json
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
from util import env_keys, vectors_of
|
|
13
|
+
|
|
14
|
+
import datumesh
|
|
15
|
+
from datumesh import DatumeshError, canonicalize, parse_uri, verify
|
|
16
|
+
|
|
17
|
+
STORED = next(v for v in vectors_of("receipt", True) if v["id"] == "receipt/stored-number")
|
|
18
|
+
R = json.loads(STORED["input"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def code_of(fn, *args):
|
|
22
|
+
try:
|
|
23
|
+
fn(*args)
|
|
24
|
+
return None
|
|
25
|
+
except DatumeshError as e:
|
|
26
|
+
return e.code
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def receipt_code(**changes) -> object:
|
|
30
|
+
r = copy.deepcopy(R)
|
|
31
|
+
for k, v in changes.items():
|
|
32
|
+
r[k] = v
|
|
33
|
+
return code_of(canonicalize, r, "receipt")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JsonParsingTest(unittest.TestCase):
|
|
37
|
+
"""SPEC 6.1: nesting at most 64 levels, and the order in which parse failures are reported."""
|
|
38
|
+
|
|
39
|
+
CASES = [
|
|
40
|
+
("[" * 64 + "]" * 64, None),
|
|
41
|
+
("[" * 65 + "]" * 65, "E_PARSE_JSON"),
|
|
42
|
+
('{"a":' * 63 + "[]" + "}" * 63, None), # 63 objects + 1 array = 64
|
|
43
|
+
('{"a":' * 64 + "[]" + "}" * 64, "E_PARSE_JSON"),
|
|
44
|
+
("[" * 65, "E_PARSE_JSON"), # unclosed, too deep
|
|
45
|
+
("[" * 100000, "E_PARSE_JSON"),
|
|
46
|
+
# brackets inside strings, escaped quotes included, do not count
|
|
47
|
+
('{"a": "' + "[" * 100 + '", "a": 2}', "E_PARSE_DUPLICATE_KEY"),
|
|
48
|
+
('["\\"' + "[" * 100 + '", 1]', None),
|
|
49
|
+
('["\\\\", ' + "[" * 63 + "]" * 63 + "]", None),
|
|
50
|
+
('["\\\\", ' + "[" * 64 + "]" * 64 + "]", "E_PARSE_JSON"),
|
|
51
|
+
# depth before anything else in the text
|
|
52
|
+
('{"a": 1, "a": 2, "b": ' + "[" * 64 + "]" * 64 + "}", "E_PARSE_JSON"),
|
|
53
|
+
('{"a": 1, "a": 2, "b": ' + "[" * 63 + "]" * 63 + "}", "E_PARSE_DUPLICATE_KEY"),
|
|
54
|
+
# then the first in text order of syntax error, NaN/Infinity, overflow, duplicate (at its close)
|
|
55
|
+
('[{"a": 1, "a": 2}, NaN]', "E_PARSE_DUPLICATE_KEY"),
|
|
56
|
+
('[NaN, {"a": 1, "a": 2}]', "E_PARSE_JSON"),
|
|
57
|
+
('[1e400, {"a": 1, "a": 2}]', "E_PARSE_JSON"),
|
|
58
|
+
('[{"a": 1, "a": 2}, 1e400]', "E_PARSE_DUPLICATE_KEY"),
|
|
59
|
+
('[{"a": 1, "a": 2}, x]', "E_PARSE_DUPLICATE_KEY"),
|
|
60
|
+
('{"a": 1, "a": 2, x}', "E_PARSE_JSON"),
|
|
61
|
+
('{"a": {"b": 1, "b": 2}, "a": 3}', "E_PARSE_DUPLICATE_KEY"),
|
|
62
|
+
# lone surrogates last
|
|
63
|
+
('{"a": "\\ud800", "a": 1}', "E_PARSE_DUPLICATE_KEY"),
|
|
64
|
+
('["\\ud800", NaN]', "E_PARSE_JSON"),
|
|
65
|
+
("[1e-400]", None), # underflow is not an error
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def test_cases(self) -> None:
|
|
69
|
+
for text, code in self.CASES:
|
|
70
|
+
with self.subTest(text[:60]):
|
|
71
|
+
self.assertEqual(code_of(datumesh.jcs, text), code)
|
|
72
|
+
self.assertEqual(code_of(datumesh.jcs, text.encode()), code)
|
|
73
|
+
|
|
74
|
+
def test_bom_and_utf8_come_first(self) -> None:
|
|
75
|
+
deep = "[" * 70 + "]" * 70
|
|
76
|
+
self.assertEqual(code_of(datumesh.jcs, b"\xef\xbb\xbf" + deep.encode()), "E_PARSE_JSON")
|
|
77
|
+
self.assertEqual(code_of(datumesh.jcs, b'["\xff"' + deep.encode() + b"]"), "E_PARSE_JSON")
|
|
78
|
+
|
|
79
|
+
def test_depth_through_verify_and_envelopes(self) -> None:
|
|
80
|
+
self.assertEqual(verify("[" * 65 + "]" * 65, {}).code, "E_PARSE_JSON")
|
|
81
|
+
body = '{"outcome": "not_covered", "x": ' + "[" * 64 + "]" * 64 + "}"
|
|
82
|
+
res = datumesh.check_envelope(404, body, [], {"cell_id": "0" * 64})
|
|
83
|
+
self.assertEqual(res.code, "E_PARSE_JSON")
|
|
84
|
+
|
|
85
|
+
def test_parsed_values_too_deep(self) -> None:
|
|
86
|
+
# A parsed value that would be more than 64 levels deep as text is refused the same way.
|
|
87
|
+
v: list = []
|
|
88
|
+
for _ in range(64):
|
|
89
|
+
v = [v]
|
|
90
|
+
self.assertEqual(code_of(datumesh.jcs, v), "E_PARSE_JSON")
|
|
91
|
+
self.assertIsNone(code_of(datumesh.jcs, v[0]))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UriSignatureTest(unittest.TestCase):
|
|
95
|
+
"""SPEC 5.4 step 5: an unreadable signature is E_SCHEMA, after steps 1-4."""
|
|
96
|
+
|
|
97
|
+
def test_unreadable(self) -> None:
|
|
98
|
+
uri = "cell://m.example/f?n=1"
|
|
99
|
+
for sig, path in [
|
|
100
|
+
(None, "signature"), ([], "signature"), ("x", "signature"), ({}, "signature"),
|
|
101
|
+
({"args": []}, "signature.args"), ({"args": {"n": 5}}, "signature.args.n"),
|
|
102
|
+
({"args": {"n": {}}}, "signature.args.n"), ({"args": {"n": {"type": 5}}}, "signature.args.n"),
|
|
103
|
+
({"args": {"z": {"type": "number"}, "b": 5, "a": {}}}, "signature.args.a"), # JCS order
|
|
104
|
+
]:
|
|
105
|
+
with self.subTest(sig=sig):
|
|
106
|
+
with self.assertRaises(DatumeshError) as cm:
|
|
107
|
+
parse_uri(uri, sig) # type: ignore[arg-type]
|
|
108
|
+
self.assertEqual((cm.exception.code, cm.exception.path), ("E_SCHEMA", path))
|
|
109
|
+
|
|
110
|
+
def test_steps_1_to_4_first(self) -> None:
|
|
111
|
+
for uri, code in [("cell://m.example/f#x", "E_PARSE_URI"), ("cell://M.example/f", "E_CANON_NAME"),
|
|
112
|
+
("cell://m.example/f?b=1&a=1", "E_CANON_ARG_ORDER"),
|
|
113
|
+
("cell://m.example/f?" + "&".join("a%02d=1" % i for i in range(33)), "E_CANON_LIMIT")]:
|
|
114
|
+
with self.subTest(uri[:40]):
|
|
115
|
+
self.assertEqual(code_of(parse_uri, uri, None), code)
|
|
116
|
+
self.assertEqual(code_of(parse_uri, "cell://m.example/f?n=1", {"args": {"n": {"type": "decimal"}}}),
|
|
117
|
+
"E_CANON_TYPE")
|
|
118
|
+
self.assertIsNone(code_of(parse_uri, "cell://m.example/f?n=*", {"args": {"n": {"type": "decimal"}}}))
|
|
119
|
+
|
|
120
|
+
def test_asof_and_repeats(self) -> None:
|
|
121
|
+
sig = {"args": {"a": {"type": "number"}, "b": {"type": "number"}}}
|
|
122
|
+
self.assertEqual(code_of(parse_uri, "cell://m.example/f?a=1&b=1&a=1", sig), "E_CANON_ARG_ORDER")
|
|
123
|
+
self.assertEqual(code_of(parse_uri, "cell://m.example/f?a=1&b=1&asof=*", sig), "E_CANON_PERCENT")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ReceiptStatementsTest(unittest.TestCase):
|
|
127
|
+
def test_licence_grammar(self) -> None:
|
|
128
|
+
for lic, code in [("CC-BY-4.0", None), ("Not-A-Real-Licence", None), ("a.b+c", None),
|
|
129
|
+
("-x", "E_CANON_LICENCE"), ("CC BY", "E_CANON_LICENCE"), ("", "E_CANON_LICENCE"),
|
|
130
|
+
("a" * 64, None), ("a" * 65, "E_CANON_LIMIT"), ("-" * 65, "E_CANON_LICENCE"),
|
|
131
|
+
("https://x.example/l", None), ("https://", "E_CANON_LICENCE"),
|
|
132
|
+
("HTTPS://x.example/l", "E_CANON_LICENCE"), ("https://x.example/a b", "E_CANON_LICENCE"),
|
|
133
|
+
("https://" + "a" * 504, None), ("https://" + "a" * 505, "E_CANON_LIMIT"),
|
|
134
|
+
("https://" + "a" * 600 + " ", "E_CANON_LICENCE"), ("http://x.example", "E_CANON_LICENCE")]:
|
|
135
|
+
with self.subTest(lic[:40]):
|
|
136
|
+
self.assertEqual(receipt_code(licence=lic), code)
|
|
137
|
+
|
|
138
|
+
def test_key_reference_codes(self) -> None:
|
|
139
|
+
for ref, code in [("rates.example.org", "E_CANON_NAME"), ("Rates.example.org#d712b32a2cc5cb7c", "E_CANON_NAME"),
|
|
140
|
+
("rates#d712b32a2cc5cb7c", "E_CANON_NAME"), ("rates.example.org#D712B32A2CC5CB7C", "E_CANON_HEX"),
|
|
141
|
+
("rates.example.org#d712b32a2cc5cb7", "E_CANON_HEX"),
|
|
142
|
+
("rates.example.org#d712b32a2cc5cb7c#x", "E_CANON_HEX")]:
|
|
143
|
+
with self.subTest(ref):
|
|
144
|
+
self.assertEqual(receipt_code(computed_by=ref), code)
|
|
145
|
+
|
|
146
|
+
def test_instant_failures(self) -> None:
|
|
147
|
+
for t in ("2023-02-29T00:00:00.000000Z", "2024-13-01T00:00:00.000000Z", "2024-03-15T23:59:60.000000Z",
|
|
148
|
+
"0000-01-01T00:00:00.000000Z"):
|
|
149
|
+
with self.subTest(t):
|
|
150
|
+
self.assertEqual(receipt_code(issued=t), "E_CANON_INSTANT")
|
|
151
|
+
|
|
152
|
+
def test_value_json_types(self) -> None:
|
|
153
|
+
self.assertEqual(receipt_code(value=1.0892), "E_SCHEMA")
|
|
154
|
+
self.assertEqual(receipt_code(value=None), "E_SCHEMA")
|
|
155
|
+
self.assertEqual(receipt_code(value=[]), "E_SCHEMA")
|
|
156
|
+
self.assertEqual(receipt_code(value=True), "E_CANON_TYPE")
|
|
157
|
+
s = {"type": "boolean", "value": "true"}
|
|
158
|
+
self.assertEqual(code_of(canonicalize, s, "scalar"), "E_CANON_TYPE")
|
|
159
|
+
self.assertEqual(code_of(canonicalize, {"type": "string", "value": 1}, "scalar"), "E_SCHEMA")
|
|
160
|
+
|
|
161
|
+
def test_consistency_order(self) -> None:
|
|
162
|
+
# Step 4 runs in the listed order: slices, publisher, valid, time.
|
|
163
|
+
args = dict(R["cell"]["args"], base="*")
|
|
164
|
+
later = "2025-01-01T00:00:00.000000Z"
|
|
165
|
+
self.assertEqual(receipt_code(cell=dict(R["cell"], args=args), publisher="other.example.org"), "E_SLICE")
|
|
166
|
+
self.assertEqual(receipt_code(publisher="other.example.org", valid={"date": {"type": "date", "value": "2024-01-01"}}),
|
|
167
|
+
"E_PUBLISHER_MISMATCH")
|
|
168
|
+
self.assertEqual(receipt_code(valid={"date": {"type": "date", "value": "2024-01-01"}}, tx=later),
|
|
169
|
+
"E_VALID_MISMATCH")
|
|
170
|
+
|
|
171
|
+
def test_container_order(self) -> None:
|
|
172
|
+
r_inputs = ["f" * 64, "0" * 64]
|
|
173
|
+
self.assertEqual(receipt_code(inputs=r_inputs), "E_CANON_SET")
|
|
174
|
+
self.assertEqual(receipt_code(inputs=["F" * 64, "0" * 64]), "E_CANON_HEX")
|
|
175
|
+
self.assertEqual(receipt_code(inputs=["%064x" % i for i in range(4097)][::-1]), "E_CANON_SET")
|
|
176
|
+
self.assertEqual(receipt_code(inputs=["%064x" % i for i in range(4097)]), "E_CANON_LIMIT")
|
|
177
|
+
args = {"a%02d" % i: {"type": "string", "value": "x"} for i in range(33)}
|
|
178
|
+
args["A"] = {"type": "string", "value": "x"}
|
|
179
|
+
self.assertEqual(code_of(canonicalize, {"args": args, "fn": "f", "ns": "m.example"}, "ref"), "E_CANON_LIMIT")
|
|
180
|
+
|
|
181
|
+
def test_stored_convention_not_enforced(self) -> None:
|
|
182
|
+
d = {"code": {"body": "x + 1\n", "lang": "stored"}, "inputs": ["m.example/f"],
|
|
183
|
+
"signature": {"args": {}, "returns": "boolean"}}
|
|
184
|
+
self.assertIsNone(code_of(datumesh.function_hash, d))
|
|
185
|
+
|
|
186
|
+
def test_ed25519_vectors(self) -> None:
|
|
187
|
+
names = {v["id"]: v for v in vectors_of("receipt") if "ed25519" in v["id"]}
|
|
188
|
+
self.assertEqual(sorted(names), ["invalid/receipt-ed25519-negative-zero-r", "invalid/receipt-ed25519-non-canonical-r",
|
|
189
|
+
"invalid/receipt-ed25519-s-not-reduced", "invalid/receipt-ed25519-small-order-key",
|
|
190
|
+
"invalid/receipt-ed25519-small-order-r", "receipt/ed25519-mixed-order-key"])
|
|
191
|
+
for vid, v in names.items():
|
|
192
|
+
with self.subTest(vid):
|
|
193
|
+
res = verify(v["input"], env_keys(v["keys"]))
|
|
194
|
+
self.assertEqual(res.code, "OK" if v["valid"] else v["error"])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
unittest.main()
|
|
@@ -29,7 +29,7 @@ class UriTest(unittest.TestCase):
|
|
|
29
29
|
self.assertEqual(parse_uri(format_uri(v["object"]), v["signature"]), v["object"])
|
|
30
30
|
n += 1
|
|
31
31
|
self.assertEqual(n, 11)
|
|
32
|
-
self.assertEqual(len(vectors_of("uri", False)),
|
|
32
|
+
self.assertEqual(len(vectors_of("uri", False)), 32)
|
|
33
33
|
|
|
34
34
|
def test_invalid_vectors(self) -> None:
|
|
35
35
|
for v in vectors_of("uri", False):
|
|
@@ -60,10 +60,11 @@ class UriTest(unittest.TestCase):
|
|
|
60
60
|
with self.assertRaises(DatumeshError) as cm:
|
|
61
61
|
parse_uri("cell://math.example/f?n=1", {"args": {"n": {"type": "decimal", "valid": False}}})
|
|
62
62
|
self.assertEqual(cm.exception.code, "E_CANON_TYPE")
|
|
63
|
-
#
|
|
63
|
+
# A signature whose argument types cannot be read is E_SCHEMA (SPEC 5.4 step 5, spec-0.2.1).
|
|
64
64
|
for bad in (None, {}, {"args": []}, {"args": {"n": {}}}, {"args": {"n": {"type": 5}}}):
|
|
65
|
-
with self.subTest(bad=bad), self.assertRaises(
|
|
65
|
+
with self.subTest(bad=bad), self.assertRaises(DatumeshError) as cm:
|
|
66
66
|
parse_uri("cell://math.example/f?n=1", bad) # type: ignore[arg-type]
|
|
67
|
+
self.assertEqual(cm.exception.code, "E_SCHEMA")
|
|
67
68
|
with self.assertRaises(TypeError):
|
|
68
69
|
parse_uri(b"cell://math.example/pi", {"args": {}, "returns": "boolean"}) # type: ignore[arg-type]
|
|
69
70
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|