grpc-client-kit 0.1.2__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.
Files changed (34) hide show
  1. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/CHANGELOG.md +7 -0
  2. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/PKG-INFO +13 -3
  3. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/README.md +9 -2
  4. grpc_client_kit-0.2.0/grpc_client_kit/__version__.py +1 -0
  5. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/factory.py +27 -6
  6. grpc_client_kit-0.2.0/grpc_client_kit/settings.py +418 -0
  7. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/pyproject.toml +5 -1
  8. grpc_client_kit-0.1.2/grpc_client_kit/__version__.py +0 -1
  9. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/.gitignore +0 -0
  10. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/LICENSE +0 -0
  11. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/__init__.py +0 -0
  12. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/balancers.py +0 -0
  13. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/channel.py +0 -0
  14. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/client.py +0 -0
  15. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/config.py +0 -0
  16. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/deadline.py +0 -0
  17. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/errors.py +0 -0
  18. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/health.py +0 -0
  19. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/__init__.py +0 -0
  20. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/base.py +0 -0
  21. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/circuit_breaker.py +0 -0
  22. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/client_logging.py +0 -0
  23. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/context.py +0 -0
  24. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/deadline.py +0 -0
  25. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/metrics.py +0 -0
  26. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/outlier.py +0 -0
  27. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/retry.py +0 -0
  28. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/timeout.py +0 -0
  29. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/tracing.py +0 -0
  30. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/wait_for_ready.py +0 -0
  31. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/protocols.py +0 -0
  32. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/py.typed +0 -0
  33. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/utils.py +0 -0
  34. {grpc_client_kit-0.1.2 → grpc_client_kit-0.2.0}/grpc_client_kit/validation.py +0 -0
@@ -1,5 +1,12 @@
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
+
3
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)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: grpc-client-kit
3
- Version: 0.1.2
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. See the
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
 
@@ -511,6 +519,7 @@ names the extra to install.
511
519
  | `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
512
520
  | `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
513
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` |
514
523
  | `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
515
524
  | `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
516
525
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
@@ -530,8 +539,9 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
530
539
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
531
540
  | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
532
541
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
542
+ | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
533
543
  | `observability` | `metrics` + `tracing` | both of the above |
534
- | `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
544
+ | `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
535
545
 
536
546
  `deadline` is deliberately not part of `observability`: propagating a budget is
537
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. See the
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
 
@@ -270,6 +275,7 @@ names the extra to install.
270
275
  | `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
271
276
  | `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
272
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` |
273
279
  | `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
274
280
  | `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
275
281
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
@@ -289,8 +295,9 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
289
295
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
290
296
  | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
291
297
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
298
+ | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
292
299
  | `observability` | `metrics` + `tracing` | both of the above |
293
- | `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
300
+ | `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
294
301
 
295
302
  `deadline` is deliberately not part of `observability`: propagating a budget is
296
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` under that name has its channels tuned by it, and one that does
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
@@ -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.2" # x-release-please-version
File without changes