clientwright 0.2.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {clientwright-0.2.2 → clientwright-0.3.0}/CHANGELOG.md +7 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/PKG-INFO +29 -1
- {clientwright-0.2.2 → clientwright-0.3.0}/README.md +23 -0
- clientwright-0.3.0/clientwright/__version__.py +1 -0
- clientwright-0.3.0/clientwright/contrib/settings.py +316 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/pyproject.toml +2 -1
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/test_bare_install.py +16 -0
- clientwright-0.3.0/tests/unit/contrib/test_settings.py +213 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/test_sdk_imports_are_guarded.py +2 -0
- clientwright-0.2.2/clientwright/__version__.py +0 -1
- {clientwright-0.2.2 → clientwright-0.3.0}/.gitignore +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/LICENSE +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/_httpx_shared.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/_lazy.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/middleware.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/options.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/trace.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/aiohttp/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/normalize_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/transport.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/normalize_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/transport.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/httpx2/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/observability/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/observability/_metrics/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/observability/_metrics/prometheus.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/observability/_tracing/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/observability/_tracing/otel.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/requests/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/adapters/urllib3/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/contrib/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/contrib/deadline.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/contrib/dishka.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/balancer/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/balancer/policy.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/config.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/contracts/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/contracts/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/contracts/context.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/contracts/message.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/contracts/observability.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/contracts/settings.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/engine/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/engine/aio.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/engine/base.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/engine/redirects.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/engine/suppress.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/engine/sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/model.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/native.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/options.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/plan.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/policy/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/policy/budget.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/policy/circuit.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/policy/concurrency.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/policy/retry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/policy/timeout.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/registry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/telemetry/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/telemetry/emitter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/telemetry/names.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/telemetry/null.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/telemetry/redaction.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/testing/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/testing/doubles.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/core/testing/origin.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/clientwright/py.typed +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/helpers/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/helpers/drivers.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/helpers/engine.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/helpers/telemetry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/helpers/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/adapters/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/adapters/test_aiohttp.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/adapters/test_httpx2.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/adapters/test_httpx_async.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/adapters/test_httpx_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/adapters/test_requests.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/adapters/test_urllib3.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/chaos/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/chaos/test_faults.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/chaos/test_origin_faults.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/contrib/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/contrib/test_deadline.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/contrib/test_dishka.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/parity/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/integration/parity/test_semantics.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/live/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/live/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/live/test_public_endpoints.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/live/test_tls_and_dns.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/aiohttp/test_build.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/aiohttp/test_normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/aiohttp/test_trace.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/aiohttp/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/httpx/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/httpx/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/httpx2/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/observability/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/observability/test_exports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/observability/test_otel.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/observability/test_prometheus.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/requests/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/requests/test_adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/requests/test_normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/requests/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/test_httpx_family_parity.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/test_httpx_shared.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/urllib3/test_adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/urllib3/test_normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/adapters/urllib3/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/contrib/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/contrib/test_deadline.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/contrib/test_deadline_context.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/contrib/test_dishka.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/engine/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/engine/test_aio.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/engine/test_base.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/engine/test_redirects.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/engine/test_suppress.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/engine/test_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_budget.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_circuit.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_circuit_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_concurrency.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_retry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_retry_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_timeout.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/policy/test_timeout_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/telemetry/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/telemetry/test_emitter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/telemetry/test_masking.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/telemetry/test_redaction.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/telemetry/test_redaction_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_balancer.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_capabilities_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_config.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_enum_coercion.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_model.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_options.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_plan.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_registry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/core/test_settings.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/test_docs_snippets.py +0 -0
- {clientwright-0.2.2 → clientwright-0.3.0}/tests/unit/test_version.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.2.2...clientwright-v0.3.0) (2026-09-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* pydantic settings models for ClientConfig behind the settings extra ([3c5006f](https://github.com/bedrock-python/clientwright/commit/3c5006f3b61e50266d6f266ea1e997dbc80d5535))
|
|
9
|
+
|
|
3
10
|
## [0.2.2](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.2.1...clientwright-v0.2.2) (2026-09-06)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: clientwright
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: HTTP client platform: one resilience and observability core with adapters for httpx, aiohttp, requests, urllib3 and friends
|
|
5
5
|
Project-URL: Repository, https://github.com/bedrock-python/clientwright
|
|
6
6
|
Project-URL: Documentation, https://bedrock-python.github.io/clientwright/
|
|
@@ -32,6 +32,8 @@ Requires-Dist: httpx2<3,>=2; extra == 'all'
|
|
|
32
32
|
Requires-Dist: httpx<1,>=0.28; extra == 'all'
|
|
33
33
|
Requires-Dist: opentelemetry-api>=1.26; extra == 'all'
|
|
34
34
|
Requires-Dist: prometheus-client>=0.20; extra == 'all'
|
|
35
|
+
Requires-Dist: pydantic-settings<3,>=2.3; extra == 'all'
|
|
36
|
+
Requires-Dist: pydantic<3,>=2; extra == 'all'
|
|
35
37
|
Requires-Dist: requests<3,>=2.32; extra == 'all'
|
|
36
38
|
Requires-Dist: urllib3<3,>=2.2; extra == 'all'
|
|
37
39
|
Provides-Extra: deadline
|
|
@@ -49,6 +51,9 @@ Requires-Dist: opentelemetry-api>=1.26; extra == 'observability'
|
|
|
49
51
|
Requires-Dist: prometheus-client>=0.20; extra == 'observability'
|
|
50
52
|
Provides-Extra: requests
|
|
51
53
|
Requires-Dist: requests<3,>=2.32; extra == 'requests'
|
|
54
|
+
Provides-Extra: settings
|
|
55
|
+
Requires-Dist: pydantic-settings<3,>=2.3; extra == 'settings'
|
|
56
|
+
Requires-Dist: pydantic<3,>=2; extra == 'settings'
|
|
52
57
|
Provides-Extra: tracing
|
|
53
58
|
Requires-Dist: opentelemetry-api>=1.26; extra == 'tracing'
|
|
54
59
|
Provides-Extra: urllib3
|
|
@@ -134,6 +139,7 @@ pip install clientwright[httpx] # httpx adapter (sync + async)
|
|
|
134
139
|
pip install clientwright[aiohttp] # aiohttp adapter (async)
|
|
135
140
|
pip install clientwright[httpx,metrics] # + Prometheus backend
|
|
136
141
|
pip install clientwright[httpx,tracing] # + OpenTelemetry backend
|
|
142
|
+
pip install clientwright[httpx,settings] # + ClientConfig from the environment
|
|
137
143
|
```
|
|
138
144
|
|
|
139
145
|
## Sync twin
|
|
@@ -202,6 +208,28 @@ with use_budget(BudgetContext.create(total_seconds=5.0)):
|
|
|
202
208
|
await client.get("/users") # runs with what is left of those 5 seconds
|
|
203
209
|
```
|
|
204
210
|
|
|
211
|
+
## From the environment
|
|
212
|
+
|
|
213
|
+
With `clientwright[settings]`, `ClientConfig` and every sub-config exist as pydantic
|
|
214
|
+
models with the same fields and defaults. They are plain `BaseModel`s — nest them in
|
|
215
|
+
your own settings, so a bare `BASE_URL` in a pod can never reach a section:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
219
|
+
|
|
220
|
+
from clientwright.contrib.settings import BaseClientSettings
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Settings(BaseSettings):
|
|
224
|
+
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
|
225
|
+
|
|
226
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true
|
|
230
|
+
config = Settings().warehouse.to_config("warehouse")
|
|
231
|
+
```
|
|
232
|
+
|
|
205
233
|
## Adapters
|
|
206
234
|
|
|
207
235
|
| adapter | modes | notes |
|
|
@@ -77,6 +77,7 @@ pip install clientwright[httpx] # httpx adapter (sync + async)
|
|
|
77
77
|
pip install clientwright[aiohttp] # aiohttp adapter (async)
|
|
78
78
|
pip install clientwright[httpx,metrics] # + Prometheus backend
|
|
79
79
|
pip install clientwright[httpx,tracing] # + OpenTelemetry backend
|
|
80
|
+
pip install clientwright[httpx,settings] # + ClientConfig from the environment
|
|
80
81
|
```
|
|
81
82
|
|
|
82
83
|
## Sync twin
|
|
@@ -145,6 +146,28 @@ with use_budget(BudgetContext.create(total_seconds=5.0)):
|
|
|
145
146
|
await client.get("/users") # runs with what is left of those 5 seconds
|
|
146
147
|
```
|
|
147
148
|
|
|
149
|
+
## From the environment
|
|
150
|
+
|
|
151
|
+
With `clientwright[settings]`, `ClientConfig` and every sub-config exist as pydantic
|
|
152
|
+
models with the same fields and defaults. They are plain `BaseModel`s — nest them in
|
|
153
|
+
your own settings, so a bare `BASE_URL` in a pod can never reach a section:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
157
|
+
|
|
158
|
+
from clientwright.contrib.settings import BaseClientSettings
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Settings(BaseSettings):
|
|
162
|
+
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
|
163
|
+
|
|
164
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true
|
|
168
|
+
config = Settings().warehouse.to_config("warehouse")
|
|
169
|
+
```
|
|
170
|
+
|
|
148
171
|
## Adapters
|
|
149
172
|
|
|
150
173
|
| adapter | modes | notes |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0" # x-release-please-version
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Settings models for ``ClientConfig`` (``[settings]`` extra).
|
|
2
|
+
|
|
3
|
+
One pydantic model per config dataclass - the same field names, the same
|
|
4
|
+
defaults - and ``to_config()`` on each, so the translation from an
|
|
5
|
+
environment-loaded settings shape to a ``ClientConfig`` is written here, once,
|
|
6
|
+
instead of in every service that uses the library.
|
|
7
|
+
|
|
8
|
+
Every class here is a plain ``BaseModel``, never a ``BaseSettings``: a section
|
|
9
|
+
is reachable only through the settings object you nest it in, so a bare
|
|
10
|
+
``BASE_URL`` or ``TIMEOUT`` in a pod cannot reach it. The parent - its prefix,
|
|
11
|
+
its nesting delimiter, its ``.env`` file - stays yours::
|
|
12
|
+
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
from clientwright.contrib.settings import BaseClientSettings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Settings(BaseSettings):
|
|
19
|
+
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
|
20
|
+
|
|
21
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true
|
|
25
|
+
config = Settings().warehouse.to_config("warehouse")
|
|
26
|
+
|
|
27
|
+
``UNSET`` survives the trip: a knob the config leaves ``UNSET`` by default
|
|
28
|
+
(``timeout.read``, ``pool.http2``, ...) reaches the config as ``UNSET`` unless
|
|
29
|
+
you set it, and an explicit ``null`` is the dataclass' explicit "unbounded" -
|
|
30
|
+
the models tell the two apart with ``model_fields_set``. Two things are not
|
|
31
|
+
environment-shaped and are not here: ``service_name`` is the argument of
|
|
32
|
+
:meth:`BaseClientSettings.to_config`, and ``observability.url_masker`` is a
|
|
33
|
+
callable to set on the config afterwards.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from typing import Any, Final
|
|
40
|
+
|
|
41
|
+
from ..core.config import (
|
|
42
|
+
DEFAULT_RETRYABLE_KINDS,
|
|
43
|
+
DEFAULT_RETRYABLE_STATUS,
|
|
44
|
+
DEFAULT_SENSITIVE_QUERY_PARAMS,
|
|
45
|
+
DEFAULT_TRIP_KINDS,
|
|
46
|
+
UNSET,
|
|
47
|
+
CallerOverride,
|
|
48
|
+
CircuitBreakerConfig,
|
|
49
|
+
ClientConfig,
|
|
50
|
+
NativeOptions,
|
|
51
|
+
ObservabilityConfig,
|
|
52
|
+
PoolConfig,
|
|
53
|
+
ProxyConfig,
|
|
54
|
+
RedirectMode,
|
|
55
|
+
RetryConfig,
|
|
56
|
+
RetryMode,
|
|
57
|
+
TimeoutConfig,
|
|
58
|
+
TlsConfig,
|
|
59
|
+
UnsupportedPolicy,
|
|
60
|
+
)
|
|
61
|
+
from ..core.model import IDEMPOTENT_METHODS, CircuitKey, FailureKind
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
65
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
66
|
+
raise ImportError("Settings models require clientwright[settings]; install it.") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _maybe(model: BaseModel, name: str) -> Any:
|
|
70
|
+
"""The field's value if it was given, else UNSET - the adapter's native default."""
|
|
71
|
+
return getattr(model, name) if name in model.model_fields_set else UNSET
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BaseTimeoutSettings(BaseModel):
|
|
75
|
+
"""``TimeoutConfig`` as settings.
|
|
76
|
+
|
|
77
|
+
A phase you do not set stays ``UNSET`` in the config, exactly as in
|
|
78
|
+
``TimeoutConfig()``; an explicit ``null`` is the explicit "unbounded".
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
total: float | None = Field(
|
|
82
|
+
default=30.0, gt=0, description="Wall clock for the whole logical call; null = unbounded"
|
|
83
|
+
)
|
|
84
|
+
attempt: float | None = Field(default=None, gt=0, description="Ceiling for one attempt")
|
|
85
|
+
connect: float | None = Field(default=5.0, gt=0, description="Connect phase")
|
|
86
|
+
read: float | None = Field(default=None, gt=0, description="Read phase")
|
|
87
|
+
write: float | None = Field(default=None, gt=0, description="Write phase")
|
|
88
|
+
pool_acquire: float | None = Field(default=None, gt=0, description="Wait for a pooled connection")
|
|
89
|
+
|
|
90
|
+
def to_config(self) -> TimeoutConfig:
|
|
91
|
+
return TimeoutConfig(
|
|
92
|
+
total=self.total,
|
|
93
|
+
attempt=_maybe(self, "attempt"),
|
|
94
|
+
connect=self.connect,
|
|
95
|
+
read=_maybe(self, "read"),
|
|
96
|
+
write=_maybe(self, "write"),
|
|
97
|
+
pool_acquire=_maybe(self, "pool_acquire"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BasePoolSettings(BaseModel):
|
|
102
|
+
"""``PoolConfig`` as settings; ``max_connections_per_host`` and ``http2`` stay ``UNSET`` until set."""
|
|
103
|
+
|
|
104
|
+
max_connections: int | None = Field(default=100, gt=0, description="Pool-wide connection cap; null = unbounded")
|
|
105
|
+
max_keepalive: int | None = Field(default=20, gt=0, description="Idle connections kept open")
|
|
106
|
+
keepalive_expiry: float | None = Field(default=30.0, gt=0, description="Seconds an idle connection is kept")
|
|
107
|
+
max_connections_per_host: int | None = Field(default=None, gt=0, description="Per-origin connection cap")
|
|
108
|
+
http2: bool | None = Field(default=None, description="HTTP/2 where the adapter can honour it")
|
|
109
|
+
|
|
110
|
+
def to_config(self) -> PoolConfig:
|
|
111
|
+
return PoolConfig(
|
|
112
|
+
max_connections=self.max_connections,
|
|
113
|
+
max_keepalive=self.max_keepalive,
|
|
114
|
+
keepalive_expiry=self.keepalive_expiry,
|
|
115
|
+
max_connections_per_host=_maybe(self, "max_connections_per_host"),
|
|
116
|
+
http2=UNSET if self.http2 is None else self.http2,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class BaseRetrySettings(BaseModel):
|
|
121
|
+
"""``RetryConfig`` as settings; the sets take JSON lists, ``mode`` its value (``owned`` / ``delegated``)."""
|
|
122
|
+
|
|
123
|
+
max_attempts: int = Field(default=3, ge=1, description="Attempts per redirect hop, the first included")
|
|
124
|
+
initial_backoff: float = Field(default=0.1, gt=0, description="First backoff, seconds")
|
|
125
|
+
max_backoff: float = Field(default=10.0, gt=0, description="Backoff ceiling, seconds")
|
|
126
|
+
multiplier: float = Field(default=2.0, ge=1, description="Backoff growth per attempt")
|
|
127
|
+
jitter: float = Field(default=0.2, ge=0, le=1, description="Multiplicative jitter within [0, 1]")
|
|
128
|
+
retryable_kinds: frozenset[FailureKind] = Field(
|
|
129
|
+
default=DEFAULT_RETRYABLE_KINDS, description="Failure kinds worth a retry"
|
|
130
|
+
)
|
|
131
|
+
retryable_status: frozenset[int] = Field(default=DEFAULT_RETRYABLE_STATUS, description="Statuses worth a retry")
|
|
132
|
+
methods: frozenset[str] = Field(default=IDEMPOTENT_METHODS, description="Methods retried without a per-call flag")
|
|
133
|
+
respect_retry_after: bool = Field(default=True, description="A Retry-After header replaces the computed backoff")
|
|
134
|
+
retry_after_max: float = Field(default=60.0, description="Cap on what a server may ask for, seconds")
|
|
135
|
+
budget_ratio: float | None = Field(
|
|
136
|
+
default=0.1, gt=0, le=1, description="Share of an origin's traffic that may be retries; null = no budget"
|
|
137
|
+
)
|
|
138
|
+
require_replayable_body: bool = Field(default=True, description="Refuse to retry a body that cannot be replayed")
|
|
139
|
+
mode: RetryMode = Field(default=RetryMode.OWNED, description="owned by the engine, or delegated (urllib3 only)")
|
|
140
|
+
|
|
141
|
+
def to_config(self) -> RetryConfig:
|
|
142
|
+
return RetryConfig(
|
|
143
|
+
max_attempts=self.max_attempts,
|
|
144
|
+
initial_backoff=self.initial_backoff,
|
|
145
|
+
max_backoff=self.max_backoff,
|
|
146
|
+
multiplier=self.multiplier,
|
|
147
|
+
jitter=self.jitter,
|
|
148
|
+
retryable_kinds=self.retryable_kinds,
|
|
149
|
+
retryable_status=self.retryable_status,
|
|
150
|
+
methods=self.methods,
|
|
151
|
+
respect_retry_after=self.respect_retry_after,
|
|
152
|
+
retry_after_max=self.retry_after_max,
|
|
153
|
+
budget_ratio=self.budget_ratio,
|
|
154
|
+
require_replayable_body=self.require_replayable_body,
|
|
155
|
+
mode=self.mode,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class BaseCircuitBreakerSettings(BaseModel):
|
|
160
|
+
"""``CircuitBreakerConfig`` as settings; ``key`` by value: ``origin``, ``origin_route``, ``origin_method``."""
|
|
161
|
+
|
|
162
|
+
fail_threshold: int = Field(default=5, ge=1, description="Consecutive tripping calls before the circuit opens")
|
|
163
|
+
recovery_timeout: float = Field(default=60.0, gt=0, description="Seconds open before the next call is a probe")
|
|
164
|
+
half_open_max_calls: int = Field(default=1, ge=1, description="Concurrent probes while half-open")
|
|
165
|
+
max_keys: int = Field(default=512, ge=1, description="LRU cap on tracked circuits")
|
|
166
|
+
key: CircuitKey = Field(default=CircuitKey.ORIGIN, description="What one circuit stands for")
|
|
167
|
+
trip_kinds: frozenset[FailureKind] = Field(default=DEFAULT_TRIP_KINDS, description="Failure kinds that count")
|
|
168
|
+
|
|
169
|
+
def to_config(self) -> CircuitBreakerConfig:
|
|
170
|
+
return CircuitBreakerConfig(
|
|
171
|
+
fail_threshold=self.fail_threshold,
|
|
172
|
+
recovery_timeout=self.recovery_timeout,
|
|
173
|
+
half_open_max_calls=self.half_open_max_calls,
|
|
174
|
+
max_keys=self.max_keys,
|
|
175
|
+
key=self.key,
|
|
176
|
+
trip_kinds=self.trip_kinds,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class BaseTlsSettings(BaseModel):
|
|
181
|
+
"""``TlsConfig`` as settings; ``cert`` is a PEM path, or a JSON list of two or three paths."""
|
|
182
|
+
|
|
183
|
+
verify: bool = Field(default=True, description="Verify the server certificate")
|
|
184
|
+
ca_bundle: str | None = Field(default=None, description="Path to a private CA bundle")
|
|
185
|
+
cert: str | tuple[str, str] | tuple[str, str, str] | None = Field(
|
|
186
|
+
default=None, description="Client certificate: a PEM path, (cert, key) or (cert, key, password)"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def to_config(self) -> TlsConfig:
|
|
190
|
+
return TlsConfig(verify=self.verify, ca_bundle=self.ca_bundle, cert=self.cert)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class BaseProxySettings(BaseModel):
|
|
194
|
+
"""``ProxyConfig`` as settings: an explicit URL or the environment's proxies, not both."""
|
|
195
|
+
|
|
196
|
+
url: str | None = Field(default=None, description="Explicit proxy URL")
|
|
197
|
+
from_env: bool = Field(default=False, description="Read HTTP(S)_PROXY / NO_PROXY instead")
|
|
198
|
+
|
|
199
|
+
@model_validator(mode="after")
|
|
200
|
+
def _exclusive(self) -> BaseProxySettings:
|
|
201
|
+
if self.url is not None and self.from_env:
|
|
202
|
+
raise ValueError("proxy.url and proxy.from_env are mutually exclusive")
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def to_config(self) -> ProxyConfig:
|
|
206
|
+
return ProxyConfig(url=self.url, from_env=self.from_env)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Module attribute captured before the class: the field named ``logging``
|
|
210
|
+
# shadows the module inside the class body, as in ``ObservabilityConfig``.
|
|
211
|
+
_INFO_LEVEL: Final = logging.INFO
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class BaseObservabilitySettings(BaseModel):
|
|
215
|
+
"""``ObservabilityConfig`` as settings, minus ``url_masker``: a callable belongs on the config.
|
|
216
|
+
|
|
217
|
+
``success_log_level`` takes a level name (``DEBUG``) as well as a number.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
logging: bool = Field(default=True, description="Emit log records")
|
|
221
|
+
metrics: bool = Field(default=True, description="Record metrics")
|
|
222
|
+
tracing: bool = Field(default=True, description="Open spans")
|
|
223
|
+
success_log_level: int = Field(default=_INFO_LEVEL, description="Level of the record for a successful call")
|
|
224
|
+
sensitive_query_params: frozenset[str] = Field(
|
|
225
|
+
default=DEFAULT_SENSITIVE_QUERY_PARAMS, description="Query parameters redacted by name in URLs"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@field_validator("success_log_level", mode="before")
|
|
229
|
+
@classmethod
|
|
230
|
+
def _level_by_name(cls, value: object) -> object:
|
|
231
|
+
if not isinstance(value, str):
|
|
232
|
+
return value
|
|
233
|
+
level = logging.getLevelNamesMapping().get(value.upper())
|
|
234
|
+
if level is None:
|
|
235
|
+
raise ValueError(f"unknown log level {value!r}")
|
|
236
|
+
return level
|
|
237
|
+
|
|
238
|
+
def to_config(self) -> ObservabilityConfig:
|
|
239
|
+
return ObservabilityConfig(
|
|
240
|
+
logging=self.logging,
|
|
241
|
+
metrics=self.metrics,
|
|
242
|
+
tracing=self.tracing,
|
|
243
|
+
success_log_level=self.success_log_level,
|
|
244
|
+
sensitive_query_params=self.sensitive_query_params,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class BaseClientSettings(BaseModel):
|
|
249
|
+
"""``ClientConfig`` as settings: the whole client, one section per sub-config.
|
|
250
|
+
|
|
251
|
+
Nest it in your own settings object; ``service_name`` is not environment-shaped
|
|
252
|
+
and is the argument of :meth:`to_config`. ``retry`` and ``circuit_breaker``
|
|
253
|
+
are on by default and go off with ``retry: None = None`` in a subclass (or
|
|
254
|
+
``RETRY=null`` in the environment); ``proxy`` is off until any of its
|
|
255
|
+
fields is set.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
base_url: str | None = Field(
|
|
259
|
+
default=None,
|
|
260
|
+
pattern=r"^https?://",
|
|
261
|
+
description="Origin every relative URL joins; unset means absolute URLs per call, "
|
|
262
|
+
"the only mode the requests and urllib3 adapters support",
|
|
263
|
+
)
|
|
264
|
+
timeout: BaseTimeoutSettings = Field(default_factory=BaseTimeoutSettings)
|
|
265
|
+
pool: BasePoolSettings = Field(default_factory=BasePoolSettings)
|
|
266
|
+
retry: BaseRetrySettings | None = Field(default_factory=BaseRetrySettings)
|
|
267
|
+
circuit_breaker: BaseCircuitBreakerSettings | None = Field(default_factory=BaseCircuitBreakerSettings)
|
|
268
|
+
tls: BaseTlsSettings = Field(default_factory=BaseTlsSettings)
|
|
269
|
+
proxy: BaseProxySettings | None = None
|
|
270
|
+
headers: dict[str, str] = Field(default_factory=dict, description="Headers injected with setdefault semantics")
|
|
271
|
+
redirects: RedirectMode = Field(default=RedirectMode.OWNED, description="owned by the engine, or native")
|
|
272
|
+
max_redirects: int = Field(default=5, ge=0, description="Hops before TooManyRedirectsError")
|
|
273
|
+
caller_override: CallerOverride = Field(
|
|
274
|
+
default=CallerOverride.CALLER_WINS, description="What a per-call timeout does: caller_wins, config_wins, raise"
|
|
275
|
+
)
|
|
276
|
+
deadline_header: str | None = Field(default=None, description="Header stamped with the remaining budget, in ms")
|
|
277
|
+
observability: BaseObservabilitySettings = Field(default_factory=BaseObservabilitySettings)
|
|
278
|
+
native: dict[str, dict[str, Any]] = Field(
|
|
279
|
+
default_factory=dict, description="Raw passthrough: {slot: {kwarg: value}}"
|
|
280
|
+
)
|
|
281
|
+
on_unsupported: UnsupportedPolicy = Field(
|
|
282
|
+
default=UnsupportedPolicy.WARN, description="What a knob the adapter cannot express does: ignore, warn, strict"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def to_config(self, service_name: str) -> ClientConfig:
|
|
286
|
+
"""The ``ClientConfig`` these settings describe, labelled ``service_name``."""
|
|
287
|
+
return ClientConfig(
|
|
288
|
+
service_name=service_name,
|
|
289
|
+
base_url=self.base_url,
|
|
290
|
+
timeout=self.timeout.to_config(),
|
|
291
|
+
pool=self.pool.to_config(),
|
|
292
|
+
retry=None if self.retry is None else self.retry.to_config(),
|
|
293
|
+
circuit_breaker=None if self.circuit_breaker is None else self.circuit_breaker.to_config(),
|
|
294
|
+
tls=self.tls.to_config(),
|
|
295
|
+
proxy=None if self.proxy is None else self.proxy.to_config(),
|
|
296
|
+
headers=self.headers,
|
|
297
|
+
redirects=self.redirects,
|
|
298
|
+
max_redirects=self.max_redirects,
|
|
299
|
+
caller_override=self.caller_override,
|
|
300
|
+
deadline_header=self.deadline_header,
|
|
301
|
+
observability=self.observability.to_config(),
|
|
302
|
+
native=NativeOptions(slots=self.native),
|
|
303
|
+
on_unsupported=self.on_unsupported,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
__all__ = [
|
|
308
|
+
"BaseCircuitBreakerSettings",
|
|
309
|
+
"BaseClientSettings",
|
|
310
|
+
"BaseObservabilitySettings",
|
|
311
|
+
"BasePoolSettings",
|
|
312
|
+
"BaseProxySettings",
|
|
313
|
+
"BaseRetrySettings",
|
|
314
|
+
"BaseTimeoutSettings",
|
|
315
|
+
"BaseTlsSettings",
|
|
316
|
+
]
|
|
@@ -57,7 +57,8 @@ tracing = ["opentelemetry-api>=1.26"]
|
|
|
57
57
|
observability = ["clientwright[metrics,tracing]"]
|
|
58
58
|
deadline = ["deadline-budget>=0.1,<1"]
|
|
59
59
|
dishka = ["dishka>=1.4"]
|
|
60
|
-
|
|
60
|
+
settings = ["pydantic>=2,<3", "pydantic-settings>=2.3,<3"]
|
|
61
|
+
all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka,settings]"]
|
|
61
62
|
|
|
62
63
|
[dependency-groups]
|
|
63
64
|
test = [
|
|
@@ -23,6 +23,8 @@ BLOCKED = (
|
|
|
23
23
|
"opentelemetry",
|
|
24
24
|
"dishka",
|
|
25
25
|
"deadline_budget",
|
|
26
|
+
"pydantic",
|
|
27
|
+
"pydantic_settings",
|
|
26
28
|
)
|
|
27
29
|
|
|
28
30
|
BLOCKER = """
|
|
@@ -147,3 +149,17 @@ def test__zero_dep_surface__config_engine_and_testing_tools_work() -> None:
|
|
|
147
149
|
"""
|
|
148
150
|
)
|
|
149
151
|
assert output == "core usable"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test__settings_models_without_the_extra__fail_with_the_install_hint() -> None:
|
|
155
|
+
output = run_probe(
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
import clientwright.contrib.settings
|
|
159
|
+
except ImportError as error:
|
|
160
|
+
print(error)
|
|
161
|
+
else:
|
|
162
|
+
print("NO ERROR")
|
|
163
|
+
"""
|
|
164
|
+
)
|
|
165
|
+
assert "clientwright[settings]" in output
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""contrib units: the settings models and their translation into ClientConfig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
pytest.importorskip("pydantic", reason="requires the [settings] extra")
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from clientwright import (
|
|
15
|
+
UNSET,
|
|
16
|
+
CircuitBreakerConfig,
|
|
17
|
+
CircuitKey,
|
|
18
|
+
ClientConfig,
|
|
19
|
+
FailureKind,
|
|
20
|
+
NativeOptions,
|
|
21
|
+
ObservabilityConfig,
|
|
22
|
+
PoolConfig,
|
|
23
|
+
ProxyConfig,
|
|
24
|
+
RetryConfig,
|
|
25
|
+
RetryMode,
|
|
26
|
+
TimeoutConfig,
|
|
27
|
+
TlsConfig,
|
|
28
|
+
UnsupportedPolicy,
|
|
29
|
+
build_sync,
|
|
30
|
+
)
|
|
31
|
+
from clientwright.contrib import settings as settings_module
|
|
32
|
+
from clientwright.contrib.settings import (
|
|
33
|
+
BaseCircuitBreakerSettings,
|
|
34
|
+
BaseClientSettings,
|
|
35
|
+
BaseObservabilitySettings,
|
|
36
|
+
BasePoolSettings,
|
|
37
|
+
BaseProxySettings,
|
|
38
|
+
BaseRetrySettings,
|
|
39
|
+
BaseTimeoutSettings,
|
|
40
|
+
BaseTlsSettings,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# --- the models are the dataclasses, written once ---
|
|
44
|
+
|
|
45
|
+
PAIRS = [
|
|
46
|
+
(BaseTimeoutSettings, TimeoutConfig, set()),
|
|
47
|
+
(BasePoolSettings, PoolConfig, set()),
|
|
48
|
+
(BaseRetrySettings, RetryConfig, set()),
|
|
49
|
+
(BaseCircuitBreakerSettings, CircuitBreakerConfig, set()),
|
|
50
|
+
(BaseTlsSettings, TlsConfig, set()),
|
|
51
|
+
(BaseProxySettings, ProxyConfig, set()),
|
|
52
|
+
(BaseObservabilitySettings, ObservabilityConfig, {"url_masker"}),
|
|
53
|
+
(BaseClientSettings, ClientConfig, {"service_name"}),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize(("model", "dataclass", "not_env_shaped"), PAIRS, ids=[m.__name__ for m, _, _ in PAIRS])
|
|
58
|
+
def test__every_model__names_exactly_the_dataclass_fields(
|
|
59
|
+
model: type, dataclass: type, not_env_shaped: set[str]
|
|
60
|
+
) -> None:
|
|
61
|
+
assert set(model.model_fields) == {f.name for f in dataclasses.fields(dataclass)} - not_env_shaped
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test__defaults__are_the_dataclass_defaults() -> None:
|
|
65
|
+
assert BaseClientSettings().to_config("orders") == ClientConfig(service_name="orders")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test__no_model__is_a_base_settings() -> None:
|
|
69
|
+
pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra")
|
|
70
|
+
for name in settings_module.__all__:
|
|
71
|
+
assert not issubclass(getattr(settings_module, name), pydantic_settings.BaseSettings), name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- the environment ---
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test__nested_in_a_base_settings__reads_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
78
|
+
pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra")
|
|
79
|
+
|
|
80
|
+
class Settings(pydantic_settings.BaseSettings):
|
|
81
|
+
model_config = pydantic_settings.SettingsConfigDict(env_nested_delimiter="__")
|
|
82
|
+
|
|
83
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
84
|
+
|
|
85
|
+
monkeypatch.setenv("WAREHOUSE__BASE_URL", "https://wh.example.com")
|
|
86
|
+
monkeypatch.setenv("WAREHOUSE__TIMEOUT__TOTAL", "10")
|
|
87
|
+
monkeypatch.setenv("WAREHOUSE__TIMEOUT__CONNECT", "2")
|
|
88
|
+
monkeypatch.setenv("WAREHOUSE__POOL__HTTP2", "true")
|
|
89
|
+
monkeypatch.setenv("WAREHOUSE__RETRY__MAX_ATTEMPTS", "5")
|
|
90
|
+
monkeypatch.setenv("WAREHOUSE__RETRY__RETRYABLE_STATUS", "[429, 500]")
|
|
91
|
+
monkeypatch.setenv("WAREHOUSE__CIRCUIT_BREAKER__FAIL_THRESHOLD", "2")
|
|
92
|
+
monkeypatch.setenv("WAREHOUSE__TLS__CERT", '["client.pem", "client.key"]')
|
|
93
|
+
monkeypatch.setenv("WAREHOUSE__PROXY__URL", "http://proxy:3128")
|
|
94
|
+
monkeypatch.setenv("WAREHOUSE__ON_UNSUPPORTED", "strict")
|
|
95
|
+
|
|
96
|
+
config = Settings().warehouse.to_config("warehouse")
|
|
97
|
+
|
|
98
|
+
assert config == ClientConfig(
|
|
99
|
+
service_name="warehouse",
|
|
100
|
+
base_url="https://wh.example.com",
|
|
101
|
+
timeout=TimeoutConfig(total=10.0, connect=2.0),
|
|
102
|
+
pool=PoolConfig(http2=True),
|
|
103
|
+
retry=RetryConfig(max_attempts=5, retryable_status=frozenset({429, 500})),
|
|
104
|
+
circuit_breaker=CircuitBreakerConfig(fail_threshold=2),
|
|
105
|
+
tls=TlsConfig(cert=("client.pem", "client.key")),
|
|
106
|
+
proxy=ProxyConfig(url="http://proxy:3128"),
|
|
107
|
+
on_unsupported=UnsupportedPolicy.STRICT,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test__bare_variables__cannot_reach_a_section(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
112
|
+
pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra")
|
|
113
|
+
|
|
114
|
+
class Settings(pydantic_settings.BaseSettings):
|
|
115
|
+
model_config = pydantic_settings.SettingsConfigDict(env_nested_delimiter="__")
|
|
116
|
+
|
|
117
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
118
|
+
|
|
119
|
+
class LeakySection(pydantic_settings.BaseSettings):
|
|
120
|
+
base_url: str | None = None
|
|
121
|
+
|
|
122
|
+
monkeypatch.setenv("BASE_URL", "https://leak.example.com")
|
|
123
|
+
monkeypatch.setenv("TIMEOUT", "1")
|
|
124
|
+
monkeypatch.setenv("TOTAL", "1")
|
|
125
|
+
monkeypatch.setenv("MAX_ATTEMPTS", "9")
|
|
126
|
+
monkeypatch.setenv("HTTP2", "true")
|
|
127
|
+
monkeypatch.setenv("VERIFY", "false")
|
|
128
|
+
|
|
129
|
+
assert LeakySection().base_url == "https://leak.example.com" # what a BaseSettings section would do
|
|
130
|
+
assert Settings().warehouse.to_config("warehouse") == ClientConfig(service_name="warehouse")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test__null_in_the_environment__disables_a_section_and_unbounds_a_phase(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
134
|
+
pydantic_settings = pytest.importorskip("pydantic_settings", reason="requires the [settings] extra")
|
|
135
|
+
|
|
136
|
+
class Settings(pydantic_settings.BaseSettings):
|
|
137
|
+
model_config = pydantic_settings.SettingsConfigDict(env_nested_delimiter="__", env_parse_none_str="null")
|
|
138
|
+
|
|
139
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
140
|
+
|
|
141
|
+
monkeypatch.setenv("WAREHOUSE__RETRY", "null")
|
|
142
|
+
monkeypatch.setenv("WAREHOUSE__TIMEOUT__READ", "null")
|
|
143
|
+
|
|
144
|
+
config = Settings().warehouse.to_config("warehouse")
|
|
145
|
+
|
|
146
|
+
assert config.retry is None
|
|
147
|
+
assert config.timeout.read is None
|
|
148
|
+
assert config.timeout.write is UNSET
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- UNSET, None and the env spellings ---
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test__an_unset_knob__stays_unset_and_null_is_unbounded() -> None:
|
|
155
|
+
assert BaseTimeoutSettings().to_config().read is UNSET
|
|
156
|
+
assert BaseTimeoutSettings(read=None).to_config().read is None
|
|
157
|
+
assert BaseTimeoutSettings(read=2.0).to_config().read == 2.0
|
|
158
|
+
assert BasePoolSettings().to_config().max_connections_per_host is UNSET
|
|
159
|
+
assert BasePoolSettings().to_config().http2 is UNSET
|
|
160
|
+
assert BasePoolSettings(http2=False).to_config().http2 is False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test__enums_sets_and_levels__are_coerced_from_their_env_spellings() -> None:
|
|
164
|
+
retry = BaseRetrySettings(mode="delegated", retryable_kinds=["dns_error"], methods=["GET"]).to_config()
|
|
165
|
+
assert retry.mode is RetryMode.DELEGATED
|
|
166
|
+
assert retry.retryable_kinds == frozenset({FailureKind.DNS_ERROR})
|
|
167
|
+
assert retry.methods == frozenset({"GET"})
|
|
168
|
+
assert BaseCircuitBreakerSettings(key="origin_route").to_config().key is CircuitKey.ORIGIN_ROUTE
|
|
169
|
+
assert BaseObservabilitySettings(success_log_level="debug").to_config().success_log_level == logging.DEBUG
|
|
170
|
+
assert BaseObservabilitySettings(success_log_level=30).to_config().success_log_level == logging.WARNING
|
|
171
|
+
with pytest.raises(ValidationError, match="unknown log level"):
|
|
172
|
+
BaseObservabilitySettings(success_log_level="LOUD")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test__sections__disable_with_none_and_proxy_appears_on_demand() -> None:
|
|
176
|
+
class Batch(BaseClientSettings):
|
|
177
|
+
retry: None = None
|
|
178
|
+
circuit_breaker: None = None
|
|
179
|
+
|
|
180
|
+
config = Batch().to_config("batch")
|
|
181
|
+
assert config.retry is None
|
|
182
|
+
assert config.circuit_breaker is None
|
|
183
|
+
assert config.proxy is None
|
|
184
|
+
assert BaseClientSettings(proxy=BaseProxySettings(from_env=True)).to_config("x").proxy == ProxyConfig(from_env=True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test__headers_and_native__reach_the_config() -> None:
|
|
188
|
+
config = BaseClientSettings(headers={"User-Agent": "svc/1"}, native={"client": {"trust_env": False}}).to_config(
|
|
189
|
+
"svc"
|
|
190
|
+
)
|
|
191
|
+
assert config.headers == {"User-Agent": "svc/1"}
|
|
192
|
+
assert config.native == NativeOptions.of(client={"trust_env": False})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test__bad_values__fail_at_load_with_the_field_path() -> None:
|
|
196
|
+
with pytest.raises(ValidationError, match=r"timeout\.total"):
|
|
197
|
+
BaseClientSettings(timeout={"total": 0})
|
|
198
|
+
with pytest.raises(ValidationError, match="base_url"):
|
|
199
|
+
BaseClientSettings(base_url="ftp://nope")
|
|
200
|
+
with pytest.raises(ValidationError, match="max_attempts"):
|
|
201
|
+
BaseRetrySettings(max_attempts=0)
|
|
202
|
+
with pytest.raises(ValidationError, match="mutually exclusive"):
|
|
203
|
+
BaseProxySettings(url="http://proxy:3128", from_env=True)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test__a_config_from_settings__builds_the_native_client() -> None:
|
|
207
|
+
httpx = pytest.importorskip("httpx", reason="requires the [httpx] extra")
|
|
208
|
+
config = BaseClientSettings(base_url="https://api.example.com", pool={"http2": False}).to_config("api")
|
|
209
|
+
client = build_sync("httpx", config)
|
|
210
|
+
try:
|
|
211
|
+
assert type(client) is httpx.Client
|
|
212
|
+
finally:
|
|
213
|
+
client.close()
|