clientwright 0.2.2__tar.gz → 0.4.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.4.0}/CHANGELOG.md +14 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/PKG-INFO +32 -1
- {clientwright-0.2.2 → clientwright-0.4.0}/README.md +26 -0
- clientwright-0.4.0/clientwright/__version__.py +1 -0
- clientwright-0.4.0/clientwright/contrib/dishka.py +120 -0
- clientwright-0.4.0/clientwright/contrib/settings.py +316 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/pyproject.toml +2 -1
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/contrib/test_dishka.py +42 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/test_bare_install.py +16 -0
- clientwright-0.4.0/tests/unit/contrib/test_dishka.py +124 -0
- clientwright-0.4.0/tests/unit/contrib/test_settings.py +213 -0
- {clientwright-0.2.2 → clientwright-0.4.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/contrib/dishka.py +0 -80
- clientwright-0.2.2/tests/unit/contrib/test_dishka.py +0 -28
- {clientwright-0.2.2 → clientwright-0.4.0}/.gitignore +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/LICENSE +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/_httpx_shared.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/_lazy.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/middleware.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/options.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/trace.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/normalize_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/transport.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/normalize_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/transport.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_metrics/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_metrics/prometheus.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_tracing/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_tracing/otel.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/_imports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/classify.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/contrib/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/contrib/deadline.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/balancer/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/balancer/policy.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/config.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/context.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/message.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/observability.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/settings.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/aio.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/base.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/redirects.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/suppress.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/model.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/native.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/options.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/plan.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/budget.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/circuit.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/concurrency.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/retry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/timeout.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/registry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/emitter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/names.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/null.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/redaction.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/testing/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/testing/doubles.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/testing/origin.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/py.typed +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/drivers.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/engine.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/telemetry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/views.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_aiohttp.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_httpx2.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_httpx_async.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_httpx_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_requests.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_urllib3.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/chaos/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/chaos/test_faults.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/chaos/test_origin_faults.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/contrib/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/contrib/test_deadline.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/parity/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/parity/test_semantics.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/test_public_endpoints.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/test_tls_and_dns.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_build.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_trace.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/conftest.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx2/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx2/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/test_exports.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/test_otel.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/test_prometheus.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/test_adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/test_normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/test_httpx_family_parity.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/test_httpx_shared.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/test_adapter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/test_normalize.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/test_units.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/contrib/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/contrib/test_deadline.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/contrib/test_deadline_context.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_aio.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_base.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_redirects.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_suppress.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_sync.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_budget.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_circuit.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_circuit_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_concurrency.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_retry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_retry_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_timeout.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_timeout_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/__init__.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_emitter.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_masking.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_redaction.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_redaction_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_balancer.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_capabilities.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_capabilities_properties.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_config.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_enum_coercion.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_errors.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_model.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_options.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_plan.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_registry.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_settings.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/test_docs_snippets.py +0 -0
- {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/test_version.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.3.0...clientwright-v0.4.0) (2026-09-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* component= and client_type= on ClientwrightProvider, and the circuit-state gauge on its runtime ([0ac6cdf](https://github.com/bedrock-python/clientwright/commit/0ac6cdfd179e1e5bd57ba3d4a49c95892cf6d844)), closes [#35](https://github.com/bedrock-python/clientwright/issues/35)
|
|
9
|
+
|
|
10
|
+
## [0.3.0](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.2.2...clientwright-v0.3.0) (2026-09-14)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* pydantic settings models for ClientConfig behind the settings extra ([3c5006f](https://github.com/bedrock-python/clientwright/commit/3c5006f3b61e50266d6f266ea1e997dbc80d5535))
|
|
16
|
+
|
|
3
17
|
## [0.2.2](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.2.1...clientwright-v0.2.2) (2026-09-06)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: clientwright
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -183,6 +189,9 @@ retry traffic by default), body replayability and the remaining deadline.
|
|
|
183
189
|
`AdapterDeps(runtime=...)` - otherwise breaker state dies with every request.
|
|
184
190
|
With `clientwright[dishka]`, `contrib.dishka.ClientwrightProvider` does both:
|
|
185
191
|
APP-scope runtime and a generator provide that closes the client in `finally`.
|
|
192
|
+
Several upstreams in one container are Dishka components — one
|
|
193
|
+
`ClientwrightProvider("httpx", config, component="github-api", client_type=httpx.AsyncClient)`
|
|
194
|
+
per upstream, injected as `Annotated[httpx.AsyncClient, FromComponent("github-api")]`.
|
|
186
195
|
|
|
187
196
|
## Deadline budgets
|
|
188
197
|
|
|
@@ -202,6 +211,28 @@ with use_budget(BudgetContext.create(total_seconds=5.0)):
|
|
|
202
211
|
await client.get("/users") # runs with what is left of those 5 seconds
|
|
203
212
|
```
|
|
204
213
|
|
|
214
|
+
## From the environment
|
|
215
|
+
|
|
216
|
+
With `clientwright[settings]`, `ClientConfig` and every sub-config exist as pydantic
|
|
217
|
+
models with the same fields and defaults. They are plain `BaseModel`s — nest them in
|
|
218
|
+
your own settings, so a bare `BASE_URL` in a pod can never reach a section:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
222
|
+
|
|
223
|
+
from clientwright.contrib.settings import BaseClientSettings
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class Settings(BaseSettings):
|
|
227
|
+
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
|
228
|
+
|
|
229
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true
|
|
233
|
+
config = Settings().warehouse.to_config("warehouse")
|
|
234
|
+
```
|
|
235
|
+
|
|
205
236
|
## Adapters
|
|
206
237
|
|
|
207
238
|
| 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
|
|
@@ -126,6 +127,9 @@ retry traffic by default), body replayability and the remaining deadline.
|
|
|
126
127
|
`AdapterDeps(runtime=...)` - otherwise breaker state dies with every request.
|
|
127
128
|
With `clientwright[dishka]`, `contrib.dishka.ClientwrightProvider` does both:
|
|
128
129
|
APP-scope runtime and a generator provide that closes the client in `finally`.
|
|
130
|
+
Several upstreams in one container are Dishka components — one
|
|
131
|
+
`ClientwrightProvider("httpx", config, component="github-api", client_type=httpx.AsyncClient)`
|
|
132
|
+
per upstream, injected as `Annotated[httpx.AsyncClient, FromComponent("github-api")]`.
|
|
129
133
|
|
|
130
134
|
## Deadline budgets
|
|
131
135
|
|
|
@@ -145,6 +149,28 @@ with use_budget(BudgetContext.create(total_seconds=5.0)):
|
|
|
145
149
|
await client.get("/users") # runs with what is left of those 5 seconds
|
|
146
150
|
```
|
|
147
151
|
|
|
152
|
+
## From the environment
|
|
153
|
+
|
|
154
|
+
With `clientwright[settings]`, `ClientConfig` and every sub-config exist as pydantic
|
|
155
|
+
models with the same fields and defaults. They are plain `BaseModel`s — nest them in
|
|
156
|
+
your own settings, so a bare `BASE_URL` in a pod can never reach a section:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
160
|
+
|
|
161
|
+
from clientwright.contrib.settings import BaseClientSettings
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Settings(BaseSettings):
|
|
165
|
+
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
|
166
|
+
|
|
167
|
+
warehouse: BaseClientSettings = BaseClientSettings()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true
|
|
171
|
+
config = Settings().warehouse.to_config("warehouse")
|
|
172
|
+
```
|
|
173
|
+
|
|
148
174
|
## Adapters
|
|
149
175
|
|
|
150
176
|
| adapter | modes | notes |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0" # x-release-please-version
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Dishka integration (``[dishka]`` extra): APP-scope runtime, leak-free lifecycle.
|
|
2
|
+
|
|
3
|
+
Two lessons from the legacy kit are structural here:
|
|
4
|
+
|
|
5
|
+
- ``ClientRuntime`` (circuits, retry budgets, per-origin limiters) is APP
|
|
6
|
+
scope. A REQUEST-scoped runtime resets breaker state on every request and
|
|
7
|
+
turns the circuit breaker into decoration.
|
|
8
|
+
- The client is provided by a GENERATOR provide whose finally closes it - the
|
|
9
|
+
legacy provider parked ``aclose`` on an AsyncExitStack that nothing ever
|
|
10
|
+
closed, leaking a client per request.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
container = make_async_container(
|
|
15
|
+
ClientwrightProvider("httpx", config),
|
|
16
|
+
...,
|
|
17
|
+
)
|
|
18
|
+
handle = await container.get(ClientHandle[Any])
|
|
19
|
+
client: httpx.AsyncClient = handle.client
|
|
20
|
+
|
|
21
|
+
Several upstreams in one container are Dishka components: one provider per
|
|
22
|
+
upstream, each in its own component, and ``client_type`` for injection sites
|
|
23
|
+
that want the native client rather than the handle::
|
|
24
|
+
|
|
25
|
+
container = make_async_container(
|
|
26
|
+
ClientwrightProvider("httpx", login_config, component="github-oauth", client_type=httpx.AsyncClient),
|
|
27
|
+
ClientwrightProvider("httpx", api_config, component="github-api", client_type=httpx.AsyncClient),
|
|
28
|
+
)
|
|
29
|
+
api = await container.get(httpx.AsyncClient, component="github-api")
|
|
30
|
+
|
|
31
|
+
The runtime built here carries the ``http_client_circuit_state`` listener the
|
|
32
|
+
way a runtime built by an adapter does; a runtime passed in through
|
|
33
|
+
``deps.runtime`` is handed back as it came.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import AsyncIterator
|
|
39
|
+
from dataclasses import replace
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from dishka import Provider, Scope, provide
|
|
44
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
45
|
+
raise ImportError("Dishka integration requires clientwright[dishka]; install it.") from exc
|
|
46
|
+
|
|
47
|
+
from ..core.config import ClientConfig
|
|
48
|
+
from ..core.contracts.adapter import AdapterDeps, default_deps
|
|
49
|
+
from ..core.plan import ClientHandle, ClientRuntime
|
|
50
|
+
from ..core.registry import resolve_adapter
|
|
51
|
+
from ..core.telemetry.emitter import ClientTelemetry
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ClientwrightProvider(Provider):
|
|
55
|
+
"""One async native client with an APP-scope runtime and guaranteed close.
|
|
56
|
+
|
|
57
|
+
``component`` puts everything the provider gives out in a Dishka component,
|
|
58
|
+
which is how several upstreams share one container: resolve with
|
|
59
|
+
``container.get(..., component="github-api")`` or inject with
|
|
60
|
+
``Annotated[..., FromComponent("github-api")]``. ``client_type`` also
|
|
61
|
+
provides the native client under that type (``httpx.AsyncClient``, ...),
|
|
62
|
+
the same object the handle holds.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
scope = Scope.APP
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
adapter: str,
|
|
70
|
+
config: ClientConfig,
|
|
71
|
+
deps: AdapterDeps | None = None,
|
|
72
|
+
*,
|
|
73
|
+
component: str | None = None,
|
|
74
|
+
client_type: type[Any] | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(component=component)
|
|
77
|
+
self._adapter = adapter
|
|
78
|
+
self._config = config
|
|
79
|
+
self._deps = deps or default_deps()
|
|
80
|
+
if client_type is not None:
|
|
81
|
+
self.provide(self._native_client, provides=client_type)
|
|
82
|
+
|
|
83
|
+
@provide
|
|
84
|
+
def client_runtime(self) -> ClientRuntime:
|
|
85
|
+
if self._deps.runtime is not None:
|
|
86
|
+
return self._deps.runtime
|
|
87
|
+
# An adapter wires the circuit-state gauge only to a runtime it builds
|
|
88
|
+
# itself; this runtime is built here, so the wiring happens here.
|
|
89
|
+
adapter = resolve_adapter(self._adapter)()
|
|
90
|
+
telemetry = ClientTelemetry(
|
|
91
|
+
service=self._config.service_name,
|
|
92
|
+
adapter=adapter.name,
|
|
93
|
+
seam=adapter.capabilities.seam,
|
|
94
|
+
config=self._config.observability,
|
|
95
|
+
metrics=self._deps.metrics,
|
|
96
|
+
tracer=None,
|
|
97
|
+
)
|
|
98
|
+
return ClientRuntime.for_config(
|
|
99
|
+
self._config, clock=self._deps.clock, circuit_listener=telemetry.circuit_state_changed
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@provide
|
|
103
|
+
async def client_handle(self, runtime: ClientRuntime) -> AsyncIterator[ClientHandle[Any]]:
|
|
104
|
+
adapter = resolve_adapter(self._adapter)()
|
|
105
|
+
handle: ClientHandle[Any] = adapter.build_async(self._config, replace(self._deps, runtime=runtime))
|
|
106
|
+
try:
|
|
107
|
+
yield handle
|
|
108
|
+
finally:
|
|
109
|
+
# The whole point: close travels WITH the provide, not on a stack
|
|
110
|
+
# nobody closes.
|
|
111
|
+
if handle.aclose is not None:
|
|
112
|
+
await handle.aclose()
|
|
113
|
+
elif handle.close is not None:
|
|
114
|
+
handle.close()
|
|
115
|
+
|
|
116
|
+
def _native_client(self, handle: ClientHandle[Any]) -> Any:
|
|
117
|
+
return handle.client
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = ["ClientwrightProvider"]
|
|
@@ -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 = [
|
|
@@ -65,3 +65,45 @@ async def test__two_container_generations__breaker_state_lives_in_the_runtime(or
|
|
|
65
65
|
await rebuilt.client.get("/status/500") # rejected locally: state survived the rebuild
|
|
66
66
|
assert origin.request_count("/status/500") == 1
|
|
67
67
|
await second.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test__two_components__each_upstream_has_its_own_breaker_and_the_trip_reaches_metrics(
|
|
71
|
+
origin: OriginServer,
|
|
72
|
+
) -> None:
|
|
73
|
+
# The reporter's shape: two upstreams in one container, each on its own
|
|
74
|
+
# runtime, each closed with the container - and the tripped breaker is
|
|
75
|
+
# exported although the provider, not the adapter, built the runtime.
|
|
76
|
+
metrics = RecordingMetrics()
|
|
77
|
+
deps = AdapterDeps(metrics=metrics)
|
|
78
|
+
|
|
79
|
+
def config(service: str) -> ClientConfig:
|
|
80
|
+
return ClientConfig(
|
|
81
|
+
service_name=service,
|
|
82
|
+
base_url=origin.url,
|
|
83
|
+
retry=None,
|
|
84
|
+
circuit_breaker=CircuitBreakerConfig(fail_threshold=1, recovery_timeout=60.0),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
container = make_async_container(
|
|
88
|
+
ClientwrightProvider(
|
|
89
|
+
"httpx", config("github-oauth"), deps, component="github-oauth", client_type=httpx.AsyncClient
|
|
90
|
+
),
|
|
91
|
+
ClientwrightProvider(
|
|
92
|
+
"httpx", config("github-api"), deps, component="github-api", client_type=httpx.AsyncClient
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
login = await container.get(httpx.AsyncClient, component="github-oauth")
|
|
96
|
+
api = await container.get(httpx.AsyncClient, component="github-api")
|
|
97
|
+
assert login is not api
|
|
98
|
+
|
|
99
|
+
trip = await api.get("/status/500")
|
|
100
|
+
assert trip.status_code == 500 # one 5xx signal arms github-api's breaker
|
|
101
|
+
with pytest.raises(httpx.HTTPError):
|
|
102
|
+
await api.get("/status/500") # rejected locally
|
|
103
|
+
assert (await login.get("/status/500")).status_code == 500 # github-oauth's breaker is its own
|
|
104
|
+
assert origin.request_count("/status/500") == 2
|
|
105
|
+
assert [record["state"] for record in metrics.circuit_states] == ["open", "open"]
|
|
106
|
+
|
|
107
|
+
await container.close()
|
|
108
|
+
assert login.is_closed
|
|
109
|
+
assert api.is_closed
|
|
@@ -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
|