kindgi 0.1.3__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.
- kindgi/__init__.py +51 -0
- kindgi/_json.py +115 -0
- kindgi/_schema.py +502 -0
- kindgi/_specs/__init__.py +4 -0
- kindgi/_specs/flow.schema.json +517 -0
- kindgi/_specs/pack-index.schema.json +277 -0
- kindgi/_specs/pack-protocol.schema.json +236 -0
- kindgi/_specs/tool.schema.json +297 -0
- kindgi/_version.py +9 -0
- kindgi/client/__init__.py +59 -0
- kindgi/client/_base.py +378 -0
- kindgi/client/_client.py +134 -0
- kindgi/client/_errors.py +228 -0
- kindgi/client/_models.py +6399 -0
- kindgi/client/_resources.py +10442 -0
- kindgi/client/_runtime_config.py +159 -0
- kindgi/client/_sse.py +61 -0
- kindgi/pack/__init__.py +4 -0
- kindgi/pack/__main__.py +79 -0
- kindgi/pack/config.py +220 -0
- kindgi/pack/context.py +143 -0
- kindgi/pack/define.py +588 -0
- kindgi/pack/discovery.py +87 -0
- kindgi/pack/index.py +460 -0
- kindgi/pack/loader.py +88 -0
- kindgi/pack/protocol.py +155 -0
- kindgi/pack/serve.py +248 -0
- kindgi/pack/service.py +674 -0
- kindgi/pack/trace.py +80 -0
- kindgi/py.typed +0 -0
- kindgi/webhooks.py +256 -0
- kindgi-0.1.3.dist-info/METADATA +401 -0
- kindgi-0.1.3.dist-info/RECORD +35 -0
- kindgi-0.1.3.dist-info/WHEEL +4 -0
- kindgi-0.1.3.dist-info/licenses/LICENSE +201 -0
kindgi/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
"""Kindgi™ for Python.
|
|
5
|
+
|
|
6
|
+
Write a pack's tools and guardrail checks in Python:
|
|
7
|
+
|
|
8
|
+
from kindgi import ToolContext, tool
|
|
9
|
+
|
|
10
|
+
A tool that is one HTTP request needs no code: `http_tool(...)`.
|
|
11
|
+
|
|
12
|
+
Agents and flows are data (`Agent`, `Flow`). `python -m kindgi.pack index`
|
|
13
|
+
writes the pack's index; `python -m kindgi.pack serve` runs its code for a
|
|
14
|
+
Kindgi runtime (`kindgi dev` does both for you).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .pack.context import CallCancelled, Cancellation, ToolContext
|
|
21
|
+
from .pack.define import (
|
|
22
|
+
Agent,
|
|
23
|
+
DefinitionError,
|
|
24
|
+
Flow,
|
|
25
|
+
Guardrail,
|
|
26
|
+
Tool,
|
|
27
|
+
guardrail,
|
|
28
|
+
http_tool,
|
|
29
|
+
tool,
|
|
30
|
+
)
|
|
31
|
+
from .pack.trace import CheckResult, ModelCallRecord, RunTrace, ToolCallRecord, ToolResultRecord
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Agent",
|
|
35
|
+
"CallCancelled",
|
|
36
|
+
"Cancellation",
|
|
37
|
+
"CheckResult",
|
|
38
|
+
"DefinitionError",
|
|
39
|
+
"Flow",
|
|
40
|
+
"Guardrail",
|
|
41
|
+
"ModelCallRecord",
|
|
42
|
+
"RunTrace",
|
|
43
|
+
"Tool",
|
|
44
|
+
"ToolCallRecord",
|
|
45
|
+
"ToolContext",
|
|
46
|
+
"ToolResultRecord",
|
|
47
|
+
"__version__",
|
|
48
|
+
"guardrail",
|
|
49
|
+
"http_tool",
|
|
50
|
+
"tool",
|
|
51
|
+
]
|
kindgi/_json.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
"""Canonical JSON — the byte form of a pack index.
|
|
5
|
+
|
|
6
|
+
`stable_dumps` writes what the TypeScript indexer writes for the same value:
|
|
7
|
+
keys sorted at every level (UTF-16 code-unit order, as JavaScript sorts),
|
|
8
|
+
two-space indentation, and numbers formatted the way `JSON.stringify` formats
|
|
9
|
+
them (`1` not `1.0`, `1e-7` not `1e-07`). Same value, same bytes — the
|
|
10
|
+
property a build's integrity check depends on.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
__all__ = ["compact_dumps", "js_number", "stable_dumps"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def stable_dumps(value: Any) -> str:
|
|
25
|
+
"""Serialize `value` canonically (no trailing newline)."""
|
|
26
|
+
out: list[str] = []
|
|
27
|
+
_write(value, 0, out)
|
|
28
|
+
return "".join(out)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compact_dumps(value: Any) -> str:
|
|
32
|
+
"""Serialize `value` without whitespace — a response body."""
|
|
33
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write(value: Any, depth: int, out: list[str]) -> None:
|
|
37
|
+
if value is None:
|
|
38
|
+
out.append("null")
|
|
39
|
+
elif value is True:
|
|
40
|
+
out.append("true")
|
|
41
|
+
elif value is False:
|
|
42
|
+
out.append("false")
|
|
43
|
+
elif isinstance(value, str):
|
|
44
|
+
out.append(json.dumps(value, ensure_ascii=False))
|
|
45
|
+
elif isinstance(value, int):
|
|
46
|
+
out.append(str(value))
|
|
47
|
+
elif isinstance(value, float):
|
|
48
|
+
out.append(js_number(value))
|
|
49
|
+
elif isinstance(value, Mapping):
|
|
50
|
+
mapping: Mapping[Any, Any] = value # pyright: ignore[reportUnknownVariableType]
|
|
51
|
+
keys = sorted((_key(k) for k in mapping), key=_utf16)
|
|
52
|
+
if not keys:
|
|
53
|
+
out.append("{}")
|
|
54
|
+
return
|
|
55
|
+
inner = " " * (depth + 1)
|
|
56
|
+
out.append("{\n")
|
|
57
|
+
for i, key in enumerate(keys):
|
|
58
|
+
if i:
|
|
59
|
+
out.append(",\n")
|
|
60
|
+
out.append(f"{inner}{json.dumps(key, ensure_ascii=False)}: ")
|
|
61
|
+
_write(mapping[key], depth + 1, out)
|
|
62
|
+
out.append(f"\n{' ' * depth}}}")
|
|
63
|
+
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
|
|
64
|
+
items: Sequence[Any] = value # pyright: ignore[reportUnknownVariableType]
|
|
65
|
+
if not items:
|
|
66
|
+
out.append("[]")
|
|
67
|
+
return
|
|
68
|
+
inner = " " * (depth + 1)
|
|
69
|
+
out.append("[\n")
|
|
70
|
+
for i, item in enumerate(items):
|
|
71
|
+
if i:
|
|
72
|
+
out.append(",\n")
|
|
73
|
+
out.append(inner)
|
|
74
|
+
_write(item, depth + 1, out)
|
|
75
|
+
out.append(f"\n{' ' * depth}]")
|
|
76
|
+
else:
|
|
77
|
+
raise TypeError(f"{type(value).__name__} is not JSON-serializable")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _key(key: Any) -> str:
|
|
81
|
+
if not isinstance(key, str):
|
|
82
|
+
raise TypeError(f"JSON object keys must be strings, got {type(key).__name__}")
|
|
83
|
+
return key
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _utf16(key: str) -> bytes:
|
|
87
|
+
# Big-endian UTF-16 bytes compare in code-unit order — JavaScript's string order.
|
|
88
|
+
return key.encode("utf-16-be", "surrogatepass")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def js_number(value: float) -> str:
|
|
92
|
+
"""Format a float as ECMAScript `Number.prototype.toString` does."""
|
|
93
|
+
if not math.isfinite(value):
|
|
94
|
+
raise ValueError(f"{value!r} is not valid JSON")
|
|
95
|
+
if value == 0:
|
|
96
|
+
return "0"
|
|
97
|
+
sign = "-" if value < 0 else ""
|
|
98
|
+
# repr gives the shortest digits that round-trip — the same digits JavaScript picks.
|
|
99
|
+
digits_tuple = Decimal(repr(abs(value))).as_tuple()
|
|
100
|
+
digits = "".join(str(d) for d in digits_tuple.digits).lstrip("0")
|
|
101
|
+
exponent = int(digits_tuple.exponent) # value = int(all digits) * 10**exponent
|
|
102
|
+
stripped = digits.rstrip("0")
|
|
103
|
+
exponent += len(digits) - len(stripped)
|
|
104
|
+
digits = stripped
|
|
105
|
+
k = len(digits)
|
|
106
|
+
n = exponent + k # value = 0.digits * 10**n
|
|
107
|
+
if k <= n <= 21:
|
|
108
|
+
return sign + digits + "0" * (n - k)
|
|
109
|
+
if 0 < n <= 21:
|
|
110
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
111
|
+
if -6 < n <= 0:
|
|
112
|
+
return sign + "0." + "0" * (-n) + digits
|
|
113
|
+
e = n - 1
|
|
114
|
+
mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
|
|
115
|
+
return f"{sign}{mantissa}e{'+' if e >= 0 else '-'}{abs(e)}"
|
kindgi/_schema.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
"""JSON Schema on the wire: derive it from Python types, validate against it.
|
|
5
|
+
|
|
6
|
+
`wire_schema` turns a pydantic model (or any type pydantic understands) into
|
|
7
|
+
the JSON Schema a pack index carries — Draft 2020-12, field names as they
|
|
8
|
+
travel (aliases), no generated titles, local `$ref`s inlined unless a type is
|
|
9
|
+
recursive. A dict is taken as JSON Schema already.
|
|
10
|
+
|
|
11
|
+
`SchemaValidator` checks a value against a schema and reports issues shaped
|
|
12
|
+
like Ajv's (`instancePath`, `schemaPath`, `keyword`, `params`, `message`) —
|
|
13
|
+
the shape pack protocol v2 carries, whichever language runs the pack.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
import ipaddress
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
23
|
+
from datetime import date, time
|
|
24
|
+
from typing import Any, cast
|
|
25
|
+
|
|
26
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
27
|
+
from jsonschema.exceptions import ValidationError as SchemaError
|
|
28
|
+
from pydantic import BaseModel, TypeAdapter
|
|
29
|
+
from pydantic import ValidationError as PydanticError
|
|
30
|
+
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaValue
|
|
31
|
+
from pydantic_core import core_schema
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"SchemaValidator",
|
|
35
|
+
"apply_defaults",
|
|
36
|
+
"check_schema",
|
|
37
|
+
"issues_from_pydantic",
|
|
38
|
+
"wire_schema",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
JsonObject = dict[str, Any]
|
|
42
|
+
Issue = dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _WireSchema(GenerateJsonSchema):
|
|
46
|
+
"""Pydantic's generator, minus the titles it invents."""
|
|
47
|
+
|
|
48
|
+
def field_title_should_be_set(self, schema: Any) -> bool:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue:
|
|
52
|
+
out = super().model_schema(schema)
|
|
53
|
+
# The core config's title defaults to the class name; the model's own config says
|
|
54
|
+
# whether the author chose one.
|
|
55
|
+
explicit = schema["cls"].model_config.get("title")
|
|
56
|
+
if explicit is None and isinstance(out.get("title"), str):
|
|
57
|
+
out.pop("title")
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def wire_schema(source: Any, mode: JsonSchemaMode = "validation") -> JsonObject:
|
|
62
|
+
"""The wire JSON Schema for `source`: a dict (verbatim), a model class, or a type."""
|
|
63
|
+
if isinstance(source, Mapping):
|
|
64
|
+
return dict(cast("Mapping[str, Any]", source))
|
|
65
|
+
if isinstance(source, type) and issubclass(source, BaseModel):
|
|
66
|
+
schema = source.model_json_schema(by_alias=True, mode=mode, schema_generator=_WireSchema)
|
|
67
|
+
else:
|
|
68
|
+
adapter: TypeAdapter[Any] = TypeAdapter(cast(Any, source))
|
|
69
|
+
schema = adapter.json_schema(by_alias=True, mode=mode, schema_generator=_WireSchema)
|
|
70
|
+
return _inline_refs(schema)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _inline_refs(schema: JsonObject) -> JsonObject:
|
|
74
|
+
"""Replace `#/$defs/X` references with the definition, except for recursive types."""
|
|
75
|
+
defs: dict[str, Any] = dict(schema.pop("$defs", {}))
|
|
76
|
+
kept: set[str] = set()
|
|
77
|
+
|
|
78
|
+
def walk(node: Any, stack: tuple[str, ...]) -> Any:
|
|
79
|
+
if isinstance(node, list):
|
|
80
|
+
return [walk(item, stack) for item in cast("list[Any]", node)]
|
|
81
|
+
if not isinstance(node, dict):
|
|
82
|
+
return node
|
|
83
|
+
obj = cast("dict[str, Any]", node)
|
|
84
|
+
ref = obj.get("$ref")
|
|
85
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
86
|
+
name = ref[len("#/$defs/") :]
|
|
87
|
+
if name in stack or name not in defs:
|
|
88
|
+
kept.add(name)
|
|
89
|
+
return {key: walk(value, stack) for key, value in obj.items()}
|
|
90
|
+
inlined = walk(defs[name], (*stack, name))
|
|
91
|
+
siblings = {key: walk(value, stack) for key, value in obj.items() if key != "$ref"}
|
|
92
|
+
return {**inlined, **siblings}
|
|
93
|
+
return {key: walk(value, stack) for key, value in obj.items()}
|
|
94
|
+
|
|
95
|
+
out = walk(schema, ())
|
|
96
|
+
# A recursive definition stays in `$defs`; its body may reference others.
|
|
97
|
+
pending = list(kept)
|
|
98
|
+
done: dict[str, Any] = {}
|
|
99
|
+
while pending:
|
|
100
|
+
name = pending.pop()
|
|
101
|
+
if name in done or name not in defs:
|
|
102
|
+
continue
|
|
103
|
+
before = set(kept)
|
|
104
|
+
done[name] = walk(defs[name], (name,))
|
|
105
|
+
pending.extend(kept - before)
|
|
106
|
+
if done:
|
|
107
|
+
out["$defs"] = done
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Validation
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _formats() -> FormatChecker:
|
|
117
|
+
"""Formats checked as Ajv's `ajv-formats` checks them (stdlib only)."""
|
|
118
|
+
checker = FormatChecker(formats=())
|
|
119
|
+
|
|
120
|
+
def register(name: str, check: Callable[[str], bool]) -> None:
|
|
121
|
+
def accepts(value: object) -> bool:
|
|
122
|
+
return not isinstance(value, str) or check(value)
|
|
123
|
+
|
|
124
|
+
checker.checks(name)(accepts)
|
|
125
|
+
|
|
126
|
+
register("date-time", _is_date_time)
|
|
127
|
+
register("date", _is_date)
|
|
128
|
+
register("time", _is_time)
|
|
129
|
+
register("uuid", lambda v: _UUID.match(v) is not None)
|
|
130
|
+
register("email", lambda v: _EMAIL.match(v) is not None)
|
|
131
|
+
register("hostname", lambda v: _HOSTNAME.match(v) is not None)
|
|
132
|
+
register("ipv4", lambda v: _is_ip(v, 4))
|
|
133
|
+
register("ipv6", lambda v: _is_ip(v, 6))
|
|
134
|
+
register("uri", lambda v: _URI.match(v) is not None)
|
|
135
|
+
register("uri-reference", lambda v: _NO_SPACE.match(v) is not None)
|
|
136
|
+
register("regex", _is_regex)
|
|
137
|
+
return checker
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
|
|
141
|
+
_TIME = re.compile(r"^(\d{2}):(\d{2}):(\d{2})(\.\d+)?([zZ]|[+-]\d{2}:\d{2})$")
|
|
142
|
+
_UUID = re.compile(r"^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$", re.IGNORECASE)
|
|
143
|
+
_EMAIL = re.compile(
|
|
144
|
+
r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*"
|
|
145
|
+
r"@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$",
|
|
146
|
+
re.IGNORECASE,
|
|
147
|
+
)
|
|
148
|
+
_HOSTNAME = re.compile(
|
|
149
|
+
r"^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$",
|
|
150
|
+
re.IGNORECASE,
|
|
151
|
+
)
|
|
152
|
+
_URI = re.compile(r"^[a-z][a-z0-9+\-.]*:[^\s]*$", re.IGNORECASE)
|
|
153
|
+
_NO_SPACE = re.compile(r"^[^\s]*$")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_date(value: str) -> bool:
|
|
157
|
+
m = _DATE.match(value)
|
|
158
|
+
if m is None:
|
|
159
|
+
return False
|
|
160
|
+
try:
|
|
161
|
+
date(int(m[1]), int(m[2]), int(m[3]))
|
|
162
|
+
except ValueError:
|
|
163
|
+
return False
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _is_time(value: str) -> bool:
|
|
168
|
+
m = _TIME.match(value)
|
|
169
|
+
if m is None:
|
|
170
|
+
return False
|
|
171
|
+
hour, minute, second = int(m[1]), int(m[2]), int(m[3])
|
|
172
|
+
try:
|
|
173
|
+
time(hour, minute, min(second, 59)) # a leap second (60) is allowed
|
|
174
|
+
except ValueError:
|
|
175
|
+
return False
|
|
176
|
+
return second <= 60
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_date_time(value: str) -> bool:
|
|
180
|
+
parts = re.split(r"[tT\s]", value, maxsplit=1)
|
|
181
|
+
return len(parts) == 2 and _is_date(parts[0]) and _is_time(parts[1])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _is_ip(value: str, version: int) -> bool:
|
|
185
|
+
try:
|
|
186
|
+
return ipaddress.ip_address(value).version == version
|
|
187
|
+
except ValueError:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_regex(value: str) -> bool:
|
|
192
|
+
try:
|
|
193
|
+
re.compile(value)
|
|
194
|
+
except re.error:
|
|
195
|
+
return False
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_FORMAT_CHECKER = _formats()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def check_schema(schema: Mapping[str, Any]) -> None:
|
|
203
|
+
"""Raise `jsonschema.SchemaError` when `schema` is not a valid Draft 2020-12 schema."""
|
|
204
|
+
Draft202012Validator.check_schema(schema)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class SchemaValidator:
|
|
208
|
+
"""A compiled Draft 2020-12 schema that reports Ajv-shaped issues."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, schema: Mapping[str, Any]) -> None:
|
|
211
|
+
check_schema(schema)
|
|
212
|
+
self._schema = schema
|
|
213
|
+
self._validator = Draft202012Validator(schema, format_checker=_FORMAT_CHECKER)
|
|
214
|
+
|
|
215
|
+
def issues(self, instance: Any) -> list[Issue]:
|
|
216
|
+
out: list[Issue] = []
|
|
217
|
+
seen: set[tuple[str, str, str]] = set()
|
|
218
|
+
errors: Iterable[SchemaError] = self._validator.iter_errors(instance) # pyright: ignore[reportUnknownMemberType]
|
|
219
|
+
for error in errors:
|
|
220
|
+
for issue in _issues_for(error):
|
|
221
|
+
key = (issue["instancePath"], issue["schemaPath"], _param_key(issue))
|
|
222
|
+
if key not in seen:
|
|
223
|
+
seen.add(key)
|
|
224
|
+
out.append(issue)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
def explain(self, instance: Any) -> Issue | None:
|
|
228
|
+
"""The issue that best says what's wrong with `instance`, or `None` when it's valid.
|
|
229
|
+
|
|
230
|
+
Through a `oneOf` / `anyOf` it follows the one alternative whose
|
|
231
|
+
discriminator (a `const` or `enum` property such as `kind`) the value
|
|
232
|
+
matches, so a tool node reports its own problem, not "matches none of the
|
|
233
|
+
node kinds"; when none matches, it names the discriminator's allowed values.
|
|
234
|
+
"""
|
|
235
|
+
errors: list[SchemaError] = list(self._validator.iter_errors(instance)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
|
236
|
+
if not errors:
|
|
237
|
+
return None
|
|
238
|
+
return _explain(errors[0])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _explain(error: SchemaError) -> Issue:
|
|
242
|
+
while error.validator in ("oneOf", "anyOf") and error.context:
|
|
243
|
+
depth = len(error.absolute_path)
|
|
244
|
+
branches = _branches(error)
|
|
245
|
+
matching = [errs for errs in branches if not _misses(errs, depth)]
|
|
246
|
+
if len(matching) == 1:
|
|
247
|
+
error = matching[0][0]
|
|
248
|
+
continue
|
|
249
|
+
if matching:
|
|
250
|
+
break
|
|
251
|
+
# Every alternative misses. The property they discriminate on (`kind`) is the
|
|
252
|
+
# one most of them check directly; an alternative that misses only on another
|
|
253
|
+
# one (`loopKind`, under a `kind` that matched) is where the value belongs.
|
|
254
|
+
props = [_prop(e) for errs in branches for e in errs if _is_discriminator(e, depth)]
|
|
255
|
+
if not props:
|
|
256
|
+
break
|
|
257
|
+
prop = max(set(props), key=props.count)
|
|
258
|
+
elsewhere = [errs for errs in branches if prop not in _missed_props(errs, depth)]
|
|
259
|
+
if len(elsewhere) == 1:
|
|
260
|
+
error = next(
|
|
261
|
+
(e for e in elsewhere[0] if e.validator in ("oneOf", "anyOf")), elsewhere[0][0]
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
values = _allowed(branches, depth, prop)
|
|
265
|
+
return {
|
|
266
|
+
"instancePath": _pointer([*error.absolute_path, prop]),
|
|
267
|
+
"schemaPath": "#" + _pointer(error.absolute_schema_path),
|
|
268
|
+
"keyword": "enum",
|
|
269
|
+
"params": {"allowedValues": values},
|
|
270
|
+
"message": "must be one of " + ", ".join(json.dumps(v) for v in values),
|
|
271
|
+
}
|
|
272
|
+
issues = _issues_for(error)
|
|
273
|
+
if issues:
|
|
274
|
+
return issues[0]
|
|
275
|
+
return {
|
|
276
|
+
"instancePath": _pointer(error.absolute_path),
|
|
277
|
+
"schemaPath": "#" + _pointer(error.absolute_schema_path),
|
|
278
|
+
"keyword": str(error.validator),
|
|
279
|
+
"params": {},
|
|
280
|
+
"message": error.message,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _branches(error: SchemaError) -> list[list[SchemaError]]:
|
|
285
|
+
"""A `oneOf` / `anyOf` error's sub-errors, grouped by alternative."""
|
|
286
|
+
grouped: dict[Any, list[SchemaError]] = {}
|
|
287
|
+
for sub in error.context or ():
|
|
288
|
+
grouped.setdefault(sub.relative_schema_path[0], []).append(sub)
|
|
289
|
+
return list(grouped.values())
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _is_discriminator(error: SchemaError, depth: int) -> bool:
|
|
293
|
+
"""A `const` / `enum` failure on a property of the object at `depth`."""
|
|
294
|
+
return error.validator in ("const", "enum") and len(error.absolute_path) == depth + 1
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _prop(error: SchemaError) -> str:
|
|
298
|
+
return str(error.absolute_path[-1])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _missed_props(errors: Iterable[SchemaError], depth: int) -> set[str]:
|
|
302
|
+
"""The discriminating properties an alternative fails on: directly, or in every
|
|
303
|
+
alternative of a nested `oneOf` / `anyOf` on the same object."""
|
|
304
|
+
missed: set[str] = set()
|
|
305
|
+
for error in errors:
|
|
306
|
+
if _is_discriminator(error, depth):
|
|
307
|
+
missed.add(_prop(error))
|
|
308
|
+
elif error.validator in ("oneOf", "anyOf") and len(error.absolute_path) == depth:
|
|
309
|
+
nested = [_missed_props(errs, depth) for errs in _branches(error)]
|
|
310
|
+
if nested and all(nested):
|
|
311
|
+
common = nested[0].intersection(*nested[1:])
|
|
312
|
+
missed |= common or nested[0].union(*nested[1:])
|
|
313
|
+
return missed
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _misses(errors: Iterable[SchemaError], depth: int) -> bool:
|
|
317
|
+
return bool(_missed_props(errors, depth))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _allowed(branches: list[list[SchemaError]], depth: int, prop: str) -> list[Any]:
|
|
321
|
+
"""The values the alternatives allow for `prop`, in schema order."""
|
|
322
|
+
values: list[Any] = []
|
|
323
|
+
|
|
324
|
+
def collect(errors: Iterable[SchemaError]) -> None:
|
|
325
|
+
for error in errors:
|
|
326
|
+
if _is_discriminator(error, depth) and _prop(error) == prop:
|
|
327
|
+
allowed = error.validator_value
|
|
328
|
+
found = allowed if error.validator == "enum" else [allowed]
|
|
329
|
+
values.extend(v for v in cast("list[Any]", found) if v not in values)
|
|
330
|
+
elif error.validator in ("oneOf", "anyOf") and error.context:
|
|
331
|
+
collect(error.context)
|
|
332
|
+
|
|
333
|
+
for errs in branches:
|
|
334
|
+
collect(errs)
|
|
335
|
+
return values
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _param_key(issue: Issue) -> str:
|
|
339
|
+
params: Any = issue.get("params", {})
|
|
340
|
+
if not isinstance(params, dict):
|
|
341
|
+
return ""
|
|
342
|
+
return repr(sorted(cast("dict[str, Any]", params).items()))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _pointer(parts: Iterable[Any]) -> str:
|
|
346
|
+
return "".join("/" + str(p).replace("~", "~0").replace("/", "~1") for p in parts)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _issues_for(error: SchemaError) -> list[Issue]:
|
|
350
|
+
keyword = str(error.validator)
|
|
351
|
+
value: Any = error.validator_value
|
|
352
|
+
instance_path = _pointer(error.absolute_path)
|
|
353
|
+
schema_path = "#" + _pointer(error.absolute_schema_path)
|
|
354
|
+
|
|
355
|
+
def issue(params: JsonObject, message: str) -> Issue:
|
|
356
|
+
return {
|
|
357
|
+
"instancePath": instance_path,
|
|
358
|
+
"schemaPath": schema_path,
|
|
359
|
+
"keyword": keyword,
|
|
360
|
+
"params": params,
|
|
361
|
+
"message": message,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
instance: Any = error.instance
|
|
365
|
+
if keyword == "required" and isinstance(instance, dict):
|
|
366
|
+
present = cast("dict[str, Any]", instance)
|
|
367
|
+
missing = [name for name in cast("list[str]", value) if name not in present]
|
|
368
|
+
return [
|
|
369
|
+
issue({"missingProperty": name}, f"must have required property '{name}'")
|
|
370
|
+
for name in missing
|
|
371
|
+
]
|
|
372
|
+
if keyword == "additionalProperties" and value is False and isinstance(instance, dict):
|
|
373
|
+
schema = cast("dict[str, Any]", error.schema)
|
|
374
|
+
declared = set(cast("dict[str, Any]", schema.get("properties", {})))
|
|
375
|
+
patterns = [
|
|
376
|
+
re.compile(p) for p in cast("dict[str, Any]", schema.get("patternProperties", {}))
|
|
377
|
+
]
|
|
378
|
+
extras = [
|
|
379
|
+
name
|
|
380
|
+
for name in cast("dict[str, Any]", instance)
|
|
381
|
+
if name not in declared and not any(p.search(name) for p in patterns)
|
|
382
|
+
]
|
|
383
|
+
return [
|
|
384
|
+
issue({"additionalProperty": name}, "must NOT have additional properties")
|
|
385
|
+
for name in extras
|
|
386
|
+
]
|
|
387
|
+
return [issue(*_params_and_message(keyword, value, error))]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _params_and_message(keyword: str, value: Any, error: SchemaError) -> tuple[JsonObject, str]:
|
|
391
|
+
if keyword == "type":
|
|
392
|
+
types = ",".join(cast("list[str]", value)) if isinstance(value, list) else str(value)
|
|
393
|
+
return {"type": types}, f"must be {types}"
|
|
394
|
+
if keyword == "enum":
|
|
395
|
+
return {"allowedValues": value}, "must be equal to one of the allowed values"
|
|
396
|
+
if keyword == "const":
|
|
397
|
+
return {"allowedValue": value}, "must be equal to constant"
|
|
398
|
+
limits = {
|
|
399
|
+
"minLength": ("fewer", "characters"),
|
|
400
|
+
"maxLength": ("more", "characters"),
|
|
401
|
+
"minItems": ("fewer", "items"),
|
|
402
|
+
"maxItems": ("more", "items"),
|
|
403
|
+
"minProperties": ("fewer", "properties"),
|
|
404
|
+
"maxProperties": ("more", "properties"),
|
|
405
|
+
}
|
|
406
|
+
if keyword in limits:
|
|
407
|
+
word, unit = limits[keyword]
|
|
408
|
+
return {"limit": value}, f"must NOT have {word} than {value} {unit}"
|
|
409
|
+
comparisons = {
|
|
410
|
+
"minimum": ">=",
|
|
411
|
+
"maximum": "<=",
|
|
412
|
+
"exclusiveMinimum": ">",
|
|
413
|
+
"exclusiveMaximum": "<",
|
|
414
|
+
}
|
|
415
|
+
if keyword in comparisons:
|
|
416
|
+
op = comparisons[keyword]
|
|
417
|
+
return {"comparison": op, "limit": value}, f"must be {op} {value}"
|
|
418
|
+
if keyword == "pattern":
|
|
419
|
+
return {"pattern": value}, f'must match pattern "{value}"'
|
|
420
|
+
if keyword == "format":
|
|
421
|
+
return {"format": value}, f'must match format "{value}"'
|
|
422
|
+
if keyword == "multipleOf":
|
|
423
|
+
return {"multipleOf": value}, f"must be multiple of {value}"
|
|
424
|
+
if keyword == "uniqueItems":
|
|
425
|
+
return {}, "must NOT have duplicate items"
|
|
426
|
+
if keyword == "anyOf":
|
|
427
|
+
return {}, "must match a schema in anyOf"
|
|
428
|
+
if keyword == "oneOf":
|
|
429
|
+
return {"passingSchemas": None}, "must match exactly one schema in oneOf"
|
|
430
|
+
if keyword == "not":
|
|
431
|
+
return {}, "must NOT be valid"
|
|
432
|
+
return {}, error.message
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def issues_from_pydantic(error: PydanticError) -> list[Issue]:
|
|
436
|
+
"""Ajv-shaped issues for a pydantic validation error (a model's own validators)."""
|
|
437
|
+
return [
|
|
438
|
+
{
|
|
439
|
+
"instancePath": _pointer(detail["loc"]),
|
|
440
|
+
"schemaPath": "#",
|
|
441
|
+
"keyword": detail["type"],
|
|
442
|
+
"params": {},
|
|
443
|
+
"message": detail["msg"],
|
|
444
|
+
}
|
|
445
|
+
for detail in error.errors(include_url=False)
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def apply_defaults(schema: Mapping[str, Any], instance: Any) -> Any:
|
|
450
|
+
"""`instance` with its schema's `default`s filled in, as Ajv's `useDefaults` fills them.
|
|
451
|
+
|
|
452
|
+
A property missing from an object gets its `properties` entry's `default`
|
|
453
|
+
(a copy), then nested objects and array items are filled the same way;
|
|
454
|
+
`allOf` branches and local `$ref`s count, `anyOf` / `oneOf` / `not` don't.
|
|
455
|
+
Returns a new value; `instance` is not modified.
|
|
456
|
+
"""
|
|
457
|
+
root = schema
|
|
458
|
+
|
|
459
|
+
def resolve(node: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
460
|
+
ref = node.get("$ref")
|
|
461
|
+
if isinstance(ref, str) and ref.startswith("#/"):
|
|
462
|
+
target: Mapping[str, Any] = root
|
|
463
|
+
for part in ref[2:].split("/"):
|
|
464
|
+
step: object = target.get(part.replace("~1", "/").replace("~0", "~"))
|
|
465
|
+
target = cast("Mapping[str, Any]", step) if isinstance(step, Mapping) else {}
|
|
466
|
+
return target
|
|
467
|
+
return node
|
|
468
|
+
|
|
469
|
+
def fill(node: Any, value: Any) -> Any:
|
|
470
|
+
if not isinstance(node, Mapping):
|
|
471
|
+
return value
|
|
472
|
+
sub = resolve(cast("Mapping[str, Any]", node))
|
|
473
|
+
for branch in cast("list[Any]", sub.get("allOf", [])):
|
|
474
|
+
value = fill(branch, value)
|
|
475
|
+
if isinstance(value, dict):
|
|
476
|
+
obj = dict(cast("dict[str, Any]", value))
|
|
477
|
+
properties = sub.get("properties")
|
|
478
|
+
if isinstance(properties, Mapping):
|
|
479
|
+
for name, prop in cast("Mapping[str, Any]", properties).items():
|
|
480
|
+
if not isinstance(prop, Mapping):
|
|
481
|
+
continue
|
|
482
|
+
prop_schema = resolve(cast("Mapping[str, Any]", prop))
|
|
483
|
+
if name not in obj and "default" in prop_schema:
|
|
484
|
+
obj[name] = copy.deepcopy(prop_schema["default"])
|
|
485
|
+
if name in obj:
|
|
486
|
+
obj[name] = fill(prop_schema, obj[name])
|
|
487
|
+
return obj
|
|
488
|
+
if isinstance(value, list):
|
|
489
|
+
items_list = list(cast("list[Any]", value))
|
|
490
|
+
prefix = sub.get("prefixItems")
|
|
491
|
+
prefix_list = cast("list[Any]", prefix) if isinstance(prefix, list) else []
|
|
492
|
+
for i, item_schema in enumerate(prefix_list):
|
|
493
|
+
if i < len(items_list):
|
|
494
|
+
items_list[i] = fill(item_schema, items_list[i])
|
|
495
|
+
items = sub.get("items")
|
|
496
|
+
if isinstance(items, Mapping):
|
|
497
|
+
for i in range(len(prefix_list), len(items_list)):
|
|
498
|
+
items_list[i] = fill(items, items_list[i])
|
|
499
|
+
return items_list
|
|
500
|
+
return value
|
|
501
|
+
|
|
502
|
+
return fill(schema, copy.deepcopy(instance))
|