cloudru-qa-grpc-client 1.0.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,630 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudru-qa-grpc-client
3
+ Version: 1.0.0
4
+ Summary: Базовый gRPC-клиент
5
+ Project-URL: Repository, https://github.com/cloud-ru-tech/qa-libraries
6
+ Project-URL: Issues, https://github.com/cloud-ru-tech/qa-libraries/issues
7
+ Author-email: Алексей Кирютин <ayukiryutin@cloud.ru>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: client,grpc,grpcio
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.13
21
+ Requires-Dist: glom>=24.11.0
22
+ Requires-Dist: grpcio-status<2,>=1.84.0
23
+ Requires-Dist: grpcio<2,>=1.84.0
24
+ Requires-Dist: protobuf<8,>=5.29.0
25
+ Provides-Extra: codegen
26
+ Requires-Dist: grpcio-tools<2,>=1.71.0; extra == 'codegen'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Base gRPC client
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/cloudru-qa-grpc-client)](https://pypi.org/project/cloudru-qa-grpc-client/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/cloudru-qa-grpc-client)](https://pypi.org/project/cloudru-qa-grpc-client/)
33
+ [![License](https://img.shields.io/pypi/l/cloudru-qa-grpc-client)](https://github.com/cloud-ru-tech/qa-libraries/blob/master/LICENSE)
34
+
35
+ English | [Русский](README.ru.md)
36
+
37
+ Base client for sending requests to gRPC services from automated tests.
38
+
39
+ - [Base gRPC client](#base-grpc-client)
40
+ - [Installation](#installation)
41
+ - [Skills for AI agents](#skills-for-ai-agents)
42
+ - [Usage](#usage)
43
+ - [1. Generate code from proto](#1-generate-code-from-proto)
44
+ - [2. Write your own client](#2-write-your-own-client)
45
+ - [3. Create a client and set options](#3-create-a-client-and-set-options)
46
+ - [4. Call methods](#4-call-methods)
47
+ - [5. Work with responses](#5-work-with-responses)
48
+ - [6. Streams](#6-streams)
49
+ - [7. Call options](#7-call-options)
50
+ - [8. Metadata](#8-metadata)
51
+ - [9. Errors and rich status](#9-errors-and-rich-status)
52
+ - [10. Authorization](#10-authorization)
53
+ - [11. Logging](#11-logging)
54
+ - [12. Asynchronous client](#12-asynchronous-client)
55
+ - [13. Custom interceptors](#13-custom-interceptors)
56
+ - [Development](#development)
57
+
58
+ ## Installation
59
+
60
+ Requires Python 3.13 or later.
61
+
62
+ ```shell
63
+ pip install cloudru-qa-grpc-client
64
+ ```
65
+
66
+ Import the library as the `grpc_client` module.
67
+
68
+ If you also need code generation from proto files, install the library with the `codegen` extra:
69
+
70
+ ```shell
71
+ pip install "cloudru-qa-grpc-client[codegen]"
72
+ ```
73
+
74
+ ## Skills for AI agents
75
+
76
+ The package includes the [`grpc-client-usage`](https://github.com/cloud-ru-tech/qa-libraries/blob/master/.agents/skills/grpc-client-usage/SKILL.md) skill
77
+ with examples and usage rules for AI agents. Install the library
78
+ in your project environment, then run this from its root (requires [`uv`](https://docs.astral.sh/uv/)):
79
+
80
+ ```shell
81
+ uvx library-skills install --skill grpc-client-usage --yes
82
+ ```
83
+
84
+ For Claude Code, add `--claude`. The instructions are shipped with the library version.
85
+
86
+ ## Usage
87
+
88
+ ### 1. Generate code from proto
89
+
90
+ First, get access to the proto files describing the service contract for your gRPC client. Ask the service developers for them. See the [proto](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client/proto/) directory in this repository for examples.
91
+
92
+ Once you have access, generate `pb2.py` files from them. Ask whether the developers have already generated these files and published them in a library or repository.
93
+
94
+ If you need to generate the contract code yourself, copy the proto files into your project (consider [git submodules](https://git-scm.com/book/ru/v2/%D0%98%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BC%D0%B5%D0%BD%D1%82%D1%8B-Git-%D0%9F%D0%BE%D0%B4%D0%BC%D0%BE%D0%B4%D1%83%D0%BB%D0%B8)). Then use [`protoc`](https://grpc.io/docs/languages/python/quickstart/) from `grpcio-tools`, installed with the `cloudru-qa-grpc-client[codegen]` extra.
95
+
96
+ ℹ️ See [gen-test-api.sh](https://github.com/cloud-ru-tech/qa-libraries/blob/master/packages/grpc-client/gen-test-api.sh) for a simple example that generates `pb2.py` files for tests.
97
+
98
+ ### 2. Write your own client
99
+
100
+ The library provides base methods for sending gRPC requests. To use them, create your own client class that inherits from `GRPCClient`, with the service Stub class as its type parameter:
101
+
102
+ ```python
103
+ from typing import Type
104
+
105
+ from grpc_client import CallOptions, GRPCClient, GRPCResponse, MetadataType
106
+
107
+ # Import the pb2.py modules generated in the previous step
108
+ from api import example_pb2, example_pb2_grpc
109
+
110
+
111
+ class MyCustomClient(GRPCClient[example_pb2_grpc.ExampleStub]):
112
+ """gRPC client for the Example service."""
113
+
114
+ @property
115
+ def stub_class(self) -> Type[example_pb2_grpc.ExampleStub]:
116
+ return example_pb2_grpc.ExampleStub
117
+
118
+ def send_example_request(
119
+ self,
120
+ data: example_pb2.RequestMessage,
121
+ metadata: MetadataType | None = None,
122
+ options: CallOptions | None = None,
123
+ ) -> GRPCResponse[example_pb2.ResponseMessage]:
124
+ """Send a test gRPC request to the service.
125
+
126
+ :param data: request data
127
+ :param metadata: request metadata (similar to HTTP headers)
128
+ :param options: call options
129
+ :return: response object containing the response data
130
+ """
131
+ return self.send_unary_unary_request(
132
+ msg=data,
133
+ method=self.stub.ExampleMethod,
134
+ metadata=metadata,
135
+ options=options,
136
+ )
137
+ ```
138
+
139
+ In detail:
140
+
141
+ 1. Inherit from `GRPCClient` and specify the Stub class in square brackets to type `self.stub`.
142
+ 2. Return the same Stub class from the __required__ `stub_class` property. Import it from the generated `pb2_grpc.py` modules; it is named "{service name}Stub". All service methods are then available through `self.stub`.
143
+ 3. Call a specific request with one of the base class's four sending methods (described below), passing the request data and a method from `self.stub`.
144
+
145
+ ### 3. Create a client and set options
146
+
147
+ Create a client with the `from_addr` factory method, passing the service address and options:
148
+
149
+ ```python
150
+ import logging
151
+
152
+ from grpc_client import options
153
+
154
+ client = MyCustomClient.from_addr(
155
+ "localhost:9000",
156
+ options.with_insecure_channel(),
157
+ options.with_timeout(30),
158
+ options.with_logger(logging.getLogger("example")),
159
+ options.with_autogenerated_request_id(),
160
+ )
161
+
162
+ response = client.send_example_request(...)
163
+ client.close()
164
+ ```
165
+
166
+ ⚠️ Close the channel created by the client. The simplest way is to use the client as a context manager:
167
+
168
+ ```python
169
+ with MyCustomClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
170
+ response = client.send_example_request(...)
171
+ ```
172
+
173
+ A fixture works well for this in pytest:
174
+
175
+ ```python
176
+ @pytest.fixture
177
+ def example_client() -> Iterator[MyCustomClient]:
178
+ with MyCustomClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
179
+ yield client
180
+ ```
181
+
182
+ If a channel already exists and the caller manages its lifetime, use `from_channel`: the client does not close that channel:
183
+
184
+ ```python
185
+ with grpc.insecure_channel("localhost:9000") as channel:
186
+ client = MyCustomClient.from_channel(channel)
187
+ ```
188
+
189
+ Available options:
190
+
191
+ | Option | Purpose |
192
+ | --- | --- |
193
+ | `with_timeout(timeout)` | Default call timeout; `None` means no limit |
194
+ | `with_insecure_channel()` | Unencrypted channel (channels are encrypted by default) |
195
+ | `with_ssl_credentials(credentials)` | Custom TLS credentials |
196
+ | `with_root_certificates(pem_bytes)` | Root certificate for verifying the server |
197
+ | `with_channel_options(options)` | Low-level grpcio channel settings, including `grpc.service_config` |
198
+ | `with_compression(compression)` | Message compression |
199
+ | `with_default_metadata(metadata)` | Metadata added to every request |
200
+ | `with_auth(auth)` | Add an access token; see [Authorization](#10-authorization) |
201
+ | `with_logger(logger, ...)` | Log requests and responses |
202
+ | `with_autogenerated_request_id(key)` | Automatic `x-request-id` |
203
+ | `with_autogenerated_traceparent(key)` | Automatic `traceparent` following W3C Trace Context |
204
+ | `with_interceptor(*interceptors)` | Custom grpcio interceptors |
205
+
206
+ Option order matters: interceptors run in the order they are added. Add logging after authorization so the log captures the final request.
207
+
208
+ ### 4. Call methods
209
+
210
+ gRPC has four method types:
211
+
212
+ 1. **Unary** — a regular blocking request: the client sends one message and receives one response.
213
+ 2. **Client streaming** — the client sends a sequence of messages, then waits for a single response.
214
+ 3. **Server streaming** — the client sends a request and reads a sequence of response messages.
215
+ 4. **Bidirectional streaming** — both the client and server send sequences of messages independently.
216
+
217
+ See the [grpc.io documentation](https://grpc.io/docs/what-is-grpc/core-concepts/) for details.
218
+
219
+ The base class implements all four:
220
+
221
+ | Method | Call type | Returns |
222
+ | --- | --- | --- |
223
+ | `send_unary_unary_request` | unary | `GRPCResponse[T]` |
224
+ | `send_stream_unary_request` | client streaming | `GRPCResponse[T]` |
225
+ | `send_unary_stream_request` | server streaming | `GRPCStreamResponse[T]` |
226
+ | `send_stream_stream_request` | bidirectional streaming | `GRPCStreamResponse[T]` |
227
+
228
+ ℹ️ In Python, request streams use an [iterator](https://docs.python.org/3/library/stdtypes.html#iterator-types), `Iterator[Message]`. Use `iter` to create a simple iterator from a list.
229
+
230
+ ### 5. Work with responses
231
+
232
+ Unary methods return `GRPCResponse`:
233
+
234
+ ```python
235
+ response = client.send_example_request(data)
236
+
237
+ response.status_code # grpc.StatusCode — response status code
238
+ response.details # str — status explanation, usually set for errors
239
+ response.ok # bool — whether the request succeeded
240
+ response.message() # response message; raises MessageUndefinedError if absent
241
+ response.initial_metadata # metadata before the response body, similar to HTTP headers
242
+ response.trailing_metadata # metadata after the response body
243
+ response.error # original grpc.RpcError if the request failed
244
+ response.error_status # rich google.rpc.Status if provided by the service
245
+ response.raise_for_status() # raise ResponseStatusError if status is not OK
246
+ ```
247
+
248
+ The client does not raise gRPC errors: each failed call becomes a `GRPCResponse` with the appropriate code. This is useful in automated tests, where a failed response can also be an expected result:
249
+
250
+ ```python
251
+ response = client.send_example_request(data)
252
+
253
+ assert response.status_code == grpc.StatusCode.NOT_FOUND
254
+ assert response.details == "order not found"
255
+ ```
256
+
257
+ To turn an error into an exception, use `raise_for_status()`.
258
+
259
+ Use `with_parser` to transform the response message, for example into a pydantic model or a single property:
260
+
261
+ ```python
262
+ from pydantic import BaseModel
263
+
264
+ from grpc_client import GRPCClient, GRPCResponseWithParser, MetadataType
265
+
266
+ from api import example_pb2, example_pb2_grpc
267
+
268
+
269
+ class ResponseModel(BaseModel):
270
+ prop1: str
271
+ prop2: int
272
+ prop3: bool
273
+
274
+
275
+ class MyCustomClient(GRPCClient[example_pb2_grpc.ExampleStub]):
276
+ @property
277
+ def stub_class(self) -> type[example_pb2_grpc.ExampleStub]:
278
+ return example_pb2_grpc.ExampleStub
279
+
280
+ def send_example_request(
281
+ self,
282
+ data: example_pb2.RequestMessage,
283
+ metadata: MetadataType | None = None,
284
+ ) -> GRPCResponseWithParser[example_pb2.ResponseMessage, ResponseModel]:
285
+ """Send a test gRPC request to the service.
286
+
287
+ :param data: request data
288
+ :param metadata: request metadata (similar to HTTP headers)
289
+ :return: response object with a response message parser
290
+ """
291
+ response = self.send_unary_unary_request(
292
+ msg=data,
293
+ method=self.stub.ExampleMethod,
294
+ metadata=metadata,
295
+ )
296
+
297
+ def parser(msg: example_pb2.ResponseMessage) -> ResponseModel:
298
+ return ResponseModel(
299
+ prop1=msg.prop1,
300
+ prop2=msg.prop2,
301
+ prop3=msg.prop3,
302
+ )
303
+
304
+ return response.with_parser(parser)
305
+ ```
306
+
307
+ Call `parse()` to get the parsed message.
308
+
309
+ ### 6. Streams
310
+
311
+ `send_unary_stream_request` and `send_stream_stream_request` return `GRPCStreamResponse`. Messages are read lazily as they arrive, without buffering.
312
+
313
+ If you need all messages at once, call `collect()`: it consumes the remaining stream and returns a regular `GRPCResponse` with a message list and the final status.
314
+
315
+ ```python
316
+ response = client.send_server_stream(data).collect()
317
+
318
+ assert response.status_code == grpc.StatusCode.OK
319
+ for message in response.message():
320
+ ...
321
+ ```
322
+
323
+ Lazy reading is useful for long or endless streams:
324
+
325
+ ```python
326
+ with client.send_server_stream(data) as stream:
327
+ for message in stream:
328
+ if message.is_last:
329
+ break
330
+ ```
331
+
332
+ ⚠️ A stream keeps the call open, so consume it fully or close it. The context manager cancels an unfinished call on exit. You can also cancel it explicitly with `cancel()`.
333
+
334
+ The server provides the final status and trailing metadata only when the stream ends. Accessing them before reading finishes raises `StreamNotConsumedError`:
335
+
336
+ ```python
337
+ stream = client.send_server_stream(data)
338
+ messages = list(stream)
339
+
340
+ stream.status_code # available only now
341
+ stream.trailing_metadata
342
+ ```
343
+
344
+ Pass `limit` to restrict the number of messages: the call is canceled when the limit is reached:
345
+
346
+ ```python
347
+ response = client.send_server_stream(data).collect(limit=100)
348
+ ```
349
+
350
+ ℹ️ A stream can be read once: reading it again raises `StreamConsumedError`. If you need the messages again, save them with `collect()`.
351
+
352
+ ### 7. Call options
353
+
354
+ Client settings apply to all requests. Use `CallOptions` to configure one call:
355
+
356
+ ```python
357
+ from grpc_client import CallOptions
358
+
359
+ response = client.send_example_request(
360
+ data,
361
+ options=CallOptions(
362
+ timeout=120, # custom timeout for a long-running method
363
+ metadata={"x-attempt": "2"},
364
+ wait_for_ready=True, # wait for the channel instead of failing with UNAVAILABLE
365
+ compression=grpc.Compression.Gzip,
366
+ ),
367
+ )
368
+ ```
369
+
370
+ `timeout=None` means no time limit. An omitted `timeout` uses the client settings: these are different behaviors.
371
+
372
+ ### 8. Metadata
373
+
374
+ Pass metadata as a dictionary or a sequence of pairs; use pairs when a key has multiple values:
375
+
376
+ ```python
377
+ client.send_example_request(data, metadata={"x-order-id": "A-1024"})
378
+ client.send_example_request(data, metadata=[("x-feature", "search"), ("x-feature", "ranking")])
379
+ ```
380
+
381
+ Response metadata is returned as a `Metadata` object. It acts like a dictionary but preserves order and duplicate keys, and compares key names without case sensitivity:
382
+
383
+ ```python
384
+ response.trailing_metadata["x-request-id"] # first value for the key
385
+ response.trailing_metadata.get_all("x-feature") # all values for the key
386
+ response.trailing_metadata.pairs() # all pairs, including duplicate keys
387
+ ```
388
+
389
+ ℹ️ Binary values are allowed only for keys ending in `-bin`, as required by gRPC.
390
+
391
+ ### 9. Errors and rich status
392
+
393
+ Services often send error details in `google.rpc.Status`. The library parses them and exposes them through `error_status`:
394
+
395
+ ```python
396
+ response = client.send_example_request(data)
397
+
398
+ if response.error_status:
399
+ print(response.error_status.code) # numeric google.rpc.Code
400
+ print(response.error_status.message) # error text
401
+ print(response.error_status.details) # typed details
402
+
403
+ # look up details by message type
404
+ bad_request = response.error_status.of_type(error_details_pb2.BadRequest)
405
+ ```
406
+
407
+ Detail types unknown to the library remain as `google.protobuf.Any`.
408
+
409
+ Library exceptions inherit from `GRPCClientError`:
410
+
411
+ | Exception | Raised when |
412
+ | --- | --- |
413
+ | `MessageUndefinedError` | calling `message()` on a response without a message |
414
+ | `ResponseStatusError` | calling `raise_for_status()` with a non-OK status |
415
+ | `StreamConsumedError` | reading an already consumed stream again |
416
+ | `StreamNotConsumedError` | accessing the final status before consuming the stream |
417
+
418
+ ### 10. Authorization
419
+
420
+ The library does not determine where tokens come from; it defines the `ClientAuth` contract:
421
+
422
+ ```python
423
+ class ClientAuth(Protocol):
424
+ def token(self) -> str: ...
425
+ def refresh(self, rejected_token: str) -> str: ...
426
+ ```
427
+
428
+ The rejected token is passed to `refresh()`, allowing the implementation to distinguish an expired token
429
+ from one already refreshed by another request while this request waited for a lock, avoiding unnecessary external calls.
430
+
431
+ The implementation issues tokens, calls the external service, caches tokens, and manages their lifetime. The client adds the header and retries requests:
432
+
433
+ ```python
434
+ client = MyCustomClient.from_addr(
435
+ "example.com:443",
436
+ options.with_auth(my_auth),
437
+ )
438
+ ```
439
+
440
+ The client:
441
+
442
+ - adds the `authorization: Bearer <token>` header to every request;
443
+ - requests a new token with `refresh(rejected_token)` after an `UNAUTHENTICATED` response and retries the unary request;
444
+ - leaves authorization unchanged if the caller supplies an `authorization` header.
445
+
446
+ ⚠️ Only unary requests are retried: a request stream is exhausted after the first send, and a response stream's final status is known only after consumption. Disable retries with `options.with_auth(my_auth, retry_on_unauthenticated=False)`.
447
+
448
+ ℹ️ Ready-to-use IAM authorization is provided by [`cloudru-qa-grpc-client-auth`](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client-auth).
449
+
450
+ ### 11. Logging
451
+
452
+ ```python
453
+ import logging
454
+
455
+ client = MyCustomClient.from_addr(
456
+ "example.com:443",
457
+ options.with_logger(
458
+ logging.getLogger("example"),
459
+ sanitize_keys=["user.password"],
460
+ json_indent=2,
461
+ ),
462
+ )
463
+ ```
464
+
465
+ All four call types are logged. For streams, the log includes the final status and message count, but not the messages: consuming them for logging would change call behavior.
466
+
467
+ ℹ️ The asynchronous client counts messages received through `async for` and `collect()`; it provides no other stream reading methods.
468
+
469
+ Secrets are masked:
470
+
471
+ - in metadata, by case-insensitive key name;
472
+ - in message bodies, in two ways depending on the key format:
473
+ - a name without separators (`password`) masks that field at any nesting level, including list items;
474
+ - a path with a dot, star, or brackets (`user.password`, `items.*.token`) is applied as a [glom path](https://glom.readthedocs.io/en/latest/).
475
+
476
+ Field names match across naming styles, so `access_token` also hides `accessToken` (protobuf serializes fields in camelCase), but does not affect fields with different word boundaries such as `sec_ret`.
477
+
478
+ Common keys (`authorization`, `cookie`, `password`, `token`, and others) are always masked in metadata and nested body fields. Disable this with `use_default_sanitize_keys=False`.
479
+
480
+ Set shared logging settings through `configure_logging`; environment variables can override them:
481
+
482
+ ```python
483
+ from grpc_client import configure_logging
484
+
485
+ configure_logging(
486
+ log_level=logging.INFO,
487
+ json_indent=2,
488
+ payload_log_maxlength=4096,
489
+ )
490
+ ```
491
+
492
+ | Environment variable | Controls |
493
+ | --- | --- |
494
+ | `GRPC_CLIENT_LOG_LEVEL` | log level for successful requests |
495
+ | `GRPC_CLIENT_ERR_LOG_LEVEL` | log level for failed requests |
496
+ | `GRPC_CLIENT_JSON_INDENT` | JSON formatting indentation |
497
+ | `GRPC_CLIENT_USE_LINE_BREAKS` | whether log properties appear on separate lines |
498
+ | `GRPC_CLIENT_PAYLOAD_LOG_MAXLENGTH` | maximum message body length in logs |
499
+
500
+ #### Custom log format
501
+
502
+ `with_logger` writes each call as a single string. For another format, such as separate fields in a structured logger, pass a log sink instead of a logger:
503
+
504
+ ```python
505
+ from grpc_client import LogSink, RequestRecord, ResponseRecord, options
506
+
507
+
508
+ class MySink(LogSink):
509
+ def is_enabled_for(self, level: int) -> bool:
510
+ return True
511
+
512
+ def write_request(self, level: int, record: RequestRecord) -> None: ...
513
+
514
+ def write_response(self, level: int, record: ResponseRecord) -> None: ...
515
+
516
+
517
+ client = MyCustomClient.from_addr("example.com:443", options.with_log_sink(MySink()))
518
+ ```
519
+
520
+ `LogSink` is a `Protocol`, so inheritance is optional, but inheriting lets mypy check method signatures.
521
+
522
+ The client builds records and selects the level: `log_level` for successful calls, `err_log_level` for failures. The sink only decides where and how to write records, so it configures its own masking and formatting, for example with `LogFormat.from_config(...)` (imported from `grpc_client`), which uses `configure_logging` values.
523
+
524
+ Read the call outcome from the record fields: `record.succeeded` distinguishes success from failure, and `record.is_stream` distinguishes a completed stream from a unary response. No need to interpret combinations of `status_code`, `message`, and `messages_count` yourself.
525
+
526
+ The client calls `is_enabled_for` before each record, so it must be cheap.
527
+
528
+ The sink must support calls from multiple threads and return quickly. If it raises an exception, the client suppresses it: the call succeeds, but the log record is lost.
529
+
530
+ A ready-to-use structlog implementation is in [`cloudru-qa-grpc-client-structlog`](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client-structlog).
531
+
532
+ ### 12. Asynchronous client
533
+
534
+ `AsyncGRPCClient` provides the synchronous client's interface on top of `grpc.aio`:
535
+
536
+ ```python
537
+ from grpc_client import AsyncGRPCClient, AsyncGRPCStreamResponse, GRPCResponse, options
538
+
539
+ from api import example_pb2, example_pb2_grpc
540
+
541
+
542
+ class MyAsyncClient(AsyncGRPCClient[example_pb2_grpc.ExampleStub]):
543
+ @property
544
+ def stub_class(self) -> type[example_pb2_grpc.ExampleStub]:
545
+ return example_pb2_grpc.ExampleStub
546
+
547
+ async def send_example_request(
548
+ self,
549
+ data: example_pb2.RequestMessage,
550
+ ) -> GRPCResponse[example_pb2.ResponseMessage]:
551
+ return await self.send_unary_unary_request(data, self.stub.ExampleMethod)
552
+
553
+
554
+ async with MyAsyncClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
555
+ response = await client.send_example_request(data)
556
+ ```
557
+
558
+ Differences from the synchronous client:
559
+
560
+ - sending methods must be awaited;
561
+ - streams return `AsyncGRPCStreamResponse`, read with `async for`, with status and metadata exposed as coroutines: `await stream.status_code()`;
562
+ - close with `await client.close()` or `async with`;
563
+ - `grpc.aio` interceptors can only be set when creating a channel, so `from_channel()` with options
564
+ that add interceptors (`with_auth`, `with_logger`, `with_autogenerated_*`, `with_interceptor`)
565
+ raises an error: create the channel yourself with `grpc.aio.insecure_channel(..., interceptors=[...])`
566
+ or create the client with `from_addr()`;
567
+ - authorization requires an asynchronous `AsyncClientAuth` implementation with coroutine `token()` and `refresh()` methods.
568
+
569
+ Both clients share the same options: `options.with_logger(...)` selects the appropriate interceptor automatically.
570
+
571
+ ### 13. Custom interceptors
572
+
573
+ An **interceptor** intercepts gRPC requests. Use it to add request data, collect metrics, or cache values.
574
+
575
+ Write interceptors using the regular grpcio API. To add metadata, inherit from `MetadataInterceptor`, which covers all four call types:
576
+
577
+ ```python
578
+ from grpc_client.interceptors import MetadataInterceptor
579
+ from grpc_client.types import MetadataItems
580
+
581
+
582
+ class TenantInterceptor(MetadataInterceptor):
583
+ """Add a tenant identifier to each request."""
584
+
585
+ def __init__(self, tenant: str):
586
+ self._tenant = tenant
587
+
588
+ def metadata_to_add(self, call_details) -> MetadataItems:
589
+ return (("x-tenant", self._tenant),)
590
+
591
+
592
+ client = MyCustomClient.from_addr(
593
+ "localhost:9000",
594
+ options.with_interceptor(TenantInterceptor("warehouse-42")),
595
+ )
596
+ ```
597
+
598
+ The asynchronous client has `AsyncMetadataInterceptor` with the same contract, but `metadata_to_add` is a coroutine.
599
+
600
+ ⚠️ Do not determine call success with `isinstance(outcome, grpc.RpcError)`: grpcio streaming call objects implement both `grpc.Call` and `grpc.RpcError` even for successful calls. Use the status code instead; the `grpc_client.interceptors.base.outcome_code` helper provides it.
601
+
602
+ ℹ️ Find more examples in the [library tests](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client/tests/).
603
+
604
+ ## Development
605
+
606
+ The library is developed in the [qa-libraries](https://github.com/cloud-ru-tech/qa-libraries) monorepo
607
+ alongside the other `cloudru-qa-*` libraries. You need [`uv`](https://docs.astral.sh/uv/), GNU Make,
608
+ and [`checkmake`](https://github.com/checkmake/checkmake).
609
+ Run commands from the repository root:
610
+
611
+ ```shell
612
+ make sync # install dependencies for all libraries from uv.lock
613
+ make test-grpc-client # tests for this library
614
+ make typecheck-grpc-client # mypy for this library
615
+ make check # formatting, ruff, mypy, and tests for the entire workspace
616
+ ```
617
+
618
+ ⚠️ **Keep** the library lightweight and general-purpose. Discuss new dependencies
619
+ only when you are sure all library users need them.
620
+
621
+ Tests require Docker running: the [grpcbin](https://grpcb.in/) service container is started
622
+ and stopped automatically through [lovely-pytest-docker](https://github.com/lovelysystems/lovely-pytest-docker).
623
+ `make test-grpc-client` regenerates test `pb2.py` files from `proto/` before running;
624
+ run `make gen-test-api` for generation alone.
625
+
626
+ Tests run on Python 3.13 and 3.14. Create a separate environment for another version:
627
+ `make sync test-grpc-client PYTHON_VERSION=3.14`.
628
+
629
+ Publish a release with the git tag `grpc-client/v<version>`. The tag version must match the version
630
+ in `packages/grpc-client/pyproject.toml`, otherwise publication stops.
@@ -0,0 +1,31 @@
1
+ grpc_client/__init__.py,sha256=1rg2WFI4QsF1rR7rniT_hGZ_g3A9wmafqQMNR_ClUho,1712
2
+ grpc_client/async_client.py,sha256=-y2-NPcglAXotj4PRBO9hXe-IjuCjbp9EBSlqkjhdLA,10500
3
+ grpc_client/base_client.py,sha256=WIXU5v1Ld0HOY4kJlkLr1ixJJkCog7eoSChAgQZVPDI,6231
4
+ grpc_client/call_options.py,sha256=GJljb2Hv9UWiFTMCKiEVLnj5orQBgu92xigJ5CldB1Y,2400
5
+ grpc_client/client.py,sha256=I3tJWyQgVe0PM_Xjugog7Yhvj3pGnTqeVXKZn80AVQA,7432
6
+ grpc_client/config.py,sha256=lXjpJUxeqIjqzb7nLSMQMVpTjPVQGaVXp1IZ8S-OAwM,2519
7
+ grpc_client/exceptions.py,sha256=Y3ZZduiUIt5BShtWBHV9mHXmMT7xfw_kfcQMkfQaOy8,2250
8
+ grpc_client/logsink.py,sha256=e4ObyNfXUVCIj_4z3QESnyI0CPDx5f4w4aalSakJS2A,6266
9
+ grpc_client/metadata.py,sha256=5p6NaSKpbLxUVBUn6_vKYki0SoCPaofnAu_a34Q7IRI,3783
10
+ grpc_client/options.py,sha256=KmSdqD1sFsrKdaXclfgp5ATBCb9UqVPvJdw8_2dxUqY,9350
11
+ grpc_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ grpc_client/response.py,sha256=I6yU5FhicN8RzNj4XHkC0aaJJCoVjJAaXfpf1BQ5_bs,18875
13
+ grpc_client/status.py,sha256=X67YQjJMue6EjLUqgqS_-DVrW1te7nuFp7ybOXEejo8,3417
14
+ grpc_client/types.py,sha256=z15jP8t6vMkSlN0sXqPLPojvxDVePkcP73B9_D71wfE,2562
15
+ grpc_client/interceptors/__init__.py,sha256=_uzUVgpy67KJPX8ZEnGbzB6rdKZ07KomdnVGOOnROxc,782
16
+ grpc_client/interceptors/auth.py,sha256=sgHffsOjvLzsHHTTMaNbAiF3SuzL2NjplAuSRzXaDe4,7170
17
+ grpc_client/interceptors/base.py,sha256=I3vIa9B4DbKjl3zdJLJjRWOo-l4KfEQis66_CQwlRa0,15364
18
+ grpc_client/interceptors/logging.py,sha256=wSpXmO2MWS7LD4OCPljhGSAfbhzIO9m1cmS3P6AWiSo,28149
19
+ grpc_client/interceptors/observability.py,sha256=nPFVwWmNLYtKbQrvEhTbTai9YQCAm_Ql2IZKzv7fxfQ,4610
20
+ grpc_client/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ grpc_client/utils/logutils.py,sha256=XMiZHbDa8n0dkFPaqulicBsmOjYQm0DP1re69_fUh5c,10277
22
+ grpc_client/utils/sanitizer.py,sha256=JQ2H8e2zlJuLNTL90FLaxEQUEEWxm7uy2SPUPEmRuFg,7548
23
+ grpc_client/utils/traceparent.py,sha256=JTbVVmYj1BfAXJaf4Q70ipbUU8MPgWe5evwewapb1nA,1024
24
+ grpc_client/.agents/skills/grpc-client-usage/SKILL.md,sha256=OKMnQu6Xh3HUGWK11x0yx3KM3YMv6wvOEieo_fRPJjY,17248
25
+ grpc_client/.agents/skills/grpc-client-usage/references/options.md,sha256=ipneDRO3ScJdBIsWvoNvyq_5AE-7f7l9zU9RA4o7nmI,14842
26
+ grpc_client/.agents/skills/grpc-client-usage/references/pitfalls.md,sha256=42W7SHLS9vAchDz9RgwQDieIEMiJp5Eo6uE56r76-Xw,8431
27
+ grpc_client/.agents/skills/grpc-client-usage/references/responses.md,sha256=MuvbrhlyoiXkn4lIttH9VcqB-aBKWNYeRPf5NPv3_pY,7712
28
+ cloudru_qa_grpc_client-1.0.0.dist-info/METADATA,sha256=eheW3TOwUhF5QxiOcXazxNssawBurYHgxOWLg005MqY,25818
29
+ cloudru_qa_grpc_client-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
30
+ cloudru_qa_grpc_client-1.0.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
31
+ cloudru_qa_grpc_client-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any