Skip to content

Commit f410571

Browse files
authored
Make the client accept a server URL to connect to (#49)
This URL is a string with the form `grpc://hostname[:<port:int=9090>][?ssl=<ssl:bool=false>]`, meaning that the `port` and `ssl` are optional and default to 9090 and `false` respectively. This makes it more convenient to take server URLs from config files and enviroment variables, but it also means gRPC channels are not exposed directly to users anymore, so the internal implementation to connect to the gRPC server can be changed without it being a breaking change (we could potentially even change the protocol completely to use something else rather than gRPC).
2 parents c46c680 + a477810 commit f410571

File tree

3 files changed

+34
-25
lines changed

3 files changed

+34
-25
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
## Upgrading
88

9-
- The client is now using [`grpclib`](https://pypi.org/project/grpclib/) to connect to the server instead of [`grpcio`](https://pypi.org/project/grpcio/). You might need to adapt the way you connect to the server in your code, using `grpcio.client.Channel`.
9+
- The client now uses a string URL to connect to the server, the `grpc_channel` and `target` arguments are now replaced by `server_url`. The current accepted format is `grpc://hostname[:<port:int=9090>][?ssl=<ssl:bool=false>]`, meaning that the `port` and `ssl` are optional and default to 9090 and `false` respectively. You will have to adapt the way you connect to the server in your code.
10+
- The client is now using [`grpclib`](https://pypi.org/project/grpclib/) to connect to the server instead of [`grpcio`](https://pypi.org/project/grpcio/). You might need to adapt your code if you are using `grpcio` directly.
1011
- The client now doesn't raise `grpc.aio.RpcError` exceptions anymore. Instead, it raises `ClientError` exceptions that have the `grpclib.GRPCError` as their `__cause__`. You might need to adapt your error handling code to catch `ClientError` exceptions instead of `grpc.aio.RpcError` exceptions.
11-
- The client now uses protobuf/grpc bindings generated [betterproto](https://github.com/danielgtaylor/python-betterproto) instead of [grpcio](https://pypi.org/project/grpcio/). If you were using the bindings directly, you might need to do some minor adjustments to your code.
12+
- The client now uses protobuf/grpc bindings generated [betterproto](https://github.com/danielgtaylor/python-betterproto) ([frequenz-microgrid-betterproto](https://github.com/frequenz-floss/frequenz-microgrid-betterproto-python)) instead of [grpcio](https://pypi.org/project/grpcio/) ([frequenz-api-microgrid](https://github.com/frequenz-floss/frequenz-api-microgrid)). If you were using the bindings directly, you might need to do some minor adjustments to your code.
1213

1314
## New Features
1415

src/frequenz/client/microgrid/_client.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import grpclib.client
1313
from betterproto.lib.google import protobuf as pb_google
1414
from frequenz.channels import Receiver
15-
from frequenz.client.base import retry, streaming
15+
from frequenz.client.base import channel, retry, streaming
1616
from frequenz.microgrid.betterproto.frequenz.api import microgrid as pb_microgrid
1717
from frequenz.microgrid.betterproto.frequenz.api.common import (
1818
components as pb_components,
@@ -48,33 +48,41 @@
4848

4949

5050
class ApiClient:
51-
"""Microgrid API client implementation using gRPC as the underlying protocol."""
51+
"""A microgrid API client."""
5252

5353
def __init__(
5454
self,
55-
grpc_channel: grpclib.client.Channel,
56-
target: str,
55+
server_url: str,
56+
*,
5757
retry_strategy: retry.Strategy | None = None,
5858
) -> None:
5959
"""Initialize the class instance.
6060
6161
Args:
62-
grpc_channel: asyncio-supporting gRPC channel
63-
target: server (host:port) to be used for asyncio-supporting gRPC
64-
channel that the client should use to contact the API
62+
server_url: The location of the microgrid API server in the form of a URL.
63+
The following format is expected:
64+
"grpc://hostname{:`port`}{?ssl=`ssl`}",
65+
where the `port` should be an int between 0 and 65535 (defaulting to
66+
9090) and `ssl` should be a boolean (defaulting to `false`).
67+
For example: `grpc://localhost:1090?ssl=true`.
6568
retry_strategy: The retry strategy to use to reconnect when the connection
6669
to the streaming method is lost. By default a linear backoff strategy
6770
is used.
6871
"""
69-
self.target = target
70-
"""The location (as "host:port") of the microgrid API gRPC server."""
72+
self._server_url = server_url
73+
"""The location of the microgrid API server as a URL."""
7174

72-
self.api = pb_microgrid.MicrogridStub(grpc_channel)
75+
self.api = pb_microgrid.MicrogridStub(channel.parse_grpc_uri(server_url))
7376
"""The gRPC stub for the microgrid API."""
7477

7578
self._broadcasters: dict[int, streaming.GrpcStreamBroadcaster[Any, Any]] = {}
7679
self._retry_strategy = retry_strategy
7780

81+
@property
82+
def server_url(self) -> str:
83+
"""The server location in URL format."""
84+
return self._server_url
85+
7886
async def components(self) -> Iterable[Component]:
7987
"""Fetch all the components present in the microgrid.
8088
@@ -93,7 +101,7 @@ async def components(self) -> Iterable[Component]:
93101

94102
except grpclib.GRPCError as err:
95103
raise ClientError(
96-
f"Failed to list components. Microgrid API: {self.target}. Err: {err}"
104+
f"Failed to list components. Microgrid API: {self._server_url}. Err: {err}"
97105
) from err
98106

99107
components_only = filter(
@@ -176,7 +184,7 @@ async def connections(
176184
)
177185
except grpclib.GRPCError as err:
178186
raise ClientError(
179-
f"Failed to list connections. Microgrid API: {self.target}. Err: {err}"
187+
f"Failed to list connections. Microgrid API: {self._server_url}. Err: {err}"
180188
) from err
181189
# Filter out the components filtered in `components` method.
182190
# id=0 is an exception indicating grid component.
@@ -388,7 +396,7 @@ async def set_power(self, component_id: int, power_w: float) -> None:
388396
)
389397
except grpclib.GRPCError as err:
390398
raise ClientError(
391-
f"Failed to set power. Microgrid API: {self.target}. Err: {err}"
399+
f"Failed to set power. Microgrid API: {self._server_url}. Err: {err}"
392400
) from err
393401

394402
async def set_bounds(
@@ -410,7 +418,7 @@ async def set_bounds(
410418
ClientError: If the connection to the Microgrid API cannot be established or
411419
when the api call exceeded the timeout.
412420
"""
413-
api_details = f"Microgrid API: {self.target}."
421+
api_details = f"Microgrid API: {self._server_url}."
414422
if upper < 0:
415423
raise ValueError(f"Upper bound {upper} must be greater than or equal to 0.")
416424
if lower > 0:
@@ -437,6 +445,6 @@ async def set_bounds(
437445
err,
438446
)
439447
raise ClientError(
440-
f"Failed to set inclusion bounds. Microgrid API: {self.target}. "
448+
f"Failed to set inclusion bounds. Microgrid API: {self._server_url}. "
441449
f"Err: {err}"
442450
) from err

tests/test_client.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,8 @@
5757

5858
class _TestClient(ApiClient):
5959
def __init__(self, *, retry_strategy: retry.Strategy | None = None) -> None:
60-
mock_channel = mock.MagicMock(name="channel", spec=grpclib.client.Channel)
6160
mock_stub = mock.MagicMock(name="stub", spec=microgrid.MicrogridStub)
62-
target = "mock_host:1234"
63-
super().__init__(mock_channel, target, retry_strategy)
64-
self.mock_channel = mock_channel
61+
super().__init__("grpc://mock_host:1234", retry_strategy=retry_strategy)
6562
self.mock_stub = mock_stub
6663
self.api = mock_stub
6764

@@ -215,7 +212,8 @@ async def test_components_grpc_error() -> None:
215212
)
216213
with pytest.raises(
217214
ClientError,
218-
match="Failed to list components. Microgrid API: mock_host:1234. Err: .*fake grpc error",
215+
match="Failed to list components. Microgrid API: grpc://mock_host:1234. "
216+
"Err: .*fake grpc error",
219217
):
220218
await client.components()
221219

@@ -354,7 +352,8 @@ async def test_connections_grpc_error() -> None:
354352
)
355353
with pytest.raises(
356354
ClientError,
357-
match="Failed to list connections. Microgrid API: mock_host:1234. Err: .*fake grpc error",
355+
match="Failed to list connections. Microgrid API: grpc://mock_host:1234. "
356+
"Err: .*fake grpc error",
358357
):
359358
await client.connections()
360359

@@ -569,7 +568,8 @@ async def test_set_power_grpc_error() -> None:
569568
)
570569
with pytest.raises(
571570
ClientError,
572-
match="Failed to set power. Microgrid API: mock_host:1234. Err: .*fake grpc error",
571+
match="Failed to set power. Microgrid API: grpc://mock_host:1234. "
572+
"Err: .*fake grpc error",
573573
):
574574
await client.set_power(component_id=83, power_w=100.0)
575575

@@ -634,7 +634,7 @@ async def test_set_bounds_grpc_error() -> None:
634634
)
635635
with pytest.raises(
636636
ClientError,
637-
match="Failed to set inclusion bounds. Microgrid API: mock_host:1234. "
637+
match="Failed to set inclusion bounds. Microgrid API: grpc://mock_host:1234. "
638638
"Err: .*fake grpc error",
639639
):
640640
await client.set_bounds(99, 0.0, 100.0)

0 commit comments

Comments
 (0)