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 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))
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026 Kindgi Inc.
3
+
4
+ """JSON Schemas vendored from `@kindgi/specs` (kept in step by `tests/test_specs_drift.py`)."""