Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions lacing/bodies/character_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,17 @@

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


CHARACTER_VOICE_BODY_SCHEMA_URI = "annot://schema/character-voice/v1"
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"}
Expand All @@ -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 "
Expand Down
5 changes: 3 additions & 2 deletions lacing/bodies/continuity_violation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"}
Expand Down
7 changes: 4 additions & 3 deletions lacing/bodies/named_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions lacing/bodies/reference_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lacing/bodies/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"}
Expand Down
5 changes: 3 additions & 2 deletions lacing/bodies/review_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
5 changes: 3 additions & 2 deletions lacing/bodies/word.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions lacing/json_schema.py
Original file line number Diff line number Diff line change
@@ -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,
)
13 changes: 7 additions & 6 deletions lacing/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"}
Expand All @@ -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"}
Expand All @@ -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"}
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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"}
Expand Down
11 changes: 9 additions & 2 deletions lacing/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ def v1_to_v2(body: dict) -> dict:

from pydantic import BaseModel, ValidationError

from lacing.json_schema import DefaultFactoryJsonSchema


# ---------------------------------------------------------------------------
# URI parsing
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions lacing/schema/continuity-violation/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions lacing/schema/reference-lock/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions lacing/schema/review/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading