feat: pydantic settings models for ClientConfig behind the settings extra - #33
Conversation
…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
|
Reporter here — the model covers everything our local one had, and the nested shape is a rewrite on
For a client section named after a service, a missing We can narrow it in a subclass, and narrowing a shipped field is idiomatic (servicewright documents
No strong preference from us beyond knowing which of the two it is. |
d3554d1 to
fd94518
Compare
fd94518 to
fde5c2d
Compare
|
It is a supported mode, and the shipped model has to keep 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, |
Closes #32.
Problem
ClientConfigand its sub-configs are dataclasses with no env-facing layer.ClientSettingsProtocol+client_config_from_settingsexist, but they name only the legacy flat surface (timeout_seconds,enable_http2, a four-field retry, a three-field breaker), so a service that wantspool.max_connections_per_host,retry.retryable_status,tls.certorproxyfrom the environment writes its own pydantic models and its own mapping onto our field set. Those hand-written sections tend to be oneBaseSettingseach, which scrape the environment with no prefix — a bareBASE_URLorTIMEOUTin 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.BaseClientSettings,BaseTimeoutSettings,BasePoolSettings,BaseRetrySettings,BaseCircuitBreakerSettings,BaseTlsSettings,BaseProxySettings,BaseObservabilitySettings— all plainBaseModel, none aBaseSettings. 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_sectionsetsBASE_URL,TIMEOUT,TOTAL,MAX_ATTEMPTS,HTTP2,VERIFYin the environment and asserts the section is untouched (with aBaseSettingsnegative control that does pickBASE_URLup).service_nameis the argument ofto_config(service_name)(as inclient_config_from_settings(settings, service_name)), andobservability.url_maskeris a callable to set on the config afterwards.nativeis{slot: {kwarg: value}}, thefrozensetfields take JSON lists, enums their values,success_log_levela name or a number,tls.certa path or a JSON list.UNSETsurvives. A knob the config leavesUNSETby default (timeout.read,pool.http2, ...) reaches the config asUNSETunless it was given; an explicitnullis the dataclass' explicit "unbounded".model_fields_settells the two apart, so the layer does not collapse the three states rule 3 of the agents page is about.retry/circuit_breakerare on by default and go off withretry: None = Nonein a subclass orRETRY=null;proxyisNoneuntil aPROXY__*variable creates it.gt=0,ge=1, the ratio ranges,base_urlscheme, proxy exclusivity); the dataclass validates again into_config().BaseClientSettings().to_config("x") == ClientConfig(service_name="x")(one assertion over every default), and the field names of each model/dataclass pair are compared.pydantic>=2,<3andpydantic-settings>=2.3,<3. The module imports only pydantic; pydantic-settings is in the extra the waydeadline-budgetis in[deadline]— the library the module is built to be nested in.allincludes it. Missing extra →ImportErrornamingclientwright[settings];pydantic/pydantic_settingsjoin the bare-install probe's blocked list and the guarded-import scan.client_config_from_settingsand 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 --clean→No issues found),agents.md(install row, the scope paragraph that said the library ships no settings model, the contrib table), README.Rejected
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.BaseSettingsparent — it would own the prefix and the delimiter, which belong to the root settings, and it is the shape the hazard is about.core— "Core is stdlib-only" forbids pydantic, and should.Verification
Baseline on
origin/master:make checkclean,make test→829 passed, 42 skipped, coverage 99.60%.After:
The reporter's probe (
pkgutil.walk_packagesoverclientwright, collectingBaseModelsubclasses) run against anorigin/masterworktree and against this branch with the same venv:uv.lockchanges only by the new extra (uv lockrun on purpose; unchanged by the latermakeruns and the docs build).