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.
Files changed (50) hide show
  1. nex/__init__.py +2 -0
  2. nex/__main__.py +3 -0
  3. nex/_data.py +27 -0
  4. nex/agent.py +66 -0
  5. nex/canonical.py +131 -0
  6. nex/cli.py +88 -0
  7. nex/demo.py +21 -0
  8. nex/errors.py +52 -0
  9. nex/fastschema.py +193 -0
  10. nex/glyphs.py +329 -0
  11. nex/models.py +97 -0
  12. nex/parser.py +194 -0
  13. nex/protocol.py +38 -0
  14. nex/runtime.py +296 -0
  15. nex/schemas/__init__.py +1 -0
  16. nex/schemas/nex-error-1.1.schema.json +15 -0
  17. nex/schemas/nex-error-message-1.1.schema.json +16 -0
  18. nex/schemas/nex-message-1.1.schema.json +72 -0
  19. nex/schemas/payloads/__init__.py +1 -0
  20. nex/schemas/payloads/act.schema.json +14 -0
  21. nex/schemas/payloads/error.schema.json +15 -0
  22. nex/schemas/payloads/handshake.schema.json +37 -0
  23. nex/schemas/payloads/plan.schema.json +27 -0
  24. nex/schemas/payloads/report.schema.json +19 -0
  25. nex/schemas/payloads/request.schema.json +19 -0
  26. nex/schemas/payloads/sync.schema.json +23 -0
  27. nex/schemas/payloads/verify.schema.json +14 -0
  28. nex/schemas/registries/__init__.py +1 -0
  29. nex/schemas/registries/nex-command-registry-1.1.json +46 -0
  30. nex/schemas/registries/nex-error-registry-1.1.json +585 -0
  31. nex/schemas/registries/nex-impact-registry-1.0.json +10 -0
  32. nex/schemas/registries/nex-intent-registry-1.0.json +13 -0
  33. nex/schemas/registries/nex-ontology-registry-1.1.json +237 -0
  34. nex/schemas/registries/nex-state-registry-1.1.json +83 -0
  35. nex/security.py +150 -0
  36. nex/session.py +70 -0
  37. nex/state_model.py +68 -0
  38. nex/stores/__init__.py +10 -0
  39. nex/stores/base.py +61 -0
  40. nex/stores/memory.py +111 -0
  41. nex/stores/sqlite.py +189 -0
  42. nex/ultrasonic.py +134 -0
  43. nex/validator.py +216 -0
  44. nex/worker.py +87 -0
  45. nex_protocol-0.1.0a1.dist-info/METADATA +63 -0
  46. nex_protocol-0.1.0a1.dist-info/RECORD +50 -0
  47. nex_protocol-0.1.0a1.dist-info/WHEEL +5 -0
  48. nex_protocol-0.1.0a1.dist-info/entry_points.txt +2 -0
  49. nex_protocol-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  50. nex_protocol-0.1.0a1.dist-info/top_level.txt +1 -0
nex/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Nex: deterministic language and protocol for agent understanding."""
2
+ __version__ = "0.1.0a1"
nex/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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)