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.
- cloudru_qa_grpc_client-1.0.0.dist-info/METADATA +630 -0
- cloudru_qa_grpc_client-1.0.0.dist-info/RECORD +31 -0
- cloudru_qa_grpc_client-1.0.0.dist-info/WHEEL +4 -0
- cloudru_qa_grpc_client-1.0.0.dist-info/licenses/LICENSE +202 -0
- grpc_client/.agents/skills/grpc-client-usage/SKILL.md +344 -0
- grpc_client/.agents/skills/grpc-client-usage/references/options.md +260 -0
- grpc_client/.agents/skills/grpc-client-usage/references/pitfalls.md +105 -0
- grpc_client/.agents/skills/grpc-client-usage/references/responses.md +153 -0
- grpc_client/__init__.py +75 -0
- grpc_client/async_client.py +271 -0
- grpc_client/base_client.py +175 -0
- grpc_client/call_options.py +71 -0
- grpc_client/client.py +203 -0
- grpc_client/config.py +74 -0
- grpc_client/exceptions.py +59 -0
- grpc_client/interceptors/__init__.py +23 -0
- grpc_client/interceptors/auth.py +194 -0
- grpc_client/interceptors/base.py +391 -0
- grpc_client/interceptors/logging.py +765 -0
- grpc_client/interceptors/observability.py +128 -0
- grpc_client/logsink.py +188 -0
- grpc_client/metadata.py +110 -0
- grpc_client/options.py +282 -0
- grpc_client/py.typed +0 -0
- grpc_client/response.py +527 -0
- grpc_client/status.py +105 -0
- grpc_client/types.py +77 -0
- grpc_client/utils/__init__.py +0 -0
- grpc_client/utils/logutils.py +299 -0
- grpc_client/utils/sanitizer.py +206 -0
- grpc_client/utils/traceparent.py +36 -0
|
@@ -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
|
+
[](https://pypi.org/project/cloudru-qa-grpc-client/)
|
|
32
|
+
[](https://pypi.org/project/cloudru-qa-grpc-client/)
|
|
33
|
+
[](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,,
|