diff --git a/config/mypy.ini b/config/mypy.ini index 576f1c3..3a6915a 100644 --- a/config/mypy.ini +++ b/config/mypy.ini @@ -16,4 +16,6 @@ warn_unused_configs = True # Import settings ignore_missing_imports = True -follow_imports = silent \ No newline at end of file +follow_imports = silent + +plugins = pydantic.mypy diff --git a/ifdo/_datetime/__init__.py b/ifdo/_datetime/__init__.py new file mode 100644 index 0000000..9229c8f --- /dev/null +++ b/ifdo/_datetime/__init__.py @@ -0,0 +1,4 @@ +from ._check_datetime import check_datatime_format +from ._serialize_datetime import add_datetime_format_info + +__all__ = ["add_datetime_format_info", "check_datatime_format"] diff --git a/ifdo/_datetime/_check_datetime.py b/ifdo/_datetime/_check_datetime.py new file mode 100644 index 0000000..9e4775f --- /dev/null +++ b/ifdo/_datetime/_check_datetime.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone +from typing import Any + +from ._format import DEFAULT_DATETIME_FORMAT + + +def check_datatime_format(ifdo_data: dict[str, Any]) -> None: + if "image-set-header" not in ifdo_data: + return + image_set_header = ifdo_data["image-set-header"] + + header_datetime_format = _check_image_item( + image_set_header, + DEFAULT_DATETIME_FORMAT, + ) + + if "image-set-items" not in ifdo_data: + return + + for item in ifdo_data["image-set-items"].values(): + if isinstance(item, dict): + _check_image_item(item, header_datetime_format) + else: + _check_video_item(item, header_datetime_format) + + +def _check_video_item(items: list[dict[str, Any]], header_format: str) -> None: + if len(items) == 0: + return + + prefix_format = _check_image_item(items[0], header_format) + for item in items[1:]: + _check_image_item(item, prefix_format) + + +def _check_image_item(item: dict[str, Any], header_format: str) -> str: + datetime_format: str = item.get("image-datetime-format", header_format) + + if "image-datetime" in item: + item["image-datetime"] = datetime.strptime( + item["image-datetime"], + datetime_format, + ).replace(tzinfo=timezone.utc) + + return datetime_format diff --git a/ifdo/_datetime/_format.py b/ifdo/_datetime/_format.py new file mode 100644 index 0000000..e102782 --- /dev/null +++ b/ifdo/_datetime/_format.py @@ -0,0 +1 @@ +DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f" diff --git a/ifdo/_datetime/_serialize_datetime.py b/ifdo/_datetime/_serialize_datetime.py new file mode 100644 index 0000000..691153d --- /dev/null +++ b/ifdo/_datetime/_serialize_datetime.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +from ifdo._datetime._format import DEFAULT_DATETIME_FORMAT + +if TYPE_CHECKING: + from ifdo import ImageData, iFDO + + +def add_datetime_format_info(ifdo: "iFDO") -> None: + header_format = ifdo.image_set_header.image_datetime_format or DEFAULT_DATETIME_FORMAT + + ifdo.image_set_header._image_datetime_format = header_format # type: ignore[attr-defined] # noqa: SLF001 + + for image_item in ifdo.image_set_items.values(): + if isinstance(image_item, list): + _serialize_video_datetimes(image_item, header_format) + else: + _serialize_image_datetimes(image_item, header_format) + + +def _serialize_video_datetimes(images: list["ImageData"], header_format: str) -> None: + if len(images) == 0: + return + + datetime_format = _serialize_image_datetimes(images[0], header_format) + + for image in images[1:]: + _serialize_image_datetimes(image, datetime_format) + + +def _serialize_image_datetimes(image: "ImageData", header_format: str) -> str: + datetime_format = image.image_datetime_format or header_format + + image._image_datetime_format = datetime_format # type: ignore[attr-defined] # noqa: SLF001 + return datetime_format diff --git a/ifdo/models/ifdo.py b/ifdo/models/ifdo.py index 2148ef5..ffc33c6 100644 --- a/ifdo/models/ifdo.py +++ b/ifdo/models/ifdo.py @@ -25,18 +25,27 @@ from __future__ import annotations import json +from copy import deepcopy from pathlib import Path -from typing import Any +from typing import Any, cast import yaml +from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator +from ifdo._datetime import check_datatime_format +from ifdo._datetime._serialize_datetime import add_datetime_format_info from ifdo.models._kebab_case_model import KebabCaseModel from ifdo.models.ifdo_capture import ImageCaptureFields from ifdo.models.ifdo_content import ImageContentFields from ifdo.models.ifdo_core import ImageCoreFields -class ImageData(KebabCaseModel, ImageCoreFields, ImageCaptureFields, ImageContentFields): +class ImageData( + KebabCaseModel, + ImageCoreFields, + ImageCaptureFields, + ImageContentFields, +): """ Represent image data with associated metadata and annotations. @@ -120,7 +129,12 @@ class ImageData(KebabCaseModel, ImageCoreFields, ImageCaptureFields, ImageConten image_handle: str | None = None -class ImageSetHeader(KebabCaseModel, ImageCoreFields, ImageCaptureFields, ImageContentFields): +class ImageSetHeader( + KebabCaseModel, + ImageCoreFields, + ImageCaptureFields, + ImageContentFields, +): """ Represent an image set header with detailed metadata and attributes. @@ -240,6 +254,20 @@ class iFDO(KebabCaseModel): # noqa: N801 image_set_header: ImageSetHeader image_set_items: dict[str, ImageData | list[ImageData]] + @model_serializer(mode="wrap") + def _serialize(self, nxt: SerializerFunctionWrapHandler) -> dict[str, Any]: + ifdo = deepcopy(self) + add_datetime_format_info(ifdo) + return cast("dict[str, Any]", nxt(ifdo)) + + @model_validator(mode="before") + @classmethod + def _validate_image_datatime(cls, data: Any) -> Any: # noqa: ANN401 + if not isinstance(data, dict): + return data + check_datatime_format(data) + return data + @classmethod def from_dict(cls, data: dict[str, Any]) -> iFDO: """Create an iFDO instance from a dictionary.""" @@ -271,7 +299,9 @@ def load(cls, path: str | Path) -> iFDO: elif suffix == "json": d = json.load(f) else: - raise ValueError("Unsupported file format. Use YAML (.yaml, .yml) or JSON (.json).") + raise ValueError( + "Unsupported file format. Use YAML (.yaml, .yml) or JSON (.json).", + ) return cls.from_dict(d) diff --git a/ifdo/models/ifdo_capture.py b/ifdo/models/ifdo_capture.py index d63cfc5..a17e4a3 100644 --- a/ifdo/models/ifdo_capture.py +++ b/ifdo/models/ifdo_capture.py @@ -402,7 +402,6 @@ class ImageCaptureFields: image_camera_pitch_degrees: float | None = None image_camera_roll_degrees: float | None = None image_overlap_fraction: float | None = None - image_datetime_format: str | None = None image_camera_pose: ImageCameraPose | None = None image_camera_housing_viewport: ImageCameraHousingViewport | None = None image_flatport_parameters: ImageFlatportParameters | None = None diff --git a/ifdo/models/ifdo_core.py b/ifdo/models/ifdo_core.py index 02e55b2..4c17c5f 100644 --- a/ifdo/models/ifdo_core.py +++ b/ifdo/models/ifdo_core.py @@ -8,9 +8,11 @@ ImageLicense: Represents an image license. """ -from datetime import datetime +from datetime import datetime, timezone -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer + +from ifdo._datetime._format import DEFAULT_DATETIME_FORMAT class ImagePI(BaseModel): @@ -85,10 +87,11 @@ def __hash__(self) -> int: return hash((self.name, self.uri)) -class ImageCoreFields: +class ImageCoreFields(BaseModel): """Core metadata fields for iFDO objects.""" image_datetime: datetime | None = None + image_datetime_format: str | None = None image_latitude: float | None = Field(None, ge=-90, le=90) image_longitude: float | None = Field(None, ge=-180, le=180) image_altitude_meters: float | None = None @@ -105,3 +108,21 @@ class ImageCoreFields: image_copyright: str | None = None image_abstract: str | None = None image_set_local_path: str | None = None + + @field_serializer("image_datetime", when_used="json") + def _serialize_image_datetime(self, image_datetime: datetime | None) -> str | None: + if image_datetime is None: + return None + + datetime_format = getattr( + self, + "_image_datetime_format", + DEFAULT_DATETIME_FORMAT, + ) + + if image_datetime.tzinfo is not None: + image_datetime = image_datetime.astimezone(timezone.utc) + + if datetime_format is not None and datetime_format != "": + return image_datetime.strftime(datetime_format) + return None diff --git a/tests/test_check_datetime.py b/tests/test_check_datetime.py new file mode 100644 index 0000000..547656c --- /dev/null +++ b/tests/test_check_datetime.py @@ -0,0 +1,71 @@ +import pytest +from ifdo._datetime import check_datatime_format + + +def test_check_datatime_default() -> None: + valid_test_data = { + "image-set-header": { + "image-datetime": "2015-01-01 02:04:03.1000", + } + } + + check_datatime_format(valid_test_data) + + invalid_test_data = { + "image-set-header": { + "image-datetime": "2015-01-01 02:04:03", + } + } + with pytest.raises(Exception): + check_datatime_format(invalid_test_data) + + +def test_check_datetime_images() -> None: + valid_test_data = { + "image-set-header": { + "image-datetime": "2015-01-01T20:21:32", + "image-datetime-format": "%Y-%m-%dT%H:%M:%S", + }, + "image-set-items": {"image": {"image-datetime": "2015-01-01T20:21:32"}}, + } + check_datatime_format(valid_test_data) + invalid_test_data = { + "image-set-header": { + "image-datetime": "2015-01-01T20:21:32", + "image-datetime-format": "%Y-%m-%dT%H:%M:%S", + }, + "image-set-items": {"image": {"image-datetime": "2015-01-01 20:21:32"}}, + } + with pytest.raises(Exception): + check_datatime_format(invalid_test_data) + + +def test_check_datetime_videos() -> None: + valid_test_data = { + "image-set-header": {}, + "image-set-items": { + "image": [ + { + "image-datetime-format": "%Y-%m-%dT%H:%M:%S", + "image-datetime": "2015-01-01T20:21:32", + }, + {"image-datetime": "2015-01-01T20:21:32"}, + ] + }, + } + check_datatime_format(valid_test_data) + invalid_test_data = { + "image-set-header": {}, + "image-set-items": { + "image": [ + { + "image-datetime-format": "%Y-%m-%dT%H:%M:%S", + "image-datetime": "2015-01-01T20:21:32", + }, + {"image-datetime": "2015-01-01 20:21:32"}, + ] + }, + } + + with pytest.raises(Exception): + check_datatime_format(invalid_test_data) diff --git a/tests/test_load.py b/tests/test_load.py index 7bbfe56..8a6381a 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -1,5 +1,5 @@ import datetime - +from datetime import timezone import json from ifdo import iFDO, ImageData @@ -12,10 +12,13 @@ def test_load_image_example(): ifdo = iFDO.from_dict(json_data) assert ifdo.image_set_header.image_set_name == "SO268 SO268-1_21-1_OFOS SO_CAM-1_Photo_OFOS" - assert isinstance(ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"], ImageData) + assert isinstance( + ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"], + ImageData, + ) assert ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"].image_datetime == datetime.datetime( 2019, 3, 4, 8, 37, 24 - ) + ).replace(tzinfo=timezone.utc) def test_load_video_example(): @@ -27,6 +30,6 @@ def test_load_video_example(): assert ifdo.image_set_header.image_set_name == "SO268 SO268-1_21-1_OFOS SO_CAM-1_Photo_OFOS" image_data = ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"] if isinstance(image_data, list): - assert image_data[1].image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25) + assert image_data[1].image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25).replace(tzinfo=timezone.utc) else: - assert image_data.image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25) + assert image_data.image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25).replace(tzinfo=timezone.utc) diff --git a/tests/test_save.py b/tests/test_save.py index fe5f7d7..2ef2f67 100644 --- a/tests/test_save.py +++ b/tests/test_save.py @@ -5,7 +5,14 @@ from jsonschema import Draft202012Validator from referencing import Registry, Resource from ifdo import iFDO -from ifdo.models import ImageLicense, ImageContext, ImagePI, ImageCreator, ImageData, ImageSetHeader +from ifdo.models import ( + ImageLicense, + ImageContext, + ImagePI, + ImageCreator, + ImageData, + ImageSetHeader, +) OUTPUT_PATH = "/tmp/test_ifdo.json" @@ -13,9 +20,12 @@ def test_save_image(): ifdo = create_ifdo() ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"] = create_ifdo_item() - validate_ifdo(ifdo) + result = ifdo.to_dict() + assert "_image_datetime_format" not in result["image-set-header"] + assert result["image-set-header"]["image-datetime"] == "2025-01-01 01:01:01.100000" + def test_save_video(): ifdo = create_ifdo() @@ -49,13 +59,16 @@ def create_ifdo() -> iFDO: ifdo.image_set_header.image_altitude_meters = 1.0 ifdo.image_set_header.image_coordinate_reference_system = "WSG84" ifdo.image_set_header.image_coordinate_uncertainty_meters = 0.1 - ifdo.image_set_header.image_datetime = datetime(2020, 1, 1) + ifdo.image_set_header.image_datetime = datetime(2025, 1, 1, 1, 1, 1, 100000) return ifdo def create_ifdo_item() -> ImageData: - image = ImageData() + image = ImageData( + image_latitude=0.0, + image_longitude=0.0, + ) image.image_handle = "test" image.image_hash_sha256 = "83f30eb35d1325c44c85fba0cf478825c0a629d20177a945069934f6cd07e087" image.image_uuid = "c6b8d981-05c7-449f-85a9-906ab866bfb6" diff --git a/tests/test_serialize_datetime.py b/tests/test_serialize_datetime.py new file mode 100644 index 0000000..a474074 --- /dev/null +++ b/tests/test_serialize_datetime.py @@ -0,0 +1,22 @@ +from datetime import datetime +from ifdo._datetime import add_datetime_format_info +from ifdo.models.ifdo import ImageSetHeader, iFDO + + +def test_serialize_datetimes() -> None: + header = ImageSetHeader( + image_set_name="", + image_set_uuid="", + image_set_handle="", + image_latitude=0, + image_longitude=0, + ) + header.image_datetime = datetime(2025, 1, 1, 1, 1, 1, int(2 * 1e5)) + + ifdo = iFDO(image_set_header=header, image_set_items={}) + + add_datetime_format_info(ifdo) + + result = ifdo.model_dump(mode="json", by_alias=True, exclude_none=True) + print(ifdo.image_set_header.__annotations__) + assert result["image-set-header"]["image-datetime"] == "2025-01-01 01:01:01.200000"