From 7f385389381c383c329a844dcb6d825df22a3a45 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:07:31 +0300 Subject: [PATCH 1/3] docs: one page for a coding assistant, and a copy-page control on every page --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/docs.yml | 1 + CONTRIBUTING.md | 15 + README.md | 9 + docs/agents.md | 648 ++++++++++++++++++++++++++ docs/assets/javascripts/copy-page.js | 158 +++++++ docs/assets/stylesheets/copy-page.css | 184 ++++++++ overrides/main.html | 84 ++++ scripts/emit_markdown.py | 87 ++++ zensical.toml | 5 + 10 files changed, 1192 insertions(+), 1 deletion(-) create mode 100644 docs/agents.md create mode 100644 docs/assets/javascripts/copy-page.js create mode 100644 docs/assets/stylesheets/copy-page.css create mode 100644 overrides/main.html create mode 100644 scripts/emit_markdown.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 55aa487..ef15417 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,7 +15,7 @@ - [ ] Tests added or updated - [ ] `make check && make test` pass locally - [ ] Conventional-commit title (the changelog is generated by Release Please — do not edit it by hand) -- [ ] Documentation updated (if the public API changed) +- [ ] Documentation updated (if the public API changed), `docs/agents.md` included ## Related issues diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2fc1033..06b3b88 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,7 @@ jobs: # --no-dev on `uv run` too: without it the implicit sync reinstalls the # whole dev group just to build docs. - run: uv run --no-dev --group docs zensical build --clean + - run: uv run --no-dev --group docs python scripts/emit_markdown.py - uses: actions/upload-pages-artifact@v5 with: path: site diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d87624..051fb5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,21 @@ Breaking changes: add `!` after the type (`feat!:`) or include a `BREAKING CHANG 4. Run `make check && make test-unit` locally 5. Open a PR against `master` +## The agents page + +`docs/agents.md` is the whole library on one page, written for a coding assistant: the +public API with its real defaults, where the boundary between the core engine and an +adapter runs, the rules that break code when they are broken, the mistakes models make, +and a map of which page to fetch for the rest. People hand it to an assistant instead of +the site, which is what makes a stale one worse than none — it teaches a model an API +that no longer exists. + +It is part of the public API, so it changes in the same pull request the API does: a name +added, renamed or removed, a changed default or signature, a new capability or failure +kind, a new rule a caller has to obey. A new docs page means a new row in the +documentation map. The review check is mechanical — if the diff changes the public +surface and `docs/agents.md` is untouched, the pull request is not finished. + ## Releasing (maintainers only) Releases are fully automated via [Release Please](https://github.com/googleapis/release-please). diff --git a/README.md b/README.md index 1d7be46..5a87304 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,15 @@ handle = inspect(client) # what actually got applied print(handle.report.dropped) # config the adapter could not express ``` +> [!TIP] +> **Building this with an AI assistant?** Hand it +> **[one page](https://bedrock-python.github.io/clientwright/agents/)** instead of the +> whole site: the whole public API with its real defaults, where the boundary between the +> core engine and an adapter runs, the rules that break code when they are broken, the +> mistakes models actually make with this API, and a map of which page to fetch for the +> rest. Every docs page is also served as raw Markdown at its own URL, and a **Copy page** +> button at the top of each one hands it straight to a chat window. + ## Why - **Instrumentation under the public API.** The seam sits in the transport diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..e4d4fd2 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,648 @@ +# clientwright for AI agents + +> One page holding everything a coding assistant needs to configure and drive +> clientwright correctly, plus a map of where the rest of the documentation keeps the +> details it leaves out. Give an agent this page rather than the whole site. + +| | | +|---|---| +| 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`, `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 | | + +## How to read this page + +Every page of this site is also served as raw Markdown at its own URL with `.md` in +place of the trailing slash — this page is `/agents.md`, the retry guide is +`/guide/retries.md` — so anything the map below points at can be fetched as plain text +rather than scraped out of HTML. The **Copy page** control at the top of a page does the +same thing for a human with a chat window open. The exception is the API reference: +`reference/core.md`, `reference/contrib.md` and `reference/testing.md` are mostly +instructions to a docstring renderer, so their Markdown twin is not the API — read them +as HTML, or read the docstrings in the source. + +Top to bottom before writing code. [Rules that hold or break the code](#rules-that-hold-or-break-the-code) +is the section correctness lives in — those are the things the library will not save you +from. Every name used below is in the public API; if you need something not listed here, +fetch the page the [documentation map](#documentation-map) points at rather than guessing +a method that sounds plausible. + +## Scope + +**It does** build the real native client of an HTTP library with one resilience and +observability engine already installed *under* its public API: a wall-clock deadline over +the whole logical call, retries with jittered backoff and a per-origin budget, a circuit +breaker, engine-owned redirects, header injection, and one frozen `http_client_*` +telemetry schema. `build("httpx", config)` returns a genuine `httpx.AsyncClient` — +`type(client) is httpx.AsyncClient` — and the same `ClientConfig` builds an +`aiohttp.ClientSession`, a `requests.Session`, a `urllib3.PoolManager` or an +`httpx2.AsyncClient` with the same semantics. + +**It does not** define a request API of its own: you keep calling `client.get(...)` in +your SDK's own vocabulary, and clientwright never wraps, subclasses or proxies the client. +It does not parse configuration files, read environment variables, or ship a settings +model. It does not pool, cache or deduplicate responses, does not do client-side load +balancing (`TargetResolverProtocol` in `core.balancer` is an unimplemented seam), and does +not hide the differences between SDKs — it *declares* them and reports what it could not +apply. + +## Mental model + +Six nouns and one fixed order. + +* **`ClientConfig`** — the whole client described once, transport-free: no `httpx.Timeout`, + no `aiohttp.ClientTimeout`, no SDK type anywhere. Frozen dataclasses, validated in + `__post_init__`. +* **Adapter** — one SDK, addressed by a string name (`"httpx"`, `"httpx2"`, `"aiohttp"`, + `"requests"`, `"urllib3"`). It finds the seam under the SDK's public API, translates + request and response objects into neutral views, classifies the SDK's exceptions into + `FailureKind`, and sends. It does not retry, does not follow redirects, does not measure + time and never writes a metric. +* **Engine** — `AsyncAttemptEngine` / `SyncAttemptEngine` in `core.engine`. One per built + client, and the only place a retry loop, a deadline, a redirect hop or a telemetry call + exists. Fixing a retry edge case fixes it for every adapter at once. +* **`AdapterCapabilities`** — what an adapter admits about itself: its seam, whether the + seam sees a hop or the logical call, whether the call duration ends at headers or at the + full body, a `Capability -> Support` map (`native` / `emulated` / `degraded` / `absent`), + the `FailureKind`s it can actually emit, and which finer kinds collapse into coarser ones. + Importable with no extras installed. +* **`ConfigApplicationReport`** — what happened when this config met this adapter: + `applied_natively`, `emulated`, `dropped` (`{Capability: reason}`) and + `dead_retryable_kinds`. `config.on_unsupported` decides whether issues raise, warn or + stay silent. +* **`ClientRuntime`** — the state that must outlive a client: circuit breakers, retry + budgets, per-origin limiters. **Application scope.** A fresh runtime per request makes + the breaker decorative. + +`build()` returns the client; `build_handle()` returns a `ClientHandle` holding the client, +the capability record, the report, the runtime, the compiled plan and the right closer. +`inspect(client)` finds that handle again from any client clientwright built. + +The order inside one logical call is baked in and not configurable: + +```text +deadline (config total ∩ ambient budget) + -> header injection (config.headers, header_providers, trace context) + -> per-origin slot (only where the per-host limit is emulated) + -> circuit check (once, before any attempt) + -> body freeze (when retries or owned redirects are on) + -> [ redirect-hop loop -> attempt loop ] + -> circuit record (once, with the FINAL outcome) + -> telemetry end (in a finally, on every path) +``` + +The attempt loop lives *inside* the hop loop: `max_attempts` is per hop, and the total +deadline is the only thing bounding the pair. + +## Wiring + +```python +import httpx + +from clientwright import ( + CircuitBreakerConfig, + ClientConfig, + RetryConfig, + TimeoutConfig, + build, + inspect, +) + +config = ClientConfig( + service_name="orders", # required: the `service` label on every metric and log line + base_url="https://api.warehouse.example.com", + timeout=TimeoutConfig(total=10.0, connect=2.0), + retry=RetryConfig(max_attempts=3), # the default; spelled out to be visible + circuit_breaker=CircuitBreakerConfig(fail_threshold=5), + on_unsupported="strict", # fail the build on anything the adapter cannot express +) + +client: httpx.AsyncClient = build("httpx", config) +assert type(client) is httpx.AsyncClient # not a subclass, not a wrapper + +response = await client.get("/stock/widgets") + +handle = inspect(client) +print(handle.report.dropped, handle.report.dead_retryable_kinds) +await client.aclose() +``` + +The sync twin is the same text with `build_sync`, `httpx.Client` and no `await`. Wiring +telemetry, ambient deadlines and a shared runtime goes through `AdapterDeps`: + +```python +from clientwright import AdapterDeps, ClientRuntime, build +from clientwright.adapters.observability import OpenTelemetryTracer, PrometheusClientMetrics + +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": "..."},), + runtime=runtime, +) +client = build("httpx", config, deps) +``` + +## The API + +Everything in this section is exported from `clientwright` unless the table says otherwise. + +### Builders and the handle + +| Name | Signature | Returns | +|---|---|---| +| `build` | `build(adapter, config, deps=None)` | the SDK's own **async** client, typed `Any` | +| `build_sync` | `build_sync(adapter, config, deps=None)` | the SDK's own **sync** client, typed `Any` | +| `build_handle` | same arguments | `ClientHandle[Any]` (async) | +| `build_sync_handle` | same arguments | `ClientHandle[Any]` (sync) | +| `inspect` / `inspect_client` | `inspect(client)` | `ClientHandle[Any] \| None` — `None` for a foreign object | +| `registered_adapters` | `registered_adapters()` | `('aiohttp', 'httpx', 'httpx2', 'requests', 'urllib3')` | +| `resolve_adapter` | `resolve_adapter(name)` | the adapter class; `UnknownAdapterError` if unknown | +| `register_adapter` | `register_adapter(name, "module.path:Class", "module.path:CAPABILITIES")` | `None` | +| `capabilities_matrix` | `capabilities_matrix()` | `dict[str, AdapterCapabilities]`, extras-free | +| `client_config_from_settings` | `client_config_from_settings(settings, service_name)` | `ClientConfig` | +| `is_set` | `is_set(value)` | `False` only for `UNSET` | + +`build` and its siblings are typed `Any` on purpose: the core cannot name +`httpx.AsyncClient` without depending on httpx, so a narrower type would force a cast on +every caller. `client: httpx.AsyncClient = build(...)` type-checks. + +`ClientHandle` fields: `client`, `adapter`, `capabilities`, `report`, `runtime`, `plan`, +`aclose` (async builds) and `close` (sync builds). Closing is yours — nothing closes the +client for you except `ClientwrightProvider`. + +`AdapterDeps` fields, every one optional: `metrics` (`ClientMetricsProtocol`), `tracer` +(`TracerProtocol`), `header_providers` (`tuple[HeaderProvider, ...]`), `deadline_source` +(`DeadlineSource`), `runtime` (`ClientRuntime`), `clock` (`() -> float`, monotonic). + +`ClientRuntime.for_config(config, *, clock=None, rng=None, circuit_listener=None)` builds +the circuit registry, the retry budget registry and the per-origin limiters from a config. +`ClientRuntime(...)` takes the same pieces directly. + +### `ClientConfig` + +| Field | Default | Meaning | +|---|---|---| +| `service_name` | **required** | non-empty; the `service` label and the logger name | +| `base_url` | `None` | must start with `http://` or `https://`; rejected by the requests and urllib3 adapters | +| `timeout` | `TimeoutConfig()` | see below | +| `pool` | `PoolConfig()` | see below | +| `retry` | `RetryConfig()` | `None` disables retries entirely | +| `circuit_breaker` | `CircuitBreakerConfig()` | `None` disables the breaker | +| `tls` | `TlsConfig()` | | +| `proxy` | `None` | `ProxyConfig(url=...)` or `ProxyConfig(from_env=True)` | +| `headers` | `{}` | injected with setdefault semantics: a caller-set header always wins | +| `redirects` | `RedirectMode.OWNED` | `NATIVE` hands following back to the SDK | +| `max_redirects` | `5` | `>= 0`; the engine raises `TooManyRedirectsError` past it | +| `caller_override` | `CallerOverride.CALLER_WINS` | what a per-call `timeout=` does | +| `deadline_header` | `None` | e.g. `"X-Deadline-Ms"`; stamped with the *remaining* budget in whole ms before each attempt | +| `observability` | `ObservabilityConfig()` | | +| `native` | `NativeOptions()` | raw passthrough, validated at build | +| `on_unsupported` | `UnsupportedPolicy.WARN` | `STRICT` raises `UnsupportedCapabilityError` at build | + +### `TimeoutConfig` + +| Field | Default | Meaning | +|---|---|---| +| `total` | `30.0` | wall clock for the whole logical call: every attempt, backoff sleep and redirect hop. `None` = unbounded | +| `attempt` | `UNSET` | ceiling for one attempt. Async enforces it by cancellation; sync cannot and reports it dropped | +| `connect` | `5.0` | | +| `read` | `UNSET` | | +| `write` | `UNSET` | | +| `pool_acquire` | `UNSET` | | + +`UNSET` is not `None`. `UNSET` means "defer to the adapter's native default" and the +deferral is visible in the report; `None` means "explicitly unbounded". Every value must be +positive. Whatever a phase resolves to, the engine clamps it to the remaining total before +each attempt — a phase that is `None` after resolution becomes exactly the remaining budget. + +### `PoolConfig` + +| Field | Default | +|---|---| +| `max_connections` | `100` | +| `max_keepalive` | `20` (ignored by aiohttp, which has no such cap) | +| `keepalive_expiry` | `30.0` seconds; `None` forces connections closed on aiohttp | +| `max_connections_per_host` | `UNSET` — setting it also makes the requests and urllib3 pools *blocking* | +| `http2` | `UNSET`; only the httpx family can honour it | + +### `RetryConfig` + +| Field | Default | Meaning | +|---|---|---| +| `max_attempts` | `3` | total attempts including the first, **per redirect hop**; `>= 1` | +| `initial_backoff` | `0.1` | seconds | +| `max_backoff` | `10.0` | | +| `multiplier` | `2.0` | `>= 1` | +| `jitter` | `0.2` | within `[0, 1]`; multiplicative ±20 % | +| `retryable_kinds` | `DEFAULT_RETRYABLE_KINDS` | `connect_timeout`, `connect_error`, `dns_error`, `pool_timeout`, `read_timeout`, `disconnected` | +| `retryable_status` | `DEFAULT_RETRYABLE_STATUS` | `{429, 502, 503, 504}` — note `500` is absent | +| `methods` | `IDEMPOTENT_METHODS` | `{GET, HEAD, PUT, DELETE, OPTIONS, TRACE}` | +| `respect_retry_after` | `True` | a `Retry-After` (seconds or HTTP-date) replaces the computed backoff | +| `retry_after_max` | `60.0` | cap on what a server may ask for | +| `budget_ratio` | `0.1` | per-origin token bucket: ~10 % of traffic may be retries. `None` disables the budget | +| `require_replayable_body` | `True` | | +| `mode` | `RetryMode.OWNED` | `DELEGATED` is honoured by the urllib3 adapter only | + +The decision ladder, in order, is: a retry-worthy outcome (`status_` or +`kind_`) → attempts left → idempotency → replayable body → the backoff fits in the +remaining deadline → a token in the origin's budget. Refusals at the last four steps emit +`http_client_retry_skipped_total{reason=method|non_replayable|deadline|budget}`. + +### `CircuitBreakerConfig` + +| Field | Default | Meaning | +|---|---|---| +| `fail_threshold` | `5` | consecutive tripping calls before `CLOSED -> OPEN` | +| `recovery_timeout` | `60.0` | seconds `OPEN` before the next call becomes a probe | +| `half_open_max_calls` | `1` | concurrent probes, the transitioning call included | +| `max_keys` | `512` | LRU cap; only cold, fully closed circuits are evicted | +| `key` | `CircuitKey.ORIGIN` | or `ORIGIN_ROUTE` (needs a per-call `route`), `ORIGIN_METHOD` | +| `trip_kinds` | `DEFAULT_TRIP_KINDS` | timeouts, connect/DNS/TLS errors, disconnects, protocol errors and `STATUS` | + +`STATUS` trips only for `5xx`. A `429` or a `404` never trips the breaker. + +### `ObservabilityConfig`, `TlsConfig`, `ProxyConfig`, `NativeOptions` + +| Config | Fields | +|---|---| +| `ObservabilityConfig` | `logging=True`, `metrics=True`, `tracing=True`, `success_log_level=logging.INFO`, `sensitive_query_params=DEFAULT_SENSITIVE_QUERY_PARAMS`, `url_masker=None` (any `(str) -> str`; must be callable) | +| `TlsConfig` | `verify=True`, `ca_bundle=None`, `cert=None` (a PEM path, a `(cert, key)` pair, or a `(cert, key, password)` triple where the SDK supports one) | +| `ProxyConfig` | `url=None`, `from_env=False` — mutually exclusive, `ValueError` if both are given | +| `NativeOptions` | `NativeOptions.of(slot={...})`; `slots` is `{slot_name: {kwarg: value}}` | + +`DEFAULT_SENSITIVE_HEADERS` is exported but is not a config knob: clientwright never emits +headers into a log line or a span, so there is nothing for it to protect here. It exists for +services that log headers themselves, with +`clientwright.core.telemetry.redaction.redact_headers`. + +### Data model and enums + +| Name | Values / fields | +|---|---| +| `FailureKind` | `connect_timeout`, `read_timeout`, `write_timeout`, `pool_timeout`, `total_timeout`, `connect_error`, `dns_error`, `tls_error`, `protocol_error`, `disconnected`, `body_error`, `status`, `cancelled`, `circuit_open`, `unknown` | +| `Outcome` | `kind` (`None` means success), `status_code`, `retry_after`, `exception`; `.ok` | +| `RequestInfo` | `method`, `origin`, `url`, `route`, `idempotent`; `.circuit_key(mode)` | +| `ResolvedTimeouts` | `connect`, `read`, `write`, `pool_acquire`, `attempt` | +| `CircuitKey` | `ORIGIN`, `ORIGIN_ROUTE`, `ORIGIN_METHOD` | +| `RedirectMode` | `OWNED`, `NATIVE` | +| `RetryMode` | `OWNED`, `DELEGATED` | +| `CallerOverride` | `CALLER_WINS`, `CONFIG_WINS`, `RAISE` | +| `UnsupportedPolicy` | `IGNORE`, `WARN`, `STRICT` | +| `Support` | `NATIVE`, `EMULATED`, `DEGRADED`, `ABSENT` | +| `Capability` | `timeout_total`, `timeout_attempt`, `timeout_connect`, `timeout_read`, `timeout_write`, `timeout_pool`, `deadline_hard`, `pool_limit_total`, `pool_limit_per_host`, `keepalive`, `pool_metrics`, `conn_metrics`, `redirects_ownable`, `native_retry_disableable`, `per_call_options`, `retrofit`, `exact_native_type`, `balancer`, `http2`, `http3`, `proxy` | +| `SeamGranularity` | `HOP`, `LOGICAL` | +| `DurationBoundary` | `HEADERS`, `FULL` | + +Every `StrEnum` field on a config is coerced at construction, so `redirects="native"` works +and `redirects="natvie"` raises `ValueError` instead of silently doing nothing. + +`AdapterCapabilities`: `adapter`, `seam`, `granularity`, `boundary`, `support`, `emits`, +`collapses`, `notes`, `.support_of(capability)`. +`ConfigApplicationReport`: `adapter`, `applied_natively`, `emulated`, `dropped`, +`dead_retryable_kinds`, `collapsed_kinds`, `native_overrides`, `.has_issues`, `.issues()`, +`.enforce(policy)`. `native_overrides` is part of the record's shape but no shipped adapter +fills it in — read the accepted passthrough off your own `NativeOptions`, not off the +report. + +### Per-call options + +Two facts only the call site knows: the low-cardinality `route` template for metrics and +the breaker key, and whether *this* non-idempotent request may be repeated. + +```python +from clientwright import call_options # works for every adapter +from clientwright.adapters.httpx import IDEMPOTENT_EXTENSION, ROUTE_EXTENSION + +# httpx and httpx2: request extensions, no ambient state +await client.post( + f"/users/{user_id}/orders", + json={"sku": "x"}, + extensions={ROUTE_EXTENSION: "/users/{id}/orders", IDEMPOTENT_EXTENSION: True}, +) + +# aiohttp, requests, urllib3: a ContextVar block around the call +with call_options(route="/users/{id}/orders", idempotent=True): + session.post(url, json={"sku": "x"}) +``` + +`clientwright.adapters.aiohttp.call_options`, `...requests.call_options` and +`...urllib3.call_options` are the same object as `clientwright.call_options`. The block is +task-local and thread-local, inherited by tasks started inside it, invisible to siblings. +`current_call_options()` reads it. + +### Contrib and testing + +| Import | Name | Purpose | +|---|---|---| +| `clientwright.contrib.deadline` | `AmbientDeadlineSource()` | `DeadlineSource` reading whatever `use_budget` installed in this task — the right default for a long-lived client | +| | `BudgetDeadlineSource(budget)` | one fixed budget, for a request-scoped client | +| | `use_budget(budget)` | context manager installing a budget; `use_budget(None)` detaches | +| | `current_budget()` | the installed budget or `None` | +| | `DeadlineBudgetProtocol` | structural: `remaining() -> float`, `expired() -> bool` | +| `clientwright.contrib.dishka` | `ClientwrightProvider(adapter, config, deps=None)` | `Scope.APP` provider giving `ClientRuntime` and a `ClientHandle` closed in `finally` | +| `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 | + +`OriginServer` routes: `/echo`, `/status/{code}`, `/slow/{seconds}`, `/redirect/{n}`, +`/redirect-loop`, `/flaky/{key}/{fails}`, `/retry-after/{seconds}`, `/disconnect`, +`/hang-body/{seconds}`, `/drop-body`, `/garbage`, `/reset`, +`/flaky-disconnect/{key}/{fails}`. It carries `.url` and `.request_count(prefix)`. + +### Telemetry + +Metric names and label sets are a frozen wire contract in +`clientwright.core.telemetry.names`; changing one is a breaking release. + +| Metric | Type | Emitted | +|---|---|---| +| `http_client_requests_total` | counter | once per **logical call** | +| `http_client_request_duration_seconds` | histogram | once per logical call | +| `http_client_body_duration_seconds` | histogram | when the adapter wraps the body stream (httpx family only) | +| `http_client_attempts_total` | counter | once per **physical attempt** (not under `RetryMode.DELEGATED`) | +| `http_client_attempt_duration_seconds` | histogram | same | +| `http_client_inflight` | gauge | ±1 around each logical call | +| `http_client_circuit_state` | gauge | on a breaker transition, **only when the adapter built the runtime** | +| `http_client_redirect_hops_total` | counter | per followed hop | +| `http_client_retry_skipped_total` | counter | `reason=method\|non_replayable\|deadline\|budget` | +| `http_client_uninstrumented_calls_total` | counter | aiohttp only: a request that bypassed the middleware | + +`outcome` is `success` or a `FailureKind` value; `status` is the numeric status or the +string `none`; `route` is `unknown` until a call site sets it. Backends: +`clientwright.adapters.observability.PrometheusClientMetrics(prefix=None, registry=REGISTRY, buckets=...)` +(cached per registry and prefix) and `OpenTelemetryTracer(tracer_provider=None)`. + +## Adapters + +| Adapter | Flavors | Seam | Native slots | Per-call channel | +|---|---|---|---|---| +| `httpx` | async + sync | `transport` | `client`, `transport` | request `extensions` | +| `httpx2` | async + sync | `transport` | `client`, `transport` | request `extensions` | +| `aiohttp` | async only | client `middleware` | `session`, `connector` | `call_options()` | +| `requests` | sync only | mounted `HTTPAdapter` | `session`, `adapter` | `call_options()` | +| `urllib3` | sync only | instance `urlopen` | `manager` | `call_options()` | + +Asking an adapter for a flavor it does not have raises `UnsupportedCapabilityError` at +build time, not at call time. + +What each one will not do: + +* **httpx / httpx2** — identical public names, one import path apart; the family shares its + implementation. Per-host pool limits are *emulated* by a per-origin in-flight semaphore + (it limits requests, not connections). DNS failures collapse into `connect_error`. + Everything TLS- and pool-related goes into the transport constructor, because + `Client(transport=...)` silently ignores `verify`, `http2` and `limits`. +* **aiohttp** — the session must be constructed inside a running event loop. Write and pool + timeouts do not exist and are dropped; `pool.max_keepalive` is ignored; HTTP/2 is dropped. + `ClientTimeout.total` is deliberately `None` so aiohttp's timer cannot wrap the engine's + own retry loop. The call duration ends at the headers, so there is no body-duration + metric. A caller writing `session.get(url, middlewares=())` replaces the chain and + bypasses the engine entirely — that is counted, not prevented. +* **requests** — no `base_url` (a config that sets one fails the build), no write or pool + timeout, no `attempt` ceiling, no hard deadline. It closes requests' famous hole: the + engine sends every attempt with a planned `(connect, read)` pair, so a bare + `session.get(url)` can no longer hang forever. A three-element `cert` (with a key + password) is refused. +* **urllib3** — no `base_url`. `type(client) is urllib3.PoolManager` holds because the + engine is an instance attribute, not a subclass. It is the only adapter where + `pool_acquire` is real, and then only when `pool.max_connections_per_host` makes the pool + blocking. It is also the only adapter that honours `RetryMode.DELEGATED`, translating + `RetryConfig` into `urllib3.util.Retry` — `multiplier` is not translated, because urllib3 + fixes the base at 2. Environment proxies are not read; pass `ProxyConfig(url=...)`. + +## Rules that hold or break the code + +1. **`ClientRuntime` is application-scoped.** It holds the breakers, the retry budgets and + the per-origin limiters. Build it once, share it through `AdapterDeps(runtime=...)` + across request-scoped clients. A runtime per request is a breaker with no memory. +2. **A runtime you build yourself has no circuit-state gauge.** The + `http_client_circuit_state` listener is wired only when the *adapter* builds the runtime. + Passing `deps.runtime` — including through `ClientwrightProvider` — keeps the breaker + working and loses that one gauge. +3. **`UNSET` is not `None`.** `UNSET` defers to the adapter's native default and says so in + the report; `None` means explicitly unbounded. Both differ from a number. +4. **`retryable_kinds` cannot retry a status.** The policy checks `retryable_status` first, + and the kind branch explicitly skips `FailureKind.STATUS`. Putting `STATUS` in + `retryable_kinds` does nothing; change `retryable_status` instead. +5. **A `429` is retried but is not a failure.** Only `5xx` becomes + `FailureKind.STATUS`, so a `429` that survives every retry is recorded as + `outcome="success", status="429"` and never trips the breaker. A plain `500` is not in + the default `retryable_status` and is not retried. +6. **`RetryMode.DELEGATED` means "no retries" on every adapter except urllib3.** The engine + builds no retry policy in that mode, and only the urllib3 adapter translates the config + into native machinery. On httpx, aiohttp or requests it silently disables retrying and + per-attempt metrics. +7. **`max_attempts` is per redirect hop.** The attempt loop sits inside the hop loop, so a + 3-attempt policy over a 5-hop chain can issue 15 physical requests — bounded only by the + total deadline, and still exactly one circuit signal and one `requests_total`. +8. **A body that cannot be replayed vetoes every repeat.** Whenever retries or owned + redirects are on — both are defaults — the engine freezes the body before the first + send. The httpx family reads the whole request body into memory; requests replays bytes + and strings and rewinds a seekable stream; aiohttp and urllib3 replay bytes-backed + bodies only. Anything else marks the call non-replayable, which forbids every retry and + every body-preserving redirect. That refusal is a counter + (`retry_skipped{reason="non_replayable"}`), never an exception — you get the failed + response, not an error. +9. **The engine will not retry a `POST` on its own.** Pass `idempotent=True` at the call + site — the extension for httpx, `call_options` elsewhere — and mean it. The reverse is + *not* symmetric: `idempotent=False` on a `GET` does not stop a retry, because the method + gate refuses only when the method is outside `retry.methods` **and** the flag is false. + To stop retrying a method, remove it from `RetryConfig.methods`. +10. **The total deadline covers everything and is only hard on async.** Async engines wrap + each attempt in a cancellation scope; sync engines cannot cancel a blocked socket, so + they clamp phases and re-check at attempt boundaries — the failure then arrives as + `read_timeout`, not `total_timeout`. Sync adapters declare `deadline_hard: absent`. +11. **`caller_override` never lets a caller escape the total.** `CALLER_WINS` replaces the + config's phases with the caller's, then clamps them to the remaining budget; a + `timeout=60` on a call with 3 seconds left gets 3 seconds. `RAISE` makes a per-call + timeout an error. +12. **Do not stack a second retry loop.** `tenacity` or a hand-rolled decorator above the + client multiplies attempts (3 × 3 = 9 against a struggling upstream) and corrupts the + accounting. Keep one loop: theirs *or* `retry=None` plus yours. +13. **Native passthrough is validated, not forwarded.** An unknown slot, a key the engine + owns (`timeout`, `transport`, `retries`, `max_redirects`, pool and TLS knobs), a + misspelled constructor argument or a collision with a config field you also set is a + `NativeConfigError` at build time. +14. **`on_unsupported` decides how loud a mismatch is.** `WARN` (the default) logs and + continues; `STRICT` raises `UnsupportedCapabilityError` at build. Production configs + should be `STRICT` — a dropped knob becomes a failed deploy instead of a false belief. +15. **`dead_retryable_kinds` is a real class of bug.** A retry trigger the chosen adapter + can never emit (`dns_error` on the httpx family, which collapses it into + `connect_error`) is configured, believed in and impossible. The report names them. +16. **You close the client.** `handle.aclose` / `handle.close` exist and nothing calls them + for you except `ClientwrightProvider`'s generator provide. +17. **`base_url` is httpx-family and aiohttp only.** requests and urllib3 reject it at build + rather than inventing a wrapper. +18. **The route label is `unknown` until you set it, and must be a template.** An + interpolated URL as `route` is a cardinality bomb in the metrics and, under + `CircuitKey.ORIGIN_ROUTE`, in the breaker's key space too. + +## Common mistakes + +```python +# WRONG - a runtime per client, so the breaker forgets everything each request +from clientwright import AdapterDeps, ClientConfig, ClientRuntime, build + + +def get_client(config: ClientConfig): + return build("httpx", config, AdapterDeps(runtime=ClientRuntime.for_config(config))) + + +# RIGHT - one runtime for the process, shared by every client of that upstream +RUNTIME = ClientRuntime.for_config(config) + + +def get_client(config: ClientConfig): + return build("httpx", config, AdapterDeps(runtime=RUNTIME)) +``` + +```python +# WRONG - a wrapper around the client, which is the pattern the library exists to remove +from clientwright import ClientConfig, build + + +class ResilientClient: + def __init__(self, config: ClientConfig): + self._client = build("httpx", config) + + async def get(self, url: str): + for _ in range(3): # a second retry loop: 3 x 3 = 9 requests + return await self._client.get(url) + + +# RIGHT - the built client already is the resilient client +client = build("httpx", config) +response = await client.get("/stock") +``` + +```python +# WRONG - retrying a status by putting it in retryable_kinds +from clientwright import FailureKind, RetryConfig + +RetryConfig(retryable_kinds=frozenset({FailureKind.STATUS})) # does nothing at all + +# RIGHT - statuses have their own list +RetryConfig(retryable_status=frozenset({429, 500, 502, 503, 504})) +``` + +```python +# WRONG - expecting the engine to retry a POST because the config allows the status +await client.post("/orders", json=payload) + +# RIGHT - the call site vouches for this particular POST +from clientwright.adapters.httpx import IDEMPOTENT_EXTENSION, ROUTE_EXTENSION + +await client.post( + "/orders", + json=payload, + extensions={ROUTE_EXTENSION: "/orders", IDEMPOTENT_EXTENSION: True}, +) +``` + +```python +# WRONG - assuming DELEGATED means "the SDK retries for me" on any adapter +from clientwright import ClientConfig, RetryConfig, RetryMode + +ClientConfig(service_name="orders", retry=RetryConfig(mode=RetryMode.DELEGATED)) # httpx: no retries + +# RIGHT - delegated is a urllib3 carve-out; everywhere else leave the engine in charge +ClientConfig(service_name="orders", retry=RetryConfig(mode=RetryMode.OWNED)) +``` + +```python +# WRONG - smuggling an engine-owned knob through native passthrough +from clientwright import ClientConfig, NativeOptions, TimeoutConfig + +ClientConfig(service_name="x", native=NativeOptions.of(client={"timeout": 5.0})) # ReservedNativeKeyError + +# RIGHT - the config owns it; passthrough is for what the config does not cover +ClientConfig( + service_name="x", + timeout=TimeoutConfig(total=5.0), + native=NativeOptions.of(client={"trust_env": False}), +) +``` + +```python +from clientwright import ClientConfig, UnsupportedPolicy, build_sync, build_sync_handle + +# WRONG - trusting that a config written for httpx means the same thing on requests +client = build_sync("requests", ClientConfig(service_name="reports")) + +# RIGHT - make the build fail on anything the adapter could not express, and read it +handle = build_sync_handle( + "requests", + ClientConfig(service_name="reports", on_unsupported=UnsupportedPolicy.STRICT), +) +assert not handle.report.has_issues +``` + +## Errors + +Everything the library raises on its own authority derives from `ClientwrightError`. +Errors raised on the call path derive from `CallError` and are re-raised by each adapter as +a class that *also* inherits the SDK's own error family, so an existing +`except httpx.HTTPError` or `except aiohttp.ClientError` keeps working. + +| Error | Raised when | +|---|---| +| `UnknownAdapterError` | `build("htpx", ...)` — carries `.name` and `.known` | +| `UnsupportedCapabilityError` | `on_unsupported="strict"` with issues in the report; also an adapter asked for a flavor it lacks, `base_url` on requests or urllib3, or a password-protected client cert on requests | +| `NativeConfigError` | base of the passthrough errors: `UnknownNativeSlotError`, `ReservedNativeKeyError`, `UnknownNativeKeyError` (with a did-you-mean), `NativeConfigConflictError` — the four subclasses live in `clientwright.core.errors` | +| `CircuitOpenError` | the circuit for this key is open; `.key`, `.retry_after` | +| `DeadlineExceededError` | the total deadline is exhausted; `.total` | +| `TooManyRedirectsError` | more than `max_redirects` hops; `.hops` | +| `NotReplayableError` | exported, and passed through the adapter translators unchanged, but the engine never raises it: a non-replayable body ends the call with the response it already has plus a `retry_skipped{reason="non_replayable"}` counter | +| `CallerOverrideForbiddenError` | a per-call timeout under `CallerOverride.RAISE`; a `CallError`, importable from `clientwright.core.policy.timeout` | + +Per-adapter classes are the same three names with the adapter's prefix: +`HttpxCircuitOpenError`, `HttpxDeadlineExceededError`, `HttpxTooManyRedirectsError` (also +under `clientwright.adapters.httpx2`, deliberately with the same class names), and the +`Aiohttp*`, `Requests*`, `Urllib3*` trios. + +## Documentation map + +Fetch a page when the task is the one named beside it. + +| Page | Read it when | +|---|---| +| [Home](index.md) | a one-screen orientation with the shortest possible example | +| [Why clientwright](learn/why.md) | justifying the library, or arguing against a wrapper | +| [Installation](learn/install.md) | choosing extras and SDK version ranges | +| [Your first client](learn/first-client.md) | writing the very first integration end to end | +| [Sync and async](learn/sync-and-async.md) | picking a flavor, or explaining hard vs soft deadlines | +| [Configuration](guide/configuration.md) | the shape of `ClientConfig`, the defaults, `UNSET` | +| [Timeouts and deadlines](guide/timeouts.md) | total vs phase, caller overrides, deadline propagation | +| [Retries](guide/retries.md) | the decision ladder, backoff, `Retry-After`, the budget | +| [Circuit breaker](guide/circuit-breaker.md) | thresholds, half-open probes, choosing the key | +| [Redirects](guide/redirects.md) | owned vs native, method demotion, cross-origin header stripping | +| [Per-call options](guide/per-call-options.md) | route labels and per-call idempotency | +| [Observability](guide/observability.md) | metric families, labels, spans, log records | +| [Masking PII](guide/masking.md) | scrubbing values a name list cannot reach | +| [Proxies and TLS](guide/proxies-tls.md) | mTLS, private CAs, explicit and environment proxies | +| [Native passthrough](guide/native-options.md) | a knob `ClientConfig` does not cover | +| [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 | +| [Deadline budgets](guide/deadline-budget.md) | propagating the inbound request's remaining time | +| [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 | +| [httpx2](adapters/httpx2.md) | moving from httpx to its successor | +| [aiohttp](adapters/aiohttp.md) | the middleware seam, the bypass sentinel, the dropped knobs | +| [requests](adapters/requests.md) | the `HTTPAdapter` mount and the closed timeout hole | +| [urllib3](adapters/urllib3.md) | the `urlopen` seam and delegated retries | +| [Architecture](advanced/architecture.md) | reading the source, or debugging a weird case | +| [Writing an adapter](advanced/writing-an-adapter.md) | adding a new SDK behind the same engine | +| [Migration](advanced/migration.md) | moving from a bare SDK or an in-house client kit | +| [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 and dishka surfaces | +| [Testing reference](reference/testing.md) | the docstrings of the test instruments | +| [Changelog](changelog.md) | what changed between versions | diff --git a/docs/assets/javascripts/copy-page.js b/docs/assets/javascripts/copy-page.js new file mode 100644 index 0000000..bc8a733 --- /dev/null +++ b/docs/assets/javascripts/copy-page.js @@ -0,0 +1,158 @@ +/* Behaviour for the "Copy page" control. + * + * Every handler is delegated from `document`, because the theme swaps the + * content in place when instant navigation is on: a listener bound to an + * element of one page would not survive the move to the next. + */ +(function () { + "use strict"; + + var RESET_AFTER_MS = 2000; + + /* Where the Markdown of a page is written, as an absolute URL. Two data + attributes say how far the site root is from here and where this page sits + below it; scripts/emit_markdown.py writes the file to match. The site's own + name is a third, so this file is the same in every project that carries it. */ + function markdownUrl(widget) { + var base = (widget.dataset.copyBase || ".").replace(/\/$/, ""); + var page = widget.dataset.copyPage || ""; + var relative = page === "" ? "index.md" : page.replace(/\/$/, "") + ".md"; + return new URL(base + "/" + relative, window.location.href).href; + } + + function prompt(widget) { + var title = widget.dataset.copyTitle || document.title; + var site = widget.dataset.copySite || "project"; + return ( + "Read " + + markdownUrl(widget) + + ' -- the "' + + title + + '" page of the ' + + site + + " documentation -- so I can ask questions about it." + ); + } + + function destination(widget, name) { + var question = encodeURIComponent(prompt(widget)); + switch (name) { + case "markdown": + return markdownUrl(widget); + case "chatgpt": + return "https://chatgpt.com/?hints=search&q=" + question; + case "claude": + return "https://claude.ai/new?q=" + question; + case "perplexity": + return "https://www.perplexity.ai/search?q=" + question; + default: + return markdownUrl(widget); + } + } + + function write(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + /* Insecure origins have no clipboard API; the old selection dance still + works there, which keeps a local preview usable. */ + return new Promise(function (resolve, reject) { + var area = document.createElement("textarea"); + area.value = text; + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + var ok = document.execCommand("copy"); + document.body.removeChild(area); + ok ? resolve() : reject(new Error("copy refused")); + }); + } + + function announce(widget, label, state) { + var slot = widget.querySelector("[data-copy-label]"); + if (slot) slot.textContent = label; + if (state) { + widget.dataset.copyState = state; + } else { + delete widget.dataset.copyState; + } + } + + function copy(widget) { + var reset = function () { + window.setTimeout(function () { + announce(widget, "Copy page", null); + }, RESET_AFTER_MS); + }; + fetch(markdownUrl(widget)) + .then(function (response) { + if (!response.ok) throw new Error(String(response.status)); + return response.text(); + }) + .then(write) + .then(function () { + announce(widget, "Copied", "copied"); + reset(); + }) + .catch(function () { + announce(widget, "Copy failed", null); + reset(); + }); + } + + function close(widget) { + var menu = widget.querySelector("[data-copy-menu]"); + var toggle = widget.querySelector("[data-copy-toggle]"); + if (menu) menu.hidden = true; + if (toggle) toggle.setAttribute("aria-expanded", "false"); + } + + function closeAll(except) { + var widgets = document.querySelectorAll(".md-copy-page"); + for (var i = 0; i < widgets.length; i++) { + if (widgets[i] !== except) close(widgets[i]); + } + } + + document.addEventListener("click", function (event) { + var target = event.target; + if (!(target instanceof Element)) return; + + var widget = target.closest(".md-copy-page"); + if (!widget) { + closeAll(null); + return; + } + closeAll(widget); + + if (target.closest("[data-copy-action]")) { + event.preventDefault(); + close(widget); + copy(widget); + return; + } + + var toggle = target.closest("[data-copy-toggle]"); + if (toggle) { + event.preventDefault(); + var menu = widget.querySelector("[data-copy-menu]"); + if (!menu) return; + /* The destinations are filled in on the way out rather than at load: + the page under the widget may have changed since. */ + var links = menu.querySelectorAll("[data-copy-open]"); + for (var i = 0; i < links.length; i++) { + links[i].href = destination(widget, links[i].dataset.copyOpen); + } + menu.hidden = !menu.hidden; + toggle.setAttribute("aria-expanded", menu.hidden ? "false" : "true"); + return; + } + + if (target.closest("[data-copy-open]")) close(widget); + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") closeAll(null); + }); +})(); diff --git a/docs/assets/stylesheets/copy-page.css b/docs/assets/stylesheets/copy-page.css new file mode 100644 index 0000000..65d70e3 --- /dev/null +++ b/docs/assets/stylesheets/copy-page.css @@ -0,0 +1,184 @@ +/* The "Copy page" control above every page: a split button and its menu. + Everything is drawn from the theme's own custom properties, so both palettes + and both font variants are covered without a second set of rules. */ + +.md-copy-page { + position: relative; + z-index: 2; + float: right; + margin: 0.2rem 0 0.4rem 0.8rem; +} + +.md-copy-page__group { + display: flex; + align-items: stretch; + border: 0.05rem solid var(--md-default-fg-color--lightest); + border-radius: 0.2rem; + background-color: var(--md-default-bg-color); +} + +.md-copy-page__button { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + padding: 0.35rem 0.6rem; + border: none; + background: transparent; + color: var(--md-default-fg-color--light); + font-family: inherit; + font-size: 0.65rem; + line-height: 1; + cursor: pointer; + transition: color 125ms, background-color 125ms; +} + +.md-copy-page__button:hover, +.md-copy-page__button:focus-visible { + color: var(--md-accent-fg-color); + background-color: var(--md-accent-fg-color--transparent); +} + +.md-copy-page__button--main { + border-radius: 0.15rem 0 0 0.15rem; +} + +.md-copy-page__button--toggle { + padding-inline: 0.4rem; + border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); + border-radius: 0 0.15rem 0.15rem 0; +} + +.md-copy-page__button--toggle .md-copy-page__icon { + transition: transform 125ms; +} + +.md-copy-page__button--toggle[aria-expanded="true"] .md-copy-page__icon { + transform: rotate(180deg); +} + +/* Icons are sized here: the bundled SVGs carry no width or height. */ +.md-copy-page__icon svg, +.md-copy-page__item-icon svg, +.md-copy-page__item-arrow svg { + display: block; + width: 0.8rem; + height: 0.8rem; +} + +.md-copy-page__icon { + display: flex; +} + +/* The tick replaces the clipboard for as long as the copy is fresh. */ +.md-copy-page__icon--done, +.md-copy-page[data-copy-state="copied"] .md-copy-page__icon--idle { + display: none; +} + +.md-copy-page[data-copy-state="copied"] .md-copy-page__icon--done { + display: flex; +} + +.md-copy-page[data-copy-state="copied"] .md-copy-page__button--main { + color: var(--md-accent-fg-color); +} + +.md-copy-page__menu { + position: absolute; + inset-inline-end: 0; + top: calc(100% + 0.25rem); + min-width: 15rem; + padding: 0.25rem; + border: 0.05rem solid var(--md-default-fg-color--lightest); + border-radius: 0.25rem; + background-color: var(--md-default-bg-color); + box-shadow: var(--md-shadow-z2); +} + +.md-copy-page__menu[hidden] { + display: none; +} + +/* The menu entries are anchors inside the typeset article, so the theme's own + `.md-typeset a` rules outrank a single class -- hence the prefix. */ +.md-typeset .md-copy-page__item, +.md-copy-page__item { + display: flex; + gap: 0.5rem; + width: 100%; + margin: 0; + padding: 0.4rem 0.5rem; + border: none; + border-radius: 0.15rem; + background: transparent; + color: var(--md-default-fg-color); + font-family: inherit; + font-size: 0.65rem; + text-align: start; + text-decoration: none; + cursor: pointer; + transition: background-color 125ms; +} + +.md-typeset .md-copy-page__item:hover, +.md-typeset .md-copy-page__item:focus-visible, +.md-copy-page__item:hover, +.md-copy-page__item:focus-visible { + background-color: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + text-decoration: none; +} + +.md-copy-page__item-icon { + display: flex; + padding-top: 0.1rem; + color: var(--md-default-fg-color--light); +} + +.md-copy-page__item-text { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.md-copy-page__item-title { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: 700; + line-height: 1.3; +} + +.md-copy-page__item-arrow { + display: flex; + color: var(--md-default-fg-color--lighter); +} + +.md-copy-page__item-arrow svg { + width: 0.6rem; + height: 0.6rem; +} + +.md-copy-page__item-hint { + color: var(--md-default-fg-color--light); + font-size: 0.6rem; + line-height: 1.3; +} + +/* Narrow screens keep the icons and drop the words. */ +@media screen and (max-width: 44.9375em) { + .md-copy-page__label { + display: none; + } + + .md-copy-page__menu { + min-width: 13rem; + } +} + +@media print { + .md-copy-page { + display: none; + } +} diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..9d91b2d --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{#- + Adds the "Copy page" control above every page: the Markdown of the page for a + language model, and the handful of places one is usually pasted into. The + Markdown itself is written next to the page by scripts/emit_markdown.py after + the build; the two data attributes are all the script needs to find it. +-#} +{% block content %} + {#- A page whose Markdown would not read as the page declines the control + with `copy_page: false` in its front matter. -#} + {% if page.meta.copy_page != false %} + + {% endif %} + {% include "partials/content.html" %} +{% endblock %} diff --git a/scripts/emit_markdown.py b/scripts/emit_markdown.py new file mode 100644 index 0000000..c7573e0 --- /dev/null +++ b/scripts/emit_markdown.py @@ -0,0 +1,87 @@ +"""Write the Markdown source of every page next to the page it built. + +The docs carry a "Copy page" control that hands a language model the Markdown +behind the page it is looking at. That Markdown has to be fetchable, so after +the site is built each ``docs/.md`` is copied to ``site/.md`` -- +one URL away from ``site//index.html``, which is what the control asks +for. Run it after the build:: + + uv run --no-dev --group docs zensical build --clean + uv run python scripts/emit_markdown.py + +``zensical serve`` rebuilds into the same directory and does not know about +these files, so a preview served that way answers 404 to the control; build +the site to try it. +""" + +from __future__ import annotations + +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +DOCS = ROOT / "docs" +SITE = ROOT / "site" + + +def declines(text: str) -> bool: + """Whether a page has asked not to be handed over as Markdown. + + The control above each page is hidden by ``copy_page: false`` in the front + matter, and a page hidden there must not be written here either -- a file + nothing links to is worse than no file when its content would mislead. + This reads that one key rather than the front matter as a whole: the docs + build has no YAML parser of its own, and one key is all that is at stake. + """ + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return False + for line in lines[1:]: + if line.strip() == "---": + return False + key, _, value = line.partition(":") + if key.strip() == "copy_page": + return value.strip().lower() == "false" + return False + + +def target_for(source: pathlib.Path) -> pathlib.Path: + """Where the control will look for one page's Markdown. + + It asks for the page's own URL with ``.md`` in place of the trailing + slash, so ``guide/foreign-keys/`` becomes ``guide/foreign-keys.md``. A + section index is the one place where that is not the source path: the URL + of ``reference/index.md`` is ``reference/``, and its Markdown therefore + belongs at ``reference.md``. The site's own index keeps its name. + """ + relative = source.relative_to(DOCS) + if relative.name == "index.md" and relative.parent != pathlib.Path(): + return SITE / relative.parent.with_suffix(".md") + return SITE / relative + + +def main() -> None: + """Copy every documentation page into the built site beside its HTML.""" + if not SITE.is_dir(): + sys.exit(f"{SITE} does not exist -- build the site first") + + written: dict[pathlib.Path, pathlib.Path] = {} + skipped = 0 + for source in sorted(DOCS.rglob("*.md")): + text = source.read_text(encoding="utf-8") + if declines(text): + skipped += 1 + continue + target = target_for(source) + if target in written: + sys.exit(f"{source} and {written[target]} both claim {target}") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + written[target] = source + + tail = f", {skipped} declined" if skipped else "" + sys.stdout.write(f"wrote {len(written)} Markdown pages into {SITE.name}/{tail}\n") + + +if __name__ == "__main__": + main() diff --git a/zensical.toml b/zensical.toml index 1470c6d..4b272b8 100644 --- a/zensical.toml +++ b/zensical.toml @@ -10,8 +10,12 @@ copyright = "Copyright © 2026 Alex Shalaev" docs_dir = "docs" site_dir = "site" +extra_css = ["assets/stylesheets/copy-page.css"] +extra_javascript = ["assets/javascripts/copy-page.js"] + nav = [ { "Home" = "index.md" }, + { "For AI agents" = "agents.md" }, { "Learn" = [ { "Why clientwright" = "learn/why.md" }, { "Installation" = "learn/install.md" }, @@ -58,6 +62,7 @@ nav = [ ] [project.theme] +custom_dir = "overrides" features = [ "content.code.annotate", "content.code.copy", From 01d70d3244319135565691803c50d236a03fcea4 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:09:06 +0300 Subject: [PATCH 2/3] docs: the agents page showed a backslash where it meant a pipe --- docs/agents.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index e4d4fd2..9bf841a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -9,8 +9,8 @@ | 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`, `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 | +| 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 | | ## How to read this page @@ -160,7 +160,7 @@ Everything in this section is exported from `clientwright` unless the table says | `build_sync` | `build_sync(adapter, config, deps=None)` | the SDK's own **sync** client, typed `Any` | | `build_handle` | same arguments | `ClientHandle[Any]` (async) | | `build_sync_handle` | same arguments | `ClientHandle[Any]` (sync) | -| `inspect` / `inspect_client` | `inspect(client)` | `ClientHandle[Any] \| None` — `None` for a foreign object | +| `inspect` / `inspect_client` | `inspect(client)` | `ClientHandle[Any] | None` — `None` for a foreign object | | `registered_adapters` | `registered_adapters()` | `('aiohttp', 'httpx', 'httpx2', 'requests', 'urllib3')` | | `resolve_adapter` | `resolve_adapter(name)` | the adapter class; `UnknownAdapterError` if unknown | | `register_adapter` | `register_adapter(name, "module.path:Class", "module.path:CAPABILITIES")` | `None` | @@ -370,7 +370,7 @@ Metric names and label sets are a frozen wire contract in | `http_client_inflight` | gauge | ±1 around each logical call | | `http_client_circuit_state` | gauge | on a breaker transition, **only when the adapter built the runtime** | | `http_client_redirect_hops_total` | counter | per followed hop | -| `http_client_retry_skipped_total` | counter | `reason=method\|non_replayable\|deadline\|budget` | +| `http_client_retry_skipped_total` | counter | `reason=method|non_replayable|deadline|budget` | | `http_client_uninstrumented_calls_total` | counter | aiohttp only: a request that bypassed the middleware | `outcome` is `success` or a `FailureKind` value; `status` is the numeric status or the From dbd7d830639d8a8a492594f0dddf1c753d6326ec Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:12:29 +0300 Subject: [PATCH 3/3] docs: the generated reference declines the copy-page control --- docs/reference/adapters.md | 4 ++++ docs/reference/contrib.md | 4 ++++ docs/reference/core.md | 4 ++++ docs/reference/testing.md | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/docs/reference/adapters.md b/docs/reference/adapters.md index 5ce6327..602b342 100644 --- a/docs/reference/adapters.md +++ b/docs/reference/adapters.md @@ -1,3 +1,7 @@ +--- +copy_page: false +--- + # Adapters Adapter packages import their SDK, so their API docs cannot be auto-rendered in diff --git a/docs/reference/contrib.md b/docs/reference/contrib.md index a6a6261..d7a6d06 100644 --- a/docs/reference/contrib.md +++ b/docs/reference/contrib.md @@ -1,3 +1,7 @@ +--- +copy_page: false +--- + # Contrib ## Deadline budgets diff --git a/docs/reference/core.md b/docs/reference/core.md index 4ece720..0887b0b 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -1,3 +1,7 @@ +--- +copy_page: false +--- + # Core ## Building and inspecting diff --git a/docs/reference/testing.md b/docs/reference/testing.md index 7ce01ee..d161bc5 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -1,3 +1,7 @@ +--- +copy_page: false +--- + # Testing Stdlib-only instruments, shipped with the library so your integration tests and