clientwright 0.2.2__tar.gz → 0.3.0__tar.gz

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