feat: catalog Record model and validation
This commit is contained in:
@@ -1 +1,72 @@
|
||||
"""Catalog data model, validation, and JSONL IO."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
MODES = frozenset({"audio", "video", "av"})
|
||||
LICENSES = frozenset({"public_domain", "cc0", "cc_by", "cc_by_nc"})
|
||||
REVIEW_STATUSES = frozenset({"proposed", "approved"})
|
||||
COORD_FIELDS = ("left", "right", "dark", "light")
|
||||
COORD_MIN = 0
|
||||
COORD_MAX = 4
|
||||
ATTRIBUTION_LICENSES = frozenset({"cc_by", "cc_by_nc"})
|
||||
|
||||
|
||||
class CatalogError(ValueError):
|
||||
"""Raised when a catalog record is structurally invalid."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record:
|
||||
id: str
|
||||
title: str
|
||||
source_url: str
|
||||
source_archive: str
|
||||
license: str
|
||||
mode: str
|
||||
left: int
|
||||
right: int
|
||||
dark: int
|
||||
light: int
|
||||
duration_s: int
|
||||
file_path: str
|
||||
review_status: str = "proposed"
|
||||
attribution: str = ""
|
||||
resolution: str = ""
|
||||
dominant_color: str = ""
|
||||
rationale: str = ""
|
||||
reviewed_at: Optional[str] = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
def validate(record: Record) -> None:
|
||||
"""Raise CatalogError if the record is structurally invalid."""
|
||||
if not record.id:
|
||||
raise CatalogError("record id must be non-empty")
|
||||
if record.mode not in MODES:
|
||||
raise CatalogError(
|
||||
f"invalid mode {record.mode!r}; expected one of {sorted(MODES)}"
|
||||
)
|
||||
if record.license not in LICENSES:
|
||||
raise CatalogError(
|
||||
f"invalid license {record.license!r}; expected one of {sorted(LICENSES)}"
|
||||
)
|
||||
if record.review_status not in REVIEW_STATUSES:
|
||||
raise CatalogError(
|
||||
f"invalid review_status {record.review_status!r}; "
|
||||
f"expected one of {sorted(REVIEW_STATUSES)}"
|
||||
)
|
||||
for axis in COORD_FIELDS:
|
||||
value = getattr(record, axis)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise CatalogError(f"coordinate {axis} must be an int, got {value!r}")
|
||||
if not (COORD_MIN <= value <= COORD_MAX):
|
||||
raise CatalogError(
|
||||
f"coordinate {axis}={value} out of range {COORD_MIN}..{COORD_MAX}"
|
||||
)
|
||||
if record.duration_s < 0:
|
||||
raise CatalogError("duration_s must be non-negative")
|
||||
if record.license in ATTRIBUTION_LICENSES and not record.attribution:
|
||||
raise CatalogError(f"license {record.license} requires attribution")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
|
||||
from hef.catalog import Record, validate, CatalogError
|
||||
|
||||
|
||||
def make_record(**overrides):
|
||||
base = dict(
|
||||
id="nasa-apollo-earthrise",
|
||||
title="Earthrise",
|
||||
source_url="https://archive.org/details/earthrise",
|
||||
source_archive="internet_archive",
|
||||
license="public_domain",
|
||||
mode="video",
|
||||
left=1,
|
||||
right=4,
|
||||
dark=1,
|
||||
light=3,
|
||||
duration_s=600,
|
||||
file_path="/media/earthrise.mp4",
|
||||
)
|
||||
base.update(overrides)
|
||||
return Record(**base)
|
||||
|
||||
|
||||
def test_valid_record_passes():
|
||||
validate(make_record()) # must not raise
|
||||
|
||||
|
||||
def test_defaults_are_set():
|
||||
record = make_record()
|
||||
assert record.review_status == "proposed"
|
||||
assert record.attribution == ""
|
||||
assert record.reviewed_at is None
|
||||
|
||||
|
||||
def test_invalid_mode_raises():
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(mode="hologram"))
|
||||
|
||||
|
||||
def test_invalid_license_raises():
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(license="all_rights_reserved"))
|
||||
|
||||
|
||||
def test_coordinate_out_of_range_raises():
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(left=5))
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(dark=-1))
|
||||
|
||||
|
||||
def test_boolean_coordinate_rejected():
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(right=True))
|
||||
|
||||
|
||||
def test_cc_by_requires_attribution():
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(license="cc_by", attribution=""))
|
||||
validate(make_record(license="cc_by", attribution="Jane Doe, CC BY 4.0"))
|
||||
|
||||
|
||||
def test_empty_id_raises():
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(id=""))
|
||||
|
||||
|
||||
def test_negative_duration_raises():
|
||||
with pytest.raises(CatalogError):
|
||||
validate(make_record(duration_s=-1))
|
||||
Reference in New Issue
Block a user