From 13077ccce5853e92754a60fe3027cb39dadf4a47 Mon Sep 17 00:00:00 2001 From: lykmapipo Date: Tue, 23 Jun 2026 01:28:31 +0300 Subject: [PATCH] feat(events): introduce EventResult base class and utility properties This: - Add `EventResult` class as base `Result` for Events API endpoints - Add `vessel_ids`, `start_dates` and `end_dates` properties to `EventResult` - Refactor `EventDetailResult` and `EventListResult` to inherit `EventResult` --- .../resources/events/base/models/response.py | 98 ++++++++++++++++++- .../events/detail/models/response.py | 5 +- .../resources/events/list/models/response.py | 5 +- tests/resources/events/base/__init__.py | 1 + .../resources/events/base/models/__init__.py | 1 + .../base/models/test_response_models.py | 68 +++++++++++++ .../detail/models/test_response_models.py | 33 +++++++ .../list/models/test_response_models.py | 33 +++++++ 8 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 tests/resources/events/base/__init__.py create mode 100644 tests/resources/events/base/models/__init__.py create mode 100644 tests/resources/events/base/models/test_response_models.py diff --git a/src/gfwapiclient/resources/events/base/models/response.py b/src/gfwapiclient/resources/events/base/models/response.py index 616d967..95bd03b 100644 --- a/src/gfwapiclient/resources/events/base/models/response.py +++ b/src/gfwapiclient/resources/events/base/models/response.py @@ -2,15 +2,15 @@ import datetime -from typing import Any, List, Optional +from typing import Any, Iterator, List, Optional, Type, TypeVar, Union from pydantic import Field, field_validator from gfwapiclient.base.models import BaseModel -from gfwapiclient.http.models import ResultItem +from gfwapiclient.http.models import Result, ResultItem -__all__ = ["EventItem"] +__all__ = ["EventItem", "EventResult"] class EventPosition(BaseModel): @@ -448,3 +448,95 @@ def empty_datetime_str_to_none(cls, value: Any) -> Optional[Any]: if isinstance(value, str) and value.strip() == "": return None return value + + +_EventItemT = TypeVar("_EventItemT", bound=EventItem) + + +class EventResult(Result[_EventItemT]): + """Result for the Events API endpoints. + + This class extends :class:`Result` to provide a specialized result container + for the Events API endpoints. + """ + + _result_item_class: Type[_EventItemT] + _data: Union[List[_EventItemT], _EventItemT] + + def __init__(self, *, data: Union[List[_EventItemT], _EventItemT]) -> None: + """Initializes a new `EventResult`. + + Args: + data (Union[List[_EventItemT], _EventItemT]): + The response data from the Events API endpoint, which can + be either a single `ResultItem` or a list of `ResultItem` instances. + """ + super().__init__(data=data) + + @property + def vessel_ids(self) -> List[str]: + """Returns AIS vessel identifiers (IDs). + + Returns: + List[str]: + Valid list of AIS vessel identifier (ID). + """ + + def extract_vessel_ids(item: _EventItemT) -> Iterator[Optional[str]]: + if item.vessel: + yield item.vessel.id + + mapped_vessel_ids: Iterator[Optional[str]] = self.flat_map( + mapper=extract_vessel_ids + ) + matched_vessel_ids: List[str] = list( + {_vessel_id.strip() for _vessel_id in mapped_vessel_ids if _vessel_id} + ) + + return matched_vessel_ids + + @property + def start_dates(self) -> List[datetime.date]: + """Returns events start dates. + + Returns: + List[str]: + Valid list of events start date. + """ + + def extract_start_date( + item: _EventItemT, + ) -> Iterator[Optional[datetime.datetime]]: + yield item.start + + mapped_start_dates: Iterator[Optional[datetime.datetime]] = self.flat_map( + mapper=extract_start_date + ) + matched_start_dates: List[datetime.date] = list( + {_start_date.date() for _start_date in mapped_start_dates if _start_date} + ) + + return matched_start_dates + + @property + def end_dates(self) -> List[datetime.date]: + """Returns events end dates. + + Returns: + List[str]: + Valid list of events end date. + """ + + def extract_end_date( + item: _EventItemT, + ) -> Iterator[Optional[datetime.datetime]]: + yield item.end + + mapped_end_date: Iterator[Optional[datetime.datetime]] = self.flat_map( + mapper=extract_end_date + ) + matched_end_date: List[datetime.date] = list( + {_end_date.date() for _end_date in mapped_end_date if _end_date} + ) + + return matched_end_date diff --git a/src/gfwapiclient/resources/events/detail/models/response.py b/src/gfwapiclient/resources/events/detail/models/response.py index a48ded4..4cda113 100644 --- a/src/gfwapiclient/resources/events/detail/models/response.py +++ b/src/gfwapiclient/resources/events/detail/models/response.py @@ -2,8 +2,7 @@ from typing import Type -from gfwapiclient.http.models import Result -from gfwapiclient.resources.events.base.models.response import EventItem +from gfwapiclient.resources.events.base.models.response import EventItem, EventResult __all__ = ["EventDetailItem", "EventDetailResult"] @@ -15,7 +14,7 @@ class EventDetailItem(EventItem): pass -class EventDetailResult(Result[EventDetailItem]): +class EventDetailResult(EventResult[EventDetailItem]): """Result containing the details of a single event.""" _result_item_class: Type[EventDetailItem] diff --git a/src/gfwapiclient/resources/events/list/models/response.py b/src/gfwapiclient/resources/events/list/models/response.py index fad9a94..a82ae73 100644 --- a/src/gfwapiclient/resources/events/list/models/response.py +++ b/src/gfwapiclient/resources/events/list/models/response.py @@ -2,8 +2,7 @@ from typing import List, Type -from gfwapiclient.http.models import Result -from gfwapiclient.resources.events.base.models.response import EventItem +from gfwapiclient.resources.events.base.models.response import EventItem, EventResult __all__ = ["EventListItem", "EventListResult"] @@ -15,7 +14,7 @@ class EventListItem(EventItem): pass -class EventListResult(Result[EventListItem]): +class EventListResult(EventResult[EventListItem]): """Result containing a list of event items.""" _result_item_class: Type[EventListItem] diff --git a/tests/resources/events/base/__init__.py b/tests/resources/events/base/__init__.py new file mode 100644 index 0000000..9c4e00a --- /dev/null +++ b/tests/resources/events/base/__init__.py @@ -0,0 +1 @@ +"""Tests for `gfwapiclient.resources.events.base`.""" diff --git a/tests/resources/events/base/models/__init__.py b/tests/resources/events/base/models/__init__.py new file mode 100644 index 0000000..d7c2afa --- /dev/null +++ b/tests/resources/events/base/models/__init__.py @@ -0,0 +1 @@ +"""Tests for `gfwapiclient.resources.events.base.models`.""" diff --git a/tests/resources/events/base/models/test_response_models.py b/tests/resources/events/base/models/test_response_models.py new file mode 100644 index 0000000..5dc5c07 --- /dev/null +++ b/tests/resources/events/base/models/test_response_models.py @@ -0,0 +1,68 @@ +"""Tests for `gfwapiclient.resources.events.base.models.response`.""" + +from typing import Any, Dict, List + +import pytest + +from gfwapiclient.resources.events.base.models.response import ( + EventItem, + EventResult, +) + + +def test_event_result_vessel_ids_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventResult` returns list of vessel ids correctly.""" + data: List[EventItem] = [EventItem(**mock_raw_event_list_item)] + result = EventResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) >= 1 + + +@pytest.mark.parametrize( + "update", + [ + {"vessel": None}, + {"vessel": {"id": None}}, + {"vessel": {"id": ""}}, + {"vessel": {"id": " "}}, + ], +) +def test_event_result_vessel_ids_returns_empty_list_when_vessel_or_id_missing( + mock_raw_event_list_item: Dict[str, Any], + update: Dict[str, Any], +) -> None: + """Test that `EventResult` vessel ids returns empty list when vessel or id missing.""" + mocked_raw_event_list_item: Dict[str, Any] = {**mock_raw_event_list_item} + for k, v in update.items(): + mocked_raw_event_list_item[k] = v + + data: List[EventItem] = [EventItem(**mocked_raw_event_list_item)] + result = EventResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) == 0 + + +def test_event_result_start_dates_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventResult` start dates returns list of start dates correctly.""" + data: List[EventItem] = [EventItem(**mock_raw_event_list_item)] + result = EventResult(data=data) + assert result.start_dates is not None + assert isinstance(result.start_dates, list) + assert len(result.start_dates) >= 1 + + +def test_event_result_end_dates_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventResult` end dates to returns list of end dates correctly.""" + data: List[EventItem] = [EventItem(**mock_raw_event_list_item)] + result = EventResult(data=data) + assert result.end_dates is not None + assert isinstance(result.end_dates, list) + assert len(result.end_dates) == 0 diff --git a/tests/resources/events/detail/models/test_response_models.py b/tests/resources/events/detail/models/test_response_models.py index d2ead37..53c65be 100644 --- a/tests/resources/events/detail/models/test_response_models.py +++ b/tests/resources/events/detail/models/test_response_models.py @@ -35,3 +35,36 @@ def test_event_detail_result_deserializes_all_fields( data: EventDetailItem = EventDetailItem(**mock_raw_event_detail_item) result = EventDetailResult(data=data) assert cast(EventDetailItem, result.data()) == data + + +def test_event_detail_result_vessel_ids_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventDetailResult` returns list of vessel ids correctly.""" + data: EventDetailItem = EventDetailItem(**mock_raw_event_list_item) + result = EventDetailResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) >= 1 + + +def test_event_detail_result_start_dates_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventDetailResult` start dates returns list of start dates correctly.""" + data: EventDetailItem = EventDetailItem(**mock_raw_event_list_item) + result = EventDetailResult(data=data) + assert result.start_dates is not None + assert isinstance(result.start_dates, list) + assert len(result.start_dates) >= 1 + + +def test_event_detail_result_end_dates_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventDetailResult` end dates to returns list of end dates correctly.""" + data: EventDetailItem = EventDetailItem(**mock_raw_event_list_item) + result = EventDetailResult(data=data) + assert result.end_dates is not None + assert isinstance(result.end_dates, list) + assert len(result.end_dates) == 0 diff --git a/tests/resources/events/list/models/test_response_models.py b/tests/resources/events/list/models/test_response_models.py index 1d13fcb..7561958 100644 --- a/tests/resources/events/list/models/test_response_models.py +++ b/tests/resources/events/list/models/test_response_models.py @@ -35,3 +35,36 @@ def test_event_list_result_deserializes_all_fields( data: List[EventListItem] = [EventListItem(**mock_raw_event_list_item)] result = EventListResult(data=data) assert cast(List[EventListItem], result.data()) == data + + +def test_event_list_result_vessel_ids_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventListResult` returns list of vessel ids correctly.""" + data: List[EventListItem] = [EventListItem(**mock_raw_event_list_item)] + result = EventListResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) >= 1 + + +def test_event_list_result_start_dates_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventListResult` start dates returns list of start dates correctly.""" + data: List[EventListItem] = [EventListItem(**mock_raw_event_list_item)] + result = EventListResult(data=data) + assert result.start_dates is not None + assert isinstance(result.start_dates, list) + assert len(result.start_dates) >= 1 + + +def test_event_list_result_end_dates_returns_correctly( + mock_raw_event_list_item: Dict[str, Any], +) -> None: + """Test that `EventListResult` end dates to returns list of end dates correctly.""" + data: List[EventListItem] = [EventListItem(**mock_raw_event_list_item)] + result = EventListResult(data=data) + assert result.end_dates is not None + assert isinstance(result.end_dates, list) + assert len(result.end_dates) == 0