diff --git a/src/gfwapiclient/base/models.py b/src/gfwapiclient/base/models.py index 9359426..edb3dda 100644 --- a/src/gfwapiclient/base/models.py +++ b/src/gfwapiclient/base/models.py @@ -1,13 +1,14 @@ """Global Fishing Watch (GFW) API Python Client - Base Models.""" -from typing import ClassVar +from enum import Enum +from typing import Any, ClassVar, Optional -from pydantic import AliasGenerator, ConfigDict +from pydantic import AliasGenerator, ConfigDict, Field, field_validator from pydantic import BaseModel as PydanticBaseModel from pydantic.alias_generators import to_camel -__all__ = ["BaseModel"] +__all__ = ["BaseModel", "Region", "RegionDataset"] class BaseModel(PydanticBaseModel): @@ -47,3 +48,111 @@ class BaseModel(PydanticBaseModel): use_enum_values=True, validate_default=True, ) + + +class RegionDataset(str, Enum): + """Regions API dataset. + + For more details on the Regions API supported datasets, please refer + to the official Global Fishing Watch API documentation: + + See: https://globalfishingwatch.org/our-apis/documentation#regions + + Attributes: + PUBLIC_EEZ_AREAS (str): + Exclusive Economic Zone (EEZ) regions dataset. + + PUBLIC_MPA_ALL (str): + Marine Protected Area (MPA) regions dataset. + + PUBLIC_RFMO (str): + Regional Fisheries Management Organization (RFMO) regions dataset. + """ + + PUBLIC_EEZ_AREAS = "public-eez-areas" + PUBLIC_MPA_ALL = "public-mpa-all" + PUBLIC_RFMO = "public-rfmo" + + +class Region(BaseModel): + """Region of interest. + + Represents a predefined geographic region (or area) of interest supported by + the Global Fishing Watch APIs, including: + + - Exclusive Economic Zones (EEZ) + - Marine Protected Areas (MPA) + - Regional Fisheries Management Organizations (RFMO) + + The predefined region (or area) of interest are used in other API endpoints when: + + - Create a report of a specified region. + See: https://globalfishingwatch.org/our-apis/documentation#create-a-report-of-a-specified-region + + - Get All Events: + See: https://globalfishingwatch.org/our-apis/documentation#get-all-events-post-endpoint + + - Create a Bulk Report. + See https://globalfishingwatch.org/our-apis/documentation#create-a-bulk-report + + For more details on the predefined region (or area) of interest, please refer + to the official Global Fishing Watch API documentation: + + See: https://globalfishingwatch.org/our-apis/documentation#regions + + For more details on the predefined region (or area) of interest data caveats, + please refer to the official Global Fishing Watch API documentation: + + See: https://globalfishingwatch.org/our-apis/documentation#exclusive-economic-zone-boundaries-definition + + See: https://globalfishingwatch.org/our-apis/documentation#marine-protected-area-boundaries-definition + + See: https://globalfishingwatch.org/our-apis/documentation#what-does-it-mean-if-an-event-is-within-a-specific-geographic-area-such-as-an-eez-mpa-or-rfmo + + See: https://globalfishingwatch.org/our-apis/documentation#how-does-gfw-calculate-that-an-event-has-a-publicly-listed-authorization + + See: https://globalfishingwatch.org/our-apis/documentation#insights-api-rfmo-iuu-vessel-list + + See: https://globalfishingwatch.org/our-apis/documentation#exclusive-economic-zone-boundaries-definitions + + See: https://globalfishingwatch.org/our-apis/documentation#marine-protected-area-boundaries-definition-2 + + Attributes: + dataset (Optional[RegionDataset]): + Dataset name (or ID) containing the region of interest (e.g., + `"public-eez-areas"`). + + id (Optional[str]): + Unique identifier (ID) for the region of interest (e.g., `"8466"`). + """ + + dataset: Optional[RegionDataset] = Field(None, alias="dataset") + id: Optional[str] = Field(None, alias="id") + + @field_validator( + "id", + mode="before", + ) + @classmethod + def normalize_id(cls, value: Any) -> Optional[Any]: + """Normalize the region identifier (ID) to a string. + + Ensures the `id` field is consistently represented as a string. + Empty or whitespace-only values are normalized to `None`. + + Args: + value (Any): + The raw region `id` value to validate. + + Returns: + Optional[Any]: + The normalized region string `id`, or `None` if the value + is empty or missing. + """ + if isinstance(value, int): + return str(value) + + if isinstance(value, str) and value.strip() == "": + return None + + return value diff --git a/src/gfwapiclient/resources/bulk_downloads/base/models/request.py b/src/gfwapiclient/resources/bulk_downloads/base/models/request.py index 233b0fd..10ccf74 100644 --- a/src/gfwapiclient/resources/bulk_downloads/base/models/request.py +++ b/src/gfwapiclient/resources/bulk_downloads/base/models/request.py @@ -5,11 +5,11 @@ """ from enum import Enum -from typing import Any, Optional, Union +from typing import Any from pydantic import Field -from gfwapiclient.base.models import BaseModel +from gfwapiclient.base.models import BaseModel, Region __all__ = [ @@ -85,7 +85,7 @@ class BulkReportGeometry(BaseModel): coordinates: Any = Field(...) -class BulkReportRegion(BaseModel): +class BulkReportRegion(Region): """Bulk report region of interest. Represents a predefined area of interest used for filtering bulk report data. @@ -96,17 +96,9 @@ class BulkReportRegion(BaseModel): See: https://globalfishingwatch.org/our-apis/documentation#bulk-report-body-only-for-post-request See: https://globalfishingwatch.org/our-apis/documentation#regions - - Attributes: - dataset (Optional[str]): - Dataset containing the region of interest (e.g. `"public-eez-areas"`). - - id (Optional[Union[str, int]]): - Region of interest identifier (ID) (e.g. `8466`). """ - dataset: Optional[str] = Field(None, alias="dataset") - id: Optional[Union[str, int]] = Field(None, alias="id") + pass class BulkReportFileType(str, Enum): diff --git a/src/gfwapiclient/resources/events/base/models/request.py b/src/gfwapiclient/resources/events/base/models/request.py index ac6012f..7445f93 100644 --- a/src/gfwapiclient/resources/events/base/models/request.py +++ b/src/gfwapiclient/resources/events/base/models/request.py @@ -7,7 +7,7 @@ from pydantic import Field -from gfwapiclient.base.models import BaseModel +from gfwapiclient.base.models import BaseModel, Region from gfwapiclient.http.models.request import RequestBody @@ -101,19 +101,20 @@ class EventGeometry(BaseModel): coordinates: Any = Field(...) -class EventRegion(BaseModel): +class EventRegion(Region): """Region where the events occur. - Attributes: - dataset (str): - The dataset containing the region. + Represents a predefined area of interest used for filtering events data. + + For more details on the Events API supported regions, please refer to the + official Global Fishing Watch API documentation: + + See: https://globalfishingwatch.org/our-apis/documentation#events-post-body-parameters - id (str): - The region ID. + See: https://globalfishingwatch.org/our-apis/documentation#regions """ - dataset: str = Field(...) - id: str = Field(...) + pass class EventBaseBody(RequestBody): diff --git a/src/gfwapiclient/resources/fourwings/report/models/request.py b/src/gfwapiclient/resources/fourwings/report/models/request.py index 727eebd..7d79896 100644 --- a/src/gfwapiclient/resources/fourwings/report/models/request.py +++ b/src/gfwapiclient/resources/fourwings/report/models/request.py @@ -5,7 +5,7 @@ from pydantic import Field -from gfwapiclient.base.models import BaseModel +from gfwapiclient.base.models import BaseModel, Region, RegionDataset from gfwapiclient.http.models import RequestBody, RequestParams @@ -228,18 +228,20 @@ class FourWingsGeometry(BaseModel): coordinates: Any = Field(...) -class FourWingsReportRegion(BaseModel): +class FourWingsReportRegion(Region): """4Wings report region of interest. - Represents a predefined region of interest used for filtering report data. + Represents a predefined region of interest used for filtering 4Wings report data. For more details on the 4Wings API supported report regions, please refer to the official Global Fishing Watch API documentation: See: https://globalfishingwatch.org/our-apis/documentation#report-body-only-for-post-request + See: https://globalfishingwatch.org/our-apis/documentation#regions + Attributes: - dataset (Optional[str]): + dataset (Optional[RegionDataset]): Dataset containing the region. id (Optional[str]): @@ -255,8 +257,7 @@ class FourWingsReportRegion(BaseModel): Value for the buffer distance. """ - dataset: Optional[str] = Field(None, alias="dataset") - id: Optional[str] = Field(None, alias="id") + dataset: Optional[RegionDataset] = Field(None, alias="dataset") buffer_operation: Optional[FourWingsReportBufferOperation] = Field( None, alias="bufferOperation" ) diff --git a/src/gfwapiclient/resources/references/__init__.py b/src/gfwapiclient/resources/references/__init__.py index 6207e4b..4bbc650 100644 --- a/src/gfwapiclient/resources/references/__init__.py +++ b/src/gfwapiclient/resources/references/__init__.py @@ -22,6 +22,8 @@ See: https://globalfishingwatch.org/our-apis/documentation#how-does-gfw-calculate-that-an-event-has-a-publicly-listed-authorization +See: https://globalfishingwatch.org/our-apis/documentation#insights-api-rfmo-iuu-vessel-list + See: https://globalfishingwatch.org/our-apis/documentation#exclusive-economic-zone-boundaries-definitions See: https://globalfishingwatch.org/our-apis/documentation#marine-protected-area-boundaries-definition-2 diff --git a/src/gfwapiclient/resources/references/regions/models/__init__.py b/src/gfwapiclient/resources/references/regions/models/__init__.py index f5d3a45..0497ed5 100644 --- a/src/gfwapiclient/resources/references/regions/models/__init__.py +++ b/src/gfwapiclient/resources/references/regions/models/__init__.py @@ -26,6 +26,8 @@ See: https://globalfishingwatch.org/our-apis/documentation#how-does-gfw-calculate-that-an-event-has-a-publicly-listed-authorization +See: https://globalfishingwatch.org/our-apis/documentation#insights-api-rfmo-iuu-vessel-list + See: https://globalfishingwatch.org/our-apis/documentation#exclusive-economic-zone-boundaries-definitions See: https://globalfishingwatch.org/our-apis/documentation#marine-protected-area-boundaries-definition-2 diff --git a/src/gfwapiclient/resources/references/regions/models/response.py b/src/gfwapiclient/resources/references/regions/models/response.py index 7ffa191..64c3b65 100644 --- a/src/gfwapiclient/resources/references/regions/models/response.py +++ b/src/gfwapiclient/resources/references/regions/models/response.py @@ -4,6 +4,7 @@ from pydantic import Field +from gfwapiclient.base.models import Region, RegionDataset from gfwapiclient.http.models import Result, ResultItem @@ -17,13 +18,13 @@ ] -class EEZRegionItem(ResultItem): +class EEZRegionItem(Region, ResultItem): """Exclusive Economic Zone (EEZ) region item. Represents single EEZ region item returned by the EEZ regions API endpoint. Attributes: - id (Optional[int]): + id (Optional[str]): Unique identifier for the EEZ region. Used in 4Wings, Events and Bulk Download API queries. @@ -31,7 +32,7 @@ class EEZRegionItem(ResultItem): Human-readable name of the EEZ region. iso3 (Optional[str]): - ISO 3166-1 alpha-3 country code (`null` for joint regimes and + ISO 3166-1 alpha-3 country code (`None` for joint regimes and overlapping claims). iso_sov_1 (Optional[str]): @@ -46,18 +47,17 @@ class EEZRegionItem(ResultItem): territory_1 (Optional[str]): Territory name. - dataset (str): + dataset (Optional[RegionDataset]): Dataset name or ID. Used in 4Wings, Events and Bulk Download API queries. """ - id: Optional[int] = Field(None) label: Optional[str] = Field(None) iso3: Optional[str] = Field(None) iso_sov_1: Optional[str] = Field(None, alias="isoSov1") iso_sov_2: Optional[str] = Field(None, alias="isoSov2") iso_sov_3: Optional[str] = Field(None, alias="isoSov3") territory_1: Optional[str] = Field(None, alias="territory1") - dataset: Optional[str] = Field("public-eez-areas") + dataset: Optional[RegionDataset] = Field(RegionDataset.PUBLIC_EEZ_AREAS) class EEZRegionResult(Result[EEZRegionItem]): @@ -92,7 +92,7 @@ def __init__(self, data: List[EEZRegionItem]) -> None: super().__init__(data=data) -class MPARegionItem(ResultItem): +class MPARegionItem(Region, ResultItem): """Marine Protected Area (MPA) region item. Represents single MPA region item returned by the MPA regions API endpoint. @@ -105,13 +105,12 @@ class MPARegionItem(ResultItem): label (Optional[str]): Name and designation of the Marine Protected Area. - dataset (str): + dataset (Optional[RegionDataset]): Dataset name or ID. Used in 4Wings, Events and Bulk Download API queries. """ - id: Optional[str] = Field(None) label: Optional[str] = Field(None) - dataset: Optional[str] = Field("public-mpa-all") + dataset: Optional[RegionDataset] = Field(RegionDataset.PUBLIC_MPA_ALL) class MPARegionResult(Result[MPARegionItem]): @@ -146,7 +145,7 @@ def __init__(self, data: List[MPARegionItem]) -> None: super().__init__(data=data) -class RFMORegionItem(ResultItem): +class RFMORegionItem(Region, ResultItem): """Regional Fisheries Management Organization (RFMO) region item. Represents single RFMO region item returned by the RFMO regions API endpoint. @@ -162,14 +161,13 @@ class RFMORegionItem(ResultItem): id_ (Optional[str]): Duplicate identifier field (matches id and label). - dataset (str): + dataset (Optional[RegionDataset]): Dataset name or ID. Used in 4Wings, Events and Bulk Download API queries. """ - id: Optional[str] = Field(None) label: Optional[str] = Field(None) id_: Optional[str] = Field(None, alias="ID") - dataset: Optional[str] = Field("public-rfmo") + dataset: Optional[RegionDataset] = Field(RegionDataset.PUBLIC_RFMO) class RFMORegionResult(Result[RFMORegionItem]): diff --git a/src/gfwapiclient/resources/references/resources.py b/src/gfwapiclient/resources/references/resources.py index d1d8171..0a1dfc0 100644 --- a/src/gfwapiclient/resources/references/resources.py +++ b/src/gfwapiclient/resources/references/resources.py @@ -46,6 +46,8 @@ class ReferenceResource(BaseResource): See: https://globalfishingwatch.org/our-apis/documentation#how-does-gfw-calculate-that-an-event-has-a-publicly-listed-authorization + See: https://globalfishingwatch.org/our-apis/documentation#insights-api-rfmo-iuu-vessel-list + See: https://globalfishingwatch.org/our-apis/documentation#exclusive-economic-zone-boundaries-definitions See: https://globalfishingwatch.org/our-apis/documentation#marine-protected-area-boundaries-definition-2 @@ -138,6 +140,8 @@ async def get_rfmo_regions(self, **kwargs: Dict[str, Any]) -> RFMORegionResult: See: https://globalfishingwatch.org/our-apis/documentation#how-does-gfw-calculate-that-an-event-has-a-publicly-listed-authorization + See: https://globalfishingwatch.org/our-apis/documentation#insights-api-rfmo-iuu-vessel-list + Args: **kwargs (Dict[str, Any]): Additional keyword arguments to pass to the RFMO region endpoint's request. diff --git a/tests/base/test_base_model.py b/tests/base/test_base_model.py index acf4454..a56ea4b 100644 --- a/tests/base/test_base_model.py +++ b/tests/base/test_base_model.py @@ -7,7 +7,7 @@ from pydantic import Field, ValidationError -from gfwapiclient.base.models import BaseModel +from gfwapiclient.base.models import BaseModel, Region, RegionDataset class SampleEnum(str, Enum): @@ -184,3 +184,147 @@ def test_base_model_raises_validation_error_on_invalid_nested_model_fields() -> id=id, nested=SampleModel(timeseries_interval=timeseries_interval), # type: ignore[call-arg] ) + + +@pytest.mark.parametrize( + "dataset,value", + [ + ( + RegionDataset.PUBLIC_EEZ_AREAS, + "public-eez-areas", + ), + ( + RegionDataset.PUBLIC_MPA_ALL, + "public-mpa-all", + ), + ( + RegionDataset.PUBLIC_RFMO, + "public-rfmo", + ), + ], +) +def test_region_dataset_enum_correct_values(dataset: RegionDataset, value: str) -> None: + """Test that correct `RegionDataset` enum values can be instantiated.""" + dataset_instance = RegionDataset(value) + assert dataset_instance == dataset + + +@pytest.mark.parametrize( + "invalid_value", + ["INVALID_DATASET", ""], +) +def test_region_dataset_enum_invalid_value_raises_value_error( + invalid_value: str, +) -> None: + """Test that invalid `RegionDataset` enum values raise a `ValueError`.""" + with pytest.raises(ValueError): + RegionDataset(invalid_value) + + +def test_region_serializes_all_fields() -> None: + """Test that `Region` serializes all required fields correctly.""" + region = Region( + dataset=RegionDataset.PUBLIC_EEZ_AREAS, + id="8371", + ) + + assert region.dataset == RegionDataset.PUBLIC_EEZ_AREAS + assert region.id == "8371" + + +@pytest.mark.parametrize( + "value,dataset", + [ + ( + "public-eez-areas", + RegionDataset.PUBLIC_EEZ_AREAS, + ), + ( + "public-mpa-all", + RegionDataset.PUBLIC_MPA_ALL, + ), + ( + "public-rfmo", + RegionDataset.PUBLIC_RFMO, + ), + ], +) +def test_region_deserializes_string_dataset_value( + value: str, dataset: RegionDataset +) -> None: + """Test that `Region` accepts dataset as a string enum value.""" + region = Region( + id="8371", + dataset=value, # type: ignore[arg-type] + ) + + assert region.dataset == dataset + assert region.id == "8371" + + +@pytest.mark.parametrize( + "value,expected", + [ + (8371, "8371"), + ("8371", "8371"), + (" 8371 ", "8371"), + ("", None), + (" ", None), + (None, None), + ], +) +def test_region_normalize_id_fields( + value: Any, + expected: Optional[str], +) -> None: + """Test that `Region` normalizes `ID` values correctly.""" + region = Region( + dataset=RegionDataset.PUBLIC_EEZ_AREAS, + id=value, + ) + + assert region.id == expected + + +@pytest.mark.parametrize( + "invalid_id", + [ + [], # invalid type + {}, # invalid type + ["123"], # invalid type + {"id": "123"}, # invalid type + ], +) +def test_region_normalize_invalid_id_values_raise_validation_error( + invalid_id: Any, +) -> None: + """Test that normalizes `Region` invalid ID values raise a `ValidationError`.""" + with pytest.raises(ValidationError): + Region( + dataset=RegionDataset.PUBLIC_EEZ_AREAS, + id=invalid_id, + ) + + +def test_region_invalid_dataset_value_raises_validation_error() -> None: + """Test that invalid `Region` dataset values raise a `ValidationError`.""" + with pytest.raises(ValidationError): + Region( + dataset="INVALID_DATASET", # type: ignore[arg-type] + id="8371", + ) + + +def test_region_serializes_fields_with_aliases() -> None: + """Test that `Region` serializes fields using aliases correctly.""" + region = Region( + dataset=RegionDataset.PUBLIC_EEZ_AREAS, + id=8371, # type: ignore[arg-type] + ) + + output: Dict[str, Any] = region.model_dump(by_alias=True) + + assert output == { + "dataset": "public-eez-areas", + "id": "8371", + } diff --git a/tests/resources/bulk_downloads/base/models/test_request_models.py b/tests/resources/bulk_downloads/base/models/test_request_models.py index 150fdc4..5f546fb 100644 --- a/tests/resources/bulk_downloads/base/models/test_request_models.py +++ b/tests/resources/bulk_downloads/base/models/test_request_models.py @@ -126,9 +126,9 @@ def test_bulk_report_geometry_invalid_inputs_raise_validation_erro( def test_bulk_report_region_serializes_all_fields() -> None: """Test that `BulkReportRegion` serializes all required fields correctly.""" - region: BulkReportRegion = BulkReportRegion(dataset=region_dataset, id=region_id) - assert region.dataset == region_dataset - assert region.id == region_id + region: BulkReportRegion = BulkReportRegion(dataset=region_dataset, id=region_id) # type: ignore[arg-type] + assert str(region.dataset) == region_dataset + assert region.id == str(region_id) def test_bulk_report_region_optional_fields_default_to_none() -> None: diff --git a/tests/resources/bulk_downloads/create/models/test_request_models.py b/tests/resources/bulk_downloads/create/models/test_request_models.py index 3dae2db..4ab34da 100644 --- a/tests/resources/bulk_downloads/create/models/test_request_models.py +++ b/tests/resources/bulk_downloads/create/models/test_request_models.py @@ -20,7 +20,14 @@ def test_bulk_report_create_request_body_serializes_all_fields( assert bulk_report_create_request_body.format is not None assert bulk_report_create_request_body.region is not None assert bulk_report_create_request_body.filters is not None + + expected_raw_bulk_report_create_request_body = { + **mock_raw_bulk_report_create_request_body + } + expected_raw_bulk_report_create_request_body["region"]["id"] = str( + mock_raw_bulk_report_create_request_body["region"]["id"] + ) assert ( bulk_report_create_request_body.to_json_body() - == mock_raw_bulk_report_create_request_body + == expected_raw_bulk_report_create_request_body ) diff --git a/tests/resources/references/regions/models/test_response_models.py b/tests/resources/references/regions/models/test_response_models.py index 6e28059..534eb29 100644 --- a/tests/resources/references/regions/models/test_response_models.py +++ b/tests/resources/references/regions/models/test_response_models.py @@ -17,7 +17,7 @@ def test_eez_region_item_deserializes_all_fields( ) -> None: """Test that `EEZRegionItem` deserializes all fields correctly.""" eez_region_item = EEZRegionItem(**mock_raw_eez_region_item) - assert eez_region_item.id == mock_raw_eez_region_item["id"] + assert eez_region_item.id == str(mock_raw_eez_region_item["id"]) assert eez_region_item.label == mock_raw_eez_region_item["label"] assert eez_region_item.iso3 == mock_raw_eez_region_item["iso3"] assert eez_region_item.iso_sov_1 == mock_raw_eez_region_item["isoSov1"]