diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4e3d55b..17ef941 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,11 +20,11 @@ jobs: steps: - name: Checkout codes id: checkout-codes - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.0 - name: Setup python id: setup-python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v6.3.0 with: python-version: ${{ matrix.python-version }} architecture: x64 @@ -43,7 +43,7 @@ jobs: - name: Upload coverage reports to Codecov id: upload-coverage-reports-to-codecov - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@v7.0.0 if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index f3ec3ce..7a56893 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -14,11 +14,11 @@ jobs: steps: - name: Checkout codes id: checkout-codes - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.0 - name: Setup python id: setup-python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v6.3.0 with: python-version: 3.12.8 architecture: x64 diff --git a/src/gfwapiclient/http/models/response.py b/src/gfwapiclient/http/models/response.py index d9d35ab..cf16c9b 100644 --- a/src/gfwapiclient/http/models/response.py +++ b/src/gfwapiclient/http/models/response.py @@ -4,6 +4,7 @@ Any, Callable, Generic, + Iterable, Iterator, List, Optional, @@ -11,11 +12,14 @@ Type, TypeVar, Union, + cast, ) import geopandas as gpd import pandas as pd +from pydantic import BaseModel as PydanticBaseModel + from gfwapiclient.base.models import BaseModel @@ -38,6 +42,7 @@ class ResultItem(BaseModel): _ResultItemT = TypeVar("_ResultItemT", bound=ResultItem) +_ResultItemMappedT = TypeVar("_ResultItemMappedT") class Result(Generic[_ResultItemT]): @@ -186,6 +191,104 @@ def find( return None + def map( + self, + *, + mapper: Callable[[_ResultItemT], _ResultItemMappedT], + ) -> Iterator[_ResultItemMappedT]: + """Transforms each API endpoint result data using a mapping function. + + This method applies `mapper` to every `ResultItem` and returns an `Iterator` + yielding the transformed `_ResultItemMappedT`. + + Args: + mapper (Callable[[_ResultItemT], _ResultItemMappedT]): + A callable that transforms a `ResultItem` instance and + returns transformed `_ResultItemMappedT` instance. + + Yields: + _ResultItemMappedT: + Individual transformed `_ResultItemMappedT` from API endpoint result data. + + Raises: + TypeError: + If `mapper` is not callable. + """ + if not callable(mapper): + raise TypeError("Expected `mapper` to be callable.") + + for item in self._iter_data(): + yield mapper(item) + + def flat_map( + self, + *, + mapper: Callable[ + [_ResultItemT], Union[_ResultItemMappedT, Iterable[_ResultItemMappedT]] + ], + ) -> Iterator[_ResultItemMappedT]: + """Transforms and flattens API endpoint result data using a mapping function. + + This method applies `mapper` to every `ResultItem`, recursively flattens + iterable values returned by the `mapper`, and yielding the transformed `_ResultItemMappedT`. + + Unlike :meth:`map`, which yields the mapper output directly, `flat_map` + expands nested collections into a single iterator of values. + + Iterable values are flattened when they represent collections, including: + + - `list` + - `tuple` + - `set` + - generators + - iterators + - other non-atomic iterable objects + + Atomic values are yielded unchanged. The following objects are treated as + scalar values and are not expanded: + + - `str` + - `bytes` + - `dict` + - `None` + - :class:`PydanticBaseModel` instances + - :class:`BaseModel` instances + - :class:`ResultItem` instances + + Args: + mapper (Callable[[_ResultItemT], Union[_ResultItemMappedT, Iterable[_ResultItemMappedT]]]): + A callable that transforms a `ResultItem` instance and + returns either transformed single or iterable of + `_ResultItemMappedT` instance. + + Yields: + _ResultItemMappedT: + Individual transformed-flattened `_ResultItemMappedT` from API endpoint + result data produced by the mapper. + + Raises: + TypeError: + If `mapper` is not callable. + """ + if not callable(mapper): + raise TypeError("Expected `mapper` to be callable.") + + def _yield_values( + value: object, + ) -> Iterator[_ResultItemMappedT]: + """Recursively yields flattened values from nested iterables.""" + if isinstance(value, Iterable) and not isinstance( + value, + (str, bytes, dict, ResultItem, BaseModel, PydanticBaseModel), + ): + for nested_value in value: + yield from _yield_values(nested_value) + else: + yield cast(_ResultItemMappedT, value) + + for item in self._iter_data(): + yield from _yield_values(mapper(item)) + def _iter_data(self) -> Iterator[_ResultItemT]: """Iterate lazily over API endpoint result data without copying. @@ -199,15 +302,56 @@ def _iter_data(self) -> Iterator[_ResultItemT]: Yields: _ResultItemT: Individual `ResultItem` contained in API endpoint result data. - - Returns: - Iterator[_ResultItemT]: - An iterator over API endpoint result data. """ if isinstance(self._data, list): yield from self._data else: yield self._data + def __iter__(self) -> Iterator[_ResultItemT]: + """Returns an iterator over API endpoint result data. + + Yields: + _ResultItemT: + Individual `ResultItem` contained in API endpoint result data. + """ + yield from self._iter_data() + + def __len__(self) -> int: + """Returns total number of items in API endpoint result data. + + Returns: + int: + The total number of items in API endpoint result data. + """ + if isinstance(self._data, list): + return len(self._data) + + return 1 + + def __add__(self, other: object) -> "Result[_ResultItemT]": + """Concatenates API endpoint result data with items from another iterable. + + This method returns a new `Result` instance containing combined + `ResultItem` objects. + + Args: + other (object): + Iterable of `ResultItem` to append to API endpoint result data. + + Returns: + Result[_ResultItemT]: + A new `Result` instance containing the combined `ResultItem` objects. + + Raises: + TypeError: + If `other` is not iterable. + """ + if isinstance(other, Iterable): + combined_items: List[_ResultItemT] = [*self._iter_data(), *other] + return self.__class__(data=combined_items) + + return NotImplemented + _ResultT = TypeVar("_ResultT", bound=Result[Any]) diff --git a/tests/http/test_response_result_model.py b/tests/http/test_response_result_model.py index e2c2ac3..e61100c 100644 --- a/tests/http/test_response_result_model.py +++ b/tests/http/test_response_result_model.py @@ -2,7 +2,7 @@ import datetime -from typing import Any, Dict, Final, List, Optional, Type, cast +from typing import Any, Dict, Final, Iterator, List, Optional, Type, cast import pandas as pd import pytest @@ -64,6 +64,7 @@ def __init__(self, data: List[SampleResultItem]) -> None: confidence: Final[int] = 3 confidences: Final[List[int]] = [3, 4] intentional_disabling: Final[bool] = True +other_id: Final[str] = "3ca9b73aee21fbf278a636709e0f8f03" @pytest.fixture @@ -446,3 +447,288 @@ def test_result_find_invalid_predicate_returns_none( found: Optional[SampleResultItem] = result.find(predicate=invalid_predicate) assert found is None + + +def test_result_map_transforms_all_result_items( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` map transforms correctly all `ResultItem`.""" + data = [ + SampleResultItem(**mock_result_item), + SampleResultItem(**{**mock_result_item, "id": mock_result_item["id"] * 2}), + ] + + result = SampleListResult(data=data) + + def to_ids(item: SampleResultItem) -> str: + return item.id + + mapped_result: Iterator[str] = result.map(mapper=to_ids) + + assert mapped_result is not None + assert isinstance(mapped_result, Iterator) + + mapped_result_list: List[str] = list(mapped_result) + assert len(mapped_result_list) == 2 + assert mapped_result_list[0] == data[0].id + assert mapped_result_list[1] == data[1].id + + +def test_result_map_transforms_single_result_item( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` map transforms correctly a single `ResultItem`.""" + item = SampleResultItem(**mock_result_item) + + result = SampleSingleResult(data=item) + + def to_ids(item: SampleResultItem) -> str: + return item.id + + mapped_result: Iterator[str] = result.map(mapper=to_ids) + + assert mapped_result is not None + assert isinstance(mapped_result, Iterator) + + mapped_result_list: List[str] = list(mapped_result) + assert len(mapped_result_list) == 1 + assert mapped_result_list[0] == item.id + + +@pytest.mark.parametrize( + "invalid_mapper", + ["invalid", 123, object(), True, [], {}], +) +def test_result_map_raises_type_error_for_non_callable_mapper( + mock_result_item: Dict[str, Any], + invalid_mapper: Any, +) -> None: + """Tests that `Result` map raises a `TypeError` when `mapper` is not callable.""" + data = [SampleResultItem(**mock_result_item)] + result = SampleListResult(data=data) + + with pytest.raises(TypeError): + list(result.map(mapper=invalid_mapper)) + + +def test_result_flat_map_transforms_and_flattens_list_values( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` flat map transforms and flattens list values correctly.""" + data = [ + SampleResultItem(**mock_result_item), + SampleResultItem(**{**mock_result_item, "id": mock_result_item["id"] * 2}), + ] + + result = SampleListResult(data=data) + + def to_ids(item: SampleResultItem) -> List[str]: + return [item.id] + + mapped_result: Iterator[str] = result.flat_map(mapper=to_ids) + + assert mapped_result is not None + assert isinstance(mapped_result, Iterator) + + mapped_result_list: List[str] = list(mapped_result) + assert len(mapped_result_list) == 2 + assert mapped_result_list[0] == data[0].id + assert mapped_result_list[1] == data[1].id + + +def test_result_flat_map_transforms_and_flattens_generator_values( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` flat map transforms and flattens generator values correctly.""" + data = [ + SampleResultItem(**mock_result_item), + SampleResultItem(**{**mock_result_item, "id": mock_result_item["id"] * 2}), + ] + + result = SampleListResult(data=data) + + def to_ids(item: SampleResultItem) -> Iterator[str]: + yield item.id + + mapped_result: Iterator[str] = result.flat_map(mapper=to_ids) + + assert mapped_result is not None + assert isinstance(mapped_result, Iterator) + + mapped_result_list: List[str] = list(mapped_result) + assert len(mapped_result_list) == 2 + assert mapped_result_list[0] == data[0].id + assert mapped_result_list[1] == data[1].id + + +def test_result_flat_map_transforms_and_not_flattens_dict_values( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` flat map transforms and not flattens dict values correctly.""" + data = [ + SampleResultItem(**mock_result_item), + SampleResultItem(**{**mock_result_item, "id": mock_result_item["id"] * 2}), + ] + + result = SampleListResult(data=data) + + def to_dict(item: SampleResultItem) -> Iterator[Dict[str, Any]]: + yield {"id": item.id} + + mapped_result: Iterator[Dict[str, Any]] = result.flat_map(mapper=to_dict) + + assert mapped_result is not None + assert isinstance(mapped_result, Iterator) + + mapped_result_list: List[Dict[str, Any]] = list(mapped_result) + assert len(mapped_result_list) == 2 + assert isinstance(mapped_result_list[0], dict) + assert mapped_result_list[0] == {"id": data[0].id} + assert isinstance(mapped_result_list[1], dict) + assert mapped_result_list[1] == {"id": data[1].id} + + +def test_result_flat_map_transforms_and_not_flattens_pydantic_models( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` flat map transforms and not flattens pydantic models correctly.""" + data = [ + SampleResultItem(**mock_result_item), + SampleResultItem(**{**mock_result_item, "id": mock_result_item["id"] * 2}), + ] + + result = SampleListResult(data=data) + + def to_model(item: SampleResultItem) -> Iterator[SampleResultItem]: + yield item + + mapped_result: Iterator[SampleResultItem] = result.flat_map(mapper=to_model) + + assert mapped_result is not None + assert isinstance(mapped_result, Iterator) + + mapped_result_list: List[SampleResultItem] = list(mapped_result) + assert len(mapped_result_list) == 2 + assert isinstance(mapped_result_list[0], SampleResultItem) + assert mapped_result_list[0] == data[0] + assert isinstance(mapped_result_list[1], SampleResultItem) + assert mapped_result_list[1] == data[1] + + +@pytest.mark.parametrize( + "invalid_mapper", + ["invalid", 123, object(), True, [], {}], +) +def test_result_flat_map_raises_type_error_for_non_callable_mapper( + mock_result_item: Dict[str, Any], + invalid_mapper: Any, +) -> None: + """Tests that `Result` flat map raises a `TypeError` when `mapper` is not callable.""" + data = [SampleResultItem(**mock_result_item)] + result = SampleListResult(data=data) + + with pytest.raises(TypeError): + list(result.flat_map(mapper=invalid_mapper)) + + +def test_result_supports_iteration( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` support iteration by implementing `__iter__`.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + assert list(result) == data + + for item in result: + assert item is not None + assert isinstance(item, SampleResultItem) + + +def test_result_supports_iteration_with_single_result_item( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` support iteration with a single `ResultItem`.""" + input = {**mock_result_item} + item = SampleResultItem(**input) + result = SampleSingleResult(data=item) + + assert list(result) == [item] + + for item in result: + assert item is not None + assert isinstance(item, SampleResultItem) + + +def test_result_supports_len( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` support len by implementing `__len__`.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + assert len(result) == 2 + + +def test_result_supports_len_with_single_result_item( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` support len with a single `ResultItem`.""" + input = {**mock_result_item} + data = SampleResultItem(**input) + result = SampleSingleResult(data=data) + + assert len(result) == 1 + + +def test_result_add_supports_adding_other_result( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` __add__ support adding items from other `Result`.""" + result = SampleListResult(data=[SampleResultItem(**mock_result_item)]) + other = SampleListResult( + data=[SampleResultItem(**{**mock_result_item, "id": other_id})] + ) + + combined = result + other + + assert isinstance(combined, SampleListResult) + assert len(combined) == 2 + assert len(result) == 1 + assert len(other) == 1 + + +def test_result_add_supports_adding_other_iterable( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` __add__ support adding items from other `Iterable`.""" + result = SampleListResult(data=[SampleResultItem(**mock_result_item)]) + other = [SampleResultItem(**{**mock_result_item, "id": other_id})] + + combined = result + other + + assert isinstance(combined, SampleListResult) + assert len(combined) == 2 + assert len(result) == 1 + assert len(other) == 1 + + +@pytest.mark.parametrize( + "invalid_other", + [ + None, + 123, + object(), + ], +) +def test_result_add_raises_type_error_when_other_is_not_iterable( + mock_result_item: dict[str, Any], + invalid_other: Any, +) -> None: + """Tests that `Result` __add__ raises a `TypeError` when `other` is not `Iterable`.""" + result = SampleListResult(data=[SampleResultItem(**mock_result_item)]) + + with pytest.raises(TypeError): + result + invalid_other