Files
lda-wf/src/wf_api/schema_projection.py
T

642 lines
22 KiB
Python

from __future__ import annotations
from collections.abc import Mapping, Sequence
from copy import deepcopy
from typing import Any
from jsonschema import Draft202012Validator, SchemaError, ValidationError
JsonObject = dict[str, Any]
SchemaLocationPart = str | int
_MAX_LOCAL_SCHEMA_REFERENCE_DEPTH = 32
def schema_path_exists(
schema: Mapping[str, Any],
parts: Sequence[str],
) -> bool:
"""Return whether an object-property path exists in a JSON Schema document."""
try:
_schema_at_path(schema, parts, label="schema")
except ValueError:
return False
return True
def schema_fragment_at_path(
schema: JsonObject,
parts: Sequence[str],
*,
label: str = "schema",
) -> JsonObject:
"""Return a self-contained selected object-property fragment.
This compatibility wrapper intentionally keeps the historical object-only
path semantics: undeclared properties are errors even when JSON Schema's
default ``additionalProperties`` behavior would allow them.
"""
_check_schema(label, schema)
fragment = deepcopy(dict(_schema_at_path(schema, parts, label=label)))
_merge_definition_block(
fragment,
schema,
"$defs",
target_label=f"{label} fragment",
source_label=label,
)
_merge_definition_block(
fragment,
schema,
"definitions",
target_label=f"{label} fragment",
source_label=label,
)
_check_schema(f"{label} fragment", fragment)
return fragment
def schema_fragment_at_location(
schema: JsonObject,
location: Sequence[SchemaLocationPart],
*,
label: str = "schema",
) -> JsonObject:
"""Return a self-contained schema fragment at an object/array location.
The navigator deliberately supports only the structural JSON Schema forms
needed by workflow input contracts: object properties and schema-valued
``additionalProperties``, homogeneous ``items``, tuple ``prefixItems``,
and bounded local ``$defs``/``definitions`` references. Composition and
advanced validation keywords remain visible to the caller so the
expression validator can fail closed instead of guessing their meaning.
"""
_check_schema(label, schema)
current: Mapping[str, Any] = schema
traversed: list[SchemaLocationPart] = []
for part in location:
current = _resolve_local_reference(
schema,
current,
label=_format_schema_location(traversed) or label,
)
if isinstance(part, int):
if part < 0:
raise ValueError(
f"{label} array position {part} is negative at "
f"{_format_schema_location(traversed) or label!r}"
)
child = _array_item_schema(
current,
part,
label=label,
location=(*traversed, part),
)
else:
child = _object_property_schema(
current,
part,
label=label,
location=(*traversed, part),
)
if not isinstance(child, Mapping):
raise ValueError(
f"{label} location {_format_schema_location((*traversed, part))!r} "
"does not select a JSON Schema object"
)
current = child
traversed.append(part)
_resolve_local_reference(
schema,
current,
label=_format_schema_location(traversed) or label,
)
fragment = deepcopy(dict(current))
_merge_definition_block(
fragment,
schema,
"$defs",
target_label=f"{label} fragment",
source_label=label,
)
_merge_definition_block(
fragment,
schema,
"definitions",
target_label=f"{label} fragment",
source_label=label,
)
_check_schema(f"{label} fragment", fragment)
return fragment
def schema_location_is_explicit(
schema: JsonObject,
location: Sequence[SchemaLocationPart],
*,
label: str = "schema",
) -> bool:
"""Return whether a location has a declared or schema-valued path.
JSON Schema treats ``additionalProperties: true`` as an open-ended,
unconstrained object. That is different from a schema-valued
``additionalProperties`` entry, which gives authoring a concrete fragment
to validate against. Callers use this distinction to preserve deferred
projection for genuinely unknown source paths.
"""
_check_schema(label, schema)
current: Mapping[str, Any] = schema
traversed: list[SchemaLocationPart] = []
for part in location:
current = _resolve_local_reference(
schema,
current,
label=_format_schema_location(traversed) or label,
)
if isinstance(part, int):
if current.get("items") is True and not (
isinstance(current.get("prefixItems"), list)
and part < len(current["prefixItems"])
):
return False
try:
child = _array_item_schema(
current,
part,
label=label,
location=(*traversed, part),
)
except ValueError:
# Predicates remain total for invalid or unconstrained paths.
return False
else:
properties = current.get("properties")
if isinstance(properties, Mapping) and part in properties:
child = properties[part]
else:
additional = current.get("additionalProperties", True)
if not isinstance(additional, Mapping):
return False
child = additional
if not isinstance(child, Mapping):
return False
current = child
traversed.append(part)
return True
def validate_json_value_at_schema_path(
*,
schema: JsonObject,
parts: Sequence[str],
value: object,
label: str,
schema_label: str = "capability input schema",
) -> None:
"""Validate one JSON-compatible literal against a selected schema path."""
fragment = schema_fragment_at_path(
schema,
parts,
label=schema_label,
)
path = ".".join(parts) or "."
try:
Draft202012Validator(fragment).validate(value)
except ValidationError as exc:
raise ValueError(
f"{label} does not satisfy schema at {path!r}: {exc.message}"
) from exc
def validate_json_value_at_schema_location(
*,
schema: JsonObject,
location: Sequence[SchemaLocationPart],
value: object,
label: str,
schema_label: str = "capability input schema",
) -> None:
"""Validate one JSON-compatible literal at an object/array schema location."""
fragment = schema_fragment_at_location(schema, location, label=schema_label)
path = _format_schema_location(location) or "."
try:
Draft202012Validator(fragment).validate(value)
except ValidationError as exc:
raise ValueError(
f"{label} does not satisfy schema at {path!r}: {exc.message}"
) from exc
def project_schema_path_to_schema_path(
*,
target_schema: JsonObject,
source_schema: JsonObject,
source_parts: tuple[str, ...],
target_parts: tuple[str, ...],
allow_existing_equivalent: bool = False,
allow_additional_properties: bool = False,
) -> JsonObject:
"""Copy one nested source subschema into a target object-property path."""
if not target_parts:
raise ValueError("target schema path must not be empty")
_check_schema("target_schema", target_schema)
_check_schema("source_schema", source_schema)
# An empty source path means the complete capability payload. This is
# distinct from an empty target path, which cannot be inserted into a parent.
source_value = (
source_schema
if not source_parts
else (
_schema_at_location(source_schema, source_parts, label="source schema")
if allow_additional_properties
else _schema_at_path(source_schema, source_parts, label="source schema")
)
)
projected = deepcopy(target_schema)
_ensure_object_schema(projected, "target_schema")
parent = projected
for index, part in enumerate(target_parts[:-1]):
properties = _properties_for_object(
parent, ".".join(target_parts[:index]) or "target_schema"
)
child = properties.get(part)
if child is None:
child = {"type": "object", "properties": {}}
properties[part] = child
if not isinstance(child, dict):
raise ValueError(
f"schema path {'.'.join(target_parts[: index + 1])!r} is not an object"
)
target_label = ".".join(target_parts[: index + 1])
# Mutate the referenced definition rather than adding sibling schema
# keywords beside $ref, which path lookup intentionally does not merge.
resolved_child = _resolve_local_reference(
projected,
child,
label=target_label,
)
if not isinstance(resolved_child, dict):
raise ValueError(f"schema path {target_label!r} is not mutable")
_ensure_object_schema(resolved_child, target_label)
parent = resolved_child
properties = _properties_for_object(
parent, ".".join(target_parts[:-1]) or "target_schema"
)
leaf = target_parts[-1]
if leaf in properties:
# Validate an existing target reference against the target document
# before source definitions are merged. Otherwise an unresolved target
# could be silently repaired by an equivalent source-side definition.
_resolve_local_reference(
projected,
properties[leaf],
label=".".join(target_parts),
)
if allow_existing_equivalent and properties[leaf] == source_value:
_merge_definition_block(projected, source_schema, "$defs")
_merge_definition_block(projected, source_schema, "definitions")
_check_schema("projected target_schema", projected)
return projected
raise ValueError(f"schema path {'.'.join(target_parts)!r} already exists")
properties[leaf] = deepcopy(source_value)
_merge_definition_block(projected, source_schema, "$defs")
_merge_definition_block(projected, source_schema, "definitions")
_check_schema("projected target_schema", projected)
return projected
def project_property_to_schema_path(
*,
target_schema: JsonObject,
source_schema: JsonObject,
source_field: str,
target_parts: tuple[str, ...],
allow_existing_equivalent: bool = False,
) -> JsonObject:
"""Copy one source property schema into a target JSON Schema object path.
``allow_existing_equivalent`` accepts exact schema equality only. It does
not attempt semantic JSON Schema compatibility analysis.
"""
try:
return project_schema_path_to_schema_path(
target_schema=target_schema,
source_schema=source_schema,
source_parts=(source_field,),
target_parts=target_parts,
allow_existing_equivalent=allow_existing_equivalent,
)
except ValueError as exc:
message = str(exc)
if message == f"source schema path {source_field!r} is not declared":
raise ValueError(f"source field {source_field!r} is not declared") from exc
if message == (
f"source schema path {source_field!r} is not a JSON Schema object"
):
raise ValueError(
f"source field {source_field!r} is not a JSON Schema object"
) from exc
raise
def project_output_property_to_state_schema(
*,
state_schema: JsonObject,
output_schema: JsonObject,
output_field: str,
state_field: str,
allow_existing_equivalent: bool = False,
) -> JsonObject:
"""Root state projection convenience wrapper.
Preserves the original error message wording for backward compatibility.
"""
try:
return project_property_to_schema_path(
target_schema=state_schema,
source_schema=output_schema,
source_field=output_field,
target_parts=(state_field,),
allow_existing_equivalent=allow_existing_equivalent,
)
except ValueError as exc:
msg = str(exc)
if msg.startswith("source field ") and "is not declared" in msg:
raise ValueError(f"output field {output_field!r} is not declared") from exc
if msg.startswith("source field ") and "not a JSON Schema" in msg:
raise ValueError(
f"output field {output_field!r} is not a JSON Schema object"
) from exc
if "schema path 'target_schema'" in msg and "is not an object" in msg:
raise ValueError("state_schema must be an object schema") from exc
if msg.startswith("schema path ") and "already exists" in msg:
raise ValueError(f"state field {state_field!r} already exists") from exc
if "target_schema is not valid JSON Schema" in msg:
raise ValueError(
f"state_schema is not valid JSON Schema: {msg.split(': ', 1)[1]}"
) from exc
if "source_schema is not valid JSON Schema" in msg:
raise ValueError(
f"output_schema is not valid JSON Schema: {msg.split(': ', 1)[1]}"
) from exc
raise
def _check_schema(name: str, schema: JsonObject) -> None:
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
raise ValueError(f"{name} is not valid JSON Schema: {exc.message}") from exc
def _schema_at_location(
root_schema: Mapping[str, Any],
location: Sequence[SchemaLocationPart],
*,
label: str,
) -> Mapping[str, Any]:
"""Select a raw fragment while allowing schema-valued additional properties."""
current: Mapping[str, Any] = root_schema
traversed: list[SchemaLocationPart] = []
for part in location:
current = _resolve_local_reference(
root_schema,
current,
label=_format_schema_location(traversed) or label,
)
if isinstance(part, int):
child = _array_item_schema(
current,
part,
label=label,
location=(*traversed, part),
)
else:
properties = current.get("properties")
if isinstance(properties, Mapping) and part in properties:
child = properties[part]
else:
additional = current.get("additionalProperties", True)
if not isinstance(additional, Mapping):
raise ValueError(
f"{label} location "
f"{_format_schema_location((*traversed, part))!r} "
"is not declared"
)
child = additional
if not isinstance(child, Mapping):
raise ValueError(
f"{label} location "
f"{_format_schema_location((*traversed, part))!r} "
"is not a JSON Schema object"
)
current = child
traversed.append(part)
return current
def _schema_at_path(
root_schema: Mapping[str, Any],
parts: Sequence[str],
*,
label: str,
) -> Mapping[str, Any]:
"""Select an object-property subschema, following bounded local references."""
current = root_schema
traversed: tuple[str, ...] = ()
for part in parts:
current = _resolve_local_reference(
root_schema,
current,
label=".".join(traversed) or label,
)
schema_type = current.get("type")
if schema_type is not None and schema_type != "object":
blocking_path = ".".join(traversed) or label
raise ValueError(f"{label} path {blocking_path!r} is not an object")
properties = current.get("properties")
full_path = ".".join((*traversed, part))
if not isinstance(properties, Mapping) or part not in properties:
raise ValueError(f"{label} path {full_path!r} is not declared")
child = properties[part]
if not isinstance(child, Mapping):
raise ValueError(f"{label} path {full_path!r} is not a JSON Schema object")
current = child
traversed = (*traversed, part)
if parts:
# Validate a selected leaf reference without replacing it. Projection must
# preserve the reference itself so the copied schema can share merged defs.
_resolve_local_reference(
root_schema,
current,
label=".".join(traversed),
)
return current
def _resolve_local_reference(
root_schema: Mapping[str, Any],
candidate: Mapping[str, Any],
*,
label: str,
) -> Mapping[str, Any]:
"""Resolve repository-generated local refs without becoming a full resolver."""
current = candidate
seen: set[str] = set()
while "$ref" in current:
reference = current["$ref"]
if not isinstance(reference, str):
raise ValueError(f"schema path {label!r} has a non-string reference")
if reference in seen:
raise ValueError(f"cyclic reference {reference!r} at schema path {label!r}")
if len(seen) >= _MAX_LOCAL_SCHEMA_REFERENCE_DEPTH:
raise ValueError(
f"local reference depth exceeds {_MAX_LOCAL_SCHEMA_REFERENCE_DEPTH} "
f"at schema path {label!r}"
)
seen.add(reference)
if reference.startswith("#/$defs/") or reference.startswith("#/definitions/"):
pointer = reference.removeprefix("#/")
else:
raise ValueError(
f"unsupported reference {reference!r} at schema path {label!r}"
)
resolved: Any = root_schema
for raw_part in pointer.split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~")
if not isinstance(resolved, Mapping) or part not in resolved:
raise ValueError(
f"unresolved reference {reference!r} at schema path {label!r}"
)
resolved = resolved[part]
if not isinstance(resolved, Mapping):
raise ValueError(
f"reference {reference!r} at schema path {label!r} "
"does not select a JSON Schema object"
)
current = resolved
return current
def _format_schema_location(location: Sequence[SchemaLocationPart]) -> str:
"""Format mixed object/array schema locations for stable diagnostics."""
result = ""
for part in location:
if isinstance(part, int):
result += f"[{part}]"
elif not result:
result = part
else:
result += f".{part}"
return result
def _object_property_schema(
schema: Mapping[str, Any],
part: str,
*,
label: str,
location: Sequence[SchemaLocationPart],
) -> object:
schema_type = schema.get("type")
if schema_type is not None and schema_type != "object":
raise ValueError(
f"{label} location {_format_schema_location(location[:-1])!r} "
"is not an object"
)
properties = schema.get("properties")
if isinstance(properties, Mapping) and part in properties:
return properties[part]
additional = schema.get("additionalProperties", True)
if additional is False:
raise ValueError(
f"{label} location {_format_schema_location(location)!r} is not declared"
)
if additional is True:
return {}
if isinstance(additional, Mapping):
return additional
raise ValueError(
f"{label} additionalProperties at "
f"{_format_schema_location(location[:-1]) or label!r} is invalid"
)
def _array_item_schema(
schema: Mapping[str, Any],
index: int,
*,
label: str,
location: Sequence[SchemaLocationPart],
) -> object:
if index < 0:
raise ValueError(
f"{label} array position {index} is negative at "
f"{_format_schema_location(location[:-1]) or label!r}"
)
schema_type = schema.get("type")
if schema_type is not None and schema_type != "array":
raise ValueError(
f"{label} location {_format_schema_location(location[:-1])!r} "
"is not an array"
)
prefix_items = schema.get("prefixItems")
if isinstance(prefix_items, list) and index < len(prefix_items):
return prefix_items[index]
items = schema.get("items")
if items is None:
raise ValueError(
f"{label} array position {index} is out of range at "
f"{_format_schema_location(location[:-1]) or label!r}"
)
if isinstance(items, bool):
if items:
return {}
raise ValueError(
f"{label} array position {index} is out of range at "
f"{_format_schema_location(location[:-1]) or label!r}"
)
return items
def _ensure_object_schema(schema: JsonObject, label: str) -> None:
schema_type = schema.get("type")
if schema_type is not None and schema_type != "object":
raise ValueError(f"schema path {label!r} is not an object")
schema.setdefault("type", "object")
def _properties_for_object(schema: JsonObject, label: str) -> JsonObject:
properties = schema.setdefault("properties", {})
if not isinstance(properties, dict):
raise ValueError(f"{label}.properties must be an object")
return properties
def _merge_definition_block(
target_schema: JsonObject,
source_schema: JsonObject,
key: str,
*,
target_label: str = "state_schema",
source_label: str = "output_schema",
) -> None:
source_defs = source_schema.get(key)
if source_defs is None:
return
if not isinstance(source_defs, dict):
raise ValueError(f"{source_label}.{key} must be an object")
target_defs = target_schema.setdefault(key, {})
if not isinstance(target_defs, dict):
raise ValueError(f"{target_label}.{key} must be an object")
for name, definition in source_defs.items():
if name in target_defs and target_defs[name] != definition:
raise ValueError(f"conflicting {key}.{name}")
target_defs[name] = deepcopy(definition)