From 407edafeca448abcd4e222b6ca096e655749d60d Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:58:07 +0000 Subject: [PATCH] Schema: export default_factory defaults in body JSON Schemas (#62) Co-Authored-By: Claude Sonnet 5.5 --- lacing/bodies/character_voice.py | 10 +- lacing/bodies/continuity_violation.py | 5 +- lacing/bodies/named_entity.py | 7 +- lacing/bodies/reference_lock.py | 5 +- lacing/bodies/review.py | 5 +- lacing/bodies/review_candidate.py | 5 +- lacing/bodies/word.py | 5 +- lacing/json_schema.py | 77 ++++++++++++++++ lacing/model.py | 13 +-- lacing/schema.py | 11 ++- lacing/schema/continuity-violation/v1.json | 1 + lacing/schema/reference-lock/v1.json | 1 + lacing/schema/review/v1.json | 1 + tests/test_json_schema.py | 102 +++++++++++++++++++++ 14 files changed, 220 insertions(+), 28 deletions(-) create mode 100644 lacing/json_schema.py create mode 100644 tests/test_json_schema.py diff --git a/lacing/bodies/character_voice.py b/lacing/bodies/character_voice.py index 8eceabc5..f111c616 100644 --- a/lacing/bodies/character_voice.py +++ b/lacing/bodies/character_voice.py @@ -39,8 +39,9 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field +from lacing.json_schema import SchemaModel from lacing.schema import register_body_schema @@ -48,7 +49,7 @@ CHARACTER_VOICE_TIER = "character-voice" -class CharacterVoiceBodyV1(BaseModel): +class CharacterVoiceBodyV1(SchemaModel): """Body of a character-voice annotation — one character's voice profile.""" model_config = {"frozen": True, "extra": "forbid"} @@ -71,11 +72,6 @@ class CharacterVoiceBodyV1(BaseModel): ) provider_voice_ids: dict[str, str] = Field( default_factory=dict, - # ``default_factory`` writes no ``default`` into the exported JSON Schema, - # so a schema-derived zod mirror would parse an absent map as ``undefined`` - # instead of ``{}``. Say it explicitly (lacing#40); keep it in step with the - # factory above. - json_schema_extra={"default": {}}, description=( "Per-provider voice ids, keyed by provider slug (``'elevenlabs'``, " "``'minimax'``, ...). A synthesis path looks its provider up here " diff --git a/lacing/bodies/continuity_violation.py b/lacing/bodies/continuity_violation.py index d85ce3cb..80e06caa 100644 --- a/lacing/bodies/continuity_violation.py +++ b/lacing/bodies/continuity_violation.py @@ -23,8 +23,9 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field +from pydantic import Field +from lacing.json_schema import SchemaModel from lacing.schema import register_body_schema @@ -44,7 +45,7 @@ Transforms pick one; the FE wires each to a button + acture command.""" -class ContinuityViolationBodyV1(BaseModel): +class ContinuityViolationBodyV1(SchemaModel): """Body of a continuity-violation annotation.""" model_config = {"frozen": True, "extra": "forbid"} diff --git a/lacing/bodies/named_entity.py b/lacing/bodies/named_entity.py index 18c26db7..1b7377c2 100644 --- a/lacing/bodies/named_entity.py +++ b/lacing/bodies/named_entity.py @@ -9,12 +9,13 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import Field +from lacing.json_schema import SchemaModel from lacing.schema import register_body_schema, register_migration -class NamedEntityBodyV1(BaseModel): +class NamedEntityBodyV1(SchemaModel): """Original NER body. ``type`` is the entity tag (PER, ORG, LOC, ...).""" model_config = {"frozen": True, "extra": "forbid"} @@ -23,7 +24,7 @@ class NamedEntityBodyV1(BaseModel): text: str = Field(..., description="Surface form of the entity mention.") -class NamedEntityBodyV2(BaseModel): +class NamedEntityBodyV2(SchemaModel): """v2 renames ``type`` -> ``entity_type`` and adds optional ``confidence``. The rename makes v2 incompatible with v1, so we register a migration. diff --git a/lacing/bodies/reference_lock.py b/lacing/bodies/reference_lock.py index 93cdb8c7..e2b94562 100644 --- a/lacing/bodies/reference_lock.py +++ b/lacing/bodies/reference_lock.py @@ -42,8 +42,9 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import Field, model_validator +from lacing.json_schema import SchemaModel from lacing.schema import register_body_schema @@ -62,7 +63,7 @@ ``scope_ref``.""" -class ReferenceLockBodyV1(BaseModel): +class ReferenceLockBodyV1(SchemaModel): """Body of a reference-lock annotation. Records that ``locked_artifact_ids`` are the canonical anchor for diff --git a/lacing/bodies/review.py b/lacing/bodies/review.py index 10b5c787..0f12b91b 100644 --- a/lacing/bodies/review.py +++ b/lacing/bodies/review.py @@ -40,8 +40,9 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field +from pydantic import Field +from lacing.json_schema import SchemaModel from lacing.schema import register_body_schema @@ -89,7 +90,7 @@ a live value is exactly the coupling that made lacing#18 possible.""" -class ReviewBodyV1(BaseModel): +class ReviewBodyV1(SchemaModel): """Body of a review annotation.""" model_config = {"frozen": True, "extra": "forbid"} diff --git a/lacing/bodies/review_candidate.py b/lacing/bodies/review_candidate.py index c60a4956..35dd9b77 100644 --- a/lacing/bodies/review_candidate.py +++ b/lacing/bodies/review_candidate.py @@ -28,15 +28,16 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import Field +from lacing.json_schema import SchemaModel from lacing.schema import register_body_schema REVIEW_CANDIDATE_BODY_SCHEMA_URI = "annot://schema/review-candidate/v1" -class ReviewCandidateBodyV1(BaseModel): +class ReviewCandidateBodyV1(SchemaModel): """Body of a review-candidate annotation.""" model_config = {"frozen": True, "extra": "forbid"} diff --git a/lacing/bodies/word.py b/lacing/bodies/word.py index a1292a3d..0760823c 100644 --- a/lacing/bodies/word.py +++ b/lacing/bodies/word.py @@ -5,12 +5,13 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import Field +from lacing.json_schema import SchemaModel from lacing.schema import register_body_schema -class WordBodyV1(BaseModel): +class WordBodyV1(SchemaModel): """A single word annotation. The ``text`` is the surface form as it appears in the source media. diff --git a/lacing/json_schema.py b/lacing/json_schema.py new file mode 100644 index 00000000..c9d02f9b --- /dev/null +++ b/lacing/json_schema.py @@ -0,0 +1,77 @@ +"""JSON Schema generation that exports ``default_factory`` defaults. + +Pydantic writes a ``default`` into a field's JSON Schema only for a literal +``default=``; a ``default_factory`` field exports no ``default`` at all. A +schema-derived zod mirror then parses an absent value as ``undefined`` where the +Python model says ``[]`` / ``{}`` (lacing#40, #62). + +:class:`DefaultFactoryJsonSchema` closes that gap for every field at once, so +no model has to remember a per-field ``json_schema_extra={"default": ...}``: +it calls each zero-argument factory and, when the result is JSON-serialisable, +writes it as the field's ``default``. Adding a schema ``default`` never +invalidates an existing document (the field stays optional), so this is +additive. + +Use it as ``Model.model_json_schema(schema_generator=DefaultFactoryJsonSchema)`` +(:func:`lacing.schema.json_schema` and ``export_json_schemas`` do), or inherit +:class:`SchemaModel` so a plain ``Model.model_json_schema()`` does the same. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode + + +class DefaultFactoryJsonSchema(GenerateJsonSchema): + """``GenerateJsonSchema`` that also emits ``default`` for zero-argument factories. + + A factory that takes the validated data, or whose result cannot be encoded + as JSON, is left without a ``default`` (there is no honest static value). + + >>> from pydantic import BaseModel, Field + >>> class M(BaseModel): + ... tags: tuple[str, ...] = Field(default_factory=tuple) + ... meta: dict = Field(default_factory=dict) + >>> props = M.model_json_schema(schema_generator=DefaultFactoryJsonSchema)["properties"] + >>> props["tags"]["default"], props["meta"]["default"] + ([], {}) + """ + + def default_schema(self, schema: Any) -> dict[str, Any]: + json_schema = super().default_schema(schema) + factory = schema.get("default_factory") + if ( + factory is None + or "default" in json_schema + or schema.get("default_factory_takes_data") + ): + return json_schema + try: + json_schema["default"] = self.encode_default(factory()) + except Exception: # noqa: BLE001 - not JSON-serialisable: export none + pass + return json_schema + + +class SchemaModel(BaseModel): + """``BaseModel`` whose ``model_json_schema()`` exports ``default_factory`` defaults.""" + + @classmethod + def model_json_schema( # type: ignore[override] + cls, + by_alias: bool = True, + ref_template: str = "#/$defs/{model}", + schema_generator: type[GenerateJsonSchema] = DefaultFactoryJsonSchema, + mode: JsonSchemaMode = "validation", + **kwargs: Any, + ) -> dict[str, Any]: + return super().model_json_schema( + by_alias=by_alias, + ref_template=ref_template, + schema_generator=schema_generator, + mode=mode, + **kwargs, + ) diff --git a/lacing/model.py b/lacing/model.py index e722dfcc..232a562e 100644 --- a/lacing/model.py +++ b/lacing/model.py @@ -10,9 +10,10 @@ from typing import Annotated, Literal from uuid import UUID -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator from lacing.digest import reject_non_string_keys +from lacing.json_schema import SchemaModel from lacing.time import RationalTime, TimeInterval @@ -22,7 +23,7 @@ # annotation (annotating another annotation — for review/discussion threads). -class MediaRef(BaseModel): +class MediaRef(SchemaModel): """Reference to a region of a content-addressed media asset.""" model_config = {"frozen": True, "extra": "forbid"} @@ -34,7 +35,7 @@ class MediaRef(BaseModel): interval: TimeInterval = Field(..., description="Region within the asset.") -class NodeRef(BaseModel): +class NodeRef(SchemaModel): """Reference to a node in a structured scene/document graph.""" model_config = {"frozen": True, "extra": "forbid"} @@ -48,7 +49,7 @@ class NodeRef(BaseModel): ) -class AnnotationRef(BaseModel): +class AnnotationRef(SchemaModel): """Reference to another annotation (for discussion threads, review, derivations).""" model_config = {"frozen": True, "extra": "forbid"} @@ -131,7 +132,7 @@ def partition_provenance_refs( """ -class Provenance(BaseModel): +class Provenance(SchemaModel): """W3C PROV-O subset, embedded inline on every annotation.""" model_config = {"frozen": True, "extra": "forbid"} @@ -188,7 +189,7 @@ def generated_at_is_known(self) -> bool: # --- Annotation envelope ----------------------------------------------------- -class Annotation(BaseModel): +class Annotation(SchemaModel): """The single annotation envelope. ``body`` is typed by ``body_schema_uri``.""" model_config = {"frozen": True, "extra": "forbid"} diff --git a/lacing/schema.py b/lacing/schema.py index af599e2f..29b4b24b 100644 --- a/lacing/schema.py +++ b/lacing/schema.py @@ -50,6 +50,8 @@ def v1_to_v2(body: dict) -> dict: from pydantic import BaseModel, ValidationError +from lacing.json_schema import DefaultFactoryJsonSchema + # --------------------------------------------------------------------------- # URI parsing @@ -157,9 +159,14 @@ def validate(body: dict, uri: str) -> BaseModel: def json_schema(uri: str) -> dict: """Return the JSON Schema for the body model registered at ``uri``. - Pydantic's ``model_json_schema()`` output, unmodified. + Pydantic's ``model_json_schema()`` output, plus a ``default`` for every + zero-argument ``default_factory`` field (see + :class:`lacing.json_schema.DefaultFactoryJsonSchema`), whatever base class + the registered model has. """ - return get_body_schema(uri).model_json_schema() + return get_body_schema(uri).model_json_schema( + schema_generator=DefaultFactoryJsonSchema + ) class EmptySchemaRegistryError(RuntimeError): diff --git a/lacing/schema/continuity-violation/v1.json b/lacing/schema/continuity-violation/v1.json index bbedd439..295f1a49 100644 --- a/lacing/schema/continuity-violation/v1.json +++ b/lacing/schema/continuity-violation/v1.json @@ -78,6 +78,7 @@ }, "evidence": { "additionalProperties": true, + "default": {}, "description": "Rule-specific evidence (e.g. eyeline vectors, bbox set diffs). Opaque to lacing; consumed by the inspector.", "title": "Evidence", "type": "object" diff --git a/lacing/schema/reference-lock/v1.json b/lacing/schema/reference-lock/v1.json index 3ae3e13f..ab6c592a 100644 --- a/lacing/schema/reference-lock/v1.json +++ b/lacing/schema/reference-lock/v1.json @@ -121,6 +121,7 @@ }, "params": { "additionalProperties": true, + "default": {}, "description": "Opaque per-aspect config (e.g. per-aspect thresholds). Ignored by lacing; consumed by the producing supervisor.", "title": "Params", "type": "object" diff --git a/lacing/schema/review/v1.json b/lacing/schema/review/v1.json index f1f552bf..fa1fb139 100644 --- a/lacing/schema/review/v1.json +++ b/lacing/schema/review/v1.json @@ -18,6 +18,7 @@ "type": "string" }, "target_annotation_ids": { + "default": [], "description": "Annotation ids this review targets. Typically one, but continuity reviews carry the same ids as the underlying ContinuityViolationBodyV1.", "items": { "type": "string" diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py new file mode 100644 index 00000000..eb7ceab9 --- /dev/null +++ b/tests/test_json_schema.py @@ -0,0 +1,102 @@ +"""``default_factory`` fields export their default in the JSON Schema (lacing#62). + +``default_factory`` alone writes no ``default``, so a schema-derived zod mirror +parses an absent value as ``undefined``. :class:`DefaultFactoryJsonSchema` +closes that for every field, and these tests guard every registered body so a +future ``default_factory`` field cannot slip through. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import BaseModel, Field + +import lacing.bodies # noqa: F401 (registers the body schemas) +from lacing.json_schema import DefaultFactoryJsonSchema, SchemaModel +from lacing.model import Annotation, Provenance +from lacing.schema import export_json_schemas, get_body_schema, registered_uris + +COMMITTED = Path(__file__).resolve().parents[1] / "lacing" / "schema" + + +def _zero_arg_factory_fields(model: type[BaseModel]): + for name, field in model.model_fields.items(): + factory = field.default_factory + if factory is not None and not field.default_factory_takes_validated_data: + yield name, field + + +@pytest.mark.parametrize("uri", registered_uris()) +def test_every_default_factory_field_of_a_body_exports_its_default(uri): + from lacing.schema import json_schema + + model = get_body_schema(uri) + props = json_schema(uri).get("properties", {}) + for name, field in _zero_arg_factory_fields(model): + key = field.alias or name + assert "default" in props[key], f"{uri}: {name} exports no default" + # The exported default is what the model actually defaults to. + instance = model.model_construct() + assert props[key]["default"] == json.loads(json.dumps(getattr(instance, name))) + assert key not in json_schema(uri).get("required", []) + + +def test_the_reported_fields_now_export_a_default(): + from lacing.schema import json_schema + + props = json_schema("annot://schema/review/v1")["properties"] + assert props["target_annotation_ids"]["default"] == [] + props = json_schema("annot://schema/continuity-violation/v1")["properties"] + assert props["evidence"]["default"] == {} + props = json_schema("annot://schema/reference-lock/v1")["properties"] + assert props["params"]["default"] == {} + + +def test_envelope_provenance_exports_its_default_too(): + assert Provenance.model_json_schema()["properties"]["was_derived_from"][ + "default" + ] == [] + defs = Annotation.model_json_schema()["$defs"]["Provenance"]["properties"] + assert defs["was_derived_from"]["default"] == [] + + +def test_committed_schemas_match_the_models(tmp_path): + """Regenerate with ``export_json_schemas('lacing/schema')`` after a model change.""" + export_json_schemas(tmp_path) + for fresh in tmp_path.rglob("*.json"): + committed = COMMITTED / fresh.relative_to(tmp_path) + assert json.loads(committed.read_text()) == json.loads(fresh.read_text()), ( + f"{committed.relative_to(COMMITTED.parent.parent)} is stale" + ) + + +def test_plain_base_models_get_the_default_through_the_generator(): + class Plain(BaseModel): + xs: list[int] = Field(default_factory=list) + + props = Plain.model_json_schema(schema_generator=DefaultFactoryJsonSchema)[ + "properties" + ] + assert props["xs"]["default"] == [] + assert "default" not in Plain.model_json_schema()["properties"]["xs"] + + +def test_schema_model_does_it_without_being_asked(): + class Mine(SchemaModel): + xs: list[int] = Field(default_factory=list) + + assert Mine.model_json_schema()["properties"]["xs"]["default"] == [] + + +def test_factories_with_no_honest_static_default_are_left_alone(): + class Odd(SchemaModel): + model_config = {"arbitrary_types_allowed": True} + stamp: object = Field(default_factory=object) # not JSON-encodable + derived: int = Field(default_factory=lambda data: len(data)) # needs data + + props = Odd.model_json_schema()["properties"] + assert "default" not in props["stamp"] + assert "default" not in props["derived"]