Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->
* 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.
14 changes: 14 additions & 0 deletions src/frequenz/client/dispatch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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"])
Comment on lines +300 to +302
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, super minor and irrelevant, but if you can call iter() on it, it is already an iterable, right? Why do you need to call it explicitly? It doesn't hurt to do so, so just out of curiosity...

Oh, wait, I just looked at client.list() and it takes an iterator instead of an iterable, I see. I think we could change it in the future to Iterable, any Iterator is iterable too, so it should work. It is pretty annoying not being able to pass a collection (iterable) directly to list() and having to explicitly convert to an iterator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


# 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):
Expand Down
17 changes: 17 additions & 0 deletions src/frequenz/client/dispatch/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
41 changes: 41 additions & 0 deletions src/frequenz/client/dispatch/test/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from frequenz.api.dispatch.v1.dispatch_pb2 import (
CreateMicrogridDispatchResponse,
DeleteMicrogridDispatchRequest,
DispatchFilter,
GetMicrogridDispatchRequest,
GetMicrogridDispatchResponse,
ListMicrogridDispatchesRequest,
Expand Down Expand Up @@ -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(
Expand Down
115 changes: 115 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down