790 lines
27 KiB
Python
790 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Literal
|
|
|
|
from jsonschema import Draft202012Validator, ValidationError
|
|
|
|
from wf_core.models.input_bindings import (
|
|
ArrayExpression,
|
|
InputExpression,
|
|
LiteralExpression,
|
|
ObjectExpression,
|
|
PathExpression,
|
|
)
|
|
|
|
from .schema_projection import (
|
|
JsonObject,
|
|
SchemaLocationPart,
|
|
_resolve_local_reference,
|
|
project_schema_path_to_schema_path,
|
|
schema_fragment_at_location,
|
|
schema_location_is_explicit,
|
|
)
|
|
|
|
Compatibility = Literal["compatible", "incompatible", "unsupported"]
|
|
|
|
_COMPOSITION_KEYWORDS = frozenset({"allOf", "anyOf", "oneOf", "if", "then", "else"})
|
|
_ANNOTATION_KEYWORDS = frozenset(
|
|
{
|
|
"title",
|
|
"description",
|
|
"default",
|
|
"examples",
|
|
"deprecated",
|
|
"readOnly",
|
|
"writeOnly",
|
|
}
|
|
)
|
|
_STRUCTURAL_KEYWORDS = frozenset(
|
|
{
|
|
"type",
|
|
"properties",
|
|
"required",
|
|
"additionalProperties",
|
|
"items",
|
|
"prefixItems",
|
|
"minItems",
|
|
"maxItems",
|
|
"minLength",
|
|
"maxLength",
|
|
"pattern",
|
|
"format",
|
|
"uniqueItems",
|
|
"multipleOf",
|
|
"minimum",
|
|
"maximum",
|
|
"exclusiveMinimum",
|
|
"exclusiveMaximum",
|
|
"const",
|
|
"enum",
|
|
"$ref",
|
|
"$defs",
|
|
"definitions",
|
|
}
|
|
)
|
|
|
|
_NUMERIC_CONSTRAINT_KEYWORDS = frozenset(
|
|
{"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"}
|
|
)
|
|
_MAX_SCHEMA_NORMALIZATION_NODES = 2048
|
|
|
|
|
|
@dataclass
|
|
class _SchemaNormalizationContext:
|
|
"""Bound recursive local-schema expansion independently of call depth."""
|
|
|
|
nodes: int = 0
|
|
active_refs: set[str] = field(default_factory=set)
|
|
|
|
def visit(self, label: str) -> None:
|
|
self.nodes += 1
|
|
if self.nodes > _MAX_SCHEMA_NORMALIZATION_NODES:
|
|
raise ValueError(
|
|
f"schema normalization exceeds the global node budget at {label}"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExpressionProjection:
|
|
"""Validated expression plus schemas inferred for missing graph sources."""
|
|
|
|
input_schema: JsonObject
|
|
state_schema: JsonObject
|
|
deferred_paths: tuple[str, ...]
|
|
|
|
|
|
def validate_and_project_input_expression(
|
|
expression: InputExpression,
|
|
*,
|
|
target_schema: JsonObject,
|
|
input_schema: JsonObject,
|
|
state_schema: JsonObject,
|
|
target_location: Sequence[SchemaLocationPart] = (),
|
|
label: str = "input expression",
|
|
) -> ExpressionProjection:
|
|
"""Validate one expression and project schemas for undeclared input/state paths.
|
|
|
|
This is intentionally not a general JSON Schema subtype engine. It handles
|
|
finite literals, primitive type widening (integer to number), declared
|
|
object/array members, and const/enum subsets. Unsupported composition or
|
|
constraints fail closed; context paths are the one deliberate exception
|
|
because their schema is only authoritative at runtime.
|
|
"""
|
|
target_fragment = schema_fragment_at_location(
|
|
target_schema,
|
|
tuple(target_location),
|
|
label="capability input schema",
|
|
)
|
|
projected_input = deepcopy(input_schema)
|
|
projected_state = deepcopy(state_schema)
|
|
deferred_paths: list[str] = []
|
|
|
|
def visit(
|
|
current: InputExpression,
|
|
fragment: JsonObject,
|
|
location: tuple[SchemaLocationPart, ...],
|
|
) -> None:
|
|
nonlocal projected_input, projected_state
|
|
_ensure_supported_schema(fragment, label=_location_label(label, location))
|
|
if isinstance(current, LiteralExpression):
|
|
try:
|
|
Draft202012Validator(fragment).validate(current.value)
|
|
except ValidationError as exc:
|
|
raise ValueError(
|
|
f"{_location_label(label, location)} literal does not satisfy "
|
|
f"the target schema: {exc.message}"
|
|
) from exc
|
|
return
|
|
|
|
if isinstance(current, PathExpression):
|
|
source_path = current.path
|
|
source_text = str(source_path)
|
|
if source_path.root == "context":
|
|
deferred_paths.append(source_text)
|
|
return
|
|
|
|
if source_path.root == "input":
|
|
source_document = projected_input
|
|
else:
|
|
source_document = projected_state
|
|
|
|
if schema_location_is_explicit(
|
|
source_document,
|
|
source_path.parts,
|
|
label=f"{source_path.root} source schema",
|
|
):
|
|
source_fragment = schema_fragment_at_location(
|
|
source_document,
|
|
source_path.parts,
|
|
label=f"{source_path.root} source schema",
|
|
)
|
|
else:
|
|
source_fragment = None
|
|
if source_fragment is not None:
|
|
compatibility = _schema_assignability(
|
|
source_fragment,
|
|
fragment,
|
|
source_label=f"{source_text} source",
|
|
target_label=_location_label(label, location),
|
|
)
|
|
if compatibility == "incompatible":
|
|
raise ValueError(
|
|
f"{_location_label(label, location)} source path "
|
|
f"{source_text!r} is incompatible with its target schema"
|
|
)
|
|
if compatibility == "unsupported":
|
|
raise ValueError(
|
|
f"{_location_label(label, location)} source path "
|
|
f"{source_text!r} uses an unsupported schema comparison"
|
|
)
|
|
return
|
|
|
|
projected = _project_missing_source_schema(
|
|
source_document,
|
|
source_parts=source_path.parts,
|
|
target_document=target_schema,
|
|
target_location=location,
|
|
)
|
|
if source_path.root == "input":
|
|
projected_input = projected
|
|
else:
|
|
projected_state = projected
|
|
return
|
|
|
|
if isinstance(current, ArrayExpression):
|
|
_validate_array_length(
|
|
current, fragment, label=_location_label(label, location)
|
|
)
|
|
for index, item in enumerate(current.items):
|
|
child = schema_fragment_at_location(
|
|
fragment,
|
|
(index,),
|
|
label=_location_label(label, location),
|
|
)
|
|
visit(item, child, (*location, index))
|
|
return
|
|
|
|
if isinstance(current, ObjectExpression):
|
|
resolved = _resolved_schema(
|
|
fragment, label=_location_label(label, location)
|
|
)
|
|
properties = resolved.get("properties")
|
|
declared = properties if isinstance(properties, Mapping) else {}
|
|
required = resolved.get("required", [])
|
|
if isinstance(required, list):
|
|
missing = [name for name in required if name not in current.fields]
|
|
if missing:
|
|
raise ValueError(
|
|
f"{_location_label(label, location)} is missing required "
|
|
f"fields {missing!r}"
|
|
)
|
|
additional = resolved.get("additionalProperties", True)
|
|
for name, item in current.fields.items():
|
|
if isinstance(declared, Mapping) and name in declared:
|
|
child = schema_fragment_at_location(
|
|
fragment,
|
|
(name,),
|
|
label=_location_label(label, location),
|
|
)
|
|
elif additional is False:
|
|
raise ValueError(
|
|
f"{_location_label(label, location)} field {name!r} "
|
|
"is not allowed by additionalProperties"
|
|
)
|
|
else:
|
|
child = schema_fragment_at_location(
|
|
fragment,
|
|
(name,),
|
|
label=_location_label(label, location),
|
|
)
|
|
visit(item, child, (*location, name))
|
|
return
|
|
|
|
raise TypeError(f"unsupported input expression {current!r}")
|
|
|
|
visit(expression, target_fragment, tuple(target_location))
|
|
return ExpressionProjection(
|
|
input_schema=projected_input,
|
|
state_schema=projected_state,
|
|
deferred_paths=tuple(deferred_paths),
|
|
)
|
|
|
|
|
|
def _validate_array_length(
|
|
expression: ArrayExpression,
|
|
schema: Mapping[str, Any],
|
|
*,
|
|
label: str,
|
|
) -> None:
|
|
resolved = _resolved_schema(schema, label=label)
|
|
schema_type = resolved.get("type")
|
|
if schema_type is not None and schema_type != "array":
|
|
raise ValueError(f"{label} array expression is incompatible with target schema")
|
|
minimum = resolved.get("minItems")
|
|
maximum = resolved.get("maxItems")
|
|
if isinstance(minimum, int) and len(expression.items) < minimum:
|
|
raise ValueError(f"{label} requires at least {minimum} array items")
|
|
if isinstance(maximum, int) and len(expression.items) > maximum:
|
|
raise ValueError(f"{label} allows at most {maximum} array items")
|
|
|
|
|
|
def _project_missing_source_schema(
|
|
source_schema: JsonObject,
|
|
*,
|
|
source_parts: tuple[str, ...],
|
|
target_document: JsonObject,
|
|
target_location: tuple[SchemaLocationPart, ...],
|
|
) -> JsonObject:
|
|
"""Copy one target-position schema into an undeclared input/state path."""
|
|
target_fragment = schema_fragment_at_location(
|
|
target_document,
|
|
target_location,
|
|
label="capability input schema",
|
|
)
|
|
source_holder: JsonObject = {
|
|
"type": "object",
|
|
"properties": {"__wf_expression_value__": target_fragment},
|
|
}
|
|
for key in ("$defs", "definitions"):
|
|
if key in target_document:
|
|
source_holder[key] = deepcopy(target_document[key])
|
|
return project_schema_path_to_schema_path(
|
|
target_schema=source_schema,
|
|
source_schema=source_holder,
|
|
source_parts=("__wf_expression_value__",),
|
|
target_parts=source_parts,
|
|
allow_existing_equivalent=True,
|
|
)
|
|
|
|
|
|
def _schema_assignability(
|
|
source: Mapping[str, Any],
|
|
target: Mapping[str, Any],
|
|
*,
|
|
source_label: str,
|
|
target_label: str,
|
|
) -> Compatibility:
|
|
"""Classify whether every value allowed by ``source`` fits ``target``.
|
|
|
|
This is deliberately a conservative three-state relation: ``compatible``
|
|
proves the subset relation, ``incompatible`` proves a counterexample, and
|
|
``unsupported`` means the supported subset of JSON Schema cannot decide.
|
|
The caller must reject the last state rather than treating it as success.
|
|
"""
|
|
try:
|
|
source_normalized = _canonical_schema(source, label=source_label)
|
|
target_normalized = _canonical_schema(target, label=target_label)
|
|
except ValueError:
|
|
return "unsupported"
|
|
if source_normalized == target_normalized:
|
|
return "compatible"
|
|
|
|
source_values = _finite_schema_values(source_normalized)
|
|
target_values = _finite_schema_values(target_normalized)
|
|
if source_values is not None:
|
|
validator = Draft202012Validator(target_normalized)
|
|
if all(validator.is_valid(value) for value in source_values):
|
|
return "compatible"
|
|
return "incompatible"
|
|
if target_values is not None:
|
|
return "unsupported"
|
|
|
|
source_types = _schema_types(source_normalized)
|
|
target_types = _schema_types(target_normalized)
|
|
if source_types is None or target_types is None:
|
|
if not target_normalized:
|
|
return "compatible"
|
|
return "unsupported"
|
|
if not _types_assignable(source_types, target_types):
|
|
return "incompatible"
|
|
|
|
if source_types & {"object"} or target_types & {"object"}:
|
|
return _object_assignability(
|
|
source_normalized, target_normalized, source_label, target_label
|
|
)
|
|
if source_types & {"array"} or target_types & {"array"}:
|
|
return _array_assignability(
|
|
source_normalized, target_normalized, source_label, target_label
|
|
)
|
|
|
|
constraint_keys = (set(source_normalized) | set(target_normalized)) - {
|
|
"type",
|
|
"const",
|
|
"enum",
|
|
}
|
|
unsupported_constraints = constraint_keys - _NUMERIC_CONSTRAINT_KEYWORDS
|
|
if unsupported_constraints:
|
|
return "unsupported"
|
|
return _numeric_constraint_assignability(source_normalized, target_normalized)
|
|
|
|
|
|
def _object_assignability(
|
|
source: Mapping[str, Any],
|
|
target: Mapping[str, Any],
|
|
source_label: str,
|
|
target_label: str,
|
|
) -> Compatibility:
|
|
source_properties = source.get("properties", {})
|
|
target_properties = target.get("properties", {})
|
|
if not isinstance(source_properties, Mapping) or not isinstance(
|
|
target_properties, Mapping
|
|
):
|
|
return "unsupported"
|
|
source_required = set(source.get("required", []))
|
|
target_required = set(target.get("required", []))
|
|
if not target_required.issubset(source_required):
|
|
return "incompatible"
|
|
for name, source_child in source_properties.items():
|
|
if name in target_properties:
|
|
if not isinstance(source_child, Mapping) or not isinstance(
|
|
target_properties[name], Mapping
|
|
):
|
|
return "unsupported"
|
|
status = _schema_assignability(
|
|
source_child,
|
|
target_properties[name],
|
|
source_label=f"{source_label}.{name}",
|
|
target_label=f"{target_label}.{name}",
|
|
)
|
|
if status != "compatible":
|
|
return status
|
|
elif target.get("additionalProperties") is False:
|
|
return "incompatible"
|
|
elif isinstance(target.get("additionalProperties"), Mapping):
|
|
status = _schema_assignability(
|
|
source_child,
|
|
target["additionalProperties"],
|
|
source_label=f"{source_label}.{name}",
|
|
target_label=f"{target_label}.additionalProperties",
|
|
)
|
|
if status != "compatible":
|
|
return status
|
|
source_additional = source.get("additionalProperties", True)
|
|
target_additional = target.get("additionalProperties", True)
|
|
if target_additional is False and source_additional is not False:
|
|
return "incompatible"
|
|
for name, target_child in target_properties.items():
|
|
if name in source_properties:
|
|
continue
|
|
if source_additional is False:
|
|
continue
|
|
if isinstance(source_additional, Mapping):
|
|
status = _schema_assignability(
|
|
source_additional,
|
|
target_child,
|
|
source_label=f"{source_label}.additionalProperties",
|
|
target_label=f"{target_label}.{name}",
|
|
)
|
|
if status != "compatible":
|
|
return status
|
|
else:
|
|
return "unsupported"
|
|
if isinstance(target_additional, Mapping):
|
|
if isinstance(source_additional, Mapping):
|
|
return _schema_assignability(
|
|
source_additional,
|
|
target_additional,
|
|
source_label=f"{source_label}.additionalProperties",
|
|
target_label=f"{target_label}.additionalProperties",
|
|
)
|
|
if source_additional is True:
|
|
return "unsupported"
|
|
return "compatible"
|
|
|
|
|
|
def _array_assignability(
|
|
source: Mapping[str, Any],
|
|
target: Mapping[str, Any],
|
|
source_label: str,
|
|
target_label: str,
|
|
) -> Compatibility:
|
|
source_min = source.get("minItems")
|
|
target_min = target.get("minItems")
|
|
if isinstance(target_min, int) and (
|
|
(not isinstance(source_min, int) and target_min > 0)
|
|
or isinstance(source_min, int)
|
|
and source_min < target_min
|
|
):
|
|
return "incompatible"
|
|
source_max = source.get("maxItems")
|
|
target_max = target.get("maxItems")
|
|
if isinstance(target_max, int) and (
|
|
not isinstance(source_max, int) or source_max > target_max
|
|
):
|
|
return "incompatible"
|
|
|
|
source_prefix = source.get("prefixItems")
|
|
target_prefix = target.get("prefixItems")
|
|
if source_prefix is not None or target_prefix is not None:
|
|
if not isinstance(source_prefix, list) or not isinstance(target_prefix, list):
|
|
return "unsupported"
|
|
if len(source_prefix) != len(target_prefix):
|
|
return "unsupported"
|
|
for index, (source_item, target_item) in enumerate(
|
|
zip(source_prefix, target_prefix, strict=True)
|
|
):
|
|
if not isinstance(source_item, Mapping) or not isinstance(
|
|
target_item, Mapping
|
|
):
|
|
return "unsupported"
|
|
status = _schema_assignability(
|
|
source_item,
|
|
target_item,
|
|
source_label=f"{source_label}.prefixItems[{index}]",
|
|
target_label=f"{target_label}.prefixItems[{index}]",
|
|
)
|
|
if status != "compatible":
|
|
return status
|
|
|
|
source_items = source.get("items", True)
|
|
target_items = target.get("items", True)
|
|
if source_items is False:
|
|
return "compatible"
|
|
if target_items is False:
|
|
return "incompatible"
|
|
if target_items is True:
|
|
return "compatible"
|
|
if source_items is True:
|
|
return "unsupported"
|
|
if isinstance(source_items, Mapping) and isinstance(target_items, Mapping):
|
|
return _schema_assignability(
|
|
source_items,
|
|
target_items,
|
|
source_label=f"{source_label}.items",
|
|
target_label=f"{target_label}.items",
|
|
)
|
|
return "unsupported"
|
|
|
|
|
|
def _types_assignable(source: set[str], target: set[str]) -> bool:
|
|
return all(
|
|
any(
|
|
source_type == target_type
|
|
or source_type == "integer"
|
|
and target_type == "number"
|
|
for target_type in target
|
|
)
|
|
for source_type in source
|
|
)
|
|
|
|
|
|
def _finite_schema_values(schema: Mapping[str, Any]) -> list[Any] | None:
|
|
"""Return the finite value set represented by a const or enum constraint."""
|
|
if "const" in schema:
|
|
value = schema["const"]
|
|
accepted = schema.get("enum")
|
|
if isinstance(accepted, list) and value not in accepted:
|
|
return []
|
|
return [value]
|
|
values = schema.get("enum")
|
|
if isinstance(values, list):
|
|
return values
|
|
return None
|
|
|
|
|
|
def _numeric_constraint_assignability(
|
|
source: Mapping[str, Any], target: Mapping[str, Any]
|
|
) -> Compatibility:
|
|
"""Compare the supported numeric interval constraints conservatively."""
|
|
source_lower = _numeric_bound(source, lower=True)
|
|
target_lower = _numeric_bound(target, lower=True)
|
|
if target_lower is not None and (
|
|
source_lower is None or not _lower_bound_contains(source_lower, target_lower)
|
|
):
|
|
return "incompatible"
|
|
source_upper = _numeric_bound(source, lower=False)
|
|
target_upper = _numeric_bound(target, lower=False)
|
|
if target_upper is not None and (
|
|
source_upper is None or not _upper_bound_contains(source_upper, target_upper)
|
|
):
|
|
return "incompatible"
|
|
return "compatible"
|
|
|
|
|
|
def _numeric_bound(
|
|
schema: Mapping[str, Any], *, lower: bool
|
|
) -> tuple[int | float, bool] | None:
|
|
names = (
|
|
("minimum", "exclusiveMinimum") if lower else ("maximum", "exclusiveMaximum")
|
|
)
|
|
candidates = [
|
|
(schema[name], name.startswith("exclusive"))
|
|
for name in names
|
|
if isinstance(schema.get(name), (int, float))
|
|
and not isinstance(schema.get(name), bool)
|
|
]
|
|
if not candidates:
|
|
return None
|
|
return (
|
|
max(candidates, key=lambda item: (item[0], item[1]))
|
|
if lower
|
|
else min(candidates, key=lambda item: (item[0], not item[1]))
|
|
)
|
|
|
|
|
|
def _lower_bound_contains(
|
|
source: tuple[int | float, bool], target: tuple[int | float, bool]
|
|
) -> bool:
|
|
if source[0] != target[0]:
|
|
return source[0] > target[0]
|
|
return source[1] or not target[1]
|
|
|
|
|
|
def _upper_bound_contains(
|
|
source: tuple[int | float, bool], target: tuple[int | float, bool]
|
|
) -> bool:
|
|
if source[0] != target[0]:
|
|
return source[0] < target[0]
|
|
return source[1] or not target[1]
|
|
|
|
|
|
def _schema_types(schema: Mapping[str, Any]) -> set[str] | None:
|
|
value = schema.get("type")
|
|
if isinstance(value, str):
|
|
return {value}
|
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
return set(value)
|
|
return None
|
|
|
|
|
|
def _ensure_supported_schema(schema: Mapping[str, Any], *, label: str) -> None:
|
|
_canonical_schema(schema, label=label)
|
|
|
|
|
|
def validate_schema_references(schema: Mapping[str, Any], *, label: str) -> None:
|
|
"""Resolve schema references without imposing expression-keyword limits."""
|
|
_validate_schema_references(
|
|
schema,
|
|
root_schema=schema,
|
|
label=label,
|
|
context=_SchemaNormalizationContext(),
|
|
)
|
|
|
|
|
|
def _validate_schema_references(
|
|
schema: Mapping[str, Any],
|
|
*,
|
|
root_schema: Mapping[str, Any],
|
|
label: str,
|
|
context: _SchemaNormalizationContext,
|
|
) -> None:
|
|
context.visit(label)
|
|
reference = schema.get("$ref")
|
|
if isinstance(reference, str):
|
|
if reference in context.active_refs:
|
|
raise ValueError(f"cyclic local schema reference {reference!r} at {label}")
|
|
context.active_refs.add(reference)
|
|
try:
|
|
resolved = _resolve_local_reference(root_schema, schema, label=label)
|
|
_validate_schema_references(
|
|
resolved,
|
|
root_schema=root_schema,
|
|
label=label,
|
|
context=context,
|
|
)
|
|
finally:
|
|
context.active_refs.remove(reference)
|
|
return
|
|
|
|
properties = schema.get("properties")
|
|
if isinstance(properties, Mapping):
|
|
for name, child in properties.items():
|
|
if isinstance(name, str) and isinstance(child, Mapping):
|
|
_validate_schema_references(
|
|
child,
|
|
root_schema=root_schema,
|
|
label=f"{label}.{name}",
|
|
context=context,
|
|
)
|
|
for key in ("items", "additionalProperties"):
|
|
child = schema.get(key)
|
|
if isinstance(child, Mapping):
|
|
_validate_schema_references(
|
|
child,
|
|
root_schema=root_schema,
|
|
label=f"{label}.{key}",
|
|
context=context,
|
|
)
|
|
prefix_items = schema.get("prefixItems")
|
|
if isinstance(prefix_items, list):
|
|
for index, child in enumerate(prefix_items):
|
|
if isinstance(child, Mapping):
|
|
_validate_schema_references(
|
|
child,
|
|
root_schema=root_schema,
|
|
label=f"{label}.prefixItems[{index}]",
|
|
context=context,
|
|
)
|
|
for key in ("allOf", "anyOf", "oneOf"):
|
|
branches = schema.get(key)
|
|
if isinstance(branches, list):
|
|
for index, child in enumerate(branches):
|
|
if isinstance(child, Mapping):
|
|
_validate_schema_references(
|
|
child,
|
|
root_schema=root_schema,
|
|
label=f"{label}.{key}[{index}]",
|
|
context=context,
|
|
)
|
|
for key in ("if", "then", "else"):
|
|
child = schema.get(key)
|
|
if isinstance(child, Mapping):
|
|
_validate_schema_references(
|
|
child,
|
|
root_schema=root_schema,
|
|
label=f"{label}.{key}",
|
|
context=context,
|
|
)
|
|
for key in ("$defs", "definitions"):
|
|
definitions = schema.get(key)
|
|
if isinstance(definitions, Mapping):
|
|
for name, child in definitions.items():
|
|
if isinstance(name, str) and isinstance(child, Mapping):
|
|
_validate_schema_references(
|
|
child,
|
|
root_schema=root_schema,
|
|
label=f"{label}.{key}.{name}",
|
|
context=context,
|
|
)
|
|
|
|
|
|
def _resolved_schema(
|
|
schema: Mapping[str, Any],
|
|
*,
|
|
label: str,
|
|
root_schema: Mapping[str, Any] | None = None,
|
|
) -> Mapping[str, Any]:
|
|
resolved = _resolve_local_reference(
|
|
root_schema if root_schema is not None else schema,
|
|
schema,
|
|
label=label,
|
|
)
|
|
if any(keyword in resolved for keyword in _COMPOSITION_KEYWORDS):
|
|
raise ValueError(f"unsupported schema composition at {label}")
|
|
return resolved
|
|
|
|
|
|
def _canonical_schema(
|
|
schema: Mapping[str, Any],
|
|
*,
|
|
label: str,
|
|
root_schema: Mapping[str, Any] | None = None,
|
|
context: _SchemaNormalizationContext | None = None,
|
|
) -> dict[str, Any]:
|
|
normalization = context or _SchemaNormalizationContext()
|
|
normalization.visit(label)
|
|
canonical_root = root_schema if root_schema is not None else schema
|
|
reference = schema.get("$ref")
|
|
if isinstance(reference, str):
|
|
if reference in normalization.active_refs:
|
|
raise ValueError(f"cyclic local schema reference {reference!r} at {label}")
|
|
normalization.active_refs.add(reference)
|
|
try:
|
|
return _canonical_schema(
|
|
_resolved_schema(schema, label=label, root_schema=canonical_root),
|
|
label=label,
|
|
root_schema=canonical_root,
|
|
context=normalization,
|
|
)
|
|
finally:
|
|
normalization.active_refs.remove(reference)
|
|
resolved = _resolved_schema(schema, label=label, root_schema=canonical_root)
|
|
if any(keyword in resolved for keyword in _COMPOSITION_KEYWORDS):
|
|
raise ValueError(f"unsupported schema composition at {label}")
|
|
unknown = set(resolved) - _STRUCTURAL_KEYWORDS - _ANNOTATION_KEYWORDS
|
|
if unknown:
|
|
raise ValueError(
|
|
f"unsupported schema keyword(s) at {label}: {sorted(unknown)!r}"
|
|
)
|
|
canonical: dict[str, Any] = {}
|
|
for key, value in resolved.items():
|
|
if key in _ANNOTATION_KEYWORDS or key in {"$defs", "definitions"}:
|
|
continue
|
|
if key == "properties" and isinstance(value, Mapping):
|
|
canonical[key] = {
|
|
name: _canonical_schema(
|
|
child,
|
|
label=f"{label}.{name}",
|
|
root_schema=canonical_root,
|
|
context=normalization,
|
|
)
|
|
for name, child in value.items()
|
|
if isinstance(name, str) and isinstance(child, Mapping)
|
|
}
|
|
elif key in {"items", "additionalProperties"} and isinstance(value, Mapping):
|
|
canonical[key] = _canonical_schema(
|
|
value,
|
|
label=f"{label}.{key}",
|
|
root_schema=canonical_root,
|
|
context=normalization,
|
|
)
|
|
elif key == "prefixItems" and isinstance(value, list):
|
|
canonical[key] = [
|
|
_canonical_schema(
|
|
child,
|
|
label=f"{label}.prefixItems[{index}]",
|
|
root_schema=canonical_root,
|
|
context=normalization,
|
|
)
|
|
for index, child in enumerate(value)
|
|
if isinstance(child, Mapping)
|
|
]
|
|
elif key == "required" and isinstance(value, list):
|
|
canonical[key] = sorted(value)
|
|
else:
|
|
canonical[key] = deepcopy(value)
|
|
return canonical
|
|
|
|
|
|
def _location_label(label: str, location: Sequence[SchemaLocationPart]) -> str:
|
|
result = label
|
|
for part in location:
|
|
if isinstance(part, int):
|
|
result += f"[{part}]"
|
|
else:
|
|
result += f".{part}"
|
|
return result
|