From 8b3d17a6aada344c39ab55e98a1fbc0b426ea64c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 4 Jun 2026 01:33:50 -0700 Subject: [PATCH] feat: catalog Record model and validation --- hef/catalog.py | 71 +++++++++++++++++++++++++++++++++++++++++++ tests/test_catalog.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/test_catalog.py diff --git a/hef/catalog.py b/hef/catalog.py index 92aba3f..4f7c142 100644 --- a/hef/catalog.py +++ b/hef/catalog.py @@ -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") diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..ff75108 --- /dev/null +++ b/tests/test_catalog.py @@ -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))