grpc-client-kit 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/CHANGELOG.md +14 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/PKG-INFO +20 -6
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/README.md +16 -5
- grpc_client_kit-0.2.0/grpc_client_kit/__version__.py +1 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/factory.py +27 -6
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/base.py +187 -65
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/tracing.py +9 -5
- grpc_client_kit-0.2.0/grpc_client_kit/settings.py +418 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/pyproject.toml +5 -1
- grpc_client_kit-0.1.1/grpc_client_kit/__version__.py +0 -1
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/.gitignore +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/LICENSE +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/__init__.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/balancers.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/channel.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/client.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/config.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/deadline.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/errors.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/health.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/__init__.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/circuit_breaker.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/client_logging.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/context.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/deadline.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/metrics.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/outlier.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/retry.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/timeout.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/wait_for_ready.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/protocols.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/py.typed +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/utils.py +0 -0
- {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/validation.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.1.2...grpc-client-kit-v0.2.0) (2026-09-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* settings models for the environment, mirroring the dataclasses ([edffb5f](https://github.com/bedrock-python/grpc-client-kit/commit/edffb5fdcf934cecd5788a99e37064133383d9cd))
|
|
9
|
+
|
|
10
|
+
## [0.1.2](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.1.1...grpc-client-kit-v0.1.2) (2026-09-07)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* an around_call teardown cannot change a call's outcome, and shares its setup's context ([#20](https://github.com/bedrock-python/grpc-client-kit/issues/20)) ([27b5c83](https://github.com/bedrock-python/grpc-client-kit/commit/27b5c8348e7430169d86059cbde36218b206d0f1))
|
|
16
|
+
|
|
3
17
|
## [0.1.1](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.1.0...grpc-client-kit-v0.1.1) (2026-09-06)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: grpc-client-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Batteries-optional async gRPC client toolkit (channel pool, load balancing, health checking, streaming-aware interceptors, observability)
|
|
5
5
|
Project-URL: Repository, https://github.com/bedrock-python/grpc-client-kit
|
|
6
6
|
Project-URL: Documentation, https://bedrock-python.github.io/grpc-client-kit/
|
|
@@ -226,6 +226,7 @@ Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'all'
|
|
|
226
226
|
Requires-Dist: grpcio-health-checking<2.0.0,>=1.78.0; extra == 'all'
|
|
227
227
|
Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'all'
|
|
228
228
|
Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'all'
|
|
229
|
+
Requires-Dist: pydantic<3.0,>=2.0.0; extra == 'all'
|
|
229
230
|
Provides-Extra: deadline
|
|
230
231
|
Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'deadline'
|
|
231
232
|
Provides-Extra: health
|
|
@@ -235,6 +236,8 @@ Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'metrics'
|
|
|
235
236
|
Provides-Extra: observability
|
|
236
237
|
Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'observability'
|
|
237
238
|
Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'observability'
|
|
239
|
+
Provides-Extra: settings
|
|
240
|
+
Requires-Dist: pydantic<3.0,>=2.0.0; extra == 'settings'
|
|
238
241
|
Provides-Extra: tracing
|
|
239
242
|
Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'tracing'
|
|
240
243
|
Description-Content-Type: text/markdown
|
|
@@ -307,6 +310,7 @@ pip install "grpc-client-kit[health]" # + grpc.health.v1 monitoring
|
|
|
307
310
|
pip install "grpc-client-kit[metrics,tracing]" # + Prometheus + OpenTelemetry
|
|
308
311
|
pip install "grpc-client-kit[observability]" # + metrics and tracing together
|
|
309
312
|
pip install "grpc-client-kit[deadline]" # + request budget propagation
|
|
313
|
+
pip install "grpc-client-kit[settings]" # + pydantic settings models
|
|
310
314
|
pip install "grpc-client-kit[all]" # everything
|
|
311
315
|
```
|
|
312
316
|
|
|
@@ -362,7 +366,11 @@ Any object carrying the fields of `GrpcClientSettingsProtocol` works —
|
|
|
362
366
|
a pydantic model, a dataclass, a plain class. Everything optional
|
|
363
367
|
(`credentials`, `options`, `compression`, `metrics_registry`,
|
|
364
368
|
`sensitive_headers`) is read through `getattr`, so a minimal settings object
|
|
365
|
-
stays valid.
|
|
369
|
+
stays valid. Or take the shipped one: `grpc_client_kit.settings.BaseGrpcClientSettings`
|
|
370
|
+
(the `[settings]` extra) is that shape written down once — plain pydantic
|
|
371
|
+
models with the kit's own defaults and bounds, one per upstream, nested under
|
|
372
|
+
your service's `BaseSettings` for loading from the environment, and
|
|
373
|
+
`to_config()` on every section for a hand-built chain. See the
|
|
366
374
|
[configuration guide](https://bedrock-python.github.io/grpc-client-kit/guide/configuration/)
|
|
367
375
|
for the full field list.
|
|
368
376
|
|
|
@@ -463,9 +471,13 @@ class FailureCounter(AsyncAroundClientInterceptor):
|
|
|
463
471
|
|
|
464
472
|
Code before the `yield` runs before the RPC exists, so that is where call
|
|
465
473
|
details are rewritten and where raising refuses a call outright. Code after it
|
|
466
|
-
runs once the call is over, whichever of the four kinds it was
|
|
467
|
-
|
|
468
|
-
|
|
474
|
+
runs once the call is over, whichever of the four kinds it was, and cannot
|
|
475
|
+
change how it ended: what the teardown raises is logged and dropped. Both sides
|
|
476
|
+
of the `yield` run in one `contextvars.Context`, so `token = VAR.set(...)`
|
|
477
|
+
before it and `VAR.reset(token)` after it is a supported way to scope a request
|
|
478
|
+
id — or an OpenTelemetry context — to a single call. Layers that re-issue a
|
|
479
|
+
call — retries — subclass `AsyncClientInterceptor` and issue it themselves; see
|
|
480
|
+
the
|
|
469
481
|
[advanced guide](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/).
|
|
470
482
|
|
|
471
483
|
**`INTERNAL` is not retryable by default.** `DEFAULT_RETRYABLE_CODES` holds
|
|
@@ -507,6 +519,7 @@ names the extra to install.
|
|
|
507
519
|
| `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
|
|
508
520
|
| `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
|
|
509
521
|
| `factory.GrpcClientFactory` | Settings → pool, balancer, health checker, per-target chains | core |
|
|
522
|
+
| `settings.BaseGrpcClientSettings` | The settings shape as pydantic models mirroring the dataclasses, `to_config()` on each | `settings` |
|
|
510
523
|
| `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
|
|
511
524
|
| `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
|
|
512
525
|
| `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
|
|
@@ -526,8 +539,9 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
|
|
|
526
539
|
| `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
|
|
527
540
|
| `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
|
|
528
541
|
| `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
|
|
542
|
+
| `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
|
|
529
543
|
| `observability` | `metrics` + `tracing` | both of the above |
|
|
530
|
-
| `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
|
|
544
|
+
| `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
|
|
531
545
|
|
|
532
546
|
`deadline` is deliberately not part of `observability`: propagating a budget is
|
|
533
547
|
resilience, not telemetry, and an observability extra should not pull in a
|
|
@@ -66,6 +66,7 @@ pip install "grpc-client-kit[health]" # + grpc.health.v1 monitoring
|
|
|
66
66
|
pip install "grpc-client-kit[metrics,tracing]" # + Prometheus + OpenTelemetry
|
|
67
67
|
pip install "grpc-client-kit[observability]" # + metrics and tracing together
|
|
68
68
|
pip install "grpc-client-kit[deadline]" # + request budget propagation
|
|
69
|
+
pip install "grpc-client-kit[settings]" # + pydantic settings models
|
|
69
70
|
pip install "grpc-client-kit[all]" # everything
|
|
70
71
|
```
|
|
71
72
|
|
|
@@ -121,7 +122,11 @@ Any object carrying the fields of `GrpcClientSettingsProtocol` works —
|
|
|
121
122
|
a pydantic model, a dataclass, a plain class. Everything optional
|
|
122
123
|
(`credentials`, `options`, `compression`, `metrics_registry`,
|
|
123
124
|
`sensitive_headers`) is read through `getattr`, so a minimal settings object
|
|
124
|
-
stays valid.
|
|
125
|
+
stays valid. Or take the shipped one: `grpc_client_kit.settings.BaseGrpcClientSettings`
|
|
126
|
+
(the `[settings]` extra) is that shape written down once — plain pydantic
|
|
127
|
+
models with the kit's own defaults and bounds, one per upstream, nested under
|
|
128
|
+
your service's `BaseSettings` for loading from the environment, and
|
|
129
|
+
`to_config()` on every section for a hand-built chain. See the
|
|
125
130
|
[configuration guide](https://bedrock-python.github.io/grpc-client-kit/guide/configuration/)
|
|
126
131
|
for the full field list.
|
|
127
132
|
|
|
@@ -222,9 +227,13 @@ class FailureCounter(AsyncAroundClientInterceptor):
|
|
|
222
227
|
|
|
223
228
|
Code before the `yield` runs before the RPC exists, so that is where call
|
|
224
229
|
details are rewritten and where raising refuses a call outright. Code after it
|
|
225
|
-
runs once the call is over, whichever of the four kinds it was
|
|
226
|
-
|
|
227
|
-
|
|
230
|
+
runs once the call is over, whichever of the four kinds it was, and cannot
|
|
231
|
+
change how it ended: what the teardown raises is logged and dropped. Both sides
|
|
232
|
+
of the `yield` run in one `contextvars.Context`, so `token = VAR.set(...)`
|
|
233
|
+
before it and `VAR.reset(token)` after it is a supported way to scope a request
|
|
234
|
+
id — or an OpenTelemetry context — to a single call. Layers that re-issue a
|
|
235
|
+
call — retries — subclass `AsyncClientInterceptor` and issue it themselves; see
|
|
236
|
+
the
|
|
228
237
|
[advanced guide](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/).
|
|
229
238
|
|
|
230
239
|
**`INTERNAL` is not retryable by default.** `DEFAULT_RETRYABLE_CODES` holds
|
|
@@ -266,6 +275,7 @@ names the extra to install.
|
|
|
266
275
|
| `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
|
|
267
276
|
| `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
|
|
268
277
|
| `factory.GrpcClientFactory` | Settings → pool, balancer, health checker, per-target chains | core |
|
|
278
|
+
| `settings.BaseGrpcClientSettings` | The settings shape as pydantic models mirroring the dataclasses, `to_config()` on each | `settings` |
|
|
269
279
|
| `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
|
|
270
280
|
| `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
|
|
271
281
|
| `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
|
|
@@ -285,8 +295,9 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
|
|
|
285
295
|
| `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
|
|
286
296
|
| `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
|
|
287
297
|
| `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
|
|
298
|
+
| `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
|
|
288
299
|
| `observability` | `metrics` + `tracing` | both of the above |
|
|
289
|
-
| `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
|
|
300
|
+
| `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
|
|
290
301
|
|
|
291
302
|
`deadline` is deliberately not part of `observability`: propagating a budget is
|
|
292
303
|
resilience, not telemetry, and an observability extra should not pull in a
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0" # x-release-please-version
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from dataclasses import fields
|
|
4
5
|
from typing import TYPE_CHECKING, Any
|
|
5
6
|
|
|
6
7
|
import grpc.aio
|
|
@@ -8,7 +9,7 @@ import grpc.aio
|
|
|
8
9
|
from .balancers import LoadBalancer, LoadBalancerConfig, LoadBalancingStrategy, create_balancer
|
|
9
10
|
from .channel import ChannelPool
|
|
10
11
|
from .client import GrpcClient
|
|
11
|
-
from .config import GrpcClientConfig
|
|
12
|
+
from .config import ConnectivityConfig, GrpcClientConfig
|
|
12
13
|
from .interceptors import (
|
|
13
14
|
CircuitBreakerConfig,
|
|
14
15
|
DeadlineBudgetConfig,
|
|
@@ -35,6 +36,10 @@ logger = logging.getLogger(__name__)
|
|
|
35
36
|
|
|
36
37
|
_MISSING_MESSAGE = "Install grpc-client-kit[health] (grpcio-health-checking) to use gRPC health checking"
|
|
37
38
|
|
|
39
|
+
# What a duck-typed `connectivity` block may carry: the dataclass's own fields, so a knob added there
|
|
40
|
+
# is read from settings without this module having to hear about it.
|
|
41
|
+
_CONNECTIVITY_FIELDS = tuple(field.name for field in fields(ConnectivityConfig))
|
|
42
|
+
|
|
38
43
|
|
|
39
44
|
def _validate_settings(settings: object | None) -> None:
|
|
40
45
|
"""Refuse a settings object that cannot satisfy the factory, while the mistake is visible.
|
|
@@ -306,6 +311,10 @@ class GrpcClientFactory:
|
|
|
306
311
|
metrics_registry=metrics_registry,
|
|
307
312
|
service_name=actual_service_name,
|
|
308
313
|
sensitive_headers=getattr(self._settings, "sensitive_headers", None),
|
|
314
|
+
sensitive_methods=getattr(self._settings, "sensitive_methods", None),
|
|
315
|
+
sensitive_patterns=getattr(self._settings, "sensitive_patterns", None),
|
|
316
|
+
log_request_payload=getattr(self._settings, "log_request_payload", False),
|
|
317
|
+
log_response_payload=getattr(self._settings, "log_response_payload", False),
|
|
309
318
|
enable_method_label=getattr(self._settings, "enable_method_label", True),
|
|
310
319
|
success_log_level=getattr(self._settings, "success_log_level", logging.INFO),
|
|
311
320
|
)
|
|
@@ -371,8 +380,8 @@ class GrpcClientFactory:
|
|
|
371
380
|
Channel construction settings are optional blocks (see `protocols.GrpcChannelExtrasProtocol`)
|
|
372
381
|
and are read with `getattr`, so a settings model that declares none of them still satisfies
|
|
373
382
|
the factory. ``connectivity`` joins them on the same terms: a settings object that carries a
|
|
374
|
-
`config.ConnectivityConfig`
|
|
375
|
-
not is left with gRPC's defaults.
|
|
383
|
+
`config.ConnectivityConfig` — or a block with its fields — under that name has its channels
|
|
384
|
+
tuned by it, and one that does not is left with gRPC's defaults.
|
|
376
385
|
"""
|
|
377
386
|
if self._settings:
|
|
378
387
|
actual_target = target or self._settings.target
|
|
@@ -380,7 +389,7 @@ class GrpcClientFactory:
|
|
|
380
389
|
credentials = getattr(self._settings, "credentials", None)
|
|
381
390
|
options = getattr(self._settings, "options", None)
|
|
382
391
|
compression = getattr(self._settings, "compression", None)
|
|
383
|
-
connectivity = getattr(self._settings, "connectivity", None)
|
|
392
|
+
connectivity = self._build_connectivity_config(getattr(self._settings, "connectivity", None))
|
|
384
393
|
else:
|
|
385
394
|
actual_target = target
|
|
386
395
|
insecure = False
|
|
@@ -422,11 +431,23 @@ class GrpcClientFactory:
|
|
|
422
431
|
health_checker=self._health_checker,
|
|
423
432
|
)
|
|
424
433
|
|
|
434
|
+
def _build_connectivity_config(self, s: Any | None) -> ConnectivityConfig | None:
|
|
435
|
+
"""Build ConnectivityConfig from an optional settings block (duck-typed).
|
|
436
|
+
|
|
437
|
+
A ready `config.ConnectivityConfig` is taken as it is. Any other block is read field by
|
|
438
|
+
field, and a field it does not carry keeps the dataclass's own default.
|
|
439
|
+
"""
|
|
440
|
+
if not s:
|
|
441
|
+
return None
|
|
442
|
+
if isinstance(s, ConnectivityConfig):
|
|
443
|
+
return s
|
|
444
|
+
return ConnectivityConfig(**{name: getattr(s, name) for name in _CONNECTIVITY_FIELDS if hasattr(s, name)})
|
|
445
|
+
|
|
425
446
|
def _build_timeout_config(self, s: TimeoutSettingsProtocol | None) -> TimeoutConfig | None:
|
|
426
|
-
"""Build TimeoutConfig from settings protocol."""
|
|
447
|
+
"""Build TimeoutConfig from settings protocol; per-method budgets are read when the block has them."""
|
|
427
448
|
if not s:
|
|
428
449
|
return None
|
|
429
|
-
return TimeoutConfig(default=s.default)
|
|
450
|
+
return TimeoutConfig(default=s.default, per_method=dict(getattr(s, "per_method", None) or {}))
|
|
430
451
|
|
|
431
452
|
def _build_retry_config(
|
|
432
453
|
self, s: RetrySettingsProtocol | None, metrics: GrpcClientMetricsProtocol | None = None
|
|
@@ -33,6 +33,15 @@ Writing one::
|
|
|
33
33
|
|
|
34
34
|
That one generator covers all four RPC kinds: for a streaming response the base class holds it open
|
|
35
35
|
until the last item has been delivered, so the ``except`` branch sees a mid-stream failure too.
|
|
36
|
+
|
|
37
|
+
Two guarantees the base class gives it, both of them measured rather than assumed. Whatever the
|
|
38
|
+
generator raises *after* the ``yield`` is logged and dropped: an interceptor is observability, and a
|
|
39
|
+
metrics push or a log write that fails must not turn a call the server answered ``OK`` into an
|
|
40
|
+
exception for the application — nor vanish into the event loop's exception handler, which is where
|
|
41
|
+
it used to go for a streaming request. And the code before the ``yield`` and the code after it run
|
|
42
|
+
in one `contextvars.Context`, so a token minted in the setup can be reset in the teardown, on every
|
|
43
|
+
one of the four kinds and not only on the one whose teardown happens to run in the task that started
|
|
44
|
+
the call. `_AroundScope` is where both live.
|
|
36
45
|
"""
|
|
37
46
|
|
|
38
47
|
from __future__ import annotations
|
|
@@ -40,24 +49,41 @@ from __future__ import annotations
|
|
|
40
49
|
import abc
|
|
41
50
|
import asyncio
|
|
42
51
|
import contextlib
|
|
52
|
+
import contextvars
|
|
43
53
|
import functools
|
|
44
|
-
|
|
54
|
+
import logging
|
|
55
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterable
|
|
45
56
|
from dataclasses import dataclass, field
|
|
46
57
|
from typing import Any, Literal
|
|
47
58
|
|
|
48
59
|
import grpc.aio
|
|
49
60
|
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
50
63
|
# Outcome observers for deferred calls; referenced here so the loop cannot collect them mid-flight.
|
|
51
64
|
_BACKGROUND_TASKS: set[asyncio.Task[None]] = set()
|
|
52
65
|
|
|
53
66
|
|
|
54
|
-
def _spawn_background(coro:
|
|
55
|
-
"""Run an outcome observer as a task the event loop cannot garbage-collect early.
|
|
56
|
-
|
|
67
|
+
def _spawn_background(coro: Coroutine[Any, Any, None], context: contextvars.Context | None = None) -> None:
|
|
68
|
+
"""Run an outcome observer as a task the event loop cannot garbage-collect early.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
coro: The observer to run.
|
|
72
|
+
context: The context to step it in. `None` copies the current one, which is what a task
|
|
73
|
+
does by default; an around scope hands over the context its setup ran in, so that the
|
|
74
|
+
teardown it eventually reaches is allowed to reset what the setup set.
|
|
75
|
+
"""
|
|
76
|
+
task = asyncio.get_running_loop().create_task(coro, context=context)
|
|
57
77
|
_BACKGROUND_TASKS.add(task)
|
|
58
78
|
task.add_done_callback(_BACKGROUND_TASKS.discard)
|
|
59
79
|
|
|
60
80
|
|
|
81
|
+
def _running_context() -> contextvars.Context | None:
|
|
82
|
+
"""The context the task running right now was started in, or None outside a task."""
|
|
83
|
+
task = asyncio.current_task()
|
|
84
|
+
return task.get_context() if task is not None else None
|
|
85
|
+
|
|
86
|
+
|
|
61
87
|
type RpcType = Literal["unary_unary", "unary_stream", "stream_unary", "stream_stream"]
|
|
62
88
|
|
|
63
89
|
# What a logical interceptor is handed as a continuation: ``(call_details, request) -> Call``.
|
|
@@ -236,6 +262,16 @@ class AsyncAroundClientInterceptor(AsyncClientInterceptor, abc.ABC):
|
|
|
236
262
|
ordinary ``try/except`` around it covers the whole call. Refusing a call is a matter of raising
|
|
237
263
|
before the ``yield``: the RPC is then never created. Swallowing an exception is not supported —
|
|
238
264
|
the call has already failed by then, and there is no response to return in its place.
|
|
265
|
+
|
|
266
|
+
Raising *after* the ``yield`` is not a way to fail a call either. The outcome is decided by
|
|
267
|
+
then, so whatever the teardown raises is logged at ERROR against the method and dropped —
|
|
268
|
+
identically for all four RPC kinds, and whether the call had succeeded or failed.
|
|
269
|
+
|
|
270
|
+
The two sides of the ``yield`` share one `contextvars.Context`. The pair that scopes something
|
|
271
|
+
for the length of a call is therefore legal here on every kind — ``token = VAR.set(...)`` before
|
|
272
|
+
the yield and ``VAR.reset(token)`` after it, or OpenTelemetry's ``attach`` and ``detach`` — and
|
|
273
|
+
what the setup sets is visible to the layers below. `_AroundScope` is where that is arranged,
|
|
274
|
+
and what it costs.
|
|
239
275
|
"""
|
|
240
276
|
|
|
241
277
|
@abc.abstractmethod
|
|
@@ -249,113 +285,199 @@ class AsyncAroundClientInterceptor(AsyncClientInterceptor, abc.ABC):
|
|
|
249
285
|
|
|
250
286
|
async def intercept(self, call: ClientCall) -> Any:
|
|
251
287
|
"""Issue the call inside `around_call` (base plumbing; subclasses override the generator)."""
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if
|
|
288
|
+
deferred_teardown = call.request_streaming or call.response_streaming
|
|
289
|
+
scope = _AroundScope(self._around(call), call.method, pin_context=deferred_teardown)
|
|
290
|
+
|
|
291
|
+
if not deferred_teardown:
|
|
292
|
+
# Setup, call and teardown are this one coroutine, so the generator already runs start
|
|
293
|
+
# to finish in a single context and there is nothing to pin. The kind that dominates
|
|
294
|
+
# every chain pays no task for a guarantee it satisfies for free.
|
|
295
|
+
await scope.enter()
|
|
256
296
|
try:
|
|
257
|
-
|
|
297
|
+
response = await call.invoke_unary()
|
|
258
298
|
except BaseException as error:
|
|
259
|
-
await
|
|
299
|
+
await scope.close(error)
|
|
260
300
|
raise
|
|
301
|
+
await scope.close(None)
|
|
302
|
+
return response
|
|
303
|
+
|
|
304
|
+
# The RPC is created inside the scope's context, not only the setup: what the setup sets
|
|
305
|
+
# has to be visible to the layers below, or scoping a value for the length of a call would
|
|
306
|
+
# be legal and pointless at the same time.
|
|
307
|
+
started = await scope.in_context(_open_call(scope, call))
|
|
308
|
+
|
|
309
|
+
if call.response_streaming:
|
|
261
310
|
# The teardown has to outlive this method: the call is not over until the last item is.
|
|
262
311
|
# It must also not depend on the consumer finishing the iteration — a cancelled or
|
|
263
312
|
# abandoned call fires its done callback, and that closes the teardown deterministically
|
|
264
313
|
# instead of waiting for garbage collection.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return _closing_stream(finalizer, stream)
|
|
314
|
+
_finalize_when_done(call.underlying_call, scope)
|
|
315
|
+
return _closing_stream(scope, started)
|
|
268
316
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
317
|
+
# The outcome of a streaming-request call arrives after this task has returned (see
|
|
318
|
+
# `invoke_unary`), so the teardown runs from an observer instead of from here.
|
|
319
|
+
_spawn_background(_finalize_unary_outcome(call, started, scope), context=scope.context)
|
|
320
|
+
return started
|
|
280
321
|
|
|
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
322
|
|
|
287
|
-
|
|
288
|
-
|
|
323
|
+
async def _open_call(scope: _AroundScope, call: ClientCall) -> Any:
|
|
324
|
+
"""Run the setup and issue the RPC, both inside the scope's context.
|
|
289
325
|
|
|
326
|
+
Args:
|
|
327
|
+
scope: The around scope wrapping this call.
|
|
328
|
+
call: The call to issue.
|
|
290
329
|
|
|
291
|
-
|
|
292
|
-
|
|
330
|
+
Returns:
|
|
331
|
+
The response iterator of a streaming response, or the `Call` of a streaming request.
|
|
293
332
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
333
|
+
Raises:
|
|
334
|
+
BaseException: Whatever the setup raised — refusing the call, with no teardown, which is
|
|
335
|
+
what raising before the ``yield`` has always meant — or whatever issuing the call
|
|
336
|
+
raised, after the teardown has been given it.
|
|
297
337
|
"""
|
|
338
|
+
await scope.enter()
|
|
339
|
+
try:
|
|
340
|
+
if call.response_streaming:
|
|
341
|
+
return await call.invoke_stream()
|
|
342
|
+
return await call.invoke_unary()
|
|
343
|
+
except BaseException as error:
|
|
344
|
+
await scope.close(error)
|
|
345
|
+
raise
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class _AroundScope:
|
|
349
|
+
"""One `around_call` generator: closed exactly once, in the context its setup ran in.
|
|
350
|
+
|
|
351
|
+
Two things a bare pair of ``__aenter__`` / ``__aexit__`` calls does not give the seam, both
|
|
352
|
+
measured against a live server rather than inferred.
|
|
353
|
+
|
|
354
|
+
**The two sides of the ``yield`` have to share a context.** For three of the four RPC kinds the
|
|
355
|
+
teardown is stepped from a different task than the setup — whichever one drains the response
|
|
356
|
+
stream, or the observer that awaits a deferred outcome — and `contextvars` compares contexts by
|
|
357
|
+
identity, so a token minted before the ``yield`` cannot be reset after it. `asyncio.Task` takes
|
|
358
|
+
a ``context=`` and *enters* that object instead of copying it, so one context of this scope's
|
|
359
|
+
own, entered by both sides, is what makes the set/reset pair legal on every kind. Unary-unary
|
|
360
|
+
pins nothing: its setup, call and teardown are already one coroutine, and a task per call per
|
|
361
|
+
layer is a real cost on the busiest path in the kit for a guarantee already held there.
|
|
362
|
+
|
|
363
|
+
**A failing teardown is not an outcome.** An interceptor is observability. Something that fails
|
|
364
|
+
after the call has finished must not replace the response the server sent, must not truncate a
|
|
365
|
+
stream, and must not vanish into the event loop's exception handler either — which were the
|
|
366
|
+
three different fates one raising teardown had, depending only on the kind of call it wrapped.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
__slots__ = ("_around", "_closed", "_context", "_method")
|
|
298
370
|
|
|
299
|
-
|
|
371
|
+
def __init__(
|
|
372
|
+
self,
|
|
373
|
+
around: contextlib.AbstractAsyncContextManager[None],
|
|
374
|
+
method: str,
|
|
375
|
+
*,
|
|
376
|
+
pin_context: bool,
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Wrap one generator, pinning a context for it when its teardown will run elsewhere.
|
|
300
379
|
|
|
301
|
-
|
|
380
|
+
Args:
|
|
381
|
+
around: The `around_call` generator, already wrapped as an async context manager.
|
|
382
|
+
method: Full method name of the call, for the log record a failing teardown produces.
|
|
383
|
+
pin_context: Whether to give the generator a context of its own, which every kind but
|
|
384
|
+
unary-unary needs.
|
|
385
|
+
"""
|
|
302
386
|
self._around = around
|
|
387
|
+
self._method = method
|
|
388
|
+
self._context = contextvars.copy_context() if pin_context else None
|
|
303
389
|
self._closed = False
|
|
304
390
|
|
|
305
391
|
@property
|
|
306
392
|
def closed(self) -> bool:
|
|
307
|
-
"""Whether the
|
|
393
|
+
"""Whether the generator has already been closed."""
|
|
308
394
|
return self._closed
|
|
309
395
|
|
|
396
|
+
@property
|
|
397
|
+
def context(self) -> contextvars.Context | None:
|
|
398
|
+
"""The pinned context, or None when the generator runs wherever it is stepped from."""
|
|
399
|
+
return self._context
|
|
400
|
+
|
|
401
|
+
def in_context[T](self, coro: Coroutine[Any, Any, T]) -> asyncio.Task[T]:
|
|
402
|
+
"""Step one coroutine in the pinned context, as a task of its own."""
|
|
403
|
+
return asyncio.get_running_loop().create_task(coro, context=self._context)
|
|
404
|
+
|
|
405
|
+
async def enter(self) -> None:
|
|
406
|
+
"""Run the setup — the code before the ``yield``. Raising here refuses the call."""
|
|
407
|
+
await self._around.__aenter__()
|
|
408
|
+
|
|
310
409
|
async def close(self, error: BaseException | None) -> None:
|
|
311
|
-
"""Close the
|
|
410
|
+
"""Close the generator with the call's outcome, once, in the context the setup ran in.
|
|
411
|
+
|
|
412
|
+
The iterator path and the call's done callback both race to report the outcome; whichever
|
|
413
|
+
arrives second must find the work already done rather than throw a second exception into a
|
|
414
|
+
generator that has already stopped.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
error: How the call ended, or None if it succeeded.
|
|
418
|
+
"""
|
|
312
419
|
if self._closed:
|
|
313
420
|
return
|
|
314
421
|
self._closed = True
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
await self.
|
|
422
|
+
# Already the right context on the unary-unary path, which pins none, and on every caller
|
|
423
|
+
# that the scope itself started — the teardown then costs no second task.
|
|
424
|
+
if self._context is None or self._context is _running_context():
|
|
425
|
+
await self._teardown(error)
|
|
426
|
+
return
|
|
427
|
+
await self.in_context(self._teardown(error))
|
|
428
|
+
|
|
429
|
+
async def _teardown(self, error: BaseException | None) -> None:
|
|
430
|
+
"""Run the code after the ``yield`` and let nothing it raises reach the call."""
|
|
431
|
+
try:
|
|
432
|
+
if error is None:
|
|
433
|
+
await self._around.__aexit__(None, None, None)
|
|
434
|
+
else:
|
|
435
|
+
await self._around.__aexit__(type(error), error, error.__traceback__)
|
|
436
|
+
except Exception:
|
|
437
|
+
# Dropped deliberately, and logged so that it is not lost: see the class docstring.
|
|
438
|
+
# Cancellation is not caught — a task torn down around the teardown is not the teardown
|
|
439
|
+
# failing, and swallowing it would strand a cancel that the caller asked for.
|
|
440
|
+
logger.exception("around_call teardown failed for %s", self._method)
|
|
319
441
|
|
|
320
442
|
|
|
321
|
-
async def _finalize_unary_outcome(call: ClientCall, deferred: Any,
|
|
322
|
-
"""Observe a deferred unary outcome and close the around
|
|
443
|
+
async def _finalize_unary_outcome(call: ClientCall, deferred: Any, scope: _AroundScope) -> None:
|
|
444
|
+
"""Observe a deferred unary outcome and close the around scope with it."""
|
|
323
445
|
try:
|
|
324
446
|
response = await deferred if hasattr(deferred, "__await__") else deferred
|
|
325
447
|
except BaseException as error:
|
|
326
|
-
await
|
|
448
|
+
await scope.close(error)
|
|
327
449
|
else:
|
|
328
450
|
call._response = response
|
|
329
|
-
await
|
|
451
|
+
await scope.close(None)
|
|
330
452
|
|
|
331
453
|
|
|
332
|
-
def _finalize_when_done(underlying: Any,
|
|
333
|
-
"""Arrange for the around
|
|
454
|
+
def _finalize_when_done(underlying: Any, scope: _AroundScope) -> None:
|
|
455
|
+
"""Arrange for the around scope to close when the call finishes, however it finishes."""
|
|
334
456
|
add_done_callback = getattr(underlying, "add_done_callback", None)
|
|
335
457
|
if add_done_callback is None:
|
|
336
458
|
return
|
|
337
459
|
|
|
338
460
|
def _on_done(done_call: Any) -> None:
|
|
339
|
-
if not
|
|
340
|
-
_spawn_background(_finalize_stream_outcome(done_call,
|
|
461
|
+
if not scope.closed:
|
|
462
|
+
_spawn_background(_finalize_stream_outcome(done_call, scope), context=scope.context)
|
|
341
463
|
|
|
342
464
|
add_done_callback(_on_done)
|
|
343
465
|
|
|
344
466
|
|
|
345
|
-
async def _finalize_stream_outcome(done_call: Any,
|
|
346
|
-
"""Close the around
|
|
347
|
-
if
|
|
467
|
+
async def _finalize_stream_outcome(done_call: Any, scope: _AroundScope) -> None:
|
|
468
|
+
"""Close the around scope with the status of an already finished streaming call."""
|
|
469
|
+
if scope.closed:
|
|
348
470
|
return
|
|
349
471
|
try:
|
|
350
472
|
code = await done_call.code()
|
|
351
473
|
except BaseException as error:
|
|
352
|
-
await
|
|
474
|
+
await scope.close(error)
|
|
353
475
|
return
|
|
354
476
|
|
|
355
477
|
if code == grpc.StatusCode.OK:
|
|
356
|
-
await
|
|
478
|
+
await scope.close(None)
|
|
357
479
|
elif done_call.cancelled():
|
|
358
|
-
await
|
|
480
|
+
await scope.close(asyncio.CancelledError())
|
|
359
481
|
else:
|
|
360
482
|
failure = grpc.aio.AioRpcError(
|
|
361
483
|
code,
|
|
@@ -363,14 +485,14 @@ async def _finalize_stream_outcome(done_call: Any, finalizer: _AroundFinalizer)
|
|
|
363
485
|
await done_call.trailing_metadata(),
|
|
364
486
|
await done_call.details(),
|
|
365
487
|
)
|
|
366
|
-
await
|
|
488
|
+
await scope.close(failure)
|
|
367
489
|
|
|
368
490
|
|
|
369
491
|
async def _closing_stream(
|
|
370
|
-
|
|
492
|
+
scope: _AroundScope,
|
|
371
493
|
stream: AsyncIterator[Any],
|
|
372
494
|
) -> AsyncIterator[Any]:
|
|
373
|
-
"""Yield a whole response stream, then close the around
|
|
495
|
+
"""Yield a whole response stream, then close the around scope with however it ended."""
|
|
374
496
|
try:
|
|
375
497
|
async for item in stream:
|
|
376
498
|
yield item
|
|
@@ -379,14 +501,14 @@ async def _closing_stream(
|
|
|
379
501
|
# view the call did not finish; closing with a cancellation says exactly that, while
|
|
380
502
|
# throwing GeneratorExit into the around generators would only produce "generator didn't
|
|
381
503
|
# stop after athrow" noise. The done callback of the underlying call, when there is one,
|
|
382
|
-
# may report the real outcome first — the
|
|
383
|
-
await
|
|
504
|
+
# may report the real outcome first — the scope keeps whichever arrived first.
|
|
505
|
+
await scope.close(asyncio.CancelledError())
|
|
384
506
|
raise
|
|
385
507
|
except BaseException as error:
|
|
386
|
-
await
|
|
508
|
+
await scope.close(error)
|
|
387
509
|
raise
|
|
388
510
|
|
|
389
|
-
await
|
|
511
|
+
await scope.close(None)
|
|
390
512
|
|
|
391
513
|
|
|
392
514
|
class _Adapter:
|
|
@@ -113,11 +113,15 @@ class AsyncClientTracingInterceptor(AsyncClientInterceptor):
|
|
|
113
113
|
|
|
114
114
|
Note:
|
|
115
115
|
This layer implements `base.AsyncClientInterceptor.intercept` rather than the simpler
|
|
116
|
-
``around_call`` seam
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
``around_call`` seam. It once had no choice: the teardown of an ``around_call`` resumed in
|
|
117
|
+
whichever task drained the stream, and detaching the OpenTelemetry context there failed
|
|
118
|
+
("Failed to detach context"), leaving the client span current in the task that started the
|
|
119
|
+
call. `base._AroundScope` steps both sides of the ``yield`` in one context now, so an
|
|
120
|
+
``attach`` / ``detach`` pair across it is supported and that reason is gone. What keeps
|
|
121
|
+
this layer on `intercept` is cost: with ``opentelemetry-api`` installed and no SDK
|
|
122
|
+
configured — the default state of the ``tracing`` extra — every span is non-recording and
|
|
123
|
+
this interceptor does nothing at all, while an around scope would be opened for every call
|
|
124
|
+
regardless, and on a streaming call that is a task per RPC.
|
|
121
125
|
|
|
122
126
|
Note:
|
|
123
127
|
Without the ``tracing`` extra (``opentelemetry-api``) the interceptor is a documented
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""The settings shape the factory reads, as pydantic models, written down once.
|
|
2
|
+
|
|
3
|
+
`factory.GrpcClientFactory` reads a settings object structurally — `protocols.GrpcClientSettingsProtocol`
|
|
4
|
+
and the optional blocks around it — so every service used to write its own models for that shape and
|
|
5
|
+
its own mapping onto the kit's dataclasses, and every copy drifted in its own way. These models are
|
|
6
|
+
that shape with the kit's own defaults and bounds; each one that mirrors a dataclass knows how to
|
|
7
|
+
become it (``to_config()``), and the kit's own tests pin the two field sets to each other.
|
|
8
|
+
|
|
9
|
+
Every class here is a plain :class:`pydantic.BaseModel`, deliberately. A section that were a
|
|
10
|
+
``BaseSettings`` would read the environment on its own, with no prefix, and a bare ``TARGET`` or
|
|
11
|
+
``TIMEOUT`` in a pod would land in it. Nest :class:`BaseGrpcClientSettings` under the service's own
|
|
12
|
+
settings instead, once per upstream, and let that class own the environment::
|
|
13
|
+
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
from grpc_client_kit import GrpcClientFactory
|
|
18
|
+
from grpc_client_kit.settings import BaseGrpcClientSettings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Settings(BaseSettings):
|
|
22
|
+
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
|
23
|
+
|
|
24
|
+
users_grpc: BaseGrpcClientSettings = Field(default_factory=BaseGrpcClientSettings)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# USERS_GRPC__TARGET=users.internal:50051 USERS_GRPC__RETRY__MAX_ATTEMPTS=5 python main.py
|
|
28
|
+
settings = Settings()
|
|
29
|
+
factory = GrpcClientFactory(settings=settings.users_grpc)
|
|
30
|
+
|
|
31
|
+
Runtime objects are not settings and have no field here: channel credentials are handed to
|
|
32
|
+
:meth:`BaseGrpcClientSettings.to_config`, metrics registries and retry callbacks to the factory or to
|
|
33
|
+
the chain built from the sections. Unknown fields are refused, so a misspelled variable fails at load
|
|
34
|
+
instead of quietly yielding a default.
|
|
35
|
+
|
|
36
|
+
This module needs the ``settings`` extra (``grpc-client-kit[settings]``), which pulls in pydantic;
|
|
37
|
+
importing it without raises an ``ImportError`` naming the extra.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
import grpc
|
|
46
|
+
|
|
47
|
+
from .balancers import LoadBalancerConfig, LoadBalancingStrategy
|
|
48
|
+
from .config import ConnectivityConfig, GrpcClientConfig
|
|
49
|
+
from .interceptors import (
|
|
50
|
+
CircuitBreakerConfig,
|
|
51
|
+
DeadlineBudgetConfig,
|
|
52
|
+
RetryConfig,
|
|
53
|
+
TimeoutConfig,
|
|
54
|
+
WaitForReadyConfig,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
59
|
+
except ImportError as exc:
|
|
60
|
+
raise ImportError("Install grpc-client-kit[settings] (pydantic) to use the settings models") from exc
|
|
61
|
+
|
|
62
|
+
# The names an operator writes for `grpc.Compression`. "none" is gRPC's explicit no-compression
|
|
63
|
+
# setting, not the absence of one — that is ``None``, which leaves the gRPC default alone.
|
|
64
|
+
_COMPRESSION_BY_NAME: dict[str, grpc.Compression] = {
|
|
65
|
+
"none": grpc.Compression.NoCompression,
|
|
66
|
+
"deflate": grpc.Compression.Deflate,
|
|
67
|
+
"gzip": grpc.Compression.Gzip,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _status_code_by_name(value: object) -> object:
|
|
72
|
+
"""Resolve a status code written by name (``UNAVAILABLE``); anything else is left to pydantic."""
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
return value
|
|
75
|
+
try:
|
|
76
|
+
return grpc.StatusCode[value.upper()]
|
|
77
|
+
except KeyError:
|
|
78
|
+
names = ", ".join(code.name for code in grpc.StatusCode)
|
|
79
|
+
raise ValueError(f"unknown gRPC status code {value!r}; expected one of {names}") from None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BaseConnectivitySettings(BaseModel):
|
|
83
|
+
"""``connectivity``: keepalive and reconnect backoff in seconds, `config.ConnectivityConfig` by environment.
|
|
84
|
+
|
|
85
|
+
Opt-in like the dataclass: an absent block leaves every channel argument at gRPC's own default,
|
|
86
|
+
and ``None`` in a field does the same for that argument alone.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(extra="forbid")
|
|
90
|
+
|
|
91
|
+
keepalive_time: float | None = Field(
|
|
92
|
+
default=30.0, gt=0, description="Seconds of inactivity before a keepalive ping (None: never ping)"
|
|
93
|
+
)
|
|
94
|
+
keepalive_timeout: float | None = Field(default=10.0, gt=0, description="Seconds to wait for the ping's answer")
|
|
95
|
+
permit_without_calls: bool = Field(default=False, description="Keep pinging while no call is in flight")
|
|
96
|
+
max_pings_without_data: int | None = Field(
|
|
97
|
+
default=2, ge=0, description="Pings allowed on a connection carrying no data (0: no limit)"
|
|
98
|
+
)
|
|
99
|
+
initial_reconnect_backoff: float | None = Field(
|
|
100
|
+
default=1.0, gt=0, description="Seconds before the first reconnect attempt"
|
|
101
|
+
)
|
|
102
|
+
min_reconnect_backoff: float | None = Field(
|
|
103
|
+
default=None, gt=0, description="Lower bound between reconnect attempts (None: gRPC's default)"
|
|
104
|
+
)
|
|
105
|
+
max_reconnect_backoff: float | None = Field(
|
|
106
|
+
default=30.0, gt=0, description="Upper bound between reconnect attempts"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def to_config(self) -> ConnectivityConfig:
|
|
110
|
+
"""Build the `config.ConnectivityConfig` these fields describe.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If the reconnect bounds contradict each other; the dataclass keeps that check.
|
|
114
|
+
"""
|
|
115
|
+
return ConnectivityConfig(**self.model_dump())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BaseChannelPoolSettings(BaseModel):
|
|
119
|
+
"""``pool``: what `channel.ChannelPool` is sized with, read by the factory."""
|
|
120
|
+
|
|
121
|
+
model_config = ConfigDict(extra="forbid")
|
|
122
|
+
|
|
123
|
+
max_channels_per_target: int = Field(default=1, ge=1, description="Channels per channel identity")
|
|
124
|
+
idle_timeout: float = Field(
|
|
125
|
+
default=300.0, description="Seconds without active RPCs before gRPC parks a connection (0: never)"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class BaseTimeoutSettings(BaseModel):
|
|
130
|
+
"""``timeout``: the budget of a whole call, retries included; `interceptors.TimeoutConfig` by environment."""
|
|
131
|
+
|
|
132
|
+
model_config = ConfigDict(extra="forbid")
|
|
133
|
+
|
|
134
|
+
default: float = Field(
|
|
135
|
+
default=10.0, ge=0, description="Budget in seconds for methods not listed in per_method (0: no deadline)"
|
|
136
|
+
)
|
|
137
|
+
per_method: dict[str, float | None] = Field(
|
|
138
|
+
default_factory=dict,
|
|
139
|
+
description='Budgets by full method name, as JSON: {"/pkg.Service/Method": 60} (null: no deadline)',
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def to_config(self) -> TimeoutConfig:
|
|
143
|
+
"""Build the `interceptors.TimeoutConfig` these fields describe."""
|
|
144
|
+
return TimeoutConfig(**self.model_dump())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class BaseRetrySettings(BaseModel):
|
|
148
|
+
"""``retry``: the retry policy, `interceptors.RetryConfig` by environment.
|
|
149
|
+
|
|
150
|
+
Status codes are written by name (``UNAVAILABLE``). ``on_retry`` and ``metrics`` are runtime
|
|
151
|
+
objects and have no field here; the factory injects the registry, a hand-built chain sets both on
|
|
152
|
+
the config `to_config` returns.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(extra="forbid")
|
|
156
|
+
|
|
157
|
+
max_attempts: int = Field(default=3, ge=1, description="Total attempts, the first one included")
|
|
158
|
+
initial_backoff: float = Field(default=0.1, ge=0, description="Seconds before the first retry")
|
|
159
|
+
max_backoff: float = Field(default=10.0, ge=0, description="Upper bound for the wait between retries")
|
|
160
|
+
backoff_multiplier: float = Field(default=2.0, ge=1, description="Growth factor of the backoff per attempt")
|
|
161
|
+
jitter: float = Field(default=0.1, ge=0, le=1, description="Random variation applied to the backoff")
|
|
162
|
+
retryable_codes: set[grpc.StatusCode] | None = Field(
|
|
163
|
+
default=None, description="Status codes that trigger a retry, by name (None: the kit's default set)"
|
|
164
|
+
)
|
|
165
|
+
retry_streaming: bool = Field(default=False, description="Allow retrying unary-stream calls at all")
|
|
166
|
+
idempotent_methods: set[str] | None = Field(
|
|
167
|
+
default=None, description="Full method names that may be retried; a whitelist once given"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@field_validator("retryable_codes", mode="before")
|
|
171
|
+
@classmethod
|
|
172
|
+
def _codes_by_name(cls, value: object) -> object:
|
|
173
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
174
|
+
return {_status_code_by_name(item) for item in value}
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
def to_config(self) -> RetryConfig:
|
|
178
|
+
"""Build the `interceptors.RetryConfig` these fields describe, with no callback and no registry."""
|
|
179
|
+
return RetryConfig(**self.model_dump())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class BaseCircuitBreakerSettings(BaseModel):
|
|
183
|
+
"""``circuit_breaker``: per-method, per-target breaking; `interceptors.CircuitBreakerConfig` by environment.
|
|
184
|
+
|
|
185
|
+
``metrics`` is a runtime object and has no field here; the factory injects the registry.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
model_config = ConfigDict(extra="forbid")
|
|
189
|
+
|
|
190
|
+
fail_threshold: int = Field(default=5, ge=1, description="Consecutive failed attempts that open a circuit")
|
|
191
|
+
recovery_timeout: float = Field(default=60.0, ge=0, description="Seconds an open circuit waits before a trial")
|
|
192
|
+
half_open_max_calls: int = Field(default=1, ge=1, description="Trial calls admitted while half-open")
|
|
193
|
+
max_methods: int = Field(default=1000, ge=1, description="Methods tracked per breaker before the LRU evicts")
|
|
194
|
+
|
|
195
|
+
def to_config(self) -> CircuitBreakerConfig:
|
|
196
|
+
"""Build the `interceptors.CircuitBreakerConfig` these fields describe, with no registry."""
|
|
197
|
+
return CircuitBreakerConfig(**self.model_dump())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class BaseWaitForReadySettings(BaseModel):
|
|
201
|
+
"""``wait_for_ready``: wait for a connection instead of failing fast; `interceptors.WaitForReadyConfig`."""
|
|
202
|
+
|
|
203
|
+
model_config = ConfigDict(extra="forbid")
|
|
204
|
+
|
|
205
|
+
default: bool | None = Field(
|
|
206
|
+
default=True, description="Value for methods not listed in per_method (None: untouched)"
|
|
207
|
+
)
|
|
208
|
+
per_method: dict[str, bool | None] = Field(
|
|
209
|
+
default_factory=dict, description='Values by full method name, as JSON: {"/pkg.Service/Method": false}'
|
|
210
|
+
)
|
|
211
|
+
require_deadline: bool = Field(default=True, description="Wait only on calls that carry a deadline")
|
|
212
|
+
|
|
213
|
+
def to_config(self) -> WaitForReadyConfig:
|
|
214
|
+
"""Build the `interceptors.WaitForReadyConfig` these fields describe."""
|
|
215
|
+
return WaitForReadyConfig(**self.model_dump())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class BaseDeadlineBudgetSettings(BaseModel):
|
|
219
|
+
"""``deadline_budget``: request budget propagation; `interceptors.DeadlineBudgetConfig` by environment.
|
|
220
|
+
|
|
221
|
+
Needs the ``deadline`` extra to take effect; without it the layer is skipped with a warning.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
model_config = ConfigDict(extra="forbid")
|
|
225
|
+
|
|
226
|
+
reserve_for_next: float = Field(
|
|
227
|
+
default=0.0, ge=0, description="Seconds every call keeps back for the work that follows it"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def to_config(self) -> DeadlineBudgetConfig:
|
|
231
|
+
"""Build the `interceptors.DeadlineBudgetConfig` these fields describe."""
|
|
232
|
+
return DeadlineBudgetConfig(**self.model_dump())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class BaseLoadBalancerSettings(BaseModel):
|
|
236
|
+
"""``balancer``: how a target is picked from ``targets``; `balancers.LoadBalancerConfig` by environment."""
|
|
237
|
+
|
|
238
|
+
model_config = ConfigDict(extra="forbid")
|
|
239
|
+
|
|
240
|
+
strategy: LoadBalancingStrategy = Field(
|
|
241
|
+
default=LoadBalancingStrategy.ROUND_ROBIN, description="round_robin, random or weighted"
|
|
242
|
+
)
|
|
243
|
+
weights: dict[str, float] | None = Field(
|
|
244
|
+
default=None, description='Weights by target for the weighted strategy, as JSON: {"host:50051": 2.0}'
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def to_config(self) -> LoadBalancerConfig:
|
|
248
|
+
"""Build the `balancers.LoadBalancerConfig` these fields describe."""
|
|
249
|
+
return LoadBalancerConfig(**self.model_dump())
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class BaseHealthCheckerSettings(BaseModel):
|
|
253
|
+
"""``health_checker``: active ``grpc.health.v1`` probing of ``targets``, read by the factory.
|
|
254
|
+
|
|
255
|
+
Needs the ``health`` extra: the factory raises ``ImportError`` naming it when this block is set.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
model_config = ConfigDict(extra="forbid")
|
|
259
|
+
|
|
260
|
+
check_interval: float = Field(default=30.0, gt=0, description="Seconds between probes of a healthy target")
|
|
261
|
+
timeout: float = Field(default=5.0, gt=0, description="Seconds one probe may take")
|
|
262
|
+
service: str = Field(default="", description="Service name probed; empty asks about the server as a whole")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class BaseGrpcClientSettings(BaseModel):
|
|
266
|
+
"""One upstream's client settings: what the factory reads, and `config.GrpcClientConfig` by `to_config`.
|
|
267
|
+
|
|
268
|
+
Satisfies `protocols.GrpcClientSettingsProtocol` and carries every optional block the factory
|
|
269
|
+
reads, so an instance is handed to `factory.GrpcClientFactory` as it is. The observability flags
|
|
270
|
+
are flat because that is how the protocol spells them; the rest of
|
|
271
|
+
`interceptors.ObservabilityConfig` sits next to them under the dataclass's own names, minus
|
|
272
|
+
``service_name`` (the factory names each client after its stub) and the registry.
|
|
273
|
+
|
|
274
|
+
Two blocks are present by default: ``pool``, at the pool's own defaults, and ``timeout``, because
|
|
275
|
+
a client without a deadline is the one default this kit will not ship — ``TIMEOUT__DEFAULT=0``
|
|
276
|
+
switches it off. Everything that adds a layer or needs an extra — retries, the breaker,
|
|
277
|
+
wait-for-ready, budget propagation, balancing, health checks, connectivity tuning — is ``None``
|
|
278
|
+
until configured; with pydantic-settings a nested block is created the moment one of its
|
|
279
|
+
variables is set. Subclass it to change a default or add fields of your own::
|
|
280
|
+
|
|
281
|
+
class UsersGrpcSettings(BaseGrpcClientSettings):
|
|
282
|
+
retry: BaseRetrySettings | None = Field(default_factory=BaseRetrySettings)
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
model_config = ConfigDict(extra="forbid")
|
|
286
|
+
|
|
287
|
+
# Channel
|
|
288
|
+
target: str | None = Field(
|
|
289
|
+
default=None, description="Single target, host:port or a gRPC resolver URI; exclusive with targets"
|
|
290
|
+
)
|
|
291
|
+
targets: list[str] | None = Field(default=None, description="Targets to balance across; exclusive with target")
|
|
292
|
+
insecure: bool = Field(
|
|
293
|
+
default=False,
|
|
294
|
+
description="Plaintext channel (False: TLS, from the system trust store or to_config's credentials)",
|
|
295
|
+
)
|
|
296
|
+
options: list[tuple[str, Any]] | None = Field(
|
|
297
|
+
default=None, description='Raw channel arguments, as JSON pairs: [["grpc.max_receive_message_length", 8388608]]'
|
|
298
|
+
)
|
|
299
|
+
compression: grpc.Compression | None = Field(
|
|
300
|
+
default=None, description="Channel compression by name: none, deflate or gzip (None: the gRPC default)"
|
|
301
|
+
)
|
|
302
|
+
connectivity: BaseConnectivitySettings | None = Field(
|
|
303
|
+
default=None, description="Keepalive and reconnect tuning (None: gRPC's defaults)"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Observability, flat: this is how GrpcClientSettingsProtocol spells the flags
|
|
307
|
+
tracing_enabled: bool = Field(default=False, description="Add the tracing layer (needs the tracing extra)")
|
|
308
|
+
metrics_enabled: bool = Field(default=False, description="Add the metrics layer (needs a registry)")
|
|
309
|
+
logging_enabled: bool = Field(default=True, description="Add the structured logging layer")
|
|
310
|
+
sensitive_headers: set[str] | None = Field(
|
|
311
|
+
default=None, description="Header names redacted in logged metadata (None: the kit's default set)"
|
|
312
|
+
)
|
|
313
|
+
sensitive_methods: set[str] | None = Field(
|
|
314
|
+
default=None, description="Full method names whose payloads and errors are kept out of the logs"
|
|
315
|
+
)
|
|
316
|
+
sensitive_patterns: list[str] | None = Field(
|
|
317
|
+
default=None, description="Regex patterns marking methods as sensitive"
|
|
318
|
+
)
|
|
319
|
+
log_request_payload: bool = Field(default=False, description="Log request payloads, truncated")
|
|
320
|
+
log_response_payload: bool = Field(default=False, description="Log response payloads, truncated")
|
|
321
|
+
enable_method_label: bool = Field(
|
|
322
|
+
default=True, description="Label metrics by method (off for services with thousands of methods)"
|
|
323
|
+
)
|
|
324
|
+
success_log_level: int = Field(
|
|
325
|
+
default=logging.INFO, description="Level of the record a successful call emits, by name or number"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# The blocks the factory maps onto the pool, the chain, the balancer and the health checker
|
|
329
|
+
pool: BaseChannelPoolSettings | None = Field(
|
|
330
|
+
default_factory=BaseChannelPoolSettings, description="Channel pool sizing"
|
|
331
|
+
)
|
|
332
|
+
timeout: BaseTimeoutSettings | None = Field(
|
|
333
|
+
default_factory=BaseTimeoutSettings, description="Call budgets (None: no timeout layer, so no deadline at all)"
|
|
334
|
+
)
|
|
335
|
+
retry: BaseRetrySettings | None = Field(default=None, description="Retry policy (None: no retry layer)")
|
|
336
|
+
circuit_breaker: BaseCircuitBreakerSettings | None = Field(
|
|
337
|
+
default=None, description="Circuit breaker (None: no breaker layer)"
|
|
338
|
+
)
|
|
339
|
+
wait_for_ready: BaseWaitForReadySettings | None = Field(
|
|
340
|
+
default=None, description="Wait for a connection instead of failing fast (None: gRPC's fail-fast)"
|
|
341
|
+
)
|
|
342
|
+
deadline_budget: BaseDeadlineBudgetSettings | None = Field(
|
|
343
|
+
default=None, description="Request budget propagation (None: none; needs the deadline extra)"
|
|
344
|
+
)
|
|
345
|
+
balancer: BaseLoadBalancerSettings | None = Field(
|
|
346
|
+
default=None, description="Strategy over targets (None: round-robin)"
|
|
347
|
+
)
|
|
348
|
+
health_checker: BaseHealthCheckerSettings | None = Field(
|
|
349
|
+
default=None, description="Active probing of targets (None: none; needs the health extra)"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@field_validator("compression", mode="before")
|
|
353
|
+
@classmethod
|
|
354
|
+
def _compression_by_name(cls, value: object) -> object:
|
|
355
|
+
if not isinstance(value, str):
|
|
356
|
+
return value
|
|
357
|
+
try:
|
|
358
|
+
return _COMPRESSION_BY_NAME[value.lower()]
|
|
359
|
+
except KeyError:
|
|
360
|
+
names = ", ".join(_COMPRESSION_BY_NAME)
|
|
361
|
+
raise ValueError(f"unknown compression {value!r}; expected one of {names}") from None
|
|
362
|
+
|
|
363
|
+
@field_validator("success_log_level", mode="before")
|
|
364
|
+
@classmethod
|
|
365
|
+
def _level_by_name(cls, value: object) -> object:
|
|
366
|
+
# An environment hands over strings, so a numeric one is left to pydantic's int coercion.
|
|
367
|
+
if not isinstance(value, str) or value.isdecimal():
|
|
368
|
+
return value
|
|
369
|
+
try:
|
|
370
|
+
return logging.getLevelNamesMapping()[value.upper()]
|
|
371
|
+
except KeyError:
|
|
372
|
+
raise ValueError(f"unknown log level {value!r}") from None
|
|
373
|
+
|
|
374
|
+
@model_validator(mode="after")
|
|
375
|
+
def _one_way_to_name_the_backend(self) -> BaseGrpcClientSettings:
|
|
376
|
+
if self.target is not None and self.targets:
|
|
377
|
+
raise ValueError("target and targets are mutually exclusive: one address, or a list to balance across")
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
def to_config(self, *, credentials: grpc.ChannelCredentials | None = None) -> GrpcClientConfig:
|
|
381
|
+
"""Build the `config.GrpcClientConfig` these fields describe.
|
|
382
|
+
|
|
383
|
+
The chain is built from the sections (``settings.retry.to_config()`` and so on, into
|
|
384
|
+
`interceptors.build_interceptors`); the factory does both from the same instance.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
credentials: TLS credentials for the channel. A credentials object is built in code, not
|
|
388
|
+
read from an environment, so it is handed in here — and ``insecure=True`` together with
|
|
389
|
+
credentials is refused by the dataclass, exactly as it always was.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
The channel configuration.
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
ValueError: If the configuration contradicts itself; the dataclasses keep those checks.
|
|
396
|
+
"""
|
|
397
|
+
return GrpcClientConfig(
|
|
398
|
+
target=self.target,
|
|
399
|
+
insecure=self.insecure,
|
|
400
|
+
credentials=credentials,
|
|
401
|
+
options=self.options,
|
|
402
|
+
compression=self.compression,
|
|
403
|
+
connectivity=self.connectivity.to_config() if self.connectivity is not None else None,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
__all__ = [
|
|
408
|
+
"BaseChannelPoolSettings",
|
|
409
|
+
"BaseCircuitBreakerSettings",
|
|
410
|
+
"BaseConnectivitySettings",
|
|
411
|
+
"BaseDeadlineBudgetSettings",
|
|
412
|
+
"BaseGrpcClientSettings",
|
|
413
|
+
"BaseHealthCheckerSettings",
|
|
414
|
+
"BaseLoadBalancerSettings",
|
|
415
|
+
"BaseRetrySettings",
|
|
416
|
+
"BaseTimeoutSettings",
|
|
417
|
+
"BaseWaitForReadySettings",
|
|
418
|
+
]
|
|
@@ -41,11 +41,14 @@ metrics = [
|
|
|
41
41
|
deadline = [
|
|
42
42
|
"deadline-budget>=0.1.0,<1.0.0",
|
|
43
43
|
]
|
|
44
|
+
settings = [
|
|
45
|
+
"pydantic>=2.0.0,<3.0",
|
|
46
|
+
]
|
|
44
47
|
observability = [
|
|
45
48
|
"grpc-client-kit[metrics,tracing]",
|
|
46
49
|
]
|
|
47
50
|
all = [
|
|
48
|
-
"grpc-client-kit[deadline,health,metrics,tracing]",
|
|
51
|
+
"grpc-client-kit[deadline,health,metrics,settings,tracing]",
|
|
49
52
|
]
|
|
50
53
|
|
|
51
54
|
[dependency-groups]
|
|
@@ -55,6 +58,7 @@ test = [
|
|
|
55
58
|
"pytest-cov>=7.0.0",
|
|
56
59
|
"pytest-lazy-fixtures>=1.1.1",
|
|
57
60
|
"freezegun>=1.5.5",
|
|
61
|
+
"pydantic-settings>=2.0.0,<3.0",
|
|
58
62
|
]
|
|
59
63
|
docs = [
|
|
60
64
|
"zensical>=0.0.37",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.1" # x-release-please-version
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/circuit_breaker.py
RENAMED
|
File without changes
|
{grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/client_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/wait_for_ready.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|