Files
lda-wf/tests/wf_api/test_schema_projection.py
T

679 lines
21 KiB
Python

from __future__ import annotations
import pytest
from wf_api.schema_projection import (
project_output_property_to_state_schema,
project_property_to_schema_path,
project_schema_path_to_schema_path,
schema_fragment_at_location,
schema_fragment_at_path,
schema_location_is_explicit,
schema_path_exists,
validate_json_value_at_schema_path,
)
def test_schema_location_is_explicit_is_total_for_invalid_array_positions() -> None:
assert not schema_location_is_explicit(
{"type": "array"},
(0,),
label="source schema",
)
assert not schema_location_is_explicit(
{"type": "object", "properties": {}},
(0,),
label="source schema",
)
assert not schema_location_is_explicit(
{"type": "array", "prefixItems": [{"type": "string"}]},
(-1,),
label="source schema",
)
def test_project_output_property_copies_schema_and_defs() -> None:
state_schema = {
"type": "object",
"properties": {"before": {"type": "object"}},
}
output_schema = {
"type": "object",
"properties": {
"after": {"$ref": "#/$defs/Snapshot"},
},
"$defs": {
"Snapshot": {
"type": "object",
"properties": {"clicked": {"type": "boolean"}},
"required": ["clicked"],
}
},
}
projected = project_output_property_to_state_schema(
state_schema=state_schema,
output_schema=output_schema,
output_field="after",
state_field="after",
)
assert projected["properties"]["before"] == {"type": "object"}
assert projected["properties"]["after"] == {"$ref": "#/$defs/Snapshot"}
assert projected["$defs"]["Snapshot"]["properties"]["clicked"] == {
"type": "boolean"
}
assert "after" not in state_schema["properties"]
def test_project_output_property_rejects_missing_output_field() -> None:
with pytest.raises(ValueError, match="output field 'after'"):
project_output_property_to_state_schema(
state_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
output_field="after",
state_field="after",
)
def test_project_output_property_rejects_conflicting_defs() -> None:
with pytest.raises(ValueError, match=r"conflicting \$defs.Snapshot"):
project_output_property_to_state_schema(
state_schema={
"type": "object",
"properties": {},
"$defs": {"Snapshot": {"type": "string"}},
},
output_schema={
"type": "object",
"properties": {"after": {"$ref": "#/$defs/Snapshot"}},
"$defs": {"Snapshot": {"type": "object"}},
},
output_field="after",
state_field="after",
)
def test_project_output_property_rejects_non_object_state_schema() -> None:
with pytest.raises(ValueError, match="state_schema must be an object schema"):
project_output_property_to_state_schema(
state_schema={"type": "array", "items": {"type": "string"}},
output_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
output_field="after",
state_field="after",
)
def test_project_output_property_rejects_existing_state_field() -> None:
with pytest.raises(ValueError, match="state field 'after' already exists"):
project_output_property_to_state_schema(
state_schema={
"type": "object",
"properties": {"after": {"type": "string"}},
},
output_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
output_field="after",
state_field="after",
)
def test_project_output_property_allows_equivalent_existing_state_field() -> None:
state_schema = {
"type": "object",
"properties": {"after": {"type": "object"}},
}
projected = project_output_property_to_state_schema(
state_schema=state_schema,
output_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
output_field="after",
state_field="after",
allow_existing_equivalent=True,
)
assert projected == state_schema
def test_project_output_property_rejects_invalid_output_schema() -> None:
with pytest.raises(ValueError, match="output_schema is not valid JSON Schema"):
project_output_property_to_state_schema(
state_schema={"type": "object", "properties": {}},
output_schema={
"type": "object",
"properties": {"after": {"type": "definitely-not-jsonschema"}},
},
output_field="after",
state_field="after",
)
def test_project_schema_property_inserts_nested_path_and_defs() -> None:
projected = project_property_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"after": {"$ref": "#/$defs/Snapshot"}},
"$defs": {
"Snapshot": {
"type": "object",
"properties": {"clicked": {"type": "boolean"}},
}
},
},
source_field="after",
target_parts=("session", "after"),
)
assert projected["properties"]["session"]["type"] == "object"
assert projected["properties"]["session"]["properties"]["after"] == {
"$ref": "#/$defs/Snapshot"
}
assert projected["$defs"]["Snapshot"]["properties"]["clicked"] == {
"type": "boolean"
}
def test_project_schema_property_rejects_existing_nested_target() -> None:
with pytest.raises(ValueError, match="schema path 'session.after' already exists"):
project_property_to_schema_path(
target_schema={
"type": "object",
"properties": {
"session": {
"type": "object",
"properties": {"after": {"type": "string"}},
}
},
},
source_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
source_field="after",
target_parts=("session", "after"),
)
def test_project_schema_property_rejects_non_object_ancestor() -> None:
with pytest.raises(ValueError, match="schema path 'session' is not an object"):
project_property_to_schema_path(
target_schema={
"type": "object",
"properties": {"session": {"type": "string"}},
},
source_schema={
"type": "object",
"properties": {"after": {"type": "object"}},
},
source_field="after",
target_parts=("session", "after"),
)
def test_project_schema_path_copies_inline_nested_source() -> None:
projected = project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {
"report": {
"type": "object",
"properties": {"title": {"type": "string"}},
}
},
},
source_parts=("report", "title"),
target_parts=("document", "title"),
)
assert projected["properties"]["document"]["properties"]["title"] == {
"type": "string"
}
def test_schema_fragment_selects_inline_nested_field() -> None:
fragment = schema_fragment_at_path(
{
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {"format": {"type": "string"}},
}
},
},
("request", "format"),
)
assert fragment == {"type": "string"}
def test_schema_fragment_preserves_defs_for_selected_reference() -> None:
fragment = schema_fragment_at_path(
{
"type": "object",
"properties": {"request": {"$ref": "#/$defs/Request"}},
"$defs": {
"Request": {
"type": "object",
"properties": {"format": {"type": "string"}},
}
},
},
("request",),
label="capability input schema",
)
assert fragment["$ref"] == "#/$defs/Request"
assert fragment["$defs"]["Request"]["properties"]["format"] == {"type": "string"}
def test_schema_fragment_accepts_whole_schema() -> None:
schema = {"type": "object", "properties": {"title": {"type": "string"}}}
assert schema_fragment_at_path(schema, ()) == schema
def test_schema_fragment_navigates_object_properties_and_additional_properties() -> (
None
):
schema = {
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {"format": {"type": "string"}},
"additionalProperties": {"type": "integer"},
}
},
}
assert schema_fragment_at_location(schema, ("request", "format")) == {
"type": "string"
}
assert schema_fragment_at_location(schema, ("request", "retries")) == {
"type": "integer"
}
def test_schema_fragment_navigates_homogeneous_array_items() -> None:
schema = {
"type": "object",
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
}
assert schema_fragment_at_location(schema, ("items", 4)) == {"type": "string"}
def test_schema_fragment_navigates_tuple_prefix_items_and_rejects_out_of_range() -> (
None
):
schema = {
"type": "object",
"properties": {
"pair": {
"type": "array",
"prefixItems": [{"type": "string"}, {"type": "integer"}],
"items": False,
}
},
}
assert schema_fragment_at_location(schema, ("pair", 0)) == {"type": "string"}
assert schema_fragment_at_location(schema, ("pair", 1)) == {"type": "integer"}
with pytest.raises(ValueError, match="array position 2"):
schema_fragment_at_location(schema, ("pair", 2))
@pytest.mark.parametrize("reference_key", ["$defs", "definitions"])
def test_schema_fragment_navigates_local_definition_references(
reference_key: str,
) -> None:
definition_name = "Request"
schema = {
"type": "object",
"properties": {"request": {"$ref": f"#/{reference_key}/{definition_name}"}},
reference_key: {
definition_name: {
"type": "object",
"properties": {"title": {"type": "string"}},
}
},
}
assert schema_fragment_at_location(schema, ("request", "title"))["type"] == "string"
@pytest.mark.parametrize(
("reference", "message"),
[
("#/$defs/Loop", "cyclic reference"),
("#/$defs/Missing", "unresolved reference"),
("https://example.com/schema.json", "unsupported reference"),
],
)
def test_schema_fragment_rejects_unusable_local_or_remote_references(
reference: str,
message: str,
) -> None:
schema = {
"type": "object",
"properties": {"value": {"$ref": reference}},
"$defs": {"Loop": {"$ref": "#/$defs/Loop"}},
}
with pytest.raises(ValueError, match=message):
schema_fragment_at_location(schema, ("value",))
def test_schema_fragment_rejects_remote_selected_reference() -> None:
with pytest.raises(
ValueError,
match="unsupported reference 'https://example.com/request.json'",
):
schema_fragment_at_path(
{
"type": "object",
"properties": {"request": {"$ref": "https://example.com/request.json"}},
},
("request",),
)
@pytest.mark.parametrize(
("schema", "value"),
[
({"type": "string"}, "markdown"),
(
{
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
},
{"title": "Report"},
),
({"type": "array", "items": {"type": "integer"}}, [1, 2]),
({"type": ["string", "null"]}, None),
],
)
def test_validate_json_value_accepts_matching_literals(
schema: dict[str, object],
value: object,
) -> None:
validate_json_value_at_schema_path(
schema={
"type": "object",
"properties": {"value": schema},
},
parts=("value",),
value=value,
label="bindings[0].value",
)
def test_validate_json_value_at_nested_schema_path() -> None:
schema = {
"type": "object",
"properties": {"request": {"$ref": "#/$defs/Request"}},
"$defs": {
"Request": {
"type": "object",
"properties": {"format": {"enum": ["markdown", "json"]}},
}
},
}
validate_json_value_at_schema_path(
schema=schema,
parts=("request", "format"),
value="markdown",
label="bindings[0].value",
)
with pytest.raises(
ValueError,
match=r"bindings\[0\]\.value does not satisfy schema at 'request.format'",
):
validate_json_value_at_schema_path(
schema=schema,
parts=("request", "format"),
value="html",
label="bindings[0].value",
)
def test_validate_json_value_uses_caller_schema_label() -> None:
with pytest.raises(ValueError, match="workflow output schema"):
validate_json_value_at_schema_path(
schema={
"type": "object",
"properties": {"format": {"type": "string"}},
},
parts=("missing",),
value="markdown",
label="bindings[0].value",
schema_label="workflow output schema",
)
@pytest.mark.parametrize(
("schema", "value"),
[
({"type": "string"}, 7),
({"type": "object"}, []),
({"type": "array"}, {}),
({"type": "null"}, "not-null"),
],
)
def test_validate_json_value_rejects_non_matching_literals(
schema: dict[str, object],
value: object,
) -> None:
with pytest.raises(
ValueError,
match=r"bindings\[0\]\.value does not satisfy schema at 'value'",
):
validate_json_value_at_schema_path(
schema={
"type": "object",
"properties": {"value": schema},
},
parts=("value",),
value=value,
label="bindings[0].value",
)
def test_project_schema_path_accepts_whole_source_schema() -> None:
projected = project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
},
source_parts=(),
target_parts=("payload",),
)
assert projected["properties"]["payload"]["required"] == ["title"]
def test_project_schema_path_traverses_pydantic_defs_reference() -> None:
projected = project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"report": {"$ref": "#/$defs/Report"}},
"$defs": {
"Report": {
"type": "object",
"properties": {"markdown": {"$ref": "#/$defs/Markdown"}},
},
"Markdown": {"type": "string", "minLength": 1},
},
},
source_parts=("report", "markdown"),
target_parts=("report", "markdown"),
)
assert projected["properties"]["report"]["properties"]["markdown"] == {
"$ref": "#/$defs/Markdown"
}
assert projected["$defs"]["Markdown"]["minLength"] == 1
def test_project_schema_path_traverses_legacy_definitions_reference() -> None:
projected = project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"report": {"$ref": "#/definitions/Report"}},
"definitions": {
"Report": {
"type": "object",
"properties": {"title": {"type": "string"}},
}
},
},
source_parts=("report", "title"),
target_parts=("title",),
)
assert projected["properties"]["title"] == {"type": "string"}
assert "Report" in projected["definitions"]
def test_project_schema_path_traverses_target_defs_reference() -> None:
projected = project_schema_path_to_schema_path(
target_schema={
"type": "object",
"properties": {"report": {"$ref": "#/$defs/Report"}},
"$defs": {
"Report": {
"type": "object",
"properties": {},
}
},
},
source_schema={
"type": "object",
"properties": {"title": {"type": "string"}},
},
source_parts=("title",),
target_parts=("report", "title"),
)
assert projected["properties"]["report"] == {"$ref": "#/$defs/Report"}
assert projected["$defs"]["Report"]["properties"]["title"] == {"type": "string"}
def test_schema_path_exists_follows_local_defs() -> None:
schema = {
"type": "object",
"properties": {"report": {"$ref": "#/$defs/Report"}},
"$defs": {
"Report": {
"type": "object",
"properties": {"title": {"type": "string"}},
}
},
}
assert schema_path_exists(schema, ("report", "title")) is True
assert schema_path_exists(schema, ("report", "missing")) is False
def test_project_schema_path_rejects_unresolved_equivalent_target_reference() -> None:
with pytest.raises(
ValueError,
match=r"unresolved reference '#/\$defs/Report'",
):
project_schema_path_to_schema_path(
target_schema={
"type": "object",
"properties": {"report": {"$ref": "#/$defs/Report"}},
},
source_schema={
"type": "object",
"properties": {"report": {"$ref": "#/$defs/Report"}},
"$defs": {"Report": {"type": "object", "properties": {}}},
},
source_parts=("report",),
target_parts=("report",),
allow_existing_equivalent=True,
)
def test_project_schema_path_rejects_missing_nested_source() -> None:
with pytest.raises(
ValueError,
match="source schema path 'report.missing' is not declared",
):
project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"report": {"type": "object", "properties": {}}},
},
source_parts=("report", "missing"),
target_parts=("value",),
)
def test_project_schema_path_rejects_scalar_source_ancestor() -> None:
with pytest.raises(
ValueError,
match="source schema path 'report' is not an object",
):
project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"report": {"type": "string"}},
},
source_parts=("report", "title"),
target_parts=("title",),
)
def test_project_schema_path_rejects_remote_reference() -> None:
with pytest.raises(
ValueError,
match="unsupported reference 'https://example.com/report.json'",
):
project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"report": {"$ref": "https://example.com/report.json"}},
},
source_parts=("report", "title"),
target_parts=("title",),
)
def test_project_schema_path_rejects_remote_reference_at_selected_leaf() -> None:
with pytest.raises(
ValueError,
match="unsupported reference 'https://example.com/report.json'",
):
project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"report": {"$ref": "https://example.com/report.json"}},
},
source_parts=("report",),
target_parts=("report",),
)