grpc-client-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,476 @@
1
+ """One logical client interceptor, four channel adapters, one seam to write against.
2
+
3
+ Two properties of `grpc.aio` — both measured against a live server, not inferred — shape everything
4
+ in this module.
5
+
6
+ **A channel files interceptors by class, not by capability.** `grpc.aio.Channel.__init__` walks the
7
+ interceptor list once and sorts each entry into one of four lists with an ``if / elif / elif /
8
+ elif`` chain over the four interceptor ABCs. An interceptor that inherits all four therefore lands
9
+ in the *first* matching list and nowhere else: the channel registers it as unary-unary only, and
10
+ every streaming call runs completely unintercepted. The fix is to give the channel four thin
11
+ adapters per logical interceptor, each inheriting exactly one ABC and delegating to the same
12
+ implementation — see `AsyncClientInterceptor.adapters` and `flatten_interceptors`.
13
+
14
+ **A continuation resolves to a Call, not to a response.** It resolves the moment the RPC is
15
+ *created*, identically for a call that will succeed and one that will fail, and it never raises. The
16
+ outcome only becomes visible later: awaiting the `Call` raises `grpc.aio.AioRpcError` for a unary
17
+ response, and iterating it raises for a streaming one. An interceptor that treats the continuation's
18
+ result as the response therefore observes every call as an instant success. `ClientCall.invoke_unary`
19
+ and `ClientCall.invoke_stream` are the two places that get this right, so no interceptor has to.
20
+
21
+ Writing one::
22
+
23
+ class TimingInterceptor(AsyncAroundClientInterceptor):
24
+ async def around_call(self, call: ClientCall) -> AsyncIterator[None]:
25
+ started = time.perf_counter()
26
+ try:
27
+ yield
28
+ except grpc.aio.AioRpcError as error:
29
+ record(call.method, error.code(), time.perf_counter() - started)
30
+ raise
31
+ else:
32
+ record(call.method, grpc.StatusCode.OK, time.perf_counter() - started)
33
+
34
+ That one generator covers all four RPC kinds: for a streaming response the base class holds it open
35
+ until the last item has been delivered, so the ``except`` branch sees a mid-stream failure too.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import abc
41
+ import asyncio
42
+ import contextlib
43
+ import functools
44
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
45
+ from dataclasses import dataclass, field
46
+ from typing import Any, Literal
47
+
48
+ import grpc.aio
49
+
50
+ # Outcome observers for deferred calls; referenced here so the loop cannot collect them mid-flight.
51
+ _BACKGROUND_TASKS: set[asyncio.Task[None]] = set()
52
+
53
+
54
+ def _spawn_background(coro: Awaitable[None]) -> None:
55
+ """Run an outcome observer as a task the event loop cannot garbage-collect early."""
56
+ task = asyncio.ensure_future(coro)
57
+ _BACKGROUND_TASKS.add(task)
58
+ task.add_done_callback(_BACKGROUND_TASKS.discard)
59
+
60
+
61
+ type RpcType = Literal["unary_unary", "unary_stream", "stream_unary", "stream_stream"]
62
+
63
+ # What a logical interceptor is handed as a continuation: ``(call_details, request) -> Call``.
64
+ type Continuation = Callable[[Any, Any], Awaitable[Any]]
65
+
66
+ # A chain entry during the migration: either an already-adapted grpc interceptor or a logical one.
67
+ type ClientInterceptorLike = grpc.aio.ClientInterceptor | AsyncClientInterceptor
68
+
69
+ _REQUEST_STREAMING: frozenset[str] = frozenset({"stream_unary", "stream_stream"})
70
+ _RESPONSE_STREAMING: frozenset[str] = frozenset({"unary_stream", "stream_stream"})
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class ClientCall:
75
+ """One outgoing RPC as an interceptor sees it, whichever of the four kinds it is.
76
+
77
+ Attributes:
78
+ method: Full method name (``/package.Service/Method``), already decoded — grpc hands the
79
+ raw call details a `bytes` method, and every interceptor used to decode it by hand.
80
+ rpc_type: Which of the four RPC kinds this call is.
81
+ details: The `grpc.aio.ClientCallDetails` the next attempt will be issued with. Rebind it
82
+ (``call.details = call.details._replace(timeout=...)``) before invoking to change the
83
+ deadline, the metadata or the credentials; `invoke_unary` and `invoke_stream` read it
84
+ at the moment they issue the call.
85
+ request: The request message, or the request iterator for a streaming-request call.
86
+ """
87
+
88
+ method: str
89
+ rpc_type: RpcType
90
+ details: Any
91
+ request: Any
92
+ _continuation: Continuation
93
+ _underlying: Any = field(default=None, init=False, repr=False)
94
+ _response: Any = field(default=None, init=False, repr=False)
95
+
96
+ @property
97
+ def request_streaming(self) -> bool:
98
+ """Whether the caller streams the request (stream-unary and stream-stream)."""
99
+ return self.rpc_type in _REQUEST_STREAMING
100
+
101
+ @property
102
+ def response_streaming(self) -> bool:
103
+ """Whether the server streams the response (unary-stream and stream-stream)."""
104
+ return self.rpc_type in _RESPONSE_STREAMING
105
+
106
+ @property
107
+ def underlying_call(self) -> Any:
108
+ """The `grpc.aio.Call` of the attempt last issued, or None while none has been."""
109
+ return self._underlying
110
+
111
+ @property
112
+ def response(self) -> Any:
113
+ """The unary response once it has arrived; None for streams and for calls that failed."""
114
+ return self._response
115
+
116
+ async def invoke_unary(self) -> Any:
117
+ """Issue the call and, for a unary request, wait for its single response.
118
+
119
+ Returns:
120
+ The response message — or, for a streaming request, the `Call` itself, promptly.
121
+
122
+ Raises:
123
+ grpc.aio.AioRpcError: If the RPC failed. The continuation never raises — it resolves to
124
+ a `Call` as soon as the RPC is created — so the status is only reached by awaiting
125
+ that Call, which is what this does.
126
+ """
127
+ call = await self._continuation(self.details, self.request)
128
+ self._underlying = call
129
+ if self.request_streaming:
130
+ # Awaiting the response here would deadlock the write()-style API: grpc parks both
131
+ # ``call.write()`` and the request proxy until the interceptors task has finished, and
132
+ # the response cannot arrive before the requests are written. The Call is handed back
133
+ # promptly instead; whoever needs the outcome observes it from a separate task.
134
+ return call
135
+ # An interceptor further in is allowed to short-circuit with a plain response; only a real
136
+ # Call has to be awaited a second time for its status to surface.
137
+ response = await call if hasattr(call, "__await__") else call
138
+ self._response = response
139
+ return response
140
+
141
+ async def invoke_stream(self) -> AsyncIterator[Any]:
142
+ """Issue the call now and return the iterator over its responses.
143
+
144
+ The RPC is created before this returns, deliberately. grpc.aio binds the `Call` an
145
+ interceptor created to whatever iterator that interceptor hands back, and an iterator that
146
+ only creates its call on the first ``__anext__`` leaves that binding empty — ``code()``,
147
+ ``cancel()`` and the call's own finalizer then fail on ``None``.
148
+
149
+ Returns:
150
+ The response iterator. A failed RPC raises `grpc.aio.AioRpcError` while it is iterated,
151
+ which for a mid-stream failure happens after some items have already been delivered.
152
+ """
153
+ call = await self._continuation(self.details, self.request)
154
+ self._underlying = call
155
+ stream: AsyncIterator[Any] = call
156
+ return stream
157
+
158
+
159
+ class AsyncClientInterceptor(abc.ABC):
160
+ """A logical client interceptor: one implementation covering all four RPC kinds.
161
+
162
+ Subclasses implement `intercept`, which is handed a `ClientCall` and owns the call from there:
163
+ it decides whether to issue it at all, how many times, and with which details. That is the hook
164
+ for layers that re-issue calls — retries — or refuse them outright. Everything that only needs
165
+ to *wrap* a call should subclass `AsyncAroundClientInterceptor` instead and write one generator.
166
+
167
+ Deliberately not a `grpc.aio.ClientInterceptor`: passing one straight to a channel raises
168
+ ``ValueError`` from grpc instead of silently registering it for unary-unary calls only. Chains
169
+ reach a channel through `flatten_interceptors`, which turns each logical interceptor into the
170
+ four adapters a channel can file correctly.
171
+ """
172
+
173
+ @abc.abstractmethod
174
+ async def intercept(self, call: ClientCall) -> Any:
175
+ """Run one RPC and return its outcome.
176
+
177
+ Args:
178
+ call: The call to run, and the handle used to issue it.
179
+
180
+ Returns:
181
+ For a unary response, the response message (`ClientCall.invoke_unary` returns it). For a
182
+ streaming response, the response iterator — which must be built over an *already issued*
183
+ call, i.e. over the result of `ClientCall.invoke_stream`.
184
+
185
+ Raises:
186
+ grpc.aio.AioRpcError: To fail the call, whether the error came off the wire or was
187
+ raised by this interceptor without issuing anything.
188
+ """
189
+
190
+ @functools.cached_property
191
+ def adapters(self) -> tuple[grpc.aio.ClientInterceptor, ...]:
192
+ """The four single-ABC objects through which a channel registers this interceptor.
193
+
194
+ Built once per instance and kept: a chain's identity — and with it the channel pool's idea
195
+ of which channels are interchangeable — is the identity of the objects in it.
196
+ """
197
+ return (
198
+ _UnaryUnaryAdapter(self),
199
+ _UnaryStreamAdapter(self),
200
+ _StreamUnaryAdapter(self),
201
+ _StreamStreamAdapter(self),
202
+ )
203
+
204
+ async def _run_rpc(self, rpc_type: RpcType, continuation: Continuation, details: Any, request: Any) -> Any:
205
+ """Turn one grpc ``intercept_*`` invocation into an `intercept` call (adapter plumbing)."""
206
+ method = details.method
207
+ call = ClientCall(
208
+ method=method.decode("utf-8", errors="replace") if isinstance(method, bytes) else method,
209
+ rpc_type=rpc_type,
210
+ details=details,
211
+ request=request,
212
+ _continuation=continuation,
213
+ )
214
+ result = await self.intercept(call)
215
+
216
+ if call.response_streaming:
217
+ return result
218
+ # grpc.aio wraps a bare response into a Call for unary-unary only; stream-unary passes it
219
+ # to the caller untouched, where a message without ``__await__`` breaks the call and its
220
+ # finalizer. Handing back the Call is correct for both, and it keeps the status and the
221
+ # trailing metadata that a bare response drops.
222
+ return call.underlying_call if call.underlying_call is not None else result
223
+
224
+
225
+ class AsyncAroundClientInterceptor(AsyncClientInterceptor, abc.ABC):
226
+ """A logical interceptor that wraps calls instead of re-issuing them.
227
+
228
+ Subclasses implement `around_call` — an async generator that yields exactly once:
229
+
230
+ - code before ``yield`` runs before the RPC is created, which is where the call details may
231
+ still be rewritten;
232
+ - the RPC happens at the ``yield``, including full consumption of a streaming response;
233
+ - code after ``yield``, or in ``except`` / ``finally``, runs once the call has finished.
234
+
235
+ A failure reaches the ``yield`` as `grpc.aio.AioRpcError`, mid-stream failures included, so an
236
+ ordinary ``try/except`` around it covers the whole call. Refusing a call is a matter of raising
237
+ before the ``yield``: the RPC is then never created. Swallowing an exception is not supported —
238
+ the call has already failed by then, and there is no response to return in its place.
239
+ """
240
+
241
+ @abc.abstractmethod
242
+ def around_call(self, call: ClientCall) -> AsyncIterator[None]:
243
+ """Wrap one RPC (async generator: setup before ``yield``, teardown after)."""
244
+
245
+ @functools.cached_property
246
+ def _around(self) -> Callable[[ClientCall], contextlib.AbstractAsyncContextManager[None]]:
247
+ # Built once per instance; asynccontextmanager itself makes a fresh manager per call.
248
+ return contextlib.asynccontextmanager(self.around_call)
249
+
250
+ async def intercept(self, call: ClientCall) -> Any:
251
+ """Issue the call inside `around_call` (base plumbing; subclasses override the generator)."""
252
+ around = self._around(call)
253
+ await around.__aenter__()
254
+
255
+ if call.response_streaming:
256
+ try:
257
+ stream = await call.invoke_stream()
258
+ except BaseException as error:
259
+ await around.__aexit__(type(error), error, error.__traceback__)
260
+ raise
261
+ # The teardown has to outlive this method: the call is not over until the last item is.
262
+ # It must also not depend on the consumer finishing the iteration — a cancelled or
263
+ # abandoned call fires its done callback, and that closes the teardown deterministically
264
+ # instead of waiting for garbage collection.
265
+ finalizer = _AroundFinalizer(around)
266
+ _finalize_when_done(call.underlying_call, finalizer)
267
+ return _closing_stream(finalizer, stream)
268
+
269
+ if call.request_streaming:
270
+ try:
271
+ deferred = await call.invoke_unary()
272
+ except BaseException as error:
273
+ await around.__aexit__(type(error), error, error.__traceback__)
274
+ raise
275
+ # The outcome of a streaming-request call arrives after this task has returned (see
276
+ # `invoke_unary`), so the teardown runs from an observer instead of from here.
277
+ finalizer = _AroundFinalizer(around)
278
+ _spawn_background(_finalize_unary_outcome(call, deferred, finalizer))
279
+ return deferred
280
+
281
+ try:
282
+ response = await call.invoke_unary()
283
+ except BaseException as error:
284
+ await around.__aexit__(type(error), error, error.__traceback__)
285
+ raise
286
+
287
+ await around.__aexit__(None, None, None)
288
+ return response
289
+
290
+
291
+ class _AroundFinalizer:
292
+ """Closes one around context exactly once, from whichever side finishes first.
293
+
294
+ The iterator path and the call's done callback both race to report the outcome; whichever
295
+ arrives second must find the work already done rather than throw a second exception into a
296
+ generator that has already stopped.
297
+ """
298
+
299
+ __slots__ = ("_around", "_closed")
300
+
301
+ def __init__(self, around: contextlib.AbstractAsyncContextManager[None]) -> None:
302
+ self._around = around
303
+ self._closed = False
304
+
305
+ @property
306
+ def closed(self) -> bool:
307
+ """Whether the context has already been closed."""
308
+ return self._closed
309
+
310
+ async def close(self, error: BaseException | None) -> None:
311
+ """Close the context with the call's outcome, once."""
312
+ if self._closed:
313
+ return
314
+ self._closed = True
315
+ if error is None:
316
+ await self._around.__aexit__(None, None, None)
317
+ else:
318
+ await self._around.__aexit__(type(error), error, error.__traceback__)
319
+
320
+
321
+ async def _finalize_unary_outcome(call: ClientCall, deferred: Any, finalizer: _AroundFinalizer) -> None:
322
+ """Observe a deferred unary outcome and close the around context with it."""
323
+ try:
324
+ response = await deferred if hasattr(deferred, "__await__") else deferred
325
+ except BaseException as error:
326
+ await finalizer.close(error)
327
+ else:
328
+ call._response = response
329
+ await finalizer.close(None)
330
+
331
+
332
+ def _finalize_when_done(underlying: Any, finalizer: _AroundFinalizer) -> None:
333
+ """Arrange for the around context to close when the call finishes, however it finishes."""
334
+ add_done_callback = getattr(underlying, "add_done_callback", None)
335
+ if add_done_callback is None:
336
+ return
337
+
338
+ def _on_done(done_call: Any) -> None:
339
+ if not finalizer.closed:
340
+ _spawn_background(_finalize_stream_outcome(done_call, finalizer))
341
+
342
+ add_done_callback(_on_done)
343
+
344
+
345
+ async def _finalize_stream_outcome(done_call: Any, finalizer: _AroundFinalizer) -> None:
346
+ """Close the around context with the status of an already finished streaming call."""
347
+ if finalizer.closed:
348
+ return
349
+ try:
350
+ code = await done_call.code()
351
+ except BaseException as error:
352
+ await finalizer.close(error)
353
+ return
354
+
355
+ if code == grpc.StatusCode.OK:
356
+ await finalizer.close(None)
357
+ elif done_call.cancelled():
358
+ await finalizer.close(asyncio.CancelledError())
359
+ else:
360
+ failure = grpc.aio.AioRpcError(
361
+ code,
362
+ await done_call.initial_metadata(),
363
+ await done_call.trailing_metadata(),
364
+ await done_call.details(),
365
+ )
366
+ await finalizer.close(failure)
367
+
368
+
369
+ async def _closing_stream(
370
+ finalizer: _AroundFinalizer,
371
+ stream: AsyncIterator[Any],
372
+ ) -> AsyncIterator[Any]:
373
+ """Yield a whole response stream, then close the around context with however it ended."""
374
+ try:
375
+ async for item in stream:
376
+ yield item
377
+ except GeneratorExit:
378
+ # The consumer closed the iterator without draining it. From the interceptors' point of
379
+ # view the call did not finish; closing with a cancellation says exactly that, while
380
+ # throwing GeneratorExit into the around generators would only produce "generator didn't
381
+ # stop after athrow" noise. The done callback of the underlying call, when there is one,
382
+ # may report the real outcome first — the finalizer keeps whichever arrived first.
383
+ await finalizer.close(asyncio.CancelledError())
384
+ raise
385
+ except BaseException as error:
386
+ await finalizer.close(error)
387
+ raise
388
+
389
+ await finalizer.close(None)
390
+
391
+
392
+ class _Adapter:
393
+ """Half of the answer to grpc's ``if / elif`` registration: one ABC per object.
394
+
395
+ Attributes:
396
+ interceptor: The logical interceptor this adapter stands for on the channel.
397
+ """
398
+
399
+ def __init__(self, interceptor: AsyncClientInterceptor) -> None:
400
+ """Bind the adapter to the interceptor it delegates to."""
401
+ self.interceptor = interceptor
402
+
403
+
404
+ class _UnaryUnaryAdapter(_Adapter, grpc.aio.UnaryUnaryClientInterceptor):
405
+ """Registers its interceptor in the channel's unary-unary list."""
406
+
407
+ async def intercept_unary_unary(self, continuation: Any, client_call_details: Any, request: Any) -> Any:
408
+ return await self.interceptor._run_rpc("unary_unary", continuation, client_call_details, request)
409
+
410
+
411
+ class _UnaryStreamAdapter(_Adapter, grpc.aio.UnaryStreamClientInterceptor):
412
+ """Registers its interceptor in the channel's unary-stream list."""
413
+
414
+ async def intercept_unary_stream(self, continuation: Any, client_call_details: Any, request: Any) -> Any:
415
+ return await self.interceptor._run_rpc("unary_stream", continuation, client_call_details, request)
416
+
417
+
418
+ class _StreamUnaryAdapter(_Adapter, grpc.aio.StreamUnaryClientInterceptor):
419
+ """Registers its interceptor in the channel's stream-unary list."""
420
+
421
+ async def intercept_stream_unary(self, continuation: Any, client_call_details: Any, request_iterator: Any) -> Any:
422
+ return await self.interceptor._run_rpc("stream_unary", continuation, client_call_details, request_iterator)
423
+
424
+
425
+ class _StreamStreamAdapter(_Adapter, grpc.aio.StreamStreamClientInterceptor):
426
+ """Registers its interceptor in the channel's stream-stream list."""
427
+
428
+ async def intercept_stream_stream(self, continuation: Any, client_call_details: Any, request_iterator: Any) -> Any:
429
+ return await self.interceptor._run_rpc("stream_stream", continuation, client_call_details, request_iterator)
430
+
431
+
432
+ def flatten_interceptors(interceptors: Iterable[ClientInterceptorLike]) -> list[grpc.aio.ClientInterceptor]:
433
+ """Expand every logical interceptor into the four adapters a channel can file correctly.
434
+
435
+ Order is preserved, and that is what makes the expansion safe: a channel appends each entry to
436
+ the list of its own kind in the order it reads them, so four adapters of A ahead of four
437
+ adapters of B put A ahead of B in all four lists at once.
438
+
439
+ Interceptors written against grpc's ``intercept_*`` methods directly are passed through
440
+ untouched, so a chain may mix both kinds while the kit is being migrated.
441
+
442
+ Args:
443
+ interceptors: The chain, outermost first.
444
+
445
+ Returns:
446
+ A flat list a channel accepts, outermost first.
447
+ """
448
+ flat: list[grpc.aio.ClientInterceptor] = []
449
+ for interceptor in interceptors:
450
+ if isinstance(interceptor, AsyncClientInterceptor):
451
+ flat.extend(interceptor.adapters)
452
+ else:
453
+ flat.append(interceptor)
454
+ return flat
455
+
456
+
457
+ def logical_interceptor(interceptor: ClientInterceptorLike) -> ClientInterceptorLike:
458
+ """Return the logical interceptor behind a channel adapter, or the argument unchanged.
459
+
460
+ Lets callers question a flattened chain (``isinstance`` checks in tests, a handle onto the
461
+ circuit breaker) without knowing whether an entry is an adapter or an old-style interceptor.
462
+ """
463
+ owner = getattr(interceptor, "interceptor", None)
464
+ return owner if isinstance(owner, AsyncClientInterceptor) else interceptor
465
+
466
+
467
+ __all__ = [
468
+ "AsyncAroundClientInterceptor",
469
+ "AsyncClientInterceptor",
470
+ "ClientCall",
471
+ "ClientInterceptorLike",
472
+ "Continuation",
473
+ "RpcType",
474
+ "flatten_interceptors",
475
+ "logical_interceptor",
476
+ ]