Skip to content

feat: pydantic settings models for ClientConfig behind the settings extra - #33

Merged
AlexeyShalaev merged 2 commits into
masterfrom
feat/settings
Sep 14, 2026
Merged

AlexeyShalaev merged 2 commits into
masterfrom
feat/settings

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

Closes #32.

Problem

ClientConfig and its sub-configs are dataclasses with no env-facing layer. ClientSettingsProtocol + client_config_from_settings exist, but they name only the legacy flat surface (timeout_seconds, enable_http2, a four-field retry, a three-field breaker), so a service that wants pool.max_connections_per_host, retry.retryable_status, tls.cert or proxy from the environment writes its own pydantic models and its own mapping onto our field set. Those hand-written sections tend to be one BaseSettings each, which scrape the environment with no prefix — a bare BASE_URL or TIMEOUT in a pod lands in a nested client section the parent never filled.

Design

clientwright[settings]clientwright.contrib.settings: one pydantic model per config dataclass, same field names, same defaults, to_config() on each.

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_nested_delimiter="__")

    warehouse: BaseClientSettings = BaseClientSettings()

config = Settings().warehouse.to_config("warehouse")  # WAREHOUSE__BASE_URL=... WAREHOUSE__TIMEOUT__TOTAL=10
  • BaseClientSettings, BaseTimeoutSettings, BasePoolSettings, BaseRetrySettings, BaseCircuitBreakerSettings, BaseTlsSettings, BaseProxySettings, BaseObservabilitySettings — all plain BaseModel, none a BaseSettings. A section is reachable only through the settings object it is nested in; the parent, its prefix and its delimiter stay the service's. test__bare_variables__cannot_reach_a_section sets BASE_URL, TIMEOUT, TOTAL, MAX_ATTEMPTS, HTTP2, VERIFY in the environment and asserts the section is untouched (with a BaseSettings negative control that does pick BASE_URL up).
  • The field set is the dataclasses' minus two things that are not environment-shaped: service_name is the argument of to_config(service_name) (as in client_config_from_settings(settings, service_name)), and observability.url_masker is a callable to set on the config afterwards. native is {slot: {kwarg: value}}, the frozenset fields take JSON lists, enums their values, success_log_level a name or a number, tls.cert a path or a JSON list.
  • UNSET survives. A knob the config leaves UNSET by default (timeout.read, pool.http2, ...) reaches the config as UNSET unless it was given; an explicit null is the dataclass' explicit "unbounded". model_fields_set tells the two apart, so the layer does not collapse the three states rule 3 of the agents page is about.
  • retry / circuit_breaker are on by default and go off with retry: None = None in a subclass or RETRY=null; proxy is None until a PROXY__* variable creates it.
  • Bounds are validated at load with the field path in the error (gt=0, ge=1, the ratio ranges, base_url scheme, proxy exclusivity); the dataclass validates again in to_config().
  • Drift tests: BaseClientSettings().to_config("x") == ClientConfig(service_name="x") (one assertion over every default), and the field names of each model/dataclass pair are compared.
  • The extra pins pydantic>=2,<3 and pydantic-settings>=2.3,<3. The module imports only pydantic; pydantic-settings is in the extra the way deadline-budget is in [deadline] — the library the module is built to be nested in. all includes it. Missing extra → ImportError naming clientwright[settings]; pydantic / pydantic_settings join the bare-install probe's blocked list and the guarded-import scan.
  • client_config_from_settings and the flat protocol stay as they are; the docs now lead with the models and keep the protocol as the pydantic-free path.

Docs: new guide/settings.md (nav + agents map), guide/configuration.md, learn/install.md, reference/contrib.md (auto-rendered; uv run --isolated --no-dev --group docs zensical build --cleanNo issues found), agents.md (install row, the scope paragraph that said the library ships no settings model, the contrib table), README.

Rejected

  • Widening the structural protocol to the full field set — the consumer still writes the model and the mapping, and the BaseSettings-per-section hazard is untouched.
  • ClientConfig.from_mapping(dict) in the core — source-agnostic and stdlib-only, but the field set is still re-derived on the consumer's side, without validation or defaults.
  • A shipped BaseSettings parent — it would own the prefix and the delimiter, which belong to the root settings, and it is the shape the hazard is about.
  • Anything in core — "Core is stdlib-only" forbids pydantic, and should.

Verification

Baseline on origin/master: make check clean, make test829 passed, 42 skipped, coverage 99.60%.

After:

$ make check
All checks passed!            # ruff check + format --check
Success: no issues found in 98 source files
Contracts: 3 kept, 0 broken.

$ make test
clientwright/contrib/settings.py    104    0   100%
Required test coverage of 97% reached. Total coverage: 99.61%
854 passed, 42 skipped in 116.42s

The reporter's probe (pkgutil.walk_packages over clientwright, collecting BaseModel subclasses) run against an origin/master worktree and against this branch with the same venv:

== origin/master
[]
== feat/settings
['BaseCircuitBreakerSettings', 'BaseClientSettings', 'BaseObservabilitySettings', 'BasePoolSettings', 'BaseProxySettings', 'BaseRetrySettings', 'BaseTimeoutSettings', 'BaseTlsSettings']

uv.lock changes only by the new extra (uv lock run on purpose; unchanged by the later make runs and the docs build).

…xtra

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
@AlexeyShalaev

Copy link
Copy Markdown
Member Author

Reporter here — the model covers everything our local one had, and the nested shape is a rewrite on
our side rather than a workaround, which is fine. One question on a default before this merges.

base_url is str | None = None; ours is required.

For a client section named after a service, a missing base_url is a misconfiguration we want the
process to refuse at startup. Optional means a missing AUTH__SOME_SERVICE__BASE_URL yields None
and surfaces at the first call instead — in a pod that has already reported ready.

We can narrow it in a subclass, and narrowing a shipped field is idiomatic (servicewright documents
exactly that for metrics), so this is not a blocker. But it is a default every consumer of a
service-to-service client will want inverted, so it is worth being deliberate about:

  • if a client with no origin is a supported mode — absolute URLs only, or the origin arriving per
    call — then optional is right and it would help to say so in the field description, so the
    narrowing is an informed choice rather than a guess;
  • if it is not a supported mode, requiring it here buys every consumer a boot-time check for free.

No strong preference from us beyond knowing which of the two it is.

@AlexeyShalaev

Copy link
Copy Markdown
Member Author

It is a supported mode, and the shipped model has to keep it: ClientConfig.base_url is str | None for a reason — httpx and aiohttp join relative URLs against it, but the requests and urllib3 adapters have no base URL at all and reject a config that sets one, so "absolute URLs per call" is the only shape that works on every adapter. The model mirrors the config there rather than deciding for it.

fde5c2d puts that in the field description, so the narrowing is the informed choice you describe: for a service-to-service client on httpx, base_url: str in your subclass is the right call, and the boot-time check comes with it.

@AlexeyShalaev
AlexeyShalaev merged commit 3c5006f into master Sep 14, 2026
8 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the feat/settings branch September 14, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientConfig and friends have no env-facing layer, so each consumer writes the settings models again

1 participant