From fd8b417ad481158166bb1cb2cf140831ab90533f Mon Sep 17 00:00:00 2001 From: lykmapipo Date: Mon, 19 Jan 2026 22:12:41 +0300 Subject: [PATCH 1/3] feat(http): add predicate-based filter method to Result This: - add a `filter()` instance method on `Result` to filter `ResultItem` objects - add comprehensive unit tests for common filtering scenarios --- src/gfwapiclient/http/models/response.py | 43 ++++++++++++---- tests/http/test_response_result_model.py | 62 ++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/gfwapiclient/http/models/response.py b/src/gfwapiclient/http/models/response.py index 5702834..bd4d798 100644 --- a/src/gfwapiclient/http/models/response.py +++ b/src/gfwapiclient/http/models/response.py @@ -1,15 +1,6 @@ """Global Fishing Watch (GFW) API Python Client - HTTP Response Models.""" -from typing import ( - Any, - Generic, - List, - Optional, - Set, - Type, - TypeVar, - Union, -) +from typing import Any, Callable, Generic, List, Optional, Set, Type, TypeVar, Union import geopandas as gpd import pandas as pd @@ -124,5 +115,37 @@ def df( ) return df + def filter( + self, + *, + predicate: Optional[Callable[[_ResultItemT], bool]] = None, + ) -> "Result[_ResultItemT]": + """Filters API endpoint result data using a predicate function. + + This method returns a new `Result` instance containing only those + `ResultItem` objects for which `predicate(item)` evaluates to `True`. + + If `predicate` is `None`, a shallow copy of the result is returned, + containing all `ResultItem` objects. + + Args: + predicate (Optional[Callable[[_ResultItemT], bool]], default=None): + An optional callable that accepts a `ResultItem` instance and + returns `True` if it should be included. + + Returns: + Result[_ResultItemT]: + A new `Result` instance containing the filtered `ResultItem` objects. + """ + items: List[_ResultItemT] = ( + [*self._data] if isinstance(self._data, list) else [self._data] + ) + + filtered_items = [*items] + if predicate and callable(predicate): + filtered_items = [item for item in items if predicate(item)] + + return self.__class__(data=filtered_items) + _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 dcd69e3..a15ac49 100644 --- a/tests/http/test_response_result_model.py +++ b/tests/http/test_response_result_model.py @@ -204,3 +204,65 @@ def test_result_dataframe_conversion_exclude( assert len(output) == 2 assert "id" not in list(output.columns) assert "flags" not in list(output.columns) + + +def test_result_filter_returns_only_items_matching_predicate( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` filter with a predicate returns a new `Result` containing only matched `ResultItem` objects.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + def predicate(item: SampleResultItem) -> bool: + return item is not None and item.confidence is not None and item.confidence >= 4 + + filtered_result: Result[SampleResultItem] = result.filter(predicate=predicate) + filtered_data: List[SampleResultItem] = cast( + List[SampleResultItem], filtered_result.data() + ) + + assert filtered_result is not result + assert isinstance(filtered_result, SampleListResult) + assert len(filtered_data) == 1 + assert filtered_data[-1].confidence == 4 + + +def test_result_filter_returns_empty_result_when_no_items_match_predicate( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` filter with a predicate that matches no `ResultItem` objects returns a new empty `Result`.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + def predicate(item: SampleResultItem) -> bool: + return item is not None and item.confidence is not None and item.confidence >= 5 + + filtered_result: Result[SampleResultItem] = result.filter(predicate=predicate) + filtered_data: List[SampleResultItem] = cast( + List[SampleResultItem], filtered_result.data() + ) + + assert filtered_result is not result + assert isinstance(filtered_result, SampleListResult) + assert len(filtered_data) == 0 + assert filtered_data == [] + + +def test_result_filter_without_predicate_returns_all_items( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` filter without a predicate returns new `Result` containing all `ResultItem` objects.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + filtered_result: Result[SampleResultItem] = result.filter() + filtered_data: List[SampleResultItem] = cast( + List[SampleResultItem], filtered_result.data() + ) + + assert filtered_result is not result + assert isinstance(filtered_result, SampleListResult) + assert len(filtered_data) == 2 From de91823b49d801c819b7d3e6f5fb36938bab83e7 Mon Sep 17 00:00:00 2001 From: lykmapipo Date: Mon, 19 Jan 2026 23:04:09 +0300 Subject: [PATCH 2/3] feat(http): add predicate-based find method to Result This: - add a `find()` instance method on `Result` to find first matching `ResultItem` object - add comprehensive unit tests for common find scenarios --- src/gfwapiclient/http/models/response.py | 31 ++++++ tests/http/test_response_result_model.py | 130 +++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/gfwapiclient/http/models/response.py b/src/gfwapiclient/http/models/response.py index bd4d798..e5789b2 100644 --- a/src/gfwapiclient/http/models/response.py +++ b/src/gfwapiclient/http/models/response.py @@ -147,5 +147,36 @@ def filter( return self.__class__(data=filtered_items) + def find( + self, + *, + predicate: Optional[Callable[[_ResultItemT], bool]] = None, + ) -> Optional[_ResultItemT]: + """Finds the first API endpoint result item matching a predicate. + + This method returns the first `ResultItem` for which + `predicate(item)` evaluates to `True`. + + If `predicate` is `None`, or if no items match, `None` is returned. + + Args: + predicate (Optional[Callable[[_ResultItemT], bool]], default=None): + An optional callable that accepts a `ResultItem` instance and + returns `True` for the desired item. + + Returns: + Optional[_ResultItemT]: + The first matching `ResultItem`, or `None` if no match is found. + """ + if predicate is None or not callable(predicate): + return None + + items = [*self._data] if isinstance(self._data, list) else [self._data] + for item in items: + if predicate(item): + return item + + return None + _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 a15ac49..0432c72 100644 --- a/tests/http/test_response_result_model.py +++ b/tests/http/test_response_result_model.py @@ -266,3 +266,133 @@ def test_result_filter_without_predicate_returns_all_items( assert filtered_result is not result assert isinstance(filtered_result, SampleListResult) assert len(filtered_data) == 2 + + +def test_result_filter_works_with_single_result_item( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` filter works correctly with a single `ResultItem`.""" + input = {**mock_result_item} + data = SampleResultItem(**input) + result = SampleSingleResult(data=data) + + def predicate(item: SampleResultItem) -> bool: + return item is not None and item.confidence is not None and item.confidence >= 3 + + filtered_result: Result[SampleResultItem] = result.filter(predicate=predicate) + filtered_data: List[SampleResultItem] = cast( + List[SampleResultItem], filtered_result.data() + ) + + assert filtered_result is not result + assert isinstance(filtered_result, SampleSingleResult) + assert len(filtered_data) == 1 + assert filtered_data[-1].confidence == 3 + + +def test_result_filter_works_with_empty_result_set() -> None: + """Tests that `Result` filter works correctly with empty `ResultItem` objects.""" + result = SampleListResult(data=[]) + + def predicate(item: SampleResultItem) -> bool: + return item is not None and item.confidence is not None and item.confidence >= 3 + + filtered_result: Result[SampleResultItem] = result.filter(predicate=predicate) + filtered_data: List[SampleResultItem] = cast( + List[SampleResultItem], filtered_result.data() + ) + + assert filtered_result is not result + assert isinstance(filtered_result, SampleListResult) + assert len(filtered_data) == 0 + assert filtered_data == [] + + +def test_result_find_returns_first_matching_item( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` find returns the first `ResultItem` matching the predicate.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + def predicate(item: SampleResultItem) -> bool: + return item.confidence is not None and item.confidence >= 3 + + found: Optional[SampleResultItem] = result.find(predicate=predicate) + + assert found is not None + assert isinstance(found, SampleResultItem) + assert found.confidence == 3 + + +def test_result_find_returns_none_when_no_items_match_predicate( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` find returns `None` when no `ResultItem` matches the predicate.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + def predicate(item: SampleResultItem) -> bool: + return item.confidence is not None and item.confidence >= 5 + + found: Optional[SampleResultItem] = result.find(predicate=predicate) + + assert found is None + + +def test_result_find_returns_none_when_predicate_is_none( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` find returns `None` when predicate is not provided.""" + input = {**mock_result_item} + data = [SampleResultItem(**input)] + result = SampleListResult(data=data) + + found: Optional[SampleResultItem] = result.find() + + assert found is None + + +def test_result_find_returns_none_for_empty_result_set() -> None: + """Tests that `Result` find returns `None` when the result contains no items.""" + result = SampleListResult(data=[]) + + def predicate(item: SampleResultItem) -> bool: + return True + + found: Optional[SampleResultItem] = result.find(predicate=predicate) + + assert found is None + + +def test_result_find_works_with_single_result_item( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` find works correctly with a single `ResultItem`.""" + item = SampleResultItem(**mock_result_item) + result = SampleSingleResult(data=item) + + def predicate(item: SampleResultItem) -> bool: + return item.id == cast(str, mock_result_item["id"]) + + found: Optional[SampleResultItem] = result.find(predicate=predicate) + + assert found is not None + assert found is item + + +def test_result_find_returns_none_when_single_item_does_not_match( + mock_result_item: Dict[str, Any], +) -> None: + """Tests that `Result` find returns `None` when the single `ResultItem` does not match.""" + item = SampleResultItem(**mock_result_item) + result = SampleSingleResult(data=item) + + def predicate(_: SampleResultItem) -> bool: + return False + + found: Optional[SampleResultItem] = result.find(predicate=predicate) + + assert found is None From d80e4839d828dc3d0fb0232a7fb7ad4ceb65623c Mon Sep 17 00:00:00 2001 From: lykmapipo Date: Wed, 4 Mar 2026 20:15:15 +0300 Subject: [PATCH 3/3] feat(http): add lazy iteration and improve predicate-based filter and find This: - introduce internal `_iter_data()` helper to lazily iterate over `Result` data without copying - refactor `df()`, `filter()`, and `find()` to use unified `_iter_data()` - make `filter()` return a shallow copy when predicate is invalid or not callable - make `find()` safely return `None` for invalid predicates - add unit tests covering invalid predicate handling and edge cases --- src/gfwapiclient/http/models/response.py | 61 ++++++++++++++++++------ tests/http/test_response_result_model.py | 50 +++++++++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/gfwapiclient/http/models/response.py b/src/gfwapiclient/http/models/response.py index e5789b2..d9d35ab 100644 --- a/src/gfwapiclient/http/models/response.py +++ b/src/gfwapiclient/http/models/response.py @@ -1,6 +1,17 @@ """Global Fishing Watch (GFW) API Python Client - HTTP Response Models.""" -from typing import Any, Callable, Generic, List, Optional, Set, Type, TypeVar, Union +from typing import ( + Any, + Callable, + Generic, + Iterator, + List, + Optional, + Set, + Type, + TypeVar, + Union, +) import geopandas as gpd import pandas as pd @@ -73,7 +84,7 @@ def data( The API endpoint result data, either a single `ResultItem` or a list of `ResultItem`. """ _items: Union[List[_ResultItemT], _ResultItemT] = ( - [*self._data] if isinstance(self._data, list) else self._data + list(self._data) if isinstance(self._data, list) else self._data ) return _items @@ -106,11 +117,11 @@ def df( A `DataFrame` representing the API endpoint result. If the result items contain geospatial data, a `GeoDataFrame` may be returned. """ - items: List[_ResultItemT] = ( - [*self._data] if isinstance(self._data, list) else [self._data] - ) df = pd.DataFrame( - [item.model_dump(include=include, exclude=exclude) for item in items], + [ + item.model_dump(include=include, exclude=exclude) + for item in self._iter_data() + ], **kwargs, ) return df @@ -137,14 +148,12 @@ def filter( Result[_ResultItemT]: A new `Result` instance containing the filtered `ResultItem` objects. """ - items: List[_ResultItemT] = ( - [*self._data] if isinstance(self._data, list) else [self._data] - ) - - filtered_items = [*items] - if predicate and callable(predicate): - filtered_items = [item for item in items if predicate(item)] + if predicate is None or not callable(predicate): + return self.__class__(data=list(self._iter_data())) + filtered_items: List[_ResultItemT] = [ + item for item in self._iter_data() if predicate(item) + ] return self.__class__(data=filtered_items) def find( @@ -171,12 +180,34 @@ def find( if predicate is None or not callable(predicate): return None - items = [*self._data] if isinstance(self._data, list) else [self._data] - for item in items: + for item in self._iter_data(): if predicate(item): return item return None + def _iter_data(self) -> Iterator[_ResultItemT]: + """Iterate lazily over API endpoint result data without copying. + + This internal helper provides a unified iteration interface over + the underlying response data regardless of whether the result + contains: + + - a single `ResultItem` + - a list of `ResultItem` + + 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 + _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 0432c72..e2c2ac3 100644 --- a/tests/http/test_response_result_model.py +++ b/tests/http/test_response_result_model.py @@ -308,6 +308,38 @@ def predicate(item: SampleResultItem) -> bool: assert filtered_data == [] +@pytest.mark.parametrize( + "invalid_predicate", + [ + "invalid", + 123, + object(), + True, + [], + {}, + ], +) +def test_result_filter_invalid_predicate_returns_result_copy( + mock_result_item: Dict[str, Any], + invalid_predicate: Any, +) -> None: + """Tests that `Result` filter with an invalid predicates returns shalslow copy of the `Result`.""" + input = {**mock_result_item} + data = [SampleResultItem(**input), SampleResultItem(**{**input, "confidence": 4})] + result = SampleListResult(data=data) + + filtered_result: Result[SampleResultItem] = result.filter( + predicate=invalid_predicate + ) + filtered_data: List[SampleResultItem] = cast( + List[SampleResultItem], filtered_result.data() + ) + + assert filtered_result is not result + assert isinstance(filtered_result, SampleListResult) + assert len(filtered_data) == 2 + + def test_result_find_returns_first_matching_item( mock_result_item: Dict[str, Any], ) -> None: @@ -396,3 +428,21 @@ def predicate(_: SampleResultItem) -> bool: found: Optional[SampleResultItem] = result.find(predicate=predicate) assert found is None + + +@pytest.mark.parametrize( + "invalid_predicate", + ["invalid", 123, object(), True, [], {}], +) +def test_result_find_invalid_predicate_returns_none( + mock_result_item: Dict[str, Any], + invalid_predicate: Any, +) -> None: + """Tests that `Result` find with an invalid predicates returns `None`.""" + input = {**mock_result_item} + data = [SampleResultItem(**input)] + result = SampleListResult(data=data) + + found: Optional[SampleResultItem] = result.find(predicate=invalid_predicate) + + assert found is None