diff --git a/src/gfwapiclient/resources/vessels/base/models/response.py b/src/gfwapiclient/resources/vessels/base/models/response.py index 6d73cac..cae35cd 100644 --- a/src/gfwapiclient/resources/vessels/base/models/response.py +++ b/src/gfwapiclient/resources/vessels/base/models/response.py @@ -6,15 +6,15 @@ import datetime -from typing import Any, List, Optional +from typing import Any, Iterator, List, Optional, Type, TypeVar, Union from pydantic import Field from gfwapiclient.base.models import BaseModel -from gfwapiclient.http.models import ResultItem +from gfwapiclient.http.models import Result, ResultItem -__all__ = ["VesselItem"] +__all__ = ["VesselItem", "VesselResult"] class ExtraField(BaseModel): @@ -284,10 +284,10 @@ class SelfReportedInfo(BaseModel): Matched fields. transmission_date_from (Optional[datetime.datetime]): - Transmission date from. + Transmission date from (start). transmission_date_to (Optional[datetime.datetime]): - Transmission date to. + Transmission date to (end). """ id: Optional[str] = Field(None, alias="id") @@ -350,3 +350,147 @@ class VesselItem(ResultItem): self_reported_info: Optional[List[SelfReportedInfo]] = Field( None, alias="selfReportedInfo" ) + + def _iter_matched_self_reported_infos(self) -> Iterator[SelfReportedInfo]: + """Yields matched AIS self-reported vessel information. + + **Note:** A vessel is considered matched when it includes both `registry_info` + and `self_reported_info` (AIS), as this indicates a successful match between + registry data and AIS information. + + See how the Vessel API is used in the Vessel Viewer + here: https://globalfishingwatch.org/our-apis/assets/2024_Vessel_Viewer_and_APIs_behind_It.pdf + + Yields: + SelfReportedInfo: + Valid matched AIS self-reported vessel information. + """ + if ( + self.registry_info_total_records + and self.registry_info_total_records >= 1 + and self.self_reported_info + ): + yield from self.self_reported_info + + +_VesselItemT = TypeVar("_VesselItemT", bound=VesselItem) + + +class VesselResult(Result[_VesselItemT]): + """Result for the Vessels API endpoints. + + This class extends :class:`Result` to provide a specialized result container + for the Vessels API endpoints. + """ + + _result_item_class: Type[_VesselItemT] + _data: Union[List[_VesselItemT], _VesselItemT] + + def __init__(self, *, data: Union[List[_VesselItemT], _VesselItemT]) -> None: + """Initializes a new `VesselResult`. + + Args: + data (Union[List[_VesselItemT], _VesselItemT]): + The response data from the Vessels 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 matched AIS self-reported vessel identifiers (IDs). + + **Note:** A vessel is considered matched when it includes both `registry_info` + and `self_reported_info` (AIS), as this indicates a successful match between + registry data and AIS information. + + See how the Vessel API is used in the Vessel Viewer + here: https://globalfishingwatch.org/our-apis/assets/2024_Vessel_Viewer_and_APIs_behind_It.pdf + + Returns: + List[str]: + Valid list of matched AIS self-reported vessel identifier (ID). + """ + + def extract_vessel_ids(item: _VesselItemT) -> Iterator[Optional[str]]: + for self_reported_info in item._iter_matched_self_reported_infos(): + yield self_reported_info.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 transmission_dates_from(self) -> List[datetime.date]: + """Returns matched AIS transmission start dates. + + **Note:** A vessel is considered matched when it includes both `registry_info` + and `self_reported_info` (AIS), as this indicates a successful match between + registry data and AIS information. + + See how the Vessel API is used in the Vessel Viewer + here: https://globalfishingwatch.org/our-apis/assets/2024_Vessel_Viewer_and_APIs_behind_It.pdf + + Returns: + List[str]: + Valid list of matched AIS self-reported transmission date from. + """ + + def extract_transmission_date_from( + item: _VesselItemT, + ) -> Iterator[Optional[datetime.datetime]]: + for self_reported_info in item._iter_matched_self_reported_infos(): + yield self_reported_info.transmission_date_from + + mapped_transmission_dates_from: Iterator[Optional[datetime.datetime]] = ( + self.flat_map(mapper=extract_transmission_date_from) + ) + matched_transmission_dates_from: List[datetime.date] = list( + { + _transmission_date_from.date() + for _transmission_date_from in mapped_transmission_dates_from + if _transmission_date_from + } + ) + + return matched_transmission_dates_from + + @property + def transmission_dates_to(self) -> List[datetime.date]: + """Returns matched AIS transmission end dates. + + **Note:** A vessel is considered matched when it includes both `registry_info` + and `self_reported_info` (AIS), as this indicates a successful match between + registry data and AIS information. + + See how the Vessel API is used in the Vessel Viewer + here: https://globalfishingwatch.org/our-apis/assets/2024_Vessel_Viewer_and_APIs_behind_It.pdf + + Returns: + List[str]: + Valid list of matched AIS self-reported transmission date to. + """ + + def extract_transmission_date_to( + item: _VesselItemT, + ) -> Iterator[Optional[datetime.datetime]]: + for self_reported_info in item._iter_matched_self_reported_infos(): + yield self_reported_info.transmission_date_to + + mapped_transmission_dates_to: Iterator[Optional[datetime.datetime]] = ( + self.flat_map(mapper=extract_transmission_date_to) + ) + matched_transmission_dates_to: List[datetime.date] = list( + { + _transmission_date_to.date() + for _transmission_date_to in mapped_transmission_dates_to + if _transmission_date_to + } + ) + + return matched_transmission_dates_to diff --git a/src/gfwapiclient/resources/vessels/detail/models/response.py b/src/gfwapiclient/resources/vessels/detail/models/response.py index fb10581..e499549 100644 --- a/src/gfwapiclient/resources/vessels/detail/models/response.py +++ b/src/gfwapiclient/resources/vessels/detail/models/response.py @@ -5,8 +5,7 @@ from typing import Type -from gfwapiclient.http.models import Result -from gfwapiclient.resources.vessels.base.models.response import VesselItem +from gfwapiclient.resources.vessels.base.models.response import VesselItem, VesselResult __all__ = ["VesselDetailItem", "VesselDetailResult"] @@ -22,10 +21,10 @@ class VesselDetailItem(VesselItem): pass -class VesselDetailResult(Result[VesselDetailItem]): +class VesselDetailResult(VesselResult[VesselDetailItem]): """Result for the get vessel by ID API endpoint. - This class extends :class:`Result` to provide a specialized result container + This class extends :class:`VesselResult` to provide a specialized result container for the vessel detail endpoint. """ diff --git a/src/gfwapiclient/resources/vessels/list/models/response.py b/src/gfwapiclient/resources/vessels/list/models/response.py index 054968e..92575b2 100644 --- a/src/gfwapiclient/resources/vessels/list/models/response.py +++ b/src/gfwapiclient/resources/vessels/list/models/response.py @@ -5,8 +5,7 @@ from typing import List, Type -from gfwapiclient.http.models import Result -from gfwapiclient.resources.vessels.base.models.response import VesselItem +from gfwapiclient.resources.vessels.base.models.response import VesselItem, VesselResult __all__ = ["VesselListItem", "VesselListResult"] @@ -22,10 +21,10 @@ class VesselListItem(VesselItem): pass -class VesselListResult(Result[VesselListItem]): +class VesselListResult(VesselResult[VesselListItem]): """Result for the get vessels by IDs API endpoint. - This class extends :class:`Result` to provide a specialized result container + This class extends :class:`VesselResult` to provide a specialized result container for the vessel list endpoint. """ diff --git a/src/gfwapiclient/resources/vessels/search/models/response.py b/src/gfwapiclient/resources/vessels/search/models/response.py index fb8eb3e..4702353 100644 --- a/src/gfwapiclient/resources/vessels/search/models/response.py +++ b/src/gfwapiclient/resources/vessels/search/models/response.py @@ -5,8 +5,7 @@ from typing import List, Type -from gfwapiclient.http.models import Result -from gfwapiclient.resources.vessels.base.models.response import VesselItem +from gfwapiclient.resources.vessels.base.models.response import VesselItem, VesselResult __all__ = ["VesselSearchItem", "VesselSearchResult"] @@ -22,10 +21,10 @@ class VesselSearchItem(VesselItem): pass -class VesselSearchResult(Result[VesselSearchItem]): +class VesselSearchResult(VesselResult[VesselSearchItem]): """Result for the vessels search API endpoint. - This class extends :class:`Result` to provide a specialized result container + This class extends :class:`VesselResult` to provide a specialized result container for the vessel search endpoint. """ diff --git a/tests/resources/vessels/base/__init__.py b/tests/resources/vessels/base/__init__.py new file mode 100644 index 0000000..f268c76 --- /dev/null +++ b/tests/resources/vessels/base/__init__.py @@ -0,0 +1 @@ +"""Tests for `gfwapiclient.resources.vessels.base`.""" diff --git a/tests/resources/vessels/base/models/__init__.py b/tests/resources/vessels/base/models/__init__.py new file mode 100644 index 0000000..66ae65c --- /dev/null +++ b/tests/resources/vessels/base/models/__init__.py @@ -0,0 +1 @@ +"""Tests for `gfwapiclient.resources.vessels.base.models`.""" diff --git a/tests/resources/vessels/base/models/test_response_models.py b/tests/resources/vessels/base/models/test_response_models.py new file mode 100644 index 0000000..971e03e --- /dev/null +++ b/tests/resources/vessels/base/models/test_response_models.py @@ -0,0 +1,165 @@ +"""Tests for `gfwapiclient.resources.vessels.base.models.response`.""" + +from typing import Any, Dict, Iterator, List + +import pytest + +from gfwapiclient.resources.vessels.base.models.response import ( + SelfReportedInfo, + VesselItem, + VesselResult, +) + + +def test_vessel_item_iter_matched_self_reported_infos_yields_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselItem` iter matched self reported infos yields correctly.""" + vessel_item: VesselItem = VesselItem(**mock_raw_vessel_list_item) + matched_self_reported_info: Iterator[SelfReportedInfo] = ( + vessel_item._iter_matched_self_reported_infos() + ) + assert matched_self_reported_info is not None + assert isinstance(matched_self_reported_info, Iterator) + assert len(list(matched_self_reported_info)) >= 1 + + +@pytest.mark.parametrize( + "update", + [ + {"registryInfoTotalRecords": None}, + {"registryInfoTotalRecords": 0}, + {"selfReportedInfo": None}, + {"selfReportedInfo": []}, + ], +) +def test_vessel_item_iter_matched_self_reported_infos_does_not_yields_when_registry_or_self_report_info_missing( + mock_raw_vessel_list_item: Dict[str, Any], + update: Dict[str, Any], +) -> None: + """Test that `VesselItem` iter matched self reported infos does not yields when registry or self reported info missing.""" + mocked_raw_vessel_list_item: Dict[str, Any] = {**mock_raw_vessel_list_item} + for k, v in update.items(): + mocked_raw_vessel_list_item[k] = v + + vessel_item: VesselItem = VesselItem(**mocked_raw_vessel_list_item) + matched_self_reported_info: Iterator[SelfReportedInfo] = ( + vessel_item._iter_matched_self_reported_infos() + ) + + assert matched_self_reported_info is not None + assert isinstance(matched_self_reported_info, Iterator) + assert len(list(matched_self_reported_info)) == 0 + + +def test_vessel_result_vessel_ids_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselResult` returns list of vessel ids correctly.""" + data: List[VesselItem] = [VesselItem(**mock_raw_vessel_list_item)] + result = VesselResult(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", + [ + {"registryInfoTotalRecords": None}, + {"registryInfoTotalRecords": 0}, + {"selfReportedInfo": None}, + {"selfReportedInfo": []}, + {"selfReportedInfo": [{"id": None}]}, + {"selfReportedInfo": [{"id": ""}]}, + {"selfReportedInfo": [{"id": " "}]}, + ], +) +def test_vessel_result_vessel_ids_returns_empty_list_when_registry_or_self_report_info_missing( + mock_raw_vessel_list_item: Dict[str, Any], + update: Dict[str, Any], +) -> None: + """Test that `VesselResult` vessel ids returns empty list when registry or self reported info missing.""" + mocked_raw_vessel_list_item: Dict[str, Any] = {**mock_raw_vessel_list_item} + for k, v in update.items(): + mocked_raw_vessel_list_item[k] = v + + data: List[VesselItem] = [VesselItem(**mocked_raw_vessel_list_item)] + result = VesselResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) == 0 + + +def test_vessel_result_transmission_dates_from_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselResult` transmission dates from returns list of transmission start dates correctly.""" + data: List[VesselItem] = [VesselItem(**mock_raw_vessel_list_item)] + result = VesselResult(data=data) + assert result.transmission_dates_from is not None + assert isinstance(result.transmission_dates_from, list) + assert len(result.transmission_dates_from) >= 1 + + +@pytest.mark.parametrize( + "update", + [ + {"registryInfoTotalRecords": None}, + {"registryInfoTotalRecords": 0}, + {"selfReportedInfo": None}, + {"selfReportedInfo": []}, + {"selfReportedInfo": [{"transmissionDateFrom": None}]}, + ], +) +def test_vessel_result_vessel_transmission_dates_from_returns_empty_list_when_registry_or_self_report_info_missing( + mock_raw_vessel_list_item: Dict[str, Any], + update: Dict[str, Any], +) -> None: + """Test that `VesselResult` transmission dates from returns empty list when registry or self reported info missing.""" + mocked_raw_vessel_list_item: Dict[str, Any] = {**mock_raw_vessel_list_item} + for k, v in update.items(): + mocked_raw_vessel_list_item[k] = v + + data: List[VesselItem] = [VesselItem(**mocked_raw_vessel_list_item)] + result = VesselResult(data=data) + assert result.transmission_dates_from is not None + assert isinstance(result.transmission_dates_from, list) + assert len(result.transmission_dates_from) == 0 + + +def test_vessel_result_transmission_dates_to_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselResult` transmission dates to returns list of transmission end dates correctly.""" + data: List[VesselItem] = [VesselItem(**mock_raw_vessel_list_item)] + result = VesselResult(data=data) + assert result.transmission_dates_to is not None + assert isinstance(result.transmission_dates_to, list) + assert len(result.transmission_dates_to) >= 1 + + +@pytest.mark.parametrize( + "update", + [ + {"registryInfoTotalRecords": None}, + {"registryInfoTotalRecords": 0}, + {"selfReportedInfo": None}, + {"selfReportedInfo": []}, + {"selfReportedInfo": [{"transmissionDateTo": None}]}, + ], +) +def test_vessel_result_vessel_transmission_dates_to_returns_empty_list_when_registry_or_self_report_info_missing( + mock_raw_vessel_list_item: Dict[str, Any], + update: Dict[str, Any], +) -> None: + """Test that `VesselResult` transmission dates to returns empty list when registry or self reported info missing.""" + mocked_raw_vessel_list_item: Dict[str, Any] = {**mock_raw_vessel_list_item} + for k, v in update.items(): + mocked_raw_vessel_list_item[k] = v + + data: List[VesselItem] = [VesselItem(**mocked_raw_vessel_list_item)] + result = VesselResult(data=data) + assert result.transmission_dates_to is not None + assert isinstance(result.transmission_dates_to, list) + assert len(result.transmission_dates_to) == 0 diff --git a/tests/resources/vessels/detail/models/test_response_models.py b/tests/resources/vessels/detail/models/test_response_models.py index a8c8d53..4aed564 100644 --- a/tests/resources/vessels/detail/models/test_response_models.py +++ b/tests/resources/vessels/detail/models/test_response_models.py @@ -31,3 +31,36 @@ def test_vessel_detail_result_deserializes_all_fields( data: VesselDetailItem = VesselDetailItem(**mock_raw_vessel_detail_item) result = VesselDetailResult(data=data) assert cast(VesselDetailItem, result.data()) == data + + +def test_vessel_detail_result_vessel_ids_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselDetailResult` returns list of vessel ids correctly.""" + data: VesselDetailItem = VesselDetailItem(**mock_raw_vessel_list_item) + result = VesselDetailResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) >= 1 + + +def test_vessel_detail_result_transmission_dates_from_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselDetailResult` transmission dates from returns list of transmission start dates correctly.""" + data: VesselDetailItem = VesselDetailItem(**mock_raw_vessel_list_item) + result = VesselDetailResult(data=data) + assert result.transmission_dates_from is not None + assert isinstance(result.transmission_dates_from, list) + assert len(result.transmission_dates_from) >= 1 + + +def test_vessel_detail_result_transmission_dates_to_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselDetailResult` transmission dates to returns list of transmission end dates correctly.""" + data: VesselDetailItem = VesselDetailItem(**mock_raw_vessel_list_item) + result = VesselDetailResult(data=data) + assert result.transmission_dates_to is not None + assert isinstance(result.transmission_dates_to, list) + assert len(result.transmission_dates_to) >= 1 diff --git a/tests/resources/vessels/list/models/test_response_models.py b/tests/resources/vessels/list/models/test_response_models.py index 4b83366..7a05e86 100644 --- a/tests/resources/vessels/list/models/test_response_models.py +++ b/tests/resources/vessels/list/models/test_response_models.py @@ -29,3 +29,36 @@ def test_vessel_list_result_deserializes_all_fields( data: List[VesselListItem] = [VesselListItem(**mock_raw_vessel_list_item)] result = VesselListResult(data=data) assert cast(List[VesselListItem], result.data()) == data + + +def test_vessel_list_result_vessel_ids_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselListResult` returns list of vessel ids correctly.""" + data: List[VesselListItem] = [VesselListItem(**mock_raw_vessel_list_item)] + result = VesselListResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) >= 1 + + +def test_vessel_list_result_transmission_dates_from_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselListResult` transmission dates from returns list of transmission start dates correctly.""" + data: List[VesselListItem] = [VesselListItem(**mock_raw_vessel_list_item)] + result = VesselListResult(data=data) + assert result.transmission_dates_from is not None + assert isinstance(result.transmission_dates_from, list) + assert len(result.transmission_dates_from) >= 1 + + +def test_vessel_list_result_transmission_dates_to_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselListResult` transmission dates to returns list of transmission end dates correctly.""" + data: List[VesselListItem] = [VesselListItem(**mock_raw_vessel_list_item)] + result = VesselListResult(data=data) + assert result.transmission_dates_to is not None + assert isinstance(result.transmission_dates_to, list) + assert len(result.transmission_dates_to) >= 1 diff --git a/tests/resources/vessels/search/models/test_response_models.py b/tests/resources/vessels/search/models/test_response_models.py index 4edecb8..aac21e5 100644 --- a/tests/resources/vessels/search/models/test_response_models.py +++ b/tests/resources/vessels/search/models/test_response_models.py @@ -25,7 +25,40 @@ def test_vessel_search_item_deserializes_all_fields( def test_vessel_search_result_deserializes_all_fields( mock_raw_vessel_list_item: Dict[str, Any], ) -> None: - """Test that `VesselListResult` deserializes all fields correctly.""" + """Test that `VesselSearchResult` deserializes all fields correctly.""" data: List[VesselSearchItem] = [VesselSearchItem(**mock_raw_vessel_list_item)] result = VesselSearchResult(data=data) assert cast(List[VesselSearchItem], result.data()) == data + + +def test_vessel_search_result_vessel_ids_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselSearchResult` returns list of vessel ids correctly.""" + data: List[VesselSearchItem] = [VesselSearchItem(**mock_raw_vessel_list_item)] + result = VesselSearchResult(data=data) + assert result.vessel_ids is not None + assert isinstance(result.vessel_ids, list) + assert len(result.vessel_ids) >= 1 + + +def test_vessel_search_result_transmission_dates_from_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselSearchResult` transmission dates from returns list of transmission start dates correctly.""" + data: List[VesselSearchItem] = [VesselSearchItem(**mock_raw_vessel_list_item)] + result = VesselSearchResult(data=data) + assert result.transmission_dates_from is not None + assert isinstance(result.transmission_dates_from, list) + assert len(result.transmission_dates_from) >= 1 + + +def test_vessel_search_result_transmission_dates_to_returns_correctly( + mock_raw_vessel_list_item: Dict[str, Any], +) -> None: + """Test that `VesselSearchResult` transmission dates to returns list of transmission end dates correctly.""" + data: List[VesselSearchItem] = [VesselSearchItem(**mock_raw_vessel_list_item)] + result = VesselSearchResult(data=data) + assert result.transmission_dates_to is not None + assert isinstance(result.transmission_dates_to, list) + assert len(result.transmission_dates_to) >= 1