grpc-client-kit 0.1.2__tar.gz → 0.3.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.
Files changed (36) hide show
  1. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/CHANGELOG.md +14 -0
  2. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/PKG-INFO +23 -5
  3. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/README.md +16 -4
  4. grpc_client_kit-0.3.0/grpc_client_kit/__version__.py +1 -0
  5. grpc_client_kit-0.3.0/grpc_client_kit/dishka.py +183 -0
  6. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/factory.py +47 -15
  7. grpc_client_kit-0.3.0/grpc_client_kit/metrics.py +229 -0
  8. grpc_client_kit-0.3.0/grpc_client_kit/settings.py +418 -0
  9. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/pyproject.toml +8 -1
  10. grpc_client_kit-0.1.2/grpc_client_kit/__version__.py +0 -1
  11. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/.gitignore +0 -0
  12. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/LICENSE +0 -0
  13. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/__init__.py +0 -0
  14. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/balancers.py +0 -0
  15. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/channel.py +0 -0
  16. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/client.py +0 -0
  17. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/config.py +0 -0
  18. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/deadline.py +0 -0
  19. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/errors.py +0 -0
  20. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/health.py +0 -0
  21. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/__init__.py +0 -0
  22. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/base.py +0 -0
  23. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/circuit_breaker.py +0 -0
  24. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/client_logging.py +0 -0
  25. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/context.py +0 -0
  26. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/deadline.py +0 -0
  27. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/metrics.py +0 -0
  28. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/outlier.py +0 -0
  29. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/retry.py +0 -0
  30. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/timeout.py +0 -0
  31. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/tracing.py +0 -0
  32. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/interceptors/wait_for_ready.py +0 -0
  33. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/protocols.py +0 -0
  34. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/py.typed +0 -0
  35. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/utils.py +0 -0
  36. {grpc_client_kit-0.1.2 → grpc_client_kit-0.3.0}/grpc_client_kit/validation.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.2.0...grpc-client-kit-v0.3.0) (2026-09-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * Prometheus collector for the metrics protocol and Dishka providers for the client lifecycle ([6dfdb44](https://github.com/bedrock-python/grpc-client-kit/commit/6dfdb44f2f818c6036f465844c96c2151ee511db)), closes [#28](https://github.com/bedrock-python/grpc-client-kit/issues/28)
9
+
10
+ ## [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)
11
+
12
+
13
+ ### Features
14
+
15
+ * settings models for the environment, mirroring the dataclasses ([edffb5f](https://github.com/bedrock-python/grpc-client-kit/commit/edffb5fdcf934cecd5788a99e37064133383d9cd))
16
+
3
17
  ## [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)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: grpc-client-kit
3
- Version: 0.1.2
3
+ Version: 0.3.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/
@@ -223,11 +223,15 @@ Requires-Python: >=3.12
223
223
  Requires-Dist: grpcio<2.0.0,>=1.78.0
224
224
  Provides-Extra: all
225
225
  Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'all'
226
+ Requires-Dist: dishka<2.0.0,>=1.4.0; extra == 'all'
226
227
  Requires-Dist: grpcio-health-checking<2.0.0,>=1.78.0; extra == 'all'
227
228
  Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'all'
228
229
  Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'all'
230
+ Requires-Dist: pydantic<3.0,>=2.0.0; extra == 'all'
229
231
  Provides-Extra: deadline
230
232
  Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'deadline'
233
+ Provides-Extra: dishka
234
+ Requires-Dist: dishka<2.0.0,>=1.4.0; extra == 'dishka'
231
235
  Provides-Extra: health
232
236
  Requires-Dist: grpcio-health-checking<2.0.0,>=1.78.0; extra == 'health'
233
237
  Provides-Extra: metrics
@@ -235,6 +239,8 @@ Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'metrics'
235
239
  Provides-Extra: observability
236
240
  Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'observability'
237
241
  Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'observability'
242
+ Provides-Extra: settings
243
+ Requires-Dist: pydantic<3.0,>=2.0.0; extra == 'settings'
238
244
  Provides-Extra: tracing
239
245
  Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'tracing'
240
246
  Description-Content-Type: text/markdown
@@ -307,6 +313,8 @@ pip install "grpc-client-kit[health]" # + grpc.health.v1 monitoring
307
313
  pip install "grpc-client-kit[metrics,tracing]" # + Prometheus + OpenTelemetry
308
314
  pip install "grpc-client-kit[observability]" # + metrics and tracing together
309
315
  pip install "grpc-client-kit[deadline]" # + request budget propagation
316
+ pip install "grpc-client-kit[settings]" # + pydantic settings models
317
+ pip install "grpc-client-kit[dishka]" # + Dishka providers
310
318
  pip install "grpc-client-kit[all]" # everything
311
319
  ```
312
320
 
@@ -362,7 +370,11 @@ Any object carrying the fields of `GrpcClientSettingsProtocol` works —
362
370
  a pydantic model, a dataclass, a plain class. Everything optional
363
371
  (`credentials`, `options`, `compression`, `metrics_registry`,
364
372
  `sensitive_headers`) is read through `getattr`, so a minimal settings object
365
- stays valid. See the
373
+ stays valid. Or take the shipped one: `grpc_client_kit.settings.BaseGrpcClientSettings`
374
+ (the `[settings]` extra) is that shape written down once — plain pydantic
375
+ models with the kit's own defaults and bounds, one per upstream, nested under
376
+ your service's `BaseSettings` for loading from the environment, and
377
+ `to_config()` on every section for a hand-built chain. See the
366
378
  [configuration guide](https://bedrock-python.github.io/grpc-client-kit/guide/configuration/)
367
379
  for the full field list.
368
380
 
@@ -511,12 +523,15 @@ names the extra to install.
511
523
  | `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
512
524
  | `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
513
525
  | `factory.GrpcClientFactory` | Settings → pool, balancer, health checker, per-target chains | core |
526
+ | `settings.BaseGrpcClientSettings` | The settings shape as pydantic models mirroring the dataclasses, `to_config()` on each | `settings` |
514
527
  | `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
515
528
  | `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
516
529
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
517
530
  | `interceptors.tracing` | OpenTelemetry CLIENT spans and `traceparent` injection | `tracing` |
518
531
  | `interceptors.metrics` | Counters, latency histograms and an in-flight gauge | core¹ |
532
+ | `metrics.GrpcClientMetrics` | The Prometheus collector for them, shaped to join `grpc-server-kit`'s series | `metrics` |
519
533
  | `health.HealthChecker` | Background `grpc.health.v1` probing with per-target backoff | `health` |
534
+ | `dishka.*` | Dishka providers owning the factory's lifecycle, one component per upstream | `dishka` |
520
535
  | `protocols` / `validation` / `utils` | Settings seams, target validation, channel and metadata helpers | core |
521
536
 
522
537
  ¹ The metrics interceptor records through `GrpcClientMetricsProtocol` and works
@@ -528,10 +543,12 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
528
543
  | :--- | :--- | :--- |
529
544
  | `health` | `grpcio-health-checking` | `HealthChecker`, health-aware balancing and pooling |
530
545
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
531
- | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
546
+ | `metrics` | `prometheus-client` | `GrpcClientMetrics`, the Prometheus collector; a custom registry needs no extra |
532
547
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
548
+ | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
549
+ | `dishka` | `dishka` | `grpc_client_providers` and the three providers behind it |
533
550
  | `observability` | `metrics` + `tracing` | both of the above |
534
- | `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
551
+ | `all` | `deadline` + `dishka` + `health` + `metrics` + `settings` + `tracing` | everything |
535
552
 
536
553
  `deadline` is deliberately not part of `observability`: propagating a budget is
537
554
  resilience, not telemetry, and an observability extra should not pull in a
@@ -565,7 +582,8 @@ Full documentation at [bedrock-python.github.io/grpc-client-kit](https://bedrock
565
582
  | [Load balancing](https://bedrock-python.github.io/grpc-client-kit/guide/load-balancing/) | round-robin, random and weighted selection with health filtering |
566
583
  | [Health](https://bedrock-python.github.io/grpc-client-kit/guide/health/) | `grpc.health.v1` probing, per-target backoff and cold starts |
567
584
  | [Observability](https://bedrock-python.github.io/grpc-client-kit/guide/observability/) | log records, CLIENT spans and the metrics an RPC emits |
568
- | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation, ownership and DI wiring |
585
+ | [Dependency injection](https://bedrock-python.github.io/grpc-client-kit/guide/dependency-injection/) | the Dishka providers, what they own, and one component per upstream |
586
+ | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation and ownership |
569
587
  | [API reference](https://bedrock-python.github.io/grpc-client-kit/reference/) | generated from the source |
570
588
  | [For AI agents](https://bedrock-python.github.io/grpc-client-kit/agents/) | the whole API surface, the rules that break code when broken and a map of the rest, on one page to hand to a coding assistant |
571
589
 
@@ -66,6 +66,8 @@ 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
70
+ pip install "grpc-client-kit[dishka]" # + Dishka providers
69
71
  pip install "grpc-client-kit[all]" # everything
70
72
  ```
71
73
 
@@ -121,7 +123,11 @@ Any object carrying the fields of `GrpcClientSettingsProtocol` works —
121
123
  a pydantic model, a dataclass, a plain class. Everything optional
122
124
  (`credentials`, `options`, `compression`, `metrics_registry`,
123
125
  `sensitive_headers`) is read through `getattr`, so a minimal settings object
124
- stays valid. See the
126
+ stays valid. Or take the shipped one: `grpc_client_kit.settings.BaseGrpcClientSettings`
127
+ (the `[settings]` extra) is that shape written down once — plain pydantic
128
+ models with the kit's own defaults and bounds, one per upstream, nested under
129
+ your service's `BaseSettings` for loading from the environment, and
130
+ `to_config()` on every section for a hand-built chain. See the
125
131
  [configuration guide](https://bedrock-python.github.io/grpc-client-kit/guide/configuration/)
126
132
  for the full field list.
127
133
 
@@ -270,12 +276,15 @@ names the extra to install.
270
276
  | `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
271
277
  | `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
272
278
  | `factory.GrpcClientFactory` | Settings → pool, balancer, health checker, per-target chains | core |
279
+ | `settings.BaseGrpcClientSettings` | The settings shape as pydantic models mirroring the dataclasses, `to_config()` on each | `settings` |
273
280
  | `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
274
281
  | `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
275
282
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
276
283
  | `interceptors.tracing` | OpenTelemetry CLIENT spans and `traceparent` injection | `tracing` |
277
284
  | `interceptors.metrics` | Counters, latency histograms and an in-flight gauge | core¹ |
285
+ | `metrics.GrpcClientMetrics` | The Prometheus collector for them, shaped to join `grpc-server-kit`'s series | `metrics` |
278
286
  | `health.HealthChecker` | Background `grpc.health.v1` probing with per-target backoff | `health` |
287
+ | `dishka.*` | Dishka providers owning the factory's lifecycle, one component per upstream | `dishka` |
279
288
  | `protocols` / `validation` / `utils` | Settings seams, target validation, channel and metadata helpers | core |
280
289
 
281
290
  ¹ The metrics interceptor records through `GrpcClientMetricsProtocol` and works
@@ -287,10 +296,12 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
287
296
  | :--- | :--- | :--- |
288
297
  | `health` | `grpcio-health-checking` | `HealthChecker`, health-aware balancing and pooling |
289
298
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
290
- | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
299
+ | `metrics` | `prometheus-client` | `GrpcClientMetrics`, the Prometheus collector; a custom registry needs no extra |
291
300
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
301
+ | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
302
+ | `dishka` | `dishka` | `grpc_client_providers` and the three providers behind it |
292
303
  | `observability` | `metrics` + `tracing` | both of the above |
293
- | `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
304
+ | `all` | `deadline` + `dishka` + `health` + `metrics` + `settings` + `tracing` | everything |
294
305
 
295
306
  `deadline` is deliberately not part of `observability`: propagating a budget is
296
307
  resilience, not telemetry, and an observability extra should not pull in a
@@ -324,7 +335,8 @@ Full documentation at [bedrock-python.github.io/grpc-client-kit](https://bedrock
324
335
  | [Load balancing](https://bedrock-python.github.io/grpc-client-kit/guide/load-balancing/) | round-robin, random and weighted selection with health filtering |
325
336
  | [Health](https://bedrock-python.github.io/grpc-client-kit/guide/health/) | `grpc.health.v1` probing, per-target backoff and cold starts |
326
337
  | [Observability](https://bedrock-python.github.io/grpc-client-kit/guide/observability/) | log records, CLIENT spans and the metrics an RPC emits |
327
- | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation, ownership and DI wiring |
338
+ | [Dependency injection](https://bedrock-python.github.io/grpc-client-kit/guide/dependency-injection/) | the Dishka providers, what they own, and one component per upstream |
339
+ | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation and ownership |
328
340
  | [API reference](https://bedrock-python.github.io/grpc-client-kit/reference/) | generated from the source |
329
341
  | [For AI agents](https://bedrock-python.github.io/grpc-client-kit/agents/) | the whole API surface, the rules that break code when broken and a map of the rest, on one page to hand to a coding assistant |
330
342
 
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0" # x-release-please-version
@@ -0,0 +1,183 @@
1
+ """Dishka providers owning the client lifecycle (``[dishka]`` extra).
2
+
3
+ What an application otherwise writes by hand — build the factory from the settings, start the health
4
+ checker with the container, close the pool with it — lives here as three providers, one per thing a
5
+ container hands out, and a bundle registering all three::
6
+
7
+ from dishka import make_async_container
8
+
9
+ from grpc_client_kit import GrpcClientFactory
10
+ from grpc_client_kit.dishka import grpc_client_providers
11
+
12
+ container = make_async_container(*grpc_client_providers(settings.users_grpc))
13
+ factory = await container.get(GrpcClientFactory)
14
+ users = factory.create_client(UserStub)
15
+ ...
16
+ await container.close() # stops the checker, closes the pool
17
+
18
+ - `GrpcClientSettingsProvider` holds the settings object and provides it as
19
+ `protocols.GrpcClientSettingsProtocol`, so `settings.BaseGrpcClientSettings` or anything structural
20
+ works.
21
+ - `PrometheusGrpcClientMetricsProvider` provides ``GrpcClientMetricsProtocol | None``: the cached
22
+ `metrics.GrpcClientMetrics` when ``settings.metrics_enabled``, ``None`` otherwise — ``None`` being
23
+ the kit's own spelling of "no metrics", which the factory leaves the layer out for.
24
+ - `AsyncGrpcClientProvider` provides `factory.GrpcClientFactory` in APP scope from an async generator,
25
+ entered when first resolved and left when the container closes.
26
+
27
+ Several upstreams in one container are Dishka components: every provider takes ``component=``, so
28
+ one bundle per upstream, each in its own component, is one factory per upstream::
29
+
30
+ container = make_async_container(
31
+ *grpc_client_providers(settings.users_grpc, component="users"),
32
+ *grpc_client_providers(settings.orders_grpc, component="orders"),
33
+ )
34
+ users = await container.get(GrpcClientFactory, component="users")
35
+
36
+ Dishka resolves a component's dependencies inside that component, which is why each one carries its
37
+ own three providers; the collector they hand out is the same instance, cached per prefix, and its
38
+ series are told apart by ``service``. A single upstream uses the default component and needs none of
39
+ this.
40
+
41
+ This module needs the ``dishka`` extra (``grpc-client-kit[dishka]``); importing it without raises an
42
+ ``ImportError`` naming the extra.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import logging
48
+ from collections.abc import AsyncIterator
49
+
50
+ try:
51
+ from dishka import Provider, Scope, provide
52
+ except ImportError as exc:
53
+ raise ImportError("Install grpc-client-kit[dishka] (dishka) to use the Dishka providers") from exc
54
+
55
+ from .factory import GrpcClientFactory
56
+ from .interceptors.metrics import HAS_METRICS
57
+ from .protocols import GrpcClientMetricsProtocol, GrpcClientSettingsProtocol
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ class GrpcClientSettingsProvider(Provider):
63
+ """Provide one upstream's settings object as ``GrpcClientSettingsProtocol``.
64
+
65
+ Construct it with anything structurally satisfying the protocol — `settings.BaseGrpcClientSettings`,
66
+ a dataclass, a plain class. The other providers request the settings through the protocol, which
67
+ is what lets a second upstream be a second component rather than a second container.
68
+ """
69
+
70
+ scope = Scope.APP
71
+
72
+ def __init__(self, settings: GrpcClientSettingsProtocol, *, component: str | None = None) -> None:
73
+ super().__init__(component=component)
74
+ self._settings = settings
75
+
76
+ @provide
77
+ def settings(self) -> GrpcClientSettingsProtocol:
78
+ """Provide the settings object as it was given."""
79
+ return self._settings
80
+
81
+
82
+ class PrometheusGrpcClientMetricsProvider(Provider):
83
+ """Provide the registry the factory records into: `metrics.GrpcClientMetrics`, or ``None``.
84
+
85
+ ``None`` when ``settings.metrics_enabled`` is off, and ``None`` with a warning when it is on but
86
+ the ``[metrics]`` extra is absent — the degradation the chain builder applies, so the provider can
87
+ always be registered. The collector comes from `metrics.get_grpc_client_metrics`, one instance per
88
+ prefix on the default registry, so a container rebuilt per test never asks Prometheus to register
89
+ the same series twice.
90
+ """
91
+
92
+ scope = Scope.APP
93
+
94
+ def __init__(self, *, prefix: str | None = None, component: str | None = None) -> None:
95
+ super().__init__(component=component)
96
+ self._prefix = prefix
97
+
98
+ @provide
99
+ def metrics(self, settings: GrpcClientSettingsProtocol) -> GrpcClientMetricsProtocol | None:
100
+ """Provide the collector when metrics are enabled and the extra is installed, ``None`` otherwise."""
101
+ if not settings.metrics_enabled:
102
+ return None
103
+ if not HAS_METRICS:
104
+ logger.warning("Metrics requested but grpc-client-kit[metrics] is not installed; metrics are disabled")
105
+ return None
106
+ from .metrics import get_grpc_client_metrics # noqa: PLC0415 - lazy: needs the [metrics] extra
107
+
108
+ return get_grpc_client_metrics(self._prefix)
109
+
110
+
111
+ class AsyncGrpcClientProvider(Provider):
112
+ """Provide `factory.GrpcClientFactory` in APP scope, entered on first use and closed with the container.
113
+
114
+ The factory builds and owns the pool and the health checker: resolving it starts the checker and
115
+ waits for its first pass (``ready_timeout``); closing the container stops the checker and closes
116
+ the pool, giving in-flight RPCs ``shutdown_grace`` — exactly what ``async with factory`` does.
117
+ Clients come from the factory as usual, ``factory.create_client(UserStub)``, and are cheap enough
118
+ to be request-scoped.
119
+ """
120
+
121
+ scope = Scope.APP
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ shutdown_grace: float | None = 5.0,
127
+ ready_timeout: float | None = 10.0,
128
+ component: str | None = None,
129
+ ) -> None:
130
+ super().__init__(component=component)
131
+ self._shutdown_grace = shutdown_grace
132
+ self._ready_timeout = ready_timeout
133
+
134
+ @provide
135
+ async def factory(
136
+ self,
137
+ settings: GrpcClientSettingsProtocol,
138
+ metrics: GrpcClientMetricsProtocol | None,
139
+ ) -> AsyncIterator[GrpcClientFactory]:
140
+ """Build the factory from the settings and the registry, and close it with the container."""
141
+ async with GrpcClientFactory(
142
+ settings=settings,
143
+ shutdown_grace=self._shutdown_grace,
144
+ ready_timeout=self._ready_timeout,
145
+ metrics=metrics,
146
+ ) as factory:
147
+ yield factory
148
+
149
+
150
+ def grpc_client_providers(
151
+ settings: GrpcClientSettingsProtocol,
152
+ *,
153
+ component: str | None = None,
154
+ metrics_prefix: str | None = None,
155
+ shutdown_grace: float | None = 5.0,
156
+ ready_timeout: float | None = 10.0,
157
+ ) -> tuple[Provider, ...]:
158
+ """Return the three providers of one upstream, for one-line registration.
159
+
160
+ Args:
161
+ settings: The upstream's settings object; anything satisfying `protocols.GrpcClientSettingsProtocol`.
162
+ component: The Dishka component to register the providers in — one per upstream when a
163
+ container serves several, the default component otherwise.
164
+ metrics_prefix: Prefix for the Prometheus metric names.
165
+ shutdown_grace: Seconds in-flight RPCs get to finish when the container closes.
166
+ ready_timeout: Seconds the factory waits for the first health check pass when resolved.
167
+
168
+ Returns:
169
+ The providers, to unpack into ``make_async_container``.
170
+ """
171
+ return (
172
+ GrpcClientSettingsProvider(settings, component=component),
173
+ PrometheusGrpcClientMetricsProvider(prefix=metrics_prefix, component=component),
174
+ AsyncGrpcClientProvider(shutdown_grace=shutdown_grace, ready_timeout=ready_timeout, component=component),
175
+ )
176
+
177
+
178
+ __all__ = [
179
+ "AsyncGrpcClientProvider",
180
+ "GrpcClientSettingsProvider",
181
+ "PrometheusGrpcClientMetricsProvider",
182
+ "grpc_client_providers",
183
+ ]
@@ -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.
@@ -93,6 +98,7 @@ class GrpcClientFactory:
93
98
  pool: ChannelProviderProtocol | None = None,
94
99
  shutdown_grace: float | None = 5.0,
95
100
  ready_timeout: float | None = 10.0,
101
+ metrics: GrpcClientMetricsProtocol | None = None,
96
102
  ) -> None:
97
103
  """Initialize the factory.
98
104
 
@@ -113,6 +119,12 @@ class GrpcClientFactory:
113
119
  that pass every target reads unhealthy, so entering the context without waiting
114
120
  would make the first call of every freshly started pod fail deterministically.
115
121
  ``None`` waits indefinitely; only relevant when health checking is configured.
122
+ metrics: The registry RPC metrics, retries, breaker state and pool statistics are
123
+ recorded into: the default for every client this factory creates, and the one
124
+ the pool it creates reports to. Wins over ``settings.metrics_registry``;
125
+ `create_client(metrics=...)` wins over it per client. The shipped settings
126
+ models carry no registry, a registry being a runtime object, so this is where
127
+ one meets them.
116
128
 
117
129
  Raises:
118
130
  TypeError: If `settings` does not satisfy `GrpcClientSettingsProtocol`. Validated
@@ -130,21 +142,24 @@ class GrpcClientFactory:
130
142
  # reuse the same interceptor instances, or every create_client would mint a fresh channel
131
143
  # identity and a per-request client pattern would open a connection per request.
132
144
  self._chains: dict[tuple[str, str], list[grpc.aio.ClientInterceptor]] = {}
145
+ # The registry every client and the owned pool default to: the explicit one, else the
146
+ # settings' own — the shipped settings models carry none, a registry being a runtime object.
147
+ settings_registry = getattr(settings, "metrics_registry", None) if settings else None
148
+ self._metrics: GrpcClientMetricsProtocol | None = metrics if metrics is not None else settings_registry
133
149
 
134
150
  if pool:
135
151
  self._pool = pool
136
152
  else:
137
153
  self._owns_pool = True
138
- metrics = getattr(settings, "metrics_registry", None) if settings else None
139
154
 
140
155
  if settings and settings.pool:
141
156
  self._pool = ChannelPool(
142
157
  max_channels_per_target=settings.pool.max_channels_per_target,
143
158
  idle_timeout=settings.pool.idle_timeout,
144
- metrics=metrics,
159
+ metrics=self._metrics,
145
160
  )
146
161
  else:
147
- self._pool = ChannelPool(metrics=metrics)
162
+ self._pool = ChannelPool(metrics=self._metrics)
148
163
 
149
164
  # Build and start HealthChecker if configured and we have targets
150
165
  if settings and settings.health_checker and settings.targets:
@@ -247,7 +262,8 @@ class GrpcClientFactory:
247
262
  stub_class: The gRPC stub class to instantiate.
248
263
  target: Optional target override. If not provided, uses target from settings.
249
264
  service_name: Name of the service for observability. Defaults to stub class name.
250
- metrics: Optional metrics registry, overriding `settings.metrics_registry`.
265
+ metrics: Optional metrics registry for this client alone, overriding the factory's
266
+ ``metrics`` and `settings.metrics_registry`.
251
267
  interceptors: Optional list of additional custom interceptors. These instances are
252
268
  shared by every target, unlike the chain the factory builds around them.
253
269
 
@@ -306,6 +322,10 @@ class GrpcClientFactory:
306
322
  metrics_registry=metrics_registry,
307
323
  service_name=actual_service_name,
308
324
  sensitive_headers=getattr(self._settings, "sensitive_headers", None),
325
+ sensitive_methods=getattr(self._settings, "sensitive_methods", None),
326
+ sensitive_patterns=getattr(self._settings, "sensitive_patterns", None),
327
+ log_request_payload=getattr(self._settings, "log_request_payload", False),
328
+ log_response_payload=getattr(self._settings, "log_response_payload", False),
309
329
  enable_method_label=getattr(self._settings, "enable_method_label", True),
310
330
  success_log_level=getattr(self._settings, "success_log_level", logging.INFO),
311
331
  )
@@ -344,8 +364,9 @@ class GrpcClientFactory:
344
364
  def _resolve_metrics_registry(self, metrics: GrpcClientMetricsProtocol | None) -> GrpcClientMetricsProtocol | None:
345
365
  """Pick the registry RPC metrics are recorded into.
346
366
 
347
- An explicit argument wins over `settings.metrics_registry`, which otherwise only fed pool
348
- statistics and left `metrics_enabled` clients recording nothing at all.
367
+ An explicit argument wins over the factory's default — its own ``metrics`` argument, else
368
+ `settings.metrics_registry`, which otherwise only fed pool statistics and left
369
+ `metrics_enabled` clients recording nothing at all.
349
370
 
350
371
  Args:
351
372
  metrics: The registry passed to `create_client`, if any.
@@ -353,13 +374,12 @@ class GrpcClientFactory:
353
374
  Returns:
354
375
  The registry to use, or None if metrics cannot be recorded.
355
376
  """
356
- settings_registry = getattr(self._settings, "metrics_registry", None) if self._settings else None
357
- registry: GrpcClientMetricsProtocol | None = metrics if metrics is not None else settings_registry
377
+ registry: GrpcClientMetricsProtocol | None = metrics if metrics is not None else self._metrics
358
378
 
359
379
  if registry is None and self._settings and self._settings.metrics_enabled:
360
380
  logger.warning(
361
381
  "metrics_enabled is set but no metrics registry is available. "
362
- "Pass 'metrics=' to create_client() or set 'metrics_registry' in settings; "
382
+ "Pass 'metrics=' to the factory or to create_client(), or set 'metrics_registry' in settings; "
363
383
  "gRPC client metrics are disabled."
364
384
  )
365
385
 
@@ -371,8 +391,8 @@ class GrpcClientFactory:
371
391
  Channel construction settings are optional blocks (see `protocols.GrpcChannelExtrasProtocol`)
372
392
  and are read with `getattr`, so a settings model that declares none of them still satisfies
373
393
  the factory. ``connectivity`` joins them on the same terms: a settings object that carries a
374
- `config.ConnectivityConfig` under that name has its channels tuned by it, and one that does
375
- not is left with gRPC's defaults.
394
+ `config.ConnectivityConfig` — or a block with its fields — under that name has its channels
395
+ tuned by it, and one that does not is left with gRPC's defaults.
376
396
  """
377
397
  if self._settings:
378
398
  actual_target = target or self._settings.target
@@ -380,7 +400,7 @@ class GrpcClientFactory:
380
400
  credentials = getattr(self._settings, "credentials", None)
381
401
  options = getattr(self._settings, "options", None)
382
402
  compression = getattr(self._settings, "compression", None)
383
- connectivity = getattr(self._settings, "connectivity", None)
403
+ connectivity = self._build_connectivity_config(getattr(self._settings, "connectivity", None))
384
404
  else:
385
405
  actual_target = target
386
406
  insecure = False
@@ -422,11 +442,23 @@ class GrpcClientFactory:
422
442
  health_checker=self._health_checker,
423
443
  )
424
444
 
445
+ def _build_connectivity_config(self, s: Any | None) -> ConnectivityConfig | None:
446
+ """Build ConnectivityConfig from an optional settings block (duck-typed).
447
+
448
+ A ready `config.ConnectivityConfig` is taken as it is. Any other block is read field by
449
+ field, and a field it does not carry keeps the dataclass's own default.
450
+ """
451
+ if not s:
452
+ return None
453
+ if isinstance(s, ConnectivityConfig):
454
+ return s
455
+ return ConnectivityConfig(**{name: getattr(s, name) for name in _CONNECTIVITY_FIELDS if hasattr(s, name)})
456
+
425
457
  def _build_timeout_config(self, s: TimeoutSettingsProtocol | None) -> TimeoutConfig | None:
426
- """Build TimeoutConfig from settings protocol."""
458
+ """Build TimeoutConfig from settings protocol; per-method budgets are read when the block has them."""
427
459
  if not s:
428
460
  return None
429
- return TimeoutConfig(default=s.default)
461
+ return TimeoutConfig(default=s.default, per_method=dict(getattr(s, "per_method", None) or {}))
430
462
 
431
463
  def _build_retry_config(
432
464
  self, s: RetrySettingsProtocol | None, metrics: GrpcClientMetricsProtocol | None = None