From b2ae371a51051016e9016a65d2a7a01a49551321 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:58:51 +0300 Subject: [PATCH 1/2] feat: pydantic settings models for ClientConfig behind the settings extra One pydantic BaseModel per config dataclass in clientwright.contrib.settings, same field names and defaults, to_config() on each. Sections are never a BaseSettings, so a bare BASE_URL in a pod cannot reach one; UNSET survives (an unset knob stays UNSET, an explicit null is the explicit unbounded). The settings extra pins pydantic and pydantic-settings; a missing extra raises the install hint like every other contrib. Closes #32 --- README.md | 23 ++ clientwright/contrib/settings.py | 311 +++++++++++++++++++++ docs/advanced/architecture.md | 2 +- docs/agents.md | 13 +- docs/guide/configuration.md | 33 ++- docs/guide/settings.md | 126 +++++++++ docs/learn/install.md | 1 + docs/reference/contrib.md | 4 + docs/reference/index.md | 2 +- pyproject.toml | 3 +- tests/unit/adapters/test_bare_install.py | 16 ++ tests/unit/contrib/test_settings.py | 213 ++++++++++++++ tests/unit/test_sdk_imports_are_guarded.py | 2 + uv.lock | 146 +++++++++- zensical.toml | 1 + 15 files changed, 883 insertions(+), 13 deletions(-) create mode 100644 clientwright/contrib/settings.py create mode 100644 docs/guide/settings.md create mode 100644 tests/unit/contrib/test_settings.py diff --git a/README.md b/README.md index 5a87304..98541cf 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ pip install clientwright[httpx] # httpx adapter (sync + async) pip install clientwright[aiohttp] # aiohttp adapter (async) pip install clientwright[httpx,metrics] # + Prometheus backend pip install clientwright[httpx,tracing] # + OpenTelemetry backend +pip install clientwright[httpx,settings] # + ClientConfig from the environment ``` ## Sync twin @@ -145,6 +146,28 @@ with use_budget(BudgetContext.create(total_seconds=5.0)): await client.get("/users") # runs with what is left of those 5 seconds ``` +## From the environment + +With `clientwright[settings]`, `ClientConfig` and every sub-config exist as pydantic +models with the same fields and defaults. They are plain `BaseModel`s — nest them in +your own settings, so a bare `BASE_URL` in a pod can never reach a section: + +```python +from pydantic_settings import BaseSettings, SettingsConfigDict + +from clientwright.contrib.settings import BaseClientSettings + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_nested_delimiter="__") + + warehouse: BaseClientSettings = BaseClientSettings() + + +# WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true +config = Settings().warehouse.to_config("warehouse") +``` + ## Adapters | adapter | modes | notes | diff --git a/clientwright/contrib/settings.py b/clientwright/contrib/settings.py new file mode 100644 index 0000000..ac1e007 --- /dev/null +++ b/clientwright/contrib/settings.py @@ -0,0 +1,311 @@ +"""Settings models for ``ClientConfig`` (``[settings]`` extra). + +One pydantic model per config dataclass - the same field names, the same +defaults - and ``to_config()`` on each, so the translation from an +environment-loaded settings shape to a ``ClientConfig`` is written here, once, +instead of in every service that uses the library. + +Every class here is a plain ``BaseModel``, never a ``BaseSettings``: a section +is reachable only through the settings object you nest it in, so a bare +``BASE_URL`` or ``TIMEOUT`` in a pod cannot reach it. The parent - its prefix, +its nesting delimiter, its ``.env`` file - stays yours:: + + from pydantic_settings import BaseSettings, SettingsConfigDict + + from clientwright.contrib.settings import BaseClientSettings + + + class Settings(BaseSettings): + model_config = SettingsConfigDict(env_nested_delimiter="__") + + warehouse: BaseClientSettings = BaseClientSettings() + + + # WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true + config = Settings().warehouse.to_config("warehouse") + +``UNSET`` survives the trip: a knob the config leaves ``UNSET`` by default +(``timeout.read``, ``pool.http2``, ...) reaches the config as ``UNSET`` unless +you set it, and an explicit ``null`` is the dataclass' explicit "unbounded" - +the models tell the two apart with ``model_fields_set``. Two things are not +environment-shaped and are not here: ``service_name`` is the argument of +:meth:`BaseClientSettings.to_config`, and ``observability.url_masker`` is a +callable to set on the config afterwards. +""" + +from __future__ import annotations + +import logging +from typing import Any, Final + +from ..core.config import ( + DEFAULT_RETRYABLE_KINDS, + DEFAULT_RETRYABLE_STATUS, + DEFAULT_SENSITIVE_QUERY_PARAMS, + DEFAULT_TRIP_KINDS, + UNSET, + CallerOverride, + CircuitBreakerConfig, + ClientConfig, + NativeOptions, + ObservabilityConfig, + PoolConfig, + ProxyConfig, + RedirectMode, + RetryConfig, + RetryMode, + TimeoutConfig, + TlsConfig, + UnsupportedPolicy, +) +from ..core.model import IDEMPOTENT_METHODS, CircuitKey, FailureKind + +try: + from pydantic import BaseModel, Field, field_validator, model_validator +except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError("Settings models require clientwright[settings]; install it.") from exc + + +def _maybe(model: BaseModel, name: str) -> Any: + """The field's value if it was given, else UNSET - the adapter's native default.""" + return getattr(model, name) if name in model.model_fields_set else UNSET + + +class BaseTimeoutSettings(BaseModel): + """``TimeoutConfig`` as settings. + + A phase you do not set stays ``UNSET`` in the config, exactly as in + ``TimeoutConfig()``; an explicit ``null`` is the explicit "unbounded". + """ + + total: float | None = Field( + default=30.0, gt=0, description="Wall clock for the whole logical call; null = unbounded" + ) + attempt: float | None = Field(default=None, gt=0, description="Ceiling for one attempt") + connect: float | None = Field(default=5.0, gt=0, description="Connect phase") + read: float | None = Field(default=None, gt=0, description="Read phase") + write: float | None = Field(default=None, gt=0, description="Write phase") + pool_acquire: float | None = Field(default=None, gt=0, description="Wait for a pooled connection") + + def to_config(self) -> TimeoutConfig: + return TimeoutConfig( + total=self.total, + attempt=_maybe(self, "attempt"), + connect=self.connect, + read=_maybe(self, "read"), + write=_maybe(self, "write"), + pool_acquire=_maybe(self, "pool_acquire"), + ) + + +class BasePoolSettings(BaseModel): + """``PoolConfig`` as settings; ``max_connections_per_host`` and ``http2`` stay ``UNSET`` until set.""" + + max_connections: int | None = Field(default=100, gt=0, description="Pool-wide connection cap; null = unbounded") + max_keepalive: int | None = Field(default=20, gt=0, description="Idle connections kept open") + keepalive_expiry: float | None = Field(default=30.0, gt=0, description="Seconds an idle connection is kept") + max_connections_per_host: int | None = Field(default=None, gt=0, description="Per-origin connection cap") + http2: bool | None = Field(default=None, description="HTTP/2 where the adapter can honour it") + + def to_config(self) -> PoolConfig: + return PoolConfig( + max_connections=self.max_connections, + max_keepalive=self.max_keepalive, + keepalive_expiry=self.keepalive_expiry, + max_connections_per_host=_maybe(self, "max_connections_per_host"), + http2=UNSET if self.http2 is None else self.http2, + ) + + +class BaseRetrySettings(BaseModel): + """``RetryConfig`` as settings; the sets take JSON lists, ``mode`` its value (``owned`` / ``delegated``).""" + + max_attempts: int = Field(default=3, ge=1, description="Attempts per redirect hop, the first included") + initial_backoff: float = Field(default=0.1, gt=0, description="First backoff, seconds") + max_backoff: float = Field(default=10.0, gt=0, description="Backoff ceiling, seconds") + multiplier: float = Field(default=2.0, ge=1, description="Backoff growth per attempt") + jitter: float = Field(default=0.2, ge=0, le=1, description="Multiplicative jitter within [0, 1]") + retryable_kinds: frozenset[FailureKind] = Field( + default=DEFAULT_RETRYABLE_KINDS, description="Failure kinds worth a retry" + ) + retryable_status: frozenset[int] = Field(default=DEFAULT_RETRYABLE_STATUS, description="Statuses worth a retry") + methods: frozenset[str] = Field(default=IDEMPOTENT_METHODS, description="Methods retried without a per-call flag") + respect_retry_after: bool = Field(default=True, description="A Retry-After header replaces the computed backoff") + retry_after_max: float = Field(default=60.0, description="Cap on what a server may ask for, seconds") + budget_ratio: float | None = Field( + default=0.1, gt=0, le=1, description="Share of an origin's traffic that may be retries; null = no budget" + ) + require_replayable_body: bool = Field(default=True, description="Refuse to retry a body that cannot be replayed") + mode: RetryMode = Field(default=RetryMode.OWNED, description="owned by the engine, or delegated (urllib3 only)") + + def to_config(self) -> RetryConfig: + return RetryConfig( + max_attempts=self.max_attempts, + initial_backoff=self.initial_backoff, + max_backoff=self.max_backoff, + multiplier=self.multiplier, + jitter=self.jitter, + retryable_kinds=self.retryable_kinds, + retryable_status=self.retryable_status, + methods=self.methods, + respect_retry_after=self.respect_retry_after, + retry_after_max=self.retry_after_max, + budget_ratio=self.budget_ratio, + require_replayable_body=self.require_replayable_body, + mode=self.mode, + ) + + +class BaseCircuitBreakerSettings(BaseModel): + """``CircuitBreakerConfig`` as settings; ``key`` by value: ``origin``, ``origin_route``, ``origin_method``.""" + + fail_threshold: int = Field(default=5, ge=1, description="Consecutive tripping calls before the circuit opens") + recovery_timeout: float = Field(default=60.0, gt=0, description="Seconds open before the next call is a probe") + half_open_max_calls: int = Field(default=1, ge=1, description="Concurrent probes while half-open") + max_keys: int = Field(default=512, ge=1, description="LRU cap on tracked circuits") + key: CircuitKey = Field(default=CircuitKey.ORIGIN, description="What one circuit stands for") + trip_kinds: frozenset[FailureKind] = Field(default=DEFAULT_TRIP_KINDS, description="Failure kinds that count") + + def to_config(self) -> CircuitBreakerConfig: + return CircuitBreakerConfig( + fail_threshold=self.fail_threshold, + recovery_timeout=self.recovery_timeout, + half_open_max_calls=self.half_open_max_calls, + max_keys=self.max_keys, + key=self.key, + trip_kinds=self.trip_kinds, + ) + + +class BaseTlsSettings(BaseModel): + """``TlsConfig`` as settings; ``cert`` is a PEM path, or a JSON list of two or three paths.""" + + verify: bool = Field(default=True, description="Verify the server certificate") + ca_bundle: str | None = Field(default=None, description="Path to a private CA bundle") + cert: str | tuple[str, str] | tuple[str, str, str] | None = Field( + default=None, description="Client certificate: a PEM path, (cert, key) or (cert, key, password)" + ) + + def to_config(self) -> TlsConfig: + return TlsConfig(verify=self.verify, ca_bundle=self.ca_bundle, cert=self.cert) + + +class BaseProxySettings(BaseModel): + """``ProxyConfig`` as settings: an explicit URL or the environment's proxies, not both.""" + + url: str | None = Field(default=None, description="Explicit proxy URL") + from_env: bool = Field(default=False, description="Read HTTP(S)_PROXY / NO_PROXY instead") + + @model_validator(mode="after") + def _exclusive(self) -> BaseProxySettings: + if self.url is not None and self.from_env: + raise ValueError("proxy.url and proxy.from_env are mutually exclusive") + return self + + def to_config(self) -> ProxyConfig: + return ProxyConfig(url=self.url, from_env=self.from_env) + + +# Module attribute captured before the class: the field named ``logging`` +# shadows the module inside the class body, as in ``ObservabilityConfig``. +_INFO_LEVEL: Final = logging.INFO + + +class BaseObservabilitySettings(BaseModel): + """``ObservabilityConfig`` as settings, minus ``url_masker``: a callable belongs on the config. + + ``success_log_level`` takes a level name (``DEBUG``) as well as a number. + """ + + logging: bool = Field(default=True, description="Emit log records") + metrics: bool = Field(default=True, description="Record metrics") + tracing: bool = Field(default=True, description="Open spans") + success_log_level: int = Field(default=_INFO_LEVEL, description="Level of the record for a successful call") + sensitive_query_params: frozenset[str] = Field( + default=DEFAULT_SENSITIVE_QUERY_PARAMS, description="Query parameters redacted by name in URLs" + ) + + @field_validator("success_log_level", mode="before") + @classmethod + def _level_by_name(cls, value: object) -> object: + if not isinstance(value, str): + return value + level = logging.getLevelNamesMapping().get(value.upper()) + if level is None: + raise ValueError(f"unknown log level {value!r}") + return level + + def to_config(self) -> ObservabilityConfig: + return ObservabilityConfig( + logging=self.logging, + metrics=self.metrics, + tracing=self.tracing, + success_log_level=self.success_log_level, + sensitive_query_params=self.sensitive_query_params, + ) + + +class BaseClientSettings(BaseModel): + """``ClientConfig`` as settings: the whole client, one section per sub-config. + + Nest it in your own settings object; ``service_name`` is not environment-shaped + and is the argument of :meth:`to_config`. ``retry`` and ``circuit_breaker`` + are on by default and go off with ``retry: None = None`` in a subclass (or + ``RETRY=null`` in the environment); ``proxy`` is off until any of its + fields is set. + """ + + base_url: str | None = Field(default=None, pattern=r"^https?://", description="Origin every relative URL joins") + timeout: BaseTimeoutSettings = Field(default_factory=BaseTimeoutSettings) + pool: BasePoolSettings = Field(default_factory=BasePoolSettings) + retry: BaseRetrySettings | None = Field(default_factory=BaseRetrySettings) + circuit_breaker: BaseCircuitBreakerSettings | None = Field(default_factory=BaseCircuitBreakerSettings) + tls: BaseTlsSettings = Field(default_factory=BaseTlsSettings) + proxy: BaseProxySettings | None = None + headers: dict[str, str] = Field(default_factory=dict, description="Headers injected with setdefault semantics") + redirects: RedirectMode = Field(default=RedirectMode.OWNED, description="owned by the engine, or native") + max_redirects: int = Field(default=5, ge=0, description="Hops before TooManyRedirectsError") + caller_override: CallerOverride = Field( + default=CallerOverride.CALLER_WINS, description="What a per-call timeout does: caller_wins, config_wins, raise" + ) + deadline_header: str | None = Field(default=None, description="Header stamped with the remaining budget, in ms") + observability: BaseObservabilitySettings = Field(default_factory=BaseObservabilitySettings) + native: dict[str, dict[str, Any]] = Field( + default_factory=dict, description="Raw passthrough: {slot: {kwarg: value}}" + ) + on_unsupported: UnsupportedPolicy = Field( + default=UnsupportedPolicy.WARN, description="What a knob the adapter cannot express does: ignore, warn, strict" + ) + + def to_config(self, service_name: str) -> ClientConfig: + """The ``ClientConfig`` these settings describe, labelled ``service_name``.""" + return ClientConfig( + service_name=service_name, + base_url=self.base_url, + timeout=self.timeout.to_config(), + pool=self.pool.to_config(), + retry=None if self.retry is None else self.retry.to_config(), + circuit_breaker=None if self.circuit_breaker is None else self.circuit_breaker.to_config(), + tls=self.tls.to_config(), + proxy=None if self.proxy is None else self.proxy.to_config(), + headers=self.headers, + redirects=self.redirects, + max_redirects=self.max_redirects, + caller_override=self.caller_override, + deadline_header=self.deadline_header, + observability=self.observability.to_config(), + native=NativeOptions(slots=self.native), + on_unsupported=self.on_unsupported, + ) + + +__all__ = [ + "BaseCircuitBreakerSettings", + "BaseClientSettings", + "BaseObservabilitySettings", + "BasePoolSettings", + "BaseProxySettings", + "BaseRetrySettings", + "BaseTimeoutSettings", + "BaseTlsSettings", +] diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 83f33e7..05baf8d 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -19,7 +19,7 @@ clientwright/ │ ├── contracts/ # the protocols adapters and backends implement │ └── testing/ # OriginServer, RecordingMetrics, ManualClock ├── adapters/ # one package per SDK; extras-gated, mutually independent -└── contrib/ # deadline-budget and dishka glue +└── contrib/ # deadline-budget, dishka and pydantic settings glue ``` Three import-linter contracts hold the shape: the core never imports adapters, diff --git a/docs/agents.md b/docs/agents.md index 18609c3..b885ffd 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -8,7 +8,7 @@ |---|---| | Package | `clientwright` on PyPI, import root `clientwright` | | Requires | Python 3.12+. The core has zero dependencies; every adapter needs its own SDK | -| Install | `pip install "clientwright[httpx]"` · extras: `httpx`, `httpx2`, `aiohttp`, `requests`, `urllib3`, `metrics`, `tracing`, `observability`, `deadline`, `dishka`, `all` | +| Install | `pip install "clientwright[httpx]"` · extras: `httpx`, `httpx2`, `aiohttp`, `requests`, `urllib3`, `metrics`, `tracing`, `observability`, `deadline`, `dishka`, `settings`, `all` | | Async | `build("httpx" | "httpx2" | "aiohttp", config)` — returns the SDK's own async client | | Sync | `build_sync("httpx" | "httpx2" | "requests" | "urllib3", config)` — returns the SDK's own sync client | | Source | | @@ -43,8 +43,10 @@ telemetry schema. `build("httpx", config)` returns a genuine `httpx.AsyncClient` **It does not** define a request API of its own: you keep calling `client.get(...)` in your SDK's own vocabulary, and clientwright never wraps, subclasses or proxies the client. -It does not parse configuration files, read environment variables, or ship a settings -model. It does not pool, cache or deduplicate responses, does not do client-side load +It does not parse configuration files or read environment variables itself: +`clientwright[settings]` ships the config as pydantic models for a settings object of +yours to nest, and `to_config()` is the one translation back. It does not pool, cache +or deduplicate responses, does not do client-side load balancing (`TargetResolverProtocol` in `core.balancer` is an unimplemented seam), and does not hide the differences between SDKs — it *declares* them and reports what it could not apply. @@ -345,6 +347,8 @@ task-local and thread-local, inherited by tasks started inside it, invisible to | | `current_budget()` | the installed budget or `None` | | | `DeadlineBudgetProtocol` | structural: `remaining() -> float`, `expired() -> bool` | | `clientwright.contrib.dishka` | `ClientwrightProvider(adapter, config, deps=None)` | `Scope.APP` provider giving `ClientRuntime` and a `ClientHandle` closed in `finally` | +| `clientwright.contrib.settings` | `BaseClientSettings` | `ClientConfig` as a pydantic `BaseModel` — nest it in your own `BaseSettings` (`env_nested_delimiter="__"`); `.to_config(service_name)` returns the `ClientConfig`. Never a `BaseSettings` itself, so a bare `BASE_URL` cannot reach a section | +| | `BaseTimeoutSettings`, `BasePoolSettings`, `BaseRetrySettings`, `BaseCircuitBreakerSettings`, `BaseTlsSettings`, `BaseProxySettings`, `BaseObservabilitySettings` | the sub-configs, same field names and defaults, each with `.to_config()`. A knob left unset stays `UNSET`; `null` is the explicit "unbounded"; `retry: None = None` in a subclass disables a section; `url_masker` is not a field | | `clientwright.core.testing` | `OriginServer()` | in-process fault-injecting origin on an ephemeral localhost port | | | `RecordingMetrics()` | a `ClientMetricsProtocol` that remembers every record | | | `ManualClock(start=0.0)` | monotonic clock advanced by hand | @@ -632,6 +636,7 @@ Fetch a page when the task is the one named beside it. | [Your first client](learn/first-client.md) | writing the very first integration end to end | | [Sync and async](learn/sync-and-async.md) | picking a flavor, or explaining hard vs soft deadlines | | [Configuration](guide/configuration.md) | the shape of `ClientConfig`, the defaults, `UNSET` | +| [Environment settings](guide/settings.md) | loading `ClientConfig` from environment variables through the shipped pydantic models | | [Timeouts and deadlines](guide/timeouts.md) | total vs phase, caller overrides, deadline propagation | | [Retries](guide/retries.md) | the decision ladder, backoff, `Retry-After`, the budget | | [Circuit breaker](guide/circuit-breaker.md) | thresholds, half-open probes, choosing the key | @@ -657,6 +662,6 @@ Fetch a page when the task is the one named beside it. | [API reference](reference/index.md) | what is covered by semver, and where each surface is documented | | [Core reference](reference/core.md) | an exact signature or docstring — rendered from source, read it as HTML | | [Adapters reference](reference/adapters.md) | the per-adapter export table, in full | -| [Contrib reference](reference/contrib.md) | the deadline and dishka surfaces | +| [Contrib reference](reference/contrib.md) | the deadline, dishka and settings surfaces | | [Testing reference](reference/testing.md) | the docstrings of the test instruments | | [Changelog](changelog.md) | what changed between versions | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d76f4be..078d691 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -75,9 +75,32 @@ RetryConfig(jitter=1.5) # ValueError: within [0, 1] ## From service settings -Services usually keep settings in a pydantic model. Rather than depending on -pydantic, clientwright accepts anything that structurally matches -`ClientSettingsProtocol` — attribute names, not base classes: +The values that differ between environments come from the environment, and with +`clientwright[settings]` the library owns that path: `clientwright.contrib.settings` +ships one pydantic model per config dataclass — same field names, same defaults — +for a settings object of yours to nest, and `to_config()` is the only translation: + +```python +from pydantic_settings import BaseSettings, SettingsConfigDict + +from clientwright.contrib.settings import BaseClientSettings + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_nested_delimiter="__") + + warehouse: BaseClientSettings = BaseClientSettings() + + +config = Settings().warehouse.to_config("warehouse") # WAREHOUSE__TIMEOUT__TOTAL=10 ... +``` + +The sections are plain `BaseModel`s, reachable only through your settings, so a bare +`BASE_URL` in the pod cannot reach one. The whole mapping is on its own page: +[Environment settings](settings.md). + +Without pydantic, anything that structurally matches `ClientSettingsProtocol` — the +flat, legacy attribute names — goes through `client_config_from_settings`: ```python from clientwright import client_config_from_settings @@ -85,8 +108,8 @@ from clientwright import client_config_from_settings config = client_config_from_settings(settings.warehouse_api, "orders") ``` -Map your settings into a `ClientConfig` in exactly one place (usually next to the -DI wiring) and pass the result around. The config is frozen, so it is safe to share. +Either way, map settings into a `ClientConfig` in exactly one place (usually next to +the DI wiring) and pass the result around. The config is frozen, so it is safe to share. ## The rest of the surface diff --git a/docs/guide/settings.md b/docs/guide/settings.md new file mode 100644 index 0000000..50630c0 --- /dev/null +++ b/docs/guide/settings.md @@ -0,0 +1,126 @@ +# Environment settings + +The part of a client that differs between dev, stage and prod — base URL, timeouts, +pool size, HTTP/2, retry and breaker policy — is exactly the part that has to come +from the environment. With `clientwright[settings]` that path is owned by the +library: one pydantic model per config dataclass, the same field names and the same +defaults, and `to_config()` to get the `ClientConfig`. + +```python +from pydantic_settings import BaseSettings, SettingsConfigDict + +from clientwright import build +from clientwright.contrib.settings import BaseClientSettings + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_nested_delimiter="__") + + warehouse: BaseClientSettings = BaseClientSettings() + identity: BaseClientSettings = BaseClientSettings() + + +settings = Settings() +client = build("httpx", settings.warehouse.to_config("warehouse")) +``` + +```bash +WAREHOUSE__BASE_URL=https://wh.example.com +WAREHOUSE__TIMEOUT__TOTAL=10 +WAREHOUSE__TIMEOUT__CONNECT=2 +WAREHOUSE__POOL__HTTP2=true +WAREHOUSE__RETRY__MAX_ATTEMPTS=5 +WAREHOUSE__RETRY__RETRYABLE_STATUS=[429,500,502,503,504] +WAREHOUSE__CIRCUIT_BREAKER__FAIL_THRESHOLD=2 +WAREHOUSE__ON_UNSUPPORTED=strict +``` + +One section per sub-config, named as the config names it: + +| Section | Model | Builds | +|---|---|---| +| the client itself | `BaseClientSettings` | `ClientConfig` | +| `timeout` | `BaseTimeoutSettings` | `TimeoutConfig` | +| `pool` | `BasePoolSettings` | `PoolConfig` | +| `retry` | `BaseRetrySettings` | `RetryConfig` | +| `circuit_breaker` | `BaseCircuitBreakerSettings` | `CircuitBreakerConfig` | +| `tls` | `BaseTlsSettings` | `TlsConfig` | +| `proxy` | `BaseProxySettings` | `ProxyConfig` | +| `observability` | `BaseObservabilitySettings` | `ObservabilityConfig` | + +The field names, types and defaults are the dataclasses' — the test suite asserts +`BaseClientSettings().to_config("x") == ClientConfig(service_name="x")` and compares +the field names of every pair, so the models cannot drift from +[the config](configuration.md) they describe. + +## Sections are models, not settings + +Every class in `clientwright.contrib.settings` is a plain pydantic `BaseModel`, +never a `BaseSettings`. That is deliberate. A `BaseSettings` scrapes the environment +on its own, and one written per section scrapes it **with no prefix** — a bare +`BASE_URL` or `TIMEOUT` set anywhere in the pod lands in a nested client section +its parent never filled. A `BaseModel` is reachable only through the settings object +you nest it in, so the only way into `warehouse.timeout.total` is +`WAREHOUSE__TIMEOUT__TOTAL`. + +The parent is yours: its prefix, its `env_nested_delimiter`, its `.env` file, how +many client sections it holds. Subclass a model to change a default for one +upstream, and narrow a section to `None` to switch it off: + +```python +from clientwright.contrib.settings import BaseClientSettings, BaseTimeoutSettings + + +class BatchApi(BaseClientSettings): + timeout: BaseTimeoutSettings = BaseTimeoutSettings(total=120.0) + retry: None = None + circuit_breaker: None = None +``` + +## What maps how + +- **`service_name` is the argument of `to_config()`**, not a field. It is the + `service` label on every metric and log line — code identity, not something a + deployment changes. +- **`UNSET` survives.** A knob the config leaves `UNSET` by default (`timeout.read`, + `timeout.attempt`, `pool.http2`, `pool.max_connections_per_host`, ...) reaches + the config as `UNSET` unless you set it — the adapter's native default, exactly as + when you write `TimeoutConfig()` yourself. An explicit `null` is the dataclass' + explicit "unbounded": `WAREHOUSE__TIMEOUT__READ=null` needs + `env_parse_none_str="null"` on the parent, while a whole section + (`WAREHOUSE__RETRY=null`) is parsed as JSON and needs nothing. +- **Enums by value**: `RETRY__MODE=delegated`, `CIRCUIT_BREAKER__KEY=origin_route`, + `REDIRECTS=native`, `ON_UNSUPPORTED=strict`. A typo fails at load. +- **Sets as JSON lists**: `RETRY__RETRYABLE_STATUS=[429,500]`, + `RETRY__RETRYABLE_KINDS=["dns_error","connect_error"]`, + `OBSERVABILITY__SENSITIVE_QUERY_PARAMS=["token"]`. +- **`success_log_level` by name or number**: `OBSERVABILITY__SUCCESS_LOG_LEVEL=DEBUG`. +- **`headers` and `native` as JSON objects**: `HEADERS={"User-Agent": "orders/2.3"}`, + `NATIVE={"client": {"trust_env": false}}` — the latter is validated at build like + any [native passthrough](native-options.md). +- **`tls.cert`** is a PEM path, or a JSON list of two or three paths for + `(cert, key)` / `(cert, key, password)`. +- **`proxy`** is `None` until any of its fields is set: `PROXY__URL=http://proxy:3128` + creates the section; `PROXY__FROM_ENV=true` reads the environment's proxies. +- **`observability.url_masker`** is a callable and has no environment spelling. Set + it on the config afterwards: + + ```python + from dataclasses import replace + + config = settings.identity.to_config("identity") + config = replace(config, observability=replace(config.observability, url_masker=mask_emails)) + ``` + +Bounds are validated at load, with the field path in the error — `timeout.total` +must be positive, `retry.max_attempts >= 1`, `proxy.url` and `proxy.from_env` are +exclusive — so a bad value fails where the deployment set it, not three layers later. +The dataclass validates once more in `to_config()`, so nothing gets past both. + +## Without pydantic + +`ClientSettingsProtocol` and `client_config_from_settings(settings, service_name)` +accept any object with the flat, legacy attribute names (`timeout_seconds`, +`enable_http2`, a four-field `retry`, a three-field `circuit_breaker`) — no base +class and no extra required. Keep it for a service that already carries a settings +model of that shape; write new code against the models above. diff --git a/docs/learn/install.md b/docs/learn/install.md index 6b22f41..dc6ad64 100644 --- a/docs/learn/install.md +++ b/docs/learn/install.md @@ -23,6 +23,7 @@ pip install clientwright[httpx] | `observability` | both of the above | shorthand for `[metrics,tracing]` | | `deadline` | `deadline-budget` | ambient request-budget propagation | | `dishka` | `dishka>=1.4` | the DI provider with a leak-free lifecycle | +| `settings` | `pydantic>=2,<3`, `pydantic-settings>=2.3,<3` | `ClientConfig` from the environment, one pydantic model per sub-config | | `all` | everything above | kitchen sink for experiments | Extras combine the way you expect: diff --git a/docs/reference/contrib.md b/docs/reference/contrib.md index d7a6d06..08c9fb8 100644 --- a/docs/reference/contrib.md +++ b/docs/reference/contrib.md @@ -8,6 +8,10 @@ copy_page: false ::: clientwright.contrib.deadline +## Settings + +::: clientwright.contrib.settings + ## Dishka `clientwright.contrib.dishka` imports `dishka` at module import time (by design — diff --git a/docs/reference/index.md b/docs/reference/index.md index dfab23b..a7918d4 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -7,7 +7,7 @@ Auto-generated from source docstrings. The prose lives in the |---|---| | [Core](core.md) | `build` / `build_sync` / `inspect`, `ClientConfig` and all sub-configs, the data model, errors, capabilities, plans and runtime | | [Adapters](adapters.md) | per-adapter public exports: per-call channels and dual-family errors | -| [Contrib](contrib.md) | deadline-budget and dishka integrations | +| [Contrib](contrib.md) | deadline-budget, dishka and pydantic settings integrations | | [Testing](testing.md) | `OriginServer`, `RecordingMetrics`, `ManualClock` | ## What is stable diff --git a/pyproject.toml b/pyproject.toml index 3d28b4c..bf96d18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,8 @@ tracing = ["opentelemetry-api>=1.26"] observability = ["clientwright[metrics,tracing]"] deadline = ["deadline-budget>=0.1,<1"] dishka = ["dishka>=1.4"] -all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka]"] +settings = ["pydantic>=2,<3", "pydantic-settings>=2.3,<3"] +all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka,settings]"] [dependency-groups] test = [ diff --git a/tests/unit/adapters/test_bare_install.py b/tests/unit/adapters/test_bare_install.py index 7e47877..b0c33a1 100644 --- a/tests/unit/adapters/test_bare_install.py +++ b/tests/unit/adapters/test_bare_install.py @@ -23,6 +23,8 @@ "opentelemetry", "dishka", "deadline_budget", + "pydantic", + "pydantic_settings", ) BLOCKER = """ @@ -147,3 +149,17 @@ def test__zero_dep_surface__config_engine_and_testing_tools_work() -> None: """ ) assert output == "core usable" + + +def test__settings_models_without_the_extra__fail_with_the_install_hint() -> None: + output = run_probe( + """ + try: + import clientwright.contrib.settings + except ImportError as error: + print(error) + else: + print("NO ERROR") + """ + ) + assert "clientwright[settings]" in output diff --git a/tests/unit/contrib/test_settings.py b/tests/unit/contrib/test_settings.py new file mode 100644 index 0000000..9214bbf --- /dev/null +++ b/tests/unit/contrib/test_settings.py @@ -0,0 +1,213 @@ +"""contrib units: the settings models and their translation into ClientConfig.""" + +from __future__ import annotations + +import dataclasses +import logging + +import pytest + +pytest.importorskip("pydantic", reason="requires the [settings] extra") + +from pydantic import ValidationError + +from clientwright import ( + UNSET, + CircuitBreakerConfig, + CircuitKey, + ClientConfig, + FailureKind, + NativeOptions, + ObservabilityConfig, + PoolConfig, + ProxyConfig, + RetryConfig, + RetryMode, + TimeoutConfig, + TlsConfig, + UnsupportedPolicy, + build_sync, +) +from clientwright.contrib import settings as settings_module +from clientwright.contrib.settings import ( + BaseCircuitBreakerSettings, + BaseClientSettings, + BaseObservabilitySettings, + BasePoolSettings, + BaseProxySettings, + BaseRetrySettings, + BaseTimeoutSettings, + BaseTlsSettings, +) + +# --- the models are the dataclasses, written once --- + +PAIRS = [ + (BaseTimeoutSettings, TimeoutConfig, set()), + (BasePoolSettings, PoolConfig, set()), + (BaseRetrySettings, RetryConfig, set()), + (BaseCircuitBreakerSettings, CircuitBreakerConfig, set()), + (BaseTlsSettings, TlsConfig, set()), + (BaseProxySettings, ProxyConfig, set()), + (BaseObservabilitySettings, ObservabilityConfig, {"url_masker"}), + (BaseClientSettings, ClientConfig, {"service_name"}), +] + + +@pytest.mark.parametrize(("model", "dataclass", "not_env_shaped"), PAIRS, ids=[m.__name__ for m, _, _ in PAIRS]) +def test__every_model__names_exactly_the_dataclass_fields( + model: type, dataclass: type, not_env_shaped: set[str] +) -> None: + assert set(model.model_fields) == {f.name for f in dataclasses.fields(dataclass)} - not_env_shaped + + +def test__defaults__are_the_dataclass_defaults() -> None: + assert BaseClientSettings().to_config("orders") == ClientConfig(service_name="orders") + + +def test__no_model__is_a_base_settings() -> None: + pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra") + for name in settings_module.__all__: + assert not issubclass(getattr(settings_module, name), pydantic_settings.BaseSettings), name + + +# --- the environment --- + + +def test__nested_in_a_base_settings__reads_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: + pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra") + + class Settings(pydantic_settings.BaseSettings): + model_config = pydantic_settings.SettingsConfigDict(env_nested_delimiter="__") + + warehouse: BaseClientSettings = BaseClientSettings() + + monkeypatch.setenv("WAREHOUSE__BASE_URL", "https://wh.example.com") + monkeypatch.setenv("WAREHOUSE__TIMEOUT__TOTAL", "10") + monkeypatch.setenv("WAREHOUSE__TIMEOUT__CONNECT", "2") + monkeypatch.setenv("WAREHOUSE__POOL__HTTP2", "true") + monkeypatch.setenv("WAREHOUSE__RETRY__MAX_ATTEMPTS", "5") + monkeypatch.setenv("WAREHOUSE__RETRY__RETRYABLE_STATUS", "[429, 500]") + monkeypatch.setenv("WAREHOUSE__CIRCUIT_BREAKER__FAIL_THRESHOLD", "2") + monkeypatch.setenv("WAREHOUSE__TLS__CERT", '["client.pem", "client.key"]') + monkeypatch.setenv("WAREHOUSE__PROXY__URL", "http://proxy:3128") + monkeypatch.setenv("WAREHOUSE__ON_UNSUPPORTED", "strict") + + config = Settings().warehouse.to_config("warehouse") + + assert config == ClientConfig( + service_name="warehouse", + base_url="https://wh.example.com", + timeout=TimeoutConfig(total=10.0, connect=2.0), + pool=PoolConfig(http2=True), + retry=RetryConfig(max_attempts=5, retryable_status=frozenset({429, 500})), + circuit_breaker=CircuitBreakerConfig(fail_threshold=2), + tls=TlsConfig(cert=("client.pem", "client.key")), + proxy=ProxyConfig(url="http://proxy:3128"), + on_unsupported=UnsupportedPolicy.STRICT, + ) + + +def test__bare_variables__cannot_reach_a_section(monkeypatch: pytest.MonkeyPatch) -> None: + pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra") + + class Settings(pydantic_settings.BaseSettings): + model_config = pydantic_settings.SettingsConfigDict(env_nested_delimiter="__") + + warehouse: BaseClientSettings = BaseClientSettings() + + class LeakySection(pydantic_settings.BaseSettings): + base_url: str | None = None + + monkeypatch.setenv("BASE_URL", "https://leak.example.com") + monkeypatch.setenv("TIMEOUT", "1") + monkeypatch.setenv("TOTAL", "1") + monkeypatch.setenv("MAX_ATTEMPTS", "9") + monkeypatch.setenv("HTTP2", "true") + monkeypatch.setenv("VERIFY", "false") + + assert LeakySection().base_url == "https://leak.example.com" # what a BaseSettings section would do + assert Settings().warehouse.to_config("warehouse") == ClientConfig(service_name="warehouse") + + +def test__null_in_the_environment__disables_a_section_and_unbounds_a_phase(monkeypatch: pytest.MonkeyPatch) -> None: + pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra") + + class Settings(pydantic_settings.BaseSettings): + model_config = pydantic_settings.SettingsConfigDict(env_nested_delimiter="__", env_parse_none_str="null") + + warehouse: BaseClientSettings = BaseClientSettings() + + monkeypatch.setenv("WAREHOUSE__RETRY", "null") + monkeypatch.setenv("WAREHOUSE__TIMEOUT__READ", "null") + + config = Settings().warehouse.to_config("warehouse") + + assert config.retry is None + assert config.timeout.read is None + assert config.timeout.write is UNSET + + +# --- UNSET, None and the env spellings --- + + +def test__an_unset_knob__stays_unset_and_null_is_unbounded() -> None: + assert BaseTimeoutSettings().to_config().read is UNSET + assert BaseTimeoutSettings(read=None).to_config().read is None + assert BaseTimeoutSettings(read=2.0).to_config().read == 2.0 + assert BasePoolSettings().to_config().max_connections_per_host is UNSET + assert BasePoolSettings().to_config().http2 is UNSET + assert BasePoolSettings(http2=False).to_config().http2 is False + + +def test__enums_sets_and_levels__are_coerced_from_their_env_spellings() -> None: + retry = BaseRetrySettings(mode="delegated", retryable_kinds=["dns_error"], methods=["GET"]).to_config() + assert retry.mode is RetryMode.DELEGATED + assert retry.retryable_kinds == frozenset({FailureKind.DNS_ERROR}) + assert retry.methods == frozenset({"GET"}) + assert BaseCircuitBreakerSettings(key="origin_route").to_config().key is CircuitKey.ORIGIN_ROUTE + assert BaseObservabilitySettings(success_log_level="debug").to_config().success_log_level == logging.DEBUG + assert BaseObservabilitySettings(success_log_level=30).to_config().success_log_level == logging.WARNING + with pytest.raises(ValidationError, match="unknown log level"): + BaseObservabilitySettings(success_log_level="LOUD") + + +def test__sections__disable_with_none_and_proxy_appears_on_demand() -> None: + class Batch(BaseClientSettings): + retry: None = None + circuit_breaker: None = None + + config = Batch().to_config("batch") + assert config.retry is None + assert config.circuit_breaker is None + assert config.proxy is None + assert BaseClientSettings(proxy=BaseProxySettings(from_env=True)).to_config("x").proxy == ProxyConfig(from_env=True) + + +def test__headers_and_native__reach_the_config() -> None: + config = BaseClientSettings(headers={"User-Agent": "svc/1"}, native={"client": {"trust_env": False}}).to_config( + "svc" + ) + assert config.headers == {"User-Agent": "svc/1"} + assert config.native == NativeOptions.of(client={"trust_env": False}) + + +def test__bad_values__fail_at_load_with_the_field_path() -> None: + with pytest.raises(ValidationError, match=r"timeout\.total"): + BaseClientSettings(timeout={"total": 0}) + with pytest.raises(ValidationError, match="base_url"): + BaseClientSettings(base_url="ftp://nope") + with pytest.raises(ValidationError, match="max_attempts"): + BaseRetrySettings(max_attempts=0) + with pytest.raises(ValidationError, match="mutually exclusive"): + BaseProxySettings(url="http://proxy:3128", from_env=True) + + +def test__a_config_from_settings__builds_the_native_client() -> None: + httpx = pytest.importorskip("httpx", reason="requires the [httpx] extra") + config = BaseClientSettings(base_url="https://api.example.com", pool={"http2": False}).to_config("api") + client = build_sync("httpx", config) + try: + assert type(client) is httpx.Client + finally: + client.close() diff --git a/tests/unit/test_sdk_imports_are_guarded.py b/tests/unit/test_sdk_imports_are_guarded.py index 6dd77b3..aeebe1c 100644 --- a/tests/unit/test_sdk_imports_are_guarded.py +++ b/tests/unit/test_sdk_imports_are_guarded.py @@ -26,6 +26,8 @@ "prometheus_client", "opentelemetry", "deadline_budget", + "pydantic", + "pydantic_settings", } ) diff --git a/uv.lock b/uv.lock index bfbca75..86218b4 100644 --- a/uv.lock +++ b/uv.lock @@ -128,6 +128,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -391,6 +400,8 @@ all = [ { name = "httpx2" }, { name = "opentelemetry-api" }, { name = "prometheus-client" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "requests" }, { name = "urllib3" }, ] @@ -416,6 +427,10 @@ observability = [ requests = [ { name = "requests" }, ] +settings = [ + { name = "pydantic" }, + { name = "pydantic-settings" }, +] tracing = [ { name = "opentelemetry-api" }, ] @@ -467,12 +482,16 @@ requires-dist = [ { name = "prometheus-client", marker = "extra == 'all'", specifier = ">=0.20" }, { name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20" }, { name = "prometheus-client", marker = "extra == 'observability'", specifier = ">=0.20" }, + { name = "pydantic", marker = "extra == 'all'", specifier = ">=2,<3" }, + { name = "pydantic", marker = "extra == 'settings'", specifier = ">=2,<3" }, + { name = "pydantic-settings", marker = "extra == 'all'", specifier = ">=2.3,<3" }, + { name = "pydantic-settings", marker = "extra == 'settings'", specifier = ">=2.3,<3" }, { name = "requests", marker = "extra == 'all'", specifier = ">=2.32,<3" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.32,<3" }, { name = "urllib3", marker = "extra == 'all'", specifier = ">=2.2,<3" }, { name = "urllib3", marker = "extra == 'urllib3'", specifier = ">=2.2,<3" }, ] -provides-extras = ["aiohttp", "all", "deadline", "dishka", "httpx", "httpx2", "metrics", "observability", "requests", "tracing", "urllib3"] +provides-extras = ["aiohttp", "all", "deadline", "dishka", "httpx", "httpx2", "metrics", "observability", "requests", "settings", "tracing", "urllib3"] [package.metadata.requires-dev] dev = [ @@ -1708,6 +1727,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1797,6 +1920,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1989,6 +2121,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" diff --git a/zensical.toml b/zensical.toml index 4b272b8..c3e1725 100644 --- a/zensical.toml +++ b/zensical.toml @@ -24,6 +24,7 @@ nav = [ ] }, { "Guide" = [ { "Configuration" = "guide/configuration.md" }, + { "Environment settings" = "guide/settings.md" }, { "Timeouts and deadlines" = "guide/timeouts.md" }, { "Retries" = "guide/retries.md" }, { "Circuit breaker" = "guide/circuit-breaker.md" }, From fde5c2d0c96f478262f90e999c991b1f359b02ee Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:57:30 +0300 Subject: [PATCH 2/2] feat: say why base_url is optional on BaseClientSettings --- clientwright/contrib/settings.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/clientwright/contrib/settings.py b/clientwright/contrib/settings.py index ac1e007..c3c42ab 100644 --- a/clientwright/contrib/settings.py +++ b/clientwright/contrib/settings.py @@ -255,7 +255,12 @@ class BaseClientSettings(BaseModel): fields is set. """ - base_url: str | None = Field(default=None, pattern=r"^https?://", description="Origin every relative URL joins") + base_url: str | None = Field( + default=None, + pattern=r"^https?://", + description="Origin every relative URL joins; unset means absolute URLs per call, " + "the only mode the requests and urllib3 adapters support", + ) timeout: BaseTimeoutSettings = Field(default_factory=BaseTimeoutSettings) pool: BasePoolSettings = Field(default_factory=BasePoolSettings) retry: BaseRetrySettings | None = Field(default_factory=BaseRetrySettings)