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.
Files changed (203) hide show
  1. {clientwright-0.2.2 → clientwright-0.4.0}/CHANGELOG.md +14 -0
  2. {clientwright-0.2.2 → clientwright-0.4.0}/PKG-INFO +32 -1
  3. {clientwright-0.2.2 → clientwright-0.4.0}/README.md +26 -0
  4. clientwright-0.4.0/clientwright/__version__.py +1 -0
  5. clientwright-0.4.0/clientwright/contrib/dishka.py +120 -0
  6. clientwright-0.4.0/clientwright/contrib/settings.py +316 -0
  7. {clientwright-0.2.2 → clientwright-0.4.0}/pyproject.toml +2 -1
  8. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/contrib/test_dishka.py +42 -0
  9. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/test_bare_install.py +16 -0
  10. clientwright-0.4.0/tests/unit/contrib/test_dishka.py +124 -0
  11. clientwright-0.4.0/tests/unit/contrib/test_settings.py +213 -0
  12. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/test_sdk_imports_are_guarded.py +2 -0
  13. clientwright-0.2.2/clientwright/__version__.py +0 -1
  14. clientwright-0.2.2/clientwright/contrib/dishka.py +0 -80
  15. clientwright-0.2.2/tests/unit/contrib/test_dishka.py +0 -28
  16. {clientwright-0.2.2 → clientwright-0.4.0}/.gitignore +0 -0
  17. {clientwright-0.2.2 → clientwright-0.4.0}/LICENSE +0 -0
  18. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/__init__.py +0 -0
  19. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/__init__.py +0 -0
  20. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/_httpx_shared.py +0 -0
  21. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/_lazy.py +0 -0
  22. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/__init__.py +0 -0
  23. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/_imports.py +0 -0
  24. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/adapter.py +0 -0
  25. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/capabilities.py +0 -0
  26. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/classify.py +0 -0
  27. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/errors.py +0 -0
  28. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/middleware.py +0 -0
  29. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/normalize.py +0 -0
  30. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/options.py +0 -0
  31. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/trace.py +0 -0
  32. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/aiohttp/views.py +0 -0
  33. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/__init__.py +0 -0
  34. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/_imports.py +0 -0
  35. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/adapter.py +0 -0
  36. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/capabilities.py +0 -0
  37. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/classify.py +0 -0
  38. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/errors.py +0 -0
  39. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/normalize.py +0 -0
  40. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/normalize_sync.py +0 -0
  41. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/transport.py +0 -0
  42. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx/views.py +0 -0
  43. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/__init__.py +0 -0
  44. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/_imports.py +0 -0
  45. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/adapter.py +0 -0
  46. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/capabilities.py +0 -0
  47. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/classify.py +0 -0
  48. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/errors.py +0 -0
  49. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/normalize.py +0 -0
  50. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/normalize_sync.py +0 -0
  51. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/transport.py +0 -0
  52. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/httpx2/views.py +0 -0
  53. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/__init__.py +0 -0
  54. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_metrics/__init__.py +0 -0
  55. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_metrics/prometheus.py +0 -0
  56. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_tracing/__init__.py +0 -0
  57. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/observability/_tracing/otel.py +0 -0
  58. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/__init__.py +0 -0
  59. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/_imports.py +0 -0
  60. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/adapter.py +0 -0
  61. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/capabilities.py +0 -0
  62. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/classify.py +0 -0
  63. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/errors.py +0 -0
  64. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/normalize.py +0 -0
  65. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/requests/views.py +0 -0
  66. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/__init__.py +0 -0
  67. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/_imports.py +0 -0
  68. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/adapter.py +0 -0
  69. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/capabilities.py +0 -0
  70. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/classify.py +0 -0
  71. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/errors.py +0 -0
  72. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/normalize.py +0 -0
  73. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/adapters/urllib3/views.py +0 -0
  74. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/contrib/__init__.py +0 -0
  75. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/contrib/deadline.py +0 -0
  76. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/__init__.py +0 -0
  77. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/balancer/__init__.py +0 -0
  78. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/balancer/policy.py +0 -0
  79. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/capabilities.py +0 -0
  80. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/config.py +0 -0
  81. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/__init__.py +0 -0
  82. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/adapter.py +0 -0
  83. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/context.py +0 -0
  84. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/message.py +0 -0
  85. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/observability.py +0 -0
  86. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/contracts/settings.py +0 -0
  87. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/__init__.py +0 -0
  88. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/aio.py +0 -0
  89. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/base.py +0 -0
  90. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/redirects.py +0 -0
  91. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/suppress.py +0 -0
  92. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/engine/sync.py +0 -0
  93. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/errors.py +0 -0
  94. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/model.py +0 -0
  95. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/native.py +0 -0
  96. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/options.py +0 -0
  97. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/plan.py +0 -0
  98. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/__init__.py +0 -0
  99. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/budget.py +0 -0
  100. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/circuit.py +0 -0
  101. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/concurrency.py +0 -0
  102. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/retry.py +0 -0
  103. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/policy/timeout.py +0 -0
  104. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/registry.py +0 -0
  105. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/__init__.py +0 -0
  106. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/emitter.py +0 -0
  107. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/names.py +0 -0
  108. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/null.py +0 -0
  109. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/telemetry/redaction.py +0 -0
  110. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/testing/__init__.py +0 -0
  111. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/testing/doubles.py +0 -0
  112. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/core/testing/origin.py +0 -0
  113. {clientwright-0.2.2 → clientwright-0.4.0}/clientwright/py.typed +0 -0
  114. {clientwright-0.2.2 → clientwright-0.4.0}/tests/__init__.py +0 -0
  115. {clientwright-0.2.2 → clientwright-0.4.0}/tests/conftest.py +0 -0
  116. {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/__init__.py +0 -0
  117. {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/drivers.py +0 -0
  118. {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/engine.py +0 -0
  119. {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/telemetry.py +0 -0
  120. {clientwright-0.2.2 → clientwright-0.4.0}/tests/helpers/views.py +0 -0
  121. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/__init__.py +0 -0
  122. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/__init__.py +0 -0
  123. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_aiohttp.py +0 -0
  124. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_httpx2.py +0 -0
  125. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_httpx_async.py +0 -0
  126. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_httpx_sync.py +0 -0
  127. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_requests.py +0 -0
  128. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/adapters/test_urllib3.py +0 -0
  129. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/chaos/__init__.py +0 -0
  130. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/chaos/test_faults.py +0 -0
  131. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/chaos/test_origin_faults.py +0 -0
  132. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/conftest.py +0 -0
  133. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/contrib/__init__.py +0 -0
  134. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/contrib/test_deadline.py +0 -0
  135. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/parity/__init__.py +0 -0
  136. {clientwright-0.2.2 → clientwright-0.4.0}/tests/integration/parity/test_semantics.py +0 -0
  137. {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/__init__.py +0 -0
  138. {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/conftest.py +0 -0
  139. {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/test_public_endpoints.py +0 -0
  140. {clientwright-0.2.2 → clientwright-0.4.0}/tests/live/test_tls_and_dns.py +0 -0
  141. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/__init__.py +0 -0
  142. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/__init__.py +0 -0
  143. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/__init__.py +0 -0
  144. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_build.py +0 -0
  145. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_normalize.py +0 -0
  146. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_trace.py +0 -0
  147. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/aiohttp/test_units.py +0 -0
  148. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/conftest.py +0 -0
  149. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx/__init__.py +0 -0
  150. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx/test_units.py +0 -0
  151. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx2/__init__.py +0 -0
  152. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/httpx2/test_units.py +0 -0
  153. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/__init__.py +0 -0
  154. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/test_exports.py +0 -0
  155. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/test_otel.py +0 -0
  156. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/observability/test_prometheus.py +0 -0
  157. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/__init__.py +0 -0
  158. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/test_adapter.py +0 -0
  159. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/test_normalize.py +0 -0
  160. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/requests/test_units.py +0 -0
  161. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/test_httpx_family_parity.py +0 -0
  162. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/test_httpx_shared.py +0 -0
  163. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/__init__.py +0 -0
  164. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/test_adapter.py +0 -0
  165. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/test_normalize.py +0 -0
  166. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/adapters/urllib3/test_units.py +0 -0
  167. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/contrib/__init__.py +0 -0
  168. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/contrib/test_deadline.py +0 -0
  169. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/contrib/test_deadline_context.py +0 -0
  170. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/__init__.py +0 -0
  171. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/__init__.py +0 -0
  172. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_aio.py +0 -0
  173. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_base.py +0 -0
  174. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_redirects.py +0 -0
  175. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_suppress.py +0 -0
  176. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/engine/test_sync.py +0 -0
  177. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/__init__.py +0 -0
  178. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_budget.py +0 -0
  179. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_circuit.py +0 -0
  180. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_circuit_properties.py +0 -0
  181. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_concurrency.py +0 -0
  182. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_retry.py +0 -0
  183. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_retry_properties.py +0 -0
  184. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_timeout.py +0 -0
  185. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/policy/test_timeout_properties.py +0 -0
  186. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/__init__.py +0 -0
  187. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_emitter.py +0 -0
  188. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_masking.py +0 -0
  189. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_redaction.py +0 -0
  190. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/telemetry/test_redaction_properties.py +0 -0
  191. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_balancer.py +0 -0
  192. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_capabilities.py +0 -0
  193. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_capabilities_properties.py +0 -0
  194. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_config.py +0 -0
  195. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_enum_coercion.py +0 -0
  196. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_errors.py +0 -0
  197. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_model.py +0 -0
  198. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_options.py +0 -0
  199. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_plan.py +0 -0
  200. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_registry.py +0 -0
  201. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/core/test_settings.py +0 -0
  202. {clientwright-0.2.2 → clientwright-0.4.0}/tests/unit/test_docs_snippets.py +0 -0
  203. {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.2.2
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
- all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka]"]
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