From a1d1126bfe8c8853eeb400b3e5864c515ad5aa8e Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:38:17 +0300 Subject: [PATCH] feat: clientwright.contrib.servicewright behind a [servicewright] extra servicewright's propagation_metadata already satisfies HeaderProvider bare; every service running both kits wrote the same AdapterDeps join by hand. servicewright_headers() returns (propagation_metadata,) as the providers tuple so it composes with AmbientDeadlineSource() instead of competing with it. The module imports servicewright at import time and raises the install hint the way contrib.dishka and contrib.settings do; the extra pins servicewright>=0.1,<1 the way [deadline] pins deadline-budget, and servicewright joins the "Core is stdlib-only" contract so the direction cannot drift. Closes #41 --- README.md | 11 +++ clientwright/contrib/servicewright.py | 47 +++++++++++++ docs/agents.md | 8 ++- docs/guide/context-propagation.md | 67 +++++++++++++++++++ docs/guide/observability.md | 3 + docs/learn/install.md | 1 + docs/reference/contrib.md | 4 ++ docs/reference/index.md | 2 +- pyproject.toml | 5 +- .../integration/contrib/test_servicewright.py | 29 ++++++++ tests/unit/adapters/test_bare_install.py | 15 +++++ tests/unit/contrib/test_servicewright.py | 23 +++++++ tests/unit/test_sdk_imports_are_guarded.py | 1 + uv.lock | 17 ++++- zensical.toml | 1 + 15 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 clientwright/contrib/servicewright.py create mode 100644 docs/guide/context-propagation.md create mode 100644 tests/integration/contrib/test_servicewright.py create mode 100644 tests/unit/contrib/test_servicewright.py diff --git a/README.md b/README.md index 279d690..387d131 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,17 @@ with use_budget(BudgetContext.create(total_seconds=5.0)): await client.get("/users") # runs with what is left of those 5 seconds ``` +## Request context + +With `clientwright[servicewright]`, the ids servicewright bound for the unit of +work being served travel to the upstream as headers: + +```python +from clientwright.contrib.servicewright import servicewright_headers + +deps = AdapterDeps(header_providers=servicewright_headers()) # x-request-id, x-user-id, ... +``` + ## From the environment With `clientwright[settings]`, `ClientConfig` and every sub-config exist as pydantic diff --git a/clientwright/contrib/servicewright.py b/clientwright/contrib/servicewright.py new file mode 100644 index 0000000..a41e12f --- /dev/null +++ b/clientwright/contrib/servicewright.py @@ -0,0 +1,47 @@ +"""servicewright integration (``[servicewright]`` extra). + +servicewright binds the ids of the unit of work being served (``request_id``, +``user_id``, ``tenant_id``, ``trace_id``) into its request context, and +``servicewright.propagation_metadata`` reads them back as outbound headers. +That function already satisfies clientwright's ``HeaderProvider`` protocol +bare; this module names the join so no service writes it by hand:: + + from clientwright.contrib.servicewright import servicewright_headers + + deps = AdapterDeps(header_providers=servicewright_headers()) + client = build("httpx", config, deps) + + await client.get("/users") # carries x-request-id and friends, when bound + +Outside a bound context the provider returns nothing and the request goes out +as before. The helper returns the providers tuple rather than a whole +``AdapterDeps`` so it composes with everything else injected there - +``contrib.deadline`` included - instead of competing with it. + +Unlike ``contrib.deadline`` there is nothing structural to type here; the real +function is the point, so servicewright is imported at module import time and +a missing extra fails right here with an install hint. +""" + +from __future__ import annotations + +try: + from servicewright import propagation_metadata +except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError("servicewright integration requires clientwright[servicewright]; install it.") from exc + +from ..core.contracts.context import HeaderProvider + + +def servicewright_headers() -> tuple[HeaderProvider, ...]: + """The header providers carrying servicewright's request context upstream. + + ``(propagation_metadata,)`` under servicewright's standard mapping - the + request, user, tenant and trace ids as ``x-request-id``, ``x-user-id``, + ``x-tenant-id`` and ``x-trace-id``. For a different mapping build the tuple + yourself: ``(partial(propagation_metadata, keys),)``. + """ + return (propagation_metadata,) + + +__all__ = ["servicewright_headers"] diff --git a/docs/agents.md b/docs/agents.md index 25541fc..16b6624 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -8,7 +8,7 @@ |---|---| | Package | `clientwright` on PyPI, import root `clientwright` | | Requires | Python 3.12+. The core has zero dependencies; every adapter needs its own SDK | -| Install | `pip install "clientwright[httpx]"` · extras: `httpx`, `httpx2`, `aiohttp`, `requests`, `urllib3`, `metrics`, `tracing`, `observability`, `deadline`, `dishka`, `settings`, `all` | +| Install | `pip install "clientwright[httpx]"` · extras: `httpx`, `httpx2`, `aiohttp`, `requests`, `urllib3`, `metrics`, `tracing`, `observability`, `deadline`, `dishka`, `settings`, `servicewright`, `all` | | Async | `build("httpx" | "httpx2" | "aiohttp", config)` — returns the SDK's own async client | | Sync | `build_sync("httpx" | "httpx2" | "requests" | "urllib3", config)` — returns the SDK's own sync client | | Source | | @@ -144,7 +144,7 @@ runtime = ClientRuntime.for_config(config) # APP scope: build it ONCE deps = AdapterDeps( metrics=PrometheusClientMetrics(), # clientwright[metrics] tracer=OpenTelemetryTracer(), # clientwright[tracing] - header_providers=(lambda: {"X-Request-ID": "..."},), + header_providers=(lambda: {"X-Request-ID": "..."},), # or servicewright_headers(), clientwright[servicewright] runtime=runtime, ) client = build("httpx", config, deps) @@ -349,6 +349,7 @@ task-local and thread-local, inherited by tasks started inside it, invisible to | `clientwright.contrib.dishka` | `ClientwrightProvider(adapter, config, deps=None, *, component=None, client_type=None)` | `Scope.APP` provider giving `ClientRuntime` (circuit-state listener wired), a `ClientHandle` closed in `finally`, and the native client under `client_type`; `component` is one per upstream, resolved with `FromComponent(...)` / `component=` | | `clientwright.contrib.settings` | `BaseClientSettings` | `ClientConfig` as a pydantic `BaseModel` — nest it in your own `BaseSettings` (`env_nested_delimiter="__"`); `.to_config(service_name)` returns the `ClientConfig` and is the route for these models (`client_config_from_settings` is the flat legacy one). Never a `BaseSettings` itself, so a bare `BASE_URL` cannot reach a section | | | `BaseTimeoutSettings`, `BasePoolSettings`, `BaseRetrySettings`, `BaseCircuitBreakerSettings`, `BaseTlsSettings`, `BaseProxySettings`, `BaseObservabilitySettings` | the sub-configs, same field names and defaults, each with `.to_config()`. A knob left unset stays `UNSET`; `null` is the explicit "unbounded"; `retry: None = None` in a subclass disables a section; `url_masker` is not a field | +| `clientwright.contrib.servicewright` | `servicewright_headers()` | `tuple[HeaderProvider, ...]` carrying servicewright's request context (`x-request-id`, `x-user-id`, `x-tenant-id`, `x-trace-id`) upstream; goes into `AdapterDeps(header_providers=...)` next to `AmbientDeadlineSource()` | | `clientwright.core.testing` | `OriginServer()` | in-process fault-injecting origin on an ephemeral localhost port | | | `RecordingMetrics()` | a `ClientMetricsProtocol` that remembers every record | | | `ManualClock(start=0.0)` | monotonic clock advanced by hand | @@ -660,6 +661,7 @@ Fetch a page when the task is the one named beside it. | [Capability honesty](guide/capabilities.md) | reading a report, comparing adapters before a migration | | [Dependency injection](guide/dishka.md) | wiring the runtime and the client lifecycle in a container, one component per upstream | | [Deadline budgets](guide/deadline-budget.md) | propagating the inbound request's remaining time | +| [Context propagation](guide/context-propagation.md) | carrying servicewright's request ids to the upstream | | [Testing your service](guide/testing.md) | `OriginServer`, `RecordingMetrics`, what to mock instead | | [Choosing an adapter](adapters/index.md) | picking one, or planning a swap | | [httpx](adapters/httpx.md) | the reference adapter's seam and quirks | @@ -673,6 +675,6 @@ Fetch a page when the task is the one named beside it. | [API reference](reference/index.md) | what is covered by semver, and where each surface is documented | | [Core reference](reference/core.md) | an exact signature or docstring — rendered from source, read it as HTML | | [Adapters reference](reference/adapters.md) | the per-adapter export table, in full | -| [Contrib reference](reference/contrib.md) | the deadline, dishka and settings surfaces | +| [Contrib reference](reference/contrib.md) | the deadline, dishka, settings and servicewright surfaces | | [Testing reference](reference/testing.md) | the docstrings of the test instruments | | [Changelog](changelog.md) | what changed between versions | diff --git a/docs/guide/context-propagation.md b/docs/guide/context-propagation.md new file mode 100644 index 0000000..e75bd84 --- /dev/null +++ b/docs/guide/context-propagation.md @@ -0,0 +1,67 @@ +# Context propagation + +Your service was handed a request carrying `x-request-id: rid-1`. servicewright's +transports bind that id — and the user, tenant and trace ids next to it — into +its request context for the whole unit of work. Every outgoing call that request +makes should carry the same ids, or the trail stops at your service. + +`clientwright[servicewright]` wires that. servicewright's `propagation_metadata` +already satisfies the `HeaderProvider` protocol as it is; the contrib module +names the join so no service writes it by hand. + +## Wiring it + +```python +from clientwright import AdapterDeps, ClientConfig, build +from clientwright.contrib.servicewright import servicewright_headers + +deps = AdapterDeps(header_providers=servicewright_headers()) +client = build("httpx", ClientConfig(service_name="orders"), deps) + +# inside a unit of work the transport has bound (or your own bind_context block): +await client.get("/inventory") # x-request-id, x-user-id, x-tenant-id, x-trace-id +``` + +Only the ids actually bound travel: outside any bound context the provider +returns nothing and the request goes out unchanged. A header the caller set +explicitly is never overwritten — providers fill in what is missing. + +## Composing + +`servicewright_headers()` returns the providers tuple rather than a whole +`AdapterDeps`, so it sits next to everything else you inject: + +```python +from clientwright.adapters.observability import PrometheusClientMetrics +from clientwright.contrib.deadline import AmbientDeadlineSource +from clientwright.contrib.servicewright import servicewright_headers + +deps = AdapterDeps( + metrics=PrometheusClientMetrics(), + header_providers=servicewright_headers(), + deadline_source=AmbientDeadlineSource(), +) +``` + +Your own providers go in the same tuple: `(*servicewright_headers(), my_provider)`. + +## A different header mapping + +`propagation_metadata` takes a `{context_key: header_name}` mapping; the helper +uses servicewright's standard one. For another, build the tuple yourself: + +```python +from functools import partial + +from servicewright import propagation_metadata + +deps = AdapterDeps(header_providers=(partial(propagation_metadata, {"request_id": "X-Correlation-ID"}),)) +``` + +## Why an extra + +Unlike `contrib.deadline`, this module imports servicewright at import time — +the real function is the point, there is nothing structural to type — so a +missing extra fails at `import clientwright.contrib.servicewright` with an +install hint. The extra pins `servicewright>=0.1,<1`; the export has not changed +since 0.1.0. diff --git a/docs/guide/observability.md b/docs/guide/observability.md index 1e28f35..5827b49 100644 --- a/docs/guide/observability.md +++ b/docs/guide/observability.md @@ -23,6 +23,9 @@ deps = AdapterDeps( client = build("httpx", ClientConfig(service_name="orders"), deps) ``` +In a servicewright service the lambda is `servicewright_headers()` from +`clientwright[servicewright]` — see [Context propagation](context-propagation.md). + Both backends are optional protocols — `ClientMetricsProtocol` and `TracerProtocol` are structural, so plugging in a custom sink means implementing methods, not inheriting classes. The test double `RecordingMetrics` (see diff --git a/docs/learn/install.md b/docs/learn/install.md index dc6ad64..db98bcb 100644 --- a/docs/learn/install.md +++ b/docs/learn/install.md @@ -24,6 +24,7 @@ pip install clientwright[httpx] | `deadline` | `deadline-budget` | ambient request-budget propagation | | `dishka` | `dishka>=1.4` | the DI provider with a leak-free lifecycle | | `settings` | `pydantic>=2,<3`, `pydantic-settings>=2.3,<3` | `ClientConfig` from the environment, one pydantic model per sub-config | +| `servicewright` | `servicewright>=0.1,<1` | servicewright's request context as outbound headers | | `all` | everything above | kitchen sink for experiments | Extras combine the way you expect: diff --git a/docs/reference/contrib.md b/docs/reference/contrib.md index fecded8..614ebd1 100644 --- a/docs/reference/contrib.md +++ b/docs/reference/contrib.md @@ -12,6 +12,10 @@ copy_page: false ::: clientwright.contrib.settings +## servicewright + +::: clientwright.contrib.servicewright + ## Dishka `clientwright.contrib.dishka` imports `dishka` at module import time (by design — diff --git a/docs/reference/index.md b/docs/reference/index.md index a7918d4..9e62f11 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -7,7 +7,7 @@ Auto-generated from source docstrings. The prose lives in the |---|---| | [Core](core.md) | `build` / `build_sync` / `inspect`, `ClientConfig` and all sub-configs, the data model, errors, capabilities, plans and runtime | | [Adapters](adapters.md) | per-adapter public exports: per-call channels and dual-family errors | -| [Contrib](contrib.md) | deadline-budget, dishka and pydantic settings integrations | +| [Contrib](contrib.md) | deadline-budget, dishka, pydantic settings and servicewright integrations | | [Testing](testing.md) | `OriginServer`, `RecordingMetrics`, `ManualClock` | ## What is stable diff --git a/pyproject.toml b/pyproject.toml index bf96d18..da086c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,8 @@ observability = ["clientwright[metrics,tracing]"] deadline = ["deadline-budget>=0.1,<1"] dishka = ["dishka>=1.4"] settings = ["pydantic>=2,<3", "pydantic-settings>=2.3,<3"] -all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka,settings]"] +servicewright = ["servicewright>=0.1,<1"] +all = ["clientwright[httpx,httpx2,aiohttp,requests,urllib3,observability,deadline,dishka,settings,servicewright]"] [dependency-groups] test = [ @@ -180,7 +181,7 @@ source_modules = ["clientwright.core"] forbidden_modules = [ "httpx", "httpx2", "aiohttp", "requests", "urllib3", "niquests", "tornado", "aiosonic", "pydantic", "dishka", "prometheus_client", - "opentelemetry", "structlog", "sentry_sdk", + "opentelemetry", "structlog", "sentry_sdk", "servicewright", ] # _httpx_shared is deliberately OUTSIDE this list: it is the one sanctioned diff --git a/tests/integration/contrib/test_servicewright.py b/tests/integration/contrib/test_servicewright.py new file mode 100644 index 0000000..7601edb --- /dev/null +++ b/tests/integration/contrib/test_servicewright.py @@ -0,0 +1,29 @@ +"""servicewright's request context flowing through the engine against a real origin.""" + +from __future__ import annotations + +import pytest + +httpx = pytest.importorskip("httpx", reason="requires the [httpx] extra") +servicewright = pytest.importorskip("servicewright", reason="requires the [servicewright] extra") + +import clientwright # noqa: E402 +from clientwright import AdapterDeps # noqa: E402 +from clientwright.contrib.servicewright import servicewright_headers # noqa: E402 +from clientwright.core.testing import OriginServer # noqa: E402 + +from ..conftest import base_config # noqa: E402 + + +async def test__bound_context__travels_as_headers_and_only_while_bound(origin: OriginServer) -> None: + wired = AdapterDeps(header_providers=servicewright_headers()) + client = clientwright.build("httpx", base_config(origin), wired) + assert isinstance(client, httpx.AsyncClient) + with servicewright.bind_context(request_id="rid-1", tenant_id="acme"): + bound = (await client.get("/echo")).json()["headers"] + outside = (await client.get("/echo")).json()["headers"] + await client.aclose() + assert bound["x-request-id"] == "rid-1" + assert bound["x-tenant-id"] == "acme" + assert "x-request-id" not in outside + assert "x-tenant-id" not in outside diff --git a/tests/unit/adapters/test_bare_install.py b/tests/unit/adapters/test_bare_install.py index b0c33a1..68e59c2 100644 --- a/tests/unit/adapters/test_bare_install.py +++ b/tests/unit/adapters/test_bare_install.py @@ -25,6 +25,7 @@ "deadline_budget", "pydantic", "pydantic_settings", + "servicewright", ) BLOCKER = """ @@ -163,3 +164,17 @@ def test__settings_models_without_the_extra__fail_with_the_install_hint() -> Non """ ) assert "clientwright[settings]" in output + + +def test__contrib_servicewright_without_the_extra__fails_with_the_install_hint() -> None: + output = run_probe( + """ + try: + import clientwright.contrib.servicewright + except ImportError as error: + print(error) + else: + print("NO ERROR") + """ + ) + assert "clientwright[servicewright]" in output diff --git a/tests/unit/contrib/test_servicewright.py b/tests/unit/contrib/test_servicewright.py new file mode 100644 index 0000000..19b529c --- /dev/null +++ b/tests/unit/contrib/test_servicewright.py @@ -0,0 +1,23 @@ +"""contrib units: the servicewright header providers.""" + +from __future__ import annotations + +import pytest + +servicewright = pytest.importorskip("servicewright", reason="requires the [servicewright] extra") + +from clientwright import HeaderProvider # noqa: E402 +from clientwright.contrib.servicewright import servicewright_headers # noqa: E402 + + +def test__providers__are_the_bare_function_and_satisfy_the_protocol() -> None: + providers = servicewright_headers() + assert providers == (servicewright.propagation_metadata,) + assert all(isinstance(provider, HeaderProvider) for provider in providers) + + +def test__provider__reads_the_bound_context_and_nothing_outside_it() -> None: + (provider,) = servicewright_headers() + with servicewright.bind_context(request_id="rid-1", user_id="u-9"): + assert provider() == {"x-request-id": "rid-1", "x-user-id": "u-9"} + assert provider() == {} diff --git a/tests/unit/test_sdk_imports_are_guarded.py b/tests/unit/test_sdk_imports_are_guarded.py index aeebe1c..6fd1463 100644 --- a/tests/unit/test_sdk_imports_are_guarded.py +++ b/tests/unit/test_sdk_imports_are_guarded.py @@ -28,6 +28,7 @@ "deadline_budget", "pydantic", "pydantic_settings", + "servicewright", } ) diff --git a/uv.lock b/uv.lock index 86218b4..ae130a0 100644 --- a/uv.lock +++ b/uv.lock @@ -403,6 +403,7 @@ all = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "requests" }, + { name = "servicewright" }, { name = "urllib3" }, ] deadline = [ @@ -427,6 +428,9 @@ observability = [ requests = [ { name = "requests" }, ] +servicewright = [ + { name = "servicewright" }, +] settings = [ { name = "pydantic" }, { name = "pydantic-settings" }, @@ -488,10 +492,12 @@ requires-dist = [ { name = "pydantic-settings", marker = "extra == 'settings'", specifier = ">=2.3,<3" }, { name = "requests", marker = "extra == 'all'", specifier = ">=2.32,<3" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.32,<3" }, + { name = "servicewright", marker = "extra == 'all'", specifier = ">=0.1,<1" }, + { name = "servicewright", marker = "extra == 'servicewright'", specifier = ">=0.1,<1" }, { name = "urllib3", marker = "extra == 'all'", specifier = ">=2.2,<3" }, { name = "urllib3", marker = "extra == 'urllib3'", specifier = ">=2.2,<3" }, ] -provides-extras = ["aiohttp", "all", "deadline", "dishka", "httpx", "httpx2", "metrics", "observability", "requests", "settings", "tracing", "urllib3"] +provides-extras = ["aiohttp", "all", "deadline", "dishka", "httpx", "httpx2", "metrics", "observability", "requests", "servicewright", "settings", "tracing", "urllib3"] [package.metadata.requires-dev] dev = [ @@ -2040,6 +2046,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] +[[package]] +name = "servicewright" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/2a/52c96b5950a473b83f6489d0a0ac829916ac32c44f0160733716a51614d0/servicewright-0.13.1.tar.gz", hash = "sha256:a92c9ac6258b80a6e3b52eca3fd1104afceb2d281906336b2f58828e066a3950", size = 124180, upload-time = "2026-09-15T12:30:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/0b/916537154ecff0bfb4deba5037248c03559cf5646aff8b77a9d3e1700443/servicewright-0.13.1-py3-none-any.whl", hash = "sha256:aa2a4bb3a0a09efc379beb60e444335c9cafc110ef80a5e3ac39c033bd38d32b", size = 182287, upload-time = "2026-09-15T12:30:16.874Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/zensical.toml b/zensical.toml index c3e1725..d3e7a41 100644 --- a/zensical.toml +++ b/zensical.toml @@ -37,6 +37,7 @@ nav = [ { "Capability honesty" = "guide/capabilities.md" }, { "Dependency injection" = "guide/dishka.md" }, { "Deadline budgets" = "guide/deadline-budget.md" }, + { "Context propagation" = "guide/context-propagation.md" }, { "Testing your service" = "guide/testing.md" }, ] }, { "Adapters" = [