nex-protocol 0.1.0a1__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.
- nex/__init__.py +2 -0
- nex/__main__.py +3 -0
- nex/_data.py +27 -0
- nex/agent.py +66 -0
- nex/canonical.py +131 -0
- nex/cli.py +88 -0
- nex/demo.py +21 -0
- nex/errors.py +52 -0
- nex/fastschema.py +193 -0
- nex/glyphs.py +329 -0
- nex/models.py +97 -0
- nex/parser.py +194 -0
- nex/protocol.py +38 -0
- nex/runtime.py +296 -0
- nex/schemas/__init__.py +1 -0
- nex/schemas/nex-error-1.1.schema.json +15 -0
- nex/schemas/nex-error-message-1.1.schema.json +16 -0
- nex/schemas/nex-message-1.1.schema.json +72 -0
- nex/schemas/payloads/__init__.py +1 -0
- nex/schemas/payloads/act.schema.json +14 -0
- nex/schemas/payloads/error.schema.json +15 -0
- nex/schemas/payloads/handshake.schema.json +37 -0
- nex/schemas/payloads/plan.schema.json +27 -0
- nex/schemas/payloads/report.schema.json +19 -0
- nex/schemas/payloads/request.schema.json +19 -0
- nex/schemas/payloads/sync.schema.json +23 -0
- nex/schemas/payloads/verify.schema.json +14 -0
- nex/schemas/registries/__init__.py +1 -0
- nex/schemas/registries/nex-command-registry-1.1.json +46 -0
- nex/schemas/registries/nex-error-registry-1.1.json +585 -0
- nex/schemas/registries/nex-impact-registry-1.0.json +10 -0
- nex/schemas/registries/nex-intent-registry-1.0.json +13 -0
- nex/schemas/registries/nex-ontology-registry-1.1.json +237 -0
- nex/schemas/registries/nex-state-registry-1.1.json +83 -0
- nex/security.py +150 -0
- nex/session.py +70 -0
- nex/state_model.py +68 -0
- nex/stores/__init__.py +10 -0
- nex/stores/base.py +61 -0
- nex/stores/memory.py +111 -0
- nex/stores/sqlite.py +189 -0
- nex/ultrasonic.py +134 -0
- nex/validator.py +216 -0
- nex/worker.py +87 -0
- nex_protocol-0.1.0a1.dist-info/METADATA +63 -0
- nex_protocol-0.1.0a1.dist-info/RECORD +50 -0
- nex_protocol-0.1.0a1.dist-info/WHEEL +5 -0
- nex_protocol-0.1.0a1.dist-info/entry_points.txt +2 -0
- nex_protocol-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- nex_protocol-0.1.0a1.dist-info/top_level.txt +1 -0
nex/__init__.py
ADDED
nex/__main__.py
ADDED
nex/_data.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Loading of packaged JSON data (schemas and registries)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_FALLBACK_ROOT = Path(__file__).resolve().parents[2] / "schemas"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read(relative: str) -> str:
|
|
14
|
+
parts = relative.split("/")
|
|
15
|
+
try:
|
|
16
|
+
node = resources.files("nex.schemas")
|
|
17
|
+
for part in parts:
|
|
18
|
+
node = node.joinpath(part)
|
|
19
|
+
return node.read_text(encoding="utf-8")
|
|
20
|
+
except (ModuleNotFoundError, FileNotFoundError, OSError):
|
|
21
|
+
return (_FALLBACK_ROOT.joinpath(*parts)).read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=None)
|
|
25
|
+
def load_json(relative: str) -> Any:
|
|
26
|
+
"""Load and cache a JSON document relative to the schemas directory."""
|
|
27
|
+
return json.loads(_read(relative))
|
nex/agent.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Nex agent: builds, signs and exchanges messages through a runtime."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from .canonical import canonical_text
|
|
7
|
+
from .errors import NexError
|
|
8
|
+
from .models import PROTOCOL_VERSION
|
|
9
|
+
from .protocol import build_message
|
|
10
|
+
from .runtime import NexRuntime
|
|
11
|
+
|
|
12
|
+
Transport = Callable[[str], "str | dict[str, Any] | None"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NexAgent:
|
|
16
|
+
"""Thin client around a :class:`NexRuntime`.
|
|
17
|
+
|
|
18
|
+
``transport`` receives a canonical Nex-J string and returns the peer's response
|
|
19
|
+
(a message dict/string) or None. :meth:`connect_local` wires two agents in-process.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, agent_id: str, **runtime_kwargs: Any) -> None:
|
|
23
|
+
self.runtime = NexRuntime(agent_id, **runtime_kwargs)
|
|
24
|
+
self.agent_id = agent_id
|
|
25
|
+
self.transport: Transport | None = None
|
|
26
|
+
|
|
27
|
+
def connect_local(self, peer: "NexAgent") -> None:
|
|
28
|
+
self.transport = lambda text: peer.runtime.process(text)
|
|
29
|
+
|
|
30
|
+
def build(self, receiver: str, command: str, payload: dict[str, Any], *, session_id: str | None = None,
|
|
31
|
+
correlation_id: str | None = None, reply_to: str | None = None) -> dict[str, Any]:
|
|
32
|
+
msg = build_message(sender=self.agent_id, receiver=receiver, command=command, payload=payload,
|
|
33
|
+
now=self.runtime.clock(), session_id=session_id, correlation_id=correlation_id,
|
|
34
|
+
reply_to=reply_to)
|
|
35
|
+
return self.runtime.sign(msg)
|
|
36
|
+
|
|
37
|
+
def send(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
38
|
+
"""Record, transmit and feed the response back through the local runtime."""
|
|
39
|
+
if self.transport is None:
|
|
40
|
+
raise RuntimeError("no transport configured")
|
|
41
|
+
self.runtime.record_outbound(message)
|
|
42
|
+
response = self.transport(canonical_text(message))
|
|
43
|
+
if response is None:
|
|
44
|
+
return None
|
|
45
|
+
return self.runtime.process(response) or (response if isinstance(response, dict) else None)
|
|
46
|
+
|
|
47
|
+
def handshake(self, receiver: str, *, intent: str = "COORDINATE",
|
|
48
|
+
algorithms: tuple[str, ...] | None = None) -> str:
|
|
49
|
+
"""Perform the HANDSHAKE and return the established session id."""
|
|
50
|
+
algs = list(algorithms or (("ED25519",) if self.runtime.key_id else ("NONE",)))
|
|
51
|
+
payload = {"identity": self.agent_id, "version": PROTOCOL_VERSION,
|
|
52
|
+
"capabilities": ["ERROR", "HANDSHAKE", "PLAN", "ACT", "REPORT", "REQUEST", "SYNC", "VERIFY"],
|
|
53
|
+
"ontology_modules": list(self.runtime.ontology_modules), "security_algorithms": algs,
|
|
54
|
+
"intent": intent}
|
|
55
|
+
resp = self.send(self.build(receiver, "HANDSHAKE", payload))
|
|
56
|
+
if resp is None:
|
|
57
|
+
raise NexError("E705", {"reason": "no handshake response"})
|
|
58
|
+
body = resp["body"]
|
|
59
|
+
if body["command"] == "ERROR":
|
|
60
|
+
raise NexError(body["payload"]["code"], body["payload"].get("details"))
|
|
61
|
+
if not body["payload"]["accepted"]:
|
|
62
|
+
raise NexError("E705", {"reason": body["payload"].get("reason")})
|
|
63
|
+
return body["payload"]["session_id"]
|
|
64
|
+
|
|
65
|
+
def request(self, receiver: str, session_id: str, goal: str, **extra: Any) -> dict[str, Any] | None:
|
|
66
|
+
return self.send(self.build(receiver, "REQUEST", {"goal": goal, **extra}, session_id=session_id))
|
nex/canonical.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Canonical JSON serialization used for hashing and signing (NEP-0003 §6)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import unicodedata
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import NexError
|
|
11
|
+
|
|
12
|
+
MAX_DEPTH = 64
|
|
13
|
+
_EXP_PAD = re.compile(r"e([+-])0+(\d)") # 1e-07 -> 1e-7 (shortest form, as in ECMAScript)
|
|
14
|
+
MAX_MESSAGE_CHARS = 1_048_576 # DoS limit on inbound message text
|
|
15
|
+
_nfc = unicodedata.normalize
|
|
16
|
+
_quote = json.encoder.encode_basestring # C-accelerated, ensure_ascii=False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dup_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
20
|
+
out: dict[str, Any] = {}
|
|
21
|
+
for k, v in pairs:
|
|
22
|
+
if k in out:
|
|
23
|
+
raise NexError("E103", {"field": k})
|
|
24
|
+
out[k] = v
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _reject_constant(name: str) -> Any:
|
|
29
|
+
raise NexError("E001", {"reason": f"non-finite number {name} is not allowed"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def loads_strict(text: str | bytes) -> Any:
|
|
33
|
+
"""Parse JSON rejecting duplicate keys, NaN and Infinity."""
|
|
34
|
+
if isinstance(text, (bytes, bytearray)):
|
|
35
|
+
try:
|
|
36
|
+
text = bytes(text).decode("utf-8")
|
|
37
|
+
except UnicodeDecodeError as exc:
|
|
38
|
+
raise NexError("E001", {"reason": "invalid UTF-8"}) from exc
|
|
39
|
+
if len(text) > MAX_MESSAGE_CHARS:
|
|
40
|
+
raise NexError("E104", {"reason": "message too large", "max_chars": MAX_MESSAGE_CHARS})
|
|
41
|
+
if not text.strip():
|
|
42
|
+
raise NexError("E005")
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(text, object_pairs_hook=_dup_hook, parse_constant=_reject_constant)
|
|
45
|
+
except NexError:
|
|
46
|
+
raise
|
|
47
|
+
except RecursionError as exc:
|
|
48
|
+
raise NexError("E104", {"reason": "nesting too deep"}) from exc
|
|
49
|
+
except ValueError as exc:
|
|
50
|
+
raise NexError("E001", {"reason": str(exc)[:200]}) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _number(value: float) -> str:
|
|
54
|
+
if not math.isfinite(value):
|
|
55
|
+
raise NexError("E904", {"reason": "NaN and infinity are prohibited"})
|
|
56
|
+
if value == 0:
|
|
57
|
+
return "0"
|
|
58
|
+
if value.is_integer() and abs(value) < 1e21:
|
|
59
|
+
return str(int(value))
|
|
60
|
+
return _EXP_PAD.sub(r"e\1\2", repr(value))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _emit(obj: Any, out: list[str], depth: int) -> None:
|
|
64
|
+
if depth > MAX_DEPTH:
|
|
65
|
+
raise NexError("E104", {"reason": "nesting too deep"})
|
|
66
|
+
if obj is None:
|
|
67
|
+
out.append("null")
|
|
68
|
+
elif obj is True:
|
|
69
|
+
out.append("true")
|
|
70
|
+
elif obj is False:
|
|
71
|
+
out.append("false")
|
|
72
|
+
elif isinstance(obj, str):
|
|
73
|
+
out.append(_quote(obj if obj.isascii() else _nfc("NFC", obj)))
|
|
74
|
+
elif isinstance(obj, int):
|
|
75
|
+
out.append(str(obj))
|
|
76
|
+
elif isinstance(obj, float):
|
|
77
|
+
out.append(_number(obj))
|
|
78
|
+
elif isinstance(obj, (list, tuple)):
|
|
79
|
+
out.append("[")
|
|
80
|
+
first = True
|
|
81
|
+
for item in obj:
|
|
82
|
+
if not first:
|
|
83
|
+
out.append(",")
|
|
84
|
+
first = False
|
|
85
|
+
_emit(item, out, depth + 1)
|
|
86
|
+
out.append("]")
|
|
87
|
+
elif isinstance(obj, dict):
|
|
88
|
+
normalized: dict[str, Any] = {}
|
|
89
|
+
for k, v in obj.items():
|
|
90
|
+
if not isinstance(k, str):
|
|
91
|
+
raise NexError("E904", {"reason": "object keys must be strings"})
|
|
92
|
+
nk = k if k.isascii() else _nfc("NFC", k)
|
|
93
|
+
if nk in normalized:
|
|
94
|
+
raise NexError("E103", {"field": nk})
|
|
95
|
+
normalized[nk] = v
|
|
96
|
+
out.append("{")
|
|
97
|
+
first = True
|
|
98
|
+
for k in sorted(normalized):
|
|
99
|
+
if not first:
|
|
100
|
+
out.append(",")
|
|
101
|
+
first = False
|
|
102
|
+
out.append(_quote(k))
|
|
103
|
+
out.append(":")
|
|
104
|
+
_emit(normalized[k], out, depth + 1)
|
|
105
|
+
out.append("}")
|
|
106
|
+
else:
|
|
107
|
+
raise NexError("E904", {"reason": f"unsupported type {type(obj).__name__}"})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def canonicalize(obj: Any) -> bytes:
|
|
111
|
+
"""Return canonical UTF-8 JSON bytes for ``obj``."""
|
|
112
|
+
out: list[str] = []
|
|
113
|
+
_emit(obj, out, 0)
|
|
114
|
+
try:
|
|
115
|
+
return "".join(out).encode("utf-8")
|
|
116
|
+
except UnicodeEncodeError as exc:
|
|
117
|
+
raise NexError("E904", {"reason": "string is not valid Unicode"}) from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def canonical_text(obj: Any) -> str:
|
|
121
|
+
return canonicalize(obj).decode("utf-8")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def signable_bytes(message: dict[str, Any]) -> bytes:
|
|
125
|
+
"""Bytes covered by a signature: header, body, signature.algorithm and key_id."""
|
|
126
|
+
sig = message["signature"]
|
|
127
|
+
return canonicalize({
|
|
128
|
+
"header": message["header"],
|
|
129
|
+
"body": message["body"],
|
|
130
|
+
"signature": {"algorithm": sig["algorithm"], "key_id": sig["key_id"]},
|
|
131
|
+
})
|
nex/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Command line interface: ``nex`` / ``python -m nex``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .canonical import canonical_text
|
|
11
|
+
from .errors import NexError, entry
|
|
12
|
+
from .glyphs import geometry_json, render_png, render_svg, scene_json
|
|
13
|
+
from .parser import parse_nex_t, parse_nexj
|
|
14
|
+
from .validator import validate_message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read(path: str) -> str:
|
|
18
|
+
return sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fail(err: NexError) -> int:
|
|
22
|
+
print(json.dumps(err.payload(), sort_keys=True), file=sys.stderr)
|
|
23
|
+
return 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
p = argparse.ArgumentParser(prog="nex", description="Nex Alpha reference tools")
|
|
28
|
+
p.add_argument("--version", action="version", version=f"nex {__version__}")
|
|
29
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
30
|
+
v = sub.add_parser("validate", help="validate a Nex-J message file (or - for stdin)")
|
|
31
|
+
v.add_argument("file")
|
|
32
|
+
v.add_argument("--no-expiry", action="store_true", help="skip the expiry check")
|
|
33
|
+
pa = sub.add_parser("parse", help="parse Nex-T and print canonical Nex-J payload")
|
|
34
|
+
pa.add_argument("file")
|
|
35
|
+
c = sub.add_parser("canonical", help="print canonical JSON of a Nex-J file")
|
|
36
|
+
c.add_argument("file")
|
|
37
|
+
g = sub.add_parser("glyph", help="generate a Nex-G glyph")
|
|
38
|
+
g.add_argument("text")
|
|
39
|
+
g.add_argument("--format", choices=("json", "svg", "png", "scene"), default="json")
|
|
40
|
+
g.add_argument("-o", "--output", help="output file (required for png)")
|
|
41
|
+
e = sub.add_parser("error", help="describe an error code")
|
|
42
|
+
e.add_argument("code")
|
|
43
|
+
sub.add_parser("demo", help="run the two-agent demonstration")
|
|
44
|
+
a = p.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
if a.cmd == "validate":
|
|
48
|
+
validate_message(parse_nexj(_read(a.file)), check_expiry=not a.no_expiry)
|
|
49
|
+
print("OK")
|
|
50
|
+
elif a.cmd == "parse":
|
|
51
|
+
print(canonical_text(parse_nex_t(_read(a.file))))
|
|
52
|
+
elif a.cmd == "canonical":
|
|
53
|
+
print(canonical_text(parse_nexj(_read(a.file))))
|
|
54
|
+
elif a.cmd == "glyph":
|
|
55
|
+
if a.format == "png":
|
|
56
|
+
if not a.output:
|
|
57
|
+
print("png output requires --output", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
with open(a.output, "wb") as fh:
|
|
60
|
+
fh.write(render_png(a.text))
|
|
61
|
+
else:
|
|
62
|
+
out = {"json": geometry_json, "svg": render_svg, "scene": scene_json}[a.format](a.text)
|
|
63
|
+
if a.output:
|
|
64
|
+
with open(a.output, "w", encoding="utf-8") as fh:
|
|
65
|
+
fh.write(out)
|
|
66
|
+
else:
|
|
67
|
+
print(out)
|
|
68
|
+
elif a.cmd == "error":
|
|
69
|
+
code = a.code.upper()
|
|
70
|
+
try:
|
|
71
|
+
data: dict[str, Any] = entry(code)
|
|
72
|
+
except ValueError as err:
|
|
73
|
+
print(f"error: {err}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
print(json.dumps(data, sort_keys=True, indent=2))
|
|
76
|
+
elif a.cmd == "demo":
|
|
77
|
+
from .demo import run_demo
|
|
78
|
+
print(run_demo())
|
|
79
|
+
except NexError as err:
|
|
80
|
+
return _fail(err)
|
|
81
|
+
except OSError as err:
|
|
82
|
+
print(f"error: {err}", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|
nex/demo.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Self-contained two-agent demonstration used by ``nex demo`` and the examples."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from .agent import NexAgent
|
|
7
|
+
from .security import MemoryKeyStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_demo() -> str:
|
|
11
|
+
"""Signed handshake + request between two in-process agents; returns a JSON summary."""
|
|
12
|
+
keys = MemoryKeyStore()
|
|
13
|
+
keys.generate("KEY-alice", "alice")
|
|
14
|
+
keys.generate("KEY-bob", "bob")
|
|
15
|
+
alice = NexAgent("alice", keystore=keys, key_id="KEY-alice")
|
|
16
|
+
bob = NexAgent("bob", keystore=keys, key_id="KEY-bob")
|
|
17
|
+
alice.connect_local(bob)
|
|
18
|
+
bob.connect_local(alice)
|
|
19
|
+
session = alice.handshake("bob")
|
|
20
|
+
report = alice.request("bob", session, "MOVE", target="CONTAINER7", destination="ZONE_B")
|
|
21
|
+
return json.dumps({"session_id": session, "report": report["body"]["payload"]}, sort_keys=True, indent=2)
|
nex/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Nex error registry access and the NexError exception."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._data import load_json
|
|
8
|
+
|
|
9
|
+
REGISTRY_PATH = "registries/nex-error-registry-1.1.json"
|
|
10
|
+
RETIRED_CODES = frozenset({"E505"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@lru_cache(maxsize=1)
|
|
14
|
+
def registry() -> dict[str, dict[str, Any]]:
|
|
15
|
+
"""Return the error registry keyed by code."""
|
|
16
|
+
return {e["code"]: e for e in load_json(REGISTRY_PATH)["entries"]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def entry(code: str) -> dict[str, Any]:
|
|
20
|
+
if code in RETIRED_CODES:
|
|
21
|
+
raise ValueError(f"error code {code} is retired and must not be used")
|
|
22
|
+
try:
|
|
23
|
+
return registry()[code]
|
|
24
|
+
except KeyError:
|
|
25
|
+
raise ValueError(f"unknown error code {code!r}") from None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def error_payload(code: str, details: Any = None, severity: str | None = None,
|
|
29
|
+
recovery: str | None = None) -> dict[str, Any]:
|
|
30
|
+
"""Build an ERROR command payload for a registry code."""
|
|
31
|
+
e = entry(code)
|
|
32
|
+
return {
|
|
33
|
+
"code": code,
|
|
34
|
+
"message": e["name"],
|
|
35
|
+
"severity": severity or e["default_severity"],
|
|
36
|
+
"recovery": recovery or e["default_recovery"],
|
|
37
|
+
"details": details,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NexError(Exception):
|
|
42
|
+
"""A protocol-level failure carrying a registry code."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, code: str, details: Any = None):
|
|
45
|
+
e = entry(code)
|
|
46
|
+
self.code = code
|
|
47
|
+
self.name = e["name"]
|
|
48
|
+
self.details = details
|
|
49
|
+
super().__init__(f"{code} {self.name}" + (f": {details}" if details is not None else ""))
|
|
50
|
+
|
|
51
|
+
def payload(self) -> dict[str, Any]:
|
|
52
|
+
return error_payload(self.code, self.details)
|
nex/fastschema.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Fast schema checker: compiles the frozen JSON Schemas into Python closures.
|
|
2
|
+
|
|
3
|
+
The JSON Schema files remain authoritative. This module supports exactly the keyword subset the
|
|
4
|
+
Nex schemas use and refuses (``Unsupported``) anything else. It only answers valid / invalid; on
|
|
5
|
+
"invalid" the validator re-runs ``jsonschema`` to produce the precise error, so error behaviour is
|
|
6
|
+
unchanged and ``jsonschema`` stays the reference implementation.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
from urllib.parse import urldefrag, urljoin
|
|
13
|
+
|
|
14
|
+
Check = Callable[[Any], bool]
|
|
15
|
+
|
|
16
|
+
_SUPPORTED = {"$schema", "$id", "title", "description", "$defs", "type", "additionalProperties", "required",
|
|
17
|
+
"properties", "pattern", "not", "const", "enum", "allOf", "oneOf", "$ref", "if", "then",
|
|
18
|
+
"minLength", "items", "uniqueItems", "minItems", "minimum", "maximum"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Unsupported(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_num(v: Any) -> bool:
|
|
26
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_int(v: Any) -> bool:
|
|
30
|
+
return isinstance(v, int) and not isinstance(v, bool) or (isinstance(v, float) and v.is_integer())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_TYPES: dict[str, Check] = {
|
|
34
|
+
"object": lambda v: isinstance(v, dict),
|
|
35
|
+
"array": lambda v: isinstance(v, list),
|
|
36
|
+
"string": lambda v: isinstance(v, str),
|
|
37
|
+
"boolean": lambda v: isinstance(v, bool),
|
|
38
|
+
"null": lambda v: v is None,
|
|
39
|
+
"number": _is_num,
|
|
40
|
+
"integer": _is_int,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _eq(a: Any, b: Any) -> bool:
|
|
45
|
+
"""JSON Schema equality: bool is distinct from numbers; 1 == 1.0."""
|
|
46
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
47
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
48
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
49
|
+
return len(a) == len(b) and all(_eq(x, y) for x, y in zip(a, b))
|
|
50
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
51
|
+
return a.keys() == b.keys() and all(_eq(a[k], b[k]) for k in a)
|
|
52
|
+
if isinstance(a, (list, dict)) or isinstance(b, (list, dict)):
|
|
53
|
+
return False
|
|
54
|
+
return a == b
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Compiler:
|
|
58
|
+
def __init__(self, documents: dict[str, Any]) -> None:
|
|
59
|
+
self.docs = documents
|
|
60
|
+
self.cache: dict[tuple[str, int], Check] = {}
|
|
61
|
+
|
|
62
|
+
def resolve(self, base: str, ref: str) -> tuple[Any, str]:
|
|
63
|
+
url, frag = urldefrag(urljoin(base, ref))
|
|
64
|
+
doc = self.docs.get(url)
|
|
65
|
+
if doc is None:
|
|
66
|
+
raise Unsupported(f"unresolvable $ref {ref!r}")
|
|
67
|
+
node = doc
|
|
68
|
+
for part in [p for p in frag.split("/") if p]:
|
|
69
|
+
node = node[part.replace("~1", "/").replace("~0", "~")]
|
|
70
|
+
return node, url
|
|
71
|
+
|
|
72
|
+
def compile(self, schema: Any, base: str) -> Check:
|
|
73
|
+
if schema is True:
|
|
74
|
+
return lambda v: True
|
|
75
|
+
if schema is False:
|
|
76
|
+
return lambda v: False
|
|
77
|
+
if not isinstance(schema, dict):
|
|
78
|
+
raise Unsupported("schema must be an object")
|
|
79
|
+
key = (base, id(schema))
|
|
80
|
+
if key in self.cache:
|
|
81
|
+
cached = self.cache[key]
|
|
82
|
+
return lambda v: cached(v) # late-bound for recursion
|
|
83
|
+
holder: list[Check] = []
|
|
84
|
+
self.cache[key] = lambda v: holder[0](v)
|
|
85
|
+
holder.append(self._build(schema, schema.get("$id", base)))
|
|
86
|
+
self.cache[key] = holder[0]
|
|
87
|
+
return holder[0]
|
|
88
|
+
|
|
89
|
+
def _build(self, s: dict[str, Any], base: str) -> Check:
|
|
90
|
+
unknown = set(s) - _SUPPORTED
|
|
91
|
+
if unknown:
|
|
92
|
+
raise Unsupported(f"unsupported keywords: {sorted(unknown)}")
|
|
93
|
+
checks: list[Check] = []
|
|
94
|
+
c = self.compile
|
|
95
|
+
|
|
96
|
+
if "$ref" in s:
|
|
97
|
+
node, url = self.resolve(base, s["$ref"])
|
|
98
|
+
checks.append(c(node, url))
|
|
99
|
+
if "type" in s:
|
|
100
|
+
t = s["type"]
|
|
101
|
+
names = [t] if isinstance(t, str) else list(t)
|
|
102
|
+
if any(n not in _TYPES for n in names):
|
|
103
|
+
raise Unsupported("unknown type")
|
|
104
|
+
if len(names) == 1:
|
|
105
|
+
checks.append(_TYPES[names[0]])
|
|
106
|
+
else:
|
|
107
|
+
fns = [_TYPES[n] for n in names]
|
|
108
|
+
checks.append(lambda v, fns=fns: any(f(v) for f in fns))
|
|
109
|
+
if "const" in s:
|
|
110
|
+
const = s["const"]
|
|
111
|
+
checks.append(lambda v: _eq(v, const))
|
|
112
|
+
if "enum" in s:
|
|
113
|
+
options = s["enum"]
|
|
114
|
+
if all(isinstance(o, str) for o in options):
|
|
115
|
+
opts = frozenset(options)
|
|
116
|
+
checks.append(lambda v: isinstance(v, str) and v in opts)
|
|
117
|
+
else:
|
|
118
|
+
checks.append(lambda v: any(_eq(v, o) for o in options))
|
|
119
|
+
if "pattern" in s:
|
|
120
|
+
rx = re.compile(s["pattern"])
|
|
121
|
+
checks.append(lambda v: not isinstance(v, str) or rx.search(v) is not None)
|
|
122
|
+
if "minLength" in s:
|
|
123
|
+
n = s["minLength"]
|
|
124
|
+
checks.append(lambda v: not isinstance(v, str) or len(v) >= n)
|
|
125
|
+
if "minimum" in s:
|
|
126
|
+
lo = s["minimum"]
|
|
127
|
+
checks.append(lambda v: not _is_num(v) or v >= lo)
|
|
128
|
+
if "maximum" in s:
|
|
129
|
+
hi = s["maximum"]
|
|
130
|
+
checks.append(lambda v: not _is_num(v) or v <= hi)
|
|
131
|
+
if "minItems" in s:
|
|
132
|
+
n = s["minItems"]
|
|
133
|
+
checks.append(lambda v: not isinstance(v, list) or len(v) >= n)
|
|
134
|
+
if s.get("uniqueItems") is True:
|
|
135
|
+
def unique(v: Any) -> bool:
|
|
136
|
+
if not isinstance(v, list):
|
|
137
|
+
return True
|
|
138
|
+
for i in range(len(v)):
|
|
139
|
+
for j in range(i + 1, len(v)):
|
|
140
|
+
if _eq(v[i], v[j]):
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
checks.append(unique)
|
|
144
|
+
if "items" in s:
|
|
145
|
+
item = c(s["items"], base)
|
|
146
|
+
checks.append(lambda v: not isinstance(v, list) or all(item(x) for x in v))
|
|
147
|
+
if "required" in s:
|
|
148
|
+
req = tuple(s["required"])
|
|
149
|
+
checks.append(lambda v: not isinstance(v, dict) or all(k in v for k in req))
|
|
150
|
+
props = s.get("properties")
|
|
151
|
+
addl = s.get("additionalProperties", True)
|
|
152
|
+
if props is not None or addl is not True:
|
|
153
|
+
sub = {k: c(p, base) for k, p in (props or {}).items()}
|
|
154
|
+
addl_check = c(addl, base) if addl is not True and addl is not False else None
|
|
155
|
+
closed = addl is False
|
|
156
|
+
|
|
157
|
+
def obj(v: Any) -> bool:
|
|
158
|
+
if not isinstance(v, dict):
|
|
159
|
+
return True
|
|
160
|
+
for k, x in v.items():
|
|
161
|
+
f = sub.get(k)
|
|
162
|
+
if f is not None:
|
|
163
|
+
if not f(x):
|
|
164
|
+
return False
|
|
165
|
+
elif closed:
|
|
166
|
+
return False
|
|
167
|
+
elif addl_check is not None and not addl_check(x):
|
|
168
|
+
return False
|
|
169
|
+
return True
|
|
170
|
+
checks.append(obj)
|
|
171
|
+
if "not" in s:
|
|
172
|
+
neg = c(s["not"], base)
|
|
173
|
+
checks.append(lambda v: not neg(v))
|
|
174
|
+
if "allOf" in s:
|
|
175
|
+
subs = [c(x, base) for x in s["allOf"]]
|
|
176
|
+
checks.append(lambda v: all(f(v) for f in subs))
|
|
177
|
+
if "oneOf" in s:
|
|
178
|
+
subs = [c(x, base) for x in s["oneOf"]]
|
|
179
|
+
checks.append(lambda v: sum(1 for f in subs if f(v)) == 1)
|
|
180
|
+
if "if" in s:
|
|
181
|
+
cond = c(s["if"], base)
|
|
182
|
+
then = c(s["then"], base) if "then" in s else (lambda v: True)
|
|
183
|
+
checks.append(lambda v: then(v) if cond(v) else True)
|
|
184
|
+
|
|
185
|
+
if not checks:
|
|
186
|
+
return lambda v: True
|
|
187
|
+
if len(checks) == 1:
|
|
188
|
+
return checks[0]
|
|
189
|
+
return lambda v: all(f(v) for f in checks)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def compile_schema(documents: dict[str, Any], schema_id: str) -> Check:
|
|
193
|
+
return Compiler(documents).compile(documents[schema_id], schema_id)
|