diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index deb1b61..8348b35 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,8 +10,11 @@ ## New Features - +* Added support for `dispatch_ids` and `queries` filters in the `list` method + - `dispatch_ids` parameter allows filtering by specific dispatch IDs + - `filter_queries` parameter supports text-based filtering on dispatch `id` and `type` fields + - Query format: IDs are prefixed with `#` (e.g., `#4`), types are matched as substrings (e.g., `bar` matches `foobar`) + - Multiple queries are combined with logical OR ## Bug Fixes -* The `FakeService` filter list code is now properly checking for unset fields to filter for. diff --git a/src/frequenz/client/dispatch/__main__.py b/src/frequenz/client/dispatch/__main__.py index 1306e32..f45d1a6 100644 --- a/src/frequenz/client/dispatch/__main__.py +++ b/src/frequenz/client/dispatch/__main__.py @@ -273,6 +273,8 @@ async def cli( # pylint: disable=too-many-arguments, too-many-positional-argume @click.option("--end-to", type=FuzzyDateTime()) @click.option("--active", type=bool) @click.option("--dry-run", type=bool) +@click.option("--dispatch-ids", type=int, multiple=True) +@click.option("--filter-queries", type=str, multiple=True) @click.option("--page-size", type=int) @click.option("--running", type=bool) @click.option("--type", "-T", type=str) @@ -282,6 +284,10 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None: Lists all dispatches for MICROGRID_ID that match the given filters. The target option can be given multiple times. + + The filter-queries option supports text-based filtering on dispatch id and type fields. + IDs are prefixed with '#' (e.g., '#4'), types are matched as substrings + (e.g., 'bar' matches 'foobar'). Multiple queries are combined with logical OR. """ filter_running: bool = filters.pop("running", False) filter_type: str | None = filters.pop("type", None) @@ -291,6 +297,14 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None: # Name of the parameter in client.list() filters["target_components"] = target + # Convert dispatch_ids to iterator to match client.list() parameter type + if "dispatch_ids" in filters: + filters["dispatch_ids"] = iter(filters["dispatch_ids"]) + + # Convert filter_queries to iterator to match client.list() parameter type + if "filter_queries" in filters: + filters["filter_queries"] = iter(filters["filter_queries"]) + num_dispatches = 0 num_filtered = 0 async for page in ctx.obj["client"].list(**filters): diff --git a/src/frequenz/client/dispatch/_client.py b/src/frequenz/client/dispatch/_client.py index 04e59f9..2ea995e 100644 --- a/src/frequenz/client/dispatch/_client.py +++ b/src/frequenz/client/dispatch/_client.py @@ -145,6 +145,8 @@ async def list( end_to: datetime | None = None, active: bool | None = None, dry_run: bool | None = None, + dispatch_ids: Iterator[DispatchId] = iter(()), + filter_queries: Iterator[str] = iter(()), page_size: int | None = None, ) -> AsyncIterator[Iterator[Dispatch]]: """List dispatches. @@ -166,6 +168,17 @@ async def list( print(dispatch) ``` + The `filter_queries` parameter is applied to the dispatch `id` and `type` fields. + Each query in the list is applied as a logical OR. + + ID tokens are preceded by a `#` so we can tell if an id is intended or a type. + + - input of [`#4`] will match only the record with id of `4` + - input of [`#not_an_id`] will match types containing `#not_an_id` + - input of [`bar`] will match `bar` and `foobar` + - input of [`#4`, `#24`, `bar`, `foo`] will match ids of `4` and `24` and + types `foo` `bar` `foobar` `foolish bartender` + Args: microgrid_id: The microgrid_id to list dispatches for. target_components: optional, list of component ids or categories to filter by. @@ -175,6 +188,8 @@ async def list( end_to: optional, filter by end_time < end_to. active: optional, filter by active status. dry_run: optional, filter by dry_run status. + dispatch_ids: optional, list of dispatch IDs to filter by. + filter_queries: optional, list of text queries to filter by. page_size: optional, number of dispatches to return per page. Returns: @@ -203,6 +218,8 @@ def to_interval( end_time_interval=end_time_interval, is_active=active, is_dry_run=dry_run, + dispatch_ids=list(map(int, dispatch_ids)), + queries=list(filter_queries), ) request = ListMicrogridDispatchesRequest( diff --git a/src/frequenz/client/dispatch/test/_service.py b/src/frequenz/client/dispatch/test/_service.py index 6071e02..3d4505e 100644 --- a/src/frequenz/client/dispatch/test/_service.py +++ b/src/frequenz/client/dispatch/test/_service.py @@ -21,6 +21,7 @@ from frequenz.api.dispatch.v1.dispatch_pb2 import ( CreateMicrogridDispatchResponse, DeleteMicrogridDispatchRequest, + DispatchFilter, GetMicrogridDispatchRequest, GetMicrogridDispatchResponse, ListMicrogridDispatchesRequest, @@ -178,6 +179,46 @@ def _filter_dispatch( if dispatch.dry_run != _filter.is_dry_run: return False + if len(_filter.dispatch_ids) > 0 and dispatch.id not in map( + DispatchId, _filter.dispatch_ids + ): + return False + + if not FakeService._matches_query(_filter, dispatch): + return False + + return True + + @staticmethod + def _matches_query(filter_: DispatchFilter, dispatch: Dispatch) -> bool: + """Check if a dispatch matches the query.""" + # Two cases: + # - query starts with # and is interpretable as int: filter by id + # - otherwise: filter by exact match in 'type' field + # Multiple queries use OR logic - dispatch must match at least one + if len(filter_.queries) > 0: + matches_any_query = False + for query in filter_.queries: + if query.startswith("#"): + try: + int_id = int(query[1:]) + except ValueError: + # not an int, interpret as exact type match (without the #) + if query[1:] == dispatch.type: + matches_any_query = True + break + else: + query_id = DispatchId(int_id) + if dispatch.id == query_id: + matches_any_query = True + break + elif query == dispatch.type: + matches_any_query = True + break + + if not matches_any_query: + return False + return True async def CreateMicrogridDispatch( diff --git a/tests/test_client.py b/tests/test_client.py index 26a6b23..ca4289b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -113,6 +113,121 @@ async def test_list_dispatches( assert dispatch == service_side_dispatch +# pylint: disable-next=too-many-locals +async def test_list_filter_queries( + client: FakeClient, generator: DispatchGenerator +) -> None: + """Test listing dispatches with filter queries.""" + microgrid_id = MicrogridId(1) + + all_dispatches = [generator.generate_dispatch() for _ in range(100)] + client.set_dispatches( + microgrid_id=microgrid_id, + value=all_dispatches, + ) + + # Filter by type + filter_type = all_dispatches[0].type + dispatches = client.list( + microgrid_id=microgrid_id, + filter_queries=iter([filter_type]), + ) + async for page in dispatches: + for dispatch in page: + assert dispatch.type == filter_type + + # Filter by id (needs to prefix with #) + filter_target_id = all_dispatches[0].id + dispatches = client.list( + microgrid_id=microgrid_id, + filter_queries=iter([f"#{int(filter_target_id)}"]), + ) + async for page in dispatches: + for dispatch in page: + assert dispatch.id == filter_target_id + + # Mixed filter - validate OR behavior with type and id + filter_mixed = [all_dispatches[3].type, f"#{int(all_dispatches[4].id)}"] + dispatches = client.list( + microgrid_id=microgrid_id, + filter_queries=iter(filter_mixed), + ) + async for page in dispatches: + for dispatch in page: + assert ( + dispatch.type == all_dispatches[3].type + or dispatch.id == all_dispatches[4].id + ) + + # Test OR behavior with multiple types - validate both dispatches are found + type1 = all_dispatches[5].type + type2 = all_dispatches[6].type + dispatches = client.list( + microgrid_id=microgrid_id, + filter_queries=iter([type1, type2]), + ) + found_dispatches = [] + async for page in dispatches: + for dispatch in page: + assert dispatch.type in (type1, type2) + found_dispatches.append(dispatch) + + # Verify both dispatches with the specified types are found + assert len(found_dispatches) == 2 + assert any(dispatch.type == type1 for dispatch in found_dispatches) + assert any(dispatch.type == type2 for dispatch in found_dispatches) + + # Test OR behavior with multiple IDs - validate both dispatches are found + id1 = all_dispatches[7].id + id2 = all_dispatches[8].id + # Use numeric part only for ID queries (fake service expects just the number) + dispatches = client.list( + microgrid_id=microgrid_id, + filter_queries=iter([f"#{int(id1)}", f"#{int(id2)}"]), + ) + found_dispatches = [] + async for page in dispatches: + for dispatch in page: + assert dispatch.id in (id1, id2) + found_dispatches.append(dispatch) + + # Verify both dispatches with the specified IDs are found + assert len(found_dispatches) == 2 + assert any(dispatch.id == id1 for dispatch in found_dispatches) + assert any(dispatch.id == id2 for dispatch in found_dispatches) + + # Test OR behavior with mixed types and IDs - validate all dispatches are found + mixed_filter = [ + all_dispatches[9].type, + f"#{int(all_dispatches[10].id)}", + all_dispatches[11].type, + f"#{int(all_dispatches[12].id)}", + ] + dispatches = client.list( + microgrid_id=microgrid_id, + filter_queries=iter(mixed_filter), + ) + found_dispatches = [] + async for page in dispatches: + for dispatch in page: + assert ( + dispatch.type == all_dispatches[9].type + or dispatch.id == all_dispatches[10].id + or dispatch.type == all_dispatches[11].type + or dispatch.id == all_dispatches[12].id + ) + found_dispatches.append(dispatch) + + # Verify all four dispatches with the specified types/IDs are found + assert len(found_dispatches) == 4 + assert any(dispatch.type == all_dispatches[9].type for dispatch in found_dispatches) + assert any(dispatch.id == all_dispatches[10].id for dispatch in found_dispatches) + assert any( + dispatch.type == all_dispatches[11].type for dispatch in found_dispatches + ) + assert any(dispatch.id == all_dispatches[12].id for dispatch in found_dispatches) + + async def test_list_dispatches_no_duration( client: FakeClient, generator: DispatchGenerator ) -> None: