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" = [