Skip to content

Commit 39333a8

Browse files
committed
Add initial structure for dispatch client
Signed-off-by: Mathias L. Baumann <[email protected]>
1 parent 5e62a71 commit 39333a8

File tree

5 files changed

+234
-6
lines changed

5 files changed

+234
-6
lines changed

pyproject.toml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ classifiers = [
2727
requires-python = ">= 3.11, < 4"
2828
dependencies = [
2929
"typing-extensions >= 4.6.1, < 5",
30-
# Use marenz fork @ 0d04bc3 until the next release
31-
"frequenz-api-dispatch @ git+https://github.com/marenz/frequenz-api-dispatch.git@0d04bc3",
32-
# "frequenz-api-dispatch >= 0.12.0, < 0.13",
33-
# Directly use unreleased/unmerged commit fe4e238 until the next release
34-
"frequenz-client-base @ git+https://github.com/marenz/frequenz-client-base-python.git@fe4e238",
30+
# Use direct commit until merge
31+
"frequenz-api-dispatch @ git+https://github.com/marenz/frequenz-api-dispatch.git@f74d15",
32+
# "frequenz-api-dispatch >= 0.13.0, < 0.14",
33+
# Directly use unreleased/unmerged commit until the next release
34+
"frequenz-client-base @ git+https://github.com/frequenz-floss/frequenz-client-base-python.git@57d3093",
3535
# "frequenz-client-base >= 0.1.0, < 0.2",
36-
"frequenz-sdk == v1.0.0-rc4",
36+
# Directly use unreleased/unmerged commit until the first release
37+
"frequenz-client-common @ git+https://github.com/marenz/frequenz-client-common-python.git@0a3abd3",
38+
# "frequenz-client-common == 0.1.0",
39+
"grpcio >= 1.51.1, < 2"
3740
]
3841
dynamic = ["version"]
3942

@@ -66,6 +69,7 @@ dev-mypy = [
6669
"types-Markdown == 3.4.2.10",
6770
# For checking the noxfile, docs/ script, and tests
6871
"frequenz-dispatch-client[dev-mkdocs,dev-noxfile,dev-pytest]",
72+
"grpc-stubs == 1.53.0.2",
6973
]
7074
dev-noxfile = [
7175
"nox == 2023.4.22",
@@ -75,6 +79,7 @@ dev-pylint = [
7579
"pylint == 3.0.2",
7680
# For checking the noxfile, docs/ script, and tests
7781
"frequenz-dispatch-client[dev-mkdocs,dev-noxfile,dev-pytest]",
82+
"frequenz-api-dispatch @ git+https://github.com/marenz/frequenz-api-dispatch.git@f74d15",
7883
]
7984
dev-pytest = [
8085
"pytest == 7.4.2",

src/frequenz/client/dispatch/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@
22
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
33

44
"""Dispatch API client for Python."""
5+
6+
from ._client import Client
7+
8+
__all__ = ["Client"]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Dispatch API client for Python."""
5+
from dataclasses import dataclass
6+
from datetime import datetime
7+
from typing import Awaitable
8+
9+
import grpc
10+
from frequenz.api.dispatch.v1 import dispatch_pb2_grpc
11+
12+
# pylint: disable=no-name-in-module
13+
from frequenz.api.dispatch.v1.dispatch_pb2 import (
14+
ComponentSelector,
15+
DispatchFilter,
16+
DispatchList,
17+
DispatchListRequest,
18+
)
19+
from frequenz.api.dispatch.v1.dispatch_pb2 import (
20+
TimeIntervalFilter as PBTimeIntervalFilter,
21+
)
22+
from frequenz.client.common.microgrid.components.components import (
23+
ComponentCategory,
24+
_component_category_to_protobuf,
25+
)
26+
from google.protobuf.timestamp_pb2 import Timestamp
27+
28+
# pylint: enable=no-name-in-module
29+
30+
31+
@dataclass(frozen=True, kw_only=True)
32+
class TimeIntervalFilter:
33+
"""Filter for a time interval."""
34+
35+
start_from: datetime
36+
"""Filter by start_time >= start_from."""
37+
38+
start_to: datetime
39+
"""Filter by start_time < start_to."""
40+
41+
end_from: datetime
42+
"""Filter by end_time >= end_from."""
43+
44+
end_to: datetime
45+
"""Filter by end_time < end_to."""
46+
47+
48+
class Client:
49+
"""Dispatch API client."""
50+
51+
def __init__(self, grpc_channel: grpc.aio.Channel, svc_addr: str) -> None:
52+
"""Initialize the client.
53+
54+
Args:
55+
grpc_channel: gRPC channel to use for communication with the API.
56+
svc_addr: Address of the service to connect to.
57+
"""
58+
self._svc_addr = svc_addr
59+
self._stub = dispatch_pb2_grpc.MicrogridDispatchServiceStub(grpc_channel)
60+
61+
# pylint: disable=too-many-arguments
62+
async def list(
63+
self,
64+
microgrid_id: int,
65+
component_selectors: list[list[int] | ComponentCategory] | None = None,
66+
interval_filter: TimeIntervalFilter | None = None,
67+
is_active: bool | None = None,
68+
is_dry_run: bool | None = None,
69+
) -> DispatchList:
70+
"""List dispatches.
71+
72+
Args:
73+
microgrid_id: The microgrid_id to list dispatches for.
74+
component_selectors: optional, list of component ids or categories to filter by.
75+
interval_filter: optional, filter by time interval.
76+
is_active: optional, filter by is_active status.
77+
is_dry_run: optional, filter by is_dry_run status.
78+
79+
Returns:
80+
DispatchList: List of dispatches.
81+
"""
82+
time_interval = None
83+
84+
def to_timestamp(dt: datetime) -> Timestamp:
85+
ts = Timestamp()
86+
ts.FromDatetime(dt)
87+
return ts
88+
89+
if interval_filter:
90+
time_interval = PBTimeIntervalFilter(
91+
start_from=to_timestamp(interval_filter.start_from),
92+
start_to=to_timestamp(interval_filter.start_to),
93+
end_from=to_timestamp(interval_filter.end_from),
94+
end_to=to_timestamp(interval_filter.end_to),
95+
)
96+
97+
selectors = []
98+
99+
if component_selectors is not None:
100+
for selector in component_selectors:
101+
proto_selector = ComponentSelector()
102+
if isinstance(selector, list):
103+
proto_selector.component_ids.component_ids.extend(selector)
104+
elif isinstance(selector, ComponentCategory):
105+
proto_selector.component_category = _component_category_to_protobuf(
106+
selector
107+
)
108+
selectors.append(proto_selector)
109+
110+
filters = DispatchFilter(
111+
selectors=selectors,
112+
time_interval=time_interval,
113+
is_active=is_active,
114+
is_dry_run=is_dry_run,
115+
)
116+
request = DispatchListRequest(microgrid_id=microgrid_id, filter=filters)
117+
118+
response: Awaitable[DispatchList]
119+
response = self._stub.ListMicrogridDispatches(request) # type: ignore
120+
return await response
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Type wrappers for the generated protobuf messages."""
5+
6+
# from frequenz.api.dispatch.v1 import dispatch_pb2, dispatch_pb2_grpc

tests/test_dispatch_client.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,97 @@
22
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
33

44
"""Tests for the frequenz.client.dispatch package."""
5+
6+
from contextlib import asynccontextmanager
7+
from typing import AsyncGenerator
8+
59
import pytest
10+
11+
# pylint: disable=no-name-in-module
12+
from frequenz.api.dispatch.v1 import dispatch_pb2_grpc
13+
from frequenz.api.dispatch.v1.dispatch_pb2 import (
14+
Dispatch,
15+
DispatchCreateRequest,
16+
DispatchDeleteRequest,
17+
DispatchGetRequest,
18+
DispatchList,
19+
DispatchListRequest,
20+
DispatchUpdateRequest,
21+
)
22+
from google.protobuf.empty_pb2 import Empty
23+
from grpc import aio
24+
25+
from frequenz.client.dispatch import Client
26+
27+
# pylint: enable=no-name-in-module
28+
29+
30+
class DispatchServicer(dispatch_pb2_grpc.MicrogridDispatchServiceServicer):
31+
"""Dispatch servicer for testing."""
32+
33+
def ListMicrogridDispatches(
34+
self, request: DispatchListRequest, context: aio.ServicerContext # type: ignore
35+
) -> DispatchList:
36+
"""List microgrid dispatches."""
37+
return DispatchList()
38+
39+
def CreateMicrogridDispatch(
40+
self,
41+
request: DispatchCreateRequest,
42+
context: aio.ServicerContext, # type: ignore
43+
) -> Empty:
44+
"""Create a new dispatch."""
45+
return Empty()
46+
47+
def UpdateMicrogridDispatch(
48+
self,
49+
request: DispatchUpdateRequest,
50+
context: aio.ServicerContext, # type: ignore
51+
) -> Empty:
52+
"""Update a dispatch."""
53+
return Empty()
54+
55+
def GetMicrogridDispatch(
56+
self,
57+
request: DispatchGetRequest,
58+
context: aio.ServicerContext, # type: ignore
59+
) -> Dispatch:
60+
"""Get a single dispatch."""
61+
return Dispatch()
62+
63+
def DeleteMicrogridDispatch(
64+
self,
65+
request: DispatchDeleteRequest,
66+
context: aio.ServicerContext, # type: ignore
67+
) -> Empty:
68+
"""Delete a given dispatch."""
69+
return Empty()
70+
71+
72+
ServicerType = AsyncGenerator[tuple[aio.Server, int], None]
73+
74+
75+
@asynccontextmanager
76+
async def grpc_servicer() -> AsyncGenerator[tuple[aio.Server, str], None]:
77+
"""Async context manager to setup and teardown a gRPC server for testing."""
78+
server = aio.server()
79+
dispatch_servicer = DispatchServicer()
80+
dispatch_pb2_grpc.add_MicrogridDispatchServiceServicer_to_server(
81+
dispatch_servicer, server
82+
)
83+
port = server.add_insecure_port("localhost:0")
84+
await server.start()
85+
try:
86+
yield server, f"localhost:{port}"
87+
finally:
88+
await server.stop(None)
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_list_dispatches() -> None:
93+
"""Test listing dispatches."""
94+
async with grpc_servicer() as (server, address):
95+
async with aio.insecure_channel(address) as channel:
96+
client = Client(channel, address)
97+
response = await client.list(microgrid_id=1)
98+
assert response is not None

0 commit comments

Comments
 (0)