-
Notifications
You must be signed in to change notification settings - Fork 5
Remove generic type from BaseApiClient
#92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -6,18 +6,15 @@ | |||||
import abc | ||||||
import inspect | ||||||
from collections.abc import Awaitable, Callable | ||||||
from typing import Any, Generic, Self, TypeVar, overload | ||||||
from typing import Any, Self, TypeVar, overload | ||||||
|
||||||
from grpc.aio import AioRpcError, Channel | ||||||
|
||||||
from .channel import ChannelOptions, parse_grpc_uri | ||||||
from .exception import ApiClientError, ClientNotConnected | ||||||
|
||||||
StubT = TypeVar("StubT") | ||||||
"""The type of the gRPC stub.""" | ||||||
|
||||||
|
||||||
class BaseApiClient(abc.ABC, Generic[StubT]): | ||||||
class BaseApiClient(abc.ABC): | ||||||
"""A base class for API clients. | ||||||
|
||||||
This class provides a common interface for API clients that communicate with a API | ||||||
|
@@ -32,12 +29,31 @@ class BaseApiClient(abc.ABC, Generic[StubT]): | |||||
a class that helps sending messages from a gRPC stream to | ||||||
a [Broadcast][frequenz.channels.Broadcast] channel. | ||||||
|
||||||
Note: | ||||||
Because grpcio doesn't provide proper type hints, a hack is needed to have | ||||||
propepr async type hints for the stubs generated by protoc. When using | ||||||
`mypy-protobuf`, a `XxxAsyncStub` class is generated for each `XxxStub` class | ||||||
but in the `.pyi` file, so the type can be used to specify type hints, but | ||||||
**not** in any other context, as the class doesn't really exist for the Python | ||||||
interpreter. This include generics, and because of this, this class can't be | ||||||
even parametrized using the async class, so the instantiation of the stub can't | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I found it confusing :D There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it is an irregular language. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
be done in the base class. | ||||||
|
||||||
Because of this, subclasses need to create the stubs by themselves, using the | ||||||
real stub class and casting it to the `XxxAsyncStub` class, so `mypy` can use | ||||||
the async version of the stubs. | ||||||
|
||||||
It is recommended to define a `stub` property that returns the async stub, so | ||||||
this hack is completely hidden from clients, even if they need to access the | ||||||
stub for more advanced uses. | ||||||
|
||||||
Example: | ||||||
This example illustrates how to create a simple API client that connects to a | ||||||
gRPC server and calls a method on a stub. | ||||||
|
||||||
```python | ||||||
from collections.abc import AsyncIterable | ||||||
from typing import cast | ||||||
from frequenz.client.base.client import BaseApiClient, call_stub_method | ||||||
from frequenz.client.base.streaming import GrpcStreamBroadcaster | ||||||
from frequenz.channels import Receiver | ||||||
|
@@ -57,25 +73,51 @@ async def example_method( | |||||
) -> ExampleResponse: | ||||||
... | ||||||
|
||||||
def example_stream(self) -> AsyncIterable[ExampleResponse]: | ||||||
def example_stream(self, _: ExampleRequest) -> AsyncIterable[ExampleResponse]: | ||||||
... | ||||||
|
||||||
class ExampleAsyncStub: | ||||||
async def example_method( | ||||||
self, | ||||||
request: ExampleRequest # pylint: disable=unused-argument | ||||||
) -> ExampleResponse: | ||||||
... | ||||||
|
||||||
def example_stream(self, _: ExampleRequest) -> AsyncIterable[ExampleResponse]: | ||||||
... | ||||||
# End of generated classes | ||||||
|
||||||
class ExampleResponseWrapper: | ||||||
def __init__(self, response: ExampleResponse): | ||||||
def __init__(self, response: ExampleResponse) -> None: | ||||||
self.transformed_value = f"{response.float_value:.2f}" | ||||||
|
||||||
class MyApiClient(BaseApiClient[ExampleStub]): | ||||||
def __init__(self, server_url: str, *, connect: bool = True): | ||||||
super().__init__( | ||||||
server_url, ExampleStub, connect=connect | ||||||
# Change defaults as needed | ||||||
DEFAULT_CHANNEL_OPTIONS = ChannelOptions() | ||||||
|
||||||
class MyApiClient(BaseApiClient): | ||||||
def __init__( | ||||||
self, | ||||||
server_url: str, | ||||||
*, | ||||||
connect: bool = True, | ||||||
channel_defaults: ChannelOptions = DEFAULT_CHANNEL_OPTIONS, | ||||||
) -> None: | ||||||
super().__init__(server_url, connect=connect, channel_defaults=channel_defaults) | ||||||
self._stub = cast( | ||||||
ExampleAsyncStub, ExampleStub(self.channel) | ||||||
) | ||||||
self._broadcaster = GrpcStreamBroadcaster( | ||||||
"stream", | ||||||
lambda: self.stub.example_stream(ExampleRequest()), | ||||||
ExampleResponseWrapper, | ||||||
) | ||||||
|
||||||
@property | ||||||
def stub(self) -> ExampleAsyncStub: | ||||||
if self._channel is None: | ||||||
raise ClientNotConnected(server_url=self.server_url, operation="stub") | ||||||
return self._stub | ||||||
|
||||||
async def example_method( | ||||||
self, int_value: int, str_value: str | ||||||
) -> ExampleResponseWrapper: | ||||||
|
@@ -114,7 +156,6 @@ async def main(): | |||||
def __init__( | ||||||
self, | ||||||
server_url: str, | ||||||
create_stub: Callable[[Channel], StubT], | ||||||
*, | ||||||
connect: bool = True, | ||||||
channel_defaults: ChannelOptions = ChannelOptions(), | ||||||
|
@@ -123,7 +164,6 @@ def __init__( | |||||
|
||||||
Args: | ||||||
server_url: The URL of the server to connect to. | ||||||
create_stub: A function that creates a stub from a channel. | ||||||
connect: Whether to connect to the server as soon as a client instance is | ||||||
created. If `False`, the client will not connect to the server until | ||||||
[connect()][frequenz.client.base.client.BaseApiClient.connect] is | ||||||
|
@@ -132,10 +172,8 @@ def __init__( | |||||
the server URL. | ||||||
""" | ||||||
self._server_url: str = server_url | ||||||
self._create_stub: Callable[[Channel], StubT] = create_stub | ||||||
self._channel_defaults: ChannelOptions = channel_defaults | ||||||
self._channel: Channel | None = None | ||||||
self._stub: StubT | None = None | ||||||
if connect: | ||||||
self.connect(server_url) | ||||||
|
||||||
|
@@ -165,22 +203,6 @@ def channel_defaults(self) -> ChannelOptions: | |||||
"""The default options for the gRPC channel.""" | ||||||
return self._channel_defaults | ||||||
|
||||||
@property | ||||||
def stub(self) -> StubT: | ||||||
"""The underlying gRPC stub. | ||||||
|
||||||
Warning: | ||||||
This stub is provided as a last resort for advanced users. It is not | ||||||
recommended to use this property directly unless you know what you are | ||||||
doing and you don't care about being tied to a specific gRPC library. | ||||||
|
||||||
Raises: | ||||||
ClientNotConnected: If the client is not connected to the server. | ||||||
""" | ||||||
if self._stub is None: | ||||||
raise ClientNotConnected(server_url=self.server_url, operation="stub") | ||||||
return self._stub | ||||||
|
||||||
@property | ||||||
def is_connected(self) -> bool: | ||||||
"""Whether the client is connected to the server.""" | ||||||
|
@@ -202,7 +224,6 @@ def connect(self, server_url: str | None = None) -> None: | |||||
elif self.is_connected: | ||||||
return | ||||||
self._channel = parse_grpc_uri(self._server_url, self._channel_defaults) | ||||||
self._stub = self._create_stub(self._channel) | ||||||
|
||||||
async def disconnect(self) -> None: | ||||||
"""Disconnect from the server. | ||||||
|
@@ -227,7 +248,6 @@ async def __aexit__( | |||||
return None | ||||||
result = await self._channel.__aexit__(_exc_type, _exc_val, _exc_tb) | ||||||
self._channel = None | ||||||
self._stub = None | ||||||
return result | ||||||
|
||||||
|
||||||
|
@@ -240,7 +260,7 @@ async def __aexit__( | |||||
|
||||||
@overload | ||||||
async def call_stub_method( | ||||||
client: BaseApiClient[StubT], | ||||||
client: BaseApiClient, | ||||||
stub_method: Callable[[], Awaitable[StubOutT]], | ||||||
*, | ||||||
method_name: str | None = None, | ||||||
|
@@ -250,7 +270,7 @@ async def call_stub_method( | |||||
|
||||||
@overload | ||||||
async def call_stub_method( | ||||||
client: BaseApiClient[StubT], | ||||||
client: BaseApiClient, | ||||||
stub_method: Callable[[], Awaitable[StubOutT]], | ||||||
*, | ||||||
method_name: str | None = None, | ||||||
|
@@ -261,7 +281,7 @@ async def call_stub_method( | |||||
# We need the `noqa: DOC503` because `pydoclint` can't figure out that | ||||||
# `ApiClientError.from_grpc_error()` returns a `GrpcError` instance. | ||||||
async def call_stub_method( # noqa: DOC503 | ||||||
client: BaseApiClient[StubT], | ||||||
client: BaseApiClient, | ||||||
stub_method: Callable[[], Awaitable[StubOutT]], | ||||||
*, | ||||||
method_name: str | None = None, | ||||||
|
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't we just ask them to pass
cast(MyServiceAsyncStub, MyServiceStub(service.channel))
as an argument, and make the base client generic overMyServiceAsyncStub
instead?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, because
__init__
can't haveMyServiceAsyncStub
as a type hint, to do that we need to take the type as a generic, and we can't use an async stub as a generic because it is parsed by the interpreter, and it can't find it because it is not present in the.py
file, only the.pyi
file.This is how great grpio is handling asyncio and type-hinting 😬 (there is a new experimental interface in the cooking, let's see how that turns out).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It won't parse them with
from __future__ import annotations
right?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
never mind, it will parse them
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, because it is not an annotation in this case, is is a proper object for the interpreter. You can give it a go if you want, I already tried many different attempts and nothing seemed to work.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Put another way, with
TYPE_CHECKING
:Then the error becomes:
Maybe it is more clear to see this way. Only stuff after a
:
(or used in special constructs, likecast
), are type hints, the rest is just code.