Skip to content

Commit 8d1c8a8

Browse files
authored
Drop betterproto/grpclib (and upgrade client-base) (#80)
Sadly the `betterproto` community is completely unresponsive when asking about the health of the project, and its main dependency, `grpclib`, is in maintenance mode and the author says this project only existed because `grpcio` didn't support `async`, and this is why it doesn't have a purpose anymore. Because of this, we go back to using `grpcio` and the Google's `protobuf` generator. This PR partially reverts these commits: * Migrate the client code to use `betterproto` (e159417) * Fix component tests to use `betterproto` (b5b52d5) * Fix client test to use `betterproto` (b3efe71) It preserves the changes to tests to use mocks instead of a fake but full gRPC server, and adapts the new code to use the `grpcio` library and the generated code from the traditional Google's `protobuf` compiler. It also bumps the `frequenz-client-base` dependency to 0.6.0, as the version being used only supported `grpclib` for parsing URLs. We also start using the new `exceptions` module from `frequenz-clien-base` to avoid having to port the local copy to use `grpcio`, but we don't use other new features in the new release in this PR. Fixes #76.
2 parents 64c6192 + da3db46 commit 8d1c8a8

13 files changed

+603
-1278
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
- This release stops using `betterproto` and `grpclib` as backend for gRPC and goes back to using Google's `grpcio` and `protobuf`.
10+
11+
If your code was using `betterproto` and `grpclib`, it now needs to be ported to use `grpcio` and `protobuf` too. Remember to also remove any `betterproto` and `grpclib` dependencies from your project.
1012

1113
## New Features
1214

pyproject.toml

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

44
[build-system]
5-
requires = ["setuptools == 68.1.0", "setuptools_scm[toml] == 7.1.0"]
5+
requires = [
6+
"setuptools == 68.1.0",
7+
"setuptools_scm[toml] == 7.1.0",
8+
"frequenz-repo-config[lib] == 0.9.1",
9+
]
610
build-backend = "setuptools.build_meta"
711

812
[project]
@@ -32,10 +36,11 @@ classifiers = [
3236
]
3337
requires-python = ">= 3.11, < 4"
3438
dependencies = [
35-
"betterproto == 2.0.0b6",
39+
"frequenz-api-microgrid >= 0.15.3, < 0.16.0",
3640
"frequenz-channels >= 1.0.0-rc1, < 2.0.0",
37-
"frequenz-client-base[grpclib] >= 0.4.0, < 0.5",
38-
"frequenz-microgrid-betterproto >= 0.15.3.1, < 0.16",
41+
"frequenz-client-base >= 0.6.0, < 0.7",
42+
"grpcio >= 1.54.2, < 2",
43+
"protobuf >= 4.21.6, < 6",
3944
"timezonefinder >= 6.2.0, < 7",
4045
"typing-extensions >= 4.5.0, < 5",
4146
]

src/frequenz/client/microgrid/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
)
4242
from ._connection import Connection
4343
from ._exception import (
44-
ClientError,
44+
ApiClientError,
45+
ClientNotConnected,
4546
DataLoss,
4647
EntityAlreadyExists,
4748
EntityNotFound,
@@ -65,12 +66,13 @@
6566

6667
__all__ = [
6768
"ApiClient",
69+
"ApiClientError",
6870
"BatteryComponentState",
6971
"BatteryData",
7072
"BatteryError",
7173
"BatteryErrorCode",
7274
"BatteryRelayState",
73-
"ClientError",
75+
"ClientNotConnected",
7476
"Component",
7577
"ComponentCategory",
7678
"ComponentData",

src/frequenz/client/microgrid/_client.py

Lines changed: 93 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,34 @@
55

66
import asyncio
77
import logging
8-
from collections.abc import Callable, Iterable, Set
9-
from typing import Any, TypeVar
8+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Set
9+
from typing import Any, TypeVar, cast
10+
11+
import grpc.aio
12+
13+
# pylint: disable=no-name-in-module
14+
from frequenz.api.common.components_pb2 import ComponentCategory as PbComponentCategory
15+
from frequenz.api.common.metrics_pb2 import Bounds as PbBounds
16+
from frequenz.api.microgrid.microgrid_pb2 import ComponentData as PbComponentData
17+
from frequenz.api.microgrid.microgrid_pb2 import ComponentFilter as PbComponentFilter
18+
from frequenz.api.microgrid.microgrid_pb2 import ComponentIdParam as PbComponentIdParam
19+
from frequenz.api.microgrid.microgrid_pb2 import ComponentList as PbComponentList
20+
from frequenz.api.microgrid.microgrid_pb2 import ConnectionFilter as PbConnectionFilter
21+
from frequenz.api.microgrid.microgrid_pb2 import ConnectionList as PbConnectionList
22+
from frequenz.api.microgrid.microgrid_pb2 import (
23+
MicrogridMetadata as PbMicrogridMetadata,
24+
)
25+
from frequenz.api.microgrid.microgrid_pb2 import SetBoundsParam as PbSetBoundsParam
26+
from frequenz.api.microgrid.microgrid_pb2 import (
27+
SetPowerActiveParam as PbSetPowerActiveParam,
28+
)
29+
from frequenz.api.microgrid.microgrid_pb2_grpc import MicrogridStub
1030

11-
import grpclib
12-
import grpclib.client
13-
from betterproto.lib.google import protobuf as pb_google
31+
# pylint: enable=no-name-in-module
1432
from frequenz.channels import Receiver
1533
from frequenz.client.base import channel, retry, streaming
16-
from frequenz.microgrid.betterproto.frequenz.api import microgrid as pb_microgrid
17-
from frequenz.microgrid.betterproto.frequenz.api.common import (
18-
components as pb_components,
19-
)
20-
from frequenz.microgrid.betterproto.frequenz.api.common import metrics as pb_metrics
34+
from google.protobuf.empty_pb2 import Empty # pylint: disable=no-name-in-module
35+
from google.protobuf.timestamp_pb2 import Timestamp # pylint: disable=no-name-in-module
2136

2237
from ._component import (
2338
Component,
@@ -35,7 +50,7 @@
3550
)
3651
from ._connection import Connection
3752
from ._constants import RECEIVER_MAX_SIZE
38-
from ._exception import ClientError
53+
from ._exception import ApiClientError
3954
from ._metadata import Location, Metadata
4055

4156
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
@@ -72,7 +87,7 @@ def __init__(
7287
self._server_url = server_url
7388
"""The location of the microgrid API server as a URL."""
7489

75-
self.api = pb_microgrid.MicrogridStub(channel.parse_grpc_uri(server_url))
90+
self.api = MicrogridStub(channel.parse_grpc_uri(server_url))
7691
"""The gRPC stub for the microgrid API."""
7792

7893
self._broadcasters: dict[int, streaming.GrpcStreamBroadcaster[Any, Any]] = {}
@@ -90,25 +105,29 @@ async def components(self) -> Iterable[Component]:
90105
Iterator whose elements are all the components in the microgrid.
91106
92107
Raises:
93-
ClientError: If the are any errors communicating with the Microgrid API,
108+
ApiClientError: If the are any errors communicating with the Microgrid API,
94109
most likely a subclass of
95110
[GrpcError][frequenz.client.microgrid.GrpcError].
96111
"""
97112
try:
98-
component_list = await self.api.list_components(
99-
pb_microgrid.ComponentFilter(),
100-
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
113+
# grpc.aio is missing types and mypy thinks this is not awaitable,
114+
# but it is
115+
component_list = await cast(
116+
Awaitable[PbComponentList],
117+
self.api.ListComponents(
118+
PbComponentFilter(),
119+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
120+
),
101121
)
102-
except grpclib.GRPCError as grpc_error:
103-
raise ClientError.from_grpc_error(
122+
except grpc.aio.AioRpcError as grpc_error:
123+
raise ApiClientError.from_grpc_error(
104124
server_url=self._server_url,
105-
operation="list_components",
125+
operation="ListComponents",
106126
grpc_error=grpc_error,
107127
) from grpc_error
108128

109129
components_only = filter(
110-
lambda c: c.category
111-
is not pb_components.ComponentCategory.COMPONENT_CATEGORY_SENSOR,
130+
lambda c: c.category is not PbComponentCategory.COMPONENT_CATEGORY_SENSOR,
112131
component_list.components,
113132
)
114133
result: Iterable[Component] = map(
@@ -132,13 +151,16 @@ async def metadata(self) -> Metadata:
132151
Returns:
133152
the microgrid metadata.
134153
"""
135-
microgrid_metadata: pb_microgrid.MicrogridMetadata | None = None
154+
microgrid_metadata: PbMicrogridMetadata | None = None
136155
try:
137-
microgrid_metadata = await self.api.get_microgrid_metadata(
138-
pb_google.Empty(),
139-
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
156+
microgrid_metadata = await cast(
157+
Awaitable[PbMicrogridMetadata],
158+
self.api.GetMicrogridMetadata(
159+
Empty(),
160+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
161+
),
140162
)
141-
except grpclib.GRPCError:
163+
except grpc.aio.AioRpcError:
142164
_logger.exception("The microgrid metadata is not available.")
143165

144166
if not microgrid_metadata:
@@ -170,25 +192,28 @@ async def connections(
170192
Microgrid connections matching the provided start and end filters.
171193
172194
Raises:
173-
ClientError: If the are any errors communicating with the Microgrid API,
195+
ApiClientError: If the are any errors communicating with the Microgrid API,
174196
most likely a subclass of
175197
[GrpcError][frequenz.client.microgrid.GrpcError].
176198
"""
177-
connection_filter = pb_microgrid.ConnectionFilter(
178-
starts=list(starts), ends=list(ends)
179-
)
199+
connection_filter = PbConnectionFilter(starts=starts, ends=ends)
180200
try:
181201
valid_components, all_connections = await asyncio.gather(
182202
self.components(),
183-
self.api.list_connections(
184-
connection_filter,
185-
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
203+
# grpc.aio is missing types and mypy thinks this is not
204+
# awaitable, but it is
205+
cast(
206+
Awaitable[PbConnectionList],
207+
self.api.ListConnections(
208+
connection_filter,
209+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
210+
),
186211
),
187212
)
188-
except grpclib.GRPCError as grpc_error:
189-
raise ClientError.from_grpc_error(
213+
except grpc.aio.AioRpcError as grpc_error:
214+
raise ApiClientError.from_grpc_error(
190215
server_url=self._server_url,
191-
operation="list_connections",
216+
operation="ListConnections",
192217
grpc_error=grpc_error,
193218
) from grpc_error
194219
# Filter out the components filtered in `components` method.
@@ -212,7 +237,7 @@ async def _new_component_data_receiver(
212237
*,
213238
component_id: int,
214239
expected_category: ComponentCategory,
215-
transform: Callable[[pb_microgrid.ComponentData], _ComponentDataT],
240+
transform: Callable[[PbComponentData], _ComponentDataT],
216241
maxsize: int,
217242
) -> Receiver[_ComponentDataT]:
218243
"""Return a new broadcaster receiver for a given `component_id`.
@@ -239,8 +264,13 @@ async def _new_component_data_receiver(
239264
if broadcaster is None:
240265
broadcaster = streaming.GrpcStreamBroadcaster(
241266
f"raw-component-data-{component_id}",
242-
lambda: self.api.stream_component_data(
243-
pb_microgrid.ComponentIdParam(id=component_id)
267+
# We need to cast here because grpc says StreamComponentData is
268+
# a grpc.CallIterator[PbComponentData] which is not an AsyncIterator,
269+
# but it is a grpc.aio.UnaryStreamCall[..., PbComponentData], which it
270+
# is.
271+
lambda: cast(
272+
AsyncIterator[PbComponentData],
273+
self.api.StreamComponentData(PbComponentIdParam(id=component_id)),
244274
),
245275
transform,
246276
retry_strategy=self._retry_strategy,
@@ -389,21 +419,22 @@ async def set_power(self, component_id: int, power_w: float) -> None:
389419
power_w: power to set for the component.
390420
391421
Raises:
392-
ClientError: If the are any errors communicating with the Microgrid API,
422+
ApiClientError: If the are any errors communicating with the Microgrid API,
393423
most likely a subclass of
394424
[GrpcError][frequenz.client.microgrid.GrpcError].
395425
"""
396426
try:
397-
await self.api.set_power_active(
398-
pb_microgrid.SetPowerActiveParam(
399-
component_id=component_id, power=power_w
427+
await cast(
428+
Awaitable[Empty],
429+
self.api.SetPowerActive(
430+
PbSetPowerActiveParam(component_id=component_id, power=power_w),
431+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
400432
),
401-
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
402433
)
403-
except grpclib.GRPCError as grpc_error:
404-
raise ClientError.from_grpc_error(
434+
except grpc.aio.AioRpcError as grpc_error:
435+
raise ApiClientError.from_grpc_error(
405436
server_url=self._server_url,
406-
operation="set_power_active",
437+
operation="SetPowerActive",
407438
grpc_error=grpc_error,
408439
) from grpc_error
409440

@@ -413,7 +444,7 @@ async def set_bounds(
413444
lower: float,
414445
upper: float,
415446
) -> None:
416-
"""Send `SetBoundsParam`s received from a channel to the Microgrid service.
447+
"""Send `PbSetBoundsParam`s received from a channel to the Microgrid service.
417448
418449
Args:
419450
component_id: ID of the component to set bounds for.
@@ -423,7 +454,7 @@ async def set_bounds(
423454
Raises:
424455
ValueError: when upper bound is less than 0, or when lower bound is
425456
greater than 0.
426-
ClientError: If the are any errors communicating with the Microgrid API,
457+
ApiClientError: If the are any errors communicating with the Microgrid API,
427458
most likely a subclass of
428459
[GrpcError][frequenz.client.microgrid.GrpcError].
429460
"""
@@ -432,21 +463,22 @@ async def set_bounds(
432463
if lower > 0:
433464
raise ValueError(f"Lower bound {lower} must be less than or equal to 0.")
434465

435-
target_metric = (
436-
pb_microgrid.SetBoundsParamTargetMetric.TARGET_METRIC_POWER_ACTIVE
437-
)
466+
target_metric = PbSetBoundsParam.TargetMetric.TARGET_METRIC_POWER_ACTIVE
438467
try:
439-
await self.api.add_inclusion_bounds(
440-
pb_microgrid.SetBoundsParam(
441-
component_id=component_id,
442-
target_metric=target_metric,
443-
bounds=pb_metrics.Bounds(lower=lower, upper=upper),
468+
await cast(
469+
Awaitable[Timestamp],
470+
self.api.AddInclusionBounds(
471+
PbSetBoundsParam(
472+
component_id=component_id,
473+
target_metric=target_metric,
474+
bounds=PbBounds(lower=lower, upper=upper),
475+
),
476+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
444477
),
445-
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
446478
)
447-
except grpclib.GRPCError as grpc_error:
448-
raise ClientError.from_grpc_error(
479+
except grpc.aio.AioRpcError as grpc_error:
480+
raise ApiClientError.from_grpc_error(
449481
server_url=self._server_url,
450-
operation="add_inclusion_bounds",
482+
operation="AddInclusionBounds",
451483
grpc_error=grpc_error,
452484
) from grpc_error

0 commit comments

Comments
 (0)