diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75d461b..8f9c434 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,28 @@ jobs: - name: Run pytest run: uv run pytest -q + # Blocking requirement-traceability audit. Collection-only run: the + # TraceabilityPlugin (registered in the root conftest) collects every + # @pytest.mark.req(...) marker, writes the matrix artifact, and — because + # DEXPACE_TRACEABILITY_GATE is set — fails the run if any MUST-level + # requirement in an enabled subsystem is neither covered by a marker nor + # listed N/A in docs/traceability-na-ids.txt (the deviations-ledger bridge). + - name: Run traceability audit (blocking) + env: + DEXPACE_TRACEABILITY_ENABLE: "seams,http,bodies,io,context,pipeline,recovery,redirect,retry,auth,xcut,serde,pagination,sse,observability,config,transport,async,nfr" + DEXPACE_TRACEABILITY_NA: docs/traceability-na-ids.txt + DEXPACE_TRACEABILITY_MATRIX: build/traceability-matrix.json + DEXPACE_TRACEABILITY_GATE: "1" + run: uv run pytest -q --co + + - name: Upload traceability matrix + if: always() + uses: actions/upload-artifact@v4 + with: + name: traceability-matrix-py${{ matrix.python-version }} + path: build/traceability-matrix.json + if-no-files-found: error + - name: Run mypy run: uv run mypy --strict @@ -45,3 +67,223 @@ jobs: - name: Run ruff format --check run: uv run ruff format --check + + - name: Run import-linter + run: uv run lint-imports + + packaging: + name: packaging invariants + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + python-version: "3.12" + + - name: Check namespace layout + run: uv run --no-project python tools/check_namespace_packages.py + + - name: Check MIT headers + run: uv run --no-project python tools/check_mit_headers.py + + - name: Check runtime dependency budget + run: uv run --no-project python tools/check_dependency_audit.py + + - name: Build core + stdlib wheels + run: | + uv build --package dexpace-sdk-core --wheel -o dist + uv build --package dexpace-sdk-http-stdlib --wheel -o dist + + - name: Smoke-import wheels in a fresh venv + run: | + uv venv /tmp/wheel-smoke + uv pip install --python /tmp/wheel-smoke \ + dist/dexpace_sdk_core-*.whl \ + dist/dexpace_sdk_http_stdlib-*.whl + /tmp/wheel-smoke/bin/python tools/smoke_wheel_import.py + + coverage: + name: coverage gate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + python-version: "3.12" + + - name: Sync workspace + run: uv sync + + # Blocking aggregate line+branch coverage gate. The 80% floor lives in + # [tool.coverage.report].fail_under in pyproject.toml; pytest-cov exits + # non-zero when the total drops below it. + - name: Run coverage gate + run: uv run pytest -q --cov --cov-report=term-missing + + typecheck-divergence: + name: typecheck divergence (pyright, non-blocking) + runs-on: ubuntu-latest + # Advisory early-warning lane: pyright cross-checks mypy's verdict so a + # divergence between the two checkers surfaces early. This is NOT a gate — + # `mypy --strict` in the `test` job remains the sole blocking type check. + # `continue-on-error` at both the job and step level keeps a red result + # (e.g. pyright's stricter completeness scoring) from ever failing CI. + # pyright is layered in per-run via `uv run --with`, so it stays out of the + # committed dependency set and uv.lock. + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + python-version: "3.14" + + - name: Sync workspace + run: uv sync + + - name: pyright --verifytypes (advisory) + continue-on-error: true + run: uv run --with pyright pyright --verifytypes dexpace.sdk.core --ignoreexternal + + - name: pyright typing-conformance corpus (advisory) + continue-on-error: true + run: uv run --with pyright pyright packages/dexpace-sdk-core/tests/typing_conformance.py + + version-skew: + name: version skew (Python ${{ matrix.python-version }}, ${{ matrix.resolution }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13", "3.14"] + resolution: ["highest", "lowest-direct"] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + # Resolve the workspace at either edge of its declared dependency window. + # `highest` exercises the newest adapters against head core (the standard + # release runtime). `lowest-direct` pins every *direct* dependency to its + # declared floor: core to the compatible-release lower bound each adapter + # and the TCK pin (dexpace-sdk-core>=0.1,<0.2) and each transport to its + # minimum (httpx>=0.27, aiohttp>=3.9, requests>=2.32, furl>=2.1.3). Running + # both edges across Python 3.12-3.14 turns the forward-compatibility + # promise -- newest adapters and the generated petstore canary run against + # the oldest supported core -- into a passing pipeline, not a prose claim. + - name: Sync workspace (${{ matrix.resolution }} resolution) + run: uv sync --resolution ${{ matrix.resolution }} + + # Newest adapters (built from HEAD) plus the TCK conformance battery, run + # against the resolved core. `--no-sync` keeps the environment exactly as + # the resolution step left it rather than re-resolving to the default edge. + - name: Adapters + TCK conformance + run: > + uv run --no-sync pytest -q + packages/dexpace-sdk-http-stdlib/tests + packages/dexpace-sdk-http-httpx/tests + packages/dexpace-sdk-http-aiohttp/tests + packages/dexpace-sdk-http-requests/tests + packages/dexpace-sdk-tck/src/dexpace/sdk/tck + + # The petstore canary is the "generated at vN.M runs on vN.x" proof: a + # generated SDK driven end to end over the certified core. Running it at + # both dependency edges proves the generated output keeps working against + # both head core and the oldest supported core. + - name: Petstore canary + run: uv run --no-sync pytest -q examples/petstore/tests + + free-threaded: + name: free-threaded CPython (${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13t", "3.14t"] + # Exercises the codebase's explicit-lock claims (no GIL assumption) on a real + # GIL-disabled interpreter, not just under code review. Only the + # lock-sensitive core suites run here, and each spins real threads: the + # single-use body once-guards, the single-flight token cache, the + # ContextStore cap-drain, the emit-once logger latch, and the SSE + # cross-thread close. Core is pure Python (only furl), so the environment + # holds core plus the test runner alone -- no C-extension transports, which + # do not yet ship free-threaded wheels. + env: + # Fail loudly if an imported extension would force the GIL back on rather + # than silently re-enabling it and voiding the exercise. + PYTHON_GIL: "0" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + + - name: Install free-threaded interpreter + run: uv python install ${{ matrix.python-version }} + + - name: Create free-threaded environment + run: uv venv --python ${{ matrix.python-version }} .venv-ft + + # `--only-binary hypothesis` uses its universal wheel instead of building + # its sdist, whose build pulls a Rust/PyO3 toolchain with no free-threaded + # support yet. hypothesis is imported by the core test conftest. + - name: Install core + test runner + run: > + uv pip install --python .venv-ft --only-binary hypothesis + ./packages/dexpace-sdk-core + pytest pytest-asyncio hypothesis + + - name: Verify the interpreter is GIL-disabled + run: .venv-ft/bin/python -c "import sys; assert not sys._is_gil_enabled(), 'GIL is enabled'; print(sys.version)" + + - name: Run lock-sensitive suites (GIL disabled) + run: > + .venv-ft/bin/python -m pytest -q + packages/dexpace-sdk-core/tests/http/test_single_use_bodies.py + packages/dexpace-sdk-core/tests/auth/test_token_cache.py + packages/dexpace-sdk-core/tests/context/test_context_store.py + packages/dexpace-sdk-core/tests/instrumentation/test_logger.py + packages/dexpace-sdk-core/tests/sse/test_connection_lifecycle.py + + version-source: + name: single version train + runs-on: ubuntu-latest + # One version train for every published package (NFR-14): the version lives + # once in each pyproject and is mirrored in uv.lock, and a built wheel + # carries that real version -- never a placeholder such as 0.0.0 or + # "unknown" (NFR-15). A bump touches all published packages together or this + # gate fails. + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + python-version: "3.12" + + - name: Build a wheel to inspect its metadata version + run: uv build --package dexpace-sdk-core --wheel -o dist + + - name: Verify one version train (pyproject + uv.lock + wheel) + run: uv run --no-project python tools/check_version_train.py --wheel-dir dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..984d30e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +# Publishes the whole version train to PyPI via trusted publishing (OIDC), with +# PEP 740 attestations and reproducible, byte-stable wheels. A tag push builds +# and publishes; a manual run defaults to a dry run that builds and verifies the +# artifacts without uploading anything. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + dry_run: + description: "Build and verify only; skip the PyPI upload." + type: boolean + default: true + +permissions: + contents: read + +jobs: + build: + name: build + verify artifacts + runs-on: ubuntu-latest + steps: + - name: Checkout + # Full history so the commit timestamp is available for a reproducible + # SOURCE_DATE_EPOCH. + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + python-version: "3.12" + + # Reproducible builds (NFR-12): pin every archive timestamp to the tagged + # commit's authored date and fix the hash seed so the build carries no + # run-to-run entropy. hatchling honours SOURCE_DATE_EPOCH and writes wheel + # entries in a stable order, so identical inputs yield byte-identical + # wheels. + - name: Pin reproducible-build environment + run: | + echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" + echo "PYTHONHASHSEED=0" >> "$GITHUB_ENV" + + # Build each published distribution explicitly so the unpublished + # workspace root never lands in dist/. Sdists ship alongside wheels. + - name: Build wheels + sdists + run: | + for pkg in \ + dexpace-sdk-core \ + dexpace-sdk-http-stdlib \ + dexpace-sdk-http-httpx \ + dexpace-sdk-http-aiohttp \ + dexpace-sdk-http-requests \ + dexpace-sdk-tck; do + uv build --package "$pkg" --out-dir dist + done + + # NFR-14/15: every artifact carries the single, real train version -- never + # a placeholder. Blocks the upload if any wheel disagrees. + - name: Verify one version train (pyproject + uv.lock + wheels) + run: uv run --no-project python tools/check_version_train.py --wheel-dir dist + + - name: List artifacts + run: ls -1 dist | sort + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: release-dist + path: dist/ + if-no-files-found: error + + publish: + name: publish to PyPI (trusted publishing) + needs: build + runs-on: ubuntu-latest + # A tag push publishes; a manual run publishes only when dry_run is turned + # off. The default manual run is a build-and-verify dry run. + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false') + environment: + name: pypi + url: https://pypi.org/project/dexpace-sdk-core/ + permissions: + # OIDC token for PyPI trusted publishing; no long-lived API token needed. + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + # PEP 740 provenance attestations, signed with the OIDC identity + # (NFR-16). + attestations: true + print-hash: true diff --git a/.gitignore b/.gitignore index 7089337..7658294 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,6 @@ dmypy.json # Cython debug symbols cython_debug/ + +# Subagent-driven-development scratch (progress ledger, task briefs/reports) +.superpowers/ diff --git a/.importlinter b/.importlinter new file mode 100644 index 0000000..e6c9c7f --- /dev/null +++ b/.importlinter @@ -0,0 +1,79 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. +# +# import-linter contracts for the dexpace Python SDK workspace. +# +# Run with `uv run lint-imports` from the workspace root (the tool auto-discovers +# this file). The contracts encode the dependency-direction invariants the +# architecture relies on: +# +# * adapters -> core, never the reverse (core is transport-agnostic); +# * the `furl` runtime dependency (and its transitive `orderedmultidict`) +# is confined to the single Url implementation module, so the rest of the +# toolkit stays stdlib-only; +# * the pure algorithm layer sits below the sync/async pipeline runners, and +# the sync and async runner trees never import one another. +# +# Some of the pure algorithm modules referenced by the layering contract do not +# exist yet (they land in later milestones). They are declared as *optional* +# layers (parenthesised) so the contract is a no-op until the module appears and +# then binds automatically — enforcing the shape going forward with no churn. + +[importlinter] +root_packages = + dexpace.sdk.core + dexpace.sdk.http.stdlib + dexpace.sdk.http.httpx + dexpace.sdk.http.aiohttp + dexpace.sdk.http.requests + dexpace.sdk.tck +# Needed so `forbidden` contracts can name external distributions (furl, +# orderedmultidict) as forbidden import targets. +include_external_packages = True + +[importlinter:contract:adapters-above-core] +name = Adapters depend on core, never the reverse +type = layers +layers = + dexpace.sdk.http.stdlib | dexpace.sdk.http.httpx | dexpace.sdk.http.aiohttp | dexpace.sdk.http.requests + dexpace.sdk.core + +[importlinter:contract:furl-confinement] +name = furl is confined to the Url module +type = forbidden +source_modules = + dexpace.sdk.core + dexpace.sdk.http.stdlib + dexpace.sdk.http.httpx + dexpace.sdk.http.aiohttp + dexpace.sdk.http.requests + dexpace.sdk.tck +forbidden_modules = + furl + orderedmultidict +ignore_imports = + dexpace.sdk.core.http.common.url -> furl +# Only *direct* imports of furl are forbidden. Everything that uses `Url` +# transitively depends on furl through it — that indirection is the whole point +# of the boundary, so indirect imports are allowed. +allow_indirect_imports = True + +[importlinter:contract:pure-below-runners] +name = Pipeline runners sit above the pure algorithm layer +type = layers +containers = + dexpace.sdk.core.pipeline +layers = + _sansio_runner | _transport_runner | _async_sansio_runner | _async_transport_runner + (_pure) +exhaustive = False + +[importlinter:contract:codegen-executors-above-pure] +name = Codegen executor shells sit above the pure planner layer and never import each other +type = layers +containers = + dexpace.sdk.core.codegen +layers = + service_core | async_service_core + (_pure) +exhaustive = False diff --git a/conftest.py b/conftest.py index 122da3a..90a70d6 100644 --- a/conftest.py +++ b/conftest.py @@ -22,11 +22,46 @@ from __future__ import annotations import asyncio +import importlib.util +import sys from collections.abc import Iterator +from pathlib import Path import pytest +def _load_traceability_plugin() -> object: + """Load and construct the requirement-traceability plugin. + + ``tools`` is not an installed package, so the audit module is imported by + file path (the same pattern the public-surface test uses). The plugin + collects ``@pytest.mark.req(...)`` markers and writes a matrix artifact; it + only fails a run when explicitly gated via ``DEXPACE_TRACEABILITY_*`` env + vars, so registering it here is inert for the normal suite. + + Returns: + A configured traceability plugin instance. + + Raises: + ImportError: If the audit module cannot be loaded from ``tools/``. + """ + tool_path = Path(__file__).resolve().parent / "tools" / "traceability_audit.py" + spec = importlib.util.spec_from_file_location("_dexpace_traceability_audit", tool_path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load traceability audit tool from {tool_path}") + module = importlib.util.module_from_spec(spec) + # Register before executing so the module's dataclasses can resolve their + # (stringified) annotations against their own namespace under Python 3.12+. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module.build_plugin_from_env() + + +def pytest_configure(config: pytest.Config) -> None: + """Register the requirement-traceability plugin for the session.""" + config.pluginmanager.register(_load_traceability_plugin(), "dexpace-traceability") + + @pytest.fixture(scope="session", autouse=True) def _own_default_event_loop() -> Iterator[None]: loop = asyncio.new_event_loop() diff --git a/docs/architecture.md b/docs/architecture.md index 63186a0..f0ab5fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,6 +71,29 @@ Same layering, with `AsyncPipeline` / `AsyncPolicy` / `AsyncHttpClient` / `(value, ctx) -> value`) work in both sync and async pipelines — the async pipeline auto-awaits any coroutine return. +The two pillars are at parity on observability: `default_async_pipeline` +carries per-attempt wire logging (`AsyncLoggingPolicy`) and a per-attempt +tracing span (`AsyncTracingPolicy`) alongside the per-operation tracer, matching +the sync default. The one deliberate asymmetry is redirect — the async redirect +twin is gated off by default while the shared redirect core is certified against +the full security battery. See [`pipelines.md`](pipelines.md) and +[`deviations.md`](deviations.md). + +## Extension points + +The toolkit is defined by the seams a consuming library plugs into. Each has a +practical guide: + +- Transport (`HttpClient` / `AsyncHttpClient`) — [`write-a-transport.md`](write-a-transport.md). +- Wire format (`Serde`) — [`write-a-serde.md`](write-a-serde.md). +- Pagination convention (`PaginationStrategy`) — [`write-a-paging-strategy.md`](write-a-paging-strategy.md). +- Result production (`ResponseHandler`) — [`write-a-response-handler.md`](write-a-response-handler.md). + +A code generator sits one level up, emitting a projection over the shared +executor. The contract it targets — `Operation` / `assemble`, `ServiceCore`, +`StatusErrorMap`, `ServiceDefaults`, and the tiered auth resolver — is +[`codegen-target.md`](codegen-target.md). + ## Why no `IoProvider` The Java port has an `IoProvider` / `Buffer` / `Source` / `Sink` layer diff --git a/docs/auth.md b/docs/auth.md index 73cc2ad..2aa39d5 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -13,7 +13,9 @@ policies, and a pluggable token cache. | `TokenCredential` | Sync Protocol for OAuth-style providers (`get_token_info`). | | `AsyncTokenCredential` | Async Protocol for OAuth-style providers. | -All concrete credentials redact secrets in their `__repr__`. +All concrete credentials redact secrets in their `__repr__` and reject +empty inputs at construction, so a blank secret fails fast rather than at +first use. ## Policies @@ -24,6 +26,12 @@ All concrete credentials redact secrets in their `__repr__`. | `BearerTokenPolicy` | Acquire + cache + apply OAuth bearer tokens (sync). | | `AsyncBearerTokenPolicy` | Async twin. | +Every credentialed policy enforces HTTPS on its stamping path by default: +the credential is never fetched, read, or written onto a plaintext-HTTP +request. Opt out per call with `ctx.options["enforce_https"] = False`. The +guard runs only while the request stays on its original origin — a redirect +to a foreign origin drops the credential entirely instead. + `BearerTokenPolicy`: - Enforces HTTPS by default (opt out per call with @@ -33,10 +41,17 @@ All concrete credentials redact secrets in their `__repr__`. - Calls `AccessTokenInfo.needs_refresh()` before each request; refreshes proactively when `refresh_on` has passed or `expires_on - leeway` (default 300 s) has been reached. -- On any 401 response, invalidates the cached token. If the response - also carries a `WWW-Authenticate` header, then calls - `on_challenge(request, response)`. Override `on_challenge` in a - subclass to handle CAE / claims-challenge flows. +- On a 401 response, invalidates the cached token — but only when that + token still matches the `Authorization` header this request carried, so + a stale rejection cannot discard a token a concurrent refresh already + replaced. A 401 without a `WWW-Authenticate` header is then surfaced + unchanged. When the header is present, `on_challenge(request, response)` + is consulted; override it in a subclass to handle CAE / claims-challenge + flows. If it authorizes a retry, the request is re-issued at most once + with a refreshed token — but a single-use (non-replayable) body is + surfaced un-replayed rather than corrupting a half-consumed stream. The + replayed response is returned regardless of its status; there is no + challenge loop. Both the sync and async policies enforce this identically. ## Token cache diff --git a/docs/bodies.md b/docs/bodies.md index 8d10de2..60f8e62 100644 --- a/docs/bodies.md +++ b/docs/bodies.md @@ -16,8 +16,14 @@ classmethod factories for the common shapes. | `RequestBody.from_stream(BinaryIO)` | ❌ | Single-use; call `to_replayable()` first if retries are needed. | | `RequestBody.from_iter(Iterable[bytes])` | ❌ | Same. The iterable is consumed on first `iter_bytes`. | -Single-use bodies raise `RuntimeError` on the second `iter_bytes` call. -The retry policy in `pipeline.policies.retry` **does** automatically +Single-use bodies raise `RuntimeError` on the second `iter_bytes` call. That is +a deliberate plain `RuntimeError` carrying an actionable message ("Call +`to_replayable()` BEFORE the first `iter_bytes()` …"), not a bespoke catchable +exception type: re-consuming an exhausted body is a programming error whose only +correct fix is at the call site, so it stays out of the `errors` hierarchy +rather than inviting an `except` handler that papers over the bug. See +[`deviations.md`](deviations.md) for the rationale. The retry policy in +`pipeline.policies.retry` **does** automatically buffer single-use bodies when the effective per-call retry total is positive: `RetryPolicy.send` merges any `retry_total` override in `ctx.options` with the policy's `total_retries` default, then calls @@ -57,7 +63,11 @@ print(logged.snapshot()[:200]) The cap is a soft truncate on the *tap*; the primary write path receives the full payload. `LoggableResponseBody` is the same shape on the response side — first access drains the underlying body into a -`bytes` cache that subsequent calls replay. +`bytes` cache. A body that fits the cap is captured whole and replayed by +every later read; a body that exceeds the cap keeps only the prefix, then +streams the live tail once (single-use past the cap). Either way, +`snapshot()` returns the captured prefix without raising, and `error()` +reports a mid-drain failure. ## Async equivalents diff --git a/docs/codegen-target.md b/docs/codegen-target.md new file mode 100644 index 0000000..dc78426 --- /dev/null +++ b/docs/codegen-target.md @@ -0,0 +1,246 @@ +# The codegen target + +This is the contract a code generator targets when it emits an SDK on top of +`dexpace-sdk-core`. The generated code is a thin **projection**: an operation +table plus a facade whose every method binds its arguments into an +`OperationInput` and delegates to a shared executor. All the behaviour — request +assembly, the pipeline, retry, auth resolution, error mapping, pagination, SSE — +lives in `core`, so the generator emits data and delegation, never logic. + +The public surface lives in `dexpace.sdk.core.codegen`: + +| Symbol | Role | +|--------|------| +| `Operation`, `OperationInput` | The declarative request description (static half + per-call half) | +| `assemble` | Turns an `Operation` + `OperationInput` into a concrete `Request` | +| `ServiceCore`, `AsyncServiceCore` | The two platform executors a facade delegates to | +| `StatusErrorMap` | Frozen status-code → typed-error mapping | +| `AsyncResponseHandler`, `AsyncDecodeSuccess` | Async twins of serde's `ResponseHandler` / `DecodeSuccess` | +| `ServiceDefaults`, `merge_config`, `merge_async_config` | The Tier-2 config-folding seam | +| `AuthRequirement`, `AuthDescriptor`, `AuthRegistry`, `AuthScheme`, `AuthResolutionError` | The tiered auth-requirement resolver | + +The [`examples/petstore`](../examples/petstore) package is the compiling witness +for everything below: a generated SDK rendered from a frozen OpenAPI document, +with a regen-diff test that fails on any drift and an end-to-end canary +(`examples/petstore/tests/test_petstore_canary.py`) that drives the whole stack +over an in-memory transport. + +## Request assembly + +An `Operation` is the static, reusable half of a request shape; an +`OperationInput` supplies the per-call values. `assemble` combines them: + +```python +from dexpace.sdk.core.codegen import Operation, OperationInput, assemble +from dexpace.sdk.core.http.request import Method + +GET_PET = Operation(name="get_pet", method=Method.GET, path="/pets/{pet_id}") + +request = assemble( + GET_PET, + OperationInput(path_params={"pet_id": "7"}), + "https://api.example.com", +) +``` + +Both value objects are frozen, keyword-only dataclasses, so a future minor +version can append a field without breaking a positional call site — the +forward-compatibility guarantee a generated client depends on. The assembly +rules are normative: + +- **Path templates** substitute `{placeholder}` tokens from + `OperationInput.path_params`. Each value is percent-encoded with `safe=""`, so + a literal `/` becomes `%2F` and stays inside one path segment. A placeholder + with no matching key raises `KeyError`, reporting only the missing name (never + the supplied values, so a signed token cannot leak). +- **The base URL's query is preserved** (a SAS / signed-URL token survives), and + `OperationInput.query` is appended after it. The base URL must not carry a + fragment — `assemble` raises `ValueError` if it does. +- **Headers overlay:** per-call `OperationInput.headers` replace the operation's + static headers by name; names only on the operation are kept. +- **`name` is advisory.** It stamps tracing context for readability and never + influences assembly, routing, or any cache key; it is excluded from equality + and hashing. +- **The body is carried through verbatim.** This layer does not serialize it; + the `serde` argument is reserved for a later executor hop. + +## The executors + +A generated facade holds a `ServiceCore` (sync) or `AsyncServiceCore` (async) +and delegates every method to it. Construct an executor from exactly one of a +transport or a caller-supplied pipeline: + +```python +from dexpace.sdk.core.codegen import ServiceCore + +# Owns the pipeline it builds around the transport — closing the core closes it. +core = ServiceCore(base_url="https://api.example.com", transport=client) + +# Borrows a caller-supplied pipeline — never closes it. Bake in auth this way. +core = ServiceCore(base_url="https://api.example.com", pipeline=my_pipeline) +``` + +Passing a `transport` builds the canonical `default_pipeline` around it and +*owns* it; passing a `pipeline` borrows it and never closes it. Ownership-aware +close is the rule: closing the executor closes an owned transport but leaves a +borrowed pipeline untouched (`examples/petstore` exercises both). + +Each executor exposes four entry points: + +- `execute(op, input, *, response_type=..., handler=..., auth=..., **call_options)` + runs one operation and decodes the result. With a `response_type` the default + `DecodeSuccess` handler decodes a 2xx body into that type; with neither a + `response_type` nor a `handler` the raw `Response` is returned for the caller + to consume and close; a `handler` overrides the default. +- `execute_request(request, ...)` is the raw-call seam: it runs a pre-built + `Request` as-is (no operation tier for auth). +- `paginate(op, input, strategy, *, max_pages=..., ...)` returns the certified + `Paginator` / `AsyncPaginator`, so the wire-exact next-request splice and + close-ledger guarantees carry through unchanged. Each page fetch runs the full + pipeline. +- `events(op, input, mapper, ...)` returns the certified typed SSE connection + with a mapper applied, leaving the core SSE layer free of any + service-specific sentinel or serde convention. + +The default handler is `DecodeSuccess` (sync) / `AsyncDecodeSuccess` (async), +wired with the executor's `StatusErrorMap` and `serde`. Per-call keyword +arguments beyond the named ones flow through to the pipeline's policies via +`ctx.options` (a `retry_total` override, `logging_enabled=False`, and so on). + +## Error mapping + +`StatusErrorMap` is the declarative half of a generated client's error +handling: a frozen status-code → typed-error mapping, read as data so a service +SDK never grows a hand-written `if status == ...: raise` chain. + +```python +from typing import Any + +from dexpace.sdk.core.codegen import StatusErrorMap +from dexpace.sdk.core.errors import HttpResponseError +from dexpace.sdk.core.http.response import Status + + +class PetStoreError(HttpResponseError[Any]): + """Base for every typed petstore response error.""" + + +class PetNotFoundError(PetStoreError): + """Raised for a 404.""" + + +PETSTORE_ERRORS = StatusErrorMap( + by_status={Status.NOT_FOUND: PetNotFoundError}, + default=PetStoreError, +) +``` + +The map spans the **response branch only**: every registered class must extend +`HttpResponseError` and must **not** extend `OSError`, enforced at construction +(`TypeError` otherwise). That is the hard rule a service taxonomy relies on — a +service-specific error can never masquerade as a transport-level `NetworkError`, +so an `except OSError:` site keeps matching transport failures unchanged. On a +4xx/5xx response, `raise_for` constructs the mapped error (the per-status +override, else `default`), buffers a bounded, replayable copy of the error body +onto it, and raises; `for_status` is the non-raising peek. See +[`errors.md`](errors.md). + +## Tiered auth resolution + +Auth requirements arrive at three tiers, most specific first: **per-call** (the +`auth=` argument), then **operation-level** (declared on the `Operation`), then +**client-level** (configured on the executor). A generated operation declares +what it needs as an `AuthDescriptor` — a non-empty, ordered list of +`AuthRequirement` alternatives (OR semantics, first satisfiable wins): + +```python +from dexpace.sdk.core.codegen import AuthDescriptor, AuthRequirement, Operation +from dexpace.sdk.core.http.request import Method + +GET_PET = Operation( + name="get_pet", + method=Method.GET, + path="/pets/{pet_id}", + auth=AuthDescriptor.of(AuthRequirement("bearer", ("pets:read",))), +) +``` + +The executor resolves that declaration against an `AuthRegistry` of +`AuthScheme`s (a scheme pairs a name and grantable scopes with the concrete +stamping policy that realises it). Resolution is deliberately strict: + +- **Presence selects the tier**, not satisfiability. The most specific *present* + tier is chosen. +- **The selected tier must then be satisfiable.** If it is present but no + registered scheme satisfies any of its alternatives, resolution raises + `AuthResolutionError` — it never falls through to a broader tier. A caller who + asked for a specific per-call credential that cannot be honoured gets a loud + failure, never a silent downgrade to the client default (which would + authenticate with the wrong credential). + +The resolver only *selects* a policy; the stamping is done downstream by the M4 +auth policies (`BearerTokenPolicy`, `KeyCredentialPolicy`, `BasicAuthPolicy`). +The petstore canary exercises all three outcomes: the operation tier winning +over a client default, falling back to the client default when an operation +declares none, and failing loudly when a present tier is unsatisfiable (with no +request sent). + +## Service defaults (the Tier-2 seam) + +`ServiceDefaults` lets a generated SDK bake in its own defaults — base URL, +product User-Agent token, api-version stamp, error map, wire serde — and +`merge_config` / `merge_async_config` fold those, plus an optional caller +override of the same shape, into a single core pipeline's constructor kwargs: + +```python +from dexpace.sdk.core.codegen import ServiceCore, ServiceDefaults, merge_config + +DEFAULTS = ServiceDefaults( + base_url="https://api.example.com", + user_agent_token="acme-sdk/2.3", + api_version="2026-01-01", +) + +core = ServiceCore(**merge_config(DEFAULTS, transport=client)) +``` + +The folding rules are normative: + +- **Field-wise precedence.** A non-`None` field on the caller override wins; a + non-`None` field on the service defaults wins next; the toolkit's own + fallbacks apply only when both leave a field unset. Overriding just `base_url` + still inherits the service's `user_agent_token`. +- **`base_url` is required** — one of the two layers must resolve it, or + `merge_config` raises `ValueError`. +- **One pipeline, never a wrapper.** `merge_config` makes exactly one + `default_pipeline(...)` call, folding the User-Agent token in as the leading + `client_identity` token (prepended to the platform and runtime tokens) and the + api-version stamp in as an appended policy re-applied on every redirect hop and + retry attempt. It never builds a second pipeline or nests one client in + another. A caller-supplied already-built `pipeline` passes through untouched — + service defaults cannot be layered onto an opaque pipeline, so use + `transport=` when the api-version stamp or User-Agent token must apply. + +`merge_config` returns a mapping ready to splat into +`ServiceCore(**merge_config(...))`; `merge_async_config` is the async twin for +`AsyncServiceCore`. + +## The generated shape, end to end + +Putting it together, a generated SDK is three small pieces (see +`examples/petstore/src/dexpace_petstore/`): + +1. **An operation table** (`_generated/operations.py`) — pure data, one + `Operation` per API operation, imported by both facades. +2. **Projection facades** (`_generated/sync_client.py`, + `_generated/async_client.py`) — each method binds arguments into an + `OperationInput` and calls `execute` / `paginate` / `events`, carrying no + logic of its own. +3. **A hand-authored runtime shim** (`models`, `errors`, `_support`) — the typed + models, the `StatusErrorMap`, and the body / pagination / SSE binders the + facades reference. + +Because the facades are projections and the executor is shared, the certified +guarantees of `core` — wire-exact pagination, reconnecting SSE, the retry and +redirect batteries, the tiered auth resolver — reach the generated caller +unchanged. The petstore canary is the standing proof that they do. diff --git a/docs/conformance-ledger.md b/docs/conformance-ledger.md new file mode 100644 index 0000000..ae6952f --- /dev/null +++ b/docs/conformance-ledger.md @@ -0,0 +1,761 @@ +# Conformance ledger + +Machine-generated record of how this Python port meets every normative requirement +in the consolidated requirement index (the product spec Appendix C, vendored as +tools/requirement_index.json). Regenerate with tools/build_requirement_index.py + +a gated pytest collection after the index or coverage changes. Each requirement is +in one state: + +- **test** — a test tagged @pytest.mark.req() asserts the behaviour. +- **deviation** — provided by a documented, different mechanism (rationale in the row). +- **n/a** — not applicable to the Python port (a Java/JVM idiom or a deliberately + omitted seam, e.g. the byte-stream provider seam or the Builder pattern). +- **uncovered** — a SHOULD/MAY with no dedicated test; non-gating by design. + +**Totals (645 requirements):** 505 test-covered · 43 deviation · 55 n/a · 42 uncovered SHOULD/MAY (non-gating). + +**MUST-level (532):** 434 test-covered · 98 accounted as deviation/n-a · **0 unaccounted**. + +The blocking CI traceability gate (tools/traceability_audit.py) fails on any +unaccounted MUST across every subsystem, so the MUST-unaccounted count is held at zero. + + +## async + +| ID | Level | Status | Evidence | +|---|---|---|---| +| ASYNC-1 | MUST | test | 8 test(s) | +| ASYNC-2 | MUST | test | 8 test(s) | +| ASYNC-3 | MUST | n/a | cancel-with-interrupt vs cancel-without-interrupt is JVM Thread.interrupt() worker-thread semantics; CPython threads are not interruptible from outside (deviations.md sync-cancella | +| ASYNC-4 | MUST | n/a | Ordered interrupt-flag delivery to avoid poisoning a pooled thread is a JVM interrupt-flag hazard; CPython has no cross-thread interrupt flag, so the poisoning failure mode does no | +| ASYNC-5 | MUST | test | 5 test(s) | +| ASYNC-6 | MUST | test | 6 test(s) | +| ASYNC-7 | SHOULD | test | 5 test(s) | +| ASYNC-8 | SHOULD | test | 2 test(s) | +| ASYNC-9 | MUST | test | 2 test(s) | +| ASYNC-10 | MUST | test | 2 test(s) | +| ASYNC-11 | MUST | test | 2 test(s) | +| ASYNC-12 | MUST | test | 1 test(s) | +| ASYNC-13 | MUST | test | 3 test(s) | +| ASYNC-14 | MUST | test | 3 test(s) | +| ASYNC-15 | MUST | test | 7 test(s) | +| ASYNC-16 | SHOULD | test | 2 test(s) | +| ASYNC-17 | SHOULD | uncovered | SHOULD-level, non-gating | +| ASYNC-18 | MUST | deviation | The non-blocking async delay is provided by stdlib asyncio.sleep via AsyncClock.sleep (_AsyncSystemClock.sleep / async_http_client.asyncio_sleep) — an event-loop timer that holds n | +| ASYNC-19 | MUST | test | 3 test(s) | +| ASYNC-20 | MUST | test | 1 test(s) | +| ASYNC-21 | MUST | n/a | Exposing SSE 'as a reactive stream' is a JVM reactive-streams/Reactor Publisher ecosystem adapter; this port has no reactive-streams seam and instead exposes SSE as a native async | +| ASYNC-22 | MUST | test | 4 test(s) | + +## auth + +| ID | Level | Status | Evidence | +|---|---|---|---| +| AUTH-1 | MUST | deviation | The port's resolver (codegen/auth.py) recognizes open scheme-name strings matched against an AuthRegistry rather than a fixed {OAUTH2,API_KEY,BASIC,DIGEST,NO_AUTH} enum, and models | +| AUTH-2 | MUST | deviation | AuthRequirement is a frozen (immutable, value-equal) dataclass binding exactly one scheme to its scopes (test_auth_scheme_satisfies_by_name_and_scope_subset), but it drops the sepa | +| AUTH-3 | MUST | deviation | AuthDescriptor is a non-empty, ordered, immutable frozen dataclass that rejects an empty requirement list at construction (test_auth_descriptor_rejects_empty), but the allowsAnonym | +| AUTH-4 | MUST | test | 3 test(s) | +| AUTH-5 | MUST | test | 2 test(s) | +| AUTH-6 | MUST | deviation | A present-but-unsatisfiable selected tier raises the distinct AuthResolutionError (test_unsatisfiable_per_call_does_not_fall_through_to_client), but when all three descriptors are | +| AUTH-7 | MUST | test | 2 test(s) | +| AUTH-8 | MUST | test | 3 test(s) | +| AUTH-9 | MUST | test | 2 test(s) | +| AUTH-10 | MUST | deviation | Additive grace-margin expiry evaluation is implemented and tested (needs_refresh/classify/is_expired), but AccessTokenInfo makes expires_on a mandatory int so there is no null-mean | +| AUTH-11 | MUST | test | 2 test(s) | +| AUTH-12 | MUST | deviation | The RFC 7235 parser handles multiple comma-separated challenges, quoted-string commas/=, backslash escapes, lower-cased parameter names and verbatim values (heavily tested), but sc | +| AUTH-13 | MUST | test | 2 test(s) | +| AUTH-14 | MUST | test | 3 test(s) | +| AUTH-15 | MUST | test | 3 test(s) | +| AUTH-16 | MUST | test | 3 test(s) | +| AUTH-17 | MUST | test | 2 test(s) | +| AUTH-18 | MUST | test | 3 test(s) | +| AUTH-19 | SHOULD | test | 3 test(s) | +| AUTH-20 | MUST | test | 3 test(s) | +| AUTH-21 | MUST | test | 3 test(s) | +| AUTH-22 | MUST | test | 3 test(s) | +| AUTH-23 | MUST | test | 2 test(s) | +| AUTH-24 | MUST | test | 1 test(s) | +| AUTH-25 | MUST | test | 2 test(s) | +| AUTH-26 | MUST | test | 3 test(s) | +| AUTH-27 | MUST | test | 2 test(s) | +| AUTH-28 | MUST | test | 3 test(s) | +| AUTH-29 | MUST | test | 3 test(s) | +| AUTH-30 | MUST | test | 2 test(s) | +| AUTH-31 | MUST | test | 2 test(s) | +| AUTH-32 | MUST | test | 3 test(s) | +| AUTH-33 | MUST | test | 2 test(s) | +| AUTH-34 | MUST | test | 3 test(s) | +| AUTH-35 | MUST | test | 3 test(s) | +| AUTH-36 | MUST | test | 2 test(s) | +| AUTH-37 | MUST | test | 5 test(s) | +| AUTH-38 | SHOULD | test | 2 test(s) | + +## bodies + +| ID | Level | Status | Evidence | +|---|---|---|---| +| BODY-1 | MUST | test | 3 test(s) | +| BODY-2 | MUST | test | 4 test(s) | +| BODY-3 | MUST | test | 5 test(s) | +| BODY-4 | MUST | test | 3 test(s) | +| BODY-5 | MUST | test | 3 test(s) | +| BODY-6 | MUST | test | 4 test(s) | +| BODY-7 | MUST | test | 2 test(s) | +| BODY-8 | MUST | test | 2 test(s) | +| BODY-9 | SHOULD | uncovered | SHOULD-level, non-gating | +| BODY-10 | MUST | n/a | The exact-length stream-to-sink copy primitive (with premature-EOF and zero-byte-read errors) belongs to the removed Io/byte-stream seam; the port's stream body iterates to natural | +| BODY-11 | MUST | test | 3 test(s) | +| BODY-12 | SHOULD | test | 1 test(s) | +| BODY-13 | MUST | test | 2 test(s) | +| BODY-14 | MUST | test | 2 test(s) | +| BODY-15 | MUST | test | 2 test(s) | +| BODY-16 | MUST | test | 2 test(s) | +| BODY-17 | MUST | test | 3 test(s) | +| BODY-18 | MUST | test | 3 test(s) | +| BODY-19 | MUST | test | 2 test(s) | +| BODY-20 | SHOULD | uncovered | SHOULD-level, non-gating | +| BODY-21 | MUST | test | 2 test(s) | +| BODY-22 | MUST | test | 2 test(s) | +| BODY-23 | MUST | test | 2 test(s) | +| BODY-24 | MUST | test | 2 test(s) | +| BODY-25 | MUST | deviation | In the iterator-based ResponseBody model (the removed read-into-buffer Io seam) end-of-stream is signalled only by iterator exhaustion, so an interior zero-length chunk is tolerate | +| BODY-26 | MUST | test | 3 test(s) | +| BODY-27 | MUST | test | 2 test(s) | +| BODY-28 | MUST | test | 2 test(s) | +| BODY-29 | SHOULD | test | 2 test(s) | +| BODY-30 | MUST | test | 3 test(s) | +| BODY-31 | MUST | test | 13 test(s) | +| BODY-32 | MUST | test | 2 test(s) | +| BODY-33 | SHOULD | test | 3 test(s) | +| BODY-34 | MUST | test | 2 test(s) | +| BODY-35 | MUST | test | 3 test(s) | +| BODY-36 | MAY | uncovered | MAY-level, non-gating | +| BODY-37 | MUST | n/a | A tee sink exposing a writable buffer handle that bypasses the primary sink is an Okio Sink/BufferedSink API concept from the removed Io/byte-stream seam (CLAUDE.md forbids reintro | + +## config + +| ID | Level | Status | Evidence | +|---|---|---|---| +| CFG-1 | MUST | test | 7 test(s) | +| CFG-2 | MUST | test | 7 test(s) | +| CFG-3 | MUST | test | 2 test(s) | +| CFG-4 | MUST | test | 2 test(s) | +| CFG-5 | MUST | test | 3 test(s) | +| CFG-6 | MUST | test | 14 test(s) | +| CFG-7 | MUST | deviation | ISO-8601, shorthand ms/s/m/h/d, negative-rejection and unknown-unit rejection are all implemented and tested, but a bare number is deliberately interpreted as SECONDS (not the spec | +| CFG-8 | MUST | test | 2 test(s) | +| CFG-9 | MUST | test | 4 test(s) | +| CFG-10 | MUST | test | 7 test(s) | +| CFG-11 | MUST | test | 3 test(s) | +| CFG-12 | SHOULD | test | 1 test(s) | +| CFG-13 | SHOULD | test | 2 test(s) | +| CFG-14 | SHOULD | uncovered | SHOULD-level, non-gating | +| CFG-15 | MUST | test | 3 test(s) | +| CFG-16 | MUST | test | 2 test(s) | +| CFG-17 | MUST | test | 4 test(s) | +| CFG-18 | SHOULD | uncovered | SHOULD-level, non-gating | +| CFG-19 | SHOULD | uncovered | SHOULD-level, non-gating | +| CFG-20 | SHOULD | uncovered | SHOULD-level, non-gating | +| CFG-21 | MUST | n/a | This is a corollary of the interruptible-task future (CFG-20): a cancelled-future-with-closeable-result discard path only exists inside the JVM ExecutorService/Future+thread-interr | +| CFG-22 | MUST | test | 3 test(s) | +| CFG-23 | MUST | test | 3 test(s) | +| CFG-24 | MUST | test | 9 test(s) | +| CFG-25 | MUST | deviation | An out-of-range/non-numeric port yields None with a warning (tested), and the anti-fabrication intent ('never invent a per-scheme default port') is honored; but a genuinely absent | +| CFG-26 | MUST | test | 6 test(s) | +| CFG-27 | MUST | test | 2 test(s) | +| CFG-28 | MAY | test | 2 test(s) | +| CFG-29 | MUST | test | 3 test(s) | +| CFG-30 | MUST | test | 5 test(s) | +| CFG-31 | MUST | deviation | The blank/empty-input MUST-fail clause is satisfied and tested (parse returns None), but parsing delegates to stdlib email.utils.parsedate_to_datetime (documented as tolerant in ht | +| CFG-32 | MUST | n/a | A bespoke non-blocking type-4 UUID generator is a JVM workaround for java.util.UUID.randomUUID()'s blocking SecureRandom; CPython's stdlib uuid.uuid4() is already non-blocking and | +| CFG-33 | MUST | n/a | Deep value-equality helpers are the Java Objects.deepEquals/Arrays.deepEquals/deepHashCode idiom; Python's native structural == and hash on list/tuple/dict already compare by conte | +| CFG-34 | MUST | n/a | The float-array deep-equality semantics (NaN==NaN, +0.0!=-0.0, object-array vs primitive-array kind distinction) are the Java Arrays.equals(double[])/Double.equals contract; Python | +| CFG-35 | SHOULD | uncovered | SHOULD-level, non-gating | +| CFG-36 | SHOULD | test | 3 test(s) | +| CFG-37 | MUST | test | 4 test(s) | +| CFG-38 | MUST | test | 3 test(s) | + +## context + +| ID | Level | Status | Evidence | +|---|---|---|---| +| CTX-1 | MUST | test | 3 test(s) | +| CTX-2 | MUST | test | 2 test(s) | +| CTX-3 | MUST | test | 1 test(s) | +| CTX-4 | MUST | test | 3 test(s) | +| CTX-5 | MUST | deviation | Per the CTX-17 promotion-time model, keys are minted at promotion (ContextStore.new_key), not at direct construction, so an off-chain context defaults to store_key=None (verified: | +| CTX-6 | MUST | test | 2 test(s) | +| CTX-7 | MUST | test | 2 test(s) | +| CTX-8 | MUST | test | 3 test(s) | +| CTX-9 | MUST | deviation | CallContext.close() removes unconditionally by the call-unique shared key (context_store.py 'Two removers coexist' docstring) rather than identity-conditionally, because request an | +| CTX-10 | MUST | deviation | A promoted dispatch stays unregistered (store_key=None) so its close is a no-op as required, but closing a promoted request tier deliberately clears the successor exchange because | +| CTX-11 | MUST | test | 3 test(s) | +| CTX-12 | SHOULD | test | 1 test(s) | +| CTX-13 | MAY | test | 1 test(s) | +| CTX-14 | MUST | test | 3 test(s) | +| CTX-15 | MUST | test | 4 test(s) | +| CTX-16 | SHOULD | uncovered | SHOULD-level, non-gating | +| CTX-17 | MUST | test | 2 test(s) | +| CTX-18 | MUST | test | 3 test(s) | +| CTX-19 | MUST | test | 1 test(s) | +| CTX-20 | SHOULD | test | 3 test(s) | + +## http + +| ID | Level | Status | Evidence | +|---|---|---|---| +| HTTP-1 | MUST | test | 3 test(s) | +| HTTP-2 | MUST | deviation | Java builders are replaced by frozen dataclasses whose __post_init__ validates on every construction path (raw constructor and dataclasses.replace included), so validation cannot b | +| HTTP-3 | MUST | deviation | newBuilder()-style derivation is provided by the with_* helpers plus dataclasses.replace (derive a modified copy without restating fields), and collections are stored as immutable | +| HTTP-4 | MUST | test | 3 test(s) | +| HTTP-5 | MUST | test | 2 test(s) | +| HTTP-6 | MUST | test | 3 test(s) | +| HTTP-7 | MUST | test | 8 test(s) | +| HTTP-8 | SHOULD | uncovered | SHOULD-level, non-gating | +| HTTP-9 | MUST | test | 12 test(s) | +| HTTP-10 | MUST | test | 5 test(s) | +| HTTP-11 | MUST | test | 28 test(s) | +| HTTP-12 | MUST | test | 4 test(s) | +| HTTP-13 | MUST | test | 6 test(s) | +| HTTP-14 | MUST | test | 3 test(s) | +| HTTP-15 | MUST | test | 1 test(s) | +| HTTP-16 | SHOULD | test | 1 test(s) | +| HTTP-17 | MUST | test | 8 test(s) | +| HTTP-18 | MUST | test | 7 test(s) | +| HTTP-19 | MUST | test | 4 test(s) | +| HTTP-20 | MUST | test | 2 test(s) | +| HTTP-21 | MUST | test | 3 test(s) | +| HTTP-22 | MAY | test | 1 test(s) | +| HTTP-23 | MUST | test | 93 test(s) | +| HTTP-24 | MUST | test | 13 test(s) | +| HTTP-25 | MUST | test | 10 test(s) | +| HTTP-26 | MUST | test | 42 test(s) | +| HTTP-27 | SHOULD | test | 3 test(s) | +| HTTP-28 | MUST | test | 3 test(s) | +| HTTP-29 | MUST | test | 57 test(s) | +| HTTP-30 | MUST | test | 2 test(s) | +| HTTP-31 | MUST | test | 14 test(s) | +| HTTP-32 | SHOULD | test | 3 test(s) | +| HTTP-33 | MUST | test | 12 test(s) | +| HTTP-34 | MUST | deviation | RequestOptions is folded into Request keyword-arg fields (timeout, retries) with None 'use-the-default' sentinels — a default-constructed Request being the canonical 'override noth | +| HTTP-35 | MUST | test | 3 test(s) | +| HTTP-36 | MUST | test | 3 test(s) | +| HTTP-37 | MUST | test | 5 test(s) | +| HTTP-38 | MUST | deviation | Replayability-by-source is honored and tested (bytes/string/file/form replayable; stream/iter single-use), but the form body deliberately uses strict RFC 3986 %20 encoding (never ' | +| HTTP-39 | MUST | n/a | The fixed-count 'copy exactly N bytes from a source, raising on early EOF' primitive is the removed Okio/Io byte-stream (Source) seam; the Python port models stream bodies over std | +| HTTP-40 | MUST | test | 8 test(s) | +| HTTP-41 | MUST | test | 3 test(s) | +| HTTP-42 | MUST | test | 2 test(s) | +| HTTP-43 | MUST | test | 3 test(s) | +| HTTP-44 | MUST | test | 3 test(s) | +| HTTP-45 | MUST | test | 1 test(s) | +| HTTP-46 | MUST | test | 1 test(s) | +| HTTP-47 | SHOULD | test | 2 test(s) | +| HTTP-48 | SHOULD | test | 8 test(s) | +| HTTP-49 | SHOULD | test | 6 test(s) | +| HTTP-50 | SHOULD | test | 7 test(s) | +| HTTP-51 | SHOULD | test | 3 test(s) | +| HTTP-52 | MUST | test | 3 test(s) | +| HTTP-53 | MUST | test | 9 test(s) | + +## io + +| ID | Level | Status | Evidence | +|---|---|---|---| +| IO-1 | MUST | n/a | Okio Source.read tail-append/return-code contract; the byte-stream Source/Sink/Buffer seam was removed and Python stdlib bytes/bytearray/memoryview/BytesIO/BinaryIO is the contract | +| IO-2 | MUST | n/a | Okio Source.read(0) EOF-vs-zero semantics; the Source seam does not exist in this port (stdlib bytes/BytesIO is the contract), so there is no read(byteCount) primitive to specify. | +| IO-3 | MUST | n/a | Negative-byteCount argument validation on Okio Source.read/Sink.write/Buffer.copyTo; those size-taking seam primitives were removed with the byte-stream provider seam (docs/archite | +| IO-4 | MUST | n/a | Okio Sink.write head-removal/short-write contract; the Sink seam was removed in favour of RequestBody.iter_bytes over stdlib bytes. | +| IO-5 | MUST | n/a | Okio Sink.flush() plus closeable Source/Sink; the Source/Sink seam was removed (bodies stream via iter_bytes/aiter_bytes, not a flushable Sink). | +| IO-6 | MUST | n/a | Okio provider wrapping/ownership of a caller-supplied underlying stream; the provider seam was removed and stream ownership is handled by the transport adapters, not a buffered Sou | +| IO-7 | MUST | n/a | Okio Buffer FIFO-that-is-both-source-and-sink; the Buffer seam was removed (an in-memory queue is just bytes/BytesIO here). | +| IO-8 | MUST | n/a | Okio Buffer.snapshot() independent-copy contract; the Buffer seam was removed, so there is no Buffer.snapshot to specify (the log-tap snapshot on LoggableRequestBody is a distinct | +| IO-9 | SHOULD | uncovered | SHOULD-level, non-gating | +| IO-10 | MUST | n/a | Okio Buffer.clear()/copyTo(window) non-consuming copy; the Buffer seam was removed. | +| IO-11 | MUST | n/a | Okio BufferedSource exhausted()/readByte()/readByteArray(); the BufferedSource seam was removed (drain via iter_bytes/bytes() over stdlib bytes). | +| IO-12 | MUST | n/a | Okio exact-count read (readByteArray(n)/readUtf8(n)) EOF-on-short contract; the BufferedSource seam was removed. | +| IO-13 | MUST | n/a | Okio UTF-8/explicit-charset read/write surface (readUtf8/writeUtf8/writeString(charset)); the BufferedSource/BufferedSink seam was removed, encoding is done with stdlib str.encode/ | +| IO-14 | MUST | n/a | Okio BufferedSource.readUtf8Line() terminator semantics; the BufferedSource seam was removed (SSE/streaming line handling is a separate subsystem, not this Okio primitive). | +| IO-15 | MUST | n/a | Okio BufferedSource.skip(byteCount) EOF contract; the BufferedSource seam was removed. | +| IO-16 | SHOULD | uncovered | SHOULD-level, non-gating | +| IO-17 | MUST | n/a | Okio Sink.writeAll(source) pump-to-exhaustion/return-count contract; the Source/Sink seam was removed. | +| IO-18 | SHOULD | uncovered | SHOULD-level, non-gating | +| IO-19 | MUST | n/a | Okio BufferedSource.peek() non-consuming view; the BufferedSource seam was removed (repeatable reads are achieved by LoggableResponseBody caching, not a peek view). | +| IO-20 | MUST | n/a | Okio BufferedSource.slice(offset, byteCount) non-consuming length-bounded view; the slice/BufferedSource seam was removed. | +| IO-21 | MUST | n/a | Okio slice lazy offset-overflow vs eager negative-arg rejection; the slice seam was removed. | +| IO-22 | MUST | n/a | Okio slice/parent close-invalidation contract; the slice seam was removed. | +| IO-23 | MUST | n/a | Okio multiple-slice/peek mutual independence and slice-of-slice offset composition; the slice/peek seam was removed. | +| IO-24 | MUST | n/a | Okio read-after-explicit-slice-close state-error contract; the slice seam was removed. | +| IO-25 | MUST | test | 1 test(s) | +| IO-26 | MUST | test | 1 test(s) | +| IO-27 | MUST | test | 2 test(s) | +| IO-28 | MUST | n/a | Okio TeeSink's guard against exposing its backing Buffer accessor; the Sink seam was removed and the LoggableRequestBody decorator exposes only iter_bytes/snapshot/captured_size (n | +| IO-29 | MUST | n/a | Okio TeeSink's flush()/close()/emit() forwarding-to-primary-only lifecycle; flush/emit are Sink methods that do not exist on the RequestBody/ResponseBody surface (the Sink lifecycl | +| IO-30 | MUST | n/a | Okio provider factory operations (create Buffer, wrap streams/byte-array as BufferedSource/Sink); the provider seam was removed, bodies are constructed via RequestBody/ResponseBody | +| IO-31 | MUST | n/a | Okio provider-registry resolution precedence via classpath auto-discovery; Protocols replace Java interfaces and there is no ServiceLoader auto-discovery idiom in this port, so no | +| IO-32 | MUST | n/a | Okio provider install idempotency/conflict-rejection; the provider registry seam was removed (no ServiceLoader-style installable provider). | +| IO-33 | MUST | n/a | Okio explicit-install-wins-over-auto-discovery precedence; the provider registry and auto-discovery seam were removed. | +| IO-34 | SHOULD | uncovered | SHOULD-level, non-gating | +| IO-35 | SHOULD | uncovered | SHOULD-level, non-gating | +| IO-36 | MAY | uncovered | MAY-level, non-gating | +| IO-37 | MUST | n/a | Single-threaded-contract declaration for Okio Source/Sink/BufferedSource/BufferedSink/Buffer/TeeSink; those streaming instance types were removed with the byte-stream seam. | +| IO-38 | MUST | n/a | Cross-thread observability of a source/buffer close-state to derived slices; the slice/Buffer seam was removed, so there are no derived slices to invalidate. | +| IO-39 | SHOULD | uncovered | SHOULD-level, non-gating | +| IO-40 | MUST | n/a | Okio no-op Timeout on wrapped streams plus non-swallowing/non-duplicating cancellation forwarding by a mirroring Sink; the Okio Timeout and Source/Sink seam were removed (in-memory | +| IO-41 | MUST | n/a | Idempotent close() / close-at-most-once for Okio Source/Sink/Buffer; those seam types were removed (close-idempotency of bodies, responses, SSE connections and pipelines is exercis | +| IO-42 | MUST | n/a | Okio buffered-source/sink use-after-close rejection with in-memory-buffer exemption and slice invalidation; the BufferedSource/BufferedSink/slice seam was removed (LoggableResponse | + +## nfr + +| ID | Level | Status | Evidence | +|---|---|---|---| +| NFR-1 | MUST | test | 4 test(s) | +| NFR-2 | SHOULD | test | 3 test(s) | +| NFR-3 | SHOULD | test | 9 test(s) | +| NFR-4 | SHOULD | test | 3 test(s) | +| NFR-5 | SHOULD | uncovered | SHOULD-level, non-gating | +| NFR-6 | SHOULD | uncovered | SHOULD-level, non-gating | +| NFR-7 | SHOULD | test | 3 test(s) | +| NFR-8 | MUST | n/a | The requirement's own escape clause ("In ecosystems without such a build step this requirement does not apply") applies: CPython wheels have no whole-program dead-code elimination/ | +| NFR-9 | SHOULD | uncovered | SHOULD-level, non-gating | +| NFR-10 | MUST | deviation | The lowest-supported-runtime floor is declared as requires-python>=3.12 in every published pyproject and enforced by the CI 3.12/3.13/3.14 matrix running the full suite on the floo | +| NFR-11 | SHOULD | test | 2 test(s) | +| NFR-12 | SHOULD | uncovered | SHOULD-level, non-gating | +| NFR-13 | SHOULD | test | 3 test(s) | +| NFR-14 | SHOULD | uncovered | SHOULD-level, non-gating | +| NFR-15 | SHOULD | test | 3 test(s) | +| NFR-16 | SHOULD | uncovered | SHOULD-level, non-gating | +| NFR-17 | MUST | deviation | The quality gates are wired blocking (fail the run) in .github/workflows/ci.yml and release.yml — pytest, traceability audit (DEXPACE_TRACEABILITY_GATE), mypy --strict, ruff/ruff-f | + +## observability + +| ID | Level | Status | Evidence | +|---|---|---|---| +| OBS-1 | MUST | test | 2 test(s) | +| OBS-2 | MUST | test | 2 test(s) | +| OBS-3 | MUST | test | 2 test(s) | +| OBS-4 | MUST | test | 2 test(s) | +| OBS-5 | MUST | test | 1 test(s) | +| OBS-6 | MUST | test | 2 test(s) | +| OBS-7 | SHOULD | test | 2 test(s) | +| OBS-8 | MUST | test | 1 test(s) | +| OBS-9 | MUST | test | 1 test(s) | +| OBS-10 | MUST | test | 3 test(s) | +| OBS-11 | MUST | test | 2 test(s) | +| OBS-12 | MUST | test | 3 test(s) | +| OBS-13 | MUST | test | 2 test(s) | +| OBS-14 | MUST | test | 3 test(s) | +| OBS-15 | MUST | test | 2 test(s) | +| OBS-16 | MUST | test | 6 test(s) | +| OBS-17 | MUST | test | 6 test(s) | +| OBS-18 | MUST | test | 5 test(s) | +| OBS-19 | SHOULD | uncovered | SHOULD-level, non-gating | +| OBS-20 | MUST | test | 3 test(s) | +| OBS-21 | MUST | test | 4 test(s) | +| OBS-22 | MUST | test | 4 test(s) | +| OBS-23 | MUST | test | 3 test(s) | +| OBS-24 | MUST | test | 2 test(s) | +| OBS-25 | MUST | test | 3 test(s) | +| OBS-26 | MUST | test | 3 test(s) | +| OBS-27 | MUST | test | 4 test(s) | +| OBS-28 | SHOULD | test | 2 test(s) | +| OBS-29 | MUST | test | 3 test(s) | +| OBS-30 | MUST | test | 3 test(s) | +| OBS-31 | MUST | test | 3 test(s) | +| OBS-32 | SHOULD | uncovered | SHOULD-level, non-gating | +| OBS-33 | MUST | test | 4 test(s) | +| OBS-34 | MUST | test | 5 test(s) | +| OBS-35 | SHOULD | uncovered | SHOULD-level, non-gating | +| OBS-36 | MUST | test | 3 test(s) | +| OBS-37 | SHOULD | test | 1 test(s) | +| OBS-38 | SHOULD | test | 2 test(s) | +| OBS-39 | MUST | test | 4 test(s) | +| OBS-40 | SHOULD | test | 1 test(s) | + +## pagination + +| ID | Level | Status | Evidence | +|---|---|---|---| +| PAGE-1 | MUST | test | 2 test(s) | +| PAGE-2 | MUST | test | 1 test(s) | +| PAGE-3 | MUST | test | 2 test(s) | +| PAGE-4 | MUST | test | 3 test(s) | +| PAGE-5 | MUST | test | 1 test(s) | +| PAGE-6 | MUST | test | 3 test(s) | +| PAGE-7 | MUST | test | 2 test(s) | +| PAGE-8 | MUST | test | 2 test(s) | +| PAGE-9 | MUST | test | 4 test(s) | +| PAGE-10 | SHOULD | test | 1 test(s) | +| PAGE-11 | MUST | test | 1 test(s) | +| PAGE-12 | MUST | test | 2 test(s) | +| PAGE-13 | MUST | test | 1 test(s) | +| PAGE-14 | MUST | test | 1 test(s) | +| PAGE-15 | MUST | test | 2 test(s) | +| PAGE-16 | MUST | test | 3 test(s) | +| PAGE-17 | MUST | test | 4 test(s) | +| PAGE-18 | MUST | test | 10 test(s) | +| PAGE-19 | MUST | test | 3 test(s) | +| PAGE-20 | SHOULD | test | 2 test(s) | +| PAGE-21 | MUST | test | 17 test(s) | +| PAGE-22 | MUST | test | 5 test(s) | +| PAGE-23 | MUST | deviation | Replace-first-in-place, append-when-absent, remove-on-null, and follow-whole-URL-preserving-method/headers are all covered by the cited tests; BUT the MUST sub-clause 'dropping any | +| PAGE-24 | MUST | test | 8 test(s) | +| PAGE-25 | MUST | test | 1 test(s) | +| PAGE-26 | MUST | test | 1 test(s) | +| PAGE-27 | MUST | test | 3 test(s) | +| PAGE-28 | MUST | test | 3 test(s) | +| PAGE-29 | MUST | test | 2 test(s) | +| PAGE-30 | MUST | n/a | Genuinely not applicable: AsyncPaginator accepts no caller-supplied driver executor (constructor: source, strategy, initial_request, max_pages, dispatch_factory), so the entire req | +| PAGE-31 | SHOULD | test | 1 test(s) | +| PAGE-32 | MUST | test | 2 test(s) | +| PAGE-33 | MUST | test | 2 test(s) | +| PAGE-34 | MUST | test | 2 test(s) | +| PAGE-35 | SHOULD | test | 1 test(s) | +| PAGE-36 | MUST | test | 2 test(s) | + +## pipeline + +| ID | Level | Status | Evidence | +|---|---|---|---| +| PIPE-1 | MUST | test | 3 test(s) | +| PIPE-2 | MUST | test | 9 test(s) | +| PIPE-3 | SHOULD | test | 16 test(s) | +| PIPE-4 | MUST | test | 8 test(s) | +| PIPE-5 | MUST | test | 3 test(s) | +| PIPE-6 | MUST | test | 4 test(s) | +| PIPE-7 | MUST | test | 3 test(s) | +| PIPE-8 | MUST | test | 2 test(s) | +| PIPE-9 | MUST | test | 2 test(s) | +| PIPE-10 | MUST | test | 2 test(s) | +| PIPE-11 | MUST | test | 1 test(s) | +| PIPE-12 | MUST | test | 3 test(s) | +| PIPE-13 | MUST | test | 3 test(s) | +| PIPE-14 | MUST | test | 1 test(s) | +| PIPE-15 | MUST | test | 1 test(s) | +| PIPE-16 | MUST | test | 2 test(s) | +| PIPE-17 | MUST | test | 2 test(s) | +| PIPE-18 | MUST | test | 3 test(s) | +| PIPE-19 | MUST | test | 2 test(s) | +| PIPE-20 | MUST | test | 1 test(s) | +| PIPE-21 | MUST | test | 1 test(s) | +| PIPE-22 | MUST | test | 2 test(s) | +| PIPE-23 | MUST | test | 2 test(s) | +| PIPE-24 | MUST | test | 2 test(s) | +| PIPE-25 | MUST | test | 2 test(s) | +| PIPE-26 | MUST | deviation | The Python pipeline exposes run(request, dispatch, **options) and is consumed via the structural SyncPipelineLike/AsyncPipelineLike Protocols (pipeline/dispatch.py) rather than lit | +| PIPE-27 | MUST | deviation | Rather than making pipeline close a strict no-op, Pipeline.__exit__ delegates to transport.close() (the CLAUDE.md context-manager convention) and transport adapters are ownership-a | +| PIPE-28 | MUST | test | 3 test(s) | +| PIPE-29 | MUST | test | 2 test(s) | +| PIPE-30 | MUST | test | 2 test(s) | +| PIPE-31 | MUST | test | 3 test(s) | +| PIPE-32 | MUST | test | 2 test(s) | +| PIPE-33 | MUST | deviation | The sync->async bridge lives at the transport seam (client/_sync_to_async.py SyncToAsyncHttpClient) and mandates a caller-supplied executor with no default (tests test_missing_exec | +| PIPE-34 | MUST | deviation | The async->sync bridge lives at the transport seam (client/_async_to_sync.py AsyncToSyncHttpClient): it blocks the calling thread for each call and on KeyboardInterrupt cancels the | +| PIPE-35 | SHOULD | uncovered | SHOULD-level, non-gating | +| PIPE-36 | SHOULD | test | 2 test(s) | +| PIPE-37 | MUST | test | 2 test(s) | +| PIPE-38 | MUST | test | 6 test(s) | +| PIPE-39 | SHOULD | test | 2 test(s) | +| PIPE-40 | MUST | test | 3 test(s) | + +## recovery + +| ID | Level | Status | Evidence | +|---|---|---|---| +| RECOV-1 | MUST | test | 2 test(s) | +| RECOV-2 | MUST | test | 2 test(s) | +| RECOV-3 | MUST | deviation | The port has no dedicated RequestRecoveryChain container; ordered request-side transforms, empty-set identity, and short-circuit-and-propagate-on-throw are realized by the pipeline | +| RECOV-4 | MUST | test | 2 test(s) | +| RECOV-5 | MUST | test | 1 test(s) | +| RECOV-6 | MUST | deviation | There is no chain container that iterates a response-step list then a recovery-step list; the response-then-recovery ordering emerges from composing map_success (Success-path) befo | +| RECOV-7 | MUST | test | 1 test(s) | +| RECOV-8 | MUST | test | 1 test(s) | +| RECOV-9 | SHOULD | test | 2 test(s) | +| RECOV-10 | MUST | test | 2 test(s) | +| RECOV-11 | MUST | test | 2 test(s) | +| RECOV-12 | MUST | test | 2 test(s) | +| RECOV-13 | MUST | test | 2 test(s) | +| RECOV-14 | MUST | deviation | No chain container defensively copies step lists; instead each policy defensively frozenset-copies its own collections (RetryPolicy.method_allowlist/retry_on_status_codes are froze | +| RECOV-15 | MUST | test | 3 test(s) | +| RECOV-16 | MUST | test | 2 test(s) | +| RECOV-17 | MUST | test | 3 test(s) | +| RECOV-18 | MUST | test | 2 test(s) | +| RECOV-19 | MUST | test | 3 test(s) | +| RECOV-20 | MUST | deviation | Retries are bounded by a max-attempts cap (initial send counts as attempt 1; asserted by test_sync_max_retries_two_sends_exactly_three_times) AND a per-call total-timeout deadline | +| RECOV-21 | MUST | deviation | The exponential curve base*multiplier^(n-1) capped at maxDelay is ported faithfully and tested, but the jitter is a different scheme — AWS full-jitter [0.5,1.0] by default and a sy | +| RECOV-22 | MUST | test | 2 test(s) | +| RECOV-23 | MUST | test | 3 test(s) | +| RECOV-24 | MUST | test | 17 test(s) | +| RECOV-25 | SHOULD | test | 2 test(s) | +| RECOV-26 | MUST | test | 2 test(s) | +| RECOV-27 | MUST | test | 3 test(s) | +| RECOV-28 | MUST | test | 2 test(s) | +| RECOV-29 | MUST | test | 2 test(s) | +| RECOV-30 | SHOULD | test | 1 test(s) | +| RECOV-31 | MAY | uncovered | MAY-level, non-gating | +| RECOV-32 | MUST | test | 6 test(s) | +| RECOV-33 | MUST | test | 3 test(s) | +| RECOV-34 | MUST | test | 21 test(s) | + +## redirect + +| ID | Level | Status | Evidence | +|---|---|---|---| +| REDIR-1 | MUST | test | 3 test(s) | +| REDIR-2 | MUST | test | 1 test(s) | +| REDIR-3 | MUST | test | 3 test(s) | +| REDIR-4 | MUST | test | 2 test(s) | +| REDIR-5 | MUST | deviation | Verified: the 303 GET-rebuild mechanics match and are tested (test_303_follow_303_true_reissues_as_get_and_drops_body: GET, body dropped, Content-* removed; test_303_follow_303_fal | +| REDIR-6 | MUST | test | 2 test(s) | +| REDIR-7 | MUST | test | 6 test(s) | +| REDIR-8 | MUST | test | 4 test(s) | +| REDIR-9 | MUST | test | 6 test(s) | +| REDIR-10 | SHOULD | uncovered | SHOULD-level, non-gating | +| REDIR-11 | MUST | deviation | Verified: the port plants no out-of-band wire marker. Instead each credential policy records the seed origin via ctx.data.setdefault(_AUTH_ORIGIN_KEY, current) on first pass and re | +| REDIR-12 | MUST | test | 15 test(s) | +| REDIR-13 | MUST | test | 13 test(s) | +| REDIR-14 | MUST | test | 1 test(s) | +| REDIR-15 | MUST | test | 6 test(s) | +| REDIR-16 | MUST | test | 2 test(s) | +| REDIR-17 | MUST | test | 2 test(s) | +| REDIR-18 | MUST | test | 1 test(s) | +| REDIR-19 | MUST | test | 2 test(s) | +| REDIR-20 | MUST | test | 4 test(s) | +| REDIR-21 | SHOULD | test | 1 test(s) | +| REDIR-22 | MUST | test | 3 test(s) | +| REDIR-23 | SHOULD | uncovered | SHOULD-level, non-gating | +| REDIR-24 | MUST | test | 2 test(s) | +| REDIR-25 | MUST | test | 2 test(s) | +| REDIR-26 | MUST | deviation | Verified: the port satisfies the defensive-copy requirement natively via type immutability rather than a copy — allowed_methods is typed and stored as a frozenset (redirect.py __in | +| REDIR-27 | MAY | uncovered | MAY-level, non-gating | +| REDIR-28 | SHOULD | uncovered | SHOULD-level, non-gating | + +## retry + +| ID | Level | Status | Evidence | +|---|---|---|---| +| RETRY-1 | MUST | deviation | The classifier IS single-sourced (DEFAULT_RETRYABLE_STATUS in pipeline/_pure/classifier.py, consumed by both HttpResponseError.retryable and RetryPolicy's default) and that single- | +| RETRY-2 | MUST | test | 3 test(s) | +| RETRY-3 | MUST | test | 7 test(s) | +| RETRY-4 | MUST | test | 3 test(s) | +| RETRY-5 | MUST | test | 3 test(s) | +| RETRY-6 | MUST | deviation | CORRECTED from marker. The idempotent-method set IS single-sourced (_DEFAULT_METHOD_ALLOWLIST in retry.py:100) and POST/PATCH are correctly excluded (retried only on 500/503/504 vi | +| RETRY-7 | MUST | test | 2 test(s) | +| RETRY-8 | MUST | test | 2 test(s) | +| RETRY-9 | MUST | deviation | compute_backoff implements base*multiplier**(attempt-1) clamped to cap (tested: test_exponential_growth, test_cap_saturates), BUT it special-cases attempt<=1 to return 0.0 (retry.p | +| RETRY-10 | MUST | test | 2 test(s) | +| RETRY-11 | MUST | test | 2 test(s) | +| RETRY-12 | SHOULD | uncovered | SHOULD-level, non-gating | +| RETRY-13 | MUST | test | 1 test(s) | +| RETRY-14 | MUST | test | 2 test(s) | +| RETRY-15 | MUST | test | 6 test(s) | +| RETRY-16 | MUST | test | 2 test(s) | +| RETRY-17 | MUST | test | 2 test(s) | +| RETRY-18 | MUST | test | 2 test(s) | +| RETRY-19 | MUST | test | 28 test(s) | +| RETRY-20 | MUST | test | 2 test(s) | +| RETRY-21 | MUST | test | 2 test(s) | +| RETRY-22 | MUST | test | 2 test(s) | +| RETRY-23 | MUST | test | 2 test(s) | +| RETRY-24 | MUST | test | 2 test(s) | +| RETRY-25 | MUST | test | 3 test(s) | +| RETRY-26 | MUST | test | 2 test(s) | +| RETRY-27 | MUST | test | 2 test(s) | +| RETRY-28 | MUST | deviation | This unified port models its single RetryPolicy on Azure corehttp, whose 'timeout' knob is an always-on total budget with a large default (604800s / 7 days, documented inline as 'mirroring Azure's def… | +| RETRY-29 | MAY | uncovered | MAY-level, non-gating | +| RETRY-30 | MUST | test | 2 test(s) | +| RETRY-31 | MUST | test | 3 test(s) | +| RETRY-32 | MUST | test | 2 test(s) | +| RETRY-33 | MUST | test | 2 test(s) | +| RETRY-34 | MUST | test | 3 test(s) | +| RETRY-35 | MUST | test | 2 test(s) | +| RETRY-36 | MUST | test | 2 test(s) | +| RETRY-37 | MUST | test | 3 test(s) | +| RETRY-38 | SHOULD | uncovered | SHOULD-level, non-gating | +| RETRY-39 | MUST | test | 2 test(s) | +| RETRY-40 | SHOULD | uncovered | SHOULD-level, non-gating | +| RETRY-41 | MUST | test | 2 test(s) | +| RETRY-42 | MUST | test | 2 test(s) | +| RETRY-43 | MAY | test | 1 test(s) | +| RETRY-44 | MUST | test | 2 test(s) | +| RETRY-45 | MUST | n/a | Scheduler-lifecycle ownership (never shut down a caller-supplied scheduler; a process-wide daemon-thread scheduler VM-wide; the async stage stack requiring a caller-supplied schedu | + +## seams + +| ID | Level | Status | Evidence | +|---|---|---|---| +| SEAM-1 | MUST | test | 3 test(s) | +| SEAM-2 | MUST | test | 2 test(s) | +| SEAM-3 | MUST | n/a | the byte-stream provider / Io / IoProvider / Okio-style seam was deliberately removed (confirmed absent in core source) — CLAUDE.md states Python stdlib bytes/bytearray/memoryview/ | +| SEAM-4 | MUST | n/a | no byte-stream provider factory exists to be thread-safe — the whole provider seam was removed in favour of the stdlib bytes/BytesIO/BinaryIO contract (CLAUDE.md removed-Io-seam de | +| SEAM-5 | MUST | n/a | provider resolution/precedence has no port: the byte-stream provider seam is removed, and there is no classpath ServiceLoader auto-discovery idiom on CPython — Protocols replace Ja | +| SEAM-6 | MUST | n/a | explicit provider installation/idempotency has no port — the byte-stream provider seam it governs was removed (CLAUDE.md removed-Io-seam deviation); no install registry exists in c | +| SEAM-7 | MUST | n/a | process-wide caching of a discovered provider has no port — there is no provider seam and no classpath discovery scan to cache (removed-Io-seam + no-ServiceLoader deviations). | +| SEAM-8 | SHOULD | uncovered | SHOULD-level, non-gating | +| SEAM-9 | MUST | n/a | provider resolve/install/swap concurrency-safety has no port — the provider registry it protects was removed; the stdlib byte types carry no install state (removed-Io-seam deviatio | +| SEAM-10 | SHOULD | uncovered | SHOULD-level, non-gating | +| SEAM-11 | MUST | test | 8 test(s) | +| SEAM-12 | MUST | test | 12 test(s) | +| SEAM-13 | SHOULD | test | 5 test(s) | +| SEAM-14 | MUST | test | 6 test(s) | +| SEAM-15 | MAY | test | 8 test(s) | +| SEAM-16 | MUST | test | 5 test(s) | +| SEAM-17 | SHOULD | test | 5 test(s) | +| SEAM-18 | MUST | test | 3 test(s) | +| SEAM-19 | MUST | test | 2 test(s) | +| SEAM-20 | MUST | test | 4 test(s) | +| SEAM-21 | MUST | test | 3 test(s) | +| SEAM-22 | MUST | test | 2 test(s) | +| SEAM-23 | MUST | test | 3 test(s) | +| SEAM-24 | SHOULD | test | 1 test(s) | +| SEAM-25 | MUST | test | 2 test(s) | +| SEAM-26 | MUST | test | 2 test(s) | +| SEAM-27 | MUST | test | 5 test(s) | +| SEAM-28 | MAY | test | 2 test(s) | +| SEAM-29 | MUST | n/a | the immutable-value + Builder pattern was deliberately not ported: CLAUDE.md mandates frozen @dataclass(slots=True) models constructed via keyword/default arguments ('Builders are | +| SEAM-30 | MUST | test | 5 test(s) | + +## serde + +| ID | Level | Status | Evidence | +|---|---|---|---| +| SERDE-1 | MUST | test | 2 test(s) | +| SERDE-2 | MUST | test | 2 test(s) | +| SERDE-3 | MUST | test | 3 test(s) | +| SERDE-4 | MUST | test | 4 test(s) | +| SERDE-5 | MUST | test | 2 test(s) | +| SERDE-6 | MUST | test | 3 test(s) | +| SERDE-7 | MUST | test | 2 test(s) | +| SERDE-8 | MUST | test | 3 test(s) | +| SERDE-9 | MUST | test | 3 test(s) | +| SERDE-10 | MUST | test | 3 test(s) | +| SERDE-11 | SHOULD | test | 1 test(s) | +| SERDE-12 | MUST | test | 3 test(s) | +| SERDE-13 | MUST | test | 8 test(s) | +| SERDE-14 | MUST | test | 3 test(s) | +| SERDE-15 | MUST | test | 3 test(s) | +| SERDE-16 | MUST | test | 3 test(s) | +| SERDE-17 | MUST | test | 3 test(s) | +| SERDE-18 | SHOULD | test | 7 test(s) | +| SERDE-19 | MUST | test | 4 test(s) | +| SERDE-20 | SHOULD | test | 1 test(s) | +| SERDE-21 | MUST | deviation | The codec is deliberately validation-free (codec.py module docstring: 'performs no schema checks or scalar coercion'); cross-shape scalars pass through unchanged ('5'->str '5', 1.5 | +| SERDE-22 | MUST | test | 9 test(s) | +| SERDE-23 | SHOULD | test | 2 test(s) | +| SERDE-24 | SHOULD | test | 3 test(s) | +| SERDE-25 | SHOULD | test | 2 test(s) | +| SERDE-26 | MUST | test | 1 test(s) | +| SERDE-27 | MUST | deviation | The response-decode lifecycle/error contract is fully implemented and tested (closes the response on every path, surfaces a missing body as a DeserializationError naming the target | +| SERDE-28 | MUST | test | 7 test(s) | +| SERDE-29 | SHOULD | test | 1 test(s) | +| SERDE-30 | MAY | test | 1 test(s) | + +## sse + +| ID | Level | Status | Evidence | +|---|---|---|---| +| SSE-1 | MUST | test | 2 test(s) | +| SSE-2 | MUST | test | 4 test(s) | +| SSE-3 | MUST | test | 2 test(s) | +| SSE-4 | MUST | test | 2 test(s) | +| SSE-5 | MUST | test | 13 test(s) | +| SSE-6 | MUST | test | 3 test(s) | +| SSE-7 | MUST | test | 1 test(s) | +| SSE-8 | MUST | deviation | Confirmed: _data_lines accumulates consecutive data lines in wire order but they are joined with a single '\n' into an immutable SseEvent.data string at dispatch rather than expose | +| SSE-9 | MUST | test | 3 test(s) | +| SSE-10 | MUST | test | 2 test(s) | +| SSE-11 | MUST | test | 4 test(s) | +| SSE-12 | MUST | test | 3 test(s) | +| SSE-13 | MUST | test | 7 test(s) | +| SSE-14 | MUST | test | 3 test(s) | +| SSE-15 | MUST | test | 2 test(s) | +| SSE-16 | MUST | test | 1 test(s) | +| SSE-17 | MUST | test | 1 test(s) | +| SSE-18 | MUST | test | 1 test(s) | +| SSE-19 | MAY | uncovered | MAY-level, non-gating | +| SSE-20 | MUST | deviation | Confirmed: SseEvent is a frozen(slots) dataclass (immutable) whose payload is an immutable joined string rather than a defensively-copied data list (consequence of SSE-8), so there | +| SSE-21 | SHOULD | test | 2 test(s) | +| SSE-22 | SHOULD | uncovered | SHOULD-level, non-gating | +| SSE-23 | MUST | test | 3 test(s) | +| SSE-24 | MUST | test | 2 test(s) | +| SSE-25 | MUST | test | 3 test(s) | +| SSE-26 | MUST | test | 2 test(s) | +| SSE-27 | MUST | test | 3 test(s) | +| SSE-28 | MUST | test | 3 test(s) | +| SSE-29 | MUST | test | 3 test(s) | +| SSE-30 | MUST | test | 6 test(s) | +| SSE-31 | MUST | test | 1 test(s) | +| SSE-32 | MUST | test | 2 test(s) | +| SSE-33 | MUST | test | 2 test(s) | +| SSE-34 | MUST | test | 3 test(s) | +| SSE-35 | MUST | test | 3 test(s) | +| SSE-36 | MUST | test | 2 test(s) | +| SSE-37 | MUST | test | 1 test(s) | +| SSE-38 | MUST | deviation | The core parser conforms — it surfaces the server retry hint and each event's raw per-block id, no longer persists a last-event-id across events (SSE-16), and sets no request header. Reconnection and … | +| SSE-39 | MUST | test | 2 test(s) | +| SSE-40 | SHOULD | test | 2 test(s) | +| SSE-41 | MAY | test | 1 test(s) | + +## transport + +| ID | Level | Status | Evidence | +|---|---|---|---| +| TRANSPORT-1 | MUST | test | 11 test(s) | +| TRANSPORT-2 | MUST | test | 10 test(s) | +| TRANSPORT-3 | MUST | test | 9 test(s) | +| TRANSPORT-4 | MUST | deviation | The cancel-flag clause is honored and tested (read timeout maps to ServiceResponseTimeoutError, never RequestCancelledError, classified as timeout), but the port deliberately devia | +| TRANSPORT-5 | MUST | test | 6 test(s) | +| TRANSPORT-6 | SHOULD | test | 4 test(s) | +| TRANSPORT-7 | MUST | test | 6 test(s) | +| TRANSPORT-8 | MUST | n/a | OkHttp's interceptor-driven internal cancel-all is a library-specific seam with no analog in httpx/aiohttp/requests/urllib (like java.net.http, which the requirement text itself ca | +| TRANSPORT-9 | MUST | n/a | The adaptation race is an artifact of OkHttp's Call.enqueue + CompletableFuture callback dispatch where a native response can arrive after the future already completed/cancelled; a | +| TRANSPORT-10 | MUST | test | 9 test(s) | +| TRANSPORT-11 | MUST | test | 6 test(s) | +| TRANSPORT-12 | MUST | test | 8 test(s) | +| TRANSPORT-13 | SHOULD | test | 8 test(s) | +| TRANSPORT-14 | MUST | test | 6 test(s) | +| TRANSPORT-15 | MUST | test | 7 test(s) | +| TRANSPORT-16 | MUST | test | 3 test(s) | +| TRANSPORT-17 | MUST | test | 12 test(s) | +| TRANSPORT-18 | MUST | test | 8 test(s) | +| TRANSPORT-19 | SHOULD | uncovered | SHOULD-level, non-gating | +| TRANSPORT-20 | MUST | test | 12 test(s) | +| TRANSPORT-21 | MUST | test | 4 test(s) | +| TRANSPORT-22 | MUST | test | 8 test(s) | +| TRANSPORT-23 | MUST | test | 8 test(s) | +| TRANSPORT-24 | MUST | test | 14 test(s) | +| TRANSPORT-25 | MUST | test | 12 test(s) | +| TRANSPORT-26 | MUST | test | 10 test(s) | +| TRANSPORT-27 | SHOULD | test | 8 test(s) | +| TRANSPORT-28 | SHOULD | test | 5 test(s) | +| TRANSPORT-29 | MUST | test | 12 test(s) | +| TRANSPORT-30 | SHOULD | test | 8 test(s) | + +## xcut + +| ID | Level | Status | Evidence | +|---|---|---|---| +| XCUT-1 | MUST | test | 2 test(s) | +| XCUT-2 | MUST | test | 3 test(s) | +| XCUT-3 | MUST | test | 3 test(s) | +| XCUT-4 | MUST | test | 4 test(s) | +| XCUT-5 | MUST | test | 119 test(s) | +| XCUT-6 | MUST | test | 5 test(s) | +| XCUT-7 | MUST | test | 3 test(s) | +| XCUT-8 | MUST | test | 13 test(s) | +| XCUT-9 | MUST | test | 3 test(s) | +| XCUT-10 | MUST | test | 5 test(s) | +| XCUT-11 | MUST | test | 3 test(s) | +| XCUT-12 | SHOULD | test | 3 test(s) | +| XCUT-13 | MUST | test | 2 test(s) | +| XCUT-14 | MUST | test | 3 test(s) | +| XCUT-15 | MUST | test | 3 test(s) | +| XCUT-16 | MUST | test | 3 test(s) | +| XCUT-17 | MUST | test | 19 test(s) | +| XCUT-18 | MUST | test | 10 test(s) | +| XCUT-19 | MUST | test | 9 test(s) | +| XCUT-20 | MUST | test | 4 test(s) | +| XCUT-21 | MUST | test | 3 test(s) | +| XCUT-22 | MUST | test | 3 test(s) | +| XCUT-23 | MUST | n/a | This requirement is the pluggable I/O-provider SPI with classpath/ServiceLoader auto-discovery and zero/multiple-candidate resolution; that byte-stream provider (Io/IoProvider/Okio | +| XCUT-24 | SHOULD | test | 3 test(s) | diff --git a/docs/deviations.md b/docs/deviations.md new file mode 100644 index 0000000..7b52cb4 --- /dev/null +++ b/docs/deviations.md @@ -0,0 +1,255 @@ +# Deviations + +Records deliberate deviations from the design intent — places where the shipped +behaviour intentionally differs from a rule or a sibling implementation, together +with the conflict that forced the choice, the decision taken, and the rationale. +Each entry is self-contained so a reader with no prior context understands why the +code looks the way it does. + +For the complete, machine-generated record of how every normative requirement is +met — test-covered, deviated, or not-applicable — see +[`conformance-ledger.md`](conformance-ledger.md). The MUST-level deviations and +not-applicable requirements it lists are projected into +[`traceability-na-ids.txt`](traceability-na-ids.txt), which the blocking CI +traceability gate reads. + +## Async redirect policy is not wired into `default_async_pipeline()` + +**Conflict.** The sync `default_pipeline()` wires `RedirectPolicy` by default, and +an async twin (`AsyncRedirectPolicy`) exists and was originally wired into +`default_async_pipeline()` for parity. That collides with the safety rule that an +async pipeline must not silently follow redirects unless the redirect path is +proven safe against the full redirect security battery: + +- loop / max-hops / malformed-`Location` all hand the current response back + unfollowed and *open* (never closed); +- `Authorization` stripped on every hop, with cross-origin judged against the + **seed** origin (not the previous hop), additionally stripping `Cookie` and + `Proxy-Authorization`; +- HTTPS→HTTP downgrade default-denied; +- `303` rebuild strips the body and every `Content-*` header; +- `Location` resolution preserves percent-encoding wire-exactly (no naive + parse/re-encode round-trip). + +**Decision.** Gated off. `AsyncRedirectPolicy` is **removed from +`default_async_pipeline()`'s default construction**; the class stays fully +available and is wired only when a caller opts in via +`default_async_pipeline(client, redirect=AsyncRedirectPolicy(...))` or by adding +it to a custom pipeline. The gap and the opt-in are documented on the factory's +docstring. + +**Why.** The battery was written and run against the async twin (see +`packages/dexpace-sdk-core/tests/pipeline/test_async_redirect_battery.py`). The +twin delegates every per-hop decision to the wrapped sync `RedirectPolicy`, so it +inherits that policy's current limitations. At the time the gate was written it +failed four dimensions of the battery: + +- HTTPS→HTTP downgrades are followed rather than default-denied; +- `Cookie` and `Proxy-Authorization` are not stripped on a cross-origin hop + (only `Authorization` is); +- cross-origin is judged against the previous hop rather than the seed origin; +- ~~`Location` resolution round-trips through a parse/re-encode that rewrites + percent-encoding (for example `%20` in a query becomes `+`).~~ **Fixed** by + the pure RFC 3986 query codec (`PY-M2-02`, `_query_codec.py`), landed after + this gate — `Url` no longer round-trips through `furl`'s serializer for the + wire-exact rendering path, so `Location` resolution now preserves + percent-encoding exactly. `test_location_resolution_is_wire_exact` passes + and its `xfail` marker was removed. + +One dimension that was outright broken — a malformed `Location` raised +`ValueError` and leaked the in-hand response — was fixed at the source in the +shared `RedirectPolicy` (it now returns the current response unfollowed and open, +matching loop / max-hops), so both the sync and async paths are correct there. +Three gaps remain (HTTPS→HTTP downgrade, cross-origin Cookie/Proxy-Authorization +stripping, seed-origin cross-origin judgment) — behavioural/feature work on the +shared resolution logic; until that logic is certified against the full battery, +silently following redirects on the async path is the failure this gate avoids. +The battery's remaining unmet dimensions stay committed as `xfail(strict=True)` +executable specs, so each flips to a hard failure as its underlying logic is +fixed — forcing this gate to be revisited, and the twin re-wired into +`default_async_pipeline()`, once all three close. + +**IMPORTANT — this is not an async-only gap.** A separate task built the +equivalent battery against the SYNC `RedirectPolicy` +(`packages/dexpace-sdk-core/tests/pipeline/test_sync_redirect_battery.py`) and +confirmed the same three dimensions are unmet there too, since `AsyncRedirectPolicy` +delegates every per-hop decision to the sync policy rather than restating its +own logic — there is only one resolution core, and it is wired into +`default_pipeline()` **by default, right now**. Concretely, as shipped: +an HTTPS `https://` request that gets redirected to `http://` is followed rather +than refused, and a cross-origin hop does not strip `Cookie` / +`Proxy-Authorization` (only `Authorization`). This is a live gap in the default +SYNC pipeline, not a theoretical one gated behind an async opt-in — it is +ledgered here rather than under a separate entry because it is the exact same +root cause and the exact same fix closes both. The sync battery's three +corresponding tests are also committed as `xfail(strict=True)`, so a fix to the +shared core flips both suites at once. + +## `furl` is a sanctioned runtime dependency, not a violation of "stdlib-only" + +**Conflict.** The toolkit's stated rule is that `dexpace-sdk-core` ships against +the standard library and nothing else — no third-party runtime dependency. Yet +the built package declares exactly one: `furl` (with its own transitive +`orderedmultidict` / `six`). Read naively, that is the rule being broken in the +first line of `[project].dependencies`. + +**Decision.** `furl` stays, as a single deliberate, audited exception, confined +to one module: `packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/url.py`, +where it powers `Url` parse/serialise. Nothing else in `core` — and nothing in +any transport adapter — may import it directly. The confinement is not a +convention on the honour system; it is machine-enforced by two checked-in gates: + +- The import-linter `furl-confinement` contract in `.importlinter` (a + `forbidden` contract listing `furl` and `orderedmultidict` as forbidden + import targets from every root package, with a single `ignore_imports` escape + hatch for `dexpace.sdk.core.http.common.url -> furl`). Run via + `uv run lint-imports`. Any second module that imports `furl` directly fails + the contract. +- The dependency-budget audit `tools/check_dependency_audit.py`, which parses + each distribution's declared `[project].dependencies` and fails if `core` + declares any third-party runtime dependency other than `furl` (its + `_CORE_ALLOWED` set is exactly `{"furl"}`), or if an adapter declares more + than one third-party HTTP library. + +**Why.** `Url` needs RFC-correct parse/normalise/serialise behaviour that the +stdlib's `urllib.parse` does not give cleanly, and reimplementing a URL library +by hand is a larger correctness liability than depending on a mature one behind +a boundary. The rule's real intent is "no *unaudited, unbounded* dependency +creep," not "zero dependencies at any cost." Pinning the exception to one module +and enforcing that with two independent tools satisfies the intent while keeping +the surface auditable: the blast radius of `furl` is one file, and adding a +second runtime dependency is a red build, not a code-review judgment call. This +entry exists so a reader who sees `furl` in the manifest and the "stdlib-only" +claim in `CLAUDE.md` does not read them as a contradiction — the exception is +the design, and the two gates are where it is proven. + +## Double-consuming a single-use body raises `RuntimeError`, not a bespoke exception + +**Conflict.** Stream- and iter-backed bodies (`RequestBody.from_stream` / +`from_iter`, their async twins, and the stream-backed `ResponseBody`) are +single-use: consuming them a second time must fail loudly rather than silently +re-emit an exhausted stream. A design that leans on exception *types* for +control flow would want a dedicated, catchable class here — say a +`BodyAlreadyConsumedError` in the `errors` hierarchy — so callers could branch +on it. + +**Decision.** The second consumption raises a plain `RuntimeError` carrying a +specific, actionable message — not a bespoke exception type. The guard itself +lives in `packages/dexpace-sdk-core/src/dexpace/sdk/core/http/_once_guard.py` +(`_OnceGuard.claim`, a lock-protected single-claim latch shared by both body +sides); the messages are defined next to the bodies that raise them, e.g. +`_STREAM_SINGLE_USE_MSG` / `_ITER_SINGLE_USE_MSG` in +`packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request_body.py`, +with parallel constants in `async_request_body.py` and the response-body +modules. Each message names the cause and the fix ("Call `to_replayable()` +BEFORE the first `iter_bytes()` …"). + +**Why.** Re-consuming an exhausted single-use body is a *programming* error, not +a recoverable runtime condition a caller should catch and branch on — the +correct response is always to fix the call site (buffer via `to_replayable()` +before the first send, or clone the request), never to handle the exception. A +bespoke type would invite `except BodyAlreadyConsumedError:` handlers that paper +over the bug instead of fixing it, and would add public surface (`__all__` +entries, docs, import paths) to encode a distinction with no legitimate consumer. +`RuntimeError` with a precise message is the deliberately simpler shape: it +surfaces the mistake at the exact call that made it, reads clearly in a +traceback, and stays out of the catchable-error hierarchy. This is a chosen +simplification, not an unfinished error-taxonomy — do not "upgrade" it to a +custom class without a concrete caller that must distinguish it programmatically. + +## Sync cooperative cancellation stops at the backoff boundary, not mid-read + +**Conflict.** The async stack can abandon an in-flight operation promptly: +cancelling the awaiting task unwinds it at the next `await`. A reader might +expect the sync stack to offer the same "cancel now" immediacy — for a blocked +retry to be interrupted the instant a caller signals cancellation. It does not, +and cannot, for a read that is already blocked in the transport. + +**Decision.** Sync cooperative cancellation is a documented *limit*, not a bug. +The interruptible-sleep primitive exists — +`packages/dexpace-sdk-core/src/dexpace/sdk/core/util/clock.py` ships +`CancellationToken`, a `threading.Event`-backed handle whose `sleep(duration)` +returns early the moment another thread calls `cancel()`. That makes a *waiting* +period (a backoff delay) promptly interruptible. But the sync `RetryPolicy` +(`packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/retry.py`) +paces its own backoff through `Clock.sleep` (`_sleep_bounded`), and — more to +the point — a request that is already blocked in a socket read on a worker +thread cannot be pre-empted at all: CPython threads are not interruptible from +outside, so an in-flight blocking read runs to its own socket timeout before the +retry loop regains control and can observe cancellation. + +**Why.** This is a property of the runtime, not a missing feature. There is no +portable, safe way to interrupt a thread parked in a blocking C-level `recv`; +the only correct lever is the transport's own read timeout, which bounds how +long that read can block. So the honest contract is: on the sync path, +cancellation and deadlines are honoured at attempt boundaries and during backoff +waits (where `CancellationToken` / deadline clamping apply), and an already-issued +blocking read is bounded by its socket timeout rather than interrupted instantly. +Callers who need tighter mid-read cancellation should set an aggressive transport +read timeout or use the async stack, where task cancellation unwinds at the next +`await`. Ledgered here so the absence of mid-read sync cancellation reads as a +deliberate boundary of the threading model, not an oversight in the retry policy. + +## The reference stdlib transport accepts capability gaps its siblings don't + +**Conflict.** The `HttpClient` / `AsyncHttpClient` Protocols promise a uniform +send surface, and the `httpx` / `aiohttp` / `requests` adapters back it with +full-featured libraries (streaming uploads, per-phase timeouts, repeated +headers). The stdlib transports in `dexpace-sdk-http-stdlib` +(`UrllibHttpClient`, `AsyncioHttpClient`) implement the same Protocol but cannot +match those capabilities, so identical calling code behaves differently +depending on which transport is plugged in. + +**Decision.** The stdlib transport is a *reference*, zero-third-party-dependency +implementation that deliberately accepts a bounded set of capability gaps rather +than reimplementing a production HTTP stack on top of `urllib` / raw asyncio +sockets. The gaps are enumerated in the module docstrings — +`packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/urllib_http_client.py` +and `.../asyncio_http_client.py`: no streaming uploads (the body is fully +buffered before send), coarse single-value timeouts (no connect/read/write +granularity), and constrained multi-value request-header handling (`urllib` +flattens repeats with `", "`; the asyncio client emits repeated lines). The +asyncio client additionally speaks only HTTP/1.1 with `Connection: close` and +rejects `Transfer-Encoding: chunked` responses loudly rather than truncating. + +**Why.** The point of the stdlib transport is to give the toolkit a working, +dependency-free transport for tests, examples, and constrained environments — +its zero-dependency posture is the feature, and matching a production library's +capability surface would mean rebuilding one, which defeats the purpose and +would need a third-party dependency the package exists to avoid. Each docstring +tells the reader exactly which limitation to escape by plugging in `httpx` / +`aiohttp` / `requests`. Ledgered here so the divergence between transports reads +as a deliberate reference-vs-production split, not a bug in the stdlib adapter. + +## How this ledger feeds the traceability audit + +This file is written to be the human-readable source of truth for intentional +deviations and not-applicable (N/A) requirements consumed by the traceability +audit in `tools/traceability_audit.py`. The audit already exposes the seam: both +`audit(...)` and `TraceabilityPlugin` accept an `na_ids` set (requirement ids +that are intentionally not implemented and therefore never counted as coverage +gaps), and `na_ids_from_file(path)` reads that set from a newline-delimited id +file (one id per line; blank lines and `#` comments ignored; a missing file +yields an empty set), pointed at by the `DEXPACE_TRACEABILITY_NA` environment +variable. + +That reader consumes a flat id file, **not** this Markdown document. The concrete +bridge between this prose ledger and that reader is the committed, hand-maintained +file `docs/traceability-na-ids.txt`: a newline-delimited projection of the N/A +requirement ids implied by the entries above (one id per line; `#` comments and +blank lines ignored, matching `na_ids_from_file`). CI points +`DEXPACE_TRACEABILITY_NA` at it and runs the audit **blocking** against every +MUST-bearing subsystem of the requirement index (see the "traceability audit" +step in `.github/workflows/ci.yml`), and the matrix artifact is uploaded per run. + +The audit deliberately does **not** parse this Markdown — deriving the id set by +scraping prose would be a fragile coupling. Instead, `docs/traceability-na-ids.txt` +is maintained by review alongside these entries: when a deviation here renders a +MUST-level requirement intentionally unmet, its id is transcribed into that file +with a back-reference to the justifying section. As of this writing no indexed +MUST is N/A (every deviation above is either a design choice that withholds no +MUST-level behaviour, or a gap in a behaviour with no assigned requirement id in +the current `PLACEHOLDER_INDEX`), so the file carries no active ids yet — the +seam is wired and exercised in CI and gains entries the moment a ledgered +deviation withholds an indexed MUST. Swapping `PLACEHOLDER_INDEX` for the real +spec index later needs no change to this bridge. diff --git a/docs/errors.md b/docs/errors.md index 3b92479..84ad92d 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -7,11 +7,14 @@ distinguish failure modes by subclass. ``` Exception └─ SdkError # root; captures sys.exc_info + inner_exception - ├─ ServiceRequestError # request never reached server - │ └─ ServiceRequestTimeoutError + ├─ NetworkError (also OSError) # transport branch; never got a response; + │ │ # always retryable at the error-class level + │ └─ ServiceRequestError # request never reached server + │ └─ ServiceRequestTimeoutError # connect-phase timeout (retryable) + ├─ RequestCancelledError (also OSError) # sync cooperative-cancel; terminal, never retried ├─ ServiceResponseError # response unparseable / connection dropped - │ └─ ServiceResponseTimeoutError - ├─ HttpResponseError # 4xx/5xx received intact + │ └─ ServiceResponseTimeoutError # (not the always-retryable transport branch) + ├─ HttpResponseError # 4xx/5xx received intact; carries bounded error body │ ├─ ClientAuthenticationError # 401/403 — short-circuits retry │ ├─ DecodeError # body could not be decoded │ ├─ ResourceExistsError # typically 409 @@ -23,10 +26,55 @@ Exception ├─ ResponseNotReadError # attribute access before read ├─ StreamingError # stream framing / decode error (e.g. SSE) ├─ PipelineAbortedError # SansIO step returned None - ├─ SerializationError # also a ValueError - └─ DeserializationError # also a ValueError + └─ SerdeError # serde failure root; also a ValueError + ├─ SerializationError # encode side + └─ DeserializationError # decode side (CodecError lives here) ``` +The taxonomy has two branches, split by whether a response was ever received: + +- **`NetworkError`** roots the transport branch — a request that never yielded a + usable response (connection refused, DNS failure, connect timeout). It also + subclasses `OSError`, so `except OSError:` sites (in user code or third-party + libraries) keep matching transport failures after the SDK re-wraps them, and + it is *always retryable* at the error-class level (a request that never + reached the service is safe to re-send on an idempotent method). Never catch a + `NetworkError` and re-wrap it into a non-network branch — that would break + those `except OSError:` sites. +- **`HttpResponseError`** roots the response branch — a 4xx/5xx response arrived + intact. Its `retryable` flag is derived per-status from the shared retry + classifier and *baked once* at construction, so a later change to a retry + policy's configured status set never mutates an already-built error. It also + carries a bounded, replayable copy of the error body via + `read_body(max_bytes=ERROR_BODY_MAX_BYTES)` (default cap: 1 MiB) for repeatable + post-mortem inspection; `body_snapshot` remains the non-consuming preview. + +`ServiceResponseError` is the in-between case (request sent, but the response +could not be parsed): it is deliberately *not* on the always-retryable transport +branch, because the request may already have been processed. + +`RequestCancelledError` is the terminal type for synchronous cooperative +cancellation. Like `NetworkError` it is an `OSError`, but — being terminal — it +is not a `NetworkError` and is never retryable. Discriminate a timeout from a +cancellation by subtype (a `ServiceRequestTimeoutError` is a retryable +`NetworkError`; a `RequestCancelledError` is not), checking the timeout first, +never by matching message text. The asynchronous cancel signal is +`asyncio.CancelledError`, a `BaseException` that needs no SDK subtype. + +On the sync path, cancellation and deadlines are honoured at attempt boundaries +and during backoff waits — the retry loop's `CancellationToken` sleep returns +early the moment another thread cancels. An already-issued blocking socket read +is *not* interrupted mid-read, though: CPython threads cannot be pre-empted from +outside, so an in-flight read runs to its own transport read timeout before the +retry loop regains control. That is a property of the threading model, not a +missing feature; callers needing tighter mid-read cancellation set an aggressive +transport read timeout or use the async stack. See [`deviations.md`](deviations.md). + +`SerdeError` roots the serde failure taxonomy: catch it to handle any +serde failure regardless of direction, or catch `SerializationError` / +`DeserializationError` to distinguish encode from decode. All three are +also `ValueError`s, so existing `except ValueError` handlers keep working. + `SdkError` captures `sys.exc_info()` at construction time, preserving the original cause even when the SDK re-wraps a stdlib exception. @@ -36,12 +84,15 @@ Consumer libraries declare a per-operation table and use `map_error` after each send: ```python +from dexpace.sdk.core.client import HttpClient from dexpace.sdk.core.errors import ( ResourceExistsError, ResourceModifiedError, ResourceNotFoundError, map_error, ) +from dexpace.sdk.core.http.request import Request +from dexpace.sdk.core.http.response import Response ERROR_MAP = { 404: ResourceNotFoundError, @@ -49,10 +100,12 @@ ERROR_MAP = { 412: ResourceModifiedError, } -with client.execute(request) as response: - if not response.is_success: - map_error(int(response.status), response, ERROR_MAP) - return response + +def send(client: HttpClient, request: Request) -> Response: + with client.execute(request) as response: + if not response.is_success: + map_error(int(response.status), response, ERROR_MAP) + return response ``` `map_error` only raises when `status_code` is a key in the supplied diff --git a/docs/pipelines.md b/docs/pipelines.md index 018345c..0ddaf04 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -71,14 +71,21 @@ one span per attempt / hop. | Policy | Purpose | |-------------------------------------|----------------------------------------------------------| -| `RetryPolicy` / `AsyncRetryPolicy` | Retry transient failures with backoff + `Retry-After`. Auto-buffers single-use request bodies when `total_retries > 0`. | -| `LoggingPolicy` | Structured request/response logs with URL redaction. | -| `OperationTracingPolicy` | Emit the per-operation lifecycle (`operation_started`, then one `operation_succeeded`/`operation_failed`) from outside the retry/redirect loop. | -| `TracingPolicy` | Open a span per attempt and emit per-request tracer events; OTel semantic-conv attributes. | -| `BearerTokenPolicy` (auth) | Acquire + cache + apply OAuth bearer tokens. | +| `RetryPolicy` / `AsyncRetryPolicy` | Retry transient failures with backoff + `Retry-After`. Auto-buffers single-use request bodies when `total_retries > 0`. Retryable status set is configurable (see below). | +| `RedirectPolicy` / `AsyncRedirectPolicy` | Follow `301`/`302`/`303`/`307`/`308`, stripping credentials across origins. The async twin is gated off by default (see below). | +| `LoggingPolicy` / `AsyncLoggingPolicy` | Structured request/response wire logs with URL redaction and a bounded body preview. | +| `OperationTracingPolicy` / `AsyncOperationTracingPolicy` | Emit the per-operation lifecycle (`operation_started`, then one `operation_succeeded`/`operation_failed`) from outside the retry/redirect loop. | +| `TracingPolicy` / `AsyncTracingPolicy` | Open a span per attempt and emit per-request tracer events; OTel semantic-conv attributes. | +| `BearerTokenPolicy` / `AsyncBearerTokenPolicy` (auth) | Acquire + cache + apply OAuth bearer tokens. | | `KeyCredentialPolicy` (auth) | Stamp an API key into a configurable header. | | `BasicAuthPolicy` (auth) | `Authorization: Basic `. | +Both observability policies now ship an async twin. `AsyncLoggingPolicy` and +`AsyncTracingPolicy` are wired into `default_async_pipeline` at the same stages +their sync counterparts occupy, so the async stack emits the same per-attempt +wire logs and per-attempt tracing span as the sync default — the two pillars are +at parity here. + ## Mutable scratchpad — `PipelineContext` Each policy receives a `PipelineContext` with: @@ -117,3 +124,45 @@ buffers, and a per-call `retry_total=0` (or `RetryPolicy.no_retries()`) skips the buffering so callers who opt out of retries pay no memory cost for a copy they will never use. Already-replayable bodies (`from_bytes`, `from_string`, `from_form`) flow through untouched because `to_replayable()` returns `self`. + +## Retryable HTTP status codes + +`RetryPolicy` treats a fixed set of status codes as transient and retryable: +`408`, `429`, and the transient `5xx` family (`500`, `502`, `503`, `504`) — +`DEFAULT_RETRYABLE_STATUS`. Permanent `5xx` codes (for example `501`) are not +retried. The set is configurable per policy through the +`retry_on_status_codes` constructor argument, and a per-call `retry_status` +override in `ctx.options` caps how many status-driven retries a single call +spends. + +The classification is *baked once* into `HttpResponseError.retryable` at +construction time, so changing a policy's configured status set later never +mutates an already-built error — see [`errors.md`](errors.md). + +## Default stacks and the redirect asymmetry + +`default_pipeline(client)` and `default_async_pipeline(client)` assemble the +canonical stack. The stage order is operation-tracing → redirect → idempotency → +retry → set-date → client-identity → [auth] → logging → tracing. Each policy is +opt-out (pass `None`) or opt-in-with-override (pass a pre-configured instance). + +The two stacks are at parity on observability — both wire per-attempt wire +logging and a per-attempt tracing span, bracketed by the per-operation tracer — +but they differ on **redirect**: + +- `default_pipeline` wires `RedirectPolicy` by default. +- `default_async_pipeline` does **not** wire `AsyncRedirectPolicy` unless the + caller passes one explicitly (`default_async_pipeline(client, + redirect=AsyncRedirectPolicy(...))`). + +This is not an async-only quirk. `AsyncRedirectPolicy` delegates every per-hop +decision to the same shared `RedirectPolicy` core the sync default wires +unconditionally, so the gaps are shared: an `https://` → `http://` downgrade is +followed rather than default-denied, and a cross-origin hop strips only +`Authorization` (not `Cookie` / `Proxy-Authorization`), judged against the +previous hop rather than the seed origin. Gating the async twin off — rather +than also removing redirect from the sync default, a larger behaviour change — is +the narrower fix while the shared core is certified against the full redirect +security battery. `Location` resolution itself is wire-exact. The full battery, +its `xfail`-tracked dimensions, and the rationale are in +[`deviations.md`](deviations.md). diff --git a/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 0000000..1f2ce16 --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,67 @@ +# Quality gates (non-functional requirements) + +Every non-functional requirement for the SDK is wired as an automatic, +**blocking** CI check, or explicitly recorded as not applicable. Nothing here is +advisory: a red gate fails the build. The checks live in +[`.github/workflows/ci.yml`](../.github/workflows/ci.yml) and their thresholds +live in the workspace root [`pyproject.toml`](../pyproject.toml). + +## Status summary + +| # | Requirement | Status | Where it is enforced | +|---|-------------|--------|----------------------| +| 1 | Aggregate line **and** branch coverage ≥ 80% | Enforced | `coverage` job → `pytest --cov`; floor is `[tool.coverage.report].fail_under = 80` | +| 2 | Warnings-as-errors, including `DeprecationWarning`, on the first-party suites | Enforced | `test` job → `pytest`; `[tool.pytest.ini_options].filterwarnings` (`error` + explicit `error::DeprecationWarning`) | +| 3 | `ruff` lint fatal, with only scoped, documented waivers | Enforced | `test` job → `ruff check`; see "Lint waivers" below | +| 4 | `ruff format` consistency | Enforced | `test` job → `ruff format --check` | +| 5 | `mypy --strict` clean; no `Any` in the public API; no unused `# type: ignore` | Enforced | `test` job → `mypy --strict` (`warn_unused_ignores = true`) | +| 6 | Runtime dependency budget: core = stdlib + `furl`; each adapter = core + ≤ 1 third-party lib | Enforced | `packaging` job → `tools/check_dependency_audit.py`; reinforced by the `import-linter` contracts | +| 7 | Import-direction, `furl` confinement, and pipeline layering invariants | Enforced | `test` job → `lint-imports` (`.importlinter`) | +| 8 | PEP 420 namespace-package layout (no stray `__init__.py` on shared prefixes) | Enforced | `packaging` job → `tools/check_namespace_packages.py` | +| 9 | MIT licence header on every `.py` file | Enforced | `packaging` job → `tools/check_mit_headers.py` | +| 10 | Wheels build and import cleanly in a fresh environment | Enforced | `packaging` job → wheel build + `tools/smoke_wheel_import.py` | +| 11 | Supported interpreter matrix (CPython 3.12 – 3.14) | Enforced | `test` job → `strategy.matrix.python-version` | +| 12 | PEP 702 `@warnings.deprecated` markers carry the right runtime shim (3.12 via `typing_extensions`, native 3.13+) | N/A | Nothing in the SDK is deprecated yet — no `@deprecated` markers exist, so there is nothing to gate. Revisit when the first deprecation lands. | +| 13 | JVM-shaped tuning knobs (bytecode shrinker, GC/heap tuning, JIT flags) | Not applicable to Python | These are Java/JVM build concerns with no CPython analogue; there is nothing to implement or gate. | + +## Coverage gate + +`pytest-cov` measures line and branch coverage over the `dexpace.sdk` source +tree (`[tool.coverage.run]`: `branch = true`, `source = ["dexpace.sdk"]`). The +blocking floor is `fail_under = 80`; `pytest-cov` exits non-zero when the +aggregate total drops below it. The tree currently measures **~94%** +(93.6% after the standard `TYPE_CHECKING` / `__main__` exclusions), so 80% is a +regression floor, not a target to code down to. + +The gate runs in its own `coverage` job (Python 3.12) rather than inside the +matrix `test` job, so the existing per-version steps are untouched and the +coverage number has a single, unambiguous source. + +## Warnings gate + +The first-party suites run under `filterwarnings = ["error", ...]`, which +promotes **every** warning category to an error. `error::DeprecationWarning` is +listed explicitly as well, so the `-W error::DeprecationWarning` requirement is +pinned literally and survives any future narrowing of the catch-all `error` +entry. The single scoped waiver, `ignore::DeprecationWarning:pytest_asyncio.*`, +suppresses deprecation warnings raised from inside the `pytest-asyncio` plugin +only; it is listed last so it takes precedence for that source alone, and +first-party deprecations still fail the run. + +## Lint waivers + +`ruff check` is fatal. Blanket suppression is structurally prevented rather than +policed by convention: + +- `RUF100` (part of the selected `RUF` group) fails the build on any unused or + redundant `# noqa`, so a `# noqa` only survives if it suppresses a real, + currently-firing diagnostic. +- Every in-tree `# noqa` names an explicit rule code (e.g. `# noqa: N802`); no + bare, code-less `# noqa` exists anywhere in the sources. +- Rule-set-wide ignores are confined to `[tool.ruff.lint.ignore]` + (`E501`, `B008`) and narrowly-scoped `per-file-ignores`, each carrying a + comment explaining why. + +The `# type: ignore` story mirrors this: `mypy`'s `warn_unused_ignores = true` +rejects any ignore that no longer suppresses an error, and every ignore in the +tree carries an explicit error code (e.g. `# type: ignore[arg-type]`). diff --git a/docs/traceability-na-ids.txt b/docs/traceability-na-ids.txt new file mode 100644 index 0000000..c40a61c --- /dev/null +++ b/docs/traceability-na-ids.txt @@ -0,0 +1,117 @@ +# Traceability N/A requirement ids — machine-readable projection of the +# conformance ledger (docs/deviations.md and docs/conformance-ledger.md). +# +# The traceability audit (tools/traceability_audit.py) reads this file via the +# DEXPACE_TRACEABILITY_NA environment variable and treats every id listed here +# as accounted-for, so it never counts as an uncovered-MUST gap. Two dispositions +# appear here, both MUST-level (SHOULD/MAY never gate): +# - na: not applicable to the Python port (a Java/JVM idiom or a seam +# this port deliberately omits — e.g. the byte-stream/Io provider +# seam, the Builder pattern, JVM interruption/MDC/fork-join). +# - deviation: the behaviour is provided by a different, documented mechanism. +# Every id carries a one-line justification; the full rationale lives in +# docs/conformance-ledger.md. Format: one id per line; '#' comments ignored. +# + +# --- Not applicable to the Python port (55 MUST) --- +ASYNC-21 # na: Exposing SSE 'as a reactive stream' is a JVM reactive-streams/Reactor Publisher ecosystem adapter; this port has no reactive-streams seam and instead exposes SSE as a native async +ASYNC-3 # na: cancel-with-interrupt vs cancel-without-interrupt is JVM Thread.interrupt() worker-thread semantics; CPython threads are not interruptible from outside (deviations.md sync-cancella +ASYNC-4 # na: Ordered interrupt-flag delivery to avoid poisoning a pooled thread is a JVM interrupt-flag hazard; CPython has no cross-thread interrupt flag, so the poisoning failure mode does no +BODY-10 # na: The exact-length stream-to-sink copy primitive (with premature-EOF and zero-byte-read errors) belongs to the removed Io/byte-stream seam; the port's stream body iterates to natural +BODY-37 # na: A tee sink exposing a writable buffer handle that bypasses the primary sink is an Okio Sink/BufferedSink API concept from the removed Io/byte-stream seam (CLAUDE.md forbids reintro +CFG-21 # na: This is a corollary of the interruptible-task future (CFG-20): a cancelled-future-with-closeable-result discard path only exists inside the JVM ExecutorService/Future+thread-interr +CFG-32 # na: A bespoke non-blocking type-4 UUID generator is a JVM workaround for java.util.UUID.randomUUID()'s blocking SecureRandom; CPython's stdlib uuid.uuid4() is already non-blocking and +CFG-33 # na: Deep value-equality helpers are the Java Objects.deepEquals/Arrays.deepEquals/deepHashCode idiom; Python's native structural == and hash on list/tuple/dict already compare by conte +CFG-34 # na: The float-array deep-equality semantics (NaN==NaN, +0.0!=-0.0, object-array vs primitive-array kind distinction) are the Java Arrays.equals(double[])/Double.equals contract; Python +HTTP-39 # na: The fixed-count 'copy exactly N bytes from a source, raising on early EOF' primitive is the removed Okio/Io byte-stream (Source) seam; the Python port models stream bodies over std +IO-1 # na: Okio Source.read tail-append/return-code contract; the byte-stream Source/Sink/Buffer seam was removed and Python stdlib bytes/bytearray/memoryview/BytesIO/BinaryIO is the contract +IO-10 # na: Okio Buffer.clear()/copyTo(window) non-consuming copy; the Buffer seam was removed. +IO-11 # na: Okio BufferedSource exhausted()/readByte()/readByteArray(); the BufferedSource seam was removed (drain via iter_bytes/bytes() over stdlib bytes). +IO-12 # na: Okio exact-count read (readByteArray(n)/readUtf8(n)) EOF-on-short contract; the BufferedSource seam was removed. +IO-13 # na: Okio UTF-8/explicit-charset read/write surface (readUtf8/writeUtf8/writeString(charset)); the BufferedSource/BufferedSink seam was removed, encoding is done with stdlib str.encode/ +IO-14 # na: Okio BufferedSource.readUtf8Line() terminator semantics; the BufferedSource seam was removed (SSE/streaming line handling is a separate subsystem, not this Okio primitive). +IO-15 # na: Okio BufferedSource.skip(byteCount) EOF contract; the BufferedSource seam was removed. +IO-17 # na: Okio Sink.writeAll(source) pump-to-exhaustion/return-count contract; the Source/Sink seam was removed. +IO-19 # na: Okio BufferedSource.peek() non-consuming view; the BufferedSource seam was removed (repeatable reads are achieved by LoggableResponseBody caching, not a peek view). +IO-2 # na: Okio Source.read(0) EOF-vs-zero semantics; the Source seam does not exist in this port (stdlib bytes/BytesIO is the contract), so there is no read(byteCount) primitive to specify. +IO-20 # na: Okio BufferedSource.slice(offset, byteCount) non-consuming length-bounded view; the slice/BufferedSource seam was removed. +IO-21 # na: Okio slice lazy offset-overflow vs eager negative-arg rejection; the slice seam was removed. +IO-22 # na: Okio slice/parent close-invalidation contract; the slice seam was removed. +IO-23 # na: Okio multiple-slice/peek mutual independence and slice-of-slice offset composition; the slice/peek seam was removed. +IO-24 # na: Okio read-after-explicit-slice-close state-error contract; the slice seam was removed. +IO-28 # na: Okio TeeSink's guard against exposing its backing Buffer accessor; the Sink seam was removed and the LoggableRequestBody decorator exposes only iter_bytes/snapshot/captured_size (n +IO-29 # na: Okio TeeSink's flush()/close()/emit() forwarding-to-primary-only lifecycle; flush/emit are Sink methods that do not exist on the RequestBody/ResponseBody surface (the Sink lifecycl +IO-3 # na: Negative-byteCount argument validation on Okio Source.read/Sink.write/Buffer.copyTo; those size-taking seam primitives were removed with the byte-stream provider seam (docs/archite +IO-30 # na: Okio provider factory operations (create Buffer, wrap streams/byte-array as BufferedSource/Sink); the provider seam was removed, bodies are constructed via RequestBody/ResponseBody +IO-31 # na: Okio provider-registry resolution precedence via classpath auto-discovery; Protocols replace Java interfaces and there is no ServiceLoader auto-discovery idiom in this port, so no +IO-32 # na: Okio provider install idempotency/conflict-rejection; the provider registry seam was removed (no ServiceLoader-style installable provider). +IO-33 # na: Okio explicit-install-wins-over-auto-discovery precedence; the provider registry and auto-discovery seam were removed. +IO-37 # na: Single-threaded-contract declaration for Okio Source/Sink/BufferedSource/BufferedSink/Buffer/TeeSink; those streaming instance types were removed with the byte-stream seam. +IO-38 # na: Cross-thread observability of a source/buffer close-state to derived slices; the slice/Buffer seam was removed, so there are no derived slices to invalidate. +IO-4 # na: Okio Sink.write head-removal/short-write contract; the Sink seam was removed in favour of RequestBody.iter_bytes over stdlib bytes. +IO-40 # na: Okio no-op Timeout on wrapped streams plus non-swallowing/non-duplicating cancellation forwarding by a mirroring Sink; the Okio Timeout and Source/Sink seam were removed (in-memory +IO-41 # na: Idempotent close() / close-at-most-once for Okio Source/Sink/Buffer; those seam types were removed (close-idempotency of bodies, responses, SSE connections and pipelines is exercis +IO-42 # na: Okio buffered-source/sink use-after-close rejection with in-memory-buffer exemption and slice invalidation; the BufferedSource/BufferedSink/slice seam was removed (LoggableResponse +IO-5 # na: Okio Sink.flush() plus closeable Source/Sink; the Source/Sink seam was removed (bodies stream via iter_bytes/aiter_bytes, not a flushable Sink). +IO-6 # na: Okio provider wrapping/ownership of a caller-supplied underlying stream; the provider seam was removed and stream ownership is handled by the transport adapters, not a buffered Sou +IO-7 # na: Okio Buffer FIFO-that-is-both-source-and-sink; the Buffer seam was removed (an in-memory queue is just bytes/BytesIO here). +IO-8 # na: Okio Buffer.snapshot() independent-copy contract; the Buffer seam was removed, so there is no Buffer.snapshot to specify (the log-tap snapshot on LoggableRequestBody is a distinct +NFR-8 # na: The requirement's own escape clause ("In ecosystems without such a build step this requirement does not apply") applies: CPython wheels have no whole-program dead-code elimination/ +PAGE-30 # na: Genuinely not applicable: AsyncPaginator accepts no caller-supplied driver executor (constructor: source, strategy, initial_request, max_pages, dispatch_factory), so the entire req +RETRY-45 # na: Scheduler-lifecycle ownership (never shut down a caller-supplied scheduler; a process-wide daemon-thread scheduler VM-wide; the async stage stack requiring a caller-supplied schedu +SEAM-29 # na: the immutable-value + Builder pattern was deliberately not ported: CLAUDE.md mandates frozen @dataclass(slots=True) models constructed via keyword/default arguments ('Builders are +SEAM-3 # na: the byte-stream provider / Io / IoProvider / Okio-style seam was deliberately removed (confirmed absent in core source) — CLAUDE.md states Python stdlib bytes/bytearray/memoryview/ +SEAM-4 # na: no byte-stream provider factory exists to be thread-safe — the whole provider seam was removed in favour of the stdlib bytes/BytesIO/BinaryIO contract (CLAUDE.md removed-Io-seam de +SEAM-5 # na: provider resolution/precedence has no port: the byte-stream provider seam is removed, and there is no classpath ServiceLoader auto-discovery idiom on CPython — Protocols replace Ja +SEAM-6 # na: explicit provider installation/idempotency has no port — the byte-stream provider seam it governs was removed (CLAUDE.md removed-Io-seam deviation); no install registry exists in c +SEAM-7 # na: process-wide caching of a discovered provider has no port — there is no provider seam and no classpath discovery scan to cache (removed-Io-seam + no-ServiceLoader deviations). +SEAM-9 # na: provider resolve/install/swap concurrency-safety has no port — the provider registry it protects was removed; the stdlib byte types carry no install state (removed-Io-seam deviatio +TRANSPORT-8 # na: OkHttp's interceptor-driven internal cancel-all is a library-specific seam with no analog in httpx/aiohttp/requests/urllib (like java.net.http, which the requirement text itself ca +TRANSPORT-9 # na: The adaptation race is an artifact of OkHttp's Call.enqueue + CompletableFuture callback dispatch where a native response can arrive after the future already completed/cancelled; a +XCUT-23 # na: This requirement is the pluggable I/O-provider SPI with classpath/ServiceLoader auto-discovery and zero/multiple-candidate resolution; that byte-stream provider (Io/IoProvider/Okio + +# --- Documented deviations (43 MUST) --- +ASYNC-18 # deviation: The non-blocking async delay is provided by stdlib asyncio.sleep via AsyncClock.sleep (_AsyncSystemClock.sleep / async_http_client.asyncio_sleep) — an event-loop timer that holds n +AUTH-1 # deviation: The port's resolver (codegen/auth.py) recognizes open scheme-name strings matched against an AuthRegistry rather than a fixed {OAUTH2,API_KEY,BASIC,DIGEST,NO_AUTH} enum, and models +AUTH-10 # deviation: Additive grace-margin expiry evaluation is implemented and tested (needs_refresh/classify/is_expired), but AccessTokenInfo makes expires_on a mandatory int so there is no null-mean +AUTH-12 # deviation: The RFC 7235 parser handles multiple comma-separated challenges, quoted-string commas/=, backslash escapes, lower-cased parameter names and verbatim values (heavily tested), but sc +AUTH-2 # deviation: AuthRequirement is a frozen (immutable, value-equal) dataclass binding exactly one scheme to its scopes (test_auth_scheme_satisfies_by_name_and_scope_subset), but it drops the sepa +AUTH-3 # deviation: AuthDescriptor is a non-empty, ordered, immutable frozen dataclass that rejects an empty requirement list at construction (test_auth_descriptor_rejects_empty), but the allowsAnonym +AUTH-6 # deviation: A present-but-unsatisfiable selected tier raises the distinct AuthResolutionError (test_unsatisfiable_per_call_does_not_fall_through_to_client), but when all three descriptors are +BODY-25 # deviation: In the iterator-based ResponseBody model (the removed read-into-buffer Io seam) end-of-stream is signalled only by iterator exhaustion, so an interior zero-length chunk is tolerate +CFG-25 # deviation: An out-of-range/non-numeric port yields None with a warning (tested), and the anti-fabrication intent ('never invent a per-scheme default port') is honored; but a genuinely absent +CFG-31 # deviation: The blank/empty-input MUST-fail clause is satisfied and tested (parse returns None), but parsing delegates to stdlib email.utils.parsedate_to_datetime (documented as tolerant in ht +CFG-7 # deviation: ISO-8601, shorthand ms/s/m/h/d, negative-rejection and unknown-unit rejection are all implemented and tested, but a bare number is deliberately interpreted as SECONDS (not the spec +CTX-10 # deviation: A promoted dispatch stays unregistered (store_key=None) so its close is a no-op as required, but closing a promoted request tier deliberately clears the successor exchange because +CTX-5 # deviation: Per the CTX-17 promotion-time model, keys are minted at promotion (ContextStore.new_key), not at direct construction, so an off-chain context defaults to store_key=None (verified: +CTX-9 # deviation: CallContext.close() removes unconditionally by the call-unique shared key (context_store.py 'Two removers coexist' docstring) rather than identity-conditionally, because request an +HTTP-2 # deviation: Java builders are replaced by frozen dataclasses whose __post_init__ validates on every construction path (raw constructor and dataclasses.replace included), so validation cannot b +HTTP-3 # deviation: newBuilder()-style derivation is provided by the with_* helpers plus dataclasses.replace (derive a modified copy without restating fields), and collections are stored as immutable +HTTP-34 # deviation: RequestOptions is folded into Request keyword-arg fields (timeout, retries) with None 'use-the-default' sentinels — a default-constructed Request being the canonical 'override noth +HTTP-38 # deviation: Replayability-by-source is honored and tested (bytes/string/file/form replayable; stream/iter single-use), but the form body deliberately uses strict RFC 3986 %20 encoding (never ' +NFR-10 # deviation: The lowest-supported-runtime floor is declared as requires-python>=3.12 in every published pyproject and enforced by the CI 3.12/3.13/3.14 matrix running the full suite on the floo +NFR-17 # deviation: The quality gates are wired blocking (fail the run) in .github/workflows/ci.yml and release.yml — pytest, traceability audit (DEXPACE_TRACEABILITY_GATE), mypy --strict, ruff/ruff-f +PAGE-23 # deviation: Replace-first-in-place, append-when-absent, remove-on-null, and follow-whole-URL-preserving-method/headers are all covered by the cited tests; BUT the MUST sub-clause 'dropping any +PIPE-26 # deviation: The Python pipeline exposes run(request, dispatch, **options) and is consumed via the structural SyncPipelineLike/AsyncPipelineLike Protocols (pipeline/dispatch.py) rather than lit +PIPE-27 # deviation: Rather than making pipeline close a strict no-op, Pipeline.__exit__ delegates to transport.close() (the CLAUDE.md context-manager convention) and transport adapters are ownership-a +PIPE-33 # deviation: The sync->async bridge lives at the transport seam (client/_sync_to_async.py SyncToAsyncHttpClient) and mandates a caller-supplied executor with no default (tests test_missing_exec +PIPE-34 # deviation: The async->sync bridge lives at the transport seam (client/_async_to_sync.py AsyncToSyncHttpClient): it blocks the calling thread for each call and on KeyboardInterrupt cancels the +RECOV-14 # deviation: No chain container defensively copies step lists; instead each policy defensively frozenset-copies its own collections (RetryPolicy.method_allowlist/retry_on_status_codes are froze +RECOV-20 # deviation: Retries are bounded by a max-attempts cap (initial send counts as attempt 1; asserted by test_sync_max_retries_two_sends_exactly_three_times) AND a per-call total-timeout deadline +RECOV-21 # deviation: The exponential curve base*multiplier^(n-1) capped at maxDelay is ported faithfully and tested, but the jitter is a different scheme — AWS full-jitter [0.5,1.0] by default and a sy +RECOV-3 # deviation: The port has no dedicated RequestRecoveryChain container; ordered request-side transforms, empty-set identity, and short-circuit-and-propagate-on-throw are realized by the pipeline +RECOV-6 # deviation: There is no chain container that iterates a response-step list then a recovery-step list; the response-then-recovery ordering emerges from composing map_success (Success-path) befo +REDIR-11 # deviation: Verified: the port plants no out-of-band wire marker. Instead each credential policy records the seed origin via ctx.data.setdefault(_AUTH_ORIGIN_KEY, current) on first pass and re +REDIR-26 # deviation: Verified: the port satisfies the defensive-copy requirement natively via type immutability rather than a copy — allowed_methods is typed and stored as a frozenset (redirect.py __in +REDIR-5 # deviation: Verified: the 303 GET-rebuild mechanics match and are tested (test_303_follow_303_true_reissues_as_get_and_drops_body: GET, body dropped, Content-* removed; test_303_follow_303_fal +REDIR-7 # deviation: Verified: the port strips Authorization ONLY on a cross-origin re-issue (test_authorization_stripped_on_cross_origin_redirect, test_authorization_stripped_on_cross_origin_303_get), +RETRY-1 # deviation: The classifier IS single-sourced (DEFAULT_RETRYABLE_STATUS in pipeline/_pure/classifier.py, consumed by both HttpResponseError.retryable and RetryPolicy's default) and that single- +RETRY-28 # deviation: This unified port models its single RetryPolicy on Azure corehttp, whose 'timeout' knob is an always-on total budget with a large default (604800s / 7 days, documented inline as 'mirroring Azure's default'). That default is effectively unbounded, so no practical budget is imposed out of the box — the failure mode RETRY-28 guards against (a unifying port making the stage stack's zero-budget always-on) does not occur; a caller opts into a tighter budget by setting 'timeout'. A None-defaulted opt-in parameter was considered but rejected to keep the corehttp-fidelity API (timeout: float) the port deliberately follows. +RETRY-6 # deviation: CORRECTED from marker. The idempotent-method set IS single-sourced (_DEFAULT_METHOD_ALLOWLIST in retry.py:100) and POST/PATCH are correctly excluded (retried only on 500/503/504 vi +RETRY-9 # deviation: compute_backoff implements base*multiplier**(attempt-1) clamped to cap (tested: test_exponential_growth, test_cap_saturates), BUT it special-cases attempt<=1 to return 0.0 (retry.p +SERDE-21 # deviation: The codec is deliberately validation-free (codec.py module docstring: 'performs no schema checks or scalar coercion'); cross-shape scalars pass through unchanged ('5'->str '5', 1.5 +SERDE-27 # deviation: The response-decode lifecycle/error contract is fully implemented and tested (closes the response on every path, surfaces a missing body as a DeserializationError naming the target +SSE-20 # deviation: Confirmed: SseEvent is a frozen(slots) dataclass (immutable) whose payload is an immutable joined string rather than a defensively-copied data list (consequence of SSE-8), so there +SSE-38 # deviation: The core parser conforms — it surfaces the server retry hint and each event's raw per-block id, no longer persists a last-event-id across events (SSE-16), and sets no request header. Reconnection and Last-Event-ID continuity are provided only by the separable, opt-in SseConnection/AsyncSseConnection value-add layer that a caller must explicitly choose, not by the core parse path. +SSE-8 # deviation: Confirmed: _data_lines accumulates consecutive data lines in wire order but they are joined with a single '\n' into an immutable SseEvent.data string at dispatch rather than expose +TRANSPORT-4 # deviation: The cancel-flag clause is honored and tested (read timeout maps to ServiceResponseTimeoutError, never RequestCancelledError, classified as timeout), but the port deliberately devia diff --git a/docs/write-a-paging-strategy.md b/docs/write-a-paging-strategy.md new file mode 100644 index 0000000..145f114 --- /dev/null +++ b/docs/write-a-paging-strategy.md @@ -0,0 +1,121 @@ +# Writing a pagination strategy + +A `PaginationStrategy` teaches the paginator one API's pagination convention: +given a decoded response, it returns a `Page` — the items on this page plus the +request that reaches the next page. The paginator does the I/O (each page fetch +runs through the full pipeline, so retry, auth, redirect, and tracing apply to +every page); the strategy is a pure translation with no side effects. + +`core` ships three that cover the common conventions — `CursorStrategy`, +`PageNumberStrategy`, and `LinkHeaderStrategy`. You write your own when an API +paginates in a way none of them fit. + +## The seam + +```python +from __future__ import annotations + +from dexpace.sdk.core.http.request import Request +from dexpace.sdk.core.pagination import HasHeaders, Page, PaginationStrategy +``` + +`PaginationStrategy[T]` has one method: + +```python +def parse( + self, + response: HasHeaders, + payload: object, + template_request: Request, +) -> Page[T]: ... +``` + +- `response` is a header-only view of the response (for reading a `Link` header, + say); its body is already decoded into `payload`. +- `payload` is the decoded body — usually a `dict` or `list`, or `None` for an + empty body. +- `template_request` is the request that produced this page; you derive the next + page's request from it so the auth headers, path, and method survive. + +A `Page` carries `items`, a `next_request` (`None` at the end of the sequence), +an optional `prev_request`, and `raw` (the originating response, kept so the +page closes the connection deterministically). + +## Prefer composing over a built-in + +The wire-exact next-request splice is the subtle part: replacing one query +parameter must preserve every untouched parameter verbatim, byte for byte, with +no lossy `parse_qs`/`urlencode` round-trip (**PAGE-21**). The built-in +strategies already do this correctly, so the strongest pattern — and the one the +generated petstore client uses — is to delegate the page mechanics to a built-in +and only add your decode on top: + +```python +from __future__ import annotations + +from dataclasses import dataclass + +from dexpace.sdk.core.http.request import Request +from dexpace.sdk.core.pagination import CursorStrategy, HasHeaders, Page, PaginationStrategy + + +@dataclass(frozen=True, slots=True) +class TypedCursorStrategy: + """CursorStrategy plus a per-item decode into a typed model.""" + + inner: CursorStrategy[object] + + def parse(self, response: HasHeaders, payload: object, template_request: Request) -> Page[str]: + page = self.inner.parse(response, payload, template_request) + names = [str(item) for item in page.items] # decode raw items into your type here + return Page( + items=names, + next_request=page.next_request, # the wire-exact splice carries through + prev_request=page.prev_request, + raw=page.raw, + ) + + +strategy: PaginationStrategy[str] = TypedCursorStrategy( + CursorStrategy(items_field="data", cursor_response_field="next_cursor", cursor_param="cursor") +) +``` + +`LinkHeaderStrategy` follows an RFC 8288 `Link` `rel="next"` target +(**PAGINATION-06**), folding a header split across multiple lines before +parsing; `PageNumberStrategy` increments a page-index parameter. Reach for a +from-scratch `parse` only when your API fits none of these. + +## Contract checklist + +- **Purity.** `parse` performs no I/O and holds no mutable per-iteration state, + so one instance is reused verbatim by both the sync and async paginators. Keep + it a pure function of its three inputs. +- **Wire-exact next request (PAGE-21).** Derive `next_request` from + `template_request` so untouched query parameters, the path, headers, and body + survive unchanged. Compose over a built-in for the query splice rather than + re-encoding the query yourself. +- **Honest termination.** Return `next_request=None` the moment the sequence is + exhausted (no cursor, an empty page, the last page index). A malformed or + unparseable next target ends the walk by returning `None` rather than raising — + one bad value must not abort a walk that has already yielded good pages. +- **`Link` following (PAGINATION-06)**, if your convention is header-driven: + resolve a relative target against the template URL, and fold a multi-line + `Link` header before parsing. +- **Carry `raw`.** Pass the originating response through as `Page(raw=...)` so + the paginator can close the underlying connection. + +## Certifying your strategy + +Run the pagination conformance suite, which drives the strategies through the +paginator and checks item order, termination, and the wire-exact splice against +the golden `pagination_splice` corpus (**PAGE-21**) and the `Link`-header +vectors (**PAGINATION-06**): + +```bash +pytest packages/dexpace-sdk-core/tests/pagination/ +``` + +Add a parametrized case for your strategy alongside the built-ins, or point the +splice vectors at it, so its next-request derivation is held to the same +wire-exactness bar. diff --git a/docs/write-a-response-handler.md b/docs/write-a-response-handler.md new file mode 100644 index 0000000..9c20ebb --- /dev/null +++ b/docs/write-a-response-handler.md @@ -0,0 +1,100 @@ +# Writing a response handler + +A `ResponseHandler` is the last hop of a call: it takes the `Response` a +transport produced and turns it into the caller's result — a decoded model, raw +bytes, `None` for a `HEAD`, a streaming iterator — or raises. `core` ships two +handlers built around one pure decision, and you write your own when a call +needs to produce something other than "decode the 2xx body into a type". + +## The seam + +```python +from __future__ import annotations + +from typing import Any + +from dexpace.sdk.core.http.response import Response +from dexpace.sdk.core.serde import ResponseHandler, TypeRef + + +class RawBytesHandler(ResponseHandler): + """Return the response body as raw bytes, ignoring the requested type.""" + + def __call__(self, response: Response, response_type: type[Any] | TypeRef[Any], /) -> Any: + try: + body = response.body + return body.bytes() if body is not None else b"" + finally: + response.close() +``` + +The call is **positional-only** (`__call__(response, response_type, /)`), so a +handler stays interchangeable regardless of how it names its parameters, and the +return type is deliberately `Any` — a raw-download handler is just as valid a +`ResponseHandler` as a decoding one. A handler that decodes nothing may ignore +`response_type` entirely. + +## The pure decision the built-ins share + +`classify_status` is the single pure classification the shipped handlers are +built over. Given a status it returns a `ResponseDisposition` and touches +nothing else — it never reads the body, closes the response, or raises: + +```python +from dexpace.sdk.core.serde import ResponseDisposition, classify_status + + +def is_decodable(response: Response) -> bool: + return classify_status(response.status) is ResponseDisposition.SUCCESS +``` + +The three-way outcome keys off the status band (**HTTP-11** — the band +classification `is_success` / `is_error` this reads): + +- `SUCCESS` (2xx) — decode the body into the target type. +- `ERROR` (4xx/5xx) — raise the mapped typed error. +- `UNEXPECTED` (1xx/3xx) — neither a decodable success nor a mapped error; a + success-only handler raises a status-led error. + +The two shipped handlers act on that decision. `DecodeBody` decodes the body +into the target type regardless of status, closing the response on every path. +`DecodeSuccess` dispatches on `classify_status`: a 2xx delegates to `DecodeBody`, +a 4xx/5xx raises the mapped error through a `StatusErrorMap`, and a 1xx/3xx +raises a status-led error. Build a custom handler by reusing `classify_status` +for the decision and delegating the decode to a `DecodeBody` you hold, so the +"decode a 2xx body" behaviour is defined once. + +## Contract checklist + +- **Close the response on every path** — clean return, decode failure, and + raised error alike. The handler owns the response's lifetime; a leaked body + pins a transport connection. `response.body.bytes()` / `.string()` already + consume and close the body, but call `response.close()` in a `finally` so an + early raise still releases it. +- **Positional-only call signature.** Keep `__call__(response, response_type, /)` + so your handler drops in wherever a `ResponseHandler` is expected. +- **Respect the status bands (HTTP-11).** If your handler distinguishes success + from error, drive that split through `classify_status` rather than + hand-rolling status arithmetic, so a vendor or out-of-range status classifies + the same way everywhere. +- **Raise the right taxonomy.** A 4xx/5xx should raise an `HttpResponseError` + (typically via a `StatusErrorMap`, which buffers a bounded, replayable copy of + the error body); a decode failure raises `DeserializationError`. Do not invent + a parallel error shape — see [`errors.md`](errors.md). +- **Single-consumption discipline.** Read the body once. If you need repeatable + reads (logging plus decode), wrap it in a `LoggableResponseBody` rather than + reading twice — a plain `ResponseBody` is single-use. + +## Certifying your handler + +Run the response-handler conformance suite, which exercises `classify_status`, +the 2xx/4xx/5xx/1xx-3xx dispatch, the close-on-every-path guarantee, and the +mapped-error path through a `StatusErrorMap`: + +```bash +pytest packages/dexpace-sdk-core/tests/serde/test_response_handler.py +``` + +Add a case for your handler there, asserting that it closes the response on both +the success and the raising path, so its lifecycle discipline is held to the +same bar as the shipped handlers. diff --git a/docs/write-a-serde.md b/docs/write-a-serde.md new file mode 100644 index 0000000..e3d8d11 --- /dev/null +++ b/docs/write-a-serde.md @@ -0,0 +1,127 @@ +# Writing a serde + +A `Serde` bundles the two directions of one wire format — encode a Python value +to bytes, decode bytes back to a value — behind a single injection point. +`core` ships `JSON_SERDE`; you implement your own when a service speaks a +different format (CBOR, MessagePack, form-encoded, a bespoke framing) and you +want the codec, response handlers, and request-body helpers to round-trip +through it unchanged. + +The serde SPI is a pure structural seam: it carries no dedicated requirement-ID +in the spec index, so its checklist is the Protocol's own contract, and its +certification is the round-trip conformance suite rather than a marked +requirement. The contract is small and strict, and every clause below is +exercised by that suite. + +## The three Protocols + +```python +from __future__ import annotations + +from typing import Any, BinaryIO + +from dexpace.sdk.core.serde import Deserializer, Serde, Serializer +``` + +- `Serializer` turns a value into wire bytes, in three allocation profiles: + `serialize(value) -> str`, `serialize_to_bytes(value) -> bytes`, and + `serialize_to_stream(value, stream) -> None`. +- `Deserializer` is the inverse: `deserialize(str)`, `deserialize_bytes(bytes)`, + and `deserialize_stream(stream)`. +- `Serde` exposes the pair plus the format's media type: the `serializer`, + `deserializer`, and `media_type` properties. + +All three are `runtime_checkable` Protocols; implement them structurally. + +## A worked implementation + +```python +from __future__ import annotations + +import json +from typing import Any, BinaryIO + +from dexpace.sdk.core.http.common import MediaType + + +class JsonSerializer: + def serialize(self, value: Any) -> str: + return json.dumps(value) + + def serialize_to_bytes(self, value: Any) -> bytes: + return json.dumps(value).encode("utf-8") + + def serialize_to_stream(self, value: Any, stream: BinaryIO) -> None: + stream.write(self.serialize_to_bytes(value)) # does NOT close the stream + + +class JsonDeserializer: + def deserialize(self, value: str) -> Any: + return json.loads(value) + + def deserialize_bytes(self, value: bytes) -> Any: + return json.loads(value) + + def deserialize_stream(self, stream: BinaryIO) -> Any: + return json.loads(stream.read()) # reads to EOF, does NOT close + + +class JsonSerde: + def __init__(self) -> None: + self._serializer = JsonSerializer() + self._deserializer = JsonDeserializer() + + @property + def serializer(self) -> JsonSerializer: + return self._serializer + + @property + def deserializer(self) -> JsonDeserializer: + return self._deserializer + + @property + def media_type(self) -> MediaType: + return MediaType.parse("application/json") +``` + +## Contract checklist + +A correct serde satisfies every clause below. These are the Protocol's own +contract, and the conformance suite enforces each one: + +- **Round-trip fidelity.** For every value the format can represent, + `deserialize_bytes(serialize_to_bytes(v)) == v`, and the same holds for the + string and stream profiles. The three profiles agree with one another — + streaming a value and byte-serialising it produce the same wire content. +- **Stream ownership stays with the caller.** `serialize_to_stream` and + `deserialize_stream` read/write their target but **must not close it**. The + caller owns the stream's lifetime. `deserialize_stream` reads to EOF. +- **The serde declares its own `media_type`.** The SPI supplies no default, so a + request assembling a `Content-Type` reads the wire format off the serde + instead of assuming JSON. Return the media type your bytes actually carry. +- **Failures raise the serde taxonomy.** Wire-syntax and shape errors surface as + `SerializationError` (encode) or `DeserializationError` (decode); both are + `SerdeError` and `ValueError`, so existing `except ValueError` handlers keep + working. Do not swallow an encode/decode failure into a partial result. See + [`errors.md`](errors.md). +- **No hidden global state.** A serde instance is reused concurrently by the + codec and the response handlers, so keep it stateless (or internally + thread-safe). + +Your serde plugs into the rest of the stack by construction: pass it to +`DecodeSuccess(errors, serde=my_serde)`, to `ServiceCore(serde=my_serde)`, or +to the request-body helpers, and every decode/encode hop routes through it. + +## Certifying your serde + +The built-in serdes are held to a structural conformance suite that checks the +Protocol shape and the round-trip laws above. Point it at your implementation +(or add a parametrized case) and run it: + +```bash +pytest packages/dexpace-sdk-core/tests/serde/test_serde_conformance.py +``` + +Run the wider serde suite (`packages/dexpace-sdk-core/tests/serde/`) to cover +the codec, `Tristate` folding, and response-handler decode paths your serde +feeds. diff --git a/docs/write-a-transport.md b/docs/write-a-transport.md new file mode 100644 index 0000000..1a93b4b --- /dev/null +++ b/docs/write-a-transport.md @@ -0,0 +1,175 @@ +# Writing a transport + +A transport is the one piece of the toolkit the SDK does not ship: `core` +provides models, pipelines, retry, auth, and logging, but it never opens a +socket. You supply that by implementing the `HttpClient` Protocol (or its async +twin `AsyncHttpClient`) over whatever HTTP library you already trust, then +certify the adapter against the shared conformance kit. + +Four transports ship this way already — `urllib`/`asyncio` (the +zero-dependency reference), `httpx`, `aiohttp`, and `requests`. This guide is +how you add a fifth. + +## The seam + +```python +from __future__ import annotations + +from dexpace.sdk.core.client import HttpClient +from dexpace.sdk.core.http.request import Request +from dexpace.sdk.core.http.response import Response + + +class MyHttpClient(HttpClient): + """Adapt one HTTP library to the transport seam.""" + + def execute(self, request: Request) -> Response: + # 1. translate the immutable Request into the native library's shape, + # 2. send it, and + # 3. adapt the native response back into an SDK Response. + raise NotImplementedError +``` + +`HttpClient` is a single method: send one `Request`, return one `Response`. +The async twin is the same shape with `async def execute` returning an +`AsyncResponse`. Both are `runtime_checkable` Protocols, so you implement them +structurally — you do not have to inherit from anything, and subclassing is +shown above only for readability. + +The transport is deliberately dumb. It does **not** follow redirects, retry, +authenticate, log, or trace: those are pipeline policies that wrap the transport +from the outside. A transport that quietly retries or follows a `Location` +header will double-count against the retry budget and defeat the redirect +security battery, so the contract forbids it. + +## What a correct transport must do + +The requirement IDs below are the project's own spec vocabulary; each resolves +to a marked test in the suite. The rest of the contract is enforced by the +conformance battery described under "Certifying your adapter". + +### Response shape and inertia + +- Return a `Response` (never `None`) for any exchange that produced a status + line. The body is **not** pre-buffered — hand back a streaming + `ResponseBody` and let the caller drain and close it. +- Do not follow redirects. A `301`/`302`/`303`/`307`/`308` is returned as-is. +- Do not retry. One `execute` call is one native exchange. + +### Error mapping into the SDK taxonomy + +Translate native failures into the `errors` hierarchy so pipeline policies can +classify them uniformly (see [`errors.md`](errors.md)): + +| Native condition | SDK exception | Retryable? | +|------------------|---------------|------------| +| Connection refused / DNS failure | `ServiceRequestError` (a `NetworkError`) | yes, always | +| Connect-phase timeout | `ServiceRequestTimeoutError` | yes | +| Read-phase timeout | `ServiceResponseTimeoutError` | via response branch | +| Cooperative cancellation | `RequestCancelledError` | no — terminal | +| Other mid-exchange transport error | `ServiceRequestError` | yes | + +`NetworkError` also subclasses `OSError`, so an `except OSError:` site in caller +or library code keeps matching transport failures after you re-wrap them — never +re-wrap a `NetworkError` onto a non-network branch. A timeout and a cancellation +are both `OSError` subtypes, so a classifier must test the timeout type +**before** the cancellation type or it will misread one as the other. + +### Content-Type and adapter-computed framing + +- An explicit `Content-Type` on the request is authoritative; never overwrite + it. When the request omits one, derive it from the body's media type. +- Compute `Content-Length` yourself from the buffered body. A body whose length + is unknown up front frames as `Transfer-Encoding: chunked` with **no** + `Content-Length`. A transport that always buffers before sending knows the + exact length once buffering completes and uses `Content-Length` even then — it + must not fake chunked framing. +- An empty-body-bearing method (`POST` with an empty body) sends a zero-length + body; a bodyless `GET` sends no `Content-Length` at all. + +### Header hygiene + +- `Headers` compares names case-insensitively and stores them lower-cased + (**HEADER-04**); honour that on both directions. +- A header the model layer accepts but the native library rejects is dropped, + **counted, and logged** — never silently discarded. The drop log is bounded + by distinct name so a hostile stream of unique bad names cannot grow it + without limit. +- A malformed inbound header is dropped while obs-text (opaque byte) values are + preserved; a malformed inbound `Content-Length` or `Content-Type` is + downgraded rather than allowed to abort the whole response. + +### Request body semantics + +- A single-use body (`from_stream` / `from_iter`) is written to the wire + **exactly once** (**BODY-08** — a second consumption raises `RuntimeError`). +- A replayable body (`from_bytes` / `from_string` / `from_form` / `from_file`) + replays byte-identical across attempts (**BODY-21**). The retry policy calls + `to_replayable()` before the first send when a retry budget is positive, so + the transport itself only has to preserve whatever body it is handed — see + [`bodies.md`](bodies.md). +- A failure while buffering a body for replay surfaces cleanly, without leaking a + half-written native request. + +### Response lifecycle + +- If SDK-level adaptation of a native response fails, close the native response + before propagating. +- Closing a streamed `Response` cascades the close to the native handle; an + abandoned or partially-read stream still releases the underlying producer. + +### Timeouts, concurrency, status totality, and close + +- A sub-resolution timeout clamps up to the adapter's smallest granularity + rather than truncating to zero. Per-call timeouts do not bleed between + concurrent calls, and `execute` is safe to call from multiple threads + (or, for the async twin, concurrently on one event loop). +- `Status` is total over integers (**TRANSPORT-24**): any status code the peer + sends — in range, out of range, or vendor-specific — surfaces intact with its + body. No valid status ever fails adaptation. +- Close is ownership-aware: an adapter built over a caller-supplied (BYO) native + client must leave that client open when the adapter closes, and calling + `execute` after close raises. +- Proxy credentials never leak into logs or errors, and any proxy feature the + adapter cannot honour is discoverable rather than silently ignored. + +## Certifying your adapter + +The contract above is executable. It lives in the `dexpace-sdk-tck` package as a +transport-neutral battery that drives a *harness* — a small seam between the +test bodies and your concrete adapter. You certify an adapter by implementing +one harness and registering it; no test body changes. + +1. Implement `SyncTransportHarness` (and/or `AsyncTransportHarness`) from + `dexpace.sdk.tck.harness`. The harness programs the next native exchange + (`expect_response`, `expect_connect_timeout`, `expect_cancellation`, …) and + exposes what the adapter did (`sent_requests`, `attempt_count`, `drop_log`, + `native_responses`). + +2. Lower any capability flag your transport genuinely cannot simulate + (`supports_streaming`, `supports_cancellation`, + `supports_adaptation_failure`, …). The battery **skips** the facets a flag + turns off rather than failing them, so a minimal transport is not forced to + fake a native quirk it does not have. + +3. Register the harness from your package's own `tests/conftest.py`: + + ```python + from dexpace.sdk.tck.harness import SYNC_HARNESS_FACTORIES + + + def register() -> None: + from my_harness import MyTransportSyncHarness + + SYNC_HARNESS_FACTORIES.append(("my-transport", MyTransportSyncHarness)) + ``` + +Then run the battery against every registered adapter: + +```bash +pytest --pyargs dexpace.sdk.tck +``` + +A green run certifies your transport against the full contract above — success +shape, error taxonomy, framing, header hygiene, body semantics, response +lifecycle, timeouts, status totality, ownership-aware close, and proxy safety. diff --git a/examples/petstore/generate.py b/examples/petstore/generate.py new file mode 100644 index 0000000..03a6a29 --- /dev/null +++ b/examples/petstore/generate.py @@ -0,0 +1,454 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Deterministic generator for the petstore codegen canary. + +Reads the frozen OpenAPI document (``spec/petstore.openapi.json``) and renders +the checked-in SDK output under ``src/dexpace_petstore/_generated``: + +- ``operations.py`` — the operation table (pure ``Operation`` data), and +- ``sync_client.py`` / ``async_client.py`` — two projection-only facades + rendered from one method template with a sync/async mode switch. + +The output is deterministic (operations sorted by id, stable ordering, no +timestamps), so re-running the generator reproduces the checked-in files +byte-for-byte — the property the regen-diff test asserts. Run it directly to +rewrite the checked-in files: + + python examples/petstore/generate.py + +`render_all` is the pure library entry point (path -> content) the regen test +compares against the checked-in tree without touching the filesystem. +""" + +from __future__ import annotations + +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +_HEADER = ( + "# Copyright (c) 2026 dexpace and Omar Aljarrah.\n" + "# Licensed under the MIT License. See LICENSE.md in the repository root for details.\n" +) + +#: HTTP methods recognised in a path item, in OpenAPI order. +_HTTP_METHODS = ("get", "put", "post", "delete", "patch") + +#: Matches a single ``{name}`` path placeholder. +_PLACEHOLDER = re.compile(r"\{([^{}]+)\}") + +_SPEC_PATH = Path(__file__).resolve().parent / "spec" / "petstore.openapi.json" +_OUT_DIR = Path(__file__).resolve().parent / "src" / "dexpace_petstore" / "_generated" + + +@dataclass(frozen=True, slots=True) +class OpSpec: + """One operation drawn from the frozen document, normalised for rendering. + + Attributes: + operation_id: The stable method / constant name. + method: The HTTP method, upper-cased (``"GET"``, ``"PATCH"``). + path: The path template, ``{name}`` placeholders intact. + kind: The facade shape — ``"unary"``, ``"paginate"``, or ``"events"``. + path_params: The ``{name}`` placeholders, in path order. + returns: The response model name for a unary op, else ``None``. + body_param: The request-body parameter name for a unary op, else + ``None``. + body_model: The request-body model name, else ``None``. + item_model: The page item model for a paginate op, else ``None``. + strategy: The pagination-strategy binder symbol, else ``None``. + event_model: The event model for an events op, else ``None``. + mapper: The SSE-mapper binder symbol, else ``None``. + auth: The operation-level auth alternatives, each a ``(scheme, scopes)`` + pair; empty when the op declares no requirement. + """ + + operation_id: str + method: str + path: str + kind: str + path_params: tuple[str, ...] + returns: str | None + body_param: str | None + body_model: str | None + item_model: str | None + strategy: str | None + event_model: str | None + mapper: str | None + auth: tuple[tuple[str, tuple[str, ...]], ...] + + +def load_spec(spec_path: Path = _SPEC_PATH) -> dict[str, object]: + """Parse and return the frozen OpenAPI document. + + Args: + spec_path: Path to the JSON document. Defaults to the checked-in spec. + + Returns: + The parsed document as a plain dictionary. + """ + parsed: dict[str, object] = json.loads(spec_path.read_text(encoding="utf-8")) + return parsed + + +def collect_operations(spec: dict[str, object]) -> list[OpSpec]: + """Extract every operation from the document, sorted by operation id. + + Args: + spec: The parsed OpenAPI document. + + Returns: + The operations in a stable, id-sorted order so rendering is + deterministic. + """ + paths = spec.get("paths", {}) + assert isinstance(paths, dict) + ops: list[OpSpec] = [] + for path, item in paths.items(): + assert isinstance(item, dict) + for method in _HTTP_METHODS: + entry = item.get(method) + if entry is not None: + assert isinstance(entry, dict) + ops.append(_op_from_entry(path, method, entry)) + return sorted(ops, key=lambda op: op.operation_id) + + +def _op_from_entry(path: str, method: str, entry: dict[str, object]) -> OpSpec: + """Normalise one path-item operation into an `OpSpec`.""" + ext = entry.get("x-dexpace", {}) + assert isinstance(ext, dict) + body = ext.get("body") + body_map = body if isinstance(body, dict) else {} + return OpSpec( + operation_id=str(entry["operationId"]), + method=method.upper(), + path=path, + kind=str(ext["kind"]), + path_params=tuple(_PLACEHOLDER.findall(path)), + returns=_opt_str(ext.get("returns")), + body_param=_opt_str(body_map.get("param")), + body_model=_opt_str(body_map.get("model")), + item_model=_opt_str(ext.get("item_model")), + strategy=_opt_str(ext.get("strategy")), + event_model=_opt_str(ext.get("event_model")), + mapper=_opt_str(ext.get("mapper")), + auth=_auth_of(ext.get("auth")), + ) + + +def _opt_str(value: object) -> str | None: + """Return ``value`` as a string, or ``None`` when it is absent.""" + return None if value is None else str(value) + + +def _auth_of(value: object) -> tuple[tuple[str, tuple[str, ...]], ...]: + """Normalise the ``auth`` extension into ``(scheme, scopes)`` pairs.""" + if not isinstance(value, list): + return () + out: list[tuple[str, tuple[str, ...]]] = [] + for alt in value: + assert isinstance(alt, dict) + scopes = alt.get("scopes", []) + assert isinstance(scopes, list) + out.append((str(alt["scheme"]), tuple(str(s) for s in scopes))) + return tuple(out) + + +_OPERATIONS_DOC = ( + '"""Operation table for the petstore canary — generated; do not edit.\n' + "\n" + "Rendered from the frozen OpenAPI document by ``examples/petstore/generate.py``.\n" + "Pure data: one ``Operation`` declaration per API operation, imported verbatim\n" + 'by both the sync and async facades.\n"""\n' +) + +_SYNC_FACADE_DOC = ( + '"""Synchronous petstore facade — generated; do not edit.\n' + "\n" + "Rendered from the frozen OpenAPI document by ``examples/petstore/generate.py``.\n" + "A projection-only wrapper: every method binds its arguments into an\n" + "``OperationInput`` and delegates to the shared ``ServiceCore`` executor,\n" + 'carrying no logic of its own.\n"""\n' +) + +_ASYNC_FACADE_DOC = ( + '"""Asynchronous petstore facade — generated; do not edit.\n' + "\n" + "The async twin of the sync facade. Rendered from the same method template by\n" + "``examples/petstore/generate.py`` with the async mode switch, so it normalises\n" + "to the sync facade's AST under the parity gate. A projection-only wrapper over\n" + 'the shared ``AsyncServiceCore`` executor.\n"""\n' +) + +_SYNC_CLASS_DOC = '"""Synchronous petstore client — a projection over ``ServiceCore``."""' +_ASYNC_CLASS_DOC = '"""Asynchronous petstore client — a projection over ``AsyncServiceCore``."""' + + +def render_operations(ops: list[OpSpec]) -> str: + """Render the operation-table module from the collected operations.""" + consts = [op.operation_id.upper() for op in ops] + all_line = "__all__ = [" + ", ".join(f'"{c}"' for c in consts) + "]" + imports = "from dexpace.sdk.core.codegen import " + imports += ( + "AuthDescriptor, AuthRequirement, Operation" if any(op.auth for op in ops) else "Operation" + ) + body: list[str] = [_HEADER, "\n", _OPERATIONS_DOC, "\nfrom __future__ import annotations\n\n"] + body.append(imports + "\nfrom dexpace.sdk.core.http.request import Method\n\n") + body.append(all_line + "\n") + auth_lines = [_auth_descriptor(op) for op in ops if op.auth] + if auth_lines: + body.append("\n" + "\n".join(auth_lines) + "\n") + body.append("\n" + "\n".join(_operation_line(op) for op in ops) + "\n") + return "".join(body) + + +def _auth_descriptor(op: OpSpec) -> str: + """Render the module-private auth-descriptor binding for one operation.""" + reqs = ", ".join(_render_requirement(scheme, scopes) for scheme, scopes in op.auth) + return f"{_auth_name(op)} = AuthDescriptor.of({reqs})" + + +def _auth_name(op: OpSpec) -> str: + """Return the module-private auth-descriptor variable name for an operation.""" + return f"_{op.operation_id.upper()}_AUTH" + + +def _render_requirement(scheme: str, scopes: tuple[str, ...]) -> str: + """Render one ``AuthRequirement`` literal, scopes included when present.""" + if not scopes: + return f'AuthRequirement("{scheme}")' + items = ", ".join(f'"{s}"' for s in scopes) + tail = "," if len(scopes) == 1 else "" + return f'AuthRequirement("{scheme}", ({items}{tail}))' + + +def _operation_line(op: OpSpec) -> str: + """Render one ``Operation`` declaration line.""" + parts = [ + f'name="{op.operation_id}"', + f"method=Method.{op.method}", + f'path="{op.path}"', + ] + if op.auth: + parts.append(f"auth={_auth_name(op)}") + return f"{op.operation_id.upper()} = Operation({', '.join(parts)})" + + +def render_facade(ops: list[OpSpec], *, mode: str) -> str: + """Render one facade module (``mode`` is ``"sync"`` or ``"async"``).""" + async_ = mode == "async" + cls = "AsyncPetStoreClient" if async_ else "PetStoreClient" + core_t = "AsyncServiceCore" if async_ else "ServiceCore" + doc = _ASYNC_FACADE_DOC if async_ else _SYNC_FACADE_DOC + class_doc = _ASYNC_CLASS_DOC if async_ else _SYNC_CLASS_DOC + preamble = [_HEADER, "\n", doc, "\n", _facade_imports(ops, mode), "\n"] + preamble.append(f'__all__ = ["{cls}"]\n\n\n') + head = [ + f"class {cls}:", + f" {class_doc}", + "", + ' __slots__ = ("_core",)', + "", + f" def __init__(self, core: {core_t}) -> None:", + " self._core = core", + ] + blocks = ["\n".join(head)] + blocks += [_render_method(op, mode) for op in ops] + blocks.append(_render_lifecycle(mode)) + return "".join(preamble) + "\n\n".join(blocks) + "\n" + + +def _facade_imports(ops: list[OpSpec], mode: str) -> str: + """Render the full import block (runtime + ``TYPE_CHECKING``) for a facade.""" + async_ = mode == "async" + support = sorted( + ({"json_body"} if any(op.body_param for op in ops) else set()) + | {op.strategy for op in ops if op.strategy} + | {op.mapper for op in ops if op.mapper} + ) + runtime_models = sorted({op.returns for op in ops if op.returns}) + tc_models = sorted( + ( + {op.body_model for op in ops if op.body_model} + | {op.item_model for op in ops if op.item_model} + | {op.event_model for op in ops if op.event_model} + ) + - set(runtime_models) + ) + runtime = [ + "from __future__ import annotations\n\n", + "from typing import TYPE_CHECKING, Self\n\n", + "from dexpace.sdk.core.codegen import OperationInput\n\n", + f"from .._support import {', '.join(support)}\n", + f"from ..models import {', '.join(runtime_models)}\n", + "from . import operations\n", + ] + return "".join(runtime) + _facade_tc_imports(ops, async_, tc_models) + + +def _facade_tc_imports(ops: list[OpSpec], async_: bool, tc_models: list[str]) -> str: + """Render the ``if TYPE_CHECKING:`` import block for a facade.""" + core_t = "AsyncServiceCore" if async_ else "ServiceCore" + third = [f" from dexpace.sdk.core.codegen import {core_t}\n"] + if any(op.kind == "events" for op in ops): + sse = "AsyncTypedSseConnection" if async_ else "TypedSseConnection" + third.append(f" from dexpace.sdk.core.http.sse.typed import {sse}\n") + if any(op.kind == "paginate" for op in ops): + pag = "AsyncPaginator" if async_ else "Paginator" + third.append(f" from dexpace.sdk.core.pagination import {pag}\n") + block = [ + "\nif TYPE_CHECKING:\n", + " from types import TracebackType\n\n", + "".join(third), + ] + if tc_models: + block.append(f"\n from ..models import {', '.join(tc_models)}\n") + return "".join(block) + + +def _render_method(op: OpSpec, mode: str) -> str: + """Dispatch to the per-kind method renderer.""" + if op.kind == "paginate": + return _paginate_method(op, mode) + if op.kind == "events": + return _events_method(op, mode) + return _unary_method(op, mode) + + +def _unary_method(op: OpSpec, mode: str) -> str: + """Render a unary (single request/response) method.""" + async_kw = "async " if mode == "async" else "" + await_kw = "await " if mode == "async" else "" + sig = f" {async_kw}def {op.operation_id}({_unary_params(op)}) -> {op.returns}:" + args = [ + f" operations.{op.operation_id.upper()},", + f" {_operation_input(op)},", + f" response_type={op.returns},", + ] + lines = [sig, f" return {await_kw}self._core.execute(", *args, " )"] + return "\n".join(lines) + + +def _unary_params(op: OpSpec) -> str: + """Render a unary method's parameter list (path params, then any body).""" + params = ["self", *(f"{name}: str" for name in op.path_params)] + if op.body_param is not None: + params.append(f"{op.body_param}: {op.body_model}") + return ", ".join(params) + + +def _operation_input(op: OpSpec) -> str: + """Render the ``OperationInput(...)`` construction for a unary method.""" + args: list[str] = [] + if op.path_params: + pairs = ", ".join(f'"{name}": {name}' for name in op.path_params) + args.append(f"path_params={{{pairs}}}") + if op.body_param is not None: + args.append(f"body=json_body({op.body_param})") + return f"OperationInput({', '.join(args)})" + + +def _paginate_method(op: OpSpec, mode: str) -> str: + """Render a paginate method returning the certified paginator.""" + pag = "AsyncPaginator" if mode == "async" else "Paginator" + sig = f" def {op.operation_id}(self, *, max_pages: int | None = None) -> {pag}[{op.item_model}]:" + lines = [ + sig, + " return self._core.paginate(", + f" operations.{op.operation_id.upper()},", + " OperationInput(),", + f" {op.strategy},", + " max_pages=max_pages,", + " )", + ] + return "\n".join(lines) + + +def _events_method(op: OpSpec, mode: str) -> str: + """Render an events method returning the certified typed SSE connection.""" + sse = "AsyncTypedSseConnection" if mode == "async" else "TypedSseConnection" + sig = f" def {op.operation_id}(self) -> {sse}[{op.event_model}]:" + lines = [ + sig, + " return self._core.events(", + f" operations.{op.operation_id.upper()},", + " OperationInput(),", + f" {op.mapper},", + " )", + ] + return "\n".join(lines) + + +def _render_lifecycle(mode: str) -> str: + """Render the context-manager / close methods for a facade.""" + if mode == "async": + return ( + " async def aclose(self) -> None:\n" + " await self._core.aclose()\n\n" + " async def __aenter__(self) -> Self:\n" + " return self\n\n" + " async def __aexit__(\n" + " self,\n" + " exc_type: type[BaseException] | None,\n" + " exc: BaseException | None,\n" + " tb: TracebackType | None,\n" + " ) -> None:\n" + " await self.aclose()" + ) + return ( + " def close(self) -> None:\n" + " self._core.close()\n\n" + " def __enter__(self) -> Self:\n" + " return self\n\n" + " def __exit__(\n" + " self,\n" + " exc_type: type[BaseException] | None,\n" + " exc: BaseException | None,\n" + " tb: TracebackType | None,\n" + " ) -> None:\n" + " self.close()" + ) + + +def render_all(spec_path: Path = _SPEC_PATH) -> dict[str, str]: + """Render every generated file as ``relative path -> content``. + + Args: + spec_path: Path to the frozen document. + + Returns: + A mapping from each output file's name to its full rendered text. Pure: + it touches no filesystem beyond reading the spec. + """ + ops = collect_operations(load_spec(spec_path)) + return { + "operations.py": render_operations(ops), + "sync_client.py": render_facade(ops, mode="sync"), + "async_client.py": render_facade(ops, mode="async"), + } + + +def write_all(spec_path: Path = _SPEC_PATH, out_dir: Path = _OUT_DIR) -> None: + """Render and write every generated file into ``out_dir``. + + Args: + spec_path: Path to the frozen document. + out_dir: Directory the ``_generated`` modules are written into. + """ + for name, content in render_all(spec_path).items(): + (out_dir / name).write_text(content, encoding="utf-8") + + +def main() -> int: + """Rewrite the checked-in generated files. Returns a process exit code.""" + write_all() + print(f"generated {len(render_all())} files into {_OUT_DIR}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/petstore/spec/petstore.openapi.json b/examples/petstore/spec/petstore.openapi.json new file mode 100644 index 0000000..b13d8d9 --- /dev/null +++ b/examples/petstore/spec/petstore.openapi.json @@ -0,0 +1,115 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Dexpace Petstore Canary", + "version": "1.0.0", + "description": "A small, frozen OpenAPI document driving the petstore codegen canary. It is a fixture, not a real service: it exercises paginate, SSE events, a merge-patch body with three-valued fields, typed status errors, and a tiered auth requirement end to end through the generated facades." + }, + "x-dexpace-codegen": { + "package": "dexpace_petstore", + "client_class": "PetStoreClient", + "async_client_class": "AsyncPetStoreClient" + }, + "paths": { + "/pets": { + "get": { + "operationId": "list_pets", + "summary": "List pets, paginating by opaque cursor.", + "x-dexpace": { + "kind": "paginate", + "item_model": "Pet", + "strategy": "PET_PAGE_STRATEGY" + }, + "responses": { + "200": {"description": "A page of pets plus the next cursor."} + } + } + }, + "/pets/events": { + "get": { + "operationId": "watch_pets", + "summary": "Stream pet lifecycle events over Server-Sent Events.", + "x-dexpace": { + "kind": "events", + "event_model": "PetEvent", + "mapper": "PET_EVENT_MAPPER" + }, + "responses": { + "200": {"description": "An SSE stream of pet events."} + } + } + }, + "/pets/{pet_id}": { + "get": { + "operationId": "get_pet", + "summary": "Fetch one pet by id.", + "parameters": [ + {"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "x-dexpace": { + "kind": "unary", + "returns": "Pet", + "auth": [{"scheme": "bearer", "scopes": ["pets:read"]}] + }, + "responses": { + "200": {"description": "The requested pet."}, + "404": {"description": "No pet with that id."} + } + }, + "patch": { + "operationId": "update_pet", + "summary": "Apply a merge-patch update to a pet.", + "parameters": [ + {"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "requestBody": { + "required": true, + "content": { + "application/merge-patch+json": { + "schema": {"$ref": "#/components/schemas/PetPatch"} + } + } + }, + "x-dexpace": { + "kind": "unary", + "returns": "Pet", + "body": {"param": "patch", "model": "PetPatch"} + }, + "responses": { + "200": {"description": "The updated pet."}, + "404": {"description": "No pet with that id."} + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "tag": {"type": ["string", "null"]} + } + }, + "PetEvent": { + "type": "object", + "required": ["kind", "pet_id"], + "properties": { + "kind": {"type": "string"}, + "pet_id": {"type": "string"} + } + }, + "PetPatch": { + "type": "object", + "description": "A merge-patch body: an omitted field leaves the target unchanged, an explicit null clears it, and a value sets it.", + "properties": { + "name": {"type": ["string", "null"]}, + "tag": {"type": ["string", "null"]}, + "weight_kg": {"type": ["number", "null"]} + } + } + } + } +} diff --git a/examples/petstore/src/dexpace_petstore/__init__.py b/examples/petstore/src/dexpace_petstore/__init__.py new file mode 100644 index 0000000..b917702 --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/__init__.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Generated petstore SDK — the standing codegen canary. + +This package is produced from a frozen OpenAPI document (``spec/``) by the +deterministic generator (``generate.py``). The `_generated` subpackage holds the +regen-checked output — one operation table and two projection-only facades. The +hand-authored siblings (`models`, `errors`, `_support`) are the small runtime +shim a generated SDK ships alongside its facades: typed models, the status → +typed-error map, and the body / pagination / SSE binders the facades reference. + +Nothing here is hand-edited after generation; a CI-style regen-diff test detects +any drift between the checked-in `_generated` files and a fresh generator run. +""" + +from __future__ import annotations + +from ._generated.async_client import AsyncPetStoreClient +from ._generated.sync_client import PetStoreClient +from .errors import PETSTORE_ERRORS, PetNotFoundError, PetStoreError +from .models import Pet, PetEvent, PetPatch + +__all__ = [ + "PETSTORE_ERRORS", + "AsyncPetStoreClient", + "Pet", + "PetEvent", + "PetNotFoundError", + "PetPatch", + "PetStoreClient", + "PetStoreError", +] diff --git a/examples/petstore/src/dexpace_petstore/_generated/__init__.py b/examples/petstore/src/dexpace_petstore/_generated/__init__.py new file mode 100644 index 0000000..43e1d1f --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/_generated/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Generated output of the petstore codegen canary — do not hand-edit. + +Every module here is produced by ``examples/petstore/generate.py`` from the +frozen OpenAPI document. A regen-diff test re-runs the generator and fails on any +difference, so edits belong in the generator or the spec, never here. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/examples/petstore/src/dexpace_petstore/_generated/async_client.py b/examples/petstore/src/dexpace_petstore/_generated/async_client.py new file mode 100644 index 0000000..2c9ff9e --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/_generated/async_client.py @@ -0,0 +1,83 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Asynchronous petstore facade — generated; do not edit. + +The async twin of the sync facade. Rendered from the same method template by +``examples/petstore/generate.py`` with the async mode switch, so it normalises +to the sync facade's AST under the parity gate. A projection-only wrapper over +the shared ``AsyncServiceCore`` executor. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Self + +from dexpace.sdk.core.codegen import OperationInput + +from .._support import PET_EVENT_MAPPER, PET_PAGE_STRATEGY, json_body +from ..models import Pet +from . import operations + +if TYPE_CHECKING: + from types import TracebackType + + from dexpace.sdk.core.codegen import AsyncServiceCore + from dexpace.sdk.core.http.sse.typed import AsyncTypedSseConnection + from dexpace.sdk.core.pagination import AsyncPaginator + + from ..models import PetEvent, PetPatch + +__all__ = ["AsyncPetStoreClient"] + + +class AsyncPetStoreClient: + """Asynchronous petstore client — a projection over ``AsyncServiceCore``.""" + + __slots__ = ("_core",) + + def __init__(self, core: AsyncServiceCore) -> None: + self._core = core + + async def get_pet(self, pet_id: str) -> Pet: + return await self._core.execute( + operations.GET_PET, + OperationInput(path_params={"pet_id": pet_id}), + response_type=Pet, + ) + + def list_pets(self, *, max_pages: int | None = None) -> AsyncPaginator[Pet]: + return self._core.paginate( + operations.LIST_PETS, + OperationInput(), + PET_PAGE_STRATEGY, + max_pages=max_pages, + ) + + async def update_pet(self, pet_id: str, patch: PetPatch) -> Pet: + return await self._core.execute( + operations.UPDATE_PET, + OperationInput(path_params={"pet_id": pet_id}, body=json_body(patch)), + response_type=Pet, + ) + + def watch_pets(self) -> AsyncTypedSseConnection[PetEvent]: + return self._core.events( + operations.WATCH_PETS, + OperationInput(), + PET_EVENT_MAPPER, + ) + + async def aclose(self) -> None: + await self._core.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() diff --git a/examples/petstore/src/dexpace_petstore/_generated/operations.py b/examples/petstore/src/dexpace_petstore/_generated/operations.py new file mode 100644 index 0000000..2ab48c6 --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/_generated/operations.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Operation table for the petstore canary — generated; do not edit. + +Rendered from the frozen OpenAPI document by ``examples/petstore/generate.py``. +Pure data: one ``Operation`` declaration per API operation, imported verbatim +by both the sync and async facades. +""" + +from __future__ import annotations + +from dexpace.sdk.core.codegen import AuthDescriptor, AuthRequirement, Operation +from dexpace.sdk.core.http.request import Method + +__all__ = ["GET_PET", "LIST_PETS", "UPDATE_PET", "WATCH_PETS"] + +_GET_PET_AUTH = AuthDescriptor.of(AuthRequirement("bearer", ("pets:read",))) + +GET_PET = Operation(name="get_pet", method=Method.GET, path="/pets/{pet_id}", auth=_GET_PET_AUTH) +LIST_PETS = Operation(name="list_pets", method=Method.GET, path="/pets") +UPDATE_PET = Operation(name="update_pet", method=Method.PATCH, path="/pets/{pet_id}") +WATCH_PETS = Operation(name="watch_pets", method=Method.GET, path="/pets/events") diff --git a/examples/petstore/src/dexpace_petstore/_generated/sync_client.py b/examples/petstore/src/dexpace_petstore/_generated/sync_client.py new file mode 100644 index 0000000..9c893db --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/_generated/sync_client.py @@ -0,0 +1,83 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Synchronous petstore facade — generated; do not edit. + +Rendered from the frozen OpenAPI document by ``examples/petstore/generate.py``. +A projection-only wrapper: every method binds its arguments into an +``OperationInput`` and delegates to the shared ``ServiceCore`` executor, +carrying no logic of its own. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Self + +from dexpace.sdk.core.codegen import OperationInput + +from .._support import PET_EVENT_MAPPER, PET_PAGE_STRATEGY, json_body +from ..models import Pet +from . import operations + +if TYPE_CHECKING: + from types import TracebackType + + from dexpace.sdk.core.codegen import ServiceCore + from dexpace.sdk.core.http.sse.typed import TypedSseConnection + from dexpace.sdk.core.pagination import Paginator + + from ..models import PetEvent, PetPatch + +__all__ = ["PetStoreClient"] + + +class PetStoreClient: + """Synchronous petstore client — a projection over ``ServiceCore``.""" + + __slots__ = ("_core",) + + def __init__(self, core: ServiceCore) -> None: + self._core = core + + def get_pet(self, pet_id: str) -> Pet: + return self._core.execute( + operations.GET_PET, + OperationInput(path_params={"pet_id": pet_id}), + response_type=Pet, + ) + + def list_pets(self, *, max_pages: int | None = None) -> Paginator[Pet]: + return self._core.paginate( + operations.LIST_PETS, + OperationInput(), + PET_PAGE_STRATEGY, + max_pages=max_pages, + ) + + def update_pet(self, pet_id: str, patch: PetPatch) -> Pet: + return self._core.execute( + operations.UPDATE_PET, + OperationInput(path_params={"pet_id": pet_id}, body=json_body(patch)), + response_type=Pet, + ) + + def watch_pets(self) -> TypedSseConnection[PetEvent]: + return self._core.events( + operations.WATCH_PETS, + OperationInput(), + PET_EVENT_MAPPER, + ) + + def close(self) -> None: + self._core.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() diff --git a/examples/petstore/src/dexpace_petstore/_support.py b/examples/petstore/src/dexpace_petstore/_support.py new file mode 100644 index 0000000..b57d53a --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/_support.py @@ -0,0 +1,120 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Runtime binders the generated facades reference. + +A generated SDK ships a small hand-authored shim next to its facades. This is +that shim for the petstore canary: + +- `json_body` encodes a typed model into a JSON `RequestBody`, folding the + `Tristate` fields of a merge-patch body (absent keys are dropped, ``NULL`` + becomes ``null``, `Present` values are written). +- `PET_PAGE_STRATEGY` is the pagination strategy for ``list_pets`` — a decoding + wrapper over the certified `CursorStrategy` that reconstructs each page's raw + item documents into typed `Pet` values. +- `PET_EVENT_MAPPER` is the SSE mapper for ``watch_pets`` — it stops on a + ``[DONE]`` sentinel and decodes every other frame's JSON into a `PetEvent`. + +The facades hold no logic of their own; they name these binders and delegate. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from dexpace.sdk.core.http.common import common_media_types +from dexpace.sdk.core.http.request import RequestBody +from dexpace.sdk.core.http.sse.typed import SseMapper, SseSignal +from dexpace.sdk.core.pagination import CursorStrategy, HasHeaders, Page, PaginationStrategy +from dexpace.sdk.core.serde import JSON_SERDE, Codec + +from .models import Pet, PetEvent + +if TYPE_CHECKING: + from dexpace.sdk.core.http.request import Request + +__all__ = ["PET_EVENT_MAPPER", "PET_PAGE_STRATEGY", "json_body"] + +#: The one shared codec instance the binders reuse. `Codec` is stateless and +#: thread-safe, so a single instance serves every model. +_CODEC: Codec = Codec() + + +def json_body(model: object) -> RequestBody: + """Encode a typed model into a JSON `RequestBody`. + + Args: + model: A dataclass model (e.g. a `PetPatch`). Its `Tristate` fields are + folded by the codec: absent keys are omitted, ``NULL`` is written as + ``null``, and `Present` values are written verbatim. + + Returns: + A replayable ``application/json`` request body carrying the encoded + document. + """ + document = _CODEC.encode(model) + data = JSON_SERDE.serializer.serialize_to_bytes(document) + return RequestBody.from_bytes(data, common_media_types.APPLICATION_JSON) + + +@dataclass(frozen=True, slots=True) +class _PetPageStrategy: + """Decoding pagination strategy: `CursorStrategy` plus per-item `Pet` decode. + + The certified `CursorStrategy` reads the item list and cursor out of the + response body but leaves items as raw documents. This wrapper delegates the + page mechanics to it, then reconstructs each raw item into a typed `Pet`, so + the paginator yields `Pet` values while the wire-splice and close-ledger + guarantees stay with the certified strategy. + """ + + inner: CursorStrategy[object] + + def parse(self, response: HasHeaders, payload: object, template_request: Request) -> Page[Pet]: + """Parse one response into a page of typed `Pet` items. + + Args: + response: The response, for header inspection by the inner strategy. + payload: The decoded response body (a mapping with a ``data`` list). + template_request: The request that produced ``response``. + + Returns: + A page whose items are decoded `Pet` values and whose next-request + splice is exactly the one the inner strategy derived. + """ + page = self.inner.parse(response, payload, template_request) + pets = [_CODEC.decode(item, Pet) for item in page.items] + return Page( + items=pets, + next_request=page.next_request, + prev_request=page.prev_request, + raw=page.raw, + ) + + +#: Pagination strategy for ``list_pets``: cursor pagination over a ``data`` array +#: with a ``next_cursor`` continuation, decoding each item into a `Pet`. +PET_PAGE_STRATEGY: PaginationStrategy[Pet] = _PetPageStrategy( + CursorStrategy(items_field="data", cursor_response_field="next_cursor", cursor_param="cursor") +) + + +def _pet_event_mapper(event: str, data: str) -> PetEvent | SseSignal: + """Map one SSE frame to a `PetEvent`, stopping on the ``[DONE]`` sentinel. + + Args: + event: The SSE event name (unused; every frame is a pet event). + data: The frame's data payload — a ``[DONE]`` marker or a JSON document. + + Returns: + `SseSignal.DONE` for the sentinel, otherwise the decoded `PetEvent`. + """ + if data == "[DONE]": + return SseSignal.DONE + document = JSON_SERDE.deserializer.deserialize_bytes(data.encode()) + return _CODEC.decode(document, PetEvent) + + +#: SSE mapper for ``watch_pets``: decode each frame into a `PetEvent`. +PET_EVENT_MAPPER: SseMapper[PetEvent] = _pet_event_mapper diff --git a/examples/petstore/src/dexpace_petstore/errors.py b/examples/petstore/src/dexpace_petstore/errors.py new file mode 100644 index 0000000..a7b3e31 --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/errors.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Typed error taxonomy for the petstore canary. + +`PETSTORE_ERRORS` is the `StatusErrorMap` the executor consults on a non-success +response: a 404 raises `PetNotFoundError`, and every other error status raises +the `PetStoreError` base. The service errors sit purely on the response branch +(they extend `HttpResponseError`, never `OSError`), so an ``except OSError:`` site +still only catches transport failures. +""" + +from __future__ import annotations + +from dexpace.sdk.core.codegen import StatusErrorMap +from dexpace.sdk.core.errors import HttpResponseError +from dexpace.sdk.core.http.response import Status + +from .models import Pet + +__all__ = ["PETSTORE_ERRORS", "PetNotFoundError", "PetStoreError"] + + +class PetStoreError(HttpResponseError[Pet]): + """Base class for every typed petstore response error.""" + + +class PetNotFoundError(PetStoreError): + """Raised for a 404 — no pet matched the requested id.""" + + +PETSTORE_ERRORS: StatusErrorMap = StatusErrorMap( + by_status={Status.NOT_FOUND: PetNotFoundError}, + default=PetStoreError, +) diff --git a/examples/petstore/src/dexpace_petstore/models.py b/examples/petstore/src/dexpace_petstore/models.py new file mode 100644 index 0000000..899790a --- /dev/null +++ b/examples/petstore/src/dexpace_petstore/models.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Typed models for the petstore canary. + +Frozen dataclasses the `Codec` reconstructs from wire documents and encodes back. +`PetPatch` is the merge-patch body: each field is a `Tristate`, so the three wire +states an update must distinguish — omit the key (leave unchanged), send an +explicit ``null`` (clear), or send a value (set) — survive a full round trip +rather than collapsing into a single ``None``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dexpace.sdk.core.serde import ABSENT, Tristate + +__all__ = ["Pet", "PetEvent", "PetPatch"] + + +@dataclass(frozen=True, slots=True) +class Pet: + """A pet as returned by the service. + + Attributes: + id: The stable identifier. + name: The display name. + tag: An optional free-form tag; ``None`` when the pet carries none. + """ + + id: str + name: str + tag: str | None = None + + +@dataclass(frozen=True, slots=True) +class PetEvent: + """A single pet lifecycle event delivered over the SSE stream. + + Attributes: + kind: The event kind (e.g. ``"created"`` / ``"updated"``). + pet_id: The id of the pet the event concerns. + """ + + kind: str + pet_id: str + + +@dataclass(frozen=True, slots=True) +class PetPatch: + """A merge-patch update body. + + Every field defaults to `ABSENT`, so ``PetPatch(name=present("Rex"))`` sends + only ``name`` and leaves ``tag`` / ``weight_kg`` untouched. Passing ``NULL`` + for a field clears it on the wire. + + Attributes: + name: The new name, or ``NULL`` to clear it, or `ABSENT` to leave it. + tag: The new tag, or ``NULL`` to clear it, or `ABSENT` to leave it. + weight_kg: The new weight, or ``NULL`` to clear it, or `ABSENT`. + """ + + name: Tristate[str] = ABSENT + tag: Tristate[str] = ABSENT + weight_kg: Tristate[float] = ABSENT diff --git a/examples/petstore/tests/test_petstore_canary.py b/examples/petstore/tests/test_petstore_canary.py new file mode 100644 index 0000000..af82d9f --- /dev/null +++ b/examples/petstore/tests/test_petstore_canary.py @@ -0,0 +1,412 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""End-to-end canary for the generated petstore SDK against the head core. + +Drives the generated facades — sync and async — over an in-memory transport, so +the whole Tier-1 stack is exercised hermetically (no network). Each scenario +proves one certified capability reaches the caller through the generated facade: + +- paginate: ``list_pets`` walks two cursor pages and yields typed `Pet` items; +- events: ``watch_pets`` streams mapped `PetEvent`s and stops on ``[DONE]``; +- typed errors: a 404 raises the `StatusErrorMap`-mapped `PetNotFoundError`, and + another error status raises the map's default `PetStoreError`; +- auth tiers: the tiered resolver selects the operation-level requirement over + the client-level default, falls back to the client default when an operation + declares none, and fails loudly rather than falling through when a present + tier is unsatisfiable; +- Tristate PATCH: ``update_pet`` round-trips an absent / null / present body, + the three merge-patch states surviving the wire encode intact. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import pytest + +from dexpace.sdk.core.codegen import ( + AsyncServiceCore, + AuthDescriptor, + AuthRegistry, + AuthRequirement, + AuthResolutionError, + AuthScheme, + ServiceCore, +) +from dexpace.sdk.core.codegen._pure.options import AUTH_OPTION_KEY +from dexpace.sdk.core.http.common import Protocol +from dexpace.sdk.core.http.response import AsyncResponse, Response, Status +from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody +from dexpace.sdk.core.http.response.response_body import ResponseBody +from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline +from dexpace.sdk.core.pipeline.async_policy import AsyncPolicy +from dexpace.sdk.core.pipeline.policy import Policy +from dexpace.sdk.core.pipeline.stage import Stage +from dexpace.sdk.core.serde import NULL, present +from dexpace_petstore import ( + AsyncPetStoreClient, + PetNotFoundError, + PetStoreClient, + PetStoreError, +) +from dexpace_petstore.errors import PETSTORE_ERRORS +from dexpace_petstore.models import Pet, PetEvent, PetPatch + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from dexpace.sdk.core.http.request.request import Request + from dexpace.sdk.core.pipeline.context import PipelineContext + +_BASE = "https://api.example.com" + + +# --------------------------------------------------------------------------- # +# In-memory transports (the simplest fitting scripted-fake pattern) # +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class _Reply: + """A scripted reply: a status plus an optional body and headers.""" + + status: Status + body: bytes | None = None + headers: tuple[tuple[str, str], ...] = () + + +def _sync_response(reply: _Reply, request: Request) -> Response: + body = ResponseBody.from_bytes(reply.body) if reply.body is not None else None + response = Response(request=request, protocol=Protocol.HTTP_1_1, status=reply.status, body=body) + for name, value in reply.headers: + response = response.with_header(name, value) + return response + + +def _async_response(reply: _Reply, request: Request) -> AsyncResponse: + body = AsyncResponseBody.from_bytes(reply.body) if reply.body is not None else None + response = AsyncResponse( + request=request, protocol=Protocol.HTTP_1_1, status=reply.status, body=body + ) + for name, value in reply.headers: + response = response.with_header(name, value) + return response + + +class _FakeTransport: + """Sync transport replaying a script; a fresh response is built per call.""" + + def __init__(self, script: Sequence[_Reply]) -> None: + self._script = list(script) + self.requests: list[Request] = [] + self.close_calls = 0 + + def execute(self, request: Request) -> Response: + reply = self._script[min(len(self.requests), len(self._script) - 1)] + self.requests.append(request) + return _sync_response(reply, request) + + def close(self) -> None: + self.close_calls += 1 + + +class _AsyncFakeTransport: + """Async twin of `_FakeTransport`.""" + + def __init__(self, script: Sequence[_Reply]) -> None: + self._script = list(script) + self.requests: list[Request] = [] + self.aclose_calls = 0 + + async def execute(self, request: Request) -> AsyncResponse: + reply = self._script[min(len(self.requests), len(self._script) - 1)] + self.requests.append(request) + return _async_response(reply, request) + + async def aclose(self) -> None: + self.aclose_calls += 1 + + +class _OptionsProbe(Policy): + """Records ``ctx.options`` per exchange so the parked auth policy is visible.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self) -> None: + self.seen: list[Mapping[str, Any]] = [] + + def send(self, request: Request, ctx: PipelineContext) -> Response: + self.seen.append(ctx.options) + return self.next.send(request, ctx) + + +class _AsyncOptionsProbe(AsyncPolicy): + """Async twin of `_OptionsProbe`.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self) -> None: + self.seen: list[Mapping[str, Any]] = [] + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + self.seen.append(ctx.options) + return await self.next.send(request, ctx) + + +class _MarkerAuth(Policy): + """A stand-in auth policy: only parked in the options mapping, never run.""" + + STAGE = Stage.AUTH + __slots__ = () + + def send(self, request: Request, ctx: PipelineContext) -> Response: # pragma: no cover + raise NotImplementedError + + +class _AsyncMarkerAuth(AsyncPolicy): + """Async twin of `_MarkerAuth`.""" + + STAGE = Stage.AUTH + __slots__ = () + + async def send( + self, request: Request, ctx: PipelineContext + ) -> AsyncResponse: # pragma: no cover + raise NotImplementedError + + +def _pet_bytes(pet_id: str, name: str) -> bytes: + return json.dumps({"id": pet_id, "name": name}).encode() + + +_PAGE_1 = b'{"data": [{"id": "1", "name": "a"}, {"id": "2", "name": "b"}], "next_cursor": "c2"}' +_PAGE_2 = b'{"data": [{"id": "3", "name": "c"}], "next_cursor": null}' + + +def _sse_body() -> bytes: + frames = [ + json.dumps({"kind": "created", "pet_id": "1"}), + json.dumps({"kind": "updated", "pet_id": "1"}), + "[DONE]", + ] + return "".join(f"data: {frame}\n\n" for frame in frames).encode() + + +# --------------------------------------------------------------------------- # +# paginate # +# --------------------------------------------------------------------------- # + + +def test_paginate_yields_typed_pets_sync() -> None: + transport = _FakeTransport([_Reply(Status.OK, _PAGE_1), _Reply(Status.OK, _PAGE_2)]) + client = PetStoreClient(ServiceCore(base_url=_BASE, pipeline=Pipeline(transport))) + pets = list(client.list_pets()) + assert all(isinstance(pet, Pet) for pet in pets) + assert [pet.name for pet in pets] == ["a", "b", "c"] + assert len(transport.requests) == 2 + + +async def test_paginate_yields_typed_pets_async() -> None: + transport = _AsyncFakeTransport([_Reply(Status.OK, _PAGE_1), _Reply(Status.OK, _PAGE_2)]) + client = AsyncPetStoreClient( + AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) + ) + pets = [pet async for pet in client.list_pets()] + assert all(isinstance(pet, Pet) for pet in pets) + assert [pet.name for pet in pets] == ["a", "b", "c"] + assert len(transport.requests) == 2 + + +# --------------------------------------------------------------------------- # +# events # +# --------------------------------------------------------------------------- # + + +def test_events_streams_typed_pet_events_sync() -> None: + transport = _FakeTransport([_Reply(Status.OK, _sse_body())]) + client = PetStoreClient(ServiceCore(base_url=_BASE, pipeline=Pipeline(transport))) + with client.watch_pets() as stream: + events = list(stream) + assert events == [PetEvent("created", "1"), PetEvent("updated", "1")] + + +async def test_events_streams_typed_pet_events_async() -> None: + transport = _AsyncFakeTransport([_Reply(Status.OK, _sse_body())]) + client = AsyncPetStoreClient( + AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) + ) + async with client.watch_pets() as stream: + events = [event async for event in stream] + assert events == [PetEvent("created", "1"), PetEvent("updated", "1")] + + +# --------------------------------------------------------------------------- # +# typed errors via StatusErrorMap # +# --------------------------------------------------------------------------- # + + +def test_mapped_404_raises_typed_error_sync() -> None: + transport = _FakeTransport([_Reply(Status.NOT_FOUND, b'{"message": "nope"}')]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport), errors=PETSTORE_ERRORS) + client = PetStoreClient(core) + with pytest.raises(PetNotFoundError): + client.update_pet("7", PetPatch(name=present("Rex"))) + + +def test_unmapped_error_raises_default_sync() -> None: + transport = _FakeTransport([_Reply(Status.INTERNAL_SERVER_ERROR, b"boom")]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport), errors=PETSTORE_ERRORS) + client = PetStoreClient(core) + with pytest.raises(PetStoreError) as excinfo: + client.update_pet("7", PetPatch(name=present("Rex"))) + assert not isinstance(excinfo.value, PetNotFoundError) + + +async def test_mapped_404_raises_typed_error_async() -> None: + transport = _AsyncFakeTransport([_Reply(Status.NOT_FOUND, b'{"message": "nope"}')]) + core = AsyncServiceCore( + base_url=_BASE, pipeline=AsyncPipeline(transport), errors=PETSTORE_ERRORS + ) + client = AsyncPetStoreClient(core) + with pytest.raises(PetNotFoundError): + await client.update_pet("7", PetPatch(name=present("Rex"))) + + +# --------------------------------------------------------------------------- # +# tiered auth resolver over operation descriptors # +# --------------------------------------------------------------------------- # + + +def _registry() -> tuple[AuthRegistry[Policy], _MarkerAuth, _MarkerAuth]: + bearer, apikey = _MarkerAuth(), _MarkerAuth() + registry: AuthRegistry[Policy] = AuthRegistry( + schemes=( + AuthScheme("bearer", bearer, frozenset({"pets:read"})), + AuthScheme("apikey", apikey), + ) + ) + return registry, bearer, apikey + + +def test_auth_operation_tier_wins_over_client_default_sync() -> None: + registry, bearer, _ = _registry() + probe = _OptionsProbe() + transport = _FakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + core = ServiceCore( + base_url=_BASE, + pipeline=Pipeline(transport, policies=[probe]), + auth=AuthDescriptor.of(AuthRequirement("apikey")), + auth_registry=registry, + ) + PetStoreClient(core).get_pet("7") # get_pet declares an operation-level bearer requirement + assert probe.seen[-1][AUTH_OPTION_KEY] is bearer + + +def test_auth_falls_back_to_client_default_sync() -> None: + registry, _, apikey = _registry() + probe = _OptionsProbe() + transport = _FakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + core = ServiceCore( + base_url=_BASE, + pipeline=Pipeline(transport, policies=[probe]), + auth=AuthDescriptor.of(AuthRequirement("apikey")), + auth_registry=registry, + ) + PetStoreClient(core).update_pet("7", PetPatch(name=present("Rex"))) # no operation-level auth + assert probe.seen[-1][AUTH_OPTION_KEY] is apikey + + +def test_auth_present_but_unsatisfiable_fails_loudly_sync() -> None: + empty: AuthRegistry[Policy] = AuthRegistry() # no scheme satisfies the operation's bearer + transport = _FakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport), auth_registry=empty) + with pytest.raises(AuthResolutionError): + PetStoreClient(core).get_pet("7") + assert transport.requests == [] # resolution fails before any request is sent + + +async def test_auth_operation_tier_wins_over_client_default_async() -> None: + bearer, apikey = _AsyncMarkerAuth(), _AsyncMarkerAuth() + registry: AuthRegistry[AsyncPolicy] = AuthRegistry( + schemes=( + AuthScheme("bearer", bearer, frozenset({"pets:read"})), + AuthScheme("apikey", apikey), + ) + ) + probe = _AsyncOptionsProbe() + transport = _AsyncFakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + core = AsyncServiceCore( + base_url=_BASE, + pipeline=AsyncPipeline(transport, policies=[probe]), + auth=AuthDescriptor.of(AuthRequirement("apikey")), + auth_registry=registry, + ) + await AsyncPetStoreClient(core).get_pet("7") + assert probe.seen[-1][AUTH_OPTION_KEY] is bearer + + +# --------------------------------------------------------------------------- # +# Tristate PATCH round-trip # +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class _Captured: + """Decoded view of the merge-patch body a facade actually sent.""" + + document: dict[str, Any] = field(default_factory=dict) + patch: PetPatch = field(default_factory=PetPatch) + + +def _capture_patch(request: Request) -> _Captured: + from dexpace.sdk.core.serde import Codec + + assert request.body is not None + raw = b"".join(request.body.iter_bytes()) + document = json.loads(raw) + return _Captured(document=document, patch=Codec().decode(document, PetPatch)) + + +def test_tristate_patch_round_trips_all_three_states_sync() -> None: + transport = _FakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + client = PetStoreClient(ServiceCore(base_url=_BASE, pipeline=Pipeline(transport))) + # name -> present, tag -> explicit null, weight_kg -> absent (left at its default). + client.update_pet("7", PetPatch(name=present("Rex"), tag=NULL)) + captured = _capture_patch(transport.requests[0]) + assert captured.document == {"name": "Rex", "tag": None} # absent key omitted, null preserved + assert captured.patch == PetPatch(name=present("Rex"), tag=NULL) + + +async def test_tristate_patch_round_trips_all_three_states_async() -> None: + transport = _AsyncFakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + client = AsyncPetStoreClient( + AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) + ) + await client.update_pet("7", PetPatch(name=present("Rex"), tag=NULL)) + captured = _capture_patch(transport.requests[0]) + assert captured.document == {"name": "Rex", "tag": None} + assert captured.patch == PetPatch(name=present("Rex"), tag=NULL) + + +# --------------------------------------------------------------------------- # +# lifecycle: closing a facade closes the owned transport # +# --------------------------------------------------------------------------- # + + +def test_facade_close_cascades_to_owned_transport_sync() -> None: + transport = _FakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + with PetStoreClient(ServiceCore(base_url=_BASE, transport=transport)) as client: + assert client is not None + assert transport.close_calls == 1 + + +async def test_facade_close_cascades_to_owned_transport_async() -> None: + transport = _AsyncFakeTransport([_Reply(Status.OK, _pet_bytes("7", "Rex"))]) + async with AsyncPetStoreClient(AsyncServiceCore(base_url=_BASE, transport=transport)) as client: + assert client is not None + assert transport.aclose_calls == 1 diff --git a/examples/petstore/tests/test_petstore_parity.py b/examples/petstore/tests/test_petstore_parity.py new file mode 100644 index 0000000..86ab86c --- /dev/null +++ b/examples/petstore/tests/test_petstore_parity.py @@ -0,0 +1,86 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Blocking sync/async parity gate over the two generated facades. + +The generated facades are rendered from one method template with a sync/async +mode switch, so after the parity tool strips ``async``/``await`` and applies the +declared token map they must normalise to identical ASTs. This runs the gate in +BLOCKING mode over the checked-in facades (any residual drift fails) and, with a +seeded one-line drift in an in-memory scratch copy, proves the gate has teeth — +the real facades on disk are never modified. + +The token map extends the tool's default with the facade-specific renames +(``AsyncServiceCore`` -> ``ServiceCore``, ``AsyncPaginator`` -> ``Paginator``, +``AsyncTypedSseConnection`` -> ``TypedSseConnection``, ``AsyncPetStoreClient`` -> +``PetStoreClient``). The tool is loaded by file path, as it is not an installed +module. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_GENERATED = _REPO_ROOT / "examples" / "petstore" / "src" / "dexpace_petstore" / "_generated" +_SYNC = _GENERATED / "sync_client.py" +_ASYNC = _GENERATED / "async_client.py" + +#: Facade-specific renames layered onto the tool's default token map. +_FACADE_TOKENS = { + "AsyncServiceCore": "ServiceCore", + "AsyncPaginator": "Paginator", + "AsyncTypedSseConnection": "TypedSseConnection", + "AsyncPetStoreClient": "PetStoreClient", +} + + +def _load_parity() -> Any: + """Load ``tools/parity_check.py`` by file path. + + Returns: + The imported parity-check module. Typed ``Any`` because it is loaded + dynamically, not resolved as a static import. + + Raises: + ImportError: If the parity tool cannot be loaded. + """ + path = _REPO_ROOT / "tools" / "parity_check.py" + spec = importlib.util.spec_from_file_location("parity_check", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load the parity tool from {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _token_map(parity: Any) -> dict[str, str]: + token_map: dict[str, str] = dict(parity.DEFAULT_TOKEN_MAP) + token_map.update(_FACADE_TOKENS) + return token_map + + +def test_generated_facades_normalise_to_identical_asts() -> None: + parity = _load_parity() + result = parity.check_parity( + _SYNC, _ASYNC, token_map=_token_map(parity), mode=parity.Mode.BLOCKING + ) + assert not result.failed, result.diff + assert result.in_parity + + +def test_parity_gate_fails_on_seeded_drift() -> None: + parity = _load_parity() + sync_src = _SYNC.read_text(encoding="utf-8") + async_src = _ASYNC.read_text(encoding="utf-8") + # Seed a single semantic drift into a scratch copy only — never on disk. + drifted = async_src.replace("max_pages=max_pages,", "max_pages=None,", 1) + assert drifted != async_src + result = parity.check_parity_sources( + sync_src, drifted, token_map=_token_map(parity), mode=parity.Mode.BLOCKING + ) + assert result.failed diff --git a/examples/petstore/tests/test_petstore_regen.py b/examples/petstore/tests/test_petstore_regen.py new file mode 100644 index 0000000..ac4e594 --- /dev/null +++ b/examples/petstore/tests/test_petstore_regen.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Regen-diff guard for the petstore canary. + +Re-runs the deterministic generator over the frozen OpenAPI document and asserts +its output matches the checked-in ``_generated`` tree exactly. A hand-edit to any +generated file — or a generator change not reflected in the checked-in output — +fails here, so the canary can only be regenerated, never hand-patched. The +generator is loaded by file path (it is a script, not an installed module), the +same way the parity and public-surface tests load their sibling tools. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_PETSTORE = _REPO_ROOT / "examples" / "petstore" +_GENERATED = _PETSTORE / "src" / "dexpace_petstore" / "_generated" + + +def _load_generator() -> Any: + """Load ``examples/petstore/generate.py`` by file path. + + Returns: + The imported generator module. Typed ``Any`` because it is loaded + dynamically, not resolved as a static import. + + Raises: + ImportError: If the generator module cannot be loaded. + """ + path = _PETSTORE / "generate.py" + spec = importlib.util.spec_from_file_location("petstore_generate", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load the petstore generator from {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_regenerating_reproduces_checked_in_output() -> None: + rendered: dict[str, str] = _load_generator().render_all() + for name, content in rendered.items(): + checked_in = (_GENERATED / name).read_text(encoding="utf-8") + assert content == checked_in, ( + f"{name} is out of sync with the generator; re-run " + "`python examples/petstore/generate.py` (never hand-edit generated files)" + ) + + +def test_generator_accounts_for_every_generated_module() -> None: + rendered = set(_load_generator().render_all()) + on_disk = {path.name for path in _GENERATED.glob("*.py") if path.name != "__init__.py"} + assert rendered == on_disk diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/__init__.py index 6bdfee2..6531e2d 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/__init__.py @@ -7,15 +7,24 @@ ship in separate packages (`dexpace-sdk-http-stdlib`, `dexpace-sdk-http-httpx`, etc.) — `core` deliberately knows nothing about specific HTTP libraries. + +Two bridges cross the sync/async divide: `SyncToAsyncHttpClient` runs a blocking +transport from async code on a caller-supplied executor, and +`AsyncToSyncHttpClient` runs an async transport from sync code on a dedicated +background event-loop thread. """ from __future__ import annotations +from ._async_to_sync import AsyncToSyncHttpClient +from ._sync_to_async import SyncToAsyncHttpClient from .async_http_client import AsyncHttpClient, asyncio_sleep from .http_client import HttpClient __all__ = [ "AsyncHttpClient", + "AsyncToSyncHttpClient", "HttpClient", + "SyncToAsyncHttpClient", "asyncio_sleep", ] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/_async_to_sync.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/_async_to_sync.py new file mode 100644 index 0000000..9663822 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/_async_to_sync.py @@ -0,0 +1,395 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Adapt an ``AsyncHttpClient`` for use from a synchronous pipeline. + +``AsyncToSyncHttpClient`` drives an async transport from blocking code. There +may be no event loop on the calling thread — and even if there were, running the +transport on it would reenter the caller's loop — so the bridge owns a dedicated +background thread with its own loop and blocks the caller for the result. + +Three contracts make this safe rather than merely functional: + +- Exceptions surface with their exact type. A caller's ``except SomeSdkError`` + still matches; ``_unwrap_exception`` peels the ``concurrent.futures`` + cancellation wrapper the thread boundary can introduce, cycle-safely. +- ``KeyboardInterrupt`` cancels the in-flight async operation, then re-raises — + never swallowed, never left dangling. +- A response produced after the caller stops waiting is orphaned; the bridge + closes it exactly once. A response already delivered belongs to the caller and + is never closed out from under them. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextlib +import threading +from typing import TYPE_CHECKING, Any, Self + +from ..http.response.response import Response +from ..http.response.response_body import ResponseBody +from ..instrumentation.correlation import capture_context +from .http_client import HttpClient + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Coroutine, Iterator + from contextvars import Context + from types import TracebackType + + from ..http.common.media_type import MediaType + from ..http.request.request import Request + from ..http.response.async_response import AsyncResponse + from ..http.response.async_response_body import AsyncResponseBody + from .async_http_client import AsyncHttpClient + +_POLL_SECONDS = 0.05 +_CHUNK_SENTINEL = object() + + +def _unwrap_exception(exc: BaseException) -> BaseException: + """Return the exception a sync caller should see for a bridged failure. + + Crossing the sync/async boundary can surface cancellation as a + ``concurrent.futures.CancelledError`` — an ``Exception`` subclass that wraps + the async-native ``asyncio.CancelledError`` (a ``BaseException``). Left + as-is, an ``except Exception`` site would swallow a cancellation meant to + propagate. This peels the wrapper back to the original cancellation. Every + other exception — including every ``SdkError`` — is returned unchanged, so a + caller's ``except SpecificError`` still matches the exact transport type. + + The wrapper's ``__cause__`` / ``__context__`` chain is walked to recover the + original ``asyncio.CancelledError``. The walk records visited exception ids + and stops on a repeat, so a self-referential (cyclic) chain terminates + instead of spinning forever. + + Args: + exc: The exception raised while retrieving the bridged result. + + Returns: + The exception the caller should observe. + """ + if not isinstance(exc, concurrent.futures.CancelledError): + return exc + seen = {id(exc)} + current: BaseException | None = exc.__cause__ or exc.__context__ + while current is not None and id(current) not in seen: + if isinstance(current, asyncio.CancelledError): + return current + seen.add(id(current)) + current = current.__cause__ or current.__context__ + return asyncio.CancelledError() + + +async def _safe_close(response: AsyncResponse) -> None: + """Close an orphaned response best-effort, swallowing cleanup failures. + + Runs on the background loop for a response the caller abandoned. A failure + here has no caller to surface to and must not crash the loop, so it is + deliberately swallowed — the response is being discarded regardless. + """ + with contextlib.suppress(Exception): + await response.close() + + +class _BridgedCall: + """Per-call handshake between the calling thread and the background loop. + + The loop thread runs the transport coroutine and records its outcome; the + calling thread blocks in `wait` for it. The class enforces response + ownership: a result the caller takes delivery of is theirs to close, while a + result produced after the caller abandons the call is closed here exactly + once (guarded by ``_closed`` under ``_lock``). + """ + + __slots__ = ( + "_abandoned", + "_bg", + "_closed", + "_delivered", + "_done", + "_exc", + "_lock", + "_result", + "_task", + ) + + def __init__(self, bg: _BackgroundLoop) -> None: + self._bg = bg + self._done = threading.Event() + self._lock = threading.Lock() + self._result: AsyncResponse | None = None + self._exc: BaseException | None = None + self._task: asyncio.Task[AsyncResponse] | None = None + self._abandoned = False + self._closed = False + self._delivered = False + + def start(self, coro: Coroutine[Any, Any, AsyncResponse], context: Context) -> None: + """Create the transport task inside ``context``. Runs on the loop thread.""" + self._task = self._bg.spawn(coro, context) + self._task.add_done_callback(self._on_done) + + def _on_done(self, task: asyncio.Task[AsyncResponse]) -> None: + """Record the task outcome and wake the caller. Runs on the loop thread.""" + if task.cancelled(): + self._exc = asyncio.CancelledError() + elif (exc := task.exception()) is not None: + self._exc = exc + else: + with self._lock: + self._result = task.result() + self._close_if_orphaned_locked() + self._done.set() + + def wait(self) -> AsyncResponse: + """Block for the outcome, then deliver it or raise. Runs on the caller. + + Returns: + The response, whose ownership transfers to the caller. + + Raises: + KeyboardInterrupt: Re-raised after the in-flight operation is + cancelled. + BaseException: The transport's failure, unwrapped to its original + type. + """ + try: + self._block() + except KeyboardInterrupt: + self._abandon() + raise + if self._exc is not None: + raise _unwrap_exception(self._exc) + with self._lock: + self._delivered = True + assert self._result is not None + return self._result + + def _block(self) -> None: + """Wait for completion, polling so a signal stays deliverable.""" + while not self._done.wait(_POLL_SECONDS): + pass + + def _abandon(self) -> None: + """Cancel the in-flight task and arrange orphan cleanup. On the caller.""" + with self._lock: + self._abandoned = True + self._close_if_orphaned_locked() + self._bg.loop.call_soon_threadsafe(self._cancel_task) + + def _close_if_orphaned_locked(self) -> None: + """Schedule an orphan close if warranted. Caller must hold ``_lock``.""" + if self._delivered or self._closed or not self._abandoned: + return + if self._result is not None: + self._closed = True + self._bg.loop.call_soon_threadsafe(self._spawn_close, self._result) + + def _cancel_task(self) -> None: + """Cancel the transport task. Runs on the loop thread.""" + if self._task is not None and not self._task.done(): + self._task.cancel() + + def _spawn_close(self, response: AsyncResponse) -> None: + """Fire off the (tracked) orphan close coroutine. Runs on the loop thread.""" + self._bg.spawn(_safe_close(response)) + + +class _BackgroundLoop: + """A dedicated daemon thread running its own asyncio event loop. + + Every task it runs is tracked in ``_tasks`` (touched only on the loop + thread, so no lock is needed) and given a done-callback that drops it. That + keeps a strong reference to each in-flight task — asyncio holds only a weak + one — so a fire-and-forget orphan close cannot be garbage-collected + mid-flight, and `close` can drain everything before stopping the loop. + """ + + __slots__ = ("_loop", "_tasks", "_thread") + + def __init__(self) -> None: + self._loop = asyncio.new_event_loop() + self._tasks: set[asyncio.Task[Any]] = set() + ready = threading.Event() + self._thread = threading.Thread( + target=self._run, + args=(ready,), + name="dexpace-sdk-async-bridge", + daemon=True, + ) + self._thread.start() + ready.wait() + + @property + def loop(self) -> asyncio.AbstractEventLoop: + return self._loop + + def _run(self, ready: threading.Event) -> None: + asyncio.set_event_loop(self._loop) + self._loop.call_soon(ready.set) + try: + self._loop.run_forever() + finally: + asyncio.set_event_loop(None) + self._loop.close() + + def spawn[T]( + self, coro: Coroutine[Any, Any, T], context: Context | None = None + ) -> asyncio.Task[T]: + """Create a tracked task on the loop. Must run on the loop thread.""" + task = self._loop.create_task(coro, context=context) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return task + + def submit( + self, + coro: Coroutine[Any, Any, AsyncResponse], + context: Context, + call: _BridgedCall, + ) -> None: + """Schedule ``call.start`` on the loop from the calling thread.""" + self._loop.call_soon_threadsafe(call.start, coro, context) + + def close(self) -> None: + """Drain in-flight tasks, then stop the loop and join the thread.""" + done = threading.Event() + self._loop.call_soon_threadsafe(self._drain_then_stop, done) + done.wait() + self._thread.join() + + def _drain_then_stop(self, done: threading.Event) -> None: + """Await outstanding tasks, then stop the loop. Runs on the loop thread. + + Re-checks after each drain: a drained task (a cancelled transport call) + can itself spawn a late orphan close, so the loop stops only once no + tracked task remains pending. + """ + pending = [task for task in self._tasks if not task.done()] + if pending: + gather = asyncio.gather(*pending, return_exceptions=True) + gather.add_done_callback(lambda _: self._drain_then_stop(done)) + return + self._loop.stop() + done.set() + + +class AsyncToSyncHttpClient(HttpClient): + """Drive an ``AsyncHttpClient`` from sync code on a background loop thread. + + Owns a dedicated event-loop thread for its lifetime; use it as a context + manager (or call `close`) to shut that thread down. The caller's contextvars + are captured at submission so the transport observes the correct correlation + context, not whatever the loop thread happened to hold. + """ + + __slots__ = ("_client", "_loop_bg") + + def __init__(self, client: AsyncHttpClient) -> None: + """Wrap ``client``, spinning up the background event-loop thread.""" + self._client = client + self._loop_bg = _BackgroundLoop() + + def execute(self, request: Request) -> Response: + """Run the async transport to completion and adapt its response. + + Args: + request: The request to send, threaded through unchanged. + + Returns: + A ``Response`` mirroring the ``AsyncResponse``, with its body adapted + to read through the background loop. + """ + context = capture_context() + call = _BridgedCall(self._loop_bg) + self._loop_bg.submit(self._client.execute(request), context, call) + async_response = call.wait() + return _to_sync_response(async_response, self._loop_bg.loop) + + def close(self) -> None: + """Shut down the background event-loop thread.""" + self._loop_bg.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + +def _to_sync_response(response: AsyncResponse, loop: asyncio.AbstractEventLoop) -> Response: + """Adapt an ``AsyncResponse`` to a sync ``Response`` reading via ``loop``.""" + body = response.body + sync_body = None if body is None else _LoopResponseBody(body, loop) + return Response( + request=response.request, + protocol=response.protocol, + status=response.status, + headers=response.headers, + reason=response.reason, + body=sync_body, + ) + + +async def _anext(aiter: AsyncIterator[bytes]) -> object: + """Advance an async iterator once, or return the sentinel at exhaustion.""" + try: + return await aiter.__anext__() + except StopAsyncIteration: + return _CHUNK_SENTINEL + + +class _LoopResponseBody(ResponseBody): + """Adapt an ``AsyncResponseBody`` to sync by driving reads on the loop. + + Every read and the final close run on the background loop and block the + calling thread. Single-use semantics come from the wrapped body: its + ``aiter_bytes`` claims the once-guard, so a second ``iter_bytes`` raises. + """ + + __slots__ = ("_closed", "_inner", "_loop") + + def __init__(self, inner: AsyncResponseBody, loop: asyncio.AbstractEventLoop) -> None: + self._inner = inner + self._loop = loop + self._closed = False + + def media_type(self) -> MediaType | None: + return self._inner.media_type() + + def content_length(self) -> int: + return self._inner.content_length() + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + aiter = self._inner.aiter_bytes(chunk_size) + return self._iter(aiter) + + def _iter(self, aiter: AsyncIterator[bytes]) -> Iterator[bytes]: + try: + while True: + chunk = self._run(_anext(aiter)) + if chunk is _CHUNK_SENTINEL: + return + assert isinstance(chunk, bytes) + yield chunk + finally: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._run(self._inner.close()) + + def _run[T](self, coro: Coroutine[Any, Any, T]) -> T: + return asyncio.run_coroutine_threadsafe(coro, self._loop).result() + + +__all__ = ["AsyncToSyncHttpClient"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/_sync_to_async.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/_sync_to_async.py new file mode 100644 index 0000000..f9b5610 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/client/_sync_to_async.py @@ -0,0 +1,149 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Adapt a blocking ``HttpClient`` for use from an async pipeline. + +``SyncToAsyncHttpClient`` presents a synchronous transport as an +``AsyncHttpClient``. Every blocking call is offloaded to a caller-supplied +``concurrent.futures.Executor`` so it never stalls the event loop. The executor +is mandatory: a default, implicitly-shared thread pool is exactly the kind of +easily-exhausted resource that causes hard-to-debug starvation in production, +so the bridge refuses to fabricate one and fails at construction instead. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from ..http.response.async_response import AsyncResponse +from ..http.response.async_response_body import AsyncResponseBody +from ..instrumentation.correlation import bind_to_context +from .async_http_client import AsyncHttpClient + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterator + from concurrent.futures import Executor + + from ..http.common.media_type import MediaType + from ..http.request.request import Request + from ..http.response.response import Response + from ..http.response.response_body import ResponseBody + from .http_client import HttpClient + +_CHUNK_SENTINEL = object() + + +class SyncToAsyncHttpClient(AsyncHttpClient): + """Run a blocking ``HttpClient`` from async code via a required executor. + + The wrapped client's ``execute`` runs on ``executor`` (never the event + loop), and the resulting ``Response`` is adapted to an ``AsyncResponse`` + whose body is streamed back through the same executor. The caller-supplied + context (correlation ids and any other ``contextvars``) is captured at + submission time so the worker observes it, not whatever a pooled thread + happened to inherit. + """ + + __slots__ = ("_client", "_executor") + + def __init__(self, client: HttpClient, *, executor: Executor) -> None: + """Wrap ``client``, offloading its blocking calls to ``executor``. + + Args: + client: The blocking transport to adapt. + executor: The executor that runs every blocking call. Required and + keyword-only — the bridge never creates an implicit shared + pool. + + Raises: + TypeError: If ``executor`` is ``None``. Omitting it entirely raises + ``TypeError`` for the missing keyword-only argument. Either way + the failure is immediate, at construction. + """ + if executor is None: + raise TypeError("SyncToAsyncHttpClient requires an explicit executor") + self._client = client + self._executor = executor + + async def execute(self, request: Request) -> AsyncResponse: + """Run the wrapped sync ``execute`` on the executor and adapt its result. + + Args: + request: The request to send, threaded through unchanged. + + Returns: + An ``AsyncResponse`` mirroring the sync ``Response``, with its body + adapted to stream through the executor. + """ + loop = asyncio.get_running_loop() + # Capture the caller's contextvars now, at submission, so the worker + # thread observes them rather than a bare pooled-thread context. + bound = bind_to_context(lambda: self._client.execute(request)) + response = await loop.run_in_executor(self._executor, bound) + return _to_async_response(response, loop, self._executor) + + +def _to_async_response( + response: Response, loop: asyncio.AbstractEventLoop, executor: Executor +) -> AsyncResponse: + """Adapt a sync ``Response`` to an ``AsyncResponse`` sharing the executor.""" + body = response.body + async_body = None if body is None else _ExecutorResponseBody(body, loop, executor) + return AsyncResponse( + request=response.request, + protocol=response.protocol, + status=response.status, + headers=response.headers, + reason=response.reason, + body=async_body, + ) + + +def _next_chunk(iterator: Iterator[bytes]) -> object: + """Pull one chunk from a sync iterator, or the sentinel at exhaustion.""" + return next(iterator, _CHUNK_SENTINEL) + + +class _ExecutorResponseBody(AsyncResponseBody): + """Adapt a blocking ``ResponseBody`` to async by reading it on the executor. + + Each blocking read and the final close run on the executor rather than the + event loop. Single-use semantics come straight from the wrapped body: its + ``iter_bytes`` claims the once-guard, so a second ``aiter_bytes`` raises. + """ + + __slots__ = ("_executor", "_inner", "_loop") + + def __init__( + self, inner: ResponseBody, loop: asyncio.AbstractEventLoop, executor: Executor + ) -> None: + self._inner = inner + self._loop = loop + self._executor = executor + + def media_type(self) -> MediaType | None: + return self._inner.media_type() + + def content_length(self) -> int: + return self._inner.content_length() + + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + # Claim the wrapped body's single-use guard eagerly (may raise), then + # stream its chunks off the event loop. + iterator = self._inner.iter_bytes(chunk_size) + return self._aiter(iterator) + + async def _aiter(self, iterator: Iterator[bytes]) -> AsyncIterator[bytes]: + while True: + chunk = await self._loop.run_in_executor(self._executor, _next_chunk, iterator) + if chunk is _CHUNK_SENTINEL: + return + assert isinstance(chunk, bytes) + yield chunk + + async def close(self) -> None: + await self._loop.run_in_executor(self._executor, self._inner.close) + + +__all__ = ["SyncToAsyncHttpClient"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/__init__.py new file mode 100644 index 0000000..ed43750 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/__init__.py @@ -0,0 +1,64 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tier-1 declarative request-assembly contract and platform executors. + +The `Operation` / `OperationInput` value objects describe a request the way a +generated SDK client would, and `assemble` turns that declaration into a +concrete `Request` (method, URL, headers). `StatusErrorMap` is the response-side +companion: a frozen status-code → typed-error mapping. + +`ServiceCore` / `AsyncServiceCore` are the two platform executors a generated +SDK facade delegates to. Each exposes `execute` / `execute_request` / +`paginate` / `events`, and both delegate every non-awaiting decision to the same +shared pure planners so only the awaiting shell differs between the sync and +async twins. `AsyncResponseHandler` / `AsyncDecodeSuccess` are the async twin of +serde's `ResponseHandler` / `DecodeSuccess`, used as the async executor's +default handler. `ServiceDefaults` plus `merge_config` / `merge_async_config` +are the Tier-2 seam: they fold a service's baked-in defaults and an optional +user override into the single core pipeline's constructor kwargs, never a +second pipeline or a wrapper client. + +`AuthRequirement` / `AuthDescriptor` / `AuthRegistry` and the executors' tiered +resolver select the credential to apply per call by the most specific tier +present (per-call, then operation, then client), failing rather than falling +through when a present-but-unsatisfiable tier is selected. +""" + +from __future__ import annotations + +from .assembly import assemble +from .async_merge_config import merge_async_config +from .async_response_handler import AsyncDecodeSuccess, AsyncResponseHandler +from .async_service_core import AsyncServiceCore +from .auth import ( + AuthDescriptor, + AuthRegistry, + AuthRequirement, + AuthResolutionError, + AuthScheme, +) +from .merge_config import merge_config +from .operation import Operation, OperationInput +from .service_core import ServiceCore +from .service_defaults import ServiceDefaults +from .status_error_map import StatusErrorMap + +__all__ = [ + "AsyncDecodeSuccess", + "AsyncResponseHandler", + "AsyncServiceCore", + "AuthDescriptor", + "AuthRegistry", + "AuthRequirement", + "AuthResolutionError", + "AuthScheme", + "Operation", + "OperationInput", + "ServiceCore", + "ServiceDefaults", + "StatusErrorMap", + "assemble", + "merge_async_config", + "merge_config", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_api_version_policy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_api_version_policy.py new file mode 100644 index 0000000..358d740 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_api_version_policy.py @@ -0,0 +1,65 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Private sync pipeline policy that stamps a constant api-version header. + +Internal to `merge_config`'s pipeline flattening — not part of the codegen +public surface (hence the leading-underscore module name; the class itself +keeps a normal name so `Policy.__init_subclass__` still enforces its `STAGE` +declaration). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from ..pipeline.policy import Policy +from ..pipeline.stage import Stage + +if TYPE_CHECKING: + from ..http.request.request import Request + from ..http.response.response import Response + from ..pipeline.context import PipelineContext + +__all__ = ["ApiVersionPolicy"] + + +class ApiVersionPolicy(Policy): + """Stamps every outgoing request with a constant api-version header. + + Placed at `Stage.PRE_SEND` — the innermost non-terminal stage, sitting + inside both the redirect (`Stage.REDIRECT`) and retry (`Stage.RETRY`) + wrappers — so the header is re-applied on every hop and every attempt + rather than minted once and silently carried (or dropped) across a + reissued request. + + Attributes: + STAGE: Pinned to `Stage.PRE_SEND` at the type level so mis-slotting + is caught by ``mypy``. + """ + + STAGE: ClassVar[Literal[Stage.PRE_SEND]] = Stage.PRE_SEND + __slots__ = ("_header", "_value") + + def __init__(self, header: str, value: str) -> None: + """Build the policy. + + Args: + header: The header name the api-version value is stamped under. + value: The api-version value stamped on every request. + """ + self._header = header + self._value = value + + def send(self, request: Request, ctx: PipelineContext) -> Response: + """Stamp ``request`` with the configured api-version header and dispatch. + + Args: + request: Outgoing request. A new request is returned (the + original is left untouched per the immutability contract). + ctx: Pipeline context, forwarded unchanged. + + Returns: + The response from the downstream chain. + """ + return self.next.send(request.with_header(self._header, self._value), ctx) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_async_api_version_policy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_async_api_version_policy.py new file mode 100644 index 0000000..f0da702 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_async_api_version_policy.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Private async pipeline policy that stamps a constant api-version header. + +Async twin of `_api_version_policy.ApiVersionPolicy`; see that module for the +rationale behind the `Stage.PRE_SEND` placement. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from ..pipeline.async_policy import AsyncPolicy +from ..pipeline.stage import Stage + +if TYPE_CHECKING: + from ..http.request.request import Request + from ..http.response.async_response import AsyncResponse + from ..pipeline.context import PipelineContext + +__all__ = ["AsyncApiVersionPolicy"] + + +class AsyncApiVersionPolicy(AsyncPolicy): + """Async twin of `ApiVersionPolicy`. + + Attributes: + STAGE: Pinned to `Stage.PRE_SEND` at the type level so mis-slotting + is caught by ``mypy``. + """ + + STAGE: ClassVar[Literal[Stage.PRE_SEND]] = Stage.PRE_SEND + __slots__ = ("_header", "_value") + + def __init__(self, header: str, value: str) -> None: + """Build the policy. + + Args: + header: The header name the api-version value is stamped under. + value: The api-version value stamped on every request. + """ + self._header = header + self._value = value + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + """Stamp ``request`` with the configured api-version header and dispatch. + + Args: + request: Outgoing request. A new request is returned. + ctx: Pipeline context, forwarded unchanged. + + Returns: + The response from the downstream chain. + """ + return await self.next.send(request.with_header(self._header, self._value), ctx) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/__init__.py new file mode 100644 index 0000000..4384cd9 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure planner layer for the codegen executors. + +Dependency-light planning functions that carry no awaiting and no dependency +on either executor shell, so the sync `ServiceCore` and async +`AsyncServiceCore` twins import the single canonical definition instead of +restating it. Only the awaiting shell (sync vs. async) differs between the two +executors; every non-awaiting decision they share lives here. + +Keeping these modules shell-free is what lets the import-linter layering +contract place them below the two executor modules and forbid the executor +shells from importing one another. + +Modules: + requests: request binding and assembly (delegates to `assemble`). + options: packing per-call options (and per-call auth) into the frozen + options mapping threaded through the pipeline. + results: the raw-response-vs-handler decision and response-witness + resolution shared by both executors' result handling. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/options.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/options.py new file mode 100644 index 0000000..7708f4f --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/options.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure per-call options planner shared by both executor shells. + +`plan_options` packs a call's per-call keyword overrides — plus an optional +per-call auth override — into a single frozen mapping. The executor splats that +mapping into ``Pipeline.run(**options)`` / ``AsyncPipeline.run(**options)``, +which copies it once into the mutable ``PipelineContext.options`` that stays the +*same object* across every retry / redirect re-drive (the identity guarantee +PY-M3-05 certified). Freezing the planner's output keeps the caller from +mutating the mapping after the fact. +""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = ["AUTH_OPTION_KEY", "plan_options"] + +#: Reserved key under which a per-call ``auth`` override is parked in the +#: options mapping. It is threaded through the pipeline verbatim so a future +#: auth-requirement policy (PY-M9-05) can read it; this task only parks it. +AUTH_OPTION_KEY: Final = "auth" + + +def plan_options(auth: object | None, call_options: Mapping[str, Any]) -> Mapping[str, Any]: + """Pack per-call options and an optional auth override into a frozen mapping. + + Args: + auth: An optional per-call authentication override. When not ``None`` it + is parked under `AUTH_OPTION_KEY`; it is threaded through the + pipeline unchanged, not consumed here. + call_options: The caller's per-call keyword overrides, forwarded to the + pipeline's policies via ``ctx.options``. + + Returns: + A read-only mapping of the merged options. The executor splats it into + ``Pipeline.run``; a per-call ``auth`` key set here takes precedence over + one of the same name in ``call_options``. + """ + merged: dict[str, Any] = dict(call_options) + if auth is not None: + merged[AUTH_OPTION_KEY] = auth + return MappingProxyType(merged) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/requests.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/requests.py new file mode 100644 index 0000000..6fb6063 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/requests.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure request-binding planner shared by both executor shells. + +`plan_request` is the single seam both `ServiceCore` and `AsyncServiceCore` +call to turn a declarative operation plus its per-call input into a concrete +`Request`. It delegates to the certified `assemble` (PY-M9-01) rather than +restating any path / query / header logic; keeping it here — free of any +awaiting and of either executor shell — lets the sync and async twins share one +definition of request binding. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..assembly import assemble + +if TYPE_CHECKING: + from ...http.common import Url + from ...http.request import Request + from ...serde import Serde + from ..operation import Operation, OperationInput + +__all__ = ["plan_request"] + + +def plan_request( + op: Operation, + input: OperationInput, + base_url: str | Url, + serde: Serde | None, +) -> Request: + """Bind an operation and its per-call input into a concrete `Request`. + + The executor's request-binding step. It is a thin, awaiting-free wrapper + over `assemble`, so both executor shells produce byte-identical requests + from the same inputs; a generated caller's parity check therefore compares + two shells that share this one planner rather than two open-coded builders. + + Args: + op: The static operation description (method, path template, headers). + input: The per-call values (path params, query, headers, body). + base_url: The absolute base URL, as a string or `Url`. + serde: Body serializer reserved for a later executor layer; forwarded + to `assemble` unchanged (the assembly layer carries the body + through verbatim and does not consult it yet). + + Returns: + The assembled `Request`. + + Raises: + KeyError: If the path template names a placeholder with no matching + entry in ``input.path_params``. + ValueError: If ``base_url`` carries a URL fragment or is not a valid + absolute URL. + """ + return assemble(op, input, base_url, serde=serde) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/results.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/results.py new file mode 100644 index 0000000..e1c5a37 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/_pure/results.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure result-handling planner shared by both executor shells. + +`plan_result` is the awaiting-free decision both `ServiceCore` and +`AsyncServiceCore` make once they hold a response: hand the raw response back, +or run a handler and, if so, which handler and against which response-type +witness. The executor shells then *apply* the chosen handler — the only +difference being that the async shell awaits it. The classification and decode +logic itself lives in the response handlers (`DecodeSuccess` / its async twin) +and the pure `classify_status` decision they share; this planner only routes. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ...serde import TypeRef + +__all__ = ["plan_result"] + +#: A resolved response-type witness — a concrete class, a subscripted generic +#: (``list[Pet]``), or a validated `TypeRef` — as a response handler accepts. +type _Witness = type[Any] | TypeRef[Any] + + +def plan_result[H]( + handler: H | None, + default_handler: H, + response_type: _Witness | None, + default_witness: _Witness, +) -> tuple[H, _Witness] | None: + """Decide how a response becomes the caller's result. + + Args: + handler: An explicit per-call handler, or ``None`` to use + ``default_handler``. + default_handler: The executor's default response handler (a + `DecodeSuccess` for the sync shell, its async twin for the async + shell). + response_type: The requested result type, or ``None`` when the caller + asked for no decoding. + default_witness: The witness to pass a handler when ``response_type`` is + ``None`` (the concrete response class of the shell — a handler that + decodes nothing ignores it). + + Returns: + ``None`` when the raw response should be returned unchanged — the case + of no ``response_type`` and no ``handler``. Otherwise a + ``(handler, witness)`` pair: the chosen handler and the witness to + invoke it with. A per-call ``handler`` wins over the default; a given + ``response_type`` wins over ``default_witness``. + """ + if handler is None and response_type is None: + return None + chosen = default_handler if handler is None else handler + witness = default_witness if response_type is None else response_type + return chosen, witness diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/assembly.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/assembly.py new file mode 100644 index 0000000..a2634c6 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/assembly.py @@ -0,0 +1,189 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Build a concrete `Request` from a declarative `Operation` and `OperationInput`.""" + +from __future__ import annotations + +import re +from dataclasses import replace +from typing import TYPE_CHECKING +from urllib.parse import quote + +from ..http.common import Headers, QueryParams, Url +from ..http.request import Request + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ..serde import Serde + from .operation import Operation, OperationInput + +__all__ = ["assemble"] + +#: Matches a single ``{name}`` path placeholder; ``name`` excludes braces so a +#: malformed ``{`` or ``}`` is left untouched rather than mis-parsed. +_PLACEHOLDER = re.compile(r"\{([^{}]+)\}") + + +def assemble( + op: Operation, + input: OperationInput, + base_url: str | Url, + *, + serde: Serde | None = None, +) -> Request: + """Build a concrete `Request` from a declarative operation description. + + Substitutes the operation's path template against ``input.path_params``, + joins it onto ``base_url``, appends ``input.query`` after any query already + on the base URL, overlays per-call headers on the operation's static + headers, and carries the body through unchanged. The operation's ``name`` is + advisory and never affects the result. + + Args: + op: The static operation description (method, path template, headers). + input: The per-call values (path params, query, headers, body). + base_url: The absolute base URL, as a string or `Url`. Any query it + carries (e.g. a SAS / signed-URL token) is preserved; it must not + carry a URL fragment. + serde: Reserved for a later executor layer that serializes the body and + derives a ``Content-Type``. This assembly layer carries the body + through verbatim and does not consult it. + + Returns: + The assembled `Request`. + + Raises: + KeyError: If the path template names a placeholder with no matching + entry in ``input.path_params``; the missing name is reported. + ValueError: If ``base_url`` carries a URL fragment or is not a valid + absolute URL. + """ + base = _base_url(base_url) + url = replace( + base, + path=_join_path(base.path, _render_path(op.path, input.path_params)), + query=_append_query(base.query, input.query), + ) + return Request( + method=op.method, + url=url, + headers=_merge_headers(op.headers, input.headers), + body=input.body, + ) + + +def _base_url(base_url: str | Url) -> Url: + """Parse and validate the base URL, rejecting fragments and malformed input. + + Args: + base_url: The base URL as a string or `Url`. + + Returns: + The validated `Url`. + + Raises: + ValueError: If ``base_url`` is not a valid absolute URL or carries a + fragment. The raw value is not echoed so a signed-URL secret cannot + leak through the message. + """ + try: + base = Url.parse(base_url) if isinstance(base_url, str) else base_url + except ValueError as exc: + raise ValueError( + "assemble: base_url is not a valid absolute URL (a scheme and host are required)" + ) from exc + if base.fragment: + raise ValueError("assemble: base_url must not carry a URL fragment ('#...')") + return base + + +def _render_path(template: str, params: Mapping[str, str]) -> str: + """Substitute every ``{placeholder}`` with its percent-encoded value. + + Each value is encoded with ``safe=""`` so only RFC 3986 ``unreserved`` + characters survive verbatim; a literal ``/`` becomes ``%2F`` and therefore + stays within a single path segment rather than introducing a new one. + + Args: + template: The path template, possibly containing ``{name}`` tokens. + params: The values to substitute, keyed by placeholder name. + + Returns: + The template with every placeholder substituted. + + Raises: + KeyError: If a placeholder has no matching key in ``params``; only the + missing name is reported, never the supplied values. + """ + + def _substitute(match: re.Match[str]) -> str: + name = match.group(1) + if name not in params: + raise KeyError( + f"assemble: missing path parameter {name!r} for placeholder " + f"'{{{name}}}' in path template {template!r}" + ) + return quote(params[name], safe="") + + return _PLACEHOLDER.sub(_substitute, template) + + +def _join_path(base_path: str, op_path: str) -> str: + """Join base and operation paths with exactly one separating slash. + + Collapses the seam so a trailing slash on the base and a leading slash on + the operation path never produce a doubled ``//``; a trailing slash on the + operation path itself is preserved. + + Args: + base_path: The path component of the base URL. + op_path: The substituted operation path. + + Returns: + The combined path. + """ + left = base_path.rstrip("/") + right = op_path.lstrip("/") + if not right: + return left or "/" + return f"{left}/{right}" + + +def _append_query(base: QueryParams, extra: QueryParams) -> QueryParams: + """Append the operation's query parameters after those already on the base URL. + + Args: + base: The query parameters already present on the base URL. + extra: The operation's own query parameters. + + Returns: + The base parameters with ``extra`` appended, preserving order and + duplicates. Rendering happens later via the pure RFC 3986 query codec + that `QueryParams` uses. + """ + if not len(extra): + return base + return QueryParams([*base.flatten(), *extra.flatten()]) + + +def _merge_headers(base: Headers, override: Headers) -> Headers: + """Overlay per-call headers on the operation's static headers. + + For each name present in ``override`` the base values for that name are + replaced outright (per-call wins); names only in ``base`` are kept. + + Args: + base: The operation's static headers. + override: The per-call headers. + + Returns: + The merged headers. + """ + if not len(override): + return base + merged = base + for name in override.names(): + merged = merged.with_set(name, *override.values(name)) + return merged diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_merge_config.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_merge_config.py new file mode 100644 index 0000000..236713b --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_merge_config.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Async twin of `merge_config.merge_config`. + +Folds `ServiceDefaults` + a user override into `AsyncServiceCore`'s kwargs the +same way the sync twin folds them into `ServiceCore`'s — one +`default_async_pipeline(...)` call, the service's User-Agent token fed into +`AsyncClientIdentityPolicy`, and the api-version stamp appended at +`Stage.PRE_SEND`. See `merge_config.py` for the full rationale. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..pipeline.defaults import default_async_pipeline +from ..pipeline.policies.async_client_identity import AsyncClientIdentityPolicy +from ..pipeline.policies.client_identity import default_user_agent +from ._async_api_version_policy import AsyncApiVersionPolicy +from .service_defaults import DEFAULT_API_VERSION_HEADER, ServiceDefaults, merge_service_defaults + +if TYPE_CHECKING: + from ..client.async_http_client import AsyncHttpClient + from ..pipeline.async_pipeline import AsyncPipeline + from ..pipeline.async_policy import AsyncPolicy + from ..pipeline.policies.async_idempotency import AsyncIdempotencyPolicy + from ..pipeline.policies.async_logging_policy import AsyncLoggingPolicy + from ..pipeline.policies.async_redirect import AsyncRedirectPolicy + from ..pipeline.policies.async_retry import AsyncRetryPolicy + from ..pipeline.policies.async_set_date import AsyncSetDatePolicy + from ..pipeline.policies.async_tracing_policy import AsyncTracingPolicy + +__all__ = ["merge_async_config"] + + +def merge_async_config( + defaults: ServiceDefaults, + user: ServiceDefaults | None = None, + *, + transport: AsyncHttpClient | None = None, + pipeline: AsyncPipeline | None = None, + redirect: AsyncRedirectPolicy | None = None, + idempotency: AsyncIdempotencyPolicy | None = None, + retry: AsyncRetryPolicy | None = None, + set_date: AsyncSetDatePolicy | None = None, + auth: AsyncPolicy | None = None, + logging: AsyncLoggingPolicy | None = None, + tracing: AsyncTracingPolicy | None = None, +) -> dict[str, Any]: + """Build `AsyncServiceCore`'s constructor kwargs from service + user config. + + Args: + defaults: The service's own baked-in defaults. + user: The caller's override of the same shape. A non-``None`` field + here always wins; a ``None`` field falls through to ``defaults``. + transport: An async transport to build the one customized + `default_async_pipeline` around. Mutually exclusive with + ``pipeline``. + pipeline: A caller-supplied, already-built `AsyncPipeline` — the BYO / + shared-transport case. Passed through unmodified. Mutually + exclusive with ``transport``. + redirect: Forwarded to `default_async_pipeline` when building around + ``transport``. Ignored when ``pipeline`` is given. + idempotency: Forwarded to `default_async_pipeline`. Ignored when + ``pipeline`` is given. + retry: Forwarded to `default_async_pipeline`. Ignored when + ``pipeline`` is given. + set_date: Forwarded to `default_async_pipeline`. Ignored when + ``pipeline`` is given. + auth: Forwarded to `default_async_pipeline`. Ignored when + ``pipeline`` is given. + logging: Forwarded to `default_async_pipeline`. Ignored when + ``pipeline`` is given. + tracing: Forwarded to `default_async_pipeline`. Ignored when + ``pipeline`` is given. + + Returns: + A mapping with ``base_url`` / ``pipeline`` / ``errors`` / ``serde`` + keys ready to splat into ``AsyncServiceCore(**merge_async_config(...))``. + + Raises: + ValueError: If neither or both of ``transport`` and ``pipeline`` are + given, or if neither ``defaults`` nor ``user`` resolves a + ``base_url``. + """ + if (transport is None) == (pipeline is None): + raise ValueError("provide exactly one of transport= or pipeline=") + merged = merge_service_defaults(defaults, user) + if merged.base_url is None: + raise ValueError("base_url must be set by service defaults or user config") + if pipeline is not None: + resolved_pipeline = pipeline + else: + assert transport is not None # narrowed by the exclusivity guard above + resolved_pipeline = _build_pipeline( + merged, + transport, + redirect=redirect, + idempotency=idempotency, + retry=retry, + set_date=set_date, + auth=auth, + logging=logging, + tracing=tracing, + ) + return { + "base_url": merged.base_url, + "pipeline": resolved_pipeline, + "errors": merged.errors, + "serde": merged.serde, + } + + +def _build_pipeline( + merged: ServiceDefaults, + transport: AsyncHttpClient, + *, + redirect: AsyncRedirectPolicy | None, + idempotency: AsyncIdempotencyPolicy | None, + retry: AsyncRetryPolicy | None, + set_date: AsyncSetDatePolicy | None, + auth: AsyncPolicy | None, + logging: AsyncLoggingPolicy | None, + tracing: AsyncTracingPolicy | None, +) -> AsyncPipeline: + """Build the single async pipeline `merge_async_config` hands to `AsyncServiceCore`. + + Exactly one `default_async_pipeline(...)` call, exactly one `.build()`. + """ + client_identity = None + if merged.user_agent_token is not None: + client_identity = AsyncClientIdentityPolicy( + user_agent=f"{merged.user_agent_token} {default_user_agent()}" + ) + builder = default_async_pipeline( + transport, + redirect=redirect, + idempotency=idempotency, + retry=retry, + set_date=set_date, + client_identity=client_identity, + auth=auth, + logging=logging, + tracing=tracing, + ) + if merged.api_version is not None: + header = merged.api_version_header or DEFAULT_API_VERSION_HEADER + builder.append(AsyncApiVersionPolicy(header, merged.api_version)) + return builder.build() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_response_handler.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_response_handler.py new file mode 100644 index 0000000..d5a9c79 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_response_handler.py @@ -0,0 +1,184 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Async response handler — the async twin of serde's `DecodeSuccess`. + +The serde layer (PY-M5-06) ships a sync `DecodeSuccess` that turns a `Response` +into a typed result, dispatching on the pure `classify_status` decision. It is +sync-only: it reads the body and closes the response without awaiting, so it +cannot drive an `AsyncResponse`. The async executor needs the same behaviour +over the awaiting surface, so `AsyncDecodeSuccess` mirrors it here. + +Only the awaiting shell differs. The *decision* — SUCCESS decodes a 2xx body, +ERROR raises the `StatusErrorMap`-mapped typed error, UNEXPECTED raises a +status-led error — is the shared pure `classify_status`, and the *decode +action* reuses serde's own primitives (the configured `Serde` deserializer and +`Codec`). No classification or decode logic is restated here; only +``await response.body.bytes()`` and ``await response.close()`` replace their +sync counterparts. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NoReturn, Protocol, runtime_checkable + +from ..errors import DeserializationError, HttpResponseError +from ..serde import JSON_SERDE, Codec, ResponseDisposition, TypeRef, classify_status + +if TYPE_CHECKING: + from ..http.response.async_response import AsyncResponse + from ..serde import Serde + from .status_error_map import StatusErrorMap + +__all__ = ["AsyncDecodeSuccess", "AsyncResponseHandler"] + + +@runtime_checkable +class AsyncResponseHandler(Protocol): + """Async twin of serde's `ResponseHandler` Protocol. + + A terminal step that turns an `AsyncResponse` into the caller's result, + awaiting the body it consumes. The call is positional-only so handlers stay + interchangeable regardless of how they name their parameters, and the + awaited result is `Any` so a non-decoding handler (a raw download) is just + as valid a handler as a decoding one. + """ + + async def __call__( + self, response: AsyncResponse, response_type: type[Any] | TypeRef[Any], / + ) -> Any: + """Handle ``response``, producing the caller's result. + + Args: + response: The received response. The handler owns closing it. + response_type: The requested result type — a class, a subscripted + generic (``list[Pet]``), or a `TypeRef`. A handler that decodes + nothing may ignore it. + + Returns: + The handler's result. + """ + ... + + +def _type_name(response_type: object) -> str: + """Return a readable name for a target type, for error messages.""" + target = response_type.witness if isinstance(response_type, TypeRef) else response_type + name = getattr(target, "__name__", None) + return name if isinstance(name, str) else str(target) + + +class AsyncDecodeSuccess: + """Decode only 2xx async responses; raise a typed error for everything else. + + Async twin of serde's `DecodeSuccess`. Dispatches on the shared pure + `classify_status` decision: + + - ``SUCCESS`` (2xx): the body is read and decoded into the target type + through the configured `Serde` and `Codec`; the response is closed on + every path. + - ``ERROR`` (4xx/5xx): raises the typed error the `StatusErrorMap` maps the + status to. (The error's bounded body copy is only captured for a sync + response body; an async body yields an empty capture — a pre-existing + limitation of the errors layer, not introduced here.) + - ``UNEXPECTED`` (1xx/3xx): raises an `HttpResponseError` whose message + leads with the status code. + """ + + __slots__ = ("_codec", "_errors", "_serde") + + def __init__( + self, + errors: StatusErrorMap, + *, + serde: Serde | None = None, + codec: Codec | None = None, + ) -> None: + """Configure the handler. + + Args: + errors: Status → typed-error mapping consulted for 4xx/5xx + responses. + serde: Wire-format decoder for a 2xx body. Defaults to JSON. + codec: Document → typed-model codec for a 2xx body. Defaults to a + fresh `Codec`. + """ + self._errors = errors + self._serde: Serde = serde if serde is not None else JSON_SERDE + self._codec = codec if codec is not None else Codec() + + async def __call__[T]( + self, response: AsyncResponse, response_type: type[T] | TypeRef[T], / + ) -> T: + """Decode a 2xx ``response`` into ``response_type``, else raise. + + Args: + response: The received response; closed on every path. + response_type: The type a 2xx body is decoded into. + + Returns: + The decoded value for a 2xx response. + + Raises: + HttpResponseError: For a 4xx/5xx response (the mapped typed error) + or a 1xx/3xx response (a status-led error). + DeserializationError: If a 2xx response has no body or cannot be + decoded. + """ + disposition = classify_status(response.status) + if disposition is ResponseDisposition.SUCCESS: + try: + return await self._decode(response, response_type) + finally: + await response.close() + try: + self._raise_non_success(response, disposition) + finally: + await response.close() + + async def _decode[T](self, response: AsyncResponse, response_type: type[T] | TypeRef[T]) -> T: + """Read, deserialise, and decode the body (no closing — the caller does).""" + body = response.body + if body is None: + raise DeserializationError( + f"cannot decode a missing response body into {_type_name(response_type)}" + ) + document = self._serde.deserializer.deserialize_bytes(await body.bytes()) + return self._codec.decode(document, response_type) + + def _raise_non_success( + self, response: AsyncResponse, disposition: ResponseDisposition + ) -> NoReturn: + """Raise the typed error for a non-success response. + + Uses `StatusErrorMap.for_status` — the non-raising, non-buffering + lookup — rather than the sync-typed `StatusErrorMap.raise_for`, which + expects a `Response`. The mapped error is constructed against the + `AsyncResponse` directly; the errors layer does not synchronously drain + an async body, so no bounded body copy is captured for an async error + (a pre-existing limitation of the errors layer, not introduced here). + + Args: + response: The received non-success response. + disposition: The `classify_status` decision — ``ERROR`` or + ``UNEXPECTED`` (``SUCCESS`` is handled before this is called). + + Raises: + HttpResponseError: The mapped error for a 4xx/5xx status, or a + status-led error for a 1xx/3xx status. + """ + if disposition is ResponseDisposition.ERROR: + error_class = self._errors.for_status(response.status) + if error_class is not None: + raise error_class(response=response) + raise _unexpected_status_error(response) + + +def _unexpected_status_error(response: AsyncResponse) -> HttpResponseError[Any]: + """Build a status-led error for a 1xx/3xx response a success handler rejects.""" + status = response.status + return HttpResponseError( + f"{int(status)} {status.name}: unexpected response status; a success handler " + f"decodes only 2xx responses and raises the mapped error for 4xx/5xx", + response=response, + ) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_service_core.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_service_core.py new file mode 100644 index 0000000..31ba39e --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/async_service_core.py @@ -0,0 +1,491 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Asynchronous platform executor a generated async SDK facade delegates to. + +`AsyncServiceCore` is the async twin of `ServiceCore`. It exposes the same four +entry points — `execute` / `execute_request` / `paginate` / `events` — and +shares every non-awaiting decision with the sync executor through the pure +planners under `_pure/` (request binding, options packing, result routing). +Only the awaiting shell differs: the pipeline run is awaited, the response +handler is awaited, and lifecycle uses `aclose` / the async context-manager +protocol. + +The default handler is `AsyncDecodeSuccess`, the async twin of serde's +`DecodeSuccess` — serde ships only the sync handler, and its layer is frozen, +so the async orchestration over the same shared `classify_status` decision and +serde decode primitives lives alongside this executor. `paginate` returns the +certified `AsyncPaginator` and `events` the certified `AsyncSseConnection` +wrapped in `AsyncTypedSseConnection`; neither is re-implemented here. +""" + +from __future__ import annotations + +from collections.abc import Awaitable +from types import TracebackType +from typing import TYPE_CHECKING, Any, Self, overload + +from ..http.response.async_response import AsyncResponse +from ..pipeline.async_policy import AsyncPolicy +from ..pipeline.defaults import default_async_pipeline +from ..serde import JSON_SERDE +from ._pure.options import plan_options +from ._pure.requests import plan_request +from ._pure.results import plan_result +from .async_response_handler import AsyncDecodeSuccess +from .auth import AuthRegistry, resolve_auth +from .status_error_map import StatusErrorMap + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from ..client.async_http_client import AsyncHttpClient + from ..http.common import Url + from ..http.context.dispatch_context import DispatchContext + from ..http.request.request import Request + from ..http.sse.typed import AsyncTypedSseConnection, SseMapper + from ..pagination import AsyncPaginator, PaginationStrategy + from ..pipeline.async_pipeline import AsyncPipeline + from ..serde import Serde, TypeRef + from .async_response_handler import AsyncResponseHandler + from .auth import AuthDescriptor + from .operation import Operation, OperationInput + +__all__ = ["AsyncServiceCore"] + +#: A resolved response-type witness accepted by `execute` / `execute_request`. +type _Witness[T] = type[T] | TypeRef[T] + + +class AsyncServiceCore: + """Asynchronous executor over the shared pure planners. + + Construct from exactly one of an async transport or a BYO async pipeline: + + - ``AsyncServiceCore(base_url=..., transport=client)`` builds the canonical + `default_async_pipeline` around ``client`` and *owns* it — closing the + executor closes ``client``. + - ``AsyncServiceCore(base_url=..., pipeline=my_pipeline)`` borrows + ``my_pipeline`` and never closes it. + """ + + __slots__ = ( + "_base_url", + "_client_auth", + "_closed", + "_default_handler", + "_dispatch_factory", + "_errors", + "_owned_transport", + "_pipeline", + "_registry", + "_serde", + ) + + def __init__( + self, + *, + base_url: str | Url, + pipeline: AsyncPipeline | None = None, + transport: AsyncHttpClient | None = None, + errors: StatusErrorMap | None = None, + serde: Serde | None = None, + dispatch_factory: Callable[[], DispatchContext] | None = None, + auth: AuthDescriptor | None = None, + auth_registry: AuthRegistry[AsyncPolicy] | None = None, + ) -> None: + """Wire the executor to an async pipeline (BYO) or a transport it builds one around. + + Args: + base_url: Absolute base URL every operation's path joins onto. + pipeline: A caller-supplied `AsyncPipeline`. Borrowed, never closed + by this executor. Mutually exclusive with ``transport``. + transport: An `AsyncHttpClient` to build the `default_async_pipeline` + around. Owned — closing the executor closes it. Mutually + exclusive with ``pipeline``. + errors: Status → typed-error mapping for the default handler. + Defaults to an empty map (a bare `HttpResponseError` per error + status). + serde: Wire-format serde for request/response bodies. Defaults to + JSON. + dispatch_factory: Builds the per-call dispatch context. Defaults to + `DispatchContext.noop`. + auth: The client-level authentication requirement — the broadest + resolver tier, used when neither a per-call nor an operation-level + requirement is present. ``None`` leaves the client without a + default requirement. + auth_registry: The pool of `AuthScheme`s a declared requirement is + resolved against. ``None`` is an empty registry, so any present + requirement is unsatisfiable (and raises rather than falling + through). + + Raises: + ValueError: If neither or both of ``pipeline`` and ``transport`` are + given. + """ + if (pipeline is None) == (transport is None): + raise ValueError("provide exactly one of pipeline= or transport=") + from ..http.context.dispatch_context import DispatchContext + + self._base_url = base_url + self._serde: Serde = serde if serde is not None else JSON_SERDE + self._errors = errors if errors is not None else StatusErrorMap() + self._client_auth = auth + self._registry: AuthRegistry[AsyncPolicy] = ( + auth_registry if auth_registry is not None else AuthRegistry() + ) + self._dispatch_factory = ( + dispatch_factory if dispatch_factory is not None else DispatchContext.noop + ) + self._default_handler: AsyncResponseHandler = AsyncDecodeSuccess( + self._errors, serde=self._serde + ) + self._closed = False + if pipeline is not None: + self._pipeline = pipeline + self._owned_transport: AsyncHttpClient | None = None + else: + assert transport is not None # narrowed by the exclusivity guard above + self._pipeline = default_async_pipeline(transport).build() + self._owned_transport = transport + + # -- execute -------------------------------------------------------------- + + @overload + async def execute[T]( + self, + op: Operation, + input: OperationInput, + *, + response_type: _Witness[T], + handler: AsyncResponseHandler | None = ..., + auth: AuthDescriptor | AsyncPolicy | None = ..., + **call_options: Any, + ) -> T: ... + + @overload + async def execute( + self, + op: Operation, + input: OperationInput, + *, + response_type: None = ..., + handler: None = ..., + auth: AuthDescriptor | AsyncPolicy | None = ..., + **call_options: Any, + ) -> AsyncResponse: ... + + @overload + async def execute( + self, + op: Operation, + input: OperationInput, + *, + response_type: None = ..., + handler: AsyncResponseHandler, + auth: AuthDescriptor | AsyncPolicy | None = ..., + **call_options: Any, + ) -> Any: ... + + async def execute( + self, + op: Operation, + input: OperationInput, + *, + response_type: _Witness[Any] | None = None, + handler: AsyncResponseHandler | None = None, + auth: AuthDescriptor | AsyncPolicy | None = None, + **call_options: Any, + ) -> Any: + """Run one operation and return its decoded result. + + Args: + op: The static operation description. + input: The per-call values (path params, query, headers, body). + response_type: The type to decode a 2xx body into. When ``None`` + (and no ``handler``), the raw `AsyncResponse` is returned for the + caller to consume and close. + handler: An explicit response handler overriding the default + `AsyncDecodeSuccess`. + auth: The per-call auth tier. An `AuthDescriptor` is resolved + against the executor's registry, over the operation's ``auth`` + and the client-level default; a concrete `AsyncPolicy` is a + verbatim override; ``None`` defers to the operation / client + tiers. + **call_options: Per-call overrides forwarded to the pipeline's + policies via ``ctx.options``. + + Returns: + The decoded value for a given ``response_type``, the raw + `AsyncResponse` when neither ``response_type`` nor ``handler`` is + given, or the handler's result. + + Raises: + AuthResolutionError: If the selected auth tier is present but no + registered scheme satisfies it (never falls through to a + broader tier). + """ + request = plan_request(op, input, self._base_url, self._serde) + resolved = self._resolve_auth(op.auth, auth) + return await self._run(request, resolved, response_type, handler, call_options) + + # -- execute_request ------------------------------------------------------ + + @overload + async def execute_request[T]( + self, + request: Request, + *, + response_type: _Witness[T], + handler: AsyncResponseHandler | None = ..., + auth: AuthDescriptor | AsyncPolicy | None = ..., + **call_options: Any, + ) -> T: ... + + @overload + async def execute_request( + self, + request: Request, + *, + response_type: None = ..., + handler: None = ..., + auth: AuthDescriptor | AsyncPolicy | None = ..., + **call_options: Any, + ) -> AsyncResponse: ... + + @overload + async def execute_request( + self, + request: Request, + *, + response_type: None = ..., + handler: AsyncResponseHandler, + auth: AuthDescriptor | AsyncPolicy | None = ..., + **call_options: Any, + ) -> Any: ... + + async def execute_request( + self, + request: Request, + *, + response_type: _Witness[Any] | None = None, + handler: AsyncResponseHandler | None = None, + auth: AuthDescriptor | AsyncPolicy | None = None, + **call_options: Any, + ) -> Any: + """Run a pre-built `Request` through the pipeline — the raw-call seam. + + Args: + request: A fully-formed request to send as-is. + response_type: The type to decode a 2xx body into, or ``None`` for + the raw `AsyncResponse`. + handler: An explicit response handler overriding the default. + auth: The per-call auth tier. Resolved as for `execute`, but with no + operation tier — the raw seam knows only the per-call and + client tiers. + **call_options: Per-call overrides forwarded to the pipeline. + + Returns: + The decoded value, the raw `AsyncResponse`, or the handler's result. + + Raises: + AuthResolutionError: If the selected auth tier is present but + unsatisfiable. + """ + resolved = self._resolve_auth(None, auth) + return await self._run(request, resolved, response_type, handler, call_options) + + def _resolve_auth( + self, op_auth: AuthDescriptor | None, per_call: AuthDescriptor | AsyncPolicy | None + ) -> AsyncPolicy | None: + """Resolve the tiered auth requirements to the async policy to stamp with. + + A concrete `AsyncPolicy` passed per-call is the most specific tier and + is returned verbatim without descriptor resolution — the BYO override + path. Otherwise the per-call descriptor (if any), the operation's + ``auth``, and the client-level default are resolved by tier against the + registry. + + Args: + op_auth: The operation-level descriptor, or ``None`` (the raw seam + passes ``None`` — it has no operation). + per_call: The per-call tier — a descriptor to resolve, an + `AsyncPolicy` override to pass through, or ``None``. + + Returns: + The chosen stamping policy, or ``None`` when no tier is present. + + Raises: + AuthResolutionError: If the selected tier is present but no + registered scheme satisfies it. + """ + if isinstance(per_call, AsyncPolicy): + return per_call + resolution = resolve_auth( + per_call=per_call, + operation=op_auth, + client=self._client_auth, + registry=self._registry, + ) + return resolution.scheme.policy if resolution is not None else None + + async def _run( + self, + request: Request, + resolved_auth: AsyncPolicy | None, + response_type: _Witness[Any] | None, + handler: AsyncResponseHandler | None, + call_options: Mapping[str, Any], + ) -> Any: + """Pack the resolved auth into options, run the pipeline, await the result.""" + options = plan_options(resolved_auth, call_options) + response = await self._pipeline.run(request, self._dispatch_factory(), **options) + return await self._handle(response, response_type, handler) + + async def _handle( + self, + response: AsyncResponse, + response_type: _Witness[Any] | None, + handler: AsyncResponseHandler | None, + ) -> Any: + """Apply the planned result handling to ``response``, awaiting the handler.""" + plan = plan_result(handler, self._default_handler, response_type, AsyncResponse) + if plan is None: + return response + chosen, witness = plan + return await chosen(response, witness) + + # -- paginate / events ---------------------------------------------------- + + def paginate[T]( + self, + op: Operation, + input: OperationInput, + strategy: PaginationStrategy[T], + *, + max_pages: int | None = None, + auth: AuthDescriptor | AsyncPolicy | None = None, + **call_options: Any, + ) -> AsyncPaginator[T]: + """Return an `AsyncPaginator` walking the operation's paged sequence. + + The returned paginator is the certified `AsyncPaginator`; its wire-exact + splice, close-ledger, and cancellation guarantees carry through + unchanged. Each page fetch runs through this executor's async pipeline + with the packed per-call options. + + Args: + op: The static operation description for the first page. + input: The per-call values for the first page. + strategy: The `PaginationStrategy` that parses each response into a + page and derives the next request. + max_pages: Optional cap on pages fetched. ``None`` is unbounded. + auth: The per-call auth tier, resolved as for `execute` (over the + operation and client tiers) before every page fetch. + **call_options: Per-call overrides forwarded to every page fetch. + + Returns: + An `AsyncPaginator` over ``strategy``'s element type. + + Raises: + AuthResolutionError: If the selected auth tier is present but + unsatisfiable. + """ + from ..pagination import AsyncPaginator + + resolved = self._resolve_auth(op.auth, auth) + request = plan_request(op, input, self._base_url, self._serde) + return AsyncPaginator( + self._page_sender(resolved, call_options), strategy, request, max_pages=max_pages + ) + + def events[T]( + self, + op: Operation, + input: OperationInput, + mapper: SseMapper[T], + *, + auth: AuthDescriptor | AsyncPolicy | None = None, + **call_options: Any, + ) -> AsyncTypedSseConnection[T]: + """Return a typed async SSE connection over the operation's event stream. + + The connection is the certified reconnecting `AsyncSseConnection` + wrapped in the certified `AsyncTypedSseConnection`, with ``mapper`` + applied. The core SSE layer stays free of any sentinel / serde + convention — what a data payload means is entirely the mapper's + decision. + + Args: + op: The static operation description opening the stream. + input: The per-call values for the opening request. + mapper: Maps ``(event_name, data)`` to a typed value or an + `SseSignal` (skip / done). + auth: The per-call auth tier, resolved as for `execute` (over the + operation and client tiers) before every (re)connection. + **call_options: Per-call overrides forwarded to every (re)connection. + + Returns: + An `AsyncTypedSseConnection` over ``mapper``'s value type. + + Raises: + AuthResolutionError: If the selected auth tier is present but + unsatisfiable. + """ + from ..http.sse.connection import AsyncSseConnection + from ..http.sse.typed import AsyncTypedSseConnection + + resolved = self._resolve_auth(op.auth, auth) + request = plan_request(op, input, self._base_url, self._serde) + connection = AsyncSseConnection(self._page_sender(resolved, call_options), request) + return AsyncTypedSseConnection(connection, mapper) + + def _page_sender( + self, auth: AsyncPolicy | None, call_options: Mapping[str, Any] + ) -> Callable[[Request], Awaitable[AsyncResponse]]: + """Build an async send-callable that threads per-call options into each fetch. + + Async paginators and SSE connections accept a bare + ``Request -> Awaitable[AsyncResponse]`` callable. Passing one (rather + than the pipeline itself) is how the packed per-call options reach every + page / reconnection. + """ + options = plan_options(auth, call_options) + + async def send(request: Request) -> AsyncResponse: + return await self._pipeline.run(request, self._dispatch_factory(), **options) + + return send + + # -- lifecycle ------------------------------------------------------------ + + async def aclose(self) -> None: + """Close an owned transport; leave a BYO pipeline untouched. Idempotent. + + A transport this executor built its pipeline around is closed (its own + ownership-aware close then decides whether to tear down its underlying + resource). A caller-supplied pipeline is never closed here. A second + call is a no-op. + """ + if self._closed: + return + self._closed = True + transport = self._owned_transport + if transport is None: + return + aclose = getattr(transport, "aclose", None) + if callable(aclose): + result = aclose() + if isinstance(result, Awaitable): + await result + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/auth.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/auth.py new file mode 100644 index 0000000..28cb466 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/auth.py @@ -0,0 +1,287 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tiered auth-requirement descriptors and their resolver. + +This is pure resolution logic layered *over* the M4 stamping policies in +`http.auth.policies` — it never stamps a credential itself. An operation +declares what authentication it needs as an `AuthDescriptor` (a non-empty, +ordered list of `AuthRequirement` alternatives); the executor resolves that +declaration against an `AuthRegistry` of the concrete stamping policies it has +available, and the chosen policy does the actual header stamping downstream. + +Requirements arrive at three tiers, most specific first: **per-call** (the +``auth=`` argument to ``execute``), then **operation-level** (declared on the +`Operation`), then **client-level** (configured on the executor). Resolution +picks the most specific tier that is *present* and resolves strictly against +that one tier: + +- Presence — not satisfiability — selects the tier. +- The selected tier must then be satisfiable. If it is present but no + registered scheme satisfies any of its requirements, resolution raises + `AuthResolutionError`. It does **not** fall through to a broader tier. + +That no-fall-through rule is the whole point of the tiering: a caller who asks +for a specific per-call credential that cannot be honoured must get a loud +failure, never a silent downgrade to the client-level default — which would +authenticate the request with the wrong credential. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from ..errors import SdkError + +__all__ = [ + "AuthDescriptor", + "AuthRegistry", + "AuthRequirement", + "AuthResolutionError", + "AuthScheme", + "AuthTier", + "ResolvedAuth", + "resolve_auth", + "select_tier", +] + +#: The scopes an OAuth-style requirement asks for; empty for scopeless schemes. +type Scopes = tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class AuthRequirement: + """A single authentication alternative — a named scheme and its scopes. + + Attributes: + scheme: The name of a security scheme the registry may satisfy (e.g. + ``"bearer"``, ``"apikey"``). Must be non-empty. + scopes: The scopes the requirement asks for. Empty for schemes that do + not carry scopes (API key, Basic). A scheme satisfies the + requirement only when it grants every requested scope. + """ + + scheme: str + scopes: Scopes = () + + def __post_init__(self) -> None: + if not self.scheme: + raise ValueError("AuthRequirement.scheme must be a non-empty scheme name") + + +#: A non-empty ordered tuple of requirement alternatives (OR semantics). +type AuthRequirements = tuple[AuthRequirement, ...] + + +@dataclass(frozen=True, slots=True) +class AuthDescriptor: + """A non-empty ordered list of authentication alternatives. + + The requirements are an OR: a descriptor is satisfied by the *first* of its + alternatives the registry can satisfy, tried in order. A descriptor with no + requirements is meaningless and rejected at construction. + + Attributes: + requirements: The ordered alternatives; must be non-empty. + """ + + requirements: AuthRequirements + + def __post_init__(self) -> None: + if not self.requirements: + raise ValueError("AuthDescriptor requires at least one AuthRequirement") + + @classmethod + def of(cls, *requirements: AuthRequirement) -> AuthDescriptor: + """Build a descriptor from one or more requirement alternatives. + + Args: + *requirements: The ordered alternatives. + + Returns: + A descriptor over ``requirements``. + + Raises: + ValueError: If no requirements are given. + """ + return cls(requirements=requirements) + + +class AuthTier(Enum): + """Which tier a resolution was drawn from, most specific first.""" + + PER_CALL = "per_call" + OPERATION = "operation" + CLIENT = "client" + + +@dataclass(frozen=True, slots=True) +class AuthScheme[P]: + """A named scheme paired with the stamping policy that realises it. + + The resolver never stamps anything; it selects an `AuthScheme` whose + ``policy`` (one of the M4 auth policies) is then applied downstream. ``P`` + is the policy type — `Policy` for the sync executor, `AsyncPolicy` for the + async one. + + Attributes: + name: The scheme name this policy answers to (matched against + `AuthRequirement.scheme`). + policy: The concrete stamping policy the executor applies when this + scheme is chosen. + scopes: The scopes this scheme can grant. A requirement is satisfied + only when its scopes are a subset of these; the default empty set + models a scopeless scheme (API key, Basic). + """ + + name: str + policy: P + scopes: frozenset[str] = frozenset() + + def satisfies(self, requirement: AuthRequirement) -> bool: + """Report whether this scheme can satisfy ``requirement``. + + Args: + requirement: The alternative to test. + + Returns: + ``True`` when the names match and every requested scope is granted. + """ + return requirement.scheme == self.name and set(requirement.scopes) <= self.scopes + + +@dataclass(frozen=True, slots=True) +class AuthRegistry[P]: + """The pool of schemes an executor has available to satisfy requirements. + + Attributes: + schemes: The available schemes, scanned in order; the first that + satisfies a requirement wins. + """ + + schemes: tuple[AuthScheme[P], ...] = () + + def scheme_for(self, requirement: AuthRequirement) -> AuthScheme[P] | None: + """Return the first registered scheme that satisfies ``requirement``. + + Args: + requirement: The alternative to satisfy. + + Returns: + The satisfying scheme, or ``None`` when none is registered for it. + """ + for scheme in self.schemes: + if scheme.satisfies(requirement): + return scheme + return None + + +@dataclass(frozen=True, slots=True) +class ResolvedAuth[P]: + """The outcome of a successful resolution. + + Attributes: + tier: The tier the requirement was drawn from. + requirement: The specific alternative that was satisfied. + scheme: The registered scheme (carrying the stamping ``policy``) chosen + to satisfy it. + """ + + tier: AuthTier + requirement: AuthRequirement + scheme: AuthScheme[P] + + +class AuthResolutionError(SdkError): + """A present auth tier could not be satisfied by any registered scheme. + + Raised instead of falling through to a broader tier, so a caller that asked + for a specific credential learns it could not be honoured rather than + silently authenticating with a different one. + + Attributes: + tier: The (most-specific present) tier that failed to resolve. + descriptor: The unsatisfiable descriptor from that tier. + """ + + tier: AuthTier + descriptor: AuthDescriptor + + def __init__(self, tier: AuthTier, descriptor: AuthDescriptor) -> None: + self.tier = tier + self.descriptor = descriptor + wanted = ", ".join(sorted({req.scheme for req in descriptor.requirements})) + super().__init__( + f"the {tier.value} auth requirement is present but unsatisfiable: no registered " + f"auth scheme satisfies any of [{wanted}]. Resolution does not fall through to a " + f"broader tier." + ) + + +def select_tier( + *, + per_call: AuthDescriptor | None, + operation: AuthDescriptor | None, + client: AuthDescriptor | None, +) -> tuple[AuthTier, AuthDescriptor] | None: + """Pick the most-specific *present* tier — presence alone selects it. + + Satisfiability is deliberately not consulted here: a present tier is + selected even when it cannot be satisfied, so that `resolve_auth` can then + fail against it rather than fall through to a broader tier. + + Args: + per_call: The per-call descriptor, or ``None`` if none was given. + operation: The operation-level descriptor, or ``None``. + client: The client-level descriptor, or ``None``. + + Returns: + The selected tier and its descriptor, or ``None`` when every tier is + absent. + """ + if per_call is not None: + return (AuthTier.PER_CALL, per_call) + if operation is not None: + return (AuthTier.OPERATION, operation) + if client is not None: + return (AuthTier.CLIENT, client) + return None + + +def resolve_auth[P]( + *, + per_call: AuthDescriptor | None, + operation: AuthDescriptor | None, + client: AuthDescriptor | None, + registry: AuthRegistry[P], +) -> ResolvedAuth[P] | None: + """Resolve the tiered requirements to a single scheme, or raise. + + Selects the most-specific present tier (`select_tier`) and resolves its + ordered requirements against ``registry``, returning the first that a + registered scheme satisfies. When the selected tier is present but nothing + satisfies it, raises `AuthResolutionError` — it never falls through to a + broader tier. + + Args: + per_call: The per-call descriptor, or ``None``. + operation: The operation-level descriptor, or ``None``. + client: The client-level descriptor, or ``None``. + registry: The pool of schemes available to satisfy a requirement. + + Returns: + The resolution, or ``None`` when no tier is present (unauthenticated). + + Raises: + AuthResolutionError: When the selected tier is present but unsatisfiable. + """ + selected = select_tier(per_call=per_call, operation=operation, client=client) + if selected is None: + return None + tier, descriptor = selected + for requirement in descriptor.requirements: + scheme = registry.scheme_for(requirement) + if scheme is not None: + return ResolvedAuth(tier=tier, requirement=requirement, scheme=scheme) + raise AuthResolutionError(tier, descriptor) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/merge_config.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/merge_config.py new file mode 100644 index 0000000..5b7423b --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/merge_config.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Fold `ServiceDefaults` + a user override into `ServiceCore`'s kwargs. + +`merge_config` is the Tier-2 flattening step: it never builds a second +pipeline and never wraps one client in another. Given a transport, it makes +exactly one `default_pipeline(...)` call — with the service's User-Agent +token and api-version stamp folded in as ordinary policy kwargs/appends — and +returns the resulting single `Pipeline` under the `pipeline` key so +`ServiceCore(**merge_config(...))` borrows it. Given a caller-supplied +already-built `Pipeline` instead (the BYO / shared-transport case), the +pipeline passes through untouched: nesting is reserved for that case alone, +and even then `ServiceCore`'s existing ownership-aware close means the shared +transport is never closed by the executor. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..pipeline.defaults import default_pipeline +from ..pipeline.policies.client_identity import ClientIdentityPolicy, default_user_agent +from ._api_version_policy import ApiVersionPolicy +from .service_defaults import DEFAULT_API_VERSION_HEADER, ServiceDefaults, merge_service_defaults + +if TYPE_CHECKING: + from ..client.http_client import HttpClient + from ..pipeline.pipeline import Pipeline + from ..pipeline.policies.idempotency import IdempotencyPolicy + from ..pipeline.policies.logging_policy import LoggingPolicy + from ..pipeline.policies.redirect import RedirectPolicy + from ..pipeline.policies.retry import RetryPolicy + from ..pipeline.policies.set_date import SetDatePolicy + from ..pipeline.policies.tracing_policy import TracingPolicy + from ..pipeline.policy import Policy + +__all__ = ["merge_config"] + + +def merge_config( + defaults: ServiceDefaults, + user: ServiceDefaults | None = None, + *, + transport: HttpClient | None = None, + pipeline: Pipeline | None = None, + redirect: RedirectPolicy | None = None, + idempotency: IdempotencyPolicy | None = None, + retry: RetryPolicy | None = None, + set_date: SetDatePolicy | None = None, + auth: Policy | None = None, + logging: LoggingPolicy | None = None, + tracing: TracingPolicy | None = None, +) -> dict[str, Any]: + """Build `ServiceCore`'s constructor kwargs from service + user config. + + Args: + defaults: The service's own baked-in defaults. + user: The caller's override of the same shape. A non-``None`` field + here always wins; a ``None`` field falls through to ``defaults``. + transport: A transport to build the one customized `default_pipeline` + around. Mutually exclusive with ``pipeline``. + pipeline: A caller-supplied, already-built `Pipeline` — the BYO / + shared-transport case. Passed through unmodified: service + defaults cannot be layered onto an opaque pipeline, so use + ``transport=`` if the api-version stamp or User-Agent token must + apply. Mutually exclusive with ``transport``. + redirect: Forwarded to `default_pipeline` when building around + ``transport``. Ignored when ``pipeline`` is given. + idempotency: Forwarded to `default_pipeline`. Ignored when + ``pipeline`` is given. + retry: Forwarded to `default_pipeline`. Ignored when ``pipeline`` is + given. + set_date: Forwarded to `default_pipeline`. Ignored when ``pipeline`` + is given. + auth: Forwarded to `default_pipeline`. Ignored when ``pipeline`` is + given. + logging: Forwarded to `default_pipeline`. Ignored when ``pipeline`` + is given. + tracing: Forwarded to `default_pipeline`. Ignored when ``pipeline`` + is given. + + Returns: + A mapping with ``base_url`` / ``pipeline`` / ``errors`` / ``serde`` + keys ready to splat into ``ServiceCore(**merge_config(...))``. + + Raises: + ValueError: If neither or both of ``transport`` and ``pipeline`` are + given, or if neither ``defaults`` nor ``user`` resolves a + ``base_url``. + """ + if (transport is None) == (pipeline is None): + raise ValueError("provide exactly one of transport= or pipeline=") + merged = merge_service_defaults(defaults, user) + if merged.base_url is None: + raise ValueError("base_url must be set by service defaults or user config") + if pipeline is not None: + resolved_pipeline = pipeline + else: + assert transport is not None # narrowed by the exclusivity guard above + resolved_pipeline = _build_pipeline( + merged, + transport, + redirect=redirect, + idempotency=idempotency, + retry=retry, + set_date=set_date, + auth=auth, + logging=logging, + tracing=tracing, + ) + return { + "base_url": merged.base_url, + "pipeline": resolved_pipeline, + "errors": merged.errors, + "serde": merged.serde, + } + + +def _build_pipeline( + merged: ServiceDefaults, + transport: HttpClient, + *, + redirect: RedirectPolicy | None, + idempotency: IdempotencyPolicy | None, + retry: RetryPolicy | None, + set_date: SetDatePolicy | None, + auth: Policy | None, + logging: LoggingPolicy | None, + tracing: TracingPolicy | None, +) -> Pipeline: + """Build the single pipeline `merge_config` hands to `ServiceCore` as BYO. + + Exactly one `default_pipeline(...)` call, exactly one `.build()` — the + service's User-Agent token and api-version stamp are folded in as an + ordinary `client_identity` kwarg and a `PRE_SEND` append, never a second + pipeline or a policy fork. + """ + client_identity = None + if merged.user_agent_token is not None: + client_identity = ClientIdentityPolicy( + user_agent=f"{merged.user_agent_token} {default_user_agent()}" + ) + builder = default_pipeline( + transport, + redirect=redirect, + idempotency=idempotency, + retry=retry, + set_date=set_date, + client_identity=client_identity, + auth=auth, + logging=logging, + tracing=tracing, + ) + if merged.api_version is not None: + header = merged.api_version_header or DEFAULT_API_VERSION_HEADER + builder.append(ApiVersionPolicy(header, merged.api_version)) + return builder.build() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/operation.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/operation.py new file mode 100644 index 0000000..129aaf0 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/operation.py @@ -0,0 +1,91 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Declarative operation description for the Tier-1 codegen contract. + +`Operation` describes a single request shape a generated client would target — +method, path template, and any static headers — while `OperationInput` supplies +the per-call values (path parameters, query parameters, extra headers, body). +Both are frozen, keyword-only dataclasses so a future minor version can append +fields without breaking any positional call site. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from ..http.common import Headers, QueryParams +from ..http.request import Method + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ..http.request import RequestBody + from .auth import AuthDescriptor + +__all__ = ["Operation", "OperationInput"] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Operation: + """A declarative description of one request shape (the static half). + + An `Operation` is the reusable, call-independent half of the contract: it + names the method and the path template and carries any headers that are + constant for every invocation. The per-call values live in `OperationInput` + and are combined with the operation by `assemble`. + + Keyword-only construction is deliberate — it lets a future minor version + append a field without breaking positional call sites, which is the + forward-compatibility guarantee a generated client depends on. + + Attributes: + name: An advisory label used only to stamp tracing / diagnostic context + for readability. It NEVER influences request assembly, dispatch + routing, or any cache / store key, and is excluded from equality and + hashing so two operations differing only in ``name`` compare equal. + method: The HTTP method for the request. + path: The path template appended to the base URL. May contain + ``{placeholder}`` tokens that `assemble` substitutes from + `OperationInput.path_params`. + headers: Static headers constant across every call. Per-call headers in + `OperationInput.headers` are overlaid on top of these by `assemble`. + auth: The operation-level authentication requirement, if the operation + declares one. It is the middle tier the executor's resolver consults + — used only when no per-call override is passed, and overridden in + turn by one that is. ``None`` (the default) leaves the operation + without its own requirement, deferring to the client-level default. + """ + + name: str = field(compare=False) + method: Method + path: str + headers: Headers = field(default_factory=Headers) + auth: AuthDescriptor | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class OperationInput: + """The per-call values combined with an `Operation` to build a request. + + Every field defaults to empty, so a parameterless operation needs only + ``OperationInput()``. Keyword-only construction mirrors `Operation` for the + same forward-compatibility reason. + + Attributes: + path_params: Values substituted into the operation's ``{placeholder}`` + path tokens. Each value is percent-encoded as a single path segment + by `assemble`. + query: Query parameters appended after any query already present on the + base URL. + headers: Per-call headers overlaid on the operation's static headers, + taking precedence per name. + body: The optional request payload, carried through verbatim. This layer + does not serialize the body — that is a later executor's concern. + """ + + path_params: Mapping[str, str] = field(default_factory=dict) + query: QueryParams = field(default_factory=QueryParams) + headers: Headers = field(default_factory=Headers) + body: RequestBody | None = None diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/service_core.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/service_core.py new file mode 100644 index 0000000..6204557 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/service_core.py @@ -0,0 +1,487 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Synchronous platform executor a generated SDK facade delegates to. + +`ServiceCore` is the sync half of the executor pair. A generated client method +does not talk to the pipeline directly; it declares an `Operation`, packs its +arguments into an `OperationInput`, and calls one of this executor's four +entry points: + +- `execute` / `execute_request` — a single request/response exchange, decoded + into the caller's `response_type` by the default `DecodeSuccess` handler. +- `paginate` — returns the certified `Paginator`, so the wire-exact splice and + close-ledger guarantees carry through unchanged. +- `events` — returns the certified SSE connection with a typed mapper applied, + leaving the core SSE layer free of any service-specific convention. + +Every non-awaiting decision — request binding, per-call options packing, and +the raw-vs-handler result routing — lives in the shared pure planners under +`_pure/`, which `AsyncServiceCore` imports too; only the awaiting shell differs +between the two. The executor is an ownership-aware context manager: closing it +closes a transport it constructed a pipeline around, but never a caller-supplied +(BYO) pipeline. +""" + +from __future__ import annotations + +from types import TracebackType +from typing import TYPE_CHECKING, Any, Self, overload + +from ..http.response.response import Response +from ..pipeline.defaults import default_pipeline +from ..pipeline.policy import Policy +from ..serde import JSON_SERDE, DecodeSuccess +from ._pure.options import plan_options +from ._pure.requests import plan_request +from ._pure.results import plan_result +from .auth import AuthRegistry, resolve_auth +from .status_error_map import StatusErrorMap + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from ..client.http_client import HttpClient + from ..http.common import Url + from ..http.context.dispatch_context import DispatchContext + from ..http.request.request import Request + from ..http.sse.typed import SseMapper, TypedSseConnection + from ..pagination import PaginationStrategy, Paginator + from ..pipeline.pipeline import Pipeline + from ..serde import ResponseHandler, Serde, TypeRef + from .auth import AuthDescriptor + from .operation import Operation, OperationInput + +__all__ = ["ServiceCore"] + +#: A resolved response-type witness accepted by `execute` / `execute_request`. +type _Witness[T] = type[T] | TypeRef[T] + + +class ServiceCore: + """Synchronous executor over the shared pure planners. + + Construct from exactly one of a transport or a BYO pipeline: + + - ``ServiceCore(base_url=..., transport=client)`` builds the canonical + `default_pipeline` around ``client`` and *owns* it — closing the executor + closes ``client``. + - ``ServiceCore(base_url=..., pipeline=my_pipeline)`` borrows ``my_pipeline`` + and never closes it (bake in auth or a custom stack this way). + """ + + __slots__ = ( + "_base_url", + "_client_auth", + "_closed", + "_default_handler", + "_dispatch_factory", + "_errors", + "_owned_transport", + "_pipeline", + "_registry", + "_serde", + ) + + def __init__( + self, + *, + base_url: str | Url, + pipeline: Pipeline | None = None, + transport: HttpClient | None = None, + errors: StatusErrorMap | None = None, + serde: Serde | None = None, + dispatch_factory: Callable[[], DispatchContext] | None = None, + auth: AuthDescriptor | None = None, + auth_registry: AuthRegistry[Policy] | None = None, + ) -> None: + """Wire the executor to a pipeline (BYO) or a transport it builds one around. + + Args: + base_url: Absolute base URL every operation's path joins onto. + pipeline: A caller-supplied `Pipeline`. Borrowed, never closed by + this executor. Mutually exclusive with ``transport``. + transport: An `HttpClient` to build the `default_pipeline` around. + Owned — closing the executor closes it. Mutually exclusive with + ``pipeline``. + errors: Status → typed-error mapping for the default handler. + Defaults to an empty map (a bare `HttpResponseError` per error + status). + serde: Wire-format serde for request/response bodies. Defaults to + JSON. + dispatch_factory: Builds the per-call dispatch context. Defaults to + `DispatchContext.noop`. + auth: The client-level authentication requirement — the broadest + resolver tier, used when neither a per-call nor an operation-level + requirement is present. ``None`` leaves the client without a + default requirement. + auth_registry: The pool of `AuthScheme`s a declared requirement is + resolved against. ``None`` is an empty registry, so any present + requirement is unsatisfiable (and raises rather than falling + through). + + Raises: + ValueError: If neither or both of ``pipeline`` and ``transport`` are + given. + """ + if (pipeline is None) == (transport is None): + raise ValueError("provide exactly one of pipeline= or transport=") + from ..http.context.dispatch_context import DispatchContext + + self._base_url = base_url + self._serde: Serde = serde if serde is not None else JSON_SERDE + self._errors = errors if errors is not None else StatusErrorMap() + self._client_auth = auth + self._registry: AuthRegistry[Policy] = ( + auth_registry if auth_registry is not None else AuthRegistry() + ) + self._dispatch_factory = ( + dispatch_factory if dispatch_factory is not None else DispatchContext.noop + ) + self._default_handler: ResponseHandler = DecodeSuccess(self._errors, serde=self._serde) + self._closed = False + if pipeline is not None: + self._pipeline = pipeline + self._owned_transport: HttpClient | None = None + else: + assert transport is not None # narrowed by the exclusivity guard above + self._pipeline = default_pipeline(transport).build() + self._owned_transport = transport + + # -- execute -------------------------------------------------------------- + + @overload + def execute[T]( + self, + op: Operation, + input: OperationInput, + *, + response_type: _Witness[T], + handler: ResponseHandler | None = ..., + auth: AuthDescriptor | Policy | None = ..., + **call_options: Any, + ) -> T: ... + + @overload + def execute( + self, + op: Operation, + input: OperationInput, + *, + response_type: None = ..., + handler: None = ..., + auth: AuthDescriptor | Policy | None = ..., + **call_options: Any, + ) -> Response: ... + + @overload + def execute( + self, + op: Operation, + input: OperationInput, + *, + response_type: None = ..., + handler: ResponseHandler, + auth: AuthDescriptor | Policy | None = ..., + **call_options: Any, + ) -> Any: ... + + def execute( + self, + op: Operation, + input: OperationInput, + *, + response_type: _Witness[Any] | None = None, + handler: ResponseHandler | None = None, + auth: AuthDescriptor | Policy | None = None, + **call_options: Any, + ) -> Any: + """Run one operation and return its decoded result. + + Args: + op: The static operation description. + input: The per-call values (path params, query, headers, body). + response_type: The type to decode a 2xx body into. When ``None`` + (and no ``handler``), the raw `Response` is returned for the + caller to consume and close. + handler: An explicit response handler overriding the default + `DecodeSuccess`. Receives the response and the witness. + auth: The per-call auth tier. An `AuthDescriptor` is resolved + against the executor's registry, over the operation's ``auth`` + and the client-level default; a concrete `Policy` is a verbatim + override; ``None`` defers to the operation / client tiers. + **call_options: Per-call overrides forwarded to the pipeline's + policies via ``ctx.options``. + + Returns: + The decoded value for a given ``response_type``, the raw `Response` + when neither ``response_type`` nor ``handler`` is given, or the + handler's result. + + Raises: + AuthResolutionError: If the selected auth tier is present but no + registered scheme satisfies it (never falls through to a + broader tier). + """ + request = plan_request(op, input, self._base_url, self._serde) + resolved = self._resolve_auth(op.auth, auth) + return self._run(request, resolved, response_type, handler, call_options) + + # -- execute_request ------------------------------------------------------ + + @overload + def execute_request[T]( + self, + request: Request, + *, + response_type: _Witness[T], + handler: ResponseHandler | None = ..., + auth: AuthDescriptor | Policy | None = ..., + **call_options: Any, + ) -> T: ... + + @overload + def execute_request( + self, + request: Request, + *, + response_type: None = ..., + handler: None = ..., + auth: AuthDescriptor | Policy | None = ..., + **call_options: Any, + ) -> Response: ... + + @overload + def execute_request( + self, + request: Request, + *, + response_type: None = ..., + handler: ResponseHandler, + auth: AuthDescriptor | Policy | None = ..., + **call_options: Any, + ) -> Any: ... + + def execute_request( + self, + request: Request, + *, + response_type: _Witness[Any] | None = None, + handler: ResponseHandler | None = None, + auth: AuthDescriptor | Policy | None = None, + **call_options: Any, + ) -> Any: + """Run a pre-built `Request` through the pipeline — the raw-call seam. + + Args: + request: A fully-formed request to send as-is. + response_type: The type to decode a 2xx body into, or ``None`` for + the raw `Response`. + handler: An explicit response handler overriding the default. + auth: The per-call auth tier. Resolved as for `execute`, but with no + operation tier — the raw seam knows only the per-call and + client tiers. + **call_options: Per-call overrides forwarded to the pipeline. + + Returns: + The decoded value, the raw `Response`, or the handler's result — as + for `execute`. + + Raises: + AuthResolutionError: If the selected auth tier is present but + unsatisfiable. + """ + resolved = self._resolve_auth(None, auth) + return self._run(request, resolved, response_type, handler, call_options) + + def _resolve_auth( + self, op_auth: AuthDescriptor | None, per_call: AuthDescriptor | Policy | None + ) -> Policy | None: + """Resolve the tiered auth requirements to the policy to stamp with. + + A concrete `Policy` passed per-call is the most specific tier and is + returned verbatim without descriptor resolution — the BYO override path. + Otherwise the per-call descriptor (if any), the operation's ``auth``, + and the client-level default are resolved by tier against the registry. + + Args: + op_auth: The operation-level descriptor, or ``None`` (the raw seam + passes ``None`` — it has no operation). + per_call: The per-call tier — a descriptor to resolve, a `Policy` + override to pass through, or ``None``. + + Returns: + The chosen stamping policy, or ``None`` when no tier is present. + + Raises: + AuthResolutionError: If the selected tier is present but no + registered scheme satisfies it. + """ + if isinstance(per_call, Policy): + return per_call + resolution = resolve_auth( + per_call=per_call, + operation=op_auth, + client=self._client_auth, + registry=self._registry, + ) + return resolution.scheme.policy if resolution is not None else None + + def _run( + self, + request: Request, + resolved_auth: Policy | None, + response_type: _Witness[Any] | None, + handler: ResponseHandler | None, + call_options: Mapping[str, Any], + ) -> Any: + """Pack the resolved auth into options, run the pipeline, handle the result.""" + options = plan_options(resolved_auth, call_options) + response = self._pipeline.run(request, self._dispatch_factory(), **options) + return self._handle(response, response_type, handler) + + def _handle( + self, + response: Response, + response_type: _Witness[Any] | None, + handler: ResponseHandler | None, + ) -> Any: + """Apply the planned result handling to ``response`` (awaiting-free).""" + plan = plan_result(handler, self._default_handler, response_type, Response) + if plan is None: + return response + chosen, witness = plan + return chosen(response, witness) + + # -- paginate / events ---------------------------------------------------- + + def paginate[T]( + self, + op: Operation, + input: OperationInput, + strategy: PaginationStrategy[T], + *, + max_pages: int | None = None, + auth: AuthDescriptor | Policy | None = None, + **call_options: Any, + ) -> Paginator[T]: + """Return a `Paginator` walking the operation's paged sequence. + + The returned paginator is the certified `Paginator`; it is never + re-implemented here, so its wire-exact next-request splice and + close-ledger guarantees carry through. Each page fetch runs through this + executor's pipeline with the packed per-call options. + + Args: + op: The static operation description for the first page. + input: The per-call values for the first page. + strategy: The `PaginationStrategy` that parses each response into a + page and derives the next request. + max_pages: Optional cap on pages fetched. ``None`` is unbounded. + auth: The per-call auth tier, resolved as for `execute` (over the + operation and client tiers) before every page fetch. + **call_options: Per-call overrides forwarded to every page fetch. + + Returns: + A `Paginator` over ``strategy``'s element type. + + Raises: + AuthResolutionError: If the selected auth tier is present but + unsatisfiable. + """ + from ..pagination import Paginator + + resolved = self._resolve_auth(op.auth, auth) + request = plan_request(op, input, self._base_url, self._serde) + return Paginator( + self._page_sender(resolved, call_options), strategy, request, max_pages=max_pages + ) + + def events[T]( + self, + op: Operation, + input: OperationInput, + mapper: SseMapper[T], + *, + auth: AuthDescriptor | Policy | None = None, + **call_options: Any, + ) -> TypedSseConnection[T]: + """Return a typed SSE connection over the operation's event stream. + + The connection is the certified reconnecting `SseConnection` wrapped in + the certified `TypedSseConnection`, with ``mapper`` applied. The core + SSE layer stays free of any sentinel / serde convention — what a data + payload means (a ``[DONE]`` marker, a JSON document) is entirely the + mapper's decision. + + Args: + op: The static operation description opening the stream. + input: The per-call values for the opening request. + mapper: Maps ``(event_name, data)`` to a typed value or an + `SseSignal` (skip / done). + auth: The per-call auth tier, resolved as for `execute` (over the + operation and client tiers) before every (re)connection. + **call_options: Per-call overrides forwarded to every (re)connection. + + Returns: + A `TypedSseConnection` over ``mapper``'s value type. + + Raises: + AuthResolutionError: If the selected auth tier is present but + unsatisfiable. + """ + from ..http.sse.connection import SseConnection + from ..http.sse.typed import TypedSseConnection + + resolved = self._resolve_auth(op.auth, auth) + request = plan_request(op, input, self._base_url, self._serde) + connection = SseConnection(self._page_sender(resolved, call_options), request) + return TypedSseConnection(connection, mapper) + + def _page_sender( + self, auth: Policy | None, call_options: Mapping[str, Any] + ) -> Callable[[Request], Response]: + """Build a send-callable that threads per-call options into each fetch. + + Paginators and SSE connections accept a bare ``Request -> Response`` + callable. Passing one (rather than the pipeline itself) is how the + packed per-call options reach every page / reconnection, since their + built-in pipeline path runs ``pipeline.run`` without options. + """ + options = plan_options(auth, call_options) + + def send(request: Request) -> Response: + return self._pipeline.run(request, self._dispatch_factory(), **options) + + return send + + # -- lifecycle ------------------------------------------------------------ + + def close(self) -> None: + """Close an owned transport; leave a BYO pipeline untouched. Idempotent. + + A transport this executor built its pipeline around is closed (its own + ownership-aware close then decides whether to tear down its underlying + resource). A caller-supplied pipeline is never closed here. A second + call is a no-op. + """ + if self._closed: + return + self._closed = True + transport = self._owned_transport + if transport is None: + return + close = getattr(transport, "close", None) + if callable(close): + close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/service_defaults.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/service_defaults.py new file mode 100644 index 0000000..d172d35 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/service_defaults.py @@ -0,0 +1,99 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Baked-in service-level configuration layered onto the single core pipeline. + +`ServiceDefaults` is the Tier-2 seam: an enterprise/service SDK generated on +top of this toolkit bakes its own defaults (base URL, product User-Agent +token, api-version stamp, error map, wire serde) into one instance, and +`merge_config` / `merge_async_config` (in `merge_config.py` / +`async_merge_config.py`) fold it — plus an optional user-supplied override of +the same shape — into the single core pipeline's constructor kwargs. There is +never a second pipeline and never a wrapper client: the service's +customizations are policies appended to the same `default_pipeline` / +`default_async_pipeline` call that builds the executor's one pipeline. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, replace +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from ..http.common import Url + from ..serde import Serde + from .status_error_map import StatusErrorMap + +__all__ = ["DEFAULT_API_VERSION_HEADER", "ServiceDefaults"] + +#: Header an `api_version` stamp is written under when `ServiceDefaults` +#: leaves `api_version_header` unset. +DEFAULT_API_VERSION_HEADER: Final[str] = "Api-Version" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ServiceDefaults: + """A service's baked-in defaults for its generated client. + + Every field defaults to ``None`` — "no opinion, fall through to whatever + the next layer supplies". `merge_config` / `merge_async_config` resolve a + `ServiceDefaults` (the service's own baked-in choices) against an optional + second `ServiceDefaults` (the end caller's overrides) field-wise: a + non-``None`` field on the caller's override always wins, a non-``None`` + field on the service defaults wins next, and the toolkit's own defaults + (`default_pipeline`'s fallbacks, `ServiceCore`'s fallbacks) apply only when + both leave a field unset. + + Attributes: + base_url: The service's default base URL. A caller override replaces + it outright; one of the two must resolve to non-``None`` or + `merge_config` raises. + user_agent_token: The service's own product token (for example + ``"acme-sdk/2.3"``). Fed into `ClientIdentityPolicy` as the + leading token, so it is PREPENDED to the platform + (``dexpace-sdk/``) and runtime (``python/``) tokens the + policy already appends — the ordered list reads service, then + platform, then runtime. + api_version: The api-version value to stamp on every outgoing + request, or ``None`` to stamp nothing. Applied by a policy at the + innermost non-terminal pipeline stage, so it is re-stamped on + every redirect hop and every retry attempt. + api_version_header: The header name the `api_version` value is + stamped under. ``None`` falls back to `DEFAULT_API_VERSION_HEADER`. + errors: The service's default status -> typed-error map, forwarded to + `ServiceCore` / `AsyncServiceCore` verbatim. + serde: The service's default wire-format serde, forwarded to + `ServiceCore` / `AsyncServiceCore` verbatim. + """ + + base_url: str | Url | None = None + user_agent_token: str | None = None + api_version: str | None = None + api_version_header: str | None = None + errors: StatusErrorMap | None = None + serde: Serde | None = None + + +def merge_service_defaults( + defaults: ServiceDefaults, user: ServiceDefaults | None +) -> ServiceDefaults: + """Field-wise merge: every non-``None`` field on ``user`` wins over ``defaults``. + + Args: + defaults: The service's own baked-in defaults. + user: The caller's override, or ``None`` for no override at all (in + which case ``defaults`` is returned unchanged). + + Returns: + A `ServiceDefaults` with each field resolved independently — the + precedence is per-field, not per-object, so a caller overriding only + ``base_url`` still inherits the service's ``user_agent_token``. + """ + if user is None: + return defaults + overrides = { + f.name: value + for f in fields(ServiceDefaults) + if (value := getattr(user, f.name)) is not None + } + return replace(defaults, **overrides) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/status_error_map.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/status_error_map.py new file mode 100644 index 0000000..5267955 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/codegen/status_error_map.py @@ -0,0 +1,161 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Data-driven status-code → typed-error mapping for generated clients. + +`StatusErrorMap` is the declarative half of a generated client's error-handling +contract. It maps HTTP status codes to the `HttpResponseError` subclasses a +service defines, with a fallback ``default`` class for any error status not +named explicitly. The map is frozen once built and carries no branching logic +itself — `raise_for` and `for_status` read it as data, so a service SDK never +grows a hand-written ``if status == ...: raise`` chain. + +The map spans the *response* branch of the error taxonomy only: every registered +class must extend `HttpResponseError` and must NOT extend `OSError`. That is +enforced at construction (`TypeError` otherwise) and is the hard rule a service +taxonomy relies on — a service-specific error can never masquerade as a +transport-level `NetworkError`, so the map never catches a `NetworkError` and +re-wraps it onto the response branch. `except OSError:` call sites keep matching +transport failures unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import TYPE_CHECKING, NoReturn + +from ..errors.http import HttpResponseError + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ..http.response.response import Response + +__all__ = ["StatusErrorMap"] + + +def _validate_branch(error_class: type[HttpResponseError]) -> None: + """Reject an error class that is not purely on the response branch. + + Args: + error_class: A candidate class registered in a `StatusErrorMap` (either + a per-status override or the ``default``). + + Raises: + TypeError: If `error_class` is not a subclass of `HttpResponseError`, or + if it also subclasses `OSError` — which would place it on the + transport / `NetworkError` branch and let an `except OSError:` site + catch a response-branch error. + """ + if not (isinstance(error_class, type) and issubclass(error_class, HttpResponseError)): + raise TypeError( + f"StatusErrorMap error classes must subclass HttpResponseError; got {error_class!r}" + ) + if issubclass(error_class, OSError): + raise TypeError( + "StatusErrorMap error classes must not subclass OSError " + f"(the NetworkError / transport branch); got {error_class!r}" + ) + + +def _is_error_status(status: int) -> bool: + """Return whether `status` is a 4xx or 5xx HTTP error code. + + Args: + status: An HTTP status code. + + Returns: + ``True`` for ``400 <= status < 600`` (client or server error), + matching `Status.is_error`; ``False`` for informational, success, and + redirect codes. + """ + return 400 <= status < 600 + + +@dataclass(frozen=True, slots=True, kw_only=True) +class StatusErrorMap: + """A frozen status-code → typed-error mapping consumed as data. + + Keyword-only, frozen, and slotted so a future minor version can append a + field without breaking positional call sites and the built map is immutable. + ``by_status`` is defensively copied into a read-only view at construction, so + mutating the caller's original dict afterwards never affects the map. + + Attributes: + by_status: Per-status overrides. A status present here wins over + ``default``. Keys may be plain ints or `Status` members (a `Status` + is an ``int``); values must be `HttpResponseError` subclasses that + are not also `OSError` subclasses. + default: The fallback error class for any error status not named in + ``by_status``. Defaults to `HttpResponseError`. + """ + + by_status: Mapping[int, type[HttpResponseError]] = field(default_factory=dict) + default: type[HttpResponseError] = HttpResponseError + + def __post_init__(self) -> None: + """Validate branch membership and freeze ``by_status`` into a read-only view. + + Raises: + TypeError: If ``default`` or any ``by_status`` value is off the + response branch (see `_validate_branch`). + """ + _validate_branch(self.default) + frozen: dict[int, type[HttpResponseError]] = {} + for status, error_class in self.by_status.items(): + _validate_branch(error_class) + frozen[int(status)] = error_class + object.__setattr__(self, "by_status", MappingProxyType(frozen)) + + def for_status(self, status: int) -> type[HttpResponseError] | None: + """Return the error class a status *would* raise, without raising. + + The permissive counterpart to `raise_for`: it never raises and never + buffers a body, so a caller can peek at the error taxonomy for a status + without triggering the full raise path. A non-error status returns + ``None``; an error status returns its per-status override if one is + registered, otherwise ``default``. + + Args: + status: The HTTP status code to look up — a plain ``int`` or a + `Status` member. + + Returns: + The mapped `HttpResponseError` subclass for a 4xx/5xx status, or + ``None`` when `status` is not an error code. + """ + if not _is_error_status(status): + return None + mapped: type[HttpResponseError] | None = self.by_status.get(int(status)) + return mapped if mapped is not None else self.default + + def raise_for(self, response: Response) -> NoReturn: + """Raise the typed error mapped to ``response``'s status. + + The strict counterpart to `for_status`: it always raises. Calling it on + a non-error (2xx / 3xx / 1xx) response is a caller error — it raises + `ValueError` rather than fabricating a success-looking exception. For an + error response it constructs the mapped error (the per-status override, + else ``default``), buffers a bounded, replayable copy of the response + body onto it, then raises it. + + Args: + response: The received response to raise for. Its status must be a + 4xx or 5xx error. + + Raises: + ValueError: If ``response``'s status is not a 4xx/5xx error. + HttpResponseError: The mapped error, carrying ``response`` and its + bounded, replayable buffered body. + """ + status = response.status + if not status.is_error: + raise ValueError( + f"raise_for requires an error (4xx/5xx) response; got a non-error status {status!r}" + ) + mapped: type[HttpResponseError] | None = self.by_status.get(int(status)) + error_class = mapped if mapped is not None else self.default + error = error_class(response=response) + error.read_body() # buffer a bounded, replayable copy before it propagates + raise error diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/__init__.py index 8b65f29..d03d16a 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/__init__.py @@ -1,10 +1,22 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Layered runtime configuration (override -> env -> default).""" +"""Layered runtime configuration (override -> env -> property -> default).""" from __future__ import annotations -from .configuration import Configuration, ConfigurationBuilder +from .build_descriptor import BuildDescriptor +from .configuration import ( + Configuration, + ConfigurationBuilder, + default_configuration, + set_default_configuration, +) -__all__ = ["Configuration", "ConfigurationBuilder"] +__all__ = [ + "BuildDescriptor", + "Configuration", + "ConfigurationBuilder", + "default_configuration", + "set_default_configuration", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/build_descriptor.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/build_descriptor.py new file mode 100644 index 0000000..59bf51f --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/build_descriptor.py @@ -0,0 +1,65 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Build descriptor: the running SDK distribution's name and version. + +``BuildDescriptor`` reports the installed distribution name and version so +observability (``User-Agent`` strings, span attributes, log context) can stamp +"which build produced this". The version is read from installed package +metadata via ``importlib.metadata``. That lookup can fail under zipapp / frozen +/ source-tree installs where distribution metadata is absent — in that case the +descriptor falls back to a non-blank sentinel (``"unknown"``) rather than +raising or silently producing a blank string. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version +from typing import ClassVar, Final, Self + +__all__ = ["BuildDescriptor"] + +_CORE_DISTRIBUTION: Final[str] = "dexpace-sdk-core" + + +@dataclass(frozen=True, slots=True) +class BuildDescriptor: + """Immutable descriptor of a distribution's build identity. + + Attributes: + name: The distribution name (never blank). + version: The resolved version, or ``UNKNOWN`` when metadata is + unavailable or blank (never blank). + """ + + name: str + version: str + + UNKNOWN: ClassVar[str] = "unknown" + + @classmethod + def resolve(cls, distribution: str = _CORE_DISTRIBUTION) -> Self: + """Resolve the descriptor for ``distribution`` from package metadata. + + The version is read via ``importlib.metadata.version``. When the + distribution is not installed (``PackageNotFoundError``) or metadata + reports a blank version, the ``UNKNOWN`` sentinel is substituted so the + descriptor is always non-blank. + + Args: + distribution: The distribution name to resolve. Defaults to the + SDK core distribution. + + Returns: + A populated ``BuildDescriptor`` — both fields are guaranteed + non-blank. + """ + name = distribution.strip() or cls.UNKNOWN + try: + resolved = version(distribution) + except PackageNotFoundError: + resolved = "" + if not resolved.strip(): + resolved = cls.UNKNOWN + return cls(name=name, version=resolved) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/configuration.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/configuration.py index 456c55c..f5a82d9 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/configuration.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/config/configuration.py @@ -1,22 +1,40 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Layered runtime configuration with override + env-var lookup. +"""Layered runtime configuration with override + env-var + property lookup. ``Configuration`` provides a small, dependency-free way for SDK consumers to tune runtime behaviour (retry caps, proxy URLs, default timeouts) without -threading kwargs through every constructor. Lookup is layered: +threading kwargs through every constructor. Lookup is layered, highest +precedence first: 1. Explicit ``overrides`` passed to the constructor or set via the builder. -2. The injected env source (defaults to ``os.environ.get``). - Empty strings from the env source are treated as absent — this matches - the Java SDK's behaviour where ``System.getenv`` returning ``""`` is - considered "not configured". -3. The caller-supplied default. - -Typed accessors (``get_int`` / ``get_bool`` / ``get_duration``) return the -default on any parse failure. They never raise at the lookup site — bad -configuration should degrade, not crash. +2. The injected env source (defaults to ``os.environ.get``), keyed by the + exact name. Empty strings from the env source are treated as absent — this + matches the Java SDK's behaviour where ``System.getenv`` returning ``""`` + is considered "not configured". +3. The injected property source (defaults to a no-op that resolves nothing), + keyed by a dotted-lowercase normalisation of the name — for example the + env-style ``MAX_RETRY_ATTEMPTS`` becomes the property-style + ``max.retry.attempts``. The ``get_raw`` accessor variant skips this + normalisation and queries the property source with the name verbatim. Like + the env source, an empty property value is treated as absent. +4. The caller-supplied default. + +Typed accessors (``get_int`` / ``get_bool`` / ``get_duration``) resolve +through the full layered lookup above and return the default on any parse +failure. They never raise at the lookup site — bad configuration should +degrade, not crash. + +Built configurations are immutable; the override map is copied at construction +so a caller cannot mutate a live configuration. Derive a modified copy with +``with_override`` (copy-on-write — the derived configuration shares the +source's env and property seams); ``without_override`` peels a single key back +off the override layer so lookups fall through to env / property / default +again. A process-wide default slot is available via +``default_configuration`` / ``set_default_configuration`` with last-write-wins +semantics. Environment and property sources are substitutable objects: tests +inject fakes rather than mutating ``os.environ``. """ from __future__ import annotations @@ -24,14 +42,64 @@ import math import os import re +import threading from collections.abc import Callable from dataclasses import dataclass, field from typing import ClassVar, Final, Self -__all__ = ["Configuration", "ConfigurationBuilder"] +__all__ = [ + "Configuration", + "ConfigurationBuilder", + "default_configuration", + "set_default_configuration", +] EnvSource = Callable[[str], str | None] +PropertySource = Callable[[str], str | None] + + +def _no_properties(name: str) -> str | None: + """Default property source: resolve nothing. + + Args: + name: The (normalised) property key. Ignored. + + Returns: + Always ``None`` — no injected property source is configured. + """ + return None + + +def _to_property_key(name: str) -> str: + """Normalise an env-style key to a dotted-lowercase property key. + + Underscores become dots and the whole key is lower-cased, so the + env-style ``MAX_RETRY_ATTEMPTS`` maps to the property-style + ``max.retry.attempts``. + + Args: + name: The configuration key in any case. + + Returns: + The dotted-lowercase property-key form. + """ + return name.replace("_", ".").lower() + + +def _require_name(name: str) -> None: + """Reject a ``None`` configuration key with a clear error. + + Args: + name: The candidate key. Typed ``str``, but guarded at runtime so an + untyped caller passing ``None`` fails loudly rather than deep in + the lookup. + + Raises: + TypeError: If ``name`` is ``None``. + """ + if name is None: + raise TypeError("configuration key must not be None") _SHORTHAND_RE: Final = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$", re.IGNORECASE) @@ -49,16 +117,22 @@ @dataclass(frozen=True, slots=True) class Configuration: - """Layered runtime configuration: override -> env var -> default. + """Layered runtime configuration: override -> env -> property -> default. Attributes: overrides: Explicit string-keyed overrides that always win. env: Callable mapping a name to its environment value or ``None``. - Defaults to ``os.environ.get``. + Defaults to ``os.environ.get``. Substitutable — inject a fake in + tests instead of mutating ``os.environ``. + props: Callable mapping a dotted-lowercase property key to its value + or ``None``. Defaults to a no-op source. Substitutable, and + queried under key normalisation by ``get`` (verbatim by + ``get_raw``). """ overrides: dict[str, str] = field(default_factory=dict) env: EnvSource = field(default=os.environ.get, repr=False) + props: PropertySource = field(default=_no_properties, repr=False) def __post_init__(self) -> None: # Defensive copy: ``frozen=True`` protects the attribute binding, not @@ -91,21 +165,61 @@ def builder(cls) -> ConfigurationBuilder: # Lookup def get(self, name: str, default: str | None = None) -> str | None: - """Resolve ``name`` honouring override -> env -> default ordering. + """Resolve ``name`` honouring override -> env -> property -> default. + + The property source is queried under dotted-lowercase key + normalisation (``MAX_RETRY_ATTEMPTS`` -> ``max.retry.attempts``); use + ``get_raw`` to query it with the name verbatim. Args: name: Configuration key. - default: Value to return when neither override nor env supplies one. + default: Value to return when no layer supplies one. + + Returns: + The resolved value or ``default``. Empty env / property strings are + treated as absent; an empty override is honoured as an intentional + value. + """ + _require_name(name) + return self._resolve(name, _to_property_key(name), default) + + def get_raw(self, name: str, default: str | None = None) -> str | None: + """Resolve ``name`` like ``get`` but query the property source verbatim. + + The override and env layers are keyed by the exact name (as in + ``get``); only the property layer differs — this accessor skips the + dotted-lowercase normalisation and looks the name up unchanged. + + Args: + name: Configuration key, used verbatim against every layer. + default: Value to return when no layer supplies one. Returns: - The resolved value or ``default``. Empty env strings are treated - as absent; an empty override is honoured as an intentional value. + The resolved value or ``default``. + """ + _require_name(name) + return self._resolve(name, name, default) + + def _resolve(self, name: str, property_key: str, default: str | None) -> str | None: + """Run the layered lookup for ``name`` against a chosen property key. + + Args: + name: Key used for the override and env layers. + property_key: Key used for the property layer (normalised by + ``get``, verbatim by ``get_raw``). + default: Fallback when no layer resolves a non-empty value. + + Returns: + The first resolved value, or ``default``. """ if name in self.overrides: return self.overrides[name] env_value = self.env(name) - if env_value is not None and env_value != "": + if env_value: return env_value + prop_value = self.props(property_key) + if prop_value: + return prop_value return default def get_int(self, name: str, default: int) -> int: @@ -178,6 +292,51 @@ def get_duration(self, name: str, default_seconds: float) -> float: return default_seconds return parsed + # ------------------------------------------------------------------ + # Derivation (copy-on-write) + + def with_override(self, name: str, value: str) -> Configuration: + """Return a copy with ``name`` set to ``value`` in the override layer. + + Copy-on-write: the source is left untouched and the derived + configuration shares the source's env and property seams (same + objects), so a single injected fake seam drives both. + + Args: + name: Override key to set. + value: Override value that wins over env / property / default. + + Returns: + A new ``Configuration`` carrying the merged overrides. + """ + _require_name(name) + merged = dict(self.overrides) + merged[name] = value + return Configuration(overrides=merged, env=self.env, props=self.props) + + def without_override(self, name: str) -> Configuration: + """Return a copy with ``name`` dropped from the override layer. + + Removing an override peels off only that one layer: a later lookup for + ``name`` falls through to env -> property -> default exactly as if the + override had never been set. Removal never forces the key to resolve to + ``None`` — a lower layer still supplies its value. Removing a key that + carries no override is a harmless no-op that returns an equivalent + copy. Copy-on-write, like ``with_override``: the source is left + untouched and the derived configuration shares the source's env and + property seams (same objects). + + Args: + name: Override key to drop from the override layer. + + Returns: + A new ``Configuration`` with ``name`` absent from the overrides. + """ + _require_name(name) + merged = dict(self.overrides) + merged.pop(name, None) + return Configuration(overrides=merged, env=self.env, props=self.props) + def _parse_duration_seconds(raw: str) -> float | None: """Parse a duration string into seconds, returning ``None`` on failure. @@ -244,17 +403,23 @@ class ConfigurationBuilder: Calls to ``put`` mutate the builder in place and return ``self`` so calls chain. ``build`` produces a frozen ``Configuration`` snapshot — further builder mutations do not affect already-built configurations. + + The builder is single-threaded by contract: a single builder instance must + not be mutated concurrently from multiple threads. Build once, then share + the immutable ``Configuration``, which is safe to read concurrently. """ - __slots__ = ("_env", "_overrides") + __slots__ = ("_env", "_overrides", "_props") _overrides: dict[str, str] _env: EnvSource + _props: PropertySource def __init__(self) -> None: - """Initialise with empty overrides and ``os.environ.get`` as env.""" + """Initialise with empty overrides, ``os.environ.get`` env, no props.""" self._overrides = {} self._env = os.environ.get + self._props = _no_properties def put(self, name: str, value: str) -> Self: """Set ``name`` to ``value`` in the override layer. @@ -281,10 +446,66 @@ def env(self, source: EnvSource) -> Self: self._env = source return self + def props(self, source: PropertySource) -> Self: + """Replace the property source. + + Args: + source: Callable that maps a (normalised) property key to its + value or ``None``. + + Returns: + ``self`` for chaining. + """ + self._props = source + return self + def build(self) -> Configuration: """Snapshot the builder into an immutable ``Configuration``. Returns: - A frozen ``Configuration`` carrying a copy of the overrides. + A frozen ``Configuration`` carrying a copy of the overrides and the + configured env / property seams. """ - return Configuration(overrides=dict(self._overrides), env=self._env) + return Configuration(overrides=dict(self._overrides), env=self._env, props=self._props) + + +# ---------------------------------------------------------------------- +# Process-wide default slot (last-write-wins) + +_default_lock: Final = threading.Lock() +_default_configuration: Configuration | None = None + + +def default_configuration() -> Configuration: + """Return the process-wide default configuration. + + Lazily initialises to an empty ``Configuration`` (env-only lookup) on + first access if none has been set. Thread-safe. + + Returns: + The current default ``Configuration``. + """ + global _default_configuration + with _default_lock: + current = _default_configuration + if current is None: + current = Configuration() + _default_configuration = current + return current + + +def set_default_configuration(config: Configuration) -> None: + """Install ``config`` as the process-wide default (last-write-wins). + + Args: + config: The configuration to publish as the default. A later call + replaces an earlier one. + + Raises: + TypeError: If ``config`` is ``None``. + """ + if config is None: + raise TypeError("default configuration must not be None") + global _default_configuration + with _default_lock: + _default_configuration = config diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/__init__.py index 8a3abf9..59f8dfb 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/__init__.py @@ -4,25 +4,39 @@ """Typed exception hierarchy for the SDK. Modelled on Azure SDK for Python's ``corehttp.exceptions`` but slimmed to the -classes the SDK actually raises. The hierarchy distinguishes three failure -shapes: +classes the SDK actually raises. The hierarchy has two branches, split by +whether a response was ever received: -- ``ServiceRequestError`` — request never reached the server (DNS failure, - connection refused, etc.). Safe to retry on idempotent methods. -- ``ServiceResponseError`` — request was sent but the response could not be - parsed (connection drop mid-response, decode failure on a chunked stream). -- ``HttpResponseError`` — a 4xx or 5xx response was received intact. Carries - the response so callers can inspect status, headers, and body. +- ``NetworkError`` — the transport branch. A transport-level failure that never + yielded a usable response (connection refused, DNS failure, connect timeout). + Subclasses ``OSError`` so ``except OSError:`` sites keep matching it, and is + always retryable at the error-class level. ``ServiceRequestError`` (and its + ``ServiceRequestTimeoutError``) live here. +- ``HttpResponseError`` — the response branch. A 4xx or 5xx response was + received intact; retryability is derived per-status from the shared + classifier and baked once at construction. Carries a bounded, replayable copy + of the error body (``read_body``, capped at ``ERROR_BODY_MAX_BYTES``). + +``ServiceResponseError`` is the in-between case (request sent, but the response +could not be parsed): it is not on the always-retryable transport branch +because the request may already have been processed. + +``RequestCancelledError`` is the terminal type for synchronous cooperative +cancellation; like ``NetworkError`` it is an ``OSError`` but, being terminal, it +is not a ``NetworkError`` and is never retryable. Plus exceptions for body / stream lifecycle violations (``StreamConsumedError``, ``StreamClosedError``, ``ResponseNotReadError``, -``StreamingError``), serialization (``SerializationError``, -``DeserializationError``), and pipeline aborts (``PipelineAbortedError``). +``StreamingError``), serde failures (``SerdeError`` and its directional +``SerializationError`` / ``DeserializationError`` subtypes), and pipeline +aborts (``PipelineAbortedError``). """ from __future__ import annotations from .base import ( + NetworkError, + RequestCancelledError, SdkError, ServiceRequestError, ServiceRequestTimeoutError, @@ -31,6 +45,7 @@ ) from .error_map import map_error from .http import ( + ERROR_BODY_MAX_BYTES, ClientAuthenticationError, DecodeError, HttpResponseError, @@ -40,7 +55,7 @@ ResourceNotModifiedError, ) from .pipeline import PipelineAbortedError -from .serialization import DeserializationError, SerializationError +from .serialization import DeserializationError, SerdeError, SerializationError from .streaming import ( ResponseNotReadError, StreamClosedError, @@ -49,17 +64,21 @@ ) __all__ = [ + "ERROR_BODY_MAX_BYTES", "ClientAuthenticationError", "DecodeError", "DeserializationError", "HttpResponseError", + "NetworkError", "PipelineAbortedError", + "RequestCancelledError", "ResourceExistsError", "ResourceModifiedError", "ResourceNotFoundError", "ResourceNotModifiedError", "ResponseNotReadError", "SdkError", + "SerdeError", "SerializationError", "ServiceRequestError", "ServiceRequestTimeoutError", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/base.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/base.py index 1d6c0dc..fb57c97 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/base.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/base.py @@ -58,7 +58,36 @@ def __init__( super().__init__(self.message) -class ServiceRequestError(SdkError): +class NetworkError(SdkError, OSError): + """A transport-level failure that never yielded a usable response. + + Roots the transport branch of the taxonomy: connection refused, DNS + failure, TLS handshake failure, connect timeout — anything where the + request never completed an exchange that produced a response. + + It subclasses ``OSError`` in addition to ``SdkError`` so that existing + ``except OSError:`` sites (in user code or third-party libraries) keep + matching transport failures after the SDK re-wraps them. The multiple + inheritance is deliberate and safe: both bases share ``Exception``, and + ``SdkError.__init__`` cooperates with ``OSError`` via ``super()``. + + Transport failures on this branch are *always* retryable at the + error-class level — a request that never reached the service is safe to + re-send on an idempotent method. This is orthogonal to the per-status + retryability of ``HttpResponseError`` (which classifies HTTP status codes + on a response that *was* received); here the retryability is a property of + the error type itself. ``retryable`` is a class-level constant so the + ``Retryable`` structural protocol reads it directly. + + Attributes: + retryable: Always ``True`` — the transport branch never got a response, + so a retry on an idempotent method may succeed. + """ + + retryable: bool = True + + +class ServiceRequestError(NetworkError): """The request never reached the service. Raised for connection failures, DNS errors, TLS handshake failures, and @@ -68,7 +97,14 @@ class ServiceRequestError(SdkError): class ServiceRequestTimeoutError(ServiceRequestError): - """The request timed out before any data was transmitted.""" + """The request timed out before any data was transmitted. + + The SDK's request-timeout type. It subclasses ``NetworkError`` (via + ``ServiceRequestError``) explicitly, so it is an ``OSError`` and retryable + by design — the SDK never relies on Python's builtin ``TimeoutError`` + (itself an ``OSError`` since 3.10) coincidentally lining up. Discriminate + it before any cancellation type by subtype, never by message text. + """ class ServiceResponseError(SdkError): @@ -84,7 +120,33 @@ class ServiceResponseTimeoutError(ServiceResponseError): """The response timed out after the request was transmitted.""" +class RequestCancelledError(SdkError, OSError): + """The request was cancelled cooperatively before completion. + + The terminal type for synchronous cooperative cancellation: a caller (or a + scheduled deadline) cancelled the in-flight request via the cooperative + cancellation handle. Like ``NetworkError`` it subclasses ``OSError`` so + ``except OSError:`` sites match it, but it is deliberately *not* a + ``NetworkError`` — cancellation is terminal, never retried. Its + ``retryable`` flag is a class-level ``False`` so the ``Retryable`` + structural protocol reads it directly and a retry loop leaves it alone. + + Discriminate it from a timeout by subtype (a ``ServiceRequestTimeoutError`` + is a retryable ``NetworkError``; this is not), never by message text — the + two carry unrelated semantics even when their messages coincide. The + asynchronous equivalent is ``asyncio.CancelledError``, which is already a + ``BaseException`` and needs no SDK subtype. + + Attributes: + retryable: Always ``False`` — cancellation is terminal. + """ + + retryable: bool = False + + __all__ = [ + "NetworkError", + "RequestCancelledError", "SdkError", "ServiceRequestError", "ServiceRequestTimeoutError", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/http.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/http.py index 49f57fc..03f1181 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/http.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/http.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar from ..http.response.loggable_response_body import LoggableResponseBody +from ..http.response.response_body import ResponseBody from .base import SdkError if TYPE_CHECKING: @@ -27,11 +28,11 @@ else: ModelT = TypeVar("ModelT") -# Status codes for which a retry is worthwhile by default: request timeout, -# rate limiting, and the transient 5xx family. Mirrors the retry policy's -# ``_DEFAULT_STATUS_RETRIES`` so ``retryable`` and the policy agree out of -# the box; callers can override per error via the ``retryable`` kwarg. -_DEFAULT_RETRYABLE_STATUS: Final[frozenset[int]] = frozenset({408, 429, 500, 502, 503, 504}) +#: Default ceiling, in bytes, on the replayable copy of an error response body +#: captured by ``HttpResponseError.read_body``. One MiB is generous for the +#: JSON / text error payloads services return while bounding the memory a single +#: failed request can pin for post-mortem inspection. +ERROR_BODY_MAX_BYTES: Final[int] = 1 << 20 # 1 MiB # UP046 wants PEP 695 ``class Foo[T = Any](...)`` form, but that syntax @@ -57,10 +58,11 @@ class HttpResponseError(SdkError, Generic[ModelT]): # noqa: UP046 libraries when they parse the error body). Typed as ``ModelT | None``. retryable: Whether retrying the request might succeed. Derived from - the response status by default (request timeout, rate limiting, - and transient 5xx are retryable) so the retry policy can read the - flag directly instead of re-deriving it; callers may override it - explicitly via the ``retryable`` constructor keyword. + the response status by default via the single shared classifier + (408, 429, and every 5xx EXCEPT 501 and 505 are retryable) so the + baked flag is computed once at construction and always agrees with + that classifier; callers may override it explicitly via the + ``retryable`` constructor keyword. """ status: Status | None @@ -68,6 +70,7 @@ class HttpResponseError(SdkError, Generic[ModelT]): # noqa: UP046 response: _AnyResponse | None model: ModelT | None retryable: bool + _captured_body: bytes | None def __init__( self, @@ -91,6 +94,7 @@ def __init__( self.status = response.status if response is not None else None self.reason = response.reason if response is not None else None self.model = kwargs.pop("model", None) + self._captured_body = None retryable_override = kwargs.pop("retryable", None) self.retryable = ( self._status_is_retryable() if retryable_override is None else bool(retryable_override) @@ -103,11 +107,22 @@ def __init__( def _status_is_retryable(self) -> bool: """Return whether this error's status is retryable by default. + Consults the single shared canonical classifier + (``default_status_is_retryable``: 408, 429, and every 5xx except 501 + and 505), so the flag is baked once from one definition instead of a + hand-mirrored literal and always agrees with that classifier. The + classifier is imported lazily inside the method because it lives under + ``pipeline`` (whose package import pulls in policies that import this + error module) — a module-level import would form an errors -> pipeline + -> errors cycle. + Returns: - ``True`` when the captured status is one of the default - retryable codes, ``False`` when no status was captured. + ``True`` when the captured status is 408, 429, or a 5xx other than + 501 / 505; ``False`` when no status was captured. """ - return self.status is not None and int(self.status) in _DEFAULT_RETRYABLE_STATUS + from ..pipeline._pure.classifier import default_status_is_retryable + + return default_status_is_retryable(self.status) def body_snapshot(self, max_bytes: int | None = None) -> bytes: """Preview the error response body without consuming it. @@ -137,6 +152,60 @@ def body_snapshot(self, max_bytes: int | None = None) -> bytes: return body.snapshot(max_bytes) return b"" + def read_body(self, max_bytes: int = ERROR_BODY_MAX_BYTES) -> bytes: + """Read a bounded, replayable copy of the error response body. + + Unlike ``body_snapshot`` — a non-consuming preview limited to bodies + already wrapped for repeatable reads — this drains the response body + once, up to ``max_bytes``, and caches the result on the error. Every + later call replays the cached copy without re-draining, so the bounded + body is readable repeatedly for logging and post-mortem inspection. + + The first read fixes the captured bytes; a single-use response body is + consumed by that read (call ``body_snapshot`` first if a non-consuming + preview is needed). An already-repeatable ``LoggableResponseBody`` is + left readable. Async response bodies and the no-response case return + empty bytes rather than draining anything. + + Args: + max_bytes: Ceiling on the captured copy. Defaults to the documented + ``ERROR_BODY_MAX_BYTES`` (1 MiB); larger bodies are truncated + to this prefix. + + Returns: + The captured body bytes, truncated to ``max_bytes``; empty when no + synchronously-readable body is present. + + Raises: + ValueError: If ``max_bytes`` is negative. + """ + if max_bytes < 0: + raise ValueError(f"max_bytes must be non-negative, got {max_bytes}") + if self._captured_body is None: + self._captured_body = self._capture_body(max_bytes) + return bytes(memoryview(self._captured_body)[:max_bytes]) + + def _capture_body(self, max_bytes: int) -> bytes: + """Drain a bounded prefix of the response body for one-time capture. + + Args: + max_bytes: Ceiling on the bytes to capture. + + Returns: + The captured prefix, or empty bytes when there is no + synchronously-readable body. + """ + body = self.response.body if self.response is not None else None + if max_bytes == 0 or not isinstance(body, ResponseBody): + return b"" + if isinstance(body, LoggableResponseBody): + return body.snapshot(max_bytes) + capture = LoggableResponseBody(body, max_capture_bytes=max_bytes) + try: + return capture.snapshot(max_bytes) + finally: + capture.close() + class DecodeError(HttpResponseError[ModelT]): """The response body could not be decoded as the expected format.""" @@ -170,6 +239,7 @@ class ClientAuthenticationError(HttpResponseError[ModelT]): __all__ = [ + "ERROR_BODY_MAX_BYTES", "ClientAuthenticationError", "DecodeError", "HttpResponseError", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/serialization.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/serialization.py index 5c438a0..d964a23 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/serialization.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/serialization.py @@ -3,9 +3,15 @@ """Serialisation / deserialisation exceptions. -These inherit from ``ValueError`` so existing ``except ValueError`` handlers -continue to work. They also inherit from ``SdkError`` so callers that catch -the SDK root see them. +``SerdeError`` is the root of the serde failure taxonomy; the directional +``SerializationError`` (encode side) and ``DeserializationError`` (decode side) +subtypes let callers tell which way a round-trip failed while still catching the +family with a single ``except SerdeError``. + +All three inherit from ``ValueError`` so existing ``except ValueError`` handlers +continue to work, and from ``SdkError`` so callers that catch the SDK root see +them. Like every Python exception they are unchecked — nothing forces a caller +to catch them. """ from __future__ import annotations @@ -13,12 +19,21 @@ from .base import SdkError -class SerializationError(SdkError, ValueError): +class SerdeError(SdkError, ValueError): + """Root of the serde failure taxonomy. + + Raised (via a directional subtype) when a value cannot cross the serde + boundary in either direction. Catch this to handle any serde failure + regardless of direction; catch a subtype to distinguish encode from decode. + """ + + +class SerializationError(SerdeError): """A value could not be serialised to the target format.""" -class DeserializationError(SdkError, ValueError): +class DeserializationError(SerdeError): """A payload could not be deserialised.""" -__all__ = ["DeserializationError", "SerializationError"] +__all__ = ["DeserializationError", "SerdeError", "SerializationError"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/_once_guard.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/_once_guard.py new file mode 100644 index 0000000..6b2e814 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/_once_guard.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Lock-protected single-use guard shared by the stream/iter-backed bodies. + +The single-use request and response bodies (stream- and iter-backed) may be +consumed exactly once: a second attempt to iterate them raises ``RuntimeError``. +That "already consumed?" decision is a check-then-act on a shared flag, so a +bare ``bool`` is not enough — two threads racing the very first consumption +could each read ``False`` before either writes ``True`` and both proceed, +consuming the underlying stream twice. This SDK targets free-threaded CPython +(PEP 703), where that race is real rather than masked by the GIL, so the flag +lives behind a ``threading.Lock``: the check and the set happen atomically under +the lock, guaranteeing exactly one winner regardless of interpreter build. + +The guard is deliberately synchronous. The async bodies claim it from their +(non-``async``) ``aiter_bytes`` entry point, before returning the iterator, so +the lock is only ever held for the trivial check-and-set and never across an +``await`` — it cannot block an event loop or deadlock. The same ``threading`` +lock therefore also protects a sync body driven through a sync/async bridge +(for example a thread-pool executor), since the guarantee never depended on the +GIL in the first place. + +It lives here — under ``http`` rather than ``http.request`` or ``http.response`` +— so both body sides import it without one body package reaching into the other +(mirroring how ``_shielded`` is shared). +""" + +from __future__ import annotations + +import threading + + +class _OnceGuard: + """A thread-safe, single-claim guard for single-use body consumption. + + Exposes one operation, ``claim``: the first caller wins and every later + caller raises ``RuntimeError``. The winner/loser decision is made under a + ``threading.Lock``, so it holds without relying on GIL atomicity of the + flag read/write — N threads racing the very first ``claim`` yield exactly + one winner. + """ + + __slots__ = ("_claimed", "_lock") + + def __init__(self) -> None: + self._lock = threading.Lock() + self._claimed = False + + def claim(self, message: str) -> None: + """Atomically claim the single consumption, or raise if already claimed. + + The check-then-act runs entirely inside the lock, so the flag is never + read and written across a window another thread could interleave into. + + Args: + message: The ``RuntimeError`` message to raise when the single + consumption has already been claimed by an earlier caller. + + Raises: + RuntimeError: If a previous caller already claimed consumption. + """ + with self._lock: + if self._claimed: + raise RuntimeError(message) + self._claimed = True + + +__all__ = ["_OnceGuard"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/_refresh.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/_refresh.py new file mode 100644 index 0000000..f0ce7f7 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/_refresh.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure token refresh zone-classification and fetched-token validation. + +The sync and async single-flight token caches share the "when to refresh" +and "is this fetched token usable" decisions so they live in exactly one +place. This module is deliberately free of concurrency primitives and I/O: +every function is a pure function of a token and the current wall-clock +time, which keeps the tricky threshold logic trivially testable and keeps +the two caches from drifting apart. +""" + +from __future__ import annotations + +from enum import Enum, auto +from typing import Final + +from ...errors import ClientAuthenticationError +from .access_token import AccessTokenInfo + +REFRESH_MARGIN_SECONDS: Final = 30.0 +"""Default seconds before ``expires_on`` at which a token is proactively refreshed.""" + + +class RefreshZone(Enum): + """Freshness classification for a cached access token.""" + + FRESH = auto() + """Valid and comfortably before expiry — serve without refreshing.""" + + SOFT = auto() + """Valid but inside the refresh margin — serve, but refresh proactively.""" + + HARD = auto() + """Absent or past ``expires_on`` — a caller must block on a fresh fetch.""" + + +def classify( + token: AccessTokenInfo | None, + *, + now: float, + margin: float = REFRESH_MARGIN_SECONDS, +) -> RefreshZone: + """Classify a cached token into a refresh zone. + + Args: + token: The cached token, or ``None`` when the cache is cold. + now: Current wall-clock time in Unix seconds. + margin: Seconds before ``expires_on`` that open the soft-refresh + window. Defaults to `REFRESH_MARGIN_SECONDS`. + + Returns: + `RefreshZone.HARD` when there is no token or it has expired, + `RefreshZone.SOFT` when it is still valid but within the margin (or + past its ``refresh_on`` hint), and `RefreshZone.FRESH` otherwise. + """ + if token is None or now >= token.expires_on: + return RefreshZone.HARD + if token.refresh_on is not None and now >= token.refresh_on: + return RefreshZone.SOFT + if now >= token.expires_on - margin: + return RefreshZone.SOFT + return RefreshZone.FRESH + + +def validate_fetched(token: AccessTokenInfo | None, *, now: float) -> AccessTokenInfo: + """Validate a token just returned by a provider before it is cached. + + Args: + token: The provider's return value. A misbehaving provider may hand + back ``None`` despite the non-optional return type. + now: Current wall-clock time in Unix seconds, used to reject a token + that is already expired at the moment it was fetched. No refresh + margin is applied here: only genuine expiry is rejected, so a + short-lived-but-valid token is accepted. + + Returns: + The validated token, unchanged. + + Raises: + ClientAuthenticationError: If ``token`` is ``None`` or already + expired at ``now``. + """ + if token is None: + raise ClientAuthenticationError("Token provider returned no token.") + if token.is_expired(now=now): + raise ClientAuthenticationError("Token provider returned an already-expired token.") + return token + + +__all__ = [ + "REFRESH_MARGIN_SECONDS", + "RefreshZone", + "classify", + "validate_fetched", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/challenge.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/challenge.py index a88818b..1668618 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/challenge.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/challenge.py @@ -6,7 +6,11 @@ The parser implements a tolerant subset of RFC 7235 §2.1 sufficient for real server responses: it splits a header into one or more challenges and recovers the scheme + parameter map for each. Quoted-string values are unquoted and -``quoted-pair`` escapes (``\\X`` → ``X``) decoded per RFC 7230 §3.2.6. +``quoted-pair`` escapes (``\\X`` → ``X``) decoded per RFC 7230 §3.2.6. A comma +inside a quoted-string is preserved, never mistaken for a challenge separator. +``parse_challenges`` also accepts an iterable of header lines, so challenges a +server splits across several ``WWW-Authenticate`` / ``Proxy-Authenticate`` +lines are each parsed and concatenated rather than dropped. Auth-params within a challenge must be comma-separated; this parser does not recover whitespace-separated parameters. ``token68`` credentials (as used by @@ -19,7 +23,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from dataclasses import dataclass from types import MappingProxyType @@ -41,22 +45,42 @@ class AuthenticateChallenge: parameters: Mapping[str, str] -def parse_challenges(header_value: str) -> list[AuthenticateChallenge]: +def parse_challenges( + header_value: str | Iterable[str], +) -> list[AuthenticateChallenge]: """Parse a ``WWW-Authenticate`` or ``Proxy-Authenticate`` header. + Accepts either a single header value or several — a server may split its + challenges across multiple ``WWW-Authenticate`` / ``Proxy-Authenticate`` + header lines (RFC 7230 §3.2.2 treats repeated field lines as one comma- + joined field). When an iterable is supplied, each line is parsed + independently and the results are concatenated, so a Digest challenge on + its own line is never lost behind a Basic challenge on an earlier line. + Auth-params within a challenge are recognised only when comma-separated; whitespace-separated params are not recovered. ``token68`` credentials (e.g. ``Negotiate``/``NTLM``) are unsupported and yield no parameters. Args: - header_value: The full header value (a single line concatenated - across folded continuations is fine — RFC 7230 deprecates - folding but tolerant parsers accept it). + header_value: A single header value string, or an iterable of the + individual header lines. A single line concatenated across folded + continuations is fine — RFC 7230 deprecates folding but tolerant + parsers accept it. Returns: - Zero or more parsed challenges. Malformed segments are skipped - silently; any valid challenge found is returned. + Zero or more parsed challenges, in the order encountered. Malformed + segments are skipped silently; any valid challenge found is returned. """ + if isinstance(header_value, str): + return _parse_one_line(header_value) + challenges: list[AuthenticateChallenge] = [] + for line in header_value: + challenges.extend(_parse_one_line(line)) + return challenges + + +def _parse_one_line(header_value: str) -> list[AuthenticateChallenge]: + """Parse the challenges carried by a single header line.""" tokens = _split_top_level(header_value) challenges: list[AuthenticateChallenge] = [] current_scheme: str | None = None diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/credentials.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/credentials.py index 7b1a34f..218aa9a 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/credentials.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/credentials.py @@ -125,13 +125,20 @@ def __repr__(self) -> str: class BasicAuthCredential: - """HTTP Basic credential — username + password, base64-encoded once.""" + """HTTP Basic credential — username + password, base64-encoded once. + + Both halves are validated as non-empty at construction so a blank secret + is rejected immediately rather than silently base64-encoding an empty + ``user:pass`` pair. + """ __slots__ = ("_encoded",) def __init__(self, username: str, password: str) -> None: if not isinstance(username, str) or not isinstance(password, str): raise TypeError("username and password must be strings") + if not username or not password: + raise ValueError("username and password must not be empty") raw = f"{username}:{password}".encode() self._encoded = base64.b64encode(raw).decode("ascii") diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/digest.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/digest.py index a9a3e3c..327961b 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/digest.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/digest.py @@ -4,8 +4,17 @@ """Digest access authentication handler (RFC 7616). Supports algorithms ``MD5``, ``MD5-sess``, ``SHA-256``, and ``SHA-256-sess`` -with ``qop=auth``. ``auth-int`` and mutual-auth (``Authentication-Info``) -verification are out of scope — matching the Java v1 cut. +with ``qop=auth``. + +``qop=auth-int`` is negotiated away rather than attempted: it folds a hash of +the request entity body into ``HA2`` (RFC 7616 §3.4.3), but the +``ChallengeHandler`` contract carries no request body, and a single-use +(non-replayable) body may already be consumed by the time a challenge is +answered. The handler therefore prefers ``auth`` whenever the server offers it +and declines an ``auth-int``-only challenge; the digest routine refuses +``auth-int`` outright so no path can silently fall through and emit an RFC 2069 +hash the server would reject. Mutual-auth (``Authentication-Info``) +verification is out of scope. A single handler instance is intended to be reused across requests so the per-nonce request counter (``nc``) advances correctly. RFC 7616 §3.4 defines @@ -277,6 +286,21 @@ def _credentials_encodable(self, charset: str) -> bool: @staticmethod def _pick_qop(qop_param: str | None) -> str | None: + """Select the ``qop`` to answer with from the server's offer. + + Always prefers ``auth`` when present — the only protection this handler + can compute — regardless of where it sits in the comma-separated list. + An ``auth-int``-only offer yields ``None`` so the caller declines the + challenge rather than emitting an unverifiable header (``auth-int`` + needs the entity body, which is unavailable here). + + Args: + qop_param: The raw ``qop`` directive, or ``None`` when the server + sent an RFC 2069-style challenge without one. + + Returns: + ``"auth"`` when offered, else ``None``. + """ if qop_param is None: return None options = [opt.strip().lower() for opt in qop_param.split(",")] @@ -296,6 +320,17 @@ def _compute_response( qop: str | None, charset: str, ) -> str: + """Compute the ``response`` digest per RFC 7616 §3.4.1. + + Handles ``qop=auth`` and the qop-less RFC 2069 shape. ``-sess`` + algorithms fold ``nonce``/``cnonce`` into ``HA1``. + + Raises: + ValueError: If ``qop`` is ``auth-int``. Integrity protection hashes + the request entity body, which this handler cannot access, so + it fails loudly rather than emit a hash the server will reject. + """ + def h(data: str) -> str: return hasher(data.encode(charset)).hexdigest() @@ -305,6 +340,17 @@ def h(data: str) -> str: ha2 = h(f"{method}:{uri}") if qop == "auth": return h(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}") + if qop == "auth-int": + # ``auth-int`` mixes a hash of the entity body into HA2, which this + # handler cannot obtain (the ChallengeHandler contract passes no + # body, and a non-replayable body may already be consumed). Fail + # loudly instead of falling through to the RFC 2069 branch below and + # emitting a hash the server would silently reject as wrong. + raise ValueError( + "Digest qop=auth-int is not supported: it requires hashing the " + "request entity body, which is unavailable here (and a " + "non-replayable body may already have been consumed)." + ) # No qop (legacy RFC 2069): response = H(HA1:nonce:HA2) return h(f"{ha1}:{nonce}:{ha2}") diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/policies.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/policies.py index b96876b..d1991f3 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/policies.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/policies.py @@ -5,8 +5,6 @@ from __future__ import annotations -import asyncio -import threading from typing import TYPE_CHECKING, Any from ...errors import ClientAuthenticationError, ServiceRequestError @@ -24,7 +22,12 @@ KeyCredential, TokenCredential, ) -from .token_cache import InMemoryTokenCache, TokenCache +from .token_cache import ( + InMemoryTokenCache, + TokenCache, + _AsyncSingleFlight, + _SyncSingleFlight, +) if TYPE_CHECKING: from ...pipeline.context import PipelineContext @@ -44,7 +47,9 @@ class KeyCredentialPolicy(Policy): origin recorded on the first pass through the policy. When a downstream redirect reissues the request against a different origin (scheme, host, or effective port), the credential is withheld so it never reaches a - foreign host. + foreign host. On the same-origin stamping path the key is refused for a + non-HTTPS URL (opt out per call with ``enforce_https=False``) so it is + never read or written onto a plaintext request. Attributes: header_name: Header to write. @@ -72,9 +77,16 @@ def __init__( def send(self, request: Request, ctx: PipelineContext) -> Response: if _crosses_recorded_origin(request, ctx): return self.next.send(request, ctx) + _enforce_https(request, ctx) value = f"{self.prefix}{self._credential.key}" return self.next.send(request.with_header(self.header_name, value), ctx) + def __repr__(self) -> str: + return ( + f"KeyCredentialPolicy(credential={self._credential!r}, " + f"header_name={self.header_name!r}, prefix={self.prefix!r})" + ) + class BasicAuthPolicy(Policy): """Stamp ``Authorization: Basic `` from a ``BasicAuthCredential``. @@ -83,7 +95,9 @@ class BasicAuthPolicy(Policy): recorded on the first pass through the policy. When a downstream redirect reissues the request against a different origin (scheme, host, or effective port), the credential is withheld so it never reaches a foreign - host. + host. On the same-origin stamping path the payload is refused for a + non-HTTPS URL (opt out per call with ``enforce_https=False``) so it is + never read or written onto a plaintext request. """ STAGE = Stage.AUTH @@ -97,16 +111,20 @@ def __init__(self, credential: BasicAuthCredential) -> None: def send(self, request: Request, ctx: PipelineContext) -> Response: if _crosses_recorded_origin(request, ctx): return self.next.send(request, ctx) + _enforce_https(request, ctx) value = f"Basic {self._credential.encoded}" return self.next.send(request.with_header("Authorization", value), ctx) + def __repr__(self) -> str: + return f"BasicAuthPolicy(credential={self._credential!r})" + class BearerTokenPolicy(Policy): """Acquire and apply an OAuth bearer token. Caches a single token in memory by default; pass a shared ``TokenCache`` - to share tokens across credentials. Refreshes when ``needs_refresh`` - returns True, or after a 401 response with ``WWW-Authenticate``. Enforces + to share tokens across credentials. Refreshes proactively as the token + nears expiry, or after a 401 response with ``WWW-Authenticate``. Enforces HTTPS unless ``enforce_https=False`` is passed in ``ctx.options``. The token is acquired and stamped only while the request stays on the @@ -116,9 +134,14 @@ class BearerTokenPolicy(Policy): stamp a token — so the bearer token never reaches a foreign host. The HTTPS-enforcement check applies only on that same-origin stamping path. - Concurrent refreshes are serialized via a ``threading.Lock`` using a - double-checked pattern so the credential's ``get_token_info`` is invoked - at most once per refresh window even under heavy concurrent send pressure. + Token serving is delegated to a `_SyncSingleFlight` coordinator: reads of + the published token are wait-free, and concurrent refreshes are collapsed + under a ``threading.Lock`` (double-checked) so the credential's + ``get_token_info`` runs at most once per refresh window even under heavy + concurrent send pressure. A token is refreshed once it comes within 30 + seconds of expiry; a provider that returns ``None`` or an already-expired + token raises rather than caching a bad token, and a throwing provider + propagates without being cached so the next request retries it. The ``on_challenge`` hook is a no-op by default; subclasses override it to handle CAE/claims challenges. Alternatively, callers can pass a @@ -133,10 +156,9 @@ class BearerTokenPolicy(Policy): "_audience", "_cache", "_challenge_handler", - "_clock", "_credential", - "_lock", "_scopes", + "_single_flight", ) def __init__( @@ -154,8 +176,8 @@ def __init__( self._scopes = scopes self._cache: TokenCache = cache or InMemoryTokenCache() self._audience = audience - self._clock: Clock = clock if clock is not None else SYSTEM_CLOCK - self._lock = threading.Lock() + resolved_clock: Clock = clock if clock is not None else SYSTEM_CLOCK + self._single_flight = _SyncSingleFlight(self._cache, scopes, audience, resolved_clock) self._challenge_handler = challenge_handler def send(self, request: Request, ctx: PipelineContext) -> Response: @@ -164,11 +186,13 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: status = int(response.status) if status not in (401, 407): return response - # On 401 the bearer token was rejected: drop it from the cache so the - # ``on_challenge`` fallback path forces a refresh. A 407 means the - # proxy rejected us, not the origin — leave the cached token alone. + # On 401 the origin rejected the bearer token: drop it from the cache + # so a later refresh re-fetches. Evict only when the cached token still + # matches the one this request carried, so a stale 401 cannot discard a + # token a concurrent refresh already replaced. A 407 means the proxy + # rejected us, not the origin — leave the cached token alone. if status == 401: - self._cache.set(self._scopes, _expired_token(), self._audience) + self._evict_rejected_token(request) handler_header = self._apply_challenge_handler(request, response, status) if handler_header is not None: request = request.with_header(*handler_header) @@ -183,14 +207,69 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: # No handler match on a 407; surface the response unchanged so # callers can inspect proxy-auth failures themselves. return response - if "WWW-Authenticate" in response.headers and self.on_challenge(request, response): - request = self._authorize(request, ctx, force_refresh=True) - # Release the rejected 401 before retrying with the refreshed token. + return self._replay_on_challenge(request, response, ctx) + + def _replay_on_challenge( + self, + request: Request, + response: Response, + ctx: PipelineContext, + ) -> Response: + """Consult ``on_challenge`` and replay the 401 at most once. + + A 401 without a ``WWW-Authenticate`` header is surfaced unchanged and + the hook is never consulted. A single-use request body is likewise + surfaced un-replayed rather than corrupting a half-consumed stream by + resending it. When the hook authorizes a retry and the body is + replayable, the request is re-issued once with a refreshed token and + the resulting response is returned regardless of its status — there is + no challenge loop. If the hook raises, the rejected response is closed + before the exception propagates so its connection is not leaked. + + Args: + request: The request that produced the 401. + response: The open 401 response. + ctx: The per-operation pipeline context. + + Returns: + The surfaced 401, or the response from the single replay. + """ + if "WWW-Authenticate" not in response.headers: + return response + if request.body is not None and not request.body.is_replayable(): + return response + try: + proceed = self.on_challenge(request, response) + except BaseException: response.close() - response = self.next.send(request, ctx) - if int(response.status) != 401: - return response - raise ClientAuthenticationError(response=response) + raise + if not proceed: + return response + request = self._authorize(request, ctx, force_refresh=True) + # Release the rejected 401 before retrying with the refreshed token. + response.close() + return self.next.send(request, ctx) + + def _evict_rejected_token(self, request: Request) -> None: + """Evict the cached token when it still matches this request's stamp. + + A 401 invalidates only the exact token that was sent. Matching the + stamped ``Authorization`` header against the cached token guards a + token a concurrent refresh already installed: a stale 401 for the + superseded token must not discard the newer one still valid for other + requests. The compare-and-set runs under the refresh lock so it is + atomic with respect to ``_authorize``. + + Args: + request: The request whose stamped token was rejected. + """ + stamped = request.headers.get("Authorization") + if stamped is None: + return + self._single_flight.evict_if_matches( + lambda cached: stamped == f"{cached.token_type} {cached.token}", + _expired_token(), + ) def _apply_challenge_handler( self, @@ -232,18 +311,22 @@ def on_challenge(self, request: Request, response: Response) -> bool: ``False`` (the default) to surface the error to the caller. Note: - By the time this hook runs, ``send`` has already replaced the - cached token for ``(scopes, audience)`` with an expired sentinel - (``token=""``, ``expires_on=0``). Subclasses that inspect the - ``TokenCache`` here will see that sentinel, not the original - token that was attached to ``request``. The sentinel is what - forces the subsequent ``_authorize`` call (when this hook - returns ``True``) to invoke the credential and acquire a fresh - token rather than reusing the rejected one. + By the time this hook runs, ``send`` has invalidated the cached + token for ``(scopes, audience)`` — replacing it with an expired + sentinel (``token=""``, ``expires_on=0``) — but only when the + cached token still matched the one stamped on ``request``. A + subclass inspecting the ``TokenCache`` here therefore sees the + sentinel whenever the rejection was for the currently cached + token, and the newer token otherwise. When this hook returns + ``True`` the subsequent ``_authorize`` call forces a fresh + acquisition regardless. """ del request, response return False + def __repr__(self) -> str: + return _bearer_repr(self) + def _authorize( self, request: Request, @@ -256,30 +339,29 @@ def _authorize( # and skip the HTTPS enforcement that only governs the stamping path. if _crosses_recorded_origin(request, ctx): return request - if ctx.options.get("enforce_https", True) and not _is_https(request.url): - raise ServiceRequestError( - "Bearer token authentication is not permitted for non-HTTPS URLs." - ) - token = self._cache.get(self._scopes, self._audience) - if force_refresh or token is None or token.needs_refresh(clock=self._clock): - with self._lock: - # Double-checked: another thread may have refreshed while we waited. - token = self._cache.get(self._scopes, self._audience) - if force_refresh or token is None or token.needs_refresh(clock=self._clock): - options = _token_options(ctx.options) - token = self._credential.get_token_info(*self._scopes, options=options) - self._cache.set(self._scopes, token, self._audience) - assert token is not None # Narrowed by the refresh branch above. + _enforce_https(request, ctx) + options = _token_options(ctx.options) + + def _provider() -> AccessTokenInfo: + return self._credential.get_token_info(*self._scopes, options=options) + + token = self._single_flight.get_token(_provider, force=force_refresh) return request.with_header("Authorization", f"{token.token_type} {token.token}") class AsyncBearerTokenPolicy(AsyncPolicy): """Async twin of ``BearerTokenPolicy``. - Concurrent refreshes are serialized via an ``asyncio.Lock`` using a - double-checked pattern; the underlying ``AsyncTokenCredential`` is - invoked at most once per refresh window even when many tasks call - ``send`` concurrently. + Token serving is delegated to an `_AsyncSingleFlight` coordinator that + implements a three-zone refresh strategy: a fresh token is served + immediately; a token inside the 30-second refresh margin is served while a + non-fatal background refresh is kicked off; and an expired (or absent) + token blocks the caller on a single shared fetch. That fetch is guarded by + an ``asyncio.Lock`` and awaited through ``asyncio.shield`` so the + ``AsyncTokenCredential`` runs at most once per refresh window and one + waiter's cancellation cannot abort the fetch the others depend on. A + provider that returns ``None`` or an already-expired token raises, and a + throwing provider propagates without being cached. Like the sync policy, an optional ``challenge_handler`` plugs in a scheme-aware handler (e.g. ``DigestChallengeHandler``); it is consulted @@ -301,10 +383,9 @@ class AsyncBearerTokenPolicy(AsyncPolicy): "_audience", "_cache", "_challenge_handler", - "_clock", "_credential", - "_lock", "_scopes", + "_single_flight", ) def __init__( @@ -322,11 +403,11 @@ def __init__( self._scopes = scopes self._cache: TokenCache = cache or InMemoryTokenCache() self._audience = audience - # ``AsyncClock`` is forwarded to the sync ``needs_refresh`` helper: - # only ``now()`` is consulted there (which is sync on both Clock - # variants), so the protocol mismatch on ``sleep`` is irrelevant. - self._clock: AsyncClock = clock if clock is not None else ASYNC_SYSTEM_CLOCK - self._lock = asyncio.Lock() + # Only ``clock.now()`` is consulted by the coordinator (a sync read on + # both Clock variants), so an ``AsyncClock`` slots in without its + # awaitable ``sleep`` ever being needed. + resolved_clock: AsyncClock = clock if clock is not None else ASYNC_SYSTEM_CLOCK + self._single_flight = _AsyncSingleFlight(self._cache, scopes, audience, resolved_clock) self._challenge_handler = challenge_handler async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: @@ -335,11 +416,13 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: status = int(response.status) if status not in (401, 407): return response - # On 401 the bearer token was rejected: drop it from the cache so the - # ``on_challenge`` fallback path forces a refresh. A 407 means the - # proxy rejected us, not the origin — leave the cached token alone. + # On 401 the origin rejected the bearer token: drop it from the cache + # so a later refresh re-fetches. Evict only when the cached token still + # matches the one this request carried, so a stale 401 cannot discard a + # token a concurrent refresh already replaced. A 407 means the proxy + # rejected us, not the origin — leave the cached token alone. if status == 401: - self._cache.set(self._scopes, _expired_token(), self._audience) + self._evict_rejected_token(request) handler_header = self._apply_challenge_handler(request, response, status) if handler_header is not None: request = request.with_header(*handler_header) @@ -354,14 +437,66 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: # No handler match on a 407; surface the response unchanged so # callers can inspect proxy-auth failures themselves. return response - if "WWW-Authenticate" in response.headers and await self.on_challenge(request, response): - request = await self._authorize(request, ctx, force_refresh=True) - # Release the rejected 401 before retrying with the refreshed token. + return await self._replay_on_challenge(request, response, ctx) + + async def _replay_on_challenge( + self, + request: Request, + response: AsyncResponse, + ctx: PipelineContext, + ) -> AsyncResponse: + """Async twin of ``BearerTokenPolicy._replay_on_challenge``. + + Enforces the same gate as the sync policy: a 401 without a + ``WWW-Authenticate`` header is surfaced without consulting the hook, a + single-use body is surfaced un-replayed, and at most one replay is made + when the hook authorizes it, whose response is returned regardless of + status. Wrapping the ``await`` of ``on_challenge`` catches every hook + failure shape — a raised coroutine and a synchronous throw from + producing the coroutine alike — so the rejected response is closed + before the exception propagates. + + Args: + request: The request that produced the 401. + response: The open 401 response. + ctx: The per-operation pipeline context. + + Returns: + The surfaced 401, or the response from the single replay. + """ + if "WWW-Authenticate" not in response.headers: + return response + if request.body is not None and not request.body.is_replayable(): + return response + try: + proceed = await self.on_challenge(request, response) + except BaseException: await response.close() - response = await self.next.send(request, ctx) - if int(response.status) != 401: - return response - raise ClientAuthenticationError(response=response) + raise + if not proceed: + return response + request = await self._authorize(request, ctx, force_refresh=True) + # Release the rejected 401 before retrying with the refreshed token. + await response.close() + return await self.next.send(request, ctx) + + def _evict_rejected_token(self, request: Request) -> None: + """Evict the cached token when it still matches this request's stamp. + + Mirrors ``BearerTokenPolicy._evict_rejected_token``. The compare-and-set + needs no lock here: it runs without an ``await`` between the read and + the write, so no other task can interleave under asyncio's cooperative + scheduling. + + Args: + request: The request whose stamped token was rejected. + """ + stamped = request.headers.get("Authorization") + if stamped is None: + return + cached = self._cache.get(self._scopes, self._audience) + if cached is not None and stamped == f"{cached.token_type} {cached.token}": + self._cache.set(self._scopes, _expired_token(), self._audience) def _apply_challenge_handler( self, @@ -399,18 +534,22 @@ async def on_challenge( """Async version of ``BearerTokenPolicy.on_challenge``. Note: - By the time this hook runs, ``send`` has already replaced the - cached token for ``(scopes, audience)`` with an expired sentinel - (``token=""``, ``expires_on=0``). Subclasses that inspect the - ``TokenCache`` here will see that sentinel, not the original - token that was attached to ``request``. The sentinel is what - forces the subsequent ``_authorize`` call (when this hook - returns ``True``) to invoke the credential and acquire a fresh - token rather than reusing the rejected one. + By the time this hook runs, ``send`` has invalidated the cached + token for ``(scopes, audience)`` — replacing it with an expired + sentinel (``token=""``, ``expires_on=0``) — but only when the + cached token still matched the one stamped on ``request``. A + subclass inspecting the ``TokenCache`` here therefore sees the + sentinel whenever the rejection was for the currently cached + token, and the newer token otherwise. When this hook returns + ``True`` the subsequent ``_authorize`` call forces a fresh + acquisition regardless. """ del request, response return False + def __repr__(self) -> str: + return _bearer_repr(self) + async def _authorize( self, request: Request, @@ -423,20 +562,13 @@ async def _authorize( # and skip the HTTPS enforcement that only governs the stamping path. if _crosses_recorded_origin(request, ctx): return request - if ctx.options.get("enforce_https", True) and not _is_https(request.url): - raise ServiceRequestError( - "Bearer token authentication is not permitted for non-HTTPS URLs." - ) - token = self._cache.get(self._scopes, self._audience) - if force_refresh or token is None or token.needs_refresh(clock=self._clock): - async with self._lock: - # Double-checked: another task may have refreshed while we waited. - token = self._cache.get(self._scopes, self._audience) - if force_refresh or token is None or token.needs_refresh(clock=self._clock): - options = _token_options(ctx.options) - token = await self._credential.get_token_info(*self._scopes, options=options) - self._cache.set(self._scopes, token, self._audience) - assert token is not None # Narrowed by the refresh branch above. + _enforce_https(request, ctx) + options = _token_options(ctx.options) + + async def _provider() -> AccessTokenInfo: + return await self._credential.get_token_info(*self._scopes, options=options) + + token = await self._single_flight.get_token(_provider, force=force_refresh) return request.with_header("Authorization", f"{token.token_type} {token.token}") @@ -470,6 +602,37 @@ def _is_https(url: Url) -> bool: return url.scheme.lower() == "https" +def _enforce_https(request: Request, ctx: PipelineContext) -> None: + """Refuse to credential a non-HTTPS request unless enforcement is disabled. + + Every credentialed policy calls this on its same-origin stamping path, + before the secret is fetched or read, so a credential is never prepared + for a plaintext-HTTP request. Enforcement is on by default; pass + ``enforce_https=False`` in the per-call options to opt out. + + Args: + request: The request an auth policy is about to credential. + ctx: The per-operation pipeline context. + + Raises: + ServiceRequestError: When the URL is not HTTPS and enforcement is on. + """ + if ctx.options.get("enforce_https", True) and not _is_https(request.url): + raise ServiceRequestError("Credential authentication is not permitted for non-HTTPS URLs.") + + +def _bearer_repr(policy: BearerTokenPolicy | AsyncBearerTokenPolicy) -> str: + """Render a bearer policy without exposing the lazily fetched token. + + The token is never held at construction; only the non-secret scopes, + audience, and credential type name are shown. + """ + return ( + f"{type(policy).__name__}(credential={type(policy._credential).__name__}, " + f"scopes={policy._scopes!r}, audience={policy._audience!r})" + ) + + def _token_options(call_options: dict[str, Any]) -> TokenRequestOptions | None: """Project ``ctx.options`` onto ``TokenRequestOptions``.""" options: TokenRequestOptions = {} diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/token_cache.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/token_cache.py index fe4dd08..f3c7eff 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/token_cache.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/token_cache.py @@ -1,16 +1,36 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Pluggable token cache for the bearer-token policy.""" +"""Pluggable token cache plus the single-flight refresh coordinators. + +Three layers live here: + +- `TokenCache` / `InMemoryTokenCache` — the pluggable storage seam keyed by + ``(scopes, audience)``. The in-process default publishes an immutable dict + snapshot so reads are wait-free (no lock on the hot path). +- `_SyncSingleFlight` / `_AsyncSingleFlight` — per-key refresh coordinators + that wrap a storage `TokenCache`, serve the published token wait-free, and + collapse concurrent misses into a single provider call. The async variant + implements the three-zone (fresh / soft / hard) strategy. + +The zone and validation rules the coordinators enforce are shared, pure, and +live in `_refresh`. +""" from __future__ import annotations +import asyncio import threading -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from typing import Protocol, runtime_checkable +from ...util.clock import AsyncClock, Clock +from ._refresh import REFRESH_MARGIN_SECONDS, RefreshZone, classify, validate_fetched from .access_token import AccessTokenInfo +type _SyncProvider = Callable[[], AccessTokenInfo] +type _AsyncProvider = Callable[[], Awaitable[AccessTokenInfo]] + def _cache_key(scopes: Sequence[str], audience: str | None) -> tuple[str, ...]: """Build the dictionary key for a ``(scopes, audience)`` pair.""" @@ -46,9 +66,17 @@ class InMemoryTokenCache: """Thread-safe in-process token cache keyed by ``(scopes, audience)``. The scope list is sorted before being keyed so ``["a", "b"]`` and - ``["b", "a"]`` map to the same entry. Every operation acquires the lock - so the guarantee survives free-threaded CPython (PEP 703) and - non-CPython runtimes that do not guarantee atomic dict ops. + ``["b", "a"]`` map to the same entry. + + Reads are wait-free: ``get`` loads the current dict snapshot in a single + attribute access and looks the key up in that immutable snapshot, taking + no lock. Writers never mutate a published dict — ``set`` and ``clear`` + build a brand-new dict under the lock and publish it with one reference + store. A concurrent reader therefore sees either the old snapshot or the + new one in full, never a half-updated dict. Because publication is a + single reference assignment (atomic on every CPython, GIL or not), the + guarantee survives free-threaded CPython (PEP 703) and non-CPython + runtimes that do not offer atomic dict operations. """ __slots__ = ("_entries", "_lock") @@ -62,8 +90,9 @@ def get( scopes: Sequence[str], audience: str | None = None, ) -> AccessTokenInfo | None: - with self._lock: - return self._entries.get(_cache_key(scopes, audience)) + # Wait-free: single load of the published snapshot, then a plain + # lookup on an immutable dict no writer will ever mutate in place. + return self._entries.get(_cache_key(scopes, audience)) def set( self, @@ -71,12 +100,209 @@ def set( token: AccessTokenInfo, audience: str | None = None, ) -> None: + key = _cache_key(scopes, audience) with self._lock: - self._entries[_cache_key(scopes, audience)] = token + self._entries = {**self._entries, key: token} def clear(self) -> None: with self._lock: - self._entries.clear() + self._entries = {} + + +class _SyncSingleFlight: + """Wait-free reads with single-flight blocking refresh for one token key. + + The hot path loads the published token from the backing `TokenCache` + without taking a lock (`InMemoryTokenCache.get` is wait-free). Only when + the token is missing or no longer `RefreshZone.FRESH` does a caller take + the lock, re-check the cache under it (double-checked locking, with no + reliance on the GIL for atomicity), and invoke the provider — so the + provider runs at most once per refresh window even under heavy + concurrent pressure. + """ + + __slots__ = ("_audience", "_cache", "_clock", "_lock", "_margin", "_scopes") + + def __init__( + self, + cache: TokenCache, + scopes: Sequence[str], + audience: str | None, + clock: Clock, + *, + margin: float = REFRESH_MARGIN_SECONDS, + ) -> None: + self._cache = cache + self._scopes = scopes + self._audience = audience + self._clock = clock + self._margin = margin + self._lock = threading.Lock() + + def get_token(self, provider: _SyncProvider, *, force: bool = False) -> AccessTokenInfo: + """Return a usable token, refreshing under single-flight if needed. + + Args: + provider: Callable that fetches a fresh token from the credential. + force: Refresh unconditionally, ignoring any cached token. + + Returns: + A validated, non-expired token. + + Raises: + ClientAuthenticationError: If the provider returns ``None`` or an + already-expired token. + """ + token = self._cache.get(self._scopes, self._audience) # wait-free hot read + if not force and self._is_fresh(token): + assert token is not None # ``FRESH`` implies a present token. + return token + with self._lock: + token = self._cache.get(self._scopes, self._audience) # double-checked + if not force and self._is_fresh(token): + assert token is not None + return token + fetched = validate_fetched(provider(), now=self._clock.now()) + self._cache.set(self._scopes, fetched, self._audience) + return fetched + + def _is_fresh(self, token: AccessTokenInfo | None) -> bool: + return classify(token, now=self._clock.now(), margin=self._margin) is RefreshZone.FRESH + + def evict_if_matches( + self, + predicate: Callable[[AccessTokenInfo], bool], + replacement: AccessTokenInfo, + ) -> None: + """Replace the cached token with ``replacement`` if it matches ``predicate``. + + Runs under the same lock ``get_token`` uses for its refresh + compare-and-set, so eviction cannot race a concurrent refresh across + threads: either this reads the token before a refresh publishes a new + one (and evicts it), or it reads the new one after (and ``predicate`` + — checked against a caller-held reference to the token that was + actually rejected — correctly declines to evict it). + + Args: + predicate: Called with the currently cached token, if any; return + ``True`` to evict it. + replacement: The value to store in place of an evicted token. + """ + with self._lock: + token = self._cache.get(self._scopes, self._audience) + if token is not None and predicate(token): + self._cache.set(self._scopes, replacement, self._audience) + + +class _AsyncSingleFlight: + """Three-zone async refresh with shielded single-flight for one token key. + + The hot read of the published token is wait-free. Beyond that the zone + (see `RefreshZone`) decides what happens: + + - ``FRESH``: serve the cached token immediately. + - ``SOFT``: serve the still-valid cached token and kick off a background + refresh whose failure is non-fatal (the served token is still good). + - ``HARD``: block the caller on a single shared fetch. + + The shared fetch runs as an independent task guarded by an + ``asyncio.Lock``; every waiter awaits it through ``asyncio.shield`` so a + single waiter's cancellation propagates to that waiter alone while the + fetch keeps running for the others. + """ + + __slots__ = ( + "_audience", + "_background", + "_cache", + "_clock", + "_lock", + "_margin", + "_refresh", + "_scopes", + ) + + def __init__( + self, + cache: TokenCache, + scopes: Sequence[str], + audience: str | None, + clock: AsyncClock, + *, + margin: float = REFRESH_MARGIN_SECONDS, + ) -> None: + self._cache = cache + self._scopes = scopes + self._audience = audience + self._clock = clock + self._margin = margin + self._lock = asyncio.Lock() + self._refresh: asyncio.Future[AccessTokenInfo] | None = None + self._background: asyncio.Task[None] | None = None + + async def get_token(self, provider: _AsyncProvider, *, force: bool = False) -> AccessTokenInfo: + """Return a usable token following the three-zone strategy. + + Args: + provider: Awaitable-returning callable that fetches a fresh token. + force: Block on a fresh fetch unconditionally, ignoring any + cached token and its zone. + + Returns: + A validated, non-expired token. + + Raises: + ClientAuthenticationError: If a blocking fetch returns ``None`` or + an already-expired token. + """ + token = self._cache.get(self._scopes, self._audience) # wait-free hot read + if force: + return await self._blocking_refresh(provider) + zone = classify(token, now=self._clock.now(), margin=self._margin) + if zone is RefreshZone.FRESH: + assert token is not None + return token + if zone is RefreshZone.SOFT: + assert token is not None + self._kick_background(provider) + return token + return await self._blocking_refresh(provider) + + async def _blocking_refresh(self, provider: _AsyncProvider) -> AccessTokenInfo: + """Await the shared fetch, shielded from this waiter's cancellation.""" + future = await self._ensure_refresh(provider) + return await asyncio.shield(future) + + async def _ensure_refresh(self, provider: _AsyncProvider) -> asyncio.Future[AccessTokenInfo]: + """Return the in-flight shared fetch, starting one if none is running.""" + async with self._lock: + future = self._refresh + if future is None or future.done(): + future = asyncio.ensure_future(self._fetch(provider)) + self._refresh = future + return future + + async def _fetch(self, provider: _AsyncProvider) -> AccessTokenInfo: + """Fetch, validate, and publish a fresh token (the shared task body).""" + fetched = validate_fetched(await provider(), now=self._clock.now()) + self._cache.set(self._scopes, fetched, self._audience) + return fetched + + def _kick_background(self, provider: _AsyncProvider) -> None: + """Schedule a proactive refresh unless one is already pending.""" + if self._background is not None and not self._background.done(): + return + self._background = asyncio.ensure_future(self._background_refresh(provider)) + + async def _background_refresh(self, provider: _AsyncProvider) -> None: + """Best-effort proactive refresh; a failure is deliberately swallowed.""" + try: + future = await self._ensure_refresh(provider) + await asyncio.shield(future) + except Exception: + # Non-fatal: the still-valid token was already served, so a failed + # proactive refresh must never surface on the request path. + pass __all__ = ["InMemoryTokenCache", "TokenCache"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/_query_codec.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/_query_codec.py new file mode 100644 index 0000000..a4e3734 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/_query_codec.py @@ -0,0 +1,169 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure RFC 3986 query-string codec (furl-free). + +Wire-exact query rendering and lenient parsing for URL query strings, kept in a +standalone module that imports no `furl` so the byte-level rendering path never +picks up furl's own normalisations (default-port stripping, `+`/`%20` +reinterpretation, unicode host forms). The `Url` / `QueryParams` public API in +`url` calls into this module for the wire-exact query paths; furl there handles +only the scheme/authority/path parse-and-serialise plumbing. + +Rendering is strict. Only the RFC 3986 `unreserved` set (`A-Za-z0-9-._~`) is +emitted verbatim; every other byte — space, `+`, the sub-delims and gen-delims, +and all non-ASCII (UTF-8 first) — is percent-encoded with upper-case hex. A +space becomes `%20` (never `+`) and a literal `+` becomes `%2B`, so the output +is a true RFC 3986 query component rather than an +`application/x-www-form-urlencoded` payload (whose plus-for-space rule is the +trap this module exists to avoid). + +Parsing is lenient. Pairs split on `&`, then name and value split on the first +`=`. A wire `+` stays a literal `+` — it is NOT form-decoded to a space — and a +token with no `=` yields a present empty-string value, distinct from an absent +name. `parse_query(render_query(pairs)) == pairs` for every pair sequence. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from urllib.parse import quote, unquote + +__all__ = ["parse_query", "remove_query_param", "render_query", "splice_query"] + + +def _encode(component: str) -> str: + """Percent-encode one query component, leaving only ``unreserved`` verbatim. + + ``urllib.parse.quote``'s always-safe set is exactly the RFC 3986 + ``unreserved`` set (``A-Za-z0-9-._~``), so ``safe=""`` percent-encodes every + other byte with upper-case hex: a space becomes ``%20``, ``+`` becomes + ``%2B``, the reserved delimiters become ``%XX``, and non-ASCII is UTF-8 + encoded first. ``quote`` never emits ``+`` for a space, unlike ``quote_plus`` + / ``urlencode``. + + Args: + component: A raw (decoded) query name or value. + + Returns: + The percent-encoded component. + """ + return quote(component, safe="") + + +def render_query(pairs: Iterable[tuple[str, str]]) -> str: + """Render ordered ``(name, value)`` pairs to a strict RFC 3986 query string. + + Each pair is emitted as ``name=value`` — always including the ``=`` — in the + given order, joined by ``&``; pairs are never sorted. Names and values are + percent-encoded so only the ``unreserved`` set survives, a space renders as + ``%20`` and a ``+`` as ``%2B``. + + Args: + pairs: Ordered ``(name, value)`` pairs. A present-but-empty value + renders as ``name=`` (distinct from the name being absent, which is + simply not supplying the pair). + + Returns: + The wire query string with no leading ``?`` (empty for no pairs). + """ + return "&".join(f"{_encode(name)}={_encode(value)}" for name, value in pairs) + + +def _token_name(token: str) -> str: + """Decode a raw ``name=value`` token's name (the part before the first ``=``). + + The name is decoded so it compares against a plain (unencoded) parameter + name, while the token itself is left untouched by the caller — only the + name is inspected, never rewritten. + + Args: + token: One raw ``&``-separated query token (a bare flag has no ``=``). + + Returns: + The percent-decoded parameter name. + """ + return unquote(token.split("=", 1)[0]) + + +def splice_query(raw_query: str, name: str, value: str) -> str: + """Set ``name=value`` in a raw query, preserving every other token verbatim. + + A wire-exact splice: the query is tokenised on ``&`` and every untouched + token is copied through byte for byte — a pre-encoded ``%20`` survives and a + literal ``+`` is neither re-encoded to ``%2B`` nor decoded to a space. Only + the new pair is freshly RFC 3986 encoded via ``render_query``. This is a + deliberate alternative to a ``parse_qs`` / ``urlencode`` round-trip, which + would normalise (and so corrupt) the untouched bytes. + + The first token whose (decoded) name equals ``name`` is replaced in place, + keeping its position; if no token matches, the new pair is appended. A + second occurrence of ``name`` is left untouched — a "set" edits the first + only. + + Args: + raw_query: The raw query string with no leading ``?`` (may be empty). + name: The parameter name to set (plain, unencoded). + value: The parameter value to set (plain, unencoded). + + Returns: + The rewritten raw query string with no leading ``?``. + """ + encoded = render_query([(name, value)]) + if not raw_query: + return encoded + result: list[str] = [] + replaced = False + for token in raw_query.split("&"): + if not replaced and _token_name(token) == name: + result.append(encoded) + replaced = True + else: + result.append(token) + if not replaced: + result.append(encoded) + return "&".join(result) + + +def remove_query_param(raw_query: str, name: str) -> str: + """Drop every occurrence of ``name`` from a raw query, preserving other tokens. + + The counterpart to ``splice_query``'s "set": all tokens whose (decoded) name + equals ``name`` are removed; every other token is copied through byte for + byte, so untouched pre-encoded or literal-``+`` bytes survive verbatim. + + Args: + raw_query: The raw query string with no leading ``?`` (may be empty). + name: The parameter name to drop (plain, unencoded). + + Returns: + The rewritten raw query string with no leading ``?``. + """ + if not raw_query: + return "" + kept = [token for token in raw_query.split("&") if _token_name(token) != name] + return "&".join(kept) + + +def parse_query(raw: str) -> list[tuple[str, str]]: + """Parse a query string into ordered ``(name, value)`` pairs (lenient). + + Splits on ``&`` then on the first ``=`` per token, preserving order. Percent + escapes are decoded as UTF-8; a wire ``+`` is kept as a literal ``+`` rather + than form-decoded to a space. A token with no ``=`` (a bare flag) yields a + present empty-string value, distinct from an absent name. + + Args: + raw: A query string with no leading ``?`` (an empty string yields no + pairs). + + Returns: + The ordered ``(name, value)`` pairs, one per ``&``-separated token. + """ + if not raw: + return [] + pairs: list[tuple[str, str]] = [] + for token in raw.split("&"): + name, sep, value = token.partition("=") + pairs.append((unquote(name), unquote(value) if sep else "")) + return pairs diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/headers.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/headers.py index 48aafde..efd6f21 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/headers.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/headers.py @@ -12,9 +12,19 @@ from .http_header_name import HttpHeaderName # RFC 7230 token: 1*tchar where tchar is the set of ASCII characters allowed -# in header field names. We match against the lower-cased name because header -# names are case-insensitive and stored in lower form. -_TOKEN: Final[re.Pattern[str]] = re.compile(r"^[!#$%&'*+\-.^_`|~0-9a-z]+$") +# in header field names. We match against the *original* (pre-fold) name so +# non-ASCII input is rejected outright rather than silently ASCII-folded — e.g. +# U+212A KELVIN SIGN lower-cases to "k" and U+0130 (dotted capital I) folds to +# "i" plus a combining mark, both of which would otherwise slip past a +# lower-cased token check. Matching the original keeps case-folding ASCII-only +# and locale-invariant (see ``_normalize``). +_TOKEN: Final[re.Pattern[str]] = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") + +# Any character outside RFC 7230 ``field-content`` (HTAB, SP, and VCHAR +# 0x21-0x7E). Used only on the outbound-strict path: it also matches obs-text +# (0x80-0xFF), which received (inbound) messages must tolerate but generated +# (outbound) ones must not emit. +_NON_FIELD_CONTENT: Final[re.Pattern[str]] = re.compile(r"[^\t\x20-\x7e]") # Headers whose values should be redacted in repr() to avoid accidental # credential leakage through logging or exception formatting. @@ -42,6 +52,17 @@ class Headers: requirement that some headers (``Set-Cookie``, ``WWW-Authenticate``, ``Via``) may legitimately repeat. + Validation has two strictness levels, both of which always reject the + CR / LF / NUL bytes that enable header injection: + + * The default constructor is **inbound-lenient**: it tolerates obs-text + (bytes 0x80-0xFF), so response headers decoded as Latin-1 by a transport + round-trip unchanged, per RFC 7230's rule that received messages accept + obs-text. + * `outbound` is **outbound-strict**: it additionally rejects obs-text and + other non-``field-content`` bytes, so generated request headers never + emit characters RFC 7230 forbids on the wire. + Instances are immutable and freely shareable across threads. """ @@ -51,21 +72,32 @@ class Headers: _hash: int | None def __init__(self, entries: _Entries | None = None) -> None: - data: dict[str, tuple[str, ...]] = {} - if entries is not None: - items: Iterable[tuple[_Name, _HeaderValue]] = ( - entries.items() if isinstance(entries, Mapping) else entries - ) - for name, value in items: - key = _normalize(name) - existing = data.get(key, ()) - new_values: tuple[str, ...] = (value,) if isinstance(value, str) else tuple(value) - for v in new_values: - _check_value(key, v) - data[key] = (*existing, *new_values) - object.__setattr__(self, "_data", tuple(data.items())) + object.__setattr__(self, "_data", _build(entries, strict=False)) object.__setattr__(self, "_hash", None) + @classmethod + def outbound(cls, entries: _Entries | None = None) -> Self: + """Build headers for an outbound request under strict RFC 7230 rules. + + Identical to the default constructor except that header values are + additionally rejected if they contain obs-text (bytes 0x80-0xFF) or any + other character outside ``field-content`` (HTAB, SP, and visible ASCII). + Use this when assembling headers the SDK will *send*, so a spec-clean + request never carries bytes forbidden on the wire. + + Args: + entries: Initial headers as a mapping or an iterable of + ``(name, value | values)`` pairs. + + Returns: + A new `Headers` populated from ``entries``. + + Raises: + ValueError: If a name is not a valid token, or a value contains + CR / LF / NUL, obs-text, or other non-``field-content`` bytes. + """ + return _construct(cls, _build(entries, strict=True)) + def __setattr__(self, name: str, value: object) -> None: raise AttributeError(f"{type(self).__name__} is immutable") @@ -217,23 +249,50 @@ def empty(cls) -> Headers: return _EMPTY +def _build(entries: _Entries | None, *, strict: bool) -> tuple[tuple[str, tuple[str, ...]], ...]: + # Shared construction path for the lenient (``__init__``) and strict + # (``outbound``) entry points; ``strict`` selects the value-validation rule. + data: dict[str, tuple[str, ...]] = {} + if entries is not None: + items: Iterable[tuple[_Name, _HeaderValue]] = ( + entries.items() if isinstance(entries, Mapping) else entries + ) + for name, value in items: + key = _normalize(name) + new_values: tuple[str, ...] = (value,) if isinstance(value, str) else tuple(value) + for v in new_values: + _check_value(key, v, strict=strict) + data[key] = (*data.get(key, ()), *new_values) + return tuple(data.items()) + + def _normalize(name: _Name) -> str: - # HttpHeaderName already holds the lower-case form; for raw strings we - # canonicalise here. RFC 7230: header names are ASCII so `.lower()` is - # sufficient and casefold() is unnecessary overhead on the hot path. + # HttpHeaderName already holds the trusted lower-case form, so skip the + # token check and the ``.lower()`` call entirely on that hot path. if isinstance(name, HttpHeaderName): return name.value - lowered = name.lower() - if not _TOKEN.match(lowered): + # Validate the *original* name: the token set is ASCII-only, so a non-ASCII + # name fails here instead of being ASCII-folded by ``.lower()`` (see + # ``_TOKEN``). Lower-casing a proven-ASCII string is locale-invariant — it + # never triggers the Turkish dotless-i or Kelvin-sign special-casing traps. + if not _TOKEN.match(name): raise ValueError(f"invalid header name: {name!r}") - return lowered + return name.lower() -def _check_value(key: str, value: str) -> None: - # Reject CR, LF, and NUL to prevent header injection / response splitting. - # ``key`` is the already-normalised name, used only for the error message. +def _check_value(key: str, value: str, *, strict: bool = False) -> None: + # Reject CR, LF, and NUL on every path to prevent header injection / + # response splitting. ``key`` is the already-normalised name, echoed via + # ``!r`` so any control characters in it are escaped; the value is never + # echoed, so a rejected secret cannot leak through the message. if "\r" in value or "\n" in value or "\0" in value: raise ValueError(f"invalid header value for {key!r}: contains control characters") + # Outbound-strict additionally forbids obs-text and other non-field-content + # bytes, which received (inbound) headers must instead tolerate. + if strict and _NON_FIELD_CONTENT.search(value): + raise ValueError( + f"invalid header value for {key!r}: contains obs-text or non-ASCII control characters" + ) def _construct[H: Headers]( diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/media_type.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/media_type.py index 192fb20..36e9a5d 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/media_type.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/media_type.py @@ -10,6 +10,8 @@ from dataclasses import dataclass from typing import Self +from .headers import _NON_FIELD_CONTENT + # RFC 7230 §3.2.6 — characters that are NOT valid in a ``token``. Any # parameter value containing one of these (or a non-printable byte) must be # rendered as a quoted-string. Space and HTAB are included because they @@ -17,6 +19,30 @@ _TOKEN_SEPARATORS = frozenset('()<>@,;:\\"/[]?={} \t') +def _reject_forbidden_chars(label: str, value: str) -> None: + """Reject a media-type component that could not survive as a header value. + + Applies the exact predicate outbound header-value validation uses + (`Headers.outbound`): any C0 control character except HTAB, ``DEL``, or a + non-ASCII byte is refused. Enforcing it at `MediaType` construction means a + media type can always be emitted as a ``Content-Type`` header without a late + rejection — and forecloses CR / LF injection at the point the type is built. + + Args: + label: Human-readable name of the offending component, for the message. + value: The component text (type, subtype, parameter key, or value). + + Raises: + ValueError: If ``value`` carries a forbidden byte. The value itself is + never echoed, so a control character cannot smuggle itself into the + message. + """ + if _NON_FIELD_CONTENT.search(value): + raise ValueError( + f"invalid media type {label}: contains a control character or non-ASCII byte" + ) + + def _split_params(value: str) -> list[str]: """Split a media-type wire string on ``;`` outside quoted-strings. @@ -99,17 +125,51 @@ def _quote_if_needed(s: str) -> str: class MediaType: """An HTTP media type (RFC 7231 §3.1.1.1). - Immutable and hashable. ``parameters`` is stored as a tuple of sorted - ``(key, value)`` pairs so two equivalent media types compare equal and hash - equally regardless of construction order. Construct via `of` or - `parse` rather than the dataclass constructor directly so the type, - subtype, and parameter keys are normalised to lower case. + Immutable and hashable. `__post_init__` normalises `type`/`subtype` to + lower-case and `parameters` to sorted, lower-cased-key pairs on *every* + construction path — including the raw dataclass constructor, not just the + `of`/`parse` factories — so two equivalent media types always compare + equal and hash equally regardless of the order or case they were built + with, and an un-normalised instance can never be observed. + + Note: the normalised parts are plain frozen fields rather than private + fields behind read-only properties. CPython's `@dataclass(frozen=True, + slots=True)` generates a broken `__setattr__`/`__delattr__` pair when the + class defines any descriptor beyond its declared slotted fields (a + reproducible stdlib issue, not specific to this field's name) — raising a + confusing `TypeError` from `super()` instead of `FrozenInstanceError` on + assignment. Plain frozen fields sidestep it while keeping the same + unbypassable-invariant guarantee; assigning to any field still raises + `FrozenInstanceError` (a `AttributeError` subclass) via the standard + generated path. """ type: str subtype: str parameters: tuple[tuple[str, str], ...] = () + def __post_init__(self) -> None: + # Normalise regardless of construction path (raw constructor included), + # so the equality/hash invariant holds for every instance rather than + # only those built through ``of``/``parse``. The dataclass is frozen, + # so mutate the fields via ``object.__setattr__``. Parameter *keys* + # are case-insensitive per RFC 7231 and are lower-cased, but *values* + # are preserved verbatim — multipart boundaries and similar tokens + # must round-trip exactly. + object.__setattr__(self, "type", self.type.lower()) + object.__setattr__(self, "subtype", self.subtype.lower()) + normalized = tuple(sorted((key.lower(), value) for key, value in self.parameters)) + object.__setattr__(self, "parameters", normalized) + # Reject any byte that outbound header-value validation would reject, so + # this media type can always be serialised into a ``Content-Type`` header + # without a late failure or a CR/LF injection. Runs on every construction + # path (raw constructor included) — the same guarantee as normalisation. + _reject_forbidden_chars("type", self.type) + _reject_forbidden_chars("subtype", self.subtype) + for key, value in self.parameters: + _reject_forbidden_chars("parameter name", key) + _reject_forbidden_chars("parameter value", value) + @property def full_type(self) -> str: """``type/subtype`` form, parameters excluded.""" @@ -143,6 +203,12 @@ def includes(self, other: MediaType) -> bool: subtype_matches = self.subtype == "*" or self.subtype == other.subtype return type_matches and subtype_matches + def __repr__(self) -> str: + return ( + f"MediaType(type={self.type!r}, subtype={self.subtype!r}, " + f"parameters={self.parameters!r})" + ) + def __str__(self) -> str: if not self.parameters: return f"{self.type}/{self.subtype}" @@ -168,15 +234,12 @@ def of( raise ValueError("type must not be blank") if not subtype or not subtype.strip(): raise ValueError("subtype must not be blank") - normalized_type = type.lower() - normalized_subtype = subtype.lower() - if normalized_type == "*" and normalized_subtype != "*": - raise ValueError(f"Invalid media type: type=*, subtype={normalized_subtype}") - # Parameter *keys* are case-insensitive per RFC 7231, but *values* - # are not — multipart boundaries, for example, must round-trip - # exactly. Only the key is lower-cased here. - params = tuple(sorted((k.lower(), v) for k, v in parameters.items())) if parameters else () - return cls(normalized_type, normalized_subtype, params) + # ``__post_init__`` lower-cases and sorts; validate the wildcard rule + # against the same normalised form it will produce. + if type.lower() == "*" and subtype.lower() != "*": + raise ValueError(f"Invalid media type: type=*, subtype={subtype.lower()}") + params = tuple(parameters.items()) if parameters else () + return cls(type, subtype, params) @classmethod def parse(cls, value: str) -> Self: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/streaming.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/streaming.py index afa7777..caed843 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/streaming.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/streaming.py @@ -5,23 +5,116 @@ These are SansIO utilities: they consume / produce ``Iterator[bytes]`` (or ``AsyncIterator[bytes]``) and do no I/O on their own. + +Lifecycle discipline (mirroring the SSE parser). The decoders work on raw +``bytes`` throughout — the buffer never passes through a text-mode stream, so a +multi-byte codepoint split across a chunk boundary is reassembled from bytes and +line boundaries stay intact. When iteration ends — on exhaustion, on error, or +when the consumer abandons the iterator part-way — the underlying byte stream's +``close`` / ``aclose`` is invoked so an interrupted read never leaks the response +body. A close failure raised while another exception is already unwinding the +decoder is chained onto that exception's ``__context__`` rather than masking it; +the async release is routed through the shared cancellation-shielded cleanup so +the transport handle is freed even when the consuming task is cancelled. """ from __future__ import annotations import json +import sys from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator from typing import Any from ...errors.serialization import DeserializationError from ...errors.streaming import StreamingError +from .._shielded import _shielded_cleanup + + +def _append_context(primary: BaseException, extra: BaseException) -> None: + """Attach ``extra`` to the tail of ``primary``'s ``__context__`` chain. + + Used when a teardown-time close fails while another exception is already + propagating: the primary error must keep propagating (it is not masked), + but the close failure is preserved for diagnostics rather than swallowed. + Any implicit context Python set on ``extra`` back to ``primary`` while the + teardown ran is cleared first, and the walk is cycle-guarded, so no + reference cycle is created. + + Args: + primary: The exception that must continue to propagate. + extra: The teardown-time close error to preserve on the chain. + """ + extra.__context__ = None + node = primary + seen = {id(primary), id(extra)} + while node.__context__ is not None and id(node.__context__) not in seen: + node = node.__context__ + seen.add(id(node)) + if node.__context__ is None: + node.__context__ = extra + + +def _close_upstream(iterator: object, primary: BaseException | None) -> None: + """Release a sync upstream iterator during decoder teardown. + + Calls the iterator's ``close`` if it exposes one (a bare ``iter`` result + without ``close`` is left untouched). A close failure surfaces when the + decoder finished cleanly, but is chained onto ``primary`` — never allowed + to mask it — when an exception is already unwinding the decoder. + + Args: + iterator: The upstream byte iterator to release. + primary: The exception already propagating out of the decoder, or + ``None`` on a clean finish. + + Raises: + Exception: The close failure, only when ``primary`` is ``None``. + """ + close = getattr(iterator, "close", None) + if close is None: + return + try: + close() + except Exception as close_err: + if primary is None: + raise + _append_context(primary, close_err) + + +async def _aclose_upstream(iterator: object, primary: BaseException | None) -> None: + """Async twin of `_close_upstream`. + + Routes the upstream ``aclose`` through the shared shielded-cleanup + convention so the transport handle is released even when the consuming task + is cancelled mid-stream, mirroring the SSE async stream. + + Args: + iterator: The upstream async byte iterator to release. + primary: The exception already propagating out of the decoder, or + ``None`` on a clean finish. + + Raises: + Exception: The close failure, only when ``primary`` is ``None``. + """ + aclose = getattr(iterator, "aclose", None) + if aclose is None: + return + try: + await _shielded_cleanup(aclose()) + except Exception as close_err: + if primary is None: + raise + _append_context(primary, close_err) def iter_jsonl(chunks: Iterable[bytes]) -> Iterator[Any]: """Parse a newline-delimited JSON stream. Each line is decoded as UTF-8 and JSON-parsed. Empty lines are skipped. - Buffering handles chunk-boundary splits. + Buffering handles chunk-boundary splits. The underlying byte stream is + released when iteration ends — on exhaustion, on error, or when the + consumer abandons the iterator part-way — so an interrupted read never + leaks the response body. Args: chunks: Iterable of byte chunks (typically ``response.body.iter_bytes()``). @@ -34,24 +127,32 @@ def iter_jsonl(chunks: Iterable[bytes]) -> Iterator[Any]: StreamingError: If a line is not valid UTF-8 (e.g. a non-UTF-8 byte sequence or a codepoint truncated by a short final line). """ + iterator = iter(chunks) buffer = bytearray() - for chunk in chunks: - buffer.extend(chunk) - yield from _drain_lines(buffer) - if buffer: - yield from _drain_lines(buffer, final=True) + try: + for chunk in iterator: + buffer.extend(chunk) + yield from _drain_lines(buffer) + if buffer: + yield from _drain_lines(buffer, final=True) + finally: + _close_upstream(iterator, sys.exc_info()[1]) async def aiter_jsonl(chunks: AsyncIterable[bytes]) -> AsyncIterator[Any]: """Async twin of ``iter_jsonl``.""" + iterator = aiter(chunks) buffer = bytearray() - async for chunk in chunks: - buffer.extend(chunk) - for value in _drain_lines(buffer): - yield value - if buffer: - for value in _drain_lines(buffer, final=True): - yield value + try: + async for chunk in iterator: + buffer.extend(chunk) + for value in _drain_lines(buffer): + yield value + if buffer: + for value in _drain_lines(buffer, final=True): + yield value + finally: + await _aclose_upstream(iterator, sys.exc_info()[1]) def _drain_lines(buffer: bytearray, *, final: bool = False) -> Iterator[Any]: @@ -85,7 +186,9 @@ def chunked_frame(chunks: Iterable[bytes]) -> Iterator[bytes]: """Wrap a byte stream in HTTP/1.1 chunked-transfer framing. Each input chunk is emitted as ``{hex-size}\\r\\n{chunk}\\r\\n``. After the - input is exhausted, a trailing ``0\\r\\n\\r\\n`` terminator is emitted. + input is exhausted, a trailing ``0\\r\\n\\r\\n`` terminator is emitted. The + source byte stream is released when framing ends — on exhaustion, on error, + or when the consumer abandons the iterator part-way. Args: chunks: Iterable of byte chunks to wrap. @@ -93,20 +196,28 @@ def chunked_frame(chunks: Iterable[bytes]) -> Iterator[bytes]: Yields: Framed bytes ready for HTTP/1.1 chunked transfer. """ - for chunk in chunks: - if not chunk: - continue - yield f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n" - yield b"0\r\n\r\n" + iterator = iter(chunks) + try: + for chunk in iterator: + if not chunk: + continue + yield f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n" + yield b"0\r\n\r\n" + finally: + _close_upstream(iterator, sys.exc_info()[1]) async def aiter_chunked_frame(chunks: AsyncIterable[bytes]) -> AsyncIterator[bytes]: """Async twin of ``chunked_frame``.""" - async for chunk in chunks: - if not chunk: - continue - yield f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n" - yield b"0\r\n\r\n" + iterator = aiter(chunks) + try: + async for chunk in iterator: + if not chunk: + continue + yield f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n" + yield b"0\r\n\r\n" + finally: + await _aclose_upstream(iterator, sys.exc_info()[1]) __all__ = ["aiter_chunked_frame", "aiter_jsonl", "chunked_frame", "iter_jsonl"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/url.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/url.py index 04303af..56a0166 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/url.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/url.py @@ -8,10 +8,12 @@ from collections.abc import Iterable, Iterator, Mapping from dataclasses import dataclass, field, replace from typing import Self -from urllib.parse import parse_qsl, quote, urlencode, urlsplit +from urllib.parse import urlsplit from furl import furl as _furl +from ._query_codec import parse_query, render_query + type _QueryValue = str | Iterable[str] type _QueryEntries = Mapping[str, _QueryValue] | Iterable[tuple[str, _QueryValue]] @@ -135,8 +137,14 @@ def without(self, name: str) -> Self: return _construct_query(type(self), entries) def encode(self) -> str: - """Serialise to ``application/x-www-form-urlencoded`` form (no leading ``?``).""" - return urlencode(self.flatten(), doseq=False, quote_via=quote) + """Render to a strict RFC 3986 query string (no leading ``?``). + + Uses the pure, furl-free query codec: a space renders as ``%20`` (never + ``+``) and a ``+`` as ``%2B``, so the output is a true query component + rather than an ``application/x-www-form-urlencoded`` payload. Insertion + order is preserved verbatim. + """ + return render_query(self.flatten()) def __str__(self) -> str: return self.encode() @@ -161,15 +169,14 @@ def __repr__(self) -> str: def parse(cls, raw: str) -> Self: """Parse a ``foo=1&bar=2`` query string (leading ``?`` is tolerated). - ``parse_qsl`` already percent-decodes keys and values; we do not - call ``unquote`` again to avoid double-decoding ``%2520`` → space. + Delegates to the pure, furl-free query codec: percent escapes are + UTF-8 decoded, a wire ``+`` stays a literal ``+`` (it is NOT + form-decoded to a space, unlike ``parse_qsl``), and a bare flag with no + ``=`` becomes a present empty-string value distinct from an absent name. """ if raw.startswith("?"): raw = raw[1:] - if not raw: - return _construct_query(cls, ()) - pairs = parse_qsl(raw, keep_blank_values=True, strict_parsing=False) - return cls(pairs) + return cls(parse_query(raw)) @classmethod def empty(cls) -> QueryParams: @@ -236,6 +243,20 @@ def authority(self, *, with_userinfo: bool) -> str: return "".join(parts) def _to_furl(self, *, with_userinfo: bool) -> _furl: + """Build a furl carrying every component EXCEPT the query. + + furl handles only the scheme / authority / path / fragment + parse-and-serialise plumbing. The query is rendered by the pure, + furl-free codec and spliced in by ``_serialise`` instead, so furl's own + normalisations never touch the wire-exact query bytes (furl re-encodes + ``%20`` back to ``+`` when it serialises a query it was handed). + + Args: + with_userinfo: When True, includes ``user[:password]`` credentials. + + Returns: + A furl for every component except the query. + """ f = _furl() f.scheme = self.scheme f.host = self.host @@ -243,8 +264,6 @@ def _to_furl(self, *, with_userinfo: bool) -> _furl: f.port = self.port if self.path: f.path = self.path - if len(self.query): - f.query = self.query.encode() if self.fragment: f.fragment = self.fragment if with_userinfo and self.userinfo is not None: @@ -254,8 +273,26 @@ def _to_furl(self, *, with_userinfo: bool) -> _furl: f.password = pwd return f + def _serialise(self, *, with_userinfo: bool) -> str: + """Serialise to wire form, splicing the pure-codec query before any fragment. + + Args: + with_userinfo: When True, includes ``user[:password]@`` credentials. + + Returns: + The wire-form URL string. + """ + rendered = str(self._to_furl(with_userinfo=with_userinfo)) + if not len(self.query): + return rendered + query = self.query.encode() + hash_index = rendered.find("#") + if hash_index == -1: + return f"{rendered}?{query}" + return f"{rendered[:hash_index]}?{query}{rendered[hash_index:]}" + def __str__(self) -> str: - return str(self._to_furl(with_userinfo=False)) + return self._serialise(with_userinfo=False) def wire_form(self) -> str: """Serialise to wire form including ``userinfo`` if present. @@ -264,7 +301,7 @@ def wire_form(self) -> str: credentials; default ``str(url)`` redacts userinfo to avoid accidental leakage through logging. """ - return str(self._to_furl(with_userinfo=True)) + return self._serialise(with_userinfo=True) def __repr__(self) -> str: userinfo = "[REDACTED]" if self.userinfo else None @@ -309,12 +346,17 @@ def parse(cls, raw: str) -> Self: userinfo = f.username if f.password is not None: userinfo = f"{f.username}:{f.password}" + # Extract the query from the raw string via ``urlsplit`` (which + # preserves the bytes verbatim) rather than ``f.query`` — furl + # re-encodes ``%20`` to ``+`` when it re-serialises a parsed query, + # which would corrupt the wire-exact round-trip. + split = urlsplit(raw) return cls( scheme=f.scheme, host=f.host, - port=urlsplit(raw).port, + port=split.port, path=str(f.path), - query=QueryParams.parse(str(f.query)), + query=QueryParams.parse(split.query), fragment=str(f.fragment), userinfo=userinfo, ) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/__init__.py index 4e945c6..c142df6 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/__init__.py @@ -12,8 +12,11 @@ 3. `ExchangeContext` — adds the `Response`; produced by `RequestContext.to_exchange_context` once a response has arrived. -Each promotion is registered with `ContextStore` under the call's trace -id so downstream observers see the latest snapshot for a call by trace id. +Each promotion is registered with `ContextStore` under a process-unique +key (`{trace_id}:{span_id}:{n}`) so downstream observers see the latest +snapshot for a call, and concurrent calls that share a trace id stay +distinct. The trace id remains the leading key segment, so a prefix scan +still finds every live context for a trace id. """ from __future__ import annotations diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/call_context.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/call_context.py index f901d69..e22f8b4 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/call_context.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/call_context.py @@ -22,17 +22,26 @@ class CallContext(abc.ABC): callers can ``with`` a context to ensure the `ContextStore` entry is evicted on exit. - The shared `ContextStore` is thread-safe; contexts for different - trace ids can be promoted concurrently without external synchronisation. + The shared `ContextStore` is thread-safe; contexts can be promoted + concurrently without external synchronisation, and calls that share a + trace id stay isolated because each carries its own `store_key`. """ instrumentation_context: InstrumentationContext # supplied by subclasses + store_key: str | None # ContextStore key; None until the call is registered def close(self) -> None: - """Remove this context from `ContextStore` (idempotent).""" + """Remove this context from `ContextStore` (idempotent). + + Removes the entry under this call's `store_key`. The request and + exchange tiers of one call share that key, so a single close clears + both. A context that was never registered (``store_key is None``) is a + no-op. + """ from .context_store import ContextStore - ContextStore.remove(self.instrumentation_context.trace_id.value) + if self.store_key is not None: + ContextStore.remove(self.store_key) def __enter__(self) -> Self: return self diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/context_store.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/context_store.py index 2128a2a..c49881e 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/context_store.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/context_store.py @@ -1,73 +1,150 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Process-wide registry mapping a call's trace id to its current context.""" +"""Process-wide, bounded registry mapping a call's registration key to its +context.""" from __future__ import annotations +import itertools import threading from typing import TYPE_CHECKING if TYPE_CHECKING: from .call_context import CallContext +#: Default upper bound on live entries in a `_ContextStore`. +#: +#: Entries are added on promotion and removed on `CallContext.close`, so a +#: well-behaved process holds only as many entries as it has in-flight calls — +#: far below this bound. The cap is a safety valve against callers that leak by +#: never closing: it keeps a forgotten entry from growing the map without +#: limit, at the cost of evicting the oldest entry once the bound is crossed. +_DEFAULT_MAX_ENTRIES = 100_000 + class _ContextStore: - """Process-wide registry mapping a call's trace id to its current - `CallContext`. + """Process-wide, bounded registry mapping a call's registration key to its + current `CallContext`. + + Keys are minted by `new_key` as ``{trace_id}:{span_id}:{n}``, where ``n`` + is drawn from a process-wide counter. The trace id alone is *not* a unique + key: a single traced operation can fan out into several concurrent calls + that share a trace id (and span id), so a trace-id-only key would let one + call's registration overwrite another's, or let one call's `close` evict a + sibling's live context. The counter segment gives every call its own slot. + The trace id stays the leading, colon-delimited segment so a prefix scan by + trace id (`contexts_for_trace`) still finds every live context for a call. Each promotion (`DispatchContext.to_request_context`, - `RequestContext.to_exchange_context`) overwrites the entry so the - latest snapshot is visible to downstream observers keyed by trace id. - Entries are removed when callers honour `CallContext.close`. - - Thread-safe — every operation acquires the lock so the guarantee - survives free-threaded CPython (PEP 703) and non-CPython runtimes that - do not guarantee atomic dict ops. + `RequestContext.to_exchange_context`) reuses the call's key so the latest + snapshot overwrites the previous tier in place. Entries are removed when + callers honour `CallContext.close`, and are held by strong reference until + then — eviction is explicit, never a weak-reference side effect of garbage + collection. + + Bounded — the map holds at most ``max_entries`` live entries. Each insert + (`put` / `set`) drains the oldest excess entries once the bound is crossed, + closing every evicted context. The close side effect runs *after* the + store lock is released, because closing a context re-enters the store + (`CallContext.close` calls `remove`); running it under the lock would + deadlock the non-reentrant lock. + + Thread-safe — every operation, including each counter increment, acquires + the lock so the guarantee survives free-threaded CPython (PEP 703) and + non-CPython runtimes that do not guarantee atomic dict ops or an atomic + `next` on `itertools.count`. Two writers coexist deliberately. ``put`` is a *guarded install* that - raises on a duplicate trace id; it is part of the public surface for - callers that own a trace id exclusively and want a duplicate to surface - as a programming error. ``set`` is the *unconditional overwrite* the - promotion chain (``DispatchContext.to_request_context`` → + raises on a duplicate key; it is part of the public surface for callers + that own a key exclusively and want a duplicate to surface as a programming + error. ``set`` is the *unconditional overwrite* the promotion chain + (``DispatchContext.to_request_context`` → ``RequestContext.to_exchange_context``) relies on, where the first promotion installs the entry and later promotions replace it in place. - ``put`` therefore has no internal callers, but removing it would narrow - the public, test-covered surface — so it stays. + ``put`` therefore has no internal callers, but removing it would narrow the + public, test-covered surface — so it stays. + + Two removers coexist for the same reason. ``remove`` is an *unconditional* + pop-by-key: a call's request and exchange tiers share one key, so a single + ``remove`` must clear whichever tier currently occupies the slot even + though `close` is invoked on the (distinct) request-tier object. Making it + identity-conditional would leak the promoted exchange tier. ``remove_if`` + is the *identity-conditional* counterpart for callers that must not clobber + a successor: it evicts only when the stored entry *is* the given context, + so an equal-but-distinct object at the same key leaves the live entry + untouched. """ - __slots__ = ("_contexts", "_lock") + __slots__ = ("_contexts", "_counter", "_lock", "_max_entries") + + def __init__(self, max_entries: int = _DEFAULT_MAX_ENTRIES) -> None: + """Create an empty store bounded at ``max_entries`` live entries. + + Args: + max_entries: Maximum number of live entries to retain. Once an + insert crosses this bound, the oldest excess entries are + drained and closed. - def __init__(self) -> None: + Raises: + ValueError: if ``max_entries`` is less than one. + """ + if max_entries < 1: + raise ValueError(f"max_entries must be >= 1, got {max_entries}") self._lock = threading.Lock() self._contexts: dict[str, CallContext] = {} + self._counter = itertools.count() + self._max_entries = max_entries + + def new_key(self, trace_id: str, span_id: str) -> str: + """Mint a process-unique registration key for a single call. + + The key is ``{trace_id}:{span_id}:{n}`` where ``n`` comes from a + process-wide counter drawn under the lock. Two concurrent calls that + share a trace id and span id still receive distinct keys, so neither + overwrites nor evicts the other. The trace id and span id are W3C hex + identifiers containing no colon, so the leading ``{trace_id}:`` segment + stays an unambiguous prefix for `contexts_for_trace`. - def get(self, trace_id: str) -> CallContext | None: - """Return the context registered under ``trace_id``, or ``None``.""" + Args: + trace_id: The call's trace id; becomes the key's leading segment. + span_id: The call's span id; the key's middle segment. + + Returns: + A registration key unique across the process for this call. + """ + with self._lock: + n = next(self._counter) + return f"{trace_id}:{span_id}:{n}" + + def get(self, key: str) -> CallContext | None: + """Return the context registered under ``key``, or ``None``.""" with self._lock: - return self._contexts.get(trace_id) + return self._contexts.get(key) - def put(self, trace_id: str, context: CallContext) -> None: - """Register ``context`` under ``trace_id``; reject duplicate ids. + def put(self, key: str, context: CallContext) -> None: + """Register ``context`` under ``key``; reject a duplicate key. - Guarded install for callers that own a trace id exclusively: a - re-registration is treated as a programming error. The promotion - chain uses `set` instead, which overwrites unconditionally. + Guarded install for callers that own a key exclusively: a + re-registration is treated as a programming error. The promotion chain + uses `set` instead, which overwrites unconditionally. Args: - trace_id: The key to register ``context`` under. + key: The key to register ``context`` under. context: The context to store. Raises: - ValueError: if ``trace_id`` is already registered. + ValueError: if ``key`` is already registered. """ with self._lock: - if trace_id in self._contexts: - raise ValueError(f"trace_id already registered: {trace_id!r}") - self._contexts[trace_id] = context + if key in self._contexts: + raise ValueError(f"key already registered: {key!r}") + self._contexts[key] = context + evicted = self._drain_to_cap() + self._close_evicted(evicted) - def set(self, trace_id: str, context: CallContext) -> None: - """Unconditionally store ``context`` under ``trace_id``. + def set(self, key: str, context: CallContext) -> None: + """Unconditionally store ``context`` under ``key``. Used by the promotion chain, where the first promotion installs the entry and later promotions overwrite it. Holds the lock so the @@ -75,12 +152,91 @@ def set(self, trace_id: str, context: CallContext) -> None: runtimes that don't guarantee atomic dict writes. """ with self._lock: - self._contexts[trace_id] = context + self._contexts[key] = context + evicted = self._drain_to_cap() + self._close_evicted(evicted) + + def remove(self, key: str) -> None: + """Remove the entry under ``key`` unconditionally. No-op if absent. + + A call's request and exchange tiers share one key, so removing that key + clears whichever tier currently occupies the slot in a single call — + the eviction `CallContext.close` relies on, even though it is invoked + on the request-tier object after promotion replaced it with the + exchange tier. Use `remove_if` when a successor at the same key must be + preserved. + """ + with self._lock: + self._contexts.pop(key, None) + + def remove_if(self, key: str, context: CallContext) -> bool: + """Remove the entry under ``key`` only if it *is* ``context``. - def remove(self, trace_id: str) -> None: - """Remove the entry under ``trace_id``. No-op if absent.""" + Identity-conditional via ``is``: an equal-but-distinct context + registered at the same key is left in place, so a stale predecessor + cannot evict the successor that replaced it. This is the safe + counterpart to the unconditional `remove` for callers that own a + specific object rather than a whole key. + + Args: + key: The key whose entry to consider. + context: The context that must currently occupy ``key`` for the + removal to happen. + + Returns: + ``True`` if this call removed the entry; ``False`` if ``key`` was + absent or held a different object. + """ + with self._lock: + if self._contexts.get(key) is context: + del self._contexts[key] + return True + return False + + def _drain_to_cap(self) -> list[CallContext]: + """Pop oldest entries until the map is at or below its cap. + + The caller must hold `self._lock`. Returns the evicted contexts so the + caller can run their close side effects *after* releasing the lock: an + evicted context's `close` re-enters the store, which would deadlock a + re-acquire of the non-reentrant lock. + + Returns: + The evicted contexts, oldest first (empty if the map is within cap). + """ + evicted: list[CallContext] = [] + while len(self._contexts) > self._max_entries: + oldest_key = next(iter(self._contexts)) + evicted.append(self._contexts.pop(oldest_key)) + return evicted + + @staticmethod + def _close_evicted(evicted: list[CallContext]) -> None: + """Run each evicted context's `close` side effect outside the lock. + + Called after the store lock is released so a `close` that re-enters the + store (its own `remove`, already a no-op here) does not deadlock. + """ + for context in evicted: + context.close() + + def contexts_for_trace(self, trace_id: str) -> list[CallContext]: + """Return every live context whose key belongs to ``trace_id``. + + Scans for keys with the leading ``{trace_id}:`` segment, so a fan-out + that produced several concurrent calls under one trace id yields all of + their live contexts. The trailing colon prevents a shorter trace id + from matching a longer one that merely shares its opening characters. + + Args: + trace_id: The trace id to scan for. + + Returns: + A snapshot list of the matching live contexts, in insertion order. + """ + prefix = f"{trace_id}:" with self._lock: - self._contexts.pop(trace_id, None) + return [ctx for key, ctx in self._contexts.items() if key.startswith(prefix)] #: Process-wide `_ContextStore` singleton. diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/dispatch_context.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/dispatch_context.py index b805484..d2f1f33 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/dispatch_context.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/dispatch_context.py @@ -23,25 +23,37 @@ class DispatchContext(CallContext): Carries only the `InstrumentationContext`. Once a `Request` has been built, `to_request_context` promotes this into a `RequestContext`. The promotion produces a new immutable instance - and re-registers it with `ContextStore` under the same trace id so + and registers it with `ContextStore` under a process-unique key so downstream observers see the latest snapshot. + + A dispatch context is not itself registered, so its `store_key` stays + ``None`` until it is promoted. """ instrumentation_context: InstrumentationContext + store_key: str | None = None def to_request_context(self, request: Request) -> RequestContext: """Promote into a `RequestContext` bound to ``request``. - Stores the new context in `ContextStore` keyed by trace id. + Mints a process-unique key (``{trace_id}:{span_id}:{n}``) and stores + the new context in `ContextStore` under it, so two concurrent calls + that share a trace id do not collide. """ from .context_store import ContextStore from .request_context import RequestContext + instrumentation = self.instrumentation_context + key = ContextStore.new_key( + instrumentation.trace_id.value, + instrumentation.span_id.value, + ) promoted = RequestContext( - instrumentation_context=self.instrumentation_context, + instrumentation_context=instrumentation, request=request, + store_key=key, ) - ContextStore.set(promoted.instrumentation_context.trace_id.value, promoted) + ContextStore.set(key, promoted) return promoted @classmethod diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/exchange_context.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/exchange_context.py index 06b6629..5775b09 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/exchange_context.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/exchange_context.py @@ -42,6 +42,7 @@ class ExchangeContext(CallContext): instrumentation_context: InstrumentationContext request: Request response: Response | AsyncResponse + store_key: str | None = None __all__ = ["ExchangeContext"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/request_context.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/request_context.py index f3a3596..5deab11 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/request_context.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/context/request_context.py @@ -29,6 +29,7 @@ class RequestContext(CallContext): instrumentation_context: InstrumentationContext request: Request + store_key: str | None = None def to_exchange_context( self, @@ -47,8 +48,8 @@ def to_exchange_context( response: The response that arrived for this call. Returns: - The new `ExchangeContext`, also stored in `ContextStore` keyed by - trace id. + The new `ExchangeContext`, also stored in `ContextStore` under this + call's key so it overwrites the request tier in place. """ from .context_store import ContextStore from .exchange_context import ExchangeContext @@ -57,8 +58,10 @@ def to_exchange_context( instrumentation_context=self.instrumentation_context, request=response.request, response=response, + store_key=self.store_key, ) - ContextStore.set(promoted.instrumentation_context.trace_id.value, promoted) + if self.store_key is not None: + ContextStore.set(self.store_key, promoted) return promoted diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/_multipart_framing.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/_multipart_framing.py new file mode 100644 index 0000000..d4f02f0 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/_multipart_framing.py @@ -0,0 +1,219 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure framing routine for ``multipart/form-data`` payloads. + +This module holds the single source of truth for how a multipart body is laid +out on the wire: the ``--boundary`` delimiters, the per-part header block, the +UTF-8/Latin-1 encoding rules, and the closing delimiter. Both the wire-write +path (``MultipartRequestBody.iter_bytes``) and any logging-tap preview consume +these functions, so the bytes a caller logs and the bytes put on the wire can +never diverge. + +The functions are pure and stateless: they read a field's data attributes and +emit bytes, holding no mutable state of their own. Single-use enforcement for +non-replayable field payloads lives on the owning body, not here. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import TYPE_CHECKING + +from .request_body import RequestBody + +if TYPE_CHECKING: + from .multipart import MultipartField + +_CRLF = b"\r\n" + + +def escape_quoted(value: str) -> str: + """Backslash-escape ``\\`` and ``"`` for an RFC 2045 quoted-string. + + Args: + value: Raw parameter value to place inside double quotes. + + Returns: + The value with backslashes and double quotes escaped. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def part_header_block(part: MultipartField, boundary: str) -> bytes: + """Render the delimiter and header lines that precede a part's payload. + + Emits everything up to (and including) the blank line that separates the + part headers from the part body: the ``--boundary`` delimiter, the header + lines, then a terminating blank line. Header lines are Latin-1 encoded (the + HTTP/1.1 wire charset); ``MultipartField.__post_init__`` has already + rejected non-ASCII names/filenames and any CR/LF/NUL. + + Args: + part: The field whose header block is being rendered. + boundary: The multipart boundary token. + + Returns: + The part's header block as bytes, ending in a blank ``CRLF`` line. + """ + custom_disposition = any(name.lower() == "content-disposition" for name, _ in part.headers) + lines: list[bytes] = [f"--{boundary}".encode("latin-1")] + if not custom_disposition: + disposition = f'form-data; name="{escape_quoted(part.name)}"' + if part.filename is not None: + disposition += f'; filename="{escape_quoted(part.filename)}"' + lines.append(f"Content-Disposition: {disposition}".encode("latin-1")) + if part.media_type is not None: + lines.append(f"Content-Type: {part.media_type}".encode("latin-1")) + for header_name, header_value in part.headers: + lines.append(f"{header_name}: {header_value}".encode("latin-1")) + return _CRLF.join(lines) + _CRLF + _CRLF + + +def closing_delimiter(boundary: str) -> bytes: + """Return the terminating ``--boundary--`` delimiter line (with ``CRLF``).""" + return f"--{boundary}--".encode("latin-1") + _CRLF + + +def iter_value_bytes(value: bytes | str | RequestBody, chunk_size: int) -> Iterator[bytes]: + """Yield a field payload's bytes, chunked for the wire. + + ``str`` values are UTF-8 encoded; ``bytes`` are chunked in place; a + ``RequestBody`` value is streamed through its own ``iter_bytes`` (which + enforces single-use semantics for non-replayable bodies). An empty value + yields nothing, producing a correct zero-length part body. + + Args: + value: The field payload. + chunk_size: Suggested chunk size for bytes/str payloads and the hint + forwarded to a ``RequestBody`` payload. + + Yields: + Successive ``bytes`` chunks of the payload. + """ + if isinstance(value, RequestBody): + yield from value.iter_bytes(chunk_size) + return + raw = value.encode("utf-8") if isinstance(value, str) else value + view = memoryview(raw) + for start in range(0, len(view), chunk_size): + yield bytes(view[start : start + chunk_size]) + + +def iter_frames( + fields: Sequence[MultipartField], + boundary: str, + chunk_size: int, +) -> Iterator[bytes]: + """Yield the full ``multipart/form-data`` wire bytes for ``fields``. + + This is *the* framing routine. The wire-write path and the logging-tap + preview both drive it, so the two can never disagree on a single byte. + + Args: + fields: Ordered multipart fields. + boundary: The multipart boundary token. + chunk_size: Suggested chunk size forwarded to each field payload. + + Yields: + Successive ``bytes`` chunks of the complete framed payload. + """ + for part in fields: + yield part_header_block(part, boundary) + yield from iter_value_bytes(part.value, chunk_size) + yield _CRLF + yield closing_delimiter(boundary) + + +def value_length(value: bytes | str | RequestBody) -> int: + """Return the byte length of a field payload, or ``-1`` if unknown. + + Args: + value: The field payload. + + Returns: + The payload's byte count, or ``-1`` when a ``RequestBody`` payload + reports an unknown ``content_length``. + """ + if isinstance(value, RequestBody): + return value.content_length() + if isinstance(value, str): + return len(value.encode("utf-8")) + return len(value) + + +def content_length(fields: Sequence[MultipartField], boundary: str) -> int: + """Return the total framed byte length, or ``-1`` if any part is unknown. + + Computed from the same header-block and delimiter rendering that + ``iter_frames`` uses, so the advertised length matches the bytes produced + exactly whenever every field length is known. + + Args: + fields: Ordered multipart fields. + boundary: The multipart boundary token. + + Returns: + The total byte count, or ``-1`` when any field payload has an unknown + length. + """ + total = len(closing_delimiter(boundary)) + for part in fields: + total += len(part_header_block(part, boundary)) + vlen = value_length(part.value) + if vlen < 0: + return -1 + total += vlen + len(_CRLF) + return total + + +def render_frames( + fields: Sequence[MultipartField], + boundary: str, + *, + max_bytes: int | None = None, + chunk_size: int = 64 * 1024, +) -> bytes: + """Materialise the framed payload, optionally truncated to ``max_bytes``. + + This is the logging-tap-consumable preview: it produces exactly the bytes + ``iter_frames`` puts on the wire (they share the routine), capped at + ``max_bytes`` when given. Consuming this drains any non-replayable field + payloads, so callers should preview replayable bodies only. + + Args: + fields: Ordered multipart fields. + boundary: The multipart boundary token. + max_bytes: Optional cap; at most this many leading bytes are returned. + chunk_size: Suggested chunk size forwarded to each field payload. + + Returns: + The framed payload bytes, truncated to ``max_bytes`` when supplied. + + Raises: + ValueError: If ``max_bytes`` is negative. + """ + if max_bytes is not None and max_bytes < 0: + raise ValueError(f"max_bytes must be non-negative, got {max_bytes}") + out = bytearray() + for chunk in iter_frames(fields, boundary, chunk_size): + if max_bytes is None: + out += chunk + continue + remaining = max_bytes - len(out) + if remaining <= 0: + break + out += chunk[:remaining] + return bytes(out) + + +__all__ = [ + "closing_delimiter", + "content_length", + "escape_quoted", + "iter_frames", + "iter_value_bytes", + "part_header_block", + "render_frames", + "value_length", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/async_request_body.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/async_request_body.py index a9be257..6b8d3bb 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/async_request_body.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/async_request_body.py @@ -7,13 +7,26 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterable, AsyncIterator, Mapping -from typing import Protocol, runtime_checkable -from urllib.parse import quote +from typing import Final, Protocol, runtime_checkable +from .._once_guard import _OnceGuard from .._shielded import _shielded_cleanup from ..common import common_media_types from ..common.media_type import MediaType -from .request_body import _check_chunk_size +from .request_body import _check_chunk_size, _form_urlencode + +_STREAM_SINGLE_USE_MSG: Final[str] = ( + "AsyncRequestBody.aiter_bytes() was already called — this single-use body " + "backed by a stream is exhausted (consuming it also exhausts the caller's " + "underlying stream). Call to_replayable() BEFORE the first aiter_bytes() if " + "the body may need to be sent more than once (for example, on retry)." +) +_ITER_SINGLE_USE_MSG: Final[str] = ( + "AsyncRequestBody.aiter_bytes() was already called — this single-use body " + "backed by an async iterable is exhausted. Call to_replayable() BEFORE the " + "first aiter_bytes() if the body may need to be sent more than once (for " + "example, on retry)." +) @runtime_checkable @@ -105,10 +118,7 @@ def from_form( fields: Mapping[str, str], encoding: str = "utf-8", ) -> AsyncRequestBody: - encoded = "&".join( - f"{quote(k, safe='', encoding=encoding)}={quote(v, safe='', encoding=encoding)}" - for k, v in fields.items() - ) + encoded = _form_urlencode(fields, encoding) return _AsyncBytesBody( encoded.encode(encoding), common_media_types.APPLICATION_FORM_URLENCODED, @@ -154,17 +164,25 @@ def is_replayable(self) -> bool: async def to_replayable(self) -> AsyncRequestBody: return self - async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: _check_chunk_size(chunk_size) + return self._aiter(chunk_size) + + async def _aiter(self, chunk_size: int) -> AsyncIterator[bytes]: view = memoryview(self._data) for start in range(0, len(view), chunk_size): yield bytes(view[start : start + chunk_size]) class _AsyncIterBody(AsyncRequestBody): - """Single-use body backed by an ``AsyncIterable[bytes]``.""" + """Single-use body backed by an ``AsyncIterable[bytes]``. + + The single-use guarantee is enforced by a lock-protected once-guard + claimed eagerly at ``aiter_bytes`` call time, so a second call raises even + if the first returned iterator was never advanced. + """ - __slots__ = ("_chunks", "_consumed", "_length", "_media_type") + __slots__ = ("_chunks", "_length", "_media_type", "_once") def __init__( self, @@ -175,7 +193,7 @@ def __init__( self._chunks = chunks self._media_type = media_type self._length = length - self._consumed = False + self._once = _OnceGuard() def media_type(self) -> MediaType | None: return self._media_type @@ -183,15 +201,13 @@ def media_type(self) -> MediaType | None: def content_length(self) -> int: return self._length - async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: _check_chunk_size(chunk_size) - del chunk_size - if self._consumed: - raise RuntimeError( - "AsyncRequestBody.aiter_bytes was already called — call " - "to_replayable() before iter if you need retries." - ) - self._consumed = True + del chunk_size # the source iterable chooses its own chunk boundaries + self._once.claim(_ITER_SINGLE_USE_MSG) + return self._aiter() + + async def _aiter(self) -> AsyncIterator[bytes]: async for chunk in self._chunks: yield chunk @@ -199,16 +215,21 @@ async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes] class _AsyncStreamBody(AsyncRequestBody): """Single-use body backed by an async-read stream. + The single-use guarantee is enforced by a lock-protected once-guard + claimed eagerly at ``aiter_bytes`` call time, so a second call raises even + if the first returned iterator was never advanced. Consumption exhausts + the caller's underlying stream and closes it. + Note: - Cancellation contract. ``aiter_bytes`` releases the underlying stream - from a ``finally`` clause. If the producing task is cancelled - mid-iteration that clause runs with a ``CancelledError`` already in - flight, so the close is routed through ``_shielded_cleanup`` to run to - completion before the cancellation continues to propagate — cleanup - never swallows it. + Cancellation contract. The iterator returned by ``aiter_bytes`` + releases the underlying stream from a ``finally`` clause. If the + producing task is cancelled mid-iteration that clause runs with a + ``CancelledError`` already in flight, so the close is routed through + ``_shielded_cleanup`` to run to completion before the cancellation + continues to propagate — cleanup never swallows it. """ - __slots__ = ("_consumed", "_length", "_media_type", "_stream") + __slots__ = ("_length", "_media_type", "_once", "_stream") def __init__( self, @@ -219,7 +240,7 @@ def __init__( self._stream = stream self._media_type = media_type self._length = length - self._consumed = False + self._once = _OnceGuard() def media_type(self) -> MediaType | None: return self._media_type @@ -227,13 +248,12 @@ def media_type(self) -> MediaType | None: def content_length(self) -> int: return self._length - async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: _check_chunk_size(chunk_size) - if self._consumed: - raise RuntimeError( - "AsyncRequestBody.aiter_bytes was already called — the stream is exhausted." - ) - self._consumed = True + self._once.claim(_STREAM_SINGLE_USE_MSG) + return self._aiter(chunk_size) + + async def _aiter(self, chunk_size: int) -> AsyncIterator[bytes]: try: while True: chunk = await self._stream.read(chunk_size) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/file_request_body.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/file_request_body.py index 5baee55..e9792fe 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/file_request_body.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/file_request_body.py @@ -5,6 +5,7 @@ from __future__ import annotations +import stat as stat_module from collections.abc import Iterator from pathlib import Path from typing import Final @@ -19,22 +20,34 @@ class FileRequestBody(RequestBody): """Replayable body that streams from a file on disk. Each ``iter_bytes`` opens the file in binary mode, seeks to ``offset``, - and yields up to ``count`` bytes (or to EOF when ``count == -1``). - Because the file is re-opened every call, the body is safely replayable - under retries. + and yields exactly ``content_length()`` bytes — the count declared to the + transport as ``Content-Length``. Because the file is re-opened every call, + the body is safely replayable under retries. + + The declared byte count is fixed at construction (from the stat-ed size), + so if the file is truncated between construction and a read the transfer + would deliver fewer bytes than declared. That short write is detected and + raised naming transferred-of-total rather than completing silently with a + truncated body. Transports that recognise this body type can fast-path with ``os.sendfile(2)`` via ``socket.sendfile`` for zero-copy delivery; the default ``iter_bytes`` implementation uses regular reads so the optimisation is transparent — transports just need to ``isinstance``-check. + The file is stat-ed once at construction: existence, regular-file-ness, and + the ``offset`` / ``offset + count`` window are all validated against the size + captured then, so an invalid body fails fast rather than deferring the error + to the first read. ``content_length`` reports from that captured size, giving + the exact byte count the body will upload. + Attributes: path: File on disk to stream. offset: Byte offset from the start of the file. count: Number of bytes to read, or ``-1`` for read-to-EOF. """ - __slots__ = ("_count", "_media_type", "_offset", "_path") + __slots__ = ("_count", "_media_type", "_offset", "_path", "_size") def __init__( self, @@ -43,7 +56,7 @@ def __init__( offset: int = 0, count: int = -1, ) -> None: - """Initialise the body. + """Initialise the body, validating the file and window fail-fast. Args: path: File on disk to stream. @@ -52,17 +65,25 @@ def __init__( count: Number of bytes to read, or ``-1`` for read-to-EOF. Raises: - ValueError: If ``offset`` is negative or ``count`` is ``0`` - or less than ``-1``. + ValueError: If ``offset`` is negative; if ``count`` is ``0`` or less + than ``-1``; if ``path`` does not exist or is not a regular file; + or if ``offset`` (or ``offset + count``) exceeds the size the + file has at construction. """ if offset < 0: raise ValueError(f"offset must be non-negative, got {offset}") if count < -1 or count == 0: raise ValueError(f"count must be -1 (read to EOF) or positive, got {count}") + size = _validate_file(path) + if offset > size: + raise ValueError(f"offset {offset} is past the file size {size}: {path}") + if count != -1 and offset + count > size: + raise ValueError(f"offset+count {offset + count} exceeds the file size {size}: {path}") self._path = path self._media_type = media_type self._offset = offset self._count = count + self._size = size @property def path(self) -> Path: @@ -80,19 +101,11 @@ def media_type(self) -> MediaType | None: return self._media_type def content_length(self) -> int: - # Stat lazily — the file may grow between body construction and send; - # whatever stat returns at call time is the best estimate we have. - try: - size = self._path.stat().st_size - except OSError: - # No stat available: fall back to the requested count, or unknown. - return self._count if self._count != -1 else -1 - available = max(0, size - self._offset) + # Report from the size captured at construction — validation already + # guaranteed the whole window fits, so this is the exact upload size. if self._count == -1: - return available - # ``iter_bytes`` stops at EOF, so a count past EOF would over-report; - # advertise only what can actually be read. - return min(self._count, available) + return self._size - self._offset + return self._count def is_replayable(self) -> bool: return True @@ -105,20 +118,47 @@ def iter_bytes(self, chunk_size: int = _DEFAULT_CHUNK) -> Iterator[bytes]: return self._iter(chunk_size) def _iter(self, chunk_size: int) -> Iterator[bytes]: - remaining = self._count + # Bound the transfer to the declared content length (fixed at + # construction). Hitting EOF before delivering it means the file was + # truncated under us: raise naming transferred-of-total rather than + # emitting a truncated body. + total = self.content_length() + remaining = total with self._path.open("rb") as stream: if self._offset: stream.seek(self._offset) - while True: - if remaining == 0: - return - read = chunk_size if remaining == -1 else min(chunk_size, remaining) - chunk = stream.read(read) + while remaining > 0: + chunk = stream.read(min(chunk_size, remaining)) if not chunk: - return + raise OSError( + f"short read from {self._path}: transferred " + f"{total - remaining} of {total} bytes before end of " + f"file (was the file truncated after construction?)" + ) + remaining -= len(chunk) yield chunk - if remaining != -1: - remaining -= len(chunk) + + +def _validate_file(path: Path) -> int: + """Stat ``path`` fail-fast and return its size in bytes. + + Args: + path: File the body will stream from. + + Returns: + The regular file's size, in bytes, as observed at this call. + + Raises: + ValueError: If ``path`` does not exist / cannot be stat-ed, or names a + directory or other non-regular file. + """ + try: + info = path.stat() + except OSError as exc: + raise ValueError(f"file does not exist or is not accessible: {path}") from exc + if not stat_module.S_ISREG(info.st_mode): + raise ValueError(f"not a regular file: {path}") + return info.st_size __all__ = ["FileRequestBody"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/method.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/method.py index f799f31..6806a18 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/method.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/method.py @@ -6,6 +6,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Final class Method(StrEnum): @@ -13,7 +14,9 @@ class Method(StrEnum): `enum.StrEnum` (3.11+) gives string-valued members whose ``str()`` *is* the wire form, so callers can interpolate or compare against bare - strings: ``Method.GET == "GET"``. + strings: ``Method.GET == "GET"``. The member name and its value are always + identical, so the enum doubles as the canonical wire token — usable + verbatim in a request line without any translation step. """ GET = "GET" @@ -27,4 +30,17 @@ class Method(StrEnum): CONNECT = "CONNECT" +#: The idempotent HTTP methods (RFC 7231 §4.2.2): a repeated request has the +#: same effect on server state as a single one. This is the sole idempotency +#: classification the SDK recognises — there is no separate "safe method" set — +#: and it is the single source that retry logic derives from: the default retry +#: method allow-list and the inherent replay-safety gate both flow from it, so a +#: request is only ever re-sent on a transport error when its method is here. +#: Kept as an internal module constant rather than a public accessor on +#: `Method`, so the classification stays an implementation detail of retry. +_IDEMPOTENT_METHODS: Final[frozenset[Method]] = frozenset( + {Method.GET, Method.HEAD, Method.OPTIONS, Method.PUT, Method.DELETE} +) + + __all__ = ["Method"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/multipart.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/multipart.py index 4da5bea..ccd469f 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/multipart.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/multipart.py @@ -3,12 +3,21 @@ """Multipart/form-data ``RequestBody`` builder. -Generates a deterministic-boundary ``multipart/form-data`` payload from a -list of fields. Each field has a name, value (bytes or string), optional -filename, optional media type, and optional extra headers. - -The resulting body is replayable (the boundary and field bytes are -captured once at construction), so retries are safe. +Generates a random-boundary ``multipart/form-data`` payload from a list of +fields. Each field has a name, a payload (bytes, string, or a nested +``RequestBody``), an optional filename, an optional media type, and optional +extra headers. + +The wire layout is produced by the shared, pure framing routine in +``_multipart_framing`` — the same routine a logging tap uses to preview the +payload, so logged bytes and wire bytes can never diverge. + +Replayability propagates from the fields: the body is replayable if and only +if every field payload is replayable. A payload of ``bytes``/``str`` is always +replayable; a nested ``RequestBody`` payload defers to its own +``is_replayable``. A body with any single-use payload streams once and then +raises ``RuntimeError`` on a second ``iter_bytes`` — call ``to_replayable`` +first if retries may be needed. """ from __future__ import annotations @@ -20,6 +29,7 @@ from urllib.parse import quote from ..common.media_type import MediaType +from . import _multipart_framing as _framing from .request_body import RequestBody, _check_chunk_size @@ -68,7 +78,9 @@ class MultipartField: name: Form field name (mandatory). Must be pure ASCII so the generated ``Content-Disposition`` header is safe across the full range of HTTP/1.1 parsers. - value: Field content as bytes or string. Strings are UTF-8 encoded. + value: Field payload. ``bytes`` and ``str`` (UTF-8 encoded) are + always replayable; a nested ``RequestBody`` streams its own bytes + and contributes its own replayability (see ``is_replayable``). filename: Optional filename for file parts; included in ``Content-Disposition``. Must be pure ASCII unless the caller has supplied a matching ``filename*`` parameter (e.g. via @@ -84,7 +96,7 @@ class MultipartField: """ name: str - value: bytes | str + value: bytes | str | RequestBody filename: str | None = None media_type: MediaType | None = None headers: Sequence[tuple[str, str]] = field(default_factory=tuple) @@ -114,12 +126,23 @@ def __post_init__(self) -> None: _reject_control_chars("header name", header_name) _reject_control_chars("header value", header_value) + def is_replayable(self) -> bool: + """Return whether this field's payload can be re-read on retry. + + Returns: + ``True`` for ``bytes``/``str`` payloads; for a nested + ``RequestBody`` payload, whatever that body reports. + """ + if isinstance(self.value, RequestBody): + return self.value.is_replayable() + return True + @classmethod def with_utf8_filename( cls, *, name: str, - value: bytes | str, + value: bytes | str | RequestBody, filename: str, media_type: MediaType | None = None, headers: Sequence[tuple[str, str]] = (), @@ -134,7 +157,7 @@ def with_utf8_filename( Args: name: Form field name (must be ASCII). - value: Field content as bytes or string. + value: Field payload (bytes, string, or nested ``RequestBody``). filename: The intended filename, possibly containing non-ASCII characters. media_type: Optional content type for the part. @@ -146,7 +169,7 @@ def with_utf8_filename( Returns: A ``MultipartField`` with the synthesised disposition stored as an extra header. The dataclass ``filename`` attribute is - set to ``None`` so ``_build_part`` does not emit a second + set to ``None`` so the framing routine does not emit a second disposition line. Raises: @@ -155,8 +178,8 @@ def with_utf8_filename( legacy = filename if _is_ascii(filename) else ascii_fallback encoded = quote(filename, safe="", encoding="utf-8") disposition = ( - f'form-data; name="{_escape_quoted(name)}"; ' - f'filename="{_escape_quoted(legacy)}"; ' + f'form-data; name="{_framing.escape_quoted(name)}"; ' + f'filename="{_framing.escape_quoted(legacy)}"; ' f"filename*=UTF-8''{encoded}" ) new_headers: tuple[tuple[str, str], ...] = ( @@ -177,58 +200,27 @@ def _generate_boundary() -> str: return "----dexpace-" + secrets.token_hex(16) -def _build_part(part: MultipartField, boundary: str) -> bytes: - """Render one part as bytes (terminating CRLF included). - - Header lines are encoded as Latin-1 (the HTTP/1.1 wire-form charset). - ``MultipartField.__post_init__`` rejects names/filenames that are not - pure ASCII (unless the caller supplied a matching ``filename*=UTF-8''…`` - parameter via ``headers`` or built the field through - ``with_utf8_filename``). - - If any caller-supplied header already begins with ``Content-Disposition`` - (case-insensitive), the auto-generated disposition is suppressed so the - custom one (typically carrying ``filename*=UTF-8''…``) is the only one - emitted. - """ - custom_disposition = any(name.lower() == "content-disposition" for name, _ in part.headers) - lines: list[bytes] = [f"--{boundary}".encode("latin-1")] - if not custom_disposition: - disposition = f'form-data; name="{_escape_quoted(part.name)}"' - if part.filename is not None: - disposition += f'; filename="{_escape_quoted(part.filename)}"' - lines.append(f"Content-Disposition: {disposition}".encode("latin-1")) - if part.media_type is not None: - lines.append(f"Content-Type: {part.media_type}".encode("latin-1")) - for header_name, header_value in part.headers: - lines.append(f"{header_name}: {header_value}".encode("latin-1")) - lines.append(b"") - if isinstance(part.value, str): - lines.append(part.value.encode("utf-8")) - else: - lines.append(part.value) - return b"\r\n".join(lines) + b"\r\n" - - -def _escape_quoted(value: str) -> str: - return value.replace("\\", "\\\\").replace('"', '\\"') - - class MultipartRequestBody(RequestBody): - """Replayable ``multipart/form-data`` body. + """``multipart/form-data`` body assembled from a sequence of fields. Build via ``RequestBody.from_multipart(fields)`` or instantiate directly. - The boundary is generated once at construction so retries see identical - bytes (and so loggable wrappers can capture the payload deterministically). - A caller-supplied ``boundary`` is rejected if it contains CR, LF, or NUL, - since it is interpolated into delimiter and header lines. + The boundary is generated once at construction (via ``secrets``) so retries + and loggable wrappers see identical bytes. A caller-supplied ``boundary`` is + rejected if it contains CR, LF, or NUL, since it is interpolated into + delimiter and header lines. + + The payload is streamed lazily through the shared framing routine rather + than buffered up front, so a single-use field payload stays single-use. + ``is_replayable`` is ``True`` only when every field payload is replayable; + otherwise ``iter_bytes`` may be called once and a second call raises + ``RuntimeError`` (call ``to_replayable`` first to buffer for retries). Raises: ValueError: If ``fields`` is empty, or if ``boundary`` contains CR, LF, or NUL. """ - __slots__ = ("_boundary", "_payload") + __slots__ = ("_boundary", "_consumed", "_fields", "_replayable") def __init__( self, @@ -243,34 +235,43 @@ def __init__( # and the ``Content-Type`` header, so a caller-supplied boundary with # CR/LF/NUL would inject delimiter or header lines into the payload. _reject_control_chars("boundary", self._boundary) - parts: list[bytes] = [_build_part(f, self._boundary) for f in fields] - parts.append(f"--{self._boundary}--\r\n".encode("ascii")) - self._payload = b"".join(parts) + self._fields: tuple[MultipartField, ...] = tuple(fields) + self._replayable = all(f.is_replayable() for f in self._fields) + self._consumed = False @property def boundary(self) -> str: return self._boundary + @property + def fields(self) -> tuple[MultipartField, ...]: + return self._fields + def media_type(self) -> MediaType | None: return MediaType.of("multipart", "form-data", {"boundary": self._boundary}) def content_length(self) -> int: - return len(self._payload) + return _framing.content_length(self._fields, self._boundary) def is_replayable(self) -> bool: - return True + return self._replayable def to_replayable(self) -> RequestBody: - return self + if self._replayable: + return self + return super().to_replayable() def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: _check_chunk_size(chunk_size) - return self._iter(chunk_size) - - def _iter(self, chunk_size: int) -> Iterator[bytes]: - view = memoryview(self._payload) - for start in range(0, len(view), chunk_size): - yield bytes(view[start : start + chunk_size]) + if not self._replayable: + if self._consumed: + raise RuntimeError( + "MultipartRequestBody.iter_bytes was already called — one or " + "more field payloads are single-use. Call to_replayable() " + "BEFORE iter_bytes if retries may be needed." + ) + self._consumed = True + return _framing.iter_frames(self._fields, self._boundary, chunk_size) __all__ = ["MultipartField", "MultipartRequestBody"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request.py index 58d392b..1c628c6 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Final, Self from ..common.headers import Headers from ..common.http_header_name import HttpHeaderName @@ -18,6 +18,17 @@ type _Name = str | HttpHeaderName +#: Methods whose semantics forbid a request payload body. Constructing a request +#: for one of these with a body is rejected at construction (never deferred to +#: the transport); ``with_method`` drops an existing body when switching to one +#: of them so fluent chains stay valid. ``GET`` / ``HEAD`` carry no body by +#: definition; ``TRACE`` must not be sent with an entity body (RFC 7231 §4.3.8) +#: and ``CONNECT``'s body has tunnel-specific meaning, so both are body-less +#: here — a payload on either is a construction error, not a wire-time surprise. +_BODYLESS_METHODS: Final[frozenset[Method]] = frozenset( + {Method.GET, Method.HEAD, Method.TRACE, Method.CONNECT} +) + @dataclass(frozen=True, slots=True) class Request: @@ -25,18 +36,81 @@ class Request: Construct directly via the dataclass constructor or derive a new instance non-destructively via `dataclasses.replace` or the ``with_*`` helpers. + Every construction path (including ``replace``) re-runs ``__post_init__``, + so the validated invariants below always hold. Header / metadata surface is immutable and safe to share across threads; the ``body``, when present, carries single-use stream state — clone before sharing if you retain a stream-backed body. See `RequestBody`. + + Attributes: + method: The HTTP method. + url: The absolute request target. + headers: The request headers (an immutable, case-insensitive value). + body: The optional request payload. Forbidden for the body-less + methods (``GET`` / ``HEAD`` / ``TRACE`` / ``CONNECT``). + timeout: Optional per-call timeout in seconds. Must be positive. + retries: Optional per-call retry budget. ``0`` disables retries for + this call; a negative value is rejected. """ method: Method url: Url headers: Headers = field(default_factory=Headers) body: RequestBody | None = None + timeout: float | None = None + retries: int | None = None + + def __post_init__(self) -> None: + """Validate required fields, the body/method pairing, and per-call bounds. + + Raises: + ValueError: If ``method`` or ``url`` is missing, if a body is + supplied for a method that forbids one (``GET`` / ``HEAD`` / + ``TRACE`` / ``CONNECT``), if ``timeout`` is not positive, or if + ``retries`` is negative. The message names the offending field. + """ + if self.method is None: + raise ValueError("method is required") + if self.url is None: + raise ValueError("url is required") + if self.body is not None and self.method in _BODYLESS_METHODS: + raise ValueError(f"body is not permitted on a {self.method} request") + if self.timeout is not None and self.timeout <= 0: + raise ValueError("timeout must be positive") + if self.retries is not None and self.retries < 0: + raise ValueError("retries must not be negative") + + def __repr__(self) -> str: + """Return a repr with the URL redacted via the shared ``UrlRedactor``. + + The default dataclass repr would render the ``Url`` in full, leaking any + userinfo or query-string secrets into logs and tracebacks. Routing the + URL through the one shared redactor — and rendering ``headers`` through + `Headers` (which masks credential-bearing header values) — keeps that + surface consistent with the logging and tracing emitters and ensures no + credential is ever echoed. + """ + from ...instrumentation.url_redactor import UrlRedactor + + url = UrlRedactor().redact(self.url) + return ( + f"Request(method={self.method!r}, url={url!r}, " + f"headers={self.headers!r}, body={self.body!r}, " + f"timeout={self.timeout!r}, retries={self.retries!r})" + ) def with_method(self, method: Method) -> Self: + """Return a copy with ``method`` replaced. + + Switching to a body-less method (``GET`` / ``HEAD`` / ``TRACE`` / + ``CONNECT``) drops any existing body, because that combination is + invalid; every other switch leaves the body untouched. Dropping here + keeps fluent chains such as a redirect's + ``with_method(GET).with_body(None)`` valid on the intermediate hop. + """ + if method in _BODYLESS_METHODS and self.body is not None: + return replace(self, method=method, body=None) return replace(self, method=method) def with_url(self, url: str | Url) -> Self: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request_body.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request_body.py index 4c6e7b8..13e4982 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request_body.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/request/request_body.py @@ -8,15 +8,29 @@ from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, BinaryIO +from typing import TYPE_CHECKING, BinaryIO, Final from urllib.parse import quote +from .._once_guard import _OnceGuard from ..common import common_media_types from ..common.media_type import MediaType if TYPE_CHECKING: from .multipart import MultipartField +_STREAM_SINGLE_USE_MSG: Final[str] = ( + "RequestBody.iter_bytes() was already called — this single-use body backed " + "by a stream is exhausted (consuming it also exhausts the caller's " + "underlying stream). Call to_replayable() BEFORE the first iter_bytes() if " + "the body may need to be sent more than once (for example, on retry)." +) +_ITER_SINGLE_USE_MSG: Final[str] = ( + "RequestBody.iter_bytes() was already called — this single-use body backed " + "by an iterable is exhausted. Call to_replayable() BEFORE the first " + "iter_bytes() if the body may need to be sent more than once (for example, " + "on retry)." +) + def _check_chunk_size(chunk_size: int) -> None: """Reject non-positive ``chunk_size`` values. @@ -31,6 +45,34 @@ def _check_chunk_size(chunk_size: int) -> None: raise ValueError(f"chunk_size must be positive, got {chunk_size}") +def _form_urlencode(fields: Mapping[str, str], encoding: str) -> str: + """Encode form fields as ``application/x-www-form-urlencoded`` (strict). + + Every key and value is percent-encoded with an empty ``safe`` set, so + spaces become ``%20`` (never ``+``) and every reserved character — + ``&``, ``=``, ``+``, ``/``, ``?``, ``#``, ``@``, ``:`` and friends — is + escaped. This deliberately avoids ``urllib.parse.urlencode`` / + ``quote_plus``, whose ``+``-for-space convention is ambiguous with a + literal ``+`` and round-trips inconsistently across decoders. + + Note: + Minimal in-module strict codec. If a shared query-codec helper lands + (the ``QueryParams`` serialiser already uses ``quote_via=quote``), + reconcile this against it rather than keeping two encoders. + + Args: + fields: Form fields keyed by name. + encoding: Text encoding applied before percent-encoding. + + Returns: + The encoded body as a ``str`` (no leading ``?``). + """ + return "&".join( + f"{quote(k, safe='', encoding=encoding)}={quote(v, safe='', encoding=encoding)}" + for k, v in fields.items() + ) + + class RequestBody(ABC): """Payload of an HTTP request. @@ -174,10 +216,7 @@ def from_form( Returns: A replayable form body carrying the standard media type. """ - encoded = "&".join( - f"{quote(k, safe='', encoding=encoding)}={quote(v, safe='', encoding=encoding)}" - for k, v in fields.items() - ) + encoded = _form_urlencode(fields, encoding) return _BytesBody( encoded.encode(encoding), common_media_types.APPLICATION_FORM_URLENCODED, @@ -192,12 +231,14 @@ def from_stream( ) -> RequestBody: """Build a single-use body backed by a ``BinaryIO`` stream. - ``iter_bytes`` consumes and closes ``stream`` on first call; a second - call raises ``RuntimeError``. Call ``to_replayable`` BEFORE - ``iter_bytes`` to obtain a buffered copy for retries. + ``iter_bytes`` consumes and closes ``stream`` on first call, exhausting + the caller's underlying stream in the process; a second call raises + ``RuntimeError``. Call ``to_replayable`` BEFORE ``iter_bytes`` to obtain + a buffered copy for retries. Args: - stream: Source stream. Ownership transfers to the body. + stream: Source stream. Ownership transfers to the body; iterating + the body drains and closes it. media_type: Optional content type. content_length: Byte count if known, else ``-1``. @@ -250,8 +291,10 @@ def from_file( A replayable ``RequestBody`` backed by the given file. Raises: - ValueError: If ``offset`` is negative or ``count`` is ``0`` - or less than ``-1``. + ValueError: If ``offset`` is negative; if ``count`` is ``0`` or less + than ``-1``; if ``path`` does not exist or is not a regular file; + or if ``offset`` (or ``offset + count``) exceeds the file's size + captured at construction. """ from .file_request_body import FileRequestBody @@ -319,9 +362,15 @@ def _iter(self, chunk_size: int) -> Iterator[bytes]: class _StreamBody(RequestBody): - """Single-use body backed by a ``BinaryIO``.""" + """Single-use body backed by a ``BinaryIO``. - __slots__ = ("_consumed", "_length", "_media_type", "_stream") + Consumption exhausts the caller's underlying stream and closes it. The + single-use guarantee is enforced by a lock-protected once-guard claimed + eagerly at ``iter_bytes`` call time, so a second call raises even if the + first returned iterator was never advanced. + """ + + __slots__ = ("_length", "_media_type", "_once", "_stream") def __init__( self, @@ -332,7 +381,7 @@ def __init__( self._stream = stream self._media_type = media_type self._length = length - self._consumed = False + self._once = _OnceGuard() def media_type(self) -> MediaType | None: return self._media_type @@ -342,13 +391,7 @@ def content_length(self) -> int: def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: _check_chunk_size(chunk_size) - if self._consumed: - raise RuntimeError( - "RequestBody.iter_bytes was already called — the underlying " - "stream is exhausted. Call to_replayable() BEFORE iter_bytes " - "if retries may be needed." - ) - self._consumed = True + self._once.claim(_STREAM_SINGLE_USE_MSG) return self._iter(chunk_size) def _iter(self, chunk_size: int) -> Iterator[bytes]: @@ -363,9 +406,14 @@ def _iter(self, chunk_size: int) -> Iterator[bytes]: class _IterBody(RequestBody): - """Single-use body backed by an iterable of ``bytes`` chunks.""" + """Single-use body backed by an iterable of ``bytes`` chunks. + + The single-use guarantee is enforced by a lock-protected once-guard + claimed eagerly at ``iter_bytes`` call time, so a second call raises even + if the first returned iterator was never advanced. + """ - __slots__ = ("_chunks", "_consumed", "_length", "_media_type") + __slots__ = ("_chunks", "_length", "_media_type", "_once") def __init__( self, @@ -376,7 +424,7 @@ def __init__( self._chunks = chunks self._media_type = media_type self._length = length - self._consumed = False + self._once = _OnceGuard() def media_type(self) -> MediaType | None: return self._media_type @@ -387,13 +435,7 @@ def content_length(self) -> int: def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: _check_chunk_size(chunk_size) del chunk_size # caller-supplied chunk size is ignored; iterator chooses its own - if self._consumed: - raise RuntimeError( - "RequestBody.iter_bytes was already called — the underlying " - "iterable is exhausted. Call to_replayable() BEFORE iter_bytes " - "if retries may be needed." - ) - self._consumed = True + self._once.claim(_ITER_SINGLE_USE_MSG) return self._iter() def _iter(self) -> Iterator[bytes]: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/__init__.py index 59b5dd2..1080746 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations +from .async_loggable_response_body import AsyncLoggableResponseBody from .async_response import AsyncResponse from .async_response_body import AsyncResponseBody from .loggable_response_body import LoggableResponseBody @@ -13,6 +14,7 @@ from .status import Status __all__ = [ + "AsyncLoggableResponseBody", "AsyncResponse", "AsyncResponseBody", "LoggableResponseBody", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_loggable_response_body.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_loggable_response_body.py new file mode 100644 index 0000000..d2f31e9 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_loggable_response_body.py @@ -0,0 +1,248 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Async twin of ``LoggableResponseBody`` with a two-regime capture.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator, Iterator +from typing import Final + +from .._shielded import _shielded_cleanup +from ..common.media_type import MediaType +from .async_response_body import AsyncResponseBody +from .response_body import _check_chunk_size + +_DEFAULT_CAP: Final[int] = (1 << 31) - 9 # CPython's effective ``bytes`` ceiling + + +class AsyncLoggableResponseBody(AsyncResponseBody): + """Async twin of ``LoggableResponseBody``. + + Mirrors the sync capture contract over ``aiter_bytes``: the delegate is + drained at most once, lazily, on first access (a read, a ``snapshot``, or an + ``error`` query). Concurrent first accesses are serialised by an + ``asyncio.Lock`` so the single-use delegate is consumed exactly once. + + The drain has the same two regimes as the sync body: + + Fits-cap (end-of-stream reached at or before ``max_capture_bytes``): + the ENTIRE body is captured, the delegate is closed during the drain, + and every subsequent read replays a fresh, non-consuming view of the + cache — reads are fully repeatable. + + Exceeds-cap (more bytes remain once the cap is hit): + only the prefix up to the cap is buffered and the delegate is left + OPEN. The next read is served as a single-use stream that replays the + buffered prefix and then the live tail; a second read raises + ``RuntimeError``. Only the prefix is ever cached, so ``snapshot`` + returns the prefix. + + Drain failure never silently truncates: whatever prefix was read is + retained and the originating exception is cached. Subsequent reads re-raise + the cached error, ``snapshot`` returns the partial bytes WITHOUT raising, + and ``error`` surfaces the cached exception without a second drain attempt. + ``CancelledError`` from the drain is not cached — cancellation is transient + and must propagate to the awaiting task rather than poisoning later reads. + + An empty chunk yielded mid-stream is never treated as end-of-stream — only + the iterator's own exhaustion ends the drain — so a zero-length chunk can + never truncate the capture. + """ + + __slots__ = ( + "_cached", + "_closed", + "_drained", + "_error", + "_fully_captured", + "_inner", + "_inner_closed", + "_lock", + "_max", + "_overflow", + "_tail_consumed", + "_tail_it", + ) + + def __init__( + self, + inner: AsyncResponseBody, + max_capture_bytes: int = _DEFAULT_CAP, + ) -> None: + """Initialise the decorator. + + Args: + inner: The body to capture. + max_capture_bytes: Cap on the in-memory capture. A body of this + size or smaller is fully captured (repeatable); a larger body + keeps only this prefix and streams the tail once. + + Raises: + ValueError: If ``max_capture_bytes`` is non-positive. + """ + if max_capture_bytes <= 0: + raise ValueError(f"max_capture_bytes must be positive, got {max_capture_bytes}") + self._inner = inner + self._max = max_capture_bytes + self._cached: bytes = b"" + self._overflow: bytes = b"" + self._tail_it: AsyncIterator[bytes] | None = None + self._error: BaseException | None = None + self._drained = False + self._fully_captured = False + self._tail_consumed = False + self._inner_closed = False + self._closed = False + self._lock = asyncio.Lock() + + def media_type(self) -> MediaType | None: + return self._inner.media_type() + + def content_length(self) -> int: + """Return the captured size once fully captured, else the delegate's. + + Never triggers a drain: reports ``-1`` (unknown) until the body has + been fully captured. + """ + if self._fully_captured: + return len(self._cached) + return self._inner.content_length() + + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + _check_chunk_size(chunk_size) + return self._read(chunk_size) + + async def _read(self, chunk_size: int) -> AsyncIterator[bytes]: + await self._drain() + if self._error is not None: + raise self._error + if self._fully_captured: + for chunk in self._replay_cache(chunk_size): + yield chunk + return + if self._tail_consumed: + raise RuntimeError( + "AsyncLoggableResponseBody exceeded its capture cap and is single-use " + "past the cap; the streamed tail has already been consumed" + ) + self._tail_consumed = True + async for chunk in self._replay_tail(chunk_size): + yield chunk + + async def snapshot(self, max_bytes: int | None = None) -> bytes: + """Return an immutable copy of the captured bytes (draining if needed). + + On a mid-drain failure the partial bytes read before the error are + returned for post-mortem logging; the cached exception is not raised + here (it surfaces from ``aiter_bytes`` and ``error``). Only the buffered + prefix is returned when the body exceeded the cap. + + Args: + max_bytes: If given, copy at most this many bytes from the front of + the capture. A ``memoryview`` bounds the slice so no more than + ``max_bytes`` are ever materialised. ``None`` returns the full + capture. + + Returns: + The captured bytes, optionally truncated to ``max_bytes``. + + Raises: + ValueError: If ``max_bytes`` is negative. + """ + await self._drain() + if max_bytes is None: + return self._cached + if max_bytes < 0: + raise ValueError(f"max_bytes must be non-negative, got {max_bytes}") + return bytes(memoryview(self._cached)[:max_bytes]) + + async def error(self) -> BaseException | None: + """Return the drain failure, if any (draining once on first access).""" + await self._drain() + return self._error + + @property + def captured_size(self) -> int: + """Bytes currently in the capture. Never exceeds ``max_capture_bytes``.""" + return len(self._cached) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + await self._close_inner() + + # ----- internals ------------------------------------------------------ + + def _replay_cache(self, chunk_size: int) -> Iterator[bytes]: + """Yield a fresh, non-consuming view of the captured prefix.""" + view = memoryview(self._cached) + for start in range(0, len(view), chunk_size): + yield bytes(view[start : start + chunk_size]) + + async def _replay_tail(self, chunk_size: int) -> AsyncIterator[bytes]: + """Yield the buffered prefix, then the overflow, then the live tail.""" + for chunk in self._replay_cache(chunk_size): + yield chunk + if self._overflow: + yield self._overflow + if self._tail_it is not None: + async for chunk in self._tail_it: + yield chunk + + async def _close_inner(self) -> None: + """Close the delegate once, swallowing any ordinary close failure.""" + if self._inner_closed: + return + self._inner_closed = True + with contextlib.suppress(Exception): + await _shielded_cleanup(self._inner.close()) + + async def _drain(self) -> None: + """Run the one-time drain, guarded by a double-checked async lock.""" + if self._drained: + return + async with self._lock: + if self._drained: + return + try: + await self._run_drain() + finally: + self._drained = True + + async def _run_drain(self) -> None: + """Capture up to the cap, deciding the fits-cap vs exceeds-cap regime.""" + it = self._inner.aiter_bytes() + chunks: list[bytes] = [] + captured = 0 + try: + async for chunk in it: + remaining = self._max - captured + if len(chunk) <= remaining: + chunks.append(chunk) + captured += len(chunk) + continue + # This chunk crosses the cap: keep the prefix, stash the tail, + # and leave the delegate open for a single-use replay. + chunks.append(chunk[:remaining]) + self._cached = b"".join(chunks) + self._overflow = chunk[remaining:] + self._tail_it = it + return + self._cached = b"".join(chunks) + self._fully_captured = True + await self._close_inner() + except asyncio.CancelledError: + # Cancellation is transient: keep what was read for a post-mortem + # snapshot but do not cache it as a terminal error, then propagate. + self._cached = b"".join(chunks) + raise + except Exception as exc: + self._error = exc + self._cached = b"".join(chunks) + + +__all__ = ["AsyncLoggableResponseBody"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_response_body.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_response_body.py index a850949..2daedfb 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_response_body.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/async_response_body.py @@ -8,8 +8,9 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterator from types import TracebackType -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Final, Self +from .._once_guard import _OnceGuard from .._shielded import _shielded_cleanup as _shielded_cleanup from ..common.media_type import MediaType from .response_body import _check_chunk_size @@ -19,6 +20,8 @@ _bytes = bytes +_CONSUMED_MSG: Final[str] = "AsyncResponseBody has already been consumed" + class AsyncResponseBody(ABC): """Async twin of ``ResponseBody``. @@ -92,14 +95,19 @@ def from_async_stream( class _AsyncBytesResponseBody(AsyncResponseBody): - """In-memory ``AsyncResponseBody``.""" + """In-memory ``AsyncResponseBody``. + + Single-use: the lock-protected once-guard is claimed eagerly at + ``aiter_bytes`` call time, so a second call raises ``RuntimeError`` even + when the first returned iterator was never advanced. + """ - __slots__ = ("_closed", "_consumed", "_data", "_media_type") + __slots__ = ("_closed", "_data", "_media_type", "_once") def __init__(self, data: _bytes, media_type: MediaType | None) -> None: self._data = data self._media_type = media_type - self._consumed = False + self._once = _OnceGuard() self._closed = False def media_type(self) -> MediaType | None: @@ -108,11 +116,12 @@ def media_type(self) -> MediaType | None: def content_length(self) -> int: return len(self._data) - async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: _check_chunk_size(chunk_size) - if self._consumed: - raise RuntimeError("AsyncResponseBody has already been consumed") - self._consumed = True + self._once.claim(_CONSUMED_MSG) + return self._aiter(chunk_size) + + async def _aiter(self, chunk_size: int) -> AsyncIterator[bytes]: view = memoryview(self._data) for start in range(0, len(view), chunk_size): yield bytes(view[start : start + chunk_size]) @@ -138,7 +147,7 @@ class _AsyncStreamResponseBody(AsyncResponseBody): cleanup never swallows it. """ - __slots__ = ("_closed", "_consumed", "_length", "_media_type", "_stream") + __slots__ = ("_closed", "_length", "_media_type", "_once", "_stream") def __init__( self, @@ -149,7 +158,7 @@ def __init__( self._stream = stream self._media_type = media_type self._length = length - self._consumed = False + self._once = _OnceGuard() self._closed = False def media_type(self) -> MediaType | None: @@ -158,11 +167,12 @@ def media_type(self) -> MediaType | None: def content_length(self) -> int: return self._length - async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: _check_chunk_size(chunk_size) - if self._consumed: - raise RuntimeError("AsyncResponseBody has already been consumed") - self._consumed = True + self._once.claim(_CONSUMED_MSG) + return self._aiter(chunk_size) + + async def _aiter(self, chunk_size: int) -> AsyncIterator[bytes]: try: while True: chunk = await self._stream.read(chunk_size) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/loggable_response_body.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/loggable_response_body.py index 0b59c7d..2b8efcc 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/loggable_response_body.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/loggable_response_body.py @@ -1,10 +1,11 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""``ResponseBody`` decorator that caches bytes for repeatable reads + logging.""" +"""``ResponseBody`` decorator with a two-regime capture for repeatable reads.""" from __future__ import annotations +import contextlib import threading from collections.abc import Iterator from typing import Final @@ -12,34 +13,58 @@ from ..common.media_type import MediaType from .response_body import ResponseBody, _check_chunk_size -_DEFAULT_CAP: Final[int] = (1 << 31) - 9 +_DEFAULT_CAP: Final[int] = (1 << 31) - 9 # CPython's effective ``bytes`` ceiling class LoggableResponseBody(ResponseBody): - """Wrap a ``ResponseBody`` and cache its bytes for repeatable reads. - - The first call to ``iter_bytes`` / ``bytes`` / ``string`` / ``snapshot`` - drains the underlying body into an in-memory ``bytes`` buffer (capped at - ``max_capture_bytes``). Subsequent calls replay the cached buffer, so the - response payload can be read repeatedly — useful for retries that need - to log the body and then re-emit it, or for diagnostic dumps. - - Cap semantics: bytes beyond ``max_capture_bytes`` are dropped from the - cache, but the underlying body is still fully drained and closed. - - Mid-drain failure: if the underlying body raises part-way through the - one-time drain, the bytes read so far are retained in the cache and the - originating exception is stored. ``iter_bytes`` re-raises that exception - on every call so callers cannot mistake a truncated read for success, - while ``snapshot`` still returns the partial bytes for post-mortem - logging. - - Thread-safe first read: the one-time drain is guarded by a lock plus a - double-checked flag so concurrent first readers consume the underlying - single-use stream exactly once. + """Wrap a ``ResponseBody`` and capture its bytes for diagnostics. + + The delegate is drained at most once, lazily, on first access — a read + (``iter_bytes`` / ``bytes`` / ``string``), a ``snapshot``, or an ``error`` + query. Concurrent first accesses are serialised by an explicit ``Lock`` + (no GIL assumption) so the single-use delegate is consumed exactly once. + + The drain has two regimes, decided by whether the body fits the cap: + + Fits-cap (end-of-stream reached at or before ``max_capture_bytes``): + the ENTIRE body is captured, the delegate is closed during the drain, + and every subsequent read replays a fresh, non-consuming view of the + cache — reads are fully repeatable. + + Exceeds-cap (more bytes remain once the cap is hit): + only the prefix up to the cap is buffered and the delegate is left + OPEN. The next read is served as a single-use stream that replays the + buffered prefix and then the live tail; a second read raises + ``RuntimeError`` because the tail is genuinely single-use once past the + cap. Only the prefix is ever cached, so ``snapshot`` returns the prefix. + + Drain failure never silently truncates: whatever prefix was read is + retained and the originating exception is cached. Subsequent reads re-raise + the cached error, ``snapshot`` returns the partial bytes WITHOUT raising, + and ``error`` surfaces the cached exception without a second drain attempt. + ``KeyboardInterrupt`` / ``SystemExit`` propagate from the drain immediately + (they are cached too, so later reads still refuse to replay a truncated + read as success). + + An empty chunk yielded mid-stream is never treated as end-of-stream — only + the iterator's own exhaustion ends the drain — so a zero-length chunk can + never truncate the capture. """ - __slots__ = ("_cached", "_closed", "_drained", "_error", "_inner", "_lock", "_max") + __slots__ = ( + "_cached", + "_closed", + "_drained", + "_error", + "_fully_captured", + "_inner", + "_inner_closed", + "_lock", + "_max", + "_overflow", + "_tail_consumed", + "_tail_it", + ) def __init__( self, @@ -49,8 +74,10 @@ def __init__( """Initialise the decorator. Args: - inner: The body to cache. - max_capture_bytes: Soft cap on the in-memory cache. + inner: The body to capture. + max_capture_bytes: Cap on the in-memory capture. A body of this + size or smaller is fully captured (repeatable); a larger body + keeps only this prefix and streams the tail once. Raises: ValueError: If ``max_capture_bytes`` is non-positive. @@ -60,8 +87,13 @@ def __init__( self._inner = inner self._max = max_capture_bytes self._cached: bytes = b"" + self._overflow: bytes = b"" + self._tail_it: Iterator[bytes] | None = None self._error: BaseException | None = None self._drained = False + self._fully_captured = False + self._tail_consumed = False + self._inner_closed = False self._closed = False self._lock = threading.Lock() @@ -69,6 +101,15 @@ def media_type(self) -> MediaType | None: return self._inner.media_type() def content_length(self) -> int: + """Return the reported content length. + + Returns: + The captured size once the whole body has been captured, otherwise + the delegate's originally-declared length (``-1`` when unknown). + Never triggers a drain. + """ + if self._fully_captured: + return len(self._cached) return self._inner.content_length() def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: @@ -76,28 +117,29 @@ def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: self._drain() if self._error is not None: raise self._error - view = memoryview(self._cached) - for start in range(0, len(view), chunk_size): - yield bytes(view[start : start + chunk_size]) - - def close(self) -> None: - if self._closed: - return - self._closed = True - self._inner.close() + if self._fully_captured: + return self._replay_cache(chunk_size) + if self._tail_consumed: + raise RuntimeError( + "LoggableResponseBody exceeded its capture cap and is single-use " + "past the cap; the streamed tail has already been consumed" + ) + self._tail_consumed = True + return self._replay_tail(chunk_size) def snapshot(self, max_bytes: int | None = None) -> bytes: """Return an immutable copy of the captured bytes (draining if needed). On a mid-drain failure the partial bytes read before the error are - returned for post-mortem logging; the stored exception is not raised - here (it surfaces from ``iter_bytes``). + returned for post-mortem logging; the cached exception is not raised + here (it surfaces from ``iter_bytes`` and ``error``). Only the buffered + prefix is returned when the body exceeded the cap. Args: - max_bytes: If given, copy at most this many bytes from the front - of the capture. A ``memoryview`` bounds the slice so no more - than ``max_bytes`` are ever materialised. ``None`` returns the - full capture. + max_bytes: If given, copy at most this many bytes from the front of + the capture. A ``memoryview`` bounds the slice so no more than + ``max_bytes`` are ever materialised. ``None`` returns the full + capture. Returns: The captured bytes, optionally truncated to ``max_bytes``. @@ -112,35 +154,101 @@ def snapshot(self, max_bytes: int | None = None) -> bytes: raise ValueError(f"max_bytes must be non-negative, got {max_bytes}") return bytes(memoryview(self._cached)[:max_bytes]) + def error(self) -> BaseException | None: + """Return the drain failure, if any. + + Triggers the one-time drain on first access, then reports the cached + exception on every later call without a second drain attempt. + + Returns: + The exception that aborted the drain, or ``None`` if the drain + completed (or captured a full/partial prefix cleanly). + """ + self._drain() + return self._error + @property def captured_size(self) -> int: + """Bytes currently in the capture. Never exceeds ``max_capture_bytes``.""" return len(self._cached) + def close(self) -> None: + if self._closed: + return + self._closed = True + self._close_inner() + + # ----- internals ------------------------------------------------------ + + def _replay_cache(self, chunk_size: int) -> Iterator[bytes]: + """Yield a fresh, non-consuming view of the captured prefix.""" + view = memoryview(self._cached) + for start in range(0, len(view), chunk_size): + yield bytes(view[start : start + chunk_size]) + + def _replay_tail(self, chunk_size: int) -> Iterator[bytes]: + """Yield the buffered prefix, then the overflow, then the live tail.""" + yield from self._replay_cache(chunk_size) + if self._overflow: + yield self._overflow + if self._tail_it is not None: + yield from self._tail_it + + def _close_inner(self) -> None: + """Close the delegate once, swallowing any close failure. + + A close failure after the payload is already secured must not surface. + ``KeyboardInterrupt`` / ``SystemExit`` still propagate. + """ + if self._inner_closed: + return + self._inner_closed = True + with contextlib.suppress(Exception): + self._inner.close() + def _drain(self) -> None: + """Run the one-time drain, guarded by a double-checked lock.""" if self._drained: return with self._lock: if self._drained: return - chunks: list[bytes] = [] - captured = 0 try: - for chunk in self._inner.iter_bytes(): - if captured < self._max: - take = min(self._max - captured, len(chunk)) - chunks.append(chunk[:take]) - captured += take - except BaseException as exc: - # Record any failure (including bare ``BaseException``) so - # ``iter_bytes`` re-raises it and never replays a truncated - # read as success. ``KeyboardInterrupt`` / ``SystemExit`` must - # not be swallowed: propagate them immediately. - self._error = exc - if isinstance(exc, (KeyboardInterrupt, SystemExit)): - raise + self._run_drain() finally: - self._cached = b"".join(chunks) self._drained = True + def _run_drain(self) -> None: + """Capture up to the cap, deciding the fits-cap vs exceeds-cap regime.""" + it = self._inner.iter_bytes() + chunks: list[bytes] = [] + captured = 0 + try: + for chunk in it: + remaining = self._max - captured + if len(chunk) <= remaining: + chunks.append(chunk) + captured += len(chunk) + continue + # This chunk crosses the cap: keep the prefix, stash the tail, + # and leave the delegate open for a single-use replay. + chunks.append(chunk[:remaining]) + self._cached = b"".join(chunks) + self._overflow = chunk[remaining:] + self._tail_it = it + return + # End-of-stream at or before the cap: the whole body was captured. + self._cached = b"".join(chunks) + self._fully_captured = True + self._close_inner() + except BaseException as exc: + # Cache every failure so a truncated read is never replayed as + # success, but let ``KeyboardInterrupt`` / ``SystemExit`` propagate + # from the drain at once (they stay cached for later reads too). + self._error = exc + self._cached = b"".join(chunks) + if isinstance(exc, (KeyboardInterrupt, SystemExit)): + raise + __all__ = ["LoggableResponseBody"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response.py index 506c13d..5acbb43 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response.py @@ -5,9 +5,11 @@ from __future__ import annotations +import threading +from collections.abc import Callable from dataclasses import dataclass, field, replace from types import TracebackType -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Self, cast from ..common.headers import Headers from ..common.http_header_name import HttpHeaderName @@ -15,12 +17,99 @@ from .status import Status if TYPE_CHECKING: + from ...serde import Serde from ..request.request import Request from .response_body import ResponseBody type _Name = str | HttpHeaderName +class _Outcome: + """A memoized parse result: either a value or the exception it raised. + + Wrapping both possibilities in one object lets the latch publish the outcome + with a single attribute store while still distinguishing "not computed yet" + (the cell holds ``None``) from a legitimately parsed ``None`` value (the cell + holds an `_Outcome` whose ``_value`` is ``None``). + """ + + __slots__ = ("_error", "_value") + + def __init__(self, value: object, error: Exception | None) -> None: + self._value = value + self._error = error + + @classmethod + def capture(cls, factory: Callable[[], object]) -> _Outcome: + """Run ``factory`` once, capturing its value or the exception it raises.""" + try: + return cls(factory(), None) + except Exception as exc: + # Any decode/handler failure is stored verbatim and re-raised on + # every later access; control-flow exceptions (KeyboardInterrupt, + # SystemExit) are not ``Exception`` and propagate uncached. + return cls(None, exc) + + def unwrap(self) -> object: + """Return the captured value, or re-raise the captured failure.""" + if self._error is not None: + raise self._error + return self._value + + +class _ParseLatch: + """Double-checked memoization cell backing `Response.parse`. + + The decoded outcome — a value *or* a thrown failure — is computed at most + once and then served to every later (and concurrent) caller. Correctness + does not rest on any atomicity of the fast-path read: a racing reader may + observe the cell as empty and fall through to the lock, but only the caller + that wins the lock ever runs the factory, and the re-check *inside* the lock + closes the window. Publishing the outcome is a single attribute store, so a + fast-path reader observes either ``None`` or the fully built `_Outcome` — + never a half-initialized one. This holds on free-threaded CPython (PEP 703), + where the GIL no longer serializes the check-then-act, exactly as it does + under the GIL. + + Both a successful result (including ``None``) and a thrown failure are + memoized: a later `Response.parse` after a decode failure re-raises the same + error without re-running the factory or re-reading the single-use body. + """ + + __slots__ = ("_lock", "_outcome") + + def __init__(self) -> None: + self._lock = threading.Lock() + self._outcome: _Outcome | None = None + + def get(self, factory: Callable[[], object]) -> object: + """Return the memoized value, computing it via ``factory`` exactly once. + + Args: + factory: Zero-arg callable producing the value on first access. It + runs at most once, under the lock, even across concurrent first + calls; whether it returns or raises, the outcome is cached and + every later call replays it (re-raising the same failure). + + Returns: + The memoized value. + + Raises: + Exception: The same exception the factory raised on first access, + re-raised on every later call. + """ + outcome = self._outcome + if outcome is not None: + return outcome.unwrap() + with self._lock: + outcome = self._outcome + if outcome is not None: + return outcome.unwrap() + outcome = _Outcome.capture(factory) + self._outcome = outcome + return outcome.unwrap() + + @dataclass(frozen=True, slots=True) class Response: """Immutable HTTP response produced by a transport. @@ -35,6 +124,9 @@ class Response: and the underlying body is closed deterministically on exit. The header / metadata surface is immutable and safe to share across threads; the body, when present, carries single-use stream state — see `ResponseBody`. + + Deserialize the body with `parse`, which reads and decodes it exactly once + and memoizes the result for repeat (and concurrent) callers. """ request: Request @@ -43,6 +135,73 @@ class Response: headers: Headers = field(default_factory=Headers) reason: str | None = None body: ResponseBody | None = None + _latch: _ParseLatch = field(default_factory=_ParseLatch, init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + """Validate the required exchange fields. + + Raises: + ValueError: If ``request``, ``protocol``, or ``status`` is missing. + The message names the offending field. + """ + if self.request is None: + raise ValueError("request is required") + if self.protocol is None: + raise ValueError("protocol is required") + if self.status is None: + raise ValueError("status is required") + + def __repr__(self) -> str: + """Return a repr that never echoes credentials. + + The wrapped ``request`` renders through `Request.__repr__` (which routes + its URL through the shared ``UrlRedactor``) and ``headers`` renders + through `Headers` (which masks credential-bearing values), so neither + URL userinfo / query secrets nor authorization headers leak. The private + parse latch is omitted. + """ + return ( + f"Response(request={self.request!r}, protocol={self.protocol!r}, " + f"status={self.status!r}, headers={self.headers!r}, " + f"reason={self.reason!r}, body={self.body!r})" + ) + + def parse[T](self, target: type[T], *, serde: Serde | None = None) -> T: + """Decode the response body into ``target``, once, and memoize it. + + The body is read and deserialized on the first call — a single-use + operation, since reading a body consumes it — and the decoded value is + cached. Every later call, including one racing the first from another + thread, returns that same cached object without touching the body. The + memoization is latched under an explicit lock with a double-checked + re-check, so exactly one deserialization happens even under concurrent + first access on free-threaded CPython. A different ``target`` on a later + call is ignored: the body is already gone, so the first result stands. + + Args: + target: The type to reconstruct — a dataclass, ``list[X]`` / + ``dict[str, X]``, a scalar, or any other type the codec accepts. + A subscripted generic such as ``list[Pet]`` may be passed + directly. + serde: Wire-format decoder for the raw bytes. Defaults to JSON. + + Returns: + The decoded value. + + Raises: + ValueError: If the response has no body to parse. + """ + return cast("T", self._latch.get(lambda: self._decode(target, serde))) + + def _decode[T](self, target: type[T], serde: Serde | None) -> T: + """Read the body fully and decode it into ``target`` (runs once).""" + if self.body is None: + raise ValueError("response has no body to parse") + from ...serde import JSON_SERDE, Codec + + active = serde if serde is not None else JSON_SERDE + structure = active.deserializer.deserialize_bytes(self.body.bytes()) + return Codec().decode(structure, target) def close(self) -> None: """Close the response body. Idempotent.""" diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response_body.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response_body.py index 888ddd0..36922a6 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response_body.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/response_body.py @@ -8,14 +8,17 @@ from abc import ABC, abstractmethod from collections.abc import Iterator from types import TracebackType -from typing import BinaryIO, Self +from typing import BinaryIO, Final, Self +from .._once_guard import _OnceGuard from ..common.media_type import MediaType # Module-level alias so the in-class ``bytes()`` method does not shadow the # built-in ``bytes`` type when annotating other methods. _bytes = bytes +_CONSUMED_MSG: Final[str] = "ResponseBody has already been consumed" + def _check_chunk_size(chunk_size: int) -> None: """Reject non-positive ``chunk_size`` values. @@ -153,14 +156,19 @@ def from_stream( class _BytesResponseBody(ResponseBody): - """In-memory ``ResponseBody``.""" + """In-memory ``ResponseBody``. + + Single-use: the lock-protected once-guard is claimed eagerly at + ``iter_bytes`` call time, so a second call raises ``RuntimeError`` even + when the first returned iterator was never advanced. + """ - __slots__ = ("_closed", "_consumed", "_data", "_media_type") + __slots__ = ("_closed", "_data", "_media_type", "_once") def __init__(self, data: _bytes, media_type: MediaType | None) -> None: self._data = data self._media_type = media_type - self._consumed = False + self._once = _OnceGuard() self._closed = False def media_type(self) -> MediaType | None: @@ -171,9 +179,10 @@ def content_length(self) -> int: def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: _check_chunk_size(chunk_size) - if self._consumed: - raise RuntimeError("ResponseBody has already been consumed") - self._consumed = True + self._once.claim(_CONSUMED_MSG) + return self._iter(chunk_size) + + def _iter(self, chunk_size: int) -> Iterator[bytes]: view = memoryview(self._data) for start in range(0, len(view), chunk_size): yield bytes(view[start : start + chunk_size]) @@ -184,9 +193,14 @@ def close(self) -> None: class _StreamResponseBody(ResponseBody): - """Stream-backed single-use ``ResponseBody``.""" + """Stream-backed single-use ``ResponseBody``. - __slots__ = ("_closed", "_consumed", "_length", "_media_type", "_stream") + Single-use: the lock-protected once-guard is claimed eagerly at + ``iter_bytes`` call time, so a second call raises ``RuntimeError`` even + when the first returned iterator was never advanced. + """ + + __slots__ = ("_closed", "_length", "_media_type", "_once", "_stream") def __init__( self, @@ -197,7 +211,7 @@ def __init__( self._stream = stream self._media_type = media_type self._length = length - self._consumed = False + self._once = _OnceGuard() self._closed = False def media_type(self) -> MediaType | None: @@ -208,9 +222,10 @@ def content_length(self) -> int: def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: _check_chunk_size(chunk_size) - if self._consumed: - raise RuntimeError("ResponseBody has already been consumed") - self._consumed = True + self._once.claim(_CONSUMED_MSG) + return self._iter(chunk_size) + + def _iter(self, chunk_size: int) -> Iterator[bytes]: try: while True: chunk = self._stream.read(chunk_size) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/status.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/status.py index 522eac6..a9932de 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/status.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/response/status.py @@ -14,14 +14,16 @@ class Status(IntEnum): Inheriting from `int` so callers can compare against integers and range-check directly: ``response.status == 200`` or ``200 <= status < 300``. - Lookup is lenient: `Status(code)` for any integer in the HTTP range - 100..599 that is not a named member returns a synthesized pseudo-member - named ``UNKNOWN_`` carrying the raw integer value. This lets - responses with unregistered-but-valid codes (for example ``218`` from - Apache or ``599`` from a proxy) flow through the SDK with their band - classification (`is_success`, `is_redirect`, ...) and integer comparisons - intact, instead of being discarded. Integers outside 100..599 (for - example ``42`` or ``1000``) remain invalid and raise `ValueError`. + Lookup is total over integers: `Status(code)` for *any* integer that is + not a named member returns a synthesized pseudo-member named + ``UNKNOWN_`` carrying the raw integer value, and never raises. This + lets responses with unregistered or outright unusual codes — a vendor + ``499`` from nginx, a ``520``..``526`` from Cloudflare, or a code outside + the 100..599 band from a broken proxy — flow through the SDK with their + band classification (`is_success`, `is_redirect`, ...) and integer + comparisons intact, instead of being discarded before a policy can react. + Equality is by code (`int` semantics): ``Status(499) == 499``. Only a + non-integer lookup (for example ``Status("200")``) raises `ValueError`. """ # 1xx Informational @@ -98,17 +100,22 @@ class Status(IntEnum): @classmethod def _missing_(cls, value: object) -> Status | None: - """Synthesize a pseudo-member for an unregistered valid HTTP code. + """Synthesize a pseudo-member for any unregistered HTTP code. + + Keeping the lookup total over integers means a broken or vendor-only + code never costs the SDK a live response: it is surfaced as a + ``Status`` carrying the raw int, band-classifiable and comparable, + rather than raising and forcing the caller to discard the exchange. Args: value: The lookup value passed to `Status(value)`. Returns: - A pseudo-member carrying `value` when it is an integer in the - HTTP range 100..599 with no named member, or `None` to let the - enum machinery raise `ValueError` for any other input. + A pseudo-member carrying `value` when it is an integer with no + named member, or `None` to let the enum machinery raise + `ValueError` for a non-integer input. """ - if isinstance(value, int) and 100 <= value <= 599: + if isinstance(value, int): pseudo = int.__new__(cls, value) pseudo._name_ = f"UNKNOWN_{value}" pseudo._value_ = value diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/__init__.py index ab24b2f..6569e39 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/__init__.py @@ -1,19 +1,24 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""WHATWG-spec Server-Sent Events parsing and reconnecting client.""" +"""WHATWG-spec Server-Sent Events parsing, reconnecting client, and typed adapter.""" from __future__ import annotations from .connection import AsyncSseConnection, SseConnection from .parser import AsyncSseStream, SseEvent, SseParser, parse_async_events, parse_events +from .typed import AsyncTypedSseConnection, SseMapper, SseSignal, TypedSseConnection __all__ = [ "AsyncSseConnection", "AsyncSseStream", + "AsyncTypedSseConnection", "SseConnection", "SseEvent", + "SseMapper", "SseParser", + "SseSignal", + "TypedSseConnection", "parse_async_events", "parse_events", ] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/connection.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/connection.py index 0ac5484..802c26b 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/connection.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/connection.py @@ -17,7 +17,9 @@ from __future__ import annotations +import logging import random +import threading from typing import TYPE_CHECKING, cast from ...errors import HttpResponseError, ServiceRequestError, ServiceResponseError @@ -42,6 +44,8 @@ from ..response.response import Response from .parser import SseEvent +_LOGGER = logging.getLogger(__name__) + _LAST_EVENT_ID: str = "Last-Event-ID" _DEFAULT_RETRY_SECONDS: float = 3.0 _DEFAULT_MAX_BACKOFF: float = 30.0 @@ -117,10 +121,13 @@ class SseConnection: __slots__ = ( "_clock", + "_closed", "_dispatch_factory", "_initial", + "_iterated", "_jitter", "_last_event_id", + "_lock", "_max_backoff", "_max_reconnects", "_rand", @@ -153,6 +160,12 @@ def __init__( self._rand = rand if rand is not None else random.Random() self._dispatch_factory = dispatch_factory or DispatchContext.noop self._response: Response | None = None + # The close-once flag is guarded by an explicit lock rather than relying + # on the GIL, so cross-thread ``close`` stays race-free on free-threaded + # CPython (PEP 703). + self._lock = threading.Lock() + self._closed = False + self._iterated = False self._send = self._normalise(source) def _normalise(self, source: SyncPipelineLike | SendSync) -> SendSync: @@ -165,13 +178,68 @@ def send(request: Request) -> Response: return send return source + def _release(self) -> None: + """Release the currently-owned response exactly once. + + The read-and-clear of the response slot happens under ``_lock``, so two + callers racing (for example ``close`` invoked from another thread while + iteration parks in the transport) can never both observe the same + response: only the caller that atomically takes the slot goes on to + close it. The close itself runs outside the lock so a slow transport + teardown never blocks a racing caller. + """ + with self._lock: + response = self._response + self._response = None + if response is not None: + response.close() + + def _release_suppressing(self, primary: BaseException) -> None: + """Release the response while ``primary`` is propagating. + + A failure raised by the release is demoted onto ``primary.__context__`` + instead of replacing ``primary``: the mid-stream failure the caller + needs to see must never be masked by a secondary teardown error. + """ + try: + self._release() + except BaseException as close_error: + # Demoted, not swallowed: the primary exception re-raised by the + # caller carries this teardown error on its ``__context__``. + primary.__context__ = close_error + + def _release_reporting(self) -> None: + """Release the finished response between reconnects, cleanly. + + This runs on an automatic clean-terminal rollover (a natural + end-of-stream or an absorbed transient drop, with no error surfacing to + the caller). A release failure here must not be turned into a thrown + result that discards the events already delivered, so it is reported + out-of-band and swallowed. An explicit `close` still propagates its + release failure. + """ + try: + self._release() + except Exception: + _LOGGER.warning( + "SSE response release failed on a clean reconnect rollover; " + "swallowed to preserve already-delivered events", + exc_info=True, + ) + + def _is_closed(self) -> bool: + with self._lock: + return self._closed + def close(self) -> None: - """Close the current response, if any. Idempotent.""" - response = self._response - if response is None: - return - self._response = None - response.close() + """Close the connection: release the response and stop reconnecting. + + Idempotent and safe to call from any thread, including while another + thread iterates. A second call is a no-op rather than a double-release. + """ + with self._lock: + self._closed = True + self._release() def __enter__(self) -> Self: return self @@ -185,6 +253,22 @@ def __exit__( self.close() def __iter__(self) -> Iterator[SseEvent]: + """Return the single-pass event iterator. + + Raises: + RuntimeError: If the connection is already closed or was iterated + before. An SSE stream cannot be restarted by re-iterating the + facade; open a fresh connection instead. + """ + with self._lock: + if self._closed: + raise RuntimeError("SseConnection is closed") + if self._iterated: + raise RuntimeError("SseConnection supports a single iteration") + self._iterated = True + return self._run() + + def _run(self) -> Iterator[SseEvent]: failures = 0 last_error: BaseException | None = None try: @@ -193,7 +277,9 @@ def __iter__(self) -> Iterator[SseEvent]: progressed, error = yield from self._stream(response) if error is not None: last_error = error - self.close() + self._release_reporting() + if self._is_closed(): + return if progressed: failures = 0 if self._max_reconnects is not None and failures >= self._max_reconnects: @@ -204,14 +290,20 @@ def __iter__(self) -> Iterator[SseEvent]: ) ) failures += 1 - finally: - self.close() + except BaseException as primary: + # The loop only ever exits by raising — a mid-stream failure, an + # exhausted reconnect budget, or ``GeneratorExit`` when the caller + # stops early — so releasing here covers every termination path and + # runs before the exception reaches the caller. + self._release_suppressing(primary) + raise def _connect(self, request: Request) -> Response: response = self._send(request) - self._response = response + with self._lock: + self._response = response if not response.status.is_success: - self.close() + self._release() raise HttpResponseError(response=response) return response @@ -225,12 +317,18 @@ def _stream( cause if the reconnect budget is later exhausted; other exceptions propagate. A ``retry:`` hint that arrived without a data event is picked up from the parser's sticky state after iteration. + + Raises: + ServiceResponseError: If the (successful) response carries no body. + An SSE stream opened over a body-less response is a protocol + violation and fails loudly rather than being treated as an + immediately-empty stream. """ progressed = False error: BaseException | None = None body = response.body if body is None: - return progressed, error + raise ServiceResponseError("SSE response has no body") parser = SseParser() try: for chunk in body.iter_bytes(): @@ -279,10 +377,13 @@ class AsyncSseConnection: __slots__ = ( "_clock", + "_closed", "_dispatch_factory", "_initial", + "_iterated", "_jitter", "_last_event_id", + "_lock", "_max_backoff", "_max_reconnects", "_rand", @@ -315,6 +416,12 @@ def __init__( self._rand = rand if rand is not None else random.Random() self._dispatch_factory = dispatch_factory or DispatchContext.noop self._response: AsyncResponse | None = None + # The close-once flag is guarded by an explicit lock, not the GIL, so + # the slot read-and-clear stays race-free on free-threaded CPython even + # when ``aclose`` is driven from a different thread's loop. + self._lock = threading.Lock() + self._closed = False + self._iterated = False self._send = self._normalise(source) def _normalise(self, source: AsyncPipelineLike | SendAsync) -> SendAsync: @@ -327,13 +434,63 @@ async def send(request: Request) -> AsyncResponse: return send return source + async def _arelease(self) -> None: + """Release the currently-owned response exactly once, cancel-safely. + + The slot is taken under ``_lock`` so a racing releaser never closes the + same response twice; the ``await`` runs outside the lock. The close is + routed through ``_shielded_cleanup`` so a cancelled consumer still + releases the transport handle before the cancellation continues. + """ + with self._lock: + response = self._response + self._response = None + if response is not None: + await _shielded_cleanup(response.close()) + + async def _arelease_suppressing(self, primary: BaseException) -> None: + """Release the response while ``primary`` is propagating. + + A teardown failure is demoted onto ``primary.__context__`` rather than + masking the primary exception the caller must see. + """ + try: + await self._arelease() + except BaseException as close_error: + # Demoted, not swallowed: the primary exception re-raised by the + # caller carries this teardown error on its ``__context__``. + primary.__context__ = close_error + + async def _arelease_reporting(self) -> None: + """Release the finished response between reconnects, cleanly. + + Async twin of `SseConnection._release_reporting`: on an automatic + clean-terminal rollover a release failure is reported out-of-band and + swallowed so it cannot discard already-delivered events. An explicit + `aclose` still propagates its release failure. + """ + try: + await self._arelease() + except Exception: + _LOGGER.warning( + "SSE response release failed on a clean reconnect rollover; " + "swallowed to preserve already-delivered events", + exc_info=True, + ) + + def _is_closed(self) -> bool: + with self._lock: + return self._closed + async def aclose(self) -> None: - """Close the current response, if any. Idempotent and cancel-safe.""" - response = self._response - if response is None: - return - self._response = None - await _shielded_cleanup(response.close()) + """Close the connection: release the response and stop reconnecting. + + Idempotent and cancel-safe; a second call is a no-op rather than a + double-release. + """ + with self._lock: + self._closed = True + await self._arelease() async def __aenter__(self) -> Self: return self @@ -347,6 +504,19 @@ async def __aexit__( await self.aclose() def __aiter__(self) -> AsyncIterator[SseEvent]: + """Return the single-pass async event iterator. + + Raises: + RuntimeError: If the connection is already closed or was iterated + before. An SSE stream cannot be restarted by re-iterating the + facade; open a fresh connection instead. + """ + with self._lock: + if self._closed: + raise RuntimeError("AsyncSseConnection is closed") + if self._iterated: + raise RuntimeError("AsyncSseConnection supports a single iteration") + self._iterated = True return self._events() async def _events(self) -> AsyncIterator[SseEvent]: @@ -357,19 +527,25 @@ async def _events(self) -> AsyncIterator[SseEvent]: response = await self._connect(_resume_request(self._initial, self._last_event_id)) progressed = False body = response.body - if body is not None: - stream = parse_async_events(body.aiter_bytes()) - try: - async with stream: - async for event in stream: - self._last_event_id = _last_event_id_of(event, self._last_event_id) - progressed = True - yield event - except _TRANSIENT as exc: - last_error = exc - if stream.retry is not None: - self._retry_base = stream.retry / 1000.0 - await self.aclose() + if body is None: + # A body-less (but successful) SSE response is a protocol + # violation: fail loudly rather than treat it as an empty + # stream. The outer handler releases before propagating. + raise ServiceResponseError("SSE response has no body") + stream = parse_async_events(body.aiter_bytes()) + try: + async with stream: + async for event in stream: + self._last_event_id = _last_event_id_of(event, self._last_event_id) + progressed = True + yield event + except _TRANSIENT as exc: + last_error = exc + if stream.retry is not None: + self._retry_base = stream.retry / 1000.0 + await self._arelease_reporting() + if self._is_closed(): + return if progressed: failures = 0 if self._max_reconnects is not None and failures >= self._max_reconnects: @@ -380,14 +556,19 @@ async def _events(self) -> AsyncIterator[SseEvent]: ) ) failures += 1 - finally: - await self.aclose() + except BaseException as primary: + # Every loop exit is an exception (mid-stream failure, exhausted + # budget, or the cancellation/close thrown into the generator), so + # releasing here releases before propagating on all of them. + await self._arelease_suppressing(primary) + raise async def _connect(self, request: Request) -> AsyncResponse: response = await self._send(request) - self._response = response + with self._lock: + self._response = response if not response.status.is_success: - await self.aclose() + await self._arelease() raise HttpResponseError(response=response) return response diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/parser.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/parser.py index eef03bb..f135906 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/parser.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/parser.py @@ -11,8 +11,13 @@ - Handles ``LF`` / ``CR`` / ``CRLF`` line terminators interchangeably per the spec. - Joins multi-line ``data:`` fields with a single ``\n``. -- Skips comment lines (those beginning with ``:``). -- Treats blank lines as event terminators; empty events are not emitted. +- Captures comment lines (those beginning with ``:``): the parser records the + comment text and treats it as a "field seen", so a comment-only keep-alive + block still dispatches an event. +- Dispatches permissively: an event is emitted whenever any tracked field + (``id`` / ``event`` / ``data`` / ``comment`` / ``retry``) was set in the + block, so ``id``-only, ``retry``-only, and comment-only blocks are visible. + A block in which no field was set (pure blank lines) emits nothing. - Decodes payloads as UTF-8 (the spec mandates UTF-8 for ``text/event-stream``); invalid bytes in a complete line are replaced with U+FFFD rather than raising, per the spec. A codepoint truncated by the stream ending surfaces as a @@ -20,8 +25,15 @@ Per-event fields: - ``data``: the (possibly multi-line) message payload. -- ``event``: the event name (defaults to ``"message"``). -- ``id``: the last-event-id (sticky across the parser's lifetime per spec). +- ``event``: the raw event name, or ``None`` when the block sent no ``event:`` + field. It is deliberately **not** defaulted to ``"message"``: the parser + surfaces the field as absent so a higher layer (an ``EventSource``-style + consumer) can apply the ``"message"`` default itself. +- ``id``: the ``id:`` value present in this block, or ``None``. The parser is + single-pass and does **not** persist a last-event-id across events — each + event surfaces only the id in its own block. Carrying an id forward for + reconnection is the caller's / `SseConnection`'s responsibility. +- ``comment``: the latest comment text seen in the block, or ``None``. - ``retry``: reconnect time in milliseconds, when the server sends one. """ @@ -83,6 +95,12 @@ async def _shielded_aclose(cleanup: Awaitable[None]) -> None: _COLON: Final[int] = 0x3A _UTF8_BOM: Final[bytes] = b"\xef\xbb\xbf" +#: Upper bound for a ``retry:`` value, in milliseconds. The reconnection time +#: is a signed 64-bit millisecond count in the reference implementations, so a +#: digits-only value above ``2**63 - 1`` is rejected (ignored) rather than +#: stored as an unbounded Python integer. +_RETRY_MAX_MS: Final[int] = (1 << 63) - 1 + @dataclass(frozen=True, slots=True) class SseEvent: @@ -91,23 +109,35 @@ class SseEvent: Attributes: data: The event payload (with newlines between concatenated ``data:`` lines). - event: The event name (``"message"`` if the stream omitted ``event:``). - id: The most recent ``id:`` value seen by the parser, sticky across - events per the spec. + event: The raw ``event:`` value for this block, or ``None`` when the + block sent no ``event:`` field. It is deliberately not defaulted to + ``"message"``: the parser surfaces an omitted event name as absent + (``None``), leaving the ``"message"`` default to an + ``EventSource``-style consumer. + id: The ``id:`` value present in this block, or ``None`` when the block + carried no valid id. It is **not** sticky: the parser does not + carry a last-event-id forward, so each event surfaces only the id + in its own block. An ``id:`` value containing a U+0000 NUL is + ignored entirely (it neither sets the id nor counts as a field). + comment: The latest comment text (a line beginning with ``:``) seen in + the block, or ``None``. Latest-wins within a block. retry: The most recent ``retry:`` value seen by the parser, sticky - like ``id`` once observed. Although carried on each ``SseEvent``, - this field is **not** per-event: the WHATWG spec defines - ``retry`` as a connection-level reconnection-time setting that - persists until the server sends a new value, so subsequent + like a connection-level setting once observed. Although carried on + each ``SseEvent``, this field is **not** per-event: the WHATWG spec + defines ``retry`` as a connection-level reconnection-time setting + that persists until the server sends a new value, so subsequent events repeat the last observed integer (milliseconds) until - superseded. ``None`` means no ``retry:`` line has been seen - yet on this stream. + superseded. Only a value of ASCII digits within the signed + 64-bit millisecond range (``0`` .. ``2**63 - 1``) is honoured; + anything else is ignored. ``None`` means no valid ``retry:`` + line has been seen yet on this stream. """ data: str - event: str = "message" + event: str | None = None id: str | None = None retry: int | None = None + comment: str | None = None @dataclass(slots=True) @@ -118,13 +148,19 @@ class SseParser: queue and are yielded by `drain`. Callers typically use the free functions `parse_events` / `parse_async_events` rather than holding a parser directly. + + Not thread-safe: a single reader must drive `feed` / `drain` / `end` in + sequence. The mutable buffer and accumulators are unguarded, so concurrent + use from multiple threads is undefined. """ _buffer: bytearray = field(default_factory=bytearray) _data_lines: list[str] = field(default_factory=list) - _event: str = "message" - _last_id: str | None = None + _event: str | None = None + _id: str | None = None + _comment: str | None = None _retry: int | None = None + _field_seen: bool = False _pending: deque[SseEvent] = field(default_factory=deque) _bom_stripped: bool = False max_line_bytes: int = 1 << 20 # 1 MiB @@ -182,8 +218,7 @@ def end(self) -> Iterator[SseEvent]: self._buffer.clear() if line is not None: self._process_line(line) - if self._data_lines: - self._dispatch() + self._dispatch() yield from self.drain() def _strip_leading_bom(self, *, at_end: bool = False) -> bool: @@ -219,42 +254,89 @@ def _process_line(self, line: str) -> None: if not line: self._dispatch() return - if line.startswith(":"): - return # comment field_name, _, value = line.partition(":") if value.startswith(" "): value = value[1:] + if field_name == "": + # A leading ':' is a comment (empty field name). The text is + # captured latest-wins and counts as a field seen, so a comment-only + # keep-alive block still dispatches. + self._comment = value + self._field_seen = True + return match field_name: case "data": self._data_lines.append(value) + self._field_seen = True case "event": self._event = value + self._field_seen = True case "id": - if "\0" not in value: # spec: ignore IDs containing NULL - self._last_id = value + # Spec: an id containing a U+0000 NUL is ignored entirely — it + # does not set the id, does not count as a field seen, and does + # not overwrite a valid id already seen in this block. + if "\0" not in value: + self._id = value + self._field_seen = True case "retry": - if value.isdigit(): - self._retry = int(value) + self._set_retry(value) + self._field_seen = True case _: - # Unknown field — spec says ignore. + # Unknown field — spec says ignore (does not count as seen). pass + def _set_retry(self, value: str) -> None: + """Set the sticky reconnect hint from a ``retry:`` field value. + + Per the WHATWG spec only ASCII digits are honoured. The ``isascii`` + guard is deliberate: ``str.isdigit`` also accepts non-ASCII digits — + superscripts such as ``²`` that ``int`` rejects with ``ValueError``, + and script digits such as ``٤`` that ``int`` would silently accept — + so restricting to ASCII keeps a raw ``ValueError`` from escaping `feed` + and keeps non-ASCII digits out. A value above the signed 64-bit + millisecond ceiling (``2**63 - 1``) is rejected cleanly (ignored) + rather than stored as an unbounded integer. + + Args: + value: The raw ``retry:`` field value (one leading space already + stripped). + """ + if value.isascii() and value.isdigit(): + parsed = int(value) + if parsed <= _RETRY_MAX_MS: + self._retry = parsed + def _dispatch(self) -> None: - if not self._data_lines: - # Spec: blank line with no data buffered ⇒ no event emitted, and the - # event name resets to the default. ``retry`` is connection-level and - # deliberately persists, so it is not reset here. - self._event = "message" + if not self._field_seen: + # A block in which no tracked field was set (pure blank lines, or + # only unknown / NUL-id lines) emits nothing. Reset the per-block + # accumulators; ``retry`` is connection-level and deliberately + # persists, so it is not reset here. + self._reset_block() return event = SseEvent( data="\n".join(self._data_lines), event=self._event, - id=self._last_id, + id=self._id, retry=self._retry, + comment=self._comment, ) self._pending.append(event) + self._reset_block() + + def _reset_block(self) -> None: + """Clear the per-block accumulators after a block ends. + + The connection-level ``retry`` hint is intentionally preserved across + blocks; ``event``, ``id``, ``comment``, and the data lines are per-block + and reset to their absent state so the next event surfaces only what its + own block sent. + """ self._data_lines = [] - self._event = "message" + self._event = None + self._id = None + self._comment = None + self._field_seen = False def _read_line(buffer: bytearray, *, at_eos: bool) -> tuple[str | None, int]: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/typed.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/typed.py new file mode 100644 index 0000000..60cc8bc --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/http/sse/typed.py @@ -0,0 +1,235 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Typed adapter over an SSE event stream. + +`TypedSseConnection` / `AsyncTypedSseConnection` wrap any owning SSE source +(an `SseConnection`, or the free `parse_events` iterator paired with a closer) +and map each event through a caller-supplied callback. The mapper receives the +event name and the newline-joined data payload and returns either a mapped +value or an `SseSignal`: + +- `SseSignal.SKIP` — discard this event and pull the next one. +- `SseSignal.DONE` — stop the stream (the wrapped source is released). + +Mapping is lazy and pull-based: exactly one event is drawn from the underlying +source per unit of consumer demand (plus any events the mapper skips), never by +draining or buffering ahead. The wrapped source is released on every +termination path — clean exhaustion, a `DONE` signal, the consumer stopping +early, or the mapper raising — with the resource released before a mapper error +propagates. + +This layer holds no sentinel, error, or serialisation conventions of its own: +what a data payload *means* (a ``[DONE]`` marker, a JSON document, a keep-alive) +is entirely the mapper's decision. +""" + +from __future__ import annotations + +import logging +from enum import Enum, auto +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable, Iterator + from types import TracebackType + from typing import Self + + from .parser import SseEvent + +_LOGGER = logging.getLogger(__name__) + + +class SseSignal(Enum): + """Control signal a typed SSE mapper returns instead of a mapped value. + + Attributes: + SKIP: Discard the current event and keep pulling from the stream. + DONE: Stop the stream; the wrapped source is released. + """ + + SKIP = auto() + DONE = auto() + + +#: A typed SSE mapper: ``(event_name, data) -> mapped value | SseSignal``. +type SseMapper[T] = Callable[[str, str], T | SseSignal] + + +class _SyncSseSource(Protocol): + """An owning, iterable sync SSE source (structurally an `SseConnection`).""" + + def __iter__(self) -> Iterator[SseEvent]: ... + + def close(self) -> None: ... + + +class _AsyncSseSource(Protocol): + """An owning, iterable async SSE source (an `AsyncSseConnection`).""" + + def __aiter__(self) -> AsyncIterator[SseEvent]: ... + + async def aclose(self) -> None: ... + + +class TypedSseConnection[T]: + """Lazily maps a synchronous SSE source's events to typed values. + + Args: + source: The owning SSE source to consume and release. + mapper: Callback mapping ``(event_name, data)`` to a value, or to an + `SseSignal` to skip the event or stop the stream. + """ + + __slots__ = ("_mapper", "_source") + + def __init__(self, source: _SyncSseSource, mapper: SseMapper[T]) -> None: + self._source = source + self._mapper = mapper + + def close(self) -> None: + """Release the wrapped source. Idempotent (delegated to the source).""" + self._source.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def __iter__(self) -> Iterator[T]: + """Yield mapped values lazily, honouring SKIP/DONE per consumer demand. + + An absent event name (the parser surfaces it as ``None``) is presented + to the mapper as ``"message"``, applying the ``EventSource`` default at + this consumer layer rather than in the parser. + + Yields: + Each event mapped to a non-signal value, in stream order. + """ + try: + for event in self._source: + outcome = self._mapper(event.event or "message", event.data) + if isinstance(outcome, SseSignal): + if outcome is SseSignal.DONE: + break + continue # SseSignal.SKIP: discard and pull the next event. + yield outcome + except BaseException as primary: + # An error is in flight — a mapper failure or the consumer stopping + # via GeneratorExit. Release before propagating and demote a release + # failure onto __context__ rather than masking the primary error. + self._close_demoting(primary) + raise + # Clean terminal: natural exhaustion or a DONE sentinel with no error in + # flight. A release failure here must not be turned into a thrown result + # that discards already-delivered events, so it is reported out-of-band + # and swallowed. An explicit close() still propagates (see close()). + self._close_swallowing() + + def _close_demoting(self, primary: BaseException) -> None: + """Release the source, demoting a failure onto ``primary``'s context.""" + try: + self._source.close() + except Exception as close_err: + # Demoted, not masked: the primary error re-raised by the caller + # carries this teardown failure on its __context__. + primary.__context__ = close_err + + def _close_swallowing(self) -> None: + """Release the source on a clean terminal; report a failure out-of-band.""" + try: + self._source.close() + except Exception: + _LOGGER.warning( + "SSE source release failed on a clean terminal path; swallowed " + "to preserve already-delivered events", + exc_info=True, + ) + + +class AsyncTypedSseConnection[T]: + """Asynchronous twin of `TypedSseConnection`. + + Args: + source: The owning async SSE source to consume and release. + mapper: Callback mapping ``(event_name, data)`` to a value, or to an + `SseSignal` to skip the event or stop the stream. + """ + + __slots__ = ("_mapper", "_source") + + def __init__(self, source: _AsyncSseSource, mapper: SseMapper[T]) -> None: + self._source = source + self._mapper = mapper + + async def aclose(self) -> None: + """Release the wrapped source. Idempotent (delegated to the source).""" + await self._source.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + async def __aiter__(self) -> AsyncIterator[T]: + """Yield mapped values lazily, honouring SKIP/DONE per consumer demand. + + An absent event name (the parser surfaces it as ``None``) is presented + to the mapper as ``"message"``, applying the ``EventSource`` default at + this consumer layer rather than in the parser. + + Yields: + Each event mapped to a non-signal value, in stream order. + """ + try: + async for event in self._source: + outcome = self._mapper(event.event or "message", event.data) + if isinstance(outcome, SseSignal): + if outcome is SseSignal.DONE: + break + continue # SseSignal.SKIP: discard and pull the next event. + yield outcome + except BaseException as primary: + await self._aclose_demoting(primary) + raise + await self._aclose_swallowing() + + async def _aclose_demoting(self, primary: BaseException) -> None: + """Release the source, demoting a failure onto ``primary``'s context.""" + try: + await self._source.aclose() + except Exception as close_err: + # Demoted, not masked: the primary error re-raised by the caller + # carries this teardown failure on its __context__. + primary.__context__ = close_err + + async def _aclose_swallowing(self) -> None: + """Release the source on a clean terminal; report a failure out-of-band.""" + try: + await self._source.aclose() + except Exception: + _LOGGER.warning( + "SSE source release failed on a clean terminal path; swallowed " + "to preserve already-delivered events", + exc_info=True, + ) + + +__all__ = [ + "AsyncTypedSseConnection", + "SseMapper", + "SseSignal", + "TypedSseConnection", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/__init__.py index 6f09a69..10fcddb 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/__init__.py @@ -14,14 +14,24 @@ from __future__ import annotations -from .client_logger import ClientLogger, CorrelationFilter +from .client_logger import ClientLogger, CorrelationFilter, LogEvent from .correlation import ( + activate_span, bind_correlation, + bind_to_context, + capture_context, + correlation_scope, + get_current_span, get_span_id, get_trace_id, set_span_id, set_trace_id, ) +from .header_redactor import ( + DEFAULT_LOGGED_HEADER_ALLOWLIST, + DEFAULT_URL_VALUED_HEADERS, + HeaderRedactor, +) from .http_tracer import HttpTracer, HttpTracerFactory from .identifiers import SpanId, TraceFlags, TraceId, TraceIdType, TraceState from .instrumentation_context import InstrumentationContext @@ -49,7 +59,9 @@ from .url_redactor import DEFAULT_QUERY_ALLOWLIST, UrlRedactor __all__ = [ + "DEFAULT_LOGGED_HEADER_ALLOWLIST", "DEFAULT_QUERY_ALLOWLIST", + "DEFAULT_URL_VALUED_HEADERS", "NOOP_COUNTER", "NOOP_HISTOGRAM", "NOOP_HTTP_TRACER", @@ -62,10 +74,12 @@ "ClientLogger", "CorrelationFilter", "Counter", + "HeaderRedactor", "Histogram", "HttpTracer", "HttpTracerFactory", "InstrumentationContext", + "LogEvent", "LogLevel", "MetricsContext", "Span", @@ -78,7 +92,12 @@ "TracingScope", "UpDownCounter", "UrlRedactor", + "activate_span", "bind_correlation", + "bind_to_context", + "capture_context", + "correlation_scope", + "get_current_span", "get_span_id", "get_trace_id", "set_span_id", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/client_logger.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/client_logger.py index 9c5901d..b62ed64 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/client_logger.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/client_logger.py @@ -1,17 +1,42 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Structured logger wrapper for SDK-internal use.""" +"""Structured logger wrapper for SDK-internal use. + +Wraps a stdlib ``logging.Logger`` behind a fluent, structured event API. The +primary surface is ``ClientLogger.at_()`` which returns a `LogEvent` +builder; callers chain ``add`` / ``event`` and terminate with ``log``: + + logger.at_info().event("http.request").add("method", "GET").log("sent") + +Design guarantees: + +- **Zero-allocation disabled path.** When a level is disabled the ``at_*`` + helpers return a single shared inert `LogEvent` (identity-stable); nothing is + rendered or allocated. +- **Total rendering.** A value whose ``__str__`` raises yields a placeholder + rather than propagating; ``None`` renders as the literal ``null``; values are + bounded by a configurable length (primitives exempt). +- **Emit-once.** Each active event emits at most once, guarded by an explicit + ``threading.Lock`` (no reliance on the GIL / atomic ops). +- **Precedence.** Field values override global-context values, which override + ambient diagnostic-context values; each key appears at most once. +""" from __future__ import annotations +import contextlib import logging import threading -from typing import Any, Final +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Final, Self from .correlation import get_span_id, get_trace_id from .log_level import LogLevel +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + _LEVEL_MAP: Final[dict[LogLevel, int]] = { LogLevel.ERROR: logging.ERROR, LogLevel.WARNING: logging.WARNING, @@ -19,6 +44,29 @@ LogLevel.VERBOSE: logging.DEBUG, } +#: Reserved structured key. It is set only through `LogEvent.event`; a plain +#: ``add`` targeting it is a collision (warned once per logger, then dropped). +_EVENT_KEY: Final = "event" +_RESERVED_KEYS: Final[frozenset[str]] = frozenset({_EVENT_KEY}) + +#: Placeholder substituted for a value whose ``str()`` raises. +_UNRENDERABLE: Final = "" + +#: Literal rendered for a ``None`` field value. +_NULL: Final = "null" + +#: Appended when a (non-primitive) value is truncated. +_TRUNCATION_MARKER: Final = "...[truncated]" + +#: Default per-value character budget before truncation kicks in. +_DEFAULT_MAX_FIELD_LENGTH: Final = 8192 + +#: Ambient diagnostic keys folded into every event by default. +_DEFAULT_DIAGNOSTIC_ALLOW_LIST: Final[frozenset[str]] = frozenset({"trace.id", "span.id"}) + +#: Event tag used when the logger re-emits its own emission failure. +_INSTRUMENTATION_ERROR_EVENT: Final = "http.instrumentation.error" + #: Serialises the check-then-act in ``_install_correlation_filter`` so two #: threads constructing loggers concurrently cannot both add a filter. A single #: process-wide lock is sufficient: installation is a one-time, low-contention @@ -48,43 +96,219 @@ def filter(self, record: logging.LogRecord) -> bool: return True +class LogEvent(ABC): + """A single structured log emission under construction. + + Obtain one from ``ClientLogger.at_()``. ``add`` / ``event`` return + the same builder for chaining; ``log`` terminates the event and emits it at + most once. The instance returned for a disabled level is a shared inert + singleton (see `ClientLogger.at_level`). + """ + + __slots__ = () + + @abstractmethod + def add(self, key: str, value: Any) -> Self: + """Attach a structured field. + + Args: + key: Non-empty field name. + value: Field value; rendered lazily at ``log`` time. + + Returns: + This builder, for chaining. + """ + + @abstractmethod + def event(self, name: str) -> Self: + """Set the reserved ``event`` tag. + + Args: + name: Event identifier; an empty string clears any previously set + tag so no ``event`` field is emitted. + + Returns: + This builder, for chaining. + """ + + @abstractmethod + def log(self, message: str = "") -> None: + """Render and emit the event exactly once. + + Args: + message: Optional human-readable message rendered before the + structured fields. + """ + + +class _ActiveLogEvent(LogEvent): + """A `LogEvent` bound to an enabled level; renders and emits on ``log``.""" + + __slots__ = ("_client", "_emitted", "_event_tag", "_fields", "_level", "_lock") + + def __init__(self, client: ClientLogger, level: LogLevel) -> None: + self._client = client + self._level = level + self._fields: dict[str, Any] = {} + self._event_tag: str | None = None + self._lock = threading.Lock() + self._emitted = False + + def add(self, key: str, value: Any) -> Self: + """Record a field, rejecting empty keys and reserved-key collisions.""" + if not key: + raise ValueError("log field key must be a non-empty string") + if key in _RESERVED_KEYS: + self._client._warn_reserved_key(key) + return self + self._fields[key] = value + return self + + def event(self, name: str) -> Self: + """Set (or, with an empty string, clear) the reserved ``event`` tag.""" + self._event_tag = name + return self + + def log(self, message: str = "") -> None: + """Emit once; on emission failure re-emit an internal diagnostic.""" + with self._lock: + if self._emitted: + return + self._emitted = True + try: + self._client._emit(self._level, message, self._event_tag, self._fields) + except Exception as exc: # a logging failure must not crash the caller + self._client._report_instrumentation_failure(exc) + + +class _NoopLogEvent(LogEvent): + """Inert `LogEvent` returned for disabled levels; every method is a no-op.""" + + __slots__ = () + + def add(self, key: str, value: Any) -> Self: + """Ignore the field and return the shared singleton for chaining.""" + return self + + def event(self, name: str) -> Self: + """Ignore the tag and return the shared singleton for chaining.""" + return self + + def log(self, message: str = "") -> None: + """Do nothing; the level is disabled.""" + + +#: Shared inert event returned whenever a level is disabled. Returning a single +#: instance keeps the disabled path allocation-free and identity-stable. +_NOOP_EVENT: Final[_NoopLogEvent] = _NoopLogEvent() + + class ClientLogger: """Thin facade over stdlib ``logging`` that emits structured key=value pairs. - Wraps a ``logging.Logger`` and prepends caller-supplied context (e.g. - ``client="dexpace.foo"``) to every emission. Values are coerced via - ``str()`` and quoted when they contain whitespace, matching the - "logfmt" convention used by many observability backends. + Wraps a ``logging.Logger`` and folds three field sources into every event, + highest precedence first: per-event fields, this logger's global context, + and the ambient diagnostic context (trace/span ids by default). Values are + coerced via ``str()``, ``None`` renders as ``null``, and any token needing + it is quoted, matching the "logfmt" convention used by many observability + backends. + + The logger guards its own emission: a failure while rendering or handing a + record to stdlib ``logging`` is caught and re-emitted as an + ``http.instrumentation.error`` diagnostic rather than propagated. It does + not, however, wrap any tracer/meter callback, so exceptions from those + (which by contract must surface) are never swallowed here. Attributes: name: The underlying logger name (also used for the log emitter). """ - __slots__ = ("_logger", "_static_fields", "name") + __slots__ = ( + "_diagnostic_allow_list", + "_diagnostic_context", + "_global_context", + "_logger", + "_max_field_length", + "_reserved_lock", + "_reserved_warned", + "name", + ) def __init__( self, name: str, - **static_fields: Any, + *, + max_field_length: int = _DEFAULT_MAX_FIELD_LENGTH, + diagnostic_allow_list: frozenset[str] | None = _DEFAULT_DIAGNOSTIC_ALLOW_LIST, + diagnostic_context: Callable[[], Mapping[str, Any]] | None = None, + **global_context: Any, ) -> None: """Configure the logger. Args: name: Logger name; passed to ``logging.getLogger``. - **static_fields: Key=value pairs prepended to every emission. + max_field_length: Character budget applied to each rendered value; + primitive values (``int`` / ``float`` / ``bool``) are exempt. + diagnostic_allow_list: Ambient diagnostic keys folded into events. + Defaults to ``{"trace.id", "span.id"}``; pass ``None`` to fold + every ambient key; ambient keys whose value is ``None`` are + always skipped. + diagnostic_context: Callable returning the ambient diagnostic + fields; defaults to the bound trace/span ids. + **global_context: Key=value pairs folded into every emission. """ self.name = name self._logger = logging.getLogger(name) - self._static_fields = static_fields + self._global_context = global_context + self._max_field_length = max_field_length + self._diagnostic_allow_list = diagnostic_allow_list + self._diagnostic_context = diagnostic_context or _default_diagnostic_context + self._reserved_lock = threading.Lock() + self._reserved_warned = False _install_correlation_filter(self._logger) + def is_enabled(self, level: LogLevel) -> bool: + """Return whether ``level`` would be emitted by the underlying logger.""" + return self._logger.isEnabledFor(_LEVEL_MAP[level]) + + def at_level(self, level: LogLevel) -> LogEvent: + """Start an event at ``level``. + + Returns: + A fresh active builder when the level is enabled, otherwise the + shared inert singleton (same object every time) so the disabled + path allocates nothing. + """ + if not self.is_enabled(level): + return _NOOP_EVENT + return _ActiveLogEvent(self, level) + + def at_error(self) -> LogEvent: + """Start an event at ``ERROR`` level.""" + return self.at_level(LogLevel.ERROR) + + def at_warning(self) -> LogEvent: + """Start an event at ``WARNING`` level.""" + return self.at_level(LogLevel.WARNING) + + def at_info(self) -> LogEvent: + """Start an event at ``INFO`` level.""" + return self.at_level(LogLevel.INFO) + + def at_verbose(self) -> LogEvent: + """Start an event at ``VERBOSE`` (``DEBUG``) level.""" + return self.at_level(LogLevel.VERBOSE) + def log(self, level: LogLevel, message: str, **fields: Any) -> None: - """Emit a structured log record at ``level``.""" - py_level = _LEVEL_MAP[level] - if not self._logger.isEnabledFor(py_level): - return - rendered = _format_fields({**self._static_fields, **_correlation_fields(), **fields}) - self._logger.log(py_level, "%s %s", message, rendered) + """Emit a structured log record at ``level``. + + Convenience wrapper over the builder API; disabled levels short-circuit + on the shared inert event. + """ + event = self.at_level(level) + for key, value in fields.items(): + event.add(key, value) + event.log(message) def error(self, message: str, **fields: Any) -> None: """Emit a structured record at ``ERROR`` level.""" @@ -102,6 +326,64 @@ def verbose(self, message: str, **fields: Any) -> None: """Emit a structured record at ``VERBOSE`` (``DEBUG``) level.""" self.log(LogLevel.VERBOSE, message, **fields) + def _emit( + self, + level: LogLevel, + message: str, + event_tag: str | None, + fields: Mapping[str, Any], + ) -> None: + """Render the merged fields and hand the record to stdlib logging.""" + ordered: dict[str, Any] = {} + if event_tag: + ordered[_EVENT_KEY] = event_tag + for key, value in self._merge(fields).items(): + if key not in _RESERVED_KEYS: + ordered[key] = value + rendered = _format_fields(ordered, self._max_field_length) + py_level = _LEVEL_MAP[level] + if message: + self._logger.log(py_level, "%s %s", message, rendered) + else: + self._logger.log(py_level, "%s", rendered) + + def _merge(self, fields: Mapping[str, Any]) -> dict[str, Any]: + """Fold the three sources: field > global > diagnostic-context.""" + return {**self._diagnostic_fields(), **self._global_context, **fields} + + def _diagnostic_fields(self) -> dict[str, Any]: + """Return allow-listed ambient diagnostic fields, dropping null values.""" + allow = self._diagnostic_allow_list + result: dict[str, Any] = {} + for key, value in self._diagnostic_context().items(): + if value is None: + continue + if allow is not None and key not in allow: + continue + result[key] = value + return result + + def _warn_reserved_key(self, key: str) -> None: + """Warn once per logger about a field colliding with a reserved key.""" + with self._reserved_lock: + if self._reserved_warned: + return + self._reserved_warned = True + self._logger.warning("log field key %r is reserved; set it via .event()", key) + + def _report_instrumentation_failure(self, exc: BaseException) -> None: + """Re-emit an emission failure as an internal diagnostic, never raising. + + Bypasses the (possibly failing) diagnostic-context provider and global + context to keep this last-resort path from re-triggering the fault. + """ + with contextlib.suppress(Exception): + self._logger.log( + logging.ERROR, + "%s", + f"{_EVENT_KEY}={_INSTRUMENTATION_ERROR_EVENT} error_type={type(exc).__name__}", + ) + def _install_correlation_filter(logger: logging.Logger) -> None: """Attach a `CorrelationFilter` to ``logger`` exactly once. @@ -116,33 +398,67 @@ def _install_correlation_filter(logger: logging.Logger) -> None: logger.addFilter(CorrelationFilter()) -def _correlation_fields() -> dict[str, str]: - """Return the bound trace/span ids as logfmt fields, omitting unset ones.""" - fields: dict[str, str] = {} - trace_id = get_trace_id() - if trace_id is not None: - fields["trace.id"] = trace_id - span_id = get_span_id() - if span_id is not None: - fields["span.id"] = span_id - return fields - - -def _format_fields(fields: dict[str, Any]) -> str: - parts: list[str] = [] - for key, value in fields.items(): - rendered = str(value) - if any(c in rendered for c in ' \t"\n\r='): - rendered = ( - '"' - + rendered.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - + '"' - ) - parts.append(f"{key}={rendered}") - return " ".join(parts) +def _default_diagnostic_context() -> dict[str, str | None]: + """Return the bound trace/span ids as the ambient diagnostic context.""" + return {"trace.id": get_trace_id(), "span.id": get_span_id()} + + +def _format_fields( + fields: Mapping[str, Any], + max_field_length: int = _DEFAULT_MAX_FIELD_LENGTH, +) -> str: + """Render ``fields`` as a space-separated logfmt string. + + Rendering is total: no value can raise. ``None`` becomes ``null``, values + are truncated to ``max_field_length`` (primitives exempt), and any token + containing whitespace, quotes, or ``=`` is quoted with escapes. + """ + return " ".join( + f"{key}={_render_value(value, max_field_length)}" for key, value in fields.items() + ) + + +def _render_value(value: Any, max_field_length: int) -> str: + """Render a single value to its logfmt token (never raises).""" + if value is None: + return _NULL + text = _safe_str(value) + if not isinstance(value, (int, float)): # bool is an int subclass -> exempt + text = _truncate(text, max_field_length) + return _quote(text) + + +def _safe_str(value: Any) -> str: + """Coerce ``value`` to ``str``, substituting a placeholder if it raises. + + Only ``Exception`` is caught; a ``BaseException`` (e.g. ``KeyboardInterrupt``) + raised from ``__str__`` still propagates. + """ + try: + return str(value) + except Exception: + return _UNRENDERABLE + + +def _truncate(text: str, max_field_length: int) -> str: + """Cap ``text`` at ``max_field_length`` chars, appending a marker if cut.""" + if len(text) <= max_field_length: + return text + return text[:max_field_length] + _TRUNCATION_MARKER + + +def _quote(text: str) -> str: + """Quote ``text`` for logfmt when it contains whitespace, quotes, or ``=``.""" + if any(c in text for c in ' \t"\n\r='): + return ( + '"' + + text.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + + '"' + ) + return text -__all__ = ["ClientLogger", "CorrelationFilter"] +__all__ = ["ClientLogger", "CorrelationFilter", "LogEvent"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/correlation.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/correlation.py index 05215ab..600f70c 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/correlation.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/correlation.py @@ -17,16 +17,22 @@ from __future__ import annotations +import functools from contextlib import contextmanager -from contextvars import ContextVar +from contextvars import ContextVar, copy_context from typing import TYPE_CHECKING +from .tracing_scope import TracingScope + if TYPE_CHECKING: - from collections.abc import Iterator - from contextvars import Token + from collections.abc import Callable, Iterator + from contextvars import Context, Token + + from .span import Span _trace_id: ContextVar[str | None] = ContextVar("dexpace_trace_id", default=None) _span_id: ContextVar[str | None] = ContextVar("dexpace_span_id", default=None) +_current_span: ContextVar[Span | None] = ContextVar("dexpace_current_span", default=None) def get_trace_id() -> str | None: @@ -96,8 +102,129 @@ def bind_correlation( _trace_id.reset(trace_token) +def get_current_span() -> Span: + """Return the ambient current span, or the no-op span when none is active. + + The active span is tracked in a ``contextvar``, so it flows across ``await`` + boundaries automatically and is captured by `capture_context` for + cross-thread handoff. When no span has been activated the shared `NOOP_SPAN` + is returned so callers never handle ``None``. + """ + span = _current_span.get() + if span is None: + from .noop import NOOP_SPAN + + return NOOP_SPAN + return span + + +def activate_span(span: Span) -> TracingScope: + """Make ``span`` the ambient current span for the executing context. + + Returns a `TracingScope` whose ``close()`` restores the previously-current + span — including when the scope body raises, because the restore runs in the + scope's ``__exit__``. This is the machinery behind the default + `Span.make_current`, so a backend span reuses the save/restore dance rather + than re-implementing it. + + Args: + span: The span to make current. + + Returns: + A scope that restores the prior current span on close. + """ + return _CurrentSpanScope(span) + + +class _CurrentSpanScope(TracingScope): + """Restores the previously-current span when closed. ``close`` is idempotent.""" + + __slots__ = ("_closed", "_token") + + def __init__(self, span: Span) -> None: + self._token: Token[Span | None] = _current_span.set(span) + self._closed = False + + def close(self) -> None: + if self._closed: + return + self._closed = True + _current_span.reset(self._token) + + +@contextmanager +def correlation_scope(span: Span) -> Iterator[None]: + """Bind ``span``'s ids for the block, skipping non-recording spans. + + For a recording span this binds ``trace.id`` / ``span.id`` from the span's + `InstrumentationContext` so `ClientLogger` stamps them onto every record + emitted while the block runs, restoring the prior ids on exit (including on + exception). For a non-recording span it is a no-op: the inherited correlation + ids are left untouched, because a span that is not recording must not + overwrite the active correlation context. + + Args: + span: The span whose ids drive correlation for the block. + + Yields: + Nothing; use as a plain scope guard. + """ + if not span.is_recording: + yield + return + context = span.context + with bind_correlation(trace_id=context.trace_id.value, span_id=context.span_id.value): + yield + + +def capture_context() -> Context: + """Return an immutable snapshot of the ambient context at the call site. + + Captured on the ORIGINATING side, at submission time. Re-run work inside it + with ``snapshot.run(func)`` — typically on a worker thread — so the work + observes the contextvars (including the correlation ids and current span) as + they were when captured, rather than whatever a fresh thread inherited. + ``Context.run`` isolates writes: a contextvar mutated inside the snapshot + never leaks back to the caller's context. + + Returns: + A ``contextvars.Context`` snapshot to run callables within. + """ + return copy_context() + + +def bind_to_context[**P, T](func: Callable[P, T]) -> Callable[P, T]: + """Bind ``func`` to the ambient context captured NOW, at submission time. + + Snapshots the caller's contextvars at this call and returns a wrapper that + runs ``func`` inside that snapshot when invoked — typically handed to + ``Executor.submit`` so the worker observes the correlation context as it was + at submission, not whatever the worker inherited or the caller later moved + to. Mutations inside the wrapper stay isolated to the snapshot and never leak + back to the caller. + + Args: + func: The callable to rebind to the captured context. + + Returns: + A wrapper that runs ``func`` within the captured context snapshot. + """ + snapshot = copy_context() + + @functools.wraps(func) + def _runner(*args: P.args, **kwargs: P.kwargs) -> T: + return snapshot.run(func, *args, **kwargs) + + return _runner + + __all__ = [ + "activate_span", "bind_correlation", + "bind_to_context", + "capture_context", + "correlation_scope", + "get_current_span", "get_span_id", "get_trace_id", "set_span_id", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/header_redactor.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/header_redactor.py new file mode 100644 index 0000000..85a935c --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/header_redactor.py @@ -0,0 +1,151 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Decide which header names are logged and redact URL-valued header values. + +This is the one component shared by the sync and async logging policies for +header-value redaction, so the rules cannot drift between the two pillars. +Header logging is default-deny by name: only allow-listed names have their +value logged, URL-valued headers (``Location`` / ``Content-Location``) route +their value through the shared `UrlRedactor`, and a disallowed name is either +marked ``REDACTED`` or omitted entirely. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from .url_redactor import UrlRedactor + +if TYPE_CHECKING: + from collections.abc import Iterable + + from ..http.common.headers import Headers + +#: Value substituted for a header whose name is not allow-listed (when the +#: policy marks rather than omits it). It replaces the value, never the name, +#: so a disallowed header's value is never logged. +_REDACTED_MARKER: Final[str] = "REDACTED" + +#: Default allow-list of header NAMES whose value may be logged. Deliberately +#: conservative: only diagnostic, non-credential headers. Credential-bearing +#: headers (``authorization``, ``proxy-authorization``, ``cookie``, +#: ``set-cookie``, ``www-authenticate``, ``proxy-authenticate``, API-key +#: headers) are intentionally absent so their value never reaches the log. +DEFAULT_LOGGED_HEADER_ALLOWLIST: Final[frozenset[str]] = frozenset( + { + "accept", + "accept-charset", + "accept-encoding", + "accept-language", + "accept-ranges", + "age", + "cache-control", + "connection", + "content-encoding", + "content-language", + "content-length", + "content-location", + "content-range", + "content-type", + "date", + "etag", + "expires", + "last-modified", + "location", + "pragma", + "retry-after", + "server", + "trailer", + "transfer-encoding", + "user-agent", + "vary", + "via", + "warning", + "traceparent", + "tracestate", + "x-correlation-id", + "x-request-id", + } +) + +#: Header names whose value is a URL and must be redacted through the URL-value +#: redactor before logging. Location and Content-Location are the minimum. +DEFAULT_URL_VALUED_HEADERS: Final[frozenset[str]] = frozenset({"content-location", "location"}) + + +class HeaderRedactor: + """Gate header names by an allow-list and redact URL-valued header values. + + Attributes: + allowed_names: Lower-cased header names whose value may be logged. + url_valued_names: Lower-cased header names whose value is a URL and is + routed through the shared `UrlRedactor`. + redact_disallowed: When ``True`` (the default) a name that is not + allow-listed is emitted with the fixed ``"REDACTED"`` marker in + place of its value; when ``False`` it is omitted entirely. + """ + + __slots__ = ("_url_redactor", "allowed_names", "redact_disallowed", "url_valued_names") + + def __init__( + self, + *, + allowed_names: Iterable[str] = DEFAULT_LOGGED_HEADER_ALLOWLIST, + url_valued_names: Iterable[str] = DEFAULT_URL_VALUED_HEADERS, + url_redactor: UrlRedactor | None = None, + redact_disallowed: bool = True, + ) -> None: + """Configure the redactor. + + Args: + allowed_names: Header names whose value may be logged. Compared + case-insensitively. + url_valued_names: Header names whose value is a URL. Compared + case-insensitively; a name here should also be allow-listed. + url_redactor: Redactor applied to URL-valued header values. Defaults + to a stock `UrlRedactor`. + redact_disallowed: Whether a disallowed name is marked ``REDACTED`` + (``True``) or omitted (``False``). + """ + self.allowed_names = frozenset(name.lower() for name in allowed_names) + self.url_valued_names = frozenset(name.lower() for name in url_valued_names) + self._url_redactor = url_redactor or UrlRedactor() + self.redact_disallowed = redact_disallowed + + def redact(self, headers: Headers) -> dict[str, str]: + """Return the loggable ``name -> value`` map for ``headers``. + + Header names arrive already lower-cased from `Headers`. Multi-valued + headers are joined with ``", "`` (the HTTP list convention). An + allow-listed URL-valued header is redacted through the URL-value + redactor; any other allow-listed header passes through unchanged. A + name that is not allow-listed is marked ``REDACTED`` or omitted per + `redact_disallowed`. + + Args: + headers: The header collection to filter and redact. + + Returns: + A mapping of the header names that may be logged to their (possibly + redacted) values. + """ + result: dict[str, str] = {} + for name, values in headers.items(): + if name in self.allowed_names: + joined = ", ".join(values) + result[name] = ( + self._url_redactor.redact_header_value(joined) + if name in self.url_valued_names + else joined + ) + elif self.redact_disallowed: + result[name] = _REDACTED_MARKER + return result + + +__all__ = [ + "DEFAULT_LOGGED_HEADER_ALLOWLIST", + "DEFAULT_URL_VALUED_HEADERS", + "HeaderRedactor", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/identifiers.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/identifiers.py index 0d55159..40ffd9d 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/identifiers.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/identifiers.py @@ -7,7 +7,40 @@ from dataclasses import dataclass from enum import StrEnum -from typing import ClassVar +from typing import ClassVar, Final, Self +from uuid import uuid4 + +#: Bit width of a Datadog trace id (a 64-bit unsigned integer). +_DATADOG_BITS: Final = 64 + +#: Mask that projects a 128-bit UUID onto the low 64 bits for a Datadog id. +_DATADOG_MASK: Final = (1 << _DATADOG_BITS) - 1 + + +def _draw_datadog_bits() -> int: + """Draw a raw 64-bit value for a Datadog trace id. + + Derived from the low 64 bits of a `uuid.uuid4()` — a non-blocking, + non-crypto source, appropriate for a correlation handle rather than a + secret. Factored out so tests can force the reserved zero draw and prove + the coercion in `_datadog_value`. + + Returns: + An integer in ``[0, 2**64 - 1]``. + """ + return uuid4().int & _DATADOG_MASK + + +def _datadog_value() -> str: + """Render a non-zero 64-bit unsigned Datadog trace id as a decimal string. + + A zero draw is the reserved all-zero id, so it is coerced to ``1`` — the + generator never yields the invalid sentinel. + + Returns: + The trace id as a base-10 string. + """ + return str(_draw_datadog_bits() or 1) @dataclass(frozen=True, slots=True) @@ -15,13 +48,47 @@ class TraceId: """Identifier shared by every span in the same logical trace. The encoding (hex / decimal, length) depends on the `TraceIdType` of - the originating context. + the originating context. The all-zero `NOOP` value is the documented + invalid sentinel; a generated trace id is never all-zero. """ value: str NOOP: ClassVar[TraceId] + @classmethod + def generate(cls, id_type: TraceIdType | None = None) -> Self: + """Return a fresh trace id in the requested flavour. + + The W3C flavour is sourced from `uuid.uuid4().hex`. UUID4 is a + non-blocking, non-crypto generator, which is appropriate here: trace + ids are correlation handles, not secrets (unlike nonces / credentials + elsewhere, which use `secrets`). Neither generative flavour ever yields + the reserved all-zero id — `uuid4` fixes the version nibble to ``'4'`` + so a W3C id is always non-zero, and a zero Datadog draw is coerced to a + non-zero value. + + Args: + id_type: The encoding flavour. ``None`` (the default) and + `TraceIdType.W3C` yield a 128-bit value rendered as 32 lowercase + hex characters. `TraceIdType.DATADOG` yields a 64-bit unsigned + integer rendered as a decimal string. `TraceIdType.NOOP` yields + the all-zero invalid sentinel. + + Returns: + A newly generated `TraceId`. + """ + if id_type is TraceIdType.NOOP: + return cls(TraceId.NOOP.value) + if id_type is TraceIdType.DATADOG: + return cls(_datadog_value()) + return cls(uuid4().hex) + + @property + def is_valid(self) -> bool: + """True when this id is not the all-zero invalid sentinel.""" + return self != TraceId.NOOP + TraceId.NOOP = TraceId("0" * 32) @@ -30,13 +97,34 @@ class TraceId: class SpanId: """Identifier of the current span within its parent trace. - Rendered as a 16-character hex string per the W3C Trace Context spec. + Rendered as a 16-character hex string per the W3C Trace Context spec. The + all-zero `NOOP` value is the documented invalid sentinel; a generated span + id is never all-zero. """ value: str NOOP: ClassVar[SpanId] + @classmethod + def generate(cls) -> Self: + """Return a fresh, valid W3C span id (16 lowercase hex characters). + + Sourced from the first 16 hex characters of `uuid.uuid4().hex` — a + non-blocking, non-crypto source, appropriate for a correlation handle. + The slice includes the version nibble ``'4'`` (hex index 12), so the + result can never equal the all-zero `NOOP` sentinel by construction. + + Returns: + A newly generated `SpanId`. + """ + return cls(uuid4().hex[:16]) + + @property + def is_valid(self) -> bool: + """True when this id is not the all-zero invalid sentinel.""" + return self != SpanId.NOOP + SpanId.NOOP = SpanId("0" * 16) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/metrics.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/metrics.py index 05fc009..5afeffe 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/metrics.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/metrics.py @@ -17,11 +17,32 @@ class Counter(ABC): - """Monotonically-increasing aggregate value.""" + """Monotonically-increasing aggregate value. + + The non-negative invariant is enforced here, once, for every backend: + `add` validates the increment and then hands it to `_record`. A negative + increment is a programming error (a monotonic counter cannot decrease) and + is rejected even by the no-op counter, so misuse surfaces whether or not + metrics are enabled. Use `UpDownCounter` for values that may decrease. + """ - @abstractmethod def add(self, value: float = 1.0, attributes: Mapping[str, str] | None = None) -> None: - """Increase the counter by ``value`` (must be non-negative).""" + """Increase the counter by ``value``. + + Args: + value: Non-negative increment; defaults to ``1.0``. + attributes: Optional dimensions to attach to the measurement. + + Raises: + ValueError: If ``value`` is negative. + """ + if value < 0: + raise ValueError(f"Counter increment must be non-negative, got {value!r}") + self._record(value, attributes) + + @abstractmethod + def _record(self, value: float, attributes: Mapping[str, str] | None) -> None: + """Record a validated, non-negative ``value``; implemented per backend.""" class UpDownCounter(ABC): @@ -78,7 +99,7 @@ def histogram( class _NoopCounter(Counter): - def add(self, value: float = 1.0, attributes: Mapping[str, str] | None = None) -> None: + def _record(self, value: float, attributes: Mapping[str, str] | None) -> None: del value, attributes diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/span.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/span.py index c432555..c649b21 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/span.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/span.py @@ -43,13 +43,20 @@ def set_attribute(self, key: str, value: Any) -> Self: def set_error(self, error_type: str) -> Self: """Mark this span as having encountered an error of the given type.""" - @abstractmethod def make_current(self) -> TracingScope: """Make this span the active span for the current execution context. - Returns a `TracingScope` whose ``close()`` restores the - previously active span. Use as a context manager to guarantee cleanup. + The default activates this span in the ambient current-span + ``contextvar`` and returns a `TracingScope` whose ``close()`` restores + the previously active span — including when the scope body raises. Use + as a context manager to guarantee cleanup. Backends may override to add + their own book-keeping but should preserve the restore-on-close + contract; the no-op span overrides this to return a shared do-nothing + scope so the tracing-disabled path allocates nothing. """ + from .correlation import activate_span + + return activate_span(self) @abstractmethod def end(self, error: BaseException | None = None) -> None: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/url_redactor.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/url_redactor.py index 760ab25..b1317d8 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/url_redactor.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/instrumentation/url_redactor.py @@ -1,11 +1,26 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Redact sensitive components from URLs before log emission.""" +"""Redact sensitive components from URLs before log emission. + +This is the one default-deny URL redactor shared by every emitter that renders +a URL for diagnostics: the request/response ``__repr__``, the logging policy, +and the tracing span attributes. Keeping the logic in a single place means a +credential, token, or PII value is stripped the same way no matter which sink +surfaces it. + +Parsing here does NOT route through ``furl`` (the throwing parser behind +``Url.parse``). Instead ``redact`` uses a total, never-raising splitter so a +malformed string can never crash the logging path; it collapses to the literal +``"[malformed url]"`` placeholder instead of leaking a possibly secret-bearing +input verbatim. +""" from __future__ import annotations +import re from collections.abc import Iterable +from dataclasses import dataclass from typing import Final from ..http.common.url import QueryParams, Url @@ -36,29 +51,60 @@ ) _REDACTED: Final[str] = "REDACTED" +_REDACTED_PAIR: Final[str] = "REDACTED=REDACTED" _REDACTED_PATH: Final[str] = "/REDACTED" -#: Returned in place of a URL that cannot be parsed. Failing closed keeps an -#: unparseable, possibly secret-bearing string out of the logs entirely. -_REDACTED_UNPARSEABLE: Final[str] = "REDACTED:unparseable" +#: Appended to a relative / unparseable header value after its path is kept and +#: any query or fragment is dropped. A query or fragment on such a value can +#: carry an OAuth ``code``, a pre-signed signature, or an implicit-flow token. +_RELATIVE_QUERY_MARKER: Final[str] = "?***" + +#: Returned in place of a URL that cannot be parsed. Failing closed to a fixed +#: string keeps an unparseable, possibly secret-bearing input out of the logs. +_MALFORMED: Final[str] = "[malformed url]" + +#: A scheme is ``ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`` per RFC 3986 §3.1. +#: Anything else before ``://`` is treated as malformed rather than a scheme. +_SCHEME_RE: Final[re.Pattern[str]] = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*") + +_MAX_PORT: Final[int] = 65535 + + +@dataclass(frozen=True, slots=True) +class _Parts: + """The redaction-relevant components of a URL. + + Userinfo is intentionally absent: it is dropped during splitting and never + reconstructed, so embedded credentials cannot survive a round trip. + """ + + scheme: str + host: str + port: int | None + path: str + query: QueryParams + fragment: str class UrlRedactor: """Strip userinfo and non-allowlisted query parameters from a URL. - Used by logging policies to emit URLs without leaking credentials, - tokens, or PII embedded in query strings. The redactor returns a - ``str`` (not a ``Url``) so callers can format it directly. + Used by logging, tracing, and repr emitters to render URLs without leaking + credentials, tokens, or PII. The redactor returns a ``str`` (not a ``Url``) + so callers can format it directly. Attributes: - allowed_query_keys: Query parameter names emitted unredacted. + allowed_query_keys: Query parameter names emitted unredacted. The + decision is per key and atomic: every value of an allow-listed key + survives, every value of any other key collapses to + ``REDACTED=REDACTED``. redact_path: When ``True``, replace the path with ``/REDACTED``. - Defaults to ``False`` because paths are commonly useful in - logs and rarely carry secrets. - redact_fragment: When ``True`` (the default), drop the URL - fragment entirely. Fragments are not normally sent on the - wire but may appear in logged input; bearer tokens are - sometimes carried there (e.g. OAuth implicit flow). + Defaults to ``False`` because paths are commonly useful in logs and + rarely carry secrets. + redact_fragment: When ``True`` (the default), scrub ``key=value`` pairs + in the fragment (bearer tokens are sometimes carried there, e.g. the + OAuth implicit flow) while preserving a plain, non-key/value + fragment as-is. When ``False``, the fragment is emitted verbatim. """ __slots__ = ("allowed_query_keys", "redact_fragment", "redact_path") @@ -78,64 +124,208 @@ def redact(self, url: str | Url) -> str: """Return a redacted wire-form string for ``url``. Args: - url: Either a parsed ``Url`` or a wire-form string. Strings are - parsed via ``Url.parse``; parse failures fail closed and return - the constant ``"REDACTED:unparseable"`` rather than the input, - so a malformed string that may embed a secret never reaches the - log sink verbatim. + url: Either an already-parsed ``Url`` or a wire-form string. Strings + are split by a total, never-raising parser; a string that cannot + be split into at least a scheme and host yields the constant + ``"[malformed url]"`` rather than the input, so a malformed + string that may embed a secret never reaches the log sink. + + Returns: + A wire-form URL with userinfo stripped, each non-allowlisted query + parameter collapsed to ``REDACTED=REDACTED`` (both key and value, so + neither the secret nor the parameter name leaks), and fragment + ``key=value`` pairs scrubbed. Returns ``"[malformed url]"`` when a + string input cannot be parsed. This method never raises. + """ + parts = _parts_from_url(url) if isinstance(url, Url) else _split(url) + if parts is None: + return _MALFORMED + return self._render(parts) + + def redact_header_value(self, value: str) -> str: + """Redact a URL that arrives as an HTTP header value. + + A parseable absolute value (scheme + host) is redacted exactly like a + request URL, via the same rendering path as `redact`. A value that is + relative or otherwise unparseable keeps its path and drops everything + after it — both query and fragment — because either can carry an OAuth + ``code``, a pre-signed signature, or an implicit-flow token. When such a + value carried a query or a fragment, a fixed ``"?***"`` marker is + appended so the redaction is visible; a value with neither is returned + verbatim. + + Args: + value: The raw header value (e.g. a ``Location`` or + ``Content-Location`` header). Returns: - A wire-form URL with userinfo stripped and each non-allowlisted - parameter collapsed to ``REDACTED=REDACTED`` (both key and value), - so neither the secret nor the parameter name leaks. When the input - string cannot be parsed, returns ``"REDACTED:unparseable"``. + The redacted header value. This method never raises. + """ + parts = _split(value) + if parts is not None: + return self._render(parts) + return _redact_relative_value(value) + + def _render(self, parts: _Parts) -> str: + """Reassemble ``parts`` into a redacted wire-form string. + + The fragment is appended after the query is decided, so a literal ``?`` + living inside a fragment can never be mistaken for a query separator. """ - parsed = url if isinstance(url, Url) else _safe_parse(url) - if parsed is None: - return _REDACTED_UNPARSEABLE - return str(self._redact_parsed(parsed)) + out = [parts.scheme, "://", parts.host] + if parts.port is not None: + out.append(f":{parts.port}") + out.append(_REDACTED_PATH if self.redact_path else parts.path) + query = self._redact_query(parts.query) + if query: + out.append(f"?{query}") + fragment = self._redact_fragment(parts.fragment) + if fragment: + out.append(f"#{fragment}") + return "".join(out) - def _redact_parsed(self, parsed: Url) -> Url: - redacted_query = QueryParams( + def _redact_query(self, query: QueryParams) -> str: + """Redact non-allowlisted parameters and re-encode (no leading ``?``).""" + redacted = QueryParams( [ (key, value) if key in self.allowed_query_keys else (_REDACTED, _REDACTED) - for key, values in parsed.query.items() + for key, values in query.items() for value in values ] ) - path = _REDACTED_PATH if self.redact_path else parsed.path - fragment = "" if self.redact_fragment else parsed.fragment - return Url( - scheme=parsed.scheme, - host=parsed.host, - path=path, - port=parsed.port, - query=redacted_query, - fragment=fragment, - userinfo=None, - ) + return redacted.encode() + + def _redact_fragment(self, fragment: str) -> str: + """Scrub ``key=value`` fragment segments; preserve plain ones. + + When ``redact_fragment`` is disabled the fragment is returned verbatim. + Otherwise each ``&``-separated segment containing ``=`` collapses to + ``REDACTED=REDACTED`` (matching the query treatment) while a segment + with no ``=`` — a plain anchor such as ``section-2`` — is kept as-is. + """ + if not self.redact_fragment or not fragment: + return fragment + segments = fragment.split("&") + return "&".join(_REDACTED_PAIR if "=" in segment else segment for segment in segments) -def _safe_parse(raw: str) -> Url | None: - """Parse ``raw`` into a ``Url`` or return ``None`` if it is malformed. +def _parts_from_url(url: Url) -> _Parts: + """Project an already-parsed ``Url`` onto its redaction-relevant parts. - ``Url.parse`` delegates to ``furl``, which raises stdlib exceptions for - bad input (``ValueError`` for missing scheme/host or an invalid port, - ``UnicodeError`` for IDNA failures, and ``LookupError`` / ``TypeError`` - for other structural surprises). Catching the broad set keeps the caller - failing closed instead of letting an exotic furl error escape into the - logging path. + The ``Url`` was produced by ``Url.parse`` upstream, so no re-parsing (and no + ``furl`` call) happens here; userinfo is simply dropped. + """ + return _Parts( + scheme=url.scheme, + host=url.host, + port=url.port, + path=url.path, + query=url.query, + fragment=url.fragment, + ) + + +def _redact_relative_value(value: str) -> str: + """Keep the path of a relative / unparseable value, dropping query + fragment. + + The fragment is peeled first and the query second, so a ``?`` living inside + the fragment is never mistaken for a query separator. A fixed ``"?***"`` + marker is appended when the value carried either component; a value with + neither is returned unchanged. + + Args: + value: A relative reference or otherwise unparseable header value. + + Returns: + The path with a ``"?***"`` marker when a query or fragment was present, + else the original value verbatim. + """ + before_fragment, hash_separator, _fragment = value.partition("#") + path, query_separator, _query = before_fragment.partition("?") + if query_separator or hash_separator: + return f"{path}{_RELATIVE_QUERY_MARKER}" + return value + + +def _split(raw: str) -> _Parts | None: + """Split a wire-form string into ``_Parts`` without ever raising. + + A hand-rolled RFC-3986-shaped splitter used in place of ``furl`` (which + raises on bad input). The fragment is peeled first and the query second, so + a ``?`` inside a fragment never re-splits the query. Returns ``None`` when + the input lacks a valid scheme or host, signalling a malformed URL. Args: raw: A wire-form URL string. Returns: - The parsed ``Url``, or ``None`` if ``raw`` could not be parsed. + The parsed ``_Parts``, or ``None`` if ``raw`` is malformed. """ - try: - return Url.parse(raw) - except (ValueError, LookupError, TypeError): + if not raw: return None + before_fragment, _, fragment = raw.partition("#") + before_query, _, query = before_fragment.partition("?") + scheme, separator, after = before_query.partition("://") + if not separator or not _SCHEME_RE.fullmatch(scheme): + return None + authority, path = _split_authority_path(after) + host, port = _parse_host_port(_strip_userinfo(authority)) + if host is None: + return None + return _Parts( + scheme=scheme, + host=host, + port=port, + path=path, + query=QueryParams.parse(query), + fragment=fragment, + ) + + +def _split_authority_path(after: str) -> tuple[str, str]: + """Split ``host[:port]`` from the path at the first ``/`` (kept on the path).""" + slash = after.find("/") + if slash < 0: + return after, "" + return after[:slash], after[slash:] + + +def _strip_userinfo(authority: str) -> str: + """Drop a ``user[:password]@`` prefix, splitting on the last ``@``.""" + at_sign = authority.rfind("@") + return authority[at_sign + 1 :] if at_sign >= 0 else authority + + +def _parse_host_port(host_port: str) -> tuple[str | None, int | None]: + """Split ``host[:port]`` (including bracketed IPv6) without raising. + + Returns ``(None, None)`` for a malformed authority — an empty host, an + unterminated IPv6 literal, or a non-numeric/out-of-range port — so the + caller can fail closed. + """ + if host_port.startswith("["): + close = host_port.find("]") + if close < 0: + return None, None + host = host_port[: close + 1] + remainder = host_port[close + 1 :] + if len(host) <= 2: + return None, None + if not remainder: + return host, None + return _with_port(host, remainder[1:]) if remainder.startswith(":") else (None, None) + host, separator, port = host_port.rpartition(":") + if not separator: + return (host_port, None) if host_port else (None, None) + return _with_port(host, port) if host else (None, None) + + +def _with_port(host: str, port: str) -> tuple[str | None, int | None]: + """Attach a validated numeric port, or fail closed on a bad one.""" + if not (port.isascii() and port.isdigit()): + return None, None + number = int(port) + return (host, number) if number <= _MAX_PORT else (None, None) __all__ = ["DEFAULT_QUERY_ALLOWLIST", "UrlRedactor"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/page.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/page.py index 47d641a..209270e 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/page.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/page.py @@ -45,6 +45,7 @@ class Page[T]: next_request: Request | None = None prev_request: Request | None = None raw: object | None = field(default=None, compare=False) + _closed: bool = field(default=False, init=False, compare=False, repr=False) @property def has_next(self) -> bool: @@ -52,7 +53,17 @@ def has_next(self) -> bool: return self.next_request is not None def close(self) -> None: - """Close the underlying synchronous response, if any. Idempotent.""" + """Close the underlying synchronous response exactly once. + + Idempotent: the first call performs the close and later calls are + no-ops, so the response is closed exactly once however many abandon + paths run over it (an eager item-view close, a page-view release, a + caller ``with page:`` exit). The closed latch is set before the + underlying close runs, so a close that raises is never retried. + """ + if self._closed: + return + object.__setattr__(self, "_closed", True) close = getattr(self.raw, "close", None) if close is None: return @@ -64,7 +75,10 @@ def close(self) -> None: cast(Coroutine[object, object, object], result).close() async def aclose(self) -> None: - """Close the underlying asynchronous response, if any. Idempotent.""" + """Close the underlying asynchronous response exactly once. Idempotent.""" + if self._closed: + return + object.__setattr__(self, "_closed", True) close = getattr(self.raw, "close", None) if close is None: return diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/paginator.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/paginator.py index 89c35e1..8469b83 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/paginator.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/paginator.py @@ -28,8 +28,10 @@ import inspect import json -from collections.abc import AsyncIterator, Callable, Iterator -from typing import TYPE_CHECKING +from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Iterator +from contextlib import aclosing +from types import TracebackType +from typing import TYPE_CHECKING, Self from ..errors import DeserializationError from ..http.context.dispatch_context import DispatchContext @@ -104,6 +106,118 @@ def _decode_for(raw: str | None, request: Request) -> object: raise +def _validate_max_pages(max_pages: int | None) -> None: + """Reject a non-positive page cap at construction. + + Args: + max_pages: The requested cap, or ``None`` for an unbounded walk. + + Raises: + ValueError: When ``max_pages`` is zero or negative. A cap of zero would + fetch nothing and a negative cap is always a mistake; both are + rejected up front rather than silently yielding an empty sequence. + """ + if max_pages is not None and max_pages <= 0: + raise ValueError(f"max_pages must be a positive integer or None, got {max_pages!r}") + + +def _close_suppressing(response: Response, primary: BaseException) -> None: + """Close ``response`` while keeping ``primary`` the exception that surfaces. + + Used on the parse-failure path, where the response never became a page and + so must be closed here. The parse error stays primary: any error the close + raises is captured and hung on the parse error's ``__context__`` rather + than replacing it, so the parse failure is never masked. + + Args: + response: The response to close. + primary: The in-flight parse error that must remain primary. + """ + try: + response.close() + except BaseException as close_error: + # Re-attached to the parse error below, never swallowed. + close_error.__context__ = None + primary.__context__ = close_error + + +async def _aclose_suppressing(response: AsyncResponse, primary: BaseException) -> None: + """Async twin of `_close_suppressing`: close ``response`` keeping ``primary`` primary. + + Used on the async parse-failure and page-granular-cancellation paths, where + the response never became a page and so must be closed here. An ordinary + close *failure* is captured onto ``primary``'s ``__context__`` rather than + replacing it, so the parse (or cancellation) error stays primary. A + ``BaseException`` from the close — an ``asyncio.CancelledError`` reasserted + by the shielded body close — is deliberately not suppressed: it propagates + so a cancellation is never swallowed. ``AsyncResponse.close`` shields its + body close, so the transport handle is released even while a cancellation + is already unwinding. + + Args: + response: The response to close. + primary: The in-flight parse or cancellation error that must stay + primary. + """ + try: + await response.close() + except Exception as close_error: + # An ordinary close failure is chained, never allowed to mask primary. + close_error.__context__ = None + primary.__context__ = close_error + + +class _PageView[T]: + """Single-use, context-managed iterator over a paginator's pages. + + Wraps the paginator's lazy page generator. Iterating yields each `Page`; + the caller owns each page's lifecycle (close it, or use it as a context + manager). On context-manager exit — normal end, early ``break``, or an + exception unwinding through the block — the page currently held (fetched + but not yet released by the caller) is closed so no live response leaks. A + close error raised there surfaces as the primary exception, wrapping any + in-flight failure on its ``__context__``. + """ + + __slots__ = ("_current", "_pages") + + def __init__(self, pages: Generator[Page[T], None, None]) -> None: + self._pages = pages + self._current: Page[T] | None = None + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self._release() + + def __iter__(self) -> Iterator[Page[T]]: + return self + + def __next__(self) -> Page[T]: + try: + page = next(self._pages) + except StopIteration: + self._current = None + raise + self._current = page + return page + + def _release(self) -> None: + """Close the held page (if any) and stop the underlying generator.""" + held, self._current = self._current, None + try: + if held is not None: + held.close() + finally: + self._pages.close() + + class Paginator[T]: """Synchronous paginator over a strategy-defined page sequence. @@ -119,7 +233,14 @@ class Paginator[T]: ``source`` is a ``Pipeline``. Defaults to ``DispatchContext.noop``. """ - __slots__ = ("_dispatch_factory", "_initial", "_max_pages", "_send", "_strategy") + __slots__ = ( + "_dispatch_factory", + "_initial", + "_max_pages", + "_page_view_taken", + "_send", + "_strategy", + ) def __init__( self, @@ -130,10 +251,12 @@ def __init__( max_pages: int | None = None, dispatch_factory: Callable[[], DispatchContext] | None = None, ) -> None: + _validate_max_pages(max_pages) self._strategy = strategy self._initial = initial_request self._max_pages = max_pages self._dispatch_factory = dispatch_factory or DispatchContext.noop + self._page_view_taken = False self._send = self._normalise(source) def _normalise(self, source: SyncPipelineLike | SendSync) -> SendSync: @@ -155,44 +278,156 @@ def send(request: Request) -> Response: ) return source - def by_page(self) -> Iterator[Page[T]]: - """Yield each `Page` in turn, honouring ``max_pages``. + def _pages(self) -> Generator[Page[T], None, None]: + """Fetch and parse each page lazily, one exchange per page consumed. + + Nothing is fetched until the first page is pulled, exactly one exchange + runs per page, and the walk stops at the terminal page or the + ``max_pages`` cap — never overrunning either. The fetcher never closes a + page it produced; a live page is closed by the item view, the page-view + release, or the caller. A response that fails to parse never becomes a + page, so `_parse` closes it inline. Yields: - Pages from first to last. Each page owns its response; iterate - within a ``with page:`` block, or call ``page.close()``, to - release the connection promptly. + Each parsed `Page`, first to last. """ request: Request | None = self._initial count = 0 while request is not None: if self._max_pages is not None and count >= self._max_pages: return - response = self._send(request) - page = self._parse(response) + page = self._parse(self._send(request)) count += 1 yield page request = page.next_request + def by_page(self) -> _PageView[T]: + """Return the single-use, context-managed page view. + + The view yields each `Page` in turn, honouring ``max_pages``. Because + each page owns a live response, the view is single-use — a second call + raises — and is a context manager: on exit (normal, early ``break``, or + an exception) the page still held is closed so no response leaks. + + Returns: + The page view. Iterate it within a ``with`` block; close each page + (or use it as a context manager) to release its connection. + + Raises: + RuntimeError: If a page view was already taken from this paginator. + """ + if self._page_view_taken: + raise RuntimeError( + "by_page() is single-use; build a fresh Paginator for another page walk", + ) + self._page_view_taken = True + return _PageView(self._pages()) + def _parse(self, response: Response) -> Page[T]: - raw = response.body.string() if response.body is not None else None - payload = _decode_for(raw, response.request) - return self._strategy.parse(response, payload, response.request) + """Decode and parse ``response`` into a `Page`, closing it on failure. + + On success the page owns the response and closes it later. On any + failure reading, decoding, or parsing, the response — which never became + a page — is closed inline, with the parse error kept primary and any + close-time error suppressed onto its ``__context__``. + """ + try: + raw = response.body.string() if response.body is not None else None + payload = _decode_for(raw, response.request) + return self._strategy.parse(response, payload, response.request) + except BaseException as parse_error: + _close_suppressing(response, parse_error) + raise def __iter__(self) -> Iterator[T]: - for page in self.by_page(): - with page: - yield from page.items + """Iterate items across all pages, eager-closing each page first. + + Each page's response is closed before its items are yielded, so items + reach the caller already detached from any live connection. A fresh + call restarts pagination from the initial request, so two ``iter`` + calls perform two independent walks with their own exchanges. + + Yields: + Items in server order across every page. + """ + for page in self._pages(): + page.close() + yield from page.items + + +class _AsyncPageView[T]: + """Single-use, async-context-managed iterator over a paginator's pages. + + Async twin of `_PageView`. Wraps the paginator's lazy async page generator. + Async-iterating yields each `Page`; the caller owns each page's lifecycle + (``await page.aclose()``, or ``async with page``). On async-context-manager + exit — normal end, early ``break``, an exception, or a cancellation + unwinding through the block — the page currently held (fetched but not yet + released by the caller) is closed so no live response leaks. A close error + raised there surfaces as the primary exception, wrapping any in-flight + failure on its ``__context__``. + """ + + __slots__ = ("_current", "_pages") + + def __init__(self, pages: AsyncGenerator[Page[T], None]) -> None: + self._pages = pages + self._current: Page[T] | None = None + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self._release() + + def __aiter__(self) -> AsyncIterator[Page[T]]: + return self + + async def __anext__(self) -> Page[T]: + try: + page = await self._pages.__anext__() + except StopAsyncIteration: + self._current = None + raise + self._current = page + return page + + async def _release(self) -> None: + """Close the held page (if any), then stop the underlying generator.""" + held, self._current = self._current, None + try: + if held is not None: + await held.aclose() + finally: + await self._pages.aclose() class AsyncPaginator[T]: """Asynchronous twin of `Paginator`. - Mirrors the sync paginator exactly with ``async`` iteration semantics. - ``source`` is an ``AsyncPipeline`` or an async send-callable. + Mirrors the sync paginator with ``async`` iteration semantics: lazy per-page + fetching, exactly one exchange per page, close-once discipline over every + termination path (success, error, cancellation, early break), and a + single-use ``by_page`` view. ``source`` is an ``AsyncPipeline`` or an async + send-callable. Cancellation propagates natively — an + ``asyncio.CancelledError`` raised while a page is being fetched or parsed + unwinds unconverted, and a page staged but not yet yielded is dropped and + closed before the cancellation continues. """ - __slots__ = ("_dispatch_factory", "_initial", "_max_pages", "_send", "_strategy") + __slots__ = ( + "_dispatch_factory", + "_initial", + "_max_pages", + "_page_view_taken", + "_send", + "_strategy", + ) def __init__( self, @@ -203,10 +438,12 @@ def __init__( max_pages: int | None = None, dispatch_factory: Callable[[], DispatchContext] | None = None, ) -> None: + _validate_max_pages(max_pages) self._strategy = strategy self._initial = initial_request self._max_pages = max_pages self._dispatch_factory = dispatch_factory or DispatchContext.noop + self._page_view_taken = False self._send = self._normalise(source) def _normalise(self, source: AsyncPipelineLike | SendAsync) -> SendAsync: @@ -228,30 +465,88 @@ async def send(request: Request) -> AsyncResponse: ) return source - async def by_page(self) -> AsyncIterator[Page[T]]: - """Async-yield each `Page` in turn, honouring ``max_pages``.""" + async def _apages(self) -> AsyncGenerator[Page[T], None]: + """Fetch and parse each page lazily, one exchange per page consumed. + + Nothing is fetched until the first page is pulled, exactly one exchange + runs per page, and the walk stops at the terminal page or the + ``max_pages`` cap. The fetcher never closes a page it produced; a live + page is closed by the item view, the page-view release, or the caller. A + response that fails to parse — or whose parse is cancelled — never + becomes a page, so `_aparse` closes it inline. + + Yields: + Each parsed `Page`, first to last. + """ request: Request | None = self._initial count = 0 while request is not None: if self._max_pages is not None and count >= self._max_pages: return - response = await self._send(request) - page = await self._parse(response) + page = await self._aparse(await self._send(request)) count += 1 yield page request = page.next_request - async def _parse(self, response: AsyncResponse) -> Page[T]: - raw = await response.body.string() if response.body is not None else None - payload = _decode_for(raw, response.request) - return self._strategy.parse(response, payload, response.request) + def by_page(self) -> _AsyncPageView[T]: + """Return the single-use, async-context-managed page view. + + The view async-yields each `Page` in turn, honouring ``max_pages``. + Because each page owns a live response, the view is single-use — a + second call raises — and is an async context manager: on exit (normal, + early ``break``, an exception, or a cancellation) the page still held is + closed so no response leaks. + + Returns: + The page view. Iterate it within an ``async with`` block; close each + page (``await page.aclose()`` or ``async with page``) to release its + connection. + + Raises: + RuntimeError: If a page view was already taken from this paginator. + """ + if self._page_view_taken: + raise RuntimeError( + "by_page() is single-use; build a fresh AsyncPaginator for another page walk", + ) + self._page_view_taken = True + return _AsyncPageView(self._apages()) + + async def _aparse(self, response: AsyncResponse) -> Page[T]: + """Decode and parse ``response`` into a `Page`, closing it on failure. + + On success the page owns the response and closes it later. On any + failure reading, decoding, or parsing — or an ``asyncio.CancelledError`` + raised mid-parse — the response, which never became a page, is closed + inline, with the primary error kept primary and any ordinary close-time + error suppressed onto its ``__context__``. The shielded close releases + the transport handle even while a cancellation unwinds. + """ + try: + raw = await response.body.string() if response.body is not None else None + payload = _decode_for(raw, response.request) + return self._strategy.parse(response, payload, response.request) + except BaseException as parse_error: + await _aclose_suppressing(response, parse_error) + raise def __aiter__(self) -> AsyncIterator[T]: + """Iterate items across all pages, eager-closing each page first. + + Each page's response is closed before its items are yielded, so items + reach the caller already detached from any live connection. A fresh + ``aiter`` restarts pagination from the initial request, so two async + iterations perform two independent walks with their own exchanges. + + Yields: + Items in server order across every page. + """ return self._iterate_items() async def _iterate_items(self) -> AsyncIterator[T]: - async for page in self.by_page(): - async with page: + async with aclosing(self._apages()) as pages: + async for page in pages: + await page.aclose() for item in page.items: yield item diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/strategy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/strategy.py index 8e2d029..27fb950 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/strategy.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pagination/strategy.py @@ -28,7 +28,8 @@ from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable from urllib.parse import urljoin -from ..http.common.url import Url +from ..http.common._query_codec import splice_query +from ..http.common.url import QueryParams, Url from .link_header import parse_link_header from .page import Page @@ -97,9 +98,26 @@ def _items_at[T](payload: object, path: Sequence[str]) -> list[T]: def _with_query_param(request: Request, name: str, value: str) -> Request: - """Return ``request`` with query parameter ``name`` set to ``value``.""" + """Return ``request`` with query parameter ``name`` set to ``value``. + + The rewrite is a wire-exact raw-query splice: the current query is spliced + at the token level (``splice_query``) so every untouched parameter is + preserved verbatim and only the new pair is freshly encoded — never a lossy + ``parse_qs`` / ``urlencode`` round-trip. The first occurrence of ``name`` is + replaced in place (or the pair appended when absent), and all other URL + components are left untouched. + + Args: + request: The template request whose URL query is rewritten. + name: The query parameter name to set. + value: The query parameter value to set. + + Returns: + A new request whose URL carries the spliced query. + """ url = request.url - return request.with_url(url.with_query(url.query.with_set(name, value))) + spliced = splice_query(url.query.encode(), name, value) + return request.with_url(url.with_query(QueryParams.parse(spliced))) def _rel_targets(header: str) -> dict[str, str]: @@ -296,10 +314,31 @@ def parse( @staticmethod def _request_for(target: str | None, template: Request) -> Request | None: + """Resolve a link target to a next/prev request, or ``None`` to end. + + A relative or query-only target is resolved against the template URL + (so the base path survives a ```` reference). A malformed or + unparseable target ends pagination — it returns ``None`` rather than + raising, so one bad ``Link`` value never aborts a walk that has already + yielded good pages. + + Args: + target: The raw link target URI, or ``None`` when the relation is + absent. + template: The request to derive method, headers, and body from. + + Returns: + The request pointing at ``target``, or ``None`` when the target is + absent or unparseable. + """ if target is None: return None absolute = urljoin(str(template.url), target) - return template.with_url(Url.parse(absolute)) + try: + resolved = Url.parse(absolute) + except ValueError: + return None + return template.with_url(resolved) if TYPE_CHECKING: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_async_recovery.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_async_recovery.py new file mode 100644 index 0000000..2573a10 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_async_recovery.py @@ -0,0 +1,216 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Asynchronous recovery fold — the async twin of ``_recovery``. + +Mirrors the synchronous fold's conversion, close, and non-collapse rules for +``AsyncResponse`` outcomes. The sharp edge is the same and matters more here: +``asyncio.CancelledError`` is a ``BaseException`` raised into a coroutine when a +task is cancelled, so an ``except Exception`` fold is structurally immune and a +cancellation is never converted into a ``Failure`` — it propagates unconverted, +with any held response closed first (the async ``close`` shields itself so the +transport handle is released even while the cancellation unwinds). +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from typing import TYPE_CHECKING, Final + +from ..errors import HttpResponseError +from ..http.response.async_response_body import AsyncResponseBody +from ._outcome import Failure, Outcome, Success + +if TYPE_CHECKING: + from ..http.response.async_response import AsyncResponse + +#: Cap, in bytes, on the buffered error-body preview. Mirrors the synchronous +#: fold's cap so both drivers bound error-body memory identically. +_ERROR_BODY_CAP: Final[int] = 1 << 20 # 1 MiB + + +async def aproduce(source: Callable[[], Awaitable[AsyncResponse]]) -> Outcome[AsyncResponse]: + """Await a response-producing coroutine, folding an ``Exception`` into a ``Failure``. + + A ``BaseException`` (``asyncio.CancelledError``, ``KeyboardInterrupt``) + propagates unconverted. + + Args: + source: Zero-arg callable returning the response coroutine. + + Returns: + A ``Success`` wrapping the response, or a ``Failure`` wrapping a raised + ``Exception``. + """ + + async def thunk() -> Outcome[AsyncResponse]: + return Success(await source()) + + return await _afold(thunk, held=None) + + +async def amap_success( + outcome: Outcome[AsyncResponse], + step: Callable[[AsyncResponse], Awaitable[AsyncResponse]], +) -> Outcome[AsyncResponse]: + """Apply a response-side coroutine to a ``Success``; pass a ``Failure`` through. + + Runs only on a ``Success``. If ``step`` raises an ``Exception`` the held + response is closed before folding into a ``Failure``; a ``BaseException`` + still closes the held response, then propagates unconverted. + + Args: + outcome: The current outcome. + step: Response-side coroutine transform. + + Returns: + The transformed ``Success``, the folded ``Failure``, or the untouched + input ``Failure``. + """ + if isinstance(outcome, Failure): + return outcome + response = outcome.value + + async def thunk() -> Outcome[AsyncResponse]: + return Success(await step(response)) + + return await _afold(thunk, held=response) + + +async def arecover( + outcome: Outcome[AsyncResponse], + cleanup: Callable[[Outcome[AsyncResponse]], Awaitable[Outcome[AsyncResponse]]], +) -> Outcome[AsyncResponse]: + """Run a recovery/cleanup coroutine against any outcome. + + Runs whether the outcome is a ``Success`` or a ``Failure``. An ``Exception`` + folds into a ``Failure`` (closing a held ``Success`` response first); a + ``BaseException`` propagates unconverted. + + Args: + outcome: The current outcome. + cleanup: Coroutine run against the outcome; may transform it. + + Returns: + The cleanup's outcome, or a folded ``Failure``. + """ + held = outcome.value if isinstance(outcome, Success) else None + + async def thunk() -> Outcome[AsyncResponse]: + return await cleanup(outcome) + + return await _afold(thunk, held=held) + + +async def afrom_status( + response: AsyncResponse, + *, + error_map: Mapping[int, type[HttpResponseError]] | None = None, + max_body_bytes: int = _ERROR_BODY_CAP, +) -> Outcome[AsyncResponse]: + """Map an async response's status band to an outcome. + + Only HTTP 400-599 is an error status; such a response folds into a + ``Failure`` carrying an ``HttpResponseError`` whose body is buffered up to + ``max_body_bytes`` inside a scope that still guarantees the response closes. + Any other status yields a ``Success`` carrying the still-open response. + + Args: + response: The response to classify. + error_map: Optional status-code to exception-subclass mapping. + max_body_bytes: Cap on the buffered error-body preview. + + Returns: + A ``Success`` for a non-error status, else a ``Failure``. + """ + if not (400 <= int(response.status) < 600): + return Success(response) + return Failure(await _ato_http_error(response, error_map, max_body_bytes)) + + +async def _afold( + thunk: Callable[[], Awaitable[Outcome[AsyncResponse]]], + held: AsyncResponse | None, +) -> Outcome[AsyncResponse]: + """Await ``thunk``, folding an ``Exception`` into a ``Failure``. + + A ``BaseException`` propagates unconverted. On any failure a ``held`` open + response is closed first, with a close failure chained onto the exception. + """ + try: + return await thunk() + except Exception as exc: + if held is not None: + await _aclose_and_chain(held, exc) + return Failure(exc) + except BaseException as exc: + if held is not None: + await _aclose_and_chain(held, exc) + raise + + +async def _aclose_and_chain(response: AsyncResponse, primary: BaseException) -> None: + """Close ``response``, chaining any close failure onto ``primary`` as a note.""" + try: + await response.close() + except Exception as close_error: # a close failure must not mask the primary + primary.add_note(f"response close during error handling failed: {close_error!r}") + + +async def _ato_http_error( + response: AsyncResponse, + error_map: Mapping[int, type[HttpResponseError]] | None, + max_body_bytes: int, +) -> HttpResponseError: + """Build the ``HttpResponseError`` for an error-status async response.""" + buffered, read_error = await _abuffer_error_response(response, max_body_bytes) + error: HttpResponseError = HttpResponseError(response=buffered) + if error_map: + mapped = error_map.get(int(buffered.status)) + if mapped is not None: + error = mapped(response=buffered) + if read_error is not None: + error.add_note(f"error response body buffering failed: {read_error!r}") + return error + + +async def _abuffer_error_response( + response: AsyncResponse, + max_body_bytes: int, +) -> tuple[AsyncResponse, Exception | None]: + """Buffer the async error body up to the cap, guaranteeing the response closes. + + Returns the response re-bodied with the captured prefix as an in-memory + ``AsyncResponseBody`` (readable after the transport handle is released), + plus any ``Exception`` raised while reading. There is no async + ``LoggableResponseBody``, so the captured prefix is exposed on the response + body itself rather than through ``HttpResponseError.body_snapshot``, which + is sync-only. + """ + body = response.body + if body is None: + return response, None + try: + prefix, read_error = await _aread_capped(body, max_body_bytes) + finally: + await response.close() + return response.with_body(AsyncResponseBody.from_bytes(prefix)), read_error + + +async def _aread_capped(body: AsyncResponseBody, cap: int) -> tuple[bytes, Exception | None]: + """Read up to ``cap`` bytes from ``body``, retaining a partial read on failure.""" + chunks: list[bytes] = [] + total = 0 + try: + async for chunk in body.aiter_bytes(): + if total >= cap: + break + take = chunk[: cap - total] + chunks.append(take) + total += len(take) + except Exception as exc: # partial capture is retained for post-mortem + return b"".join(chunks), exc + return b"".join(chunks), None + + +__all__ = ["afrom_status", "amap_success", "aproduce", "arecover"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_async_sansio_runner.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_async_sansio_runner.py index 64fd3e7..701cd10 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_async_sansio_runner.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_async_sansio_runner.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any from ..errors import PipelineAbortedError +from ._async_recovery import _aclose_and_chain from .async_policy import AsyncPolicy if TYPE_CHECKING: @@ -72,8 +73,12 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: transformed: AsyncResponse | None = await _resolve( self._step(response, ctx.call), ) - except BaseException: - await response.close() + except BaseException as exc: + # Broad by design: fold-class ``Exception``s and cancellation-class + # ``BaseException``s (``asyncio.CancelledError``, ``KeyboardInterrupt``) + # alike must re-raise unconverted after the held response is closed. + # A close failure is chained onto ``exc`` so the original propagates. + await _aclose_and_chain(response, exc) raise if transformed is None: await response.close() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_outcome.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_outcome.py new file mode 100644 index 0000000..ac89dc4 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_outcome.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""``Success | Failure`` outcome sum type shared by the recovery folds. + +The recovery machinery models the result of running a request through the +pipeline as a two-case sum: a ``Success`` carrying an (open) response, or a +``Failure`` carrying the converted exception. Only ``Exception`` ever folds +into a ``Failure`` — a ``BaseException`` such as ``asyncio.CancelledError`` or +``KeyboardInterrupt`` is never converted (see ``_recovery`` / ``_async_recovery``). + +The sum type is I/O-free, so the sync and async folds share one definition of +the shape and of the identity-preserving ``unwrap``. Response-type specialised +folding (closing a held response, buffering an error body) lives in the +side-specific modules because those steps differ between ``Response`` and +``AsyncResponse``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Success[R]: + """Outcome carrying a produced response. + + Attributes: + value: The response the pipeline produced. Held open — the recovery + fold only closes it when a later step fails. + """ + + value: R + + +@dataclass(frozen=True, slots=True) +class Failure: + """Outcome carrying a converted exception. + + Attributes: + error: The original ``Exception`` instance that was folded in. Held by + identity so ``unwrap`` can re-raise the exact object a caller + expects — never a copy or a wrapper. + """ + + error: Exception + + +#: A pipeline result: either a produced response or a converted exception. +type Outcome[R] = Success[R] | Failure + + +def unwrap[R](outcome: Outcome[R]) -> R: + """Collapse an outcome to its response, re-raising a ``Failure``'s error. + + Identity-preserving: the exact exception instance a step raised (and that + was folded into the ``Failure``) is re-raised unchanged, with its original + traceback, so a caller's ``except`` clause receives the original object + rather than a reconstructed one. + + Args: + outcome: The outcome to collapse. + + Returns: + The response held by a ``Success``. + + Raises: + Exception: The original error held by a ``Failure``. + """ + if isinstance(outcome, Success): + return outcome.value + raise outcome.error + + +__all__ = ["Failure", "Outcome", "Success", "unwrap"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/__init__.py new file mode 100644 index 0000000..24f2b02 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure algorithm layer for the pipeline. + +Stdlib-only formula modules that carry no pipeline, transport, or error +dependencies, so both the sync and async policy twins (and error +construction) can import the single canonical definition instead of +restating it. Keeping these modules dependency-free is what lets the +import-linter layering contract place them below the pipeline runners. + +Modules: + classifier: retryability classification (status table + cause-chain walk). + backoff: exponential-backoff delay computation and jitter application. + pacing: server-supplied retry-pacing header parsing (``Retry-After`` / + ``X-RateLimit-Reset``). +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/backoff.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/backoff.py new file mode 100644 index 0000000..967470f --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/backoff.py @@ -0,0 +1,104 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure exponential-backoff calculator. + +Single source of the retry backoff schedule, shared by the sync and async +retry policies. The deterministic part (``compute_backoff``) is separated from +the stochastic part (``apply_jitter``) so the schedule can be reasoned about +and property-tested independently: ``compute_backoff`` is monotonic +non-decreasing in the attempt count (until it saturates at the cap), and +``apply_jitter`` layers randomness on top of that base delay. + +The module is stdlib-only (``random`` for the jitter RNG) and imports nothing +from the pipeline or transport layers. +""" + +from __future__ import annotations + +import random +from typing import Final + +__all__ = [ + "DEFAULT_BASE_DELAY", + "DEFAULT_MAX_DELAY", + "DEFAULT_MULTIPLIER", + "apply_jitter", + "compute_backoff", +] + +#: Default first-retry delay, in seconds, before the growth factor is applied. +DEFAULT_BASE_DELAY: Final[float] = 0.2 + +#: Default per-attempt growth factor for the exponential schedule. +DEFAULT_MULTIPLIER: Final[float] = 2.0 + +#: Default ceiling, in seconds, on a single computed backoff delay. +DEFAULT_MAX_DELAY: Final[float] = 8.0 + + +def compute_backoff( + attempt: int, + *, + base: float = DEFAULT_BASE_DELAY, + cap: float = DEFAULT_MAX_DELAY, + multiplier: float = DEFAULT_MULTIPLIER, + fixed: bool = False, +) -> float: + """Return the deterministic (pre-jitter) backoff delay for an attempt. + + The first attempt (``attempt <= 1``) waits zero seconds; from the second + attempt on the delay is ``base`` (when ``fixed``) or + ``base * multiplier ** (attempt - 1)`` (exponential), clamped to ``cap``. + For ``base >= 0`` and ``multiplier >= 1`` the result is monotonic + non-decreasing in ``attempt`` until it saturates at ``cap``. + + Args: + attempt: 1-based attempt number (the number of attempts made so far). + base: The base delay in seconds. + cap: Upper bound applied to the computed delay. + multiplier: Per-attempt growth factor for the exponential schedule. + fixed: When ``True``, every retry waits ``base`` (clamped to ``cap``) + instead of growing exponentially. + + Returns: + The non-negative backoff delay in seconds, before jitter. + """ + if attempt <= 1: + return 0.0 + delay = base if fixed else base * multiplier ** (attempt - 1) + return min(cap, delay) + + +def apply_jitter( + delay: float, + rng: random.Random, + *, + full_jitter: bool = True, + symmetric: float = 0.0, +) -> float: + """Apply randomised jitter to a computed backoff delay. + + A non-positive delay is returned unchanged (and draws no random sample), + so a zero first-retry delay stays deterministic. With ``full_jitter`` the + delay is scaled by a sample in ``[0.5, 1.0]`` (AWS's recommended scheme); + otherwise the symmetric band scales it by ``[1 - symmetric, 1 + symmetric]``, + and a ``symmetric`` of ``0`` yields the delay unchanged. + + Args: + delay: The pre-jitter delay in seconds. + rng: The random source to draw the jitter sample from. + full_jitter: When ``True``, use the ``[0.5, 1.0]`` full-jitter band. + symmetric: Symmetric fractional band applied when ``full_jitter`` is + ``False``; ``0`` disables jitter. + + Returns: + The jittered delay in seconds. + """ + if delay <= 0.0: + return 0.0 + if full_jitter: + return delay * rng.uniform(0.5, 1.0) + if symmetric == 0: + return delay + return delay * rng.uniform(1.0 - symmetric, 1.0 + symmetric) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/classifier.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/classifier.py new file mode 100644 index 0000000..970f3da --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/classifier.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure retryability classifier: status table + cause-chain walk. + +Single source of the "should this be retried?" decision, shared by the sync +and async retry policies and by ``HttpResponseError`` construction. The module +is stdlib-only and free of pipeline/error imports so those consumers import +the one definition rather than each restating it. + +Three independent surfaces: + +- ``default_status_is_retryable`` is the single canonical status classifier + behind a protocol error's baked ``retryable`` flag: 408, 429, and every 5xx + EXCEPT 501 and 505. It takes no set argument because it *is* the fixed rule; + it is deliberately broader than the retry policy's configurable gate. +- ``status_is_retryable`` answers whether an HTTP status code is retryable + against a *configurable* set of codes. Callers pass the authoritative set + (the retry policy's ``retry_on_status_codes``); ``DEFAULT_RETRYABLE_STATUS`` + is the default used when no override is supplied. That default set is a + curated subset of the canonical classifier above — the eligibility gate a + policy applies, not the "could retrying ever help?" property an error bakes. +- ``is_retryable`` walks an exception's ``__cause__`` / ``__context__`` chain + looking for anything that presents as retryable via the ``Retryable`` + structural protocol. The walk is cycle-safe: a self-referential or looping + cause chain terminates instead of spinning forever. +""" + +from __future__ import annotations + +from collections.abc import Collection +from typing import Final, Protocol, runtime_checkable + +__all__ = [ + "DEFAULT_RETRYABLE_STATUS", + "Retryable", + "default_status_is_retryable", + "is_retryable", + "status_is_retryable", +] + +#: Status codes retried by default: request timeout (408), rate limiting +#: (429), and the transient 5xx family (500/502/503/504). Permanent 5xx codes +#: — notably 501 (Not Implemented) and 505 (HTTP Version Not Supported) — are +#: excluded because retrying cannot change the outcome. This is only the +#: *default*; the retry policy's configurable ``retry_on_status_codes`` set is +#: the authority the classifier consults at call time. +DEFAULT_RETRYABLE_STATUS: Final[frozenset[int]] = frozenset({408, 429, 500, 502, 503, 504}) + +#: Non-5xx status codes the canonical classifier treats as retryable: request +#: timeout (408) and rate limiting (429). Every other retryable code is a 5xx. +_RETRYABLE_NON_SERVER_STATUS: Final[frozenset[int]] = frozenset({408, 429}) + +#: The two permanent 5xx codes excluded from the canonical retryable rule: +#: 501 (Not Implemented) and 505 (HTTP Version Not Supported). Retrying either +#: can never change the outcome, so neither is retryable. +_NON_RETRYABLE_SERVER_STATUS: Final[frozenset[int]] = frozenset({501, 505}) + + +def default_status_is_retryable(status: int | None) -> bool: + """Return whether a status is retryable under the canonical baked-flag rule. + + This is the single shared status classifier behind a protocol (status- + carrying) error's baked ``retryable`` flag: 408 (Request Timeout), 429 + (Too Many Requests), and every 5xx server error EXCEPT 501 (Not + Implemented) and 505 (HTTP Version Not Supported) are retryable; every + other status is not. It is deliberately broader than the retry policy's + configurable ``retry_on_status_codes`` gate (whose default, + ``DEFAULT_RETRYABLE_STATUS``, is a curated subset): the baked flag answers + "could retrying this class of status ever help?", while the policy gate + answers "will this policy actually retry it?". + + Args: + status: The HTTP status code, or ``None`` when no response was + captured. ``None`` is never retryable. + + Returns: + ``True`` when ``status`` is 408, 429, or a 5xx other than 501 / 505. + """ + if status is None: + return False + if status in _RETRYABLE_NON_SERVER_STATUS: + return True + return 500 <= status <= 599 and status not in _NON_RETRYABLE_SERVER_STATUS + + +@runtime_checkable +class Retryable(Protocol): + """Structural opt-in for exception retryability. + + Any object exposing a boolean ``retryable`` attribute satisfies this + protocol, so a non-SDK error can advertise whether retrying it is worth + while without the classifier having to know its concrete type. ``HttpResponseError`` + satisfies it via its status-derived ``retryable`` flag. + + Attributes: + retryable: Whether retrying the operation that raised this error might + succeed. + """ + + retryable: bool + + +def status_is_retryable(status: int | None, retryable_statuses: Collection[int]) -> bool: + """Return whether an HTTP status code is in the retryable set. + + Args: + status: The HTTP status code, or ``None`` when no response was + captured. ``None`` is never retryable. + retryable_statuses: The authoritative set of codes to treat as + retryable. Callers pass their configured set; + ``DEFAULT_RETRYABLE_STATUS`` is the out-of-the-box default. + + Returns: + ``True`` when ``status`` is present and a member of + ``retryable_statuses``. + """ + return status is not None and status in retryable_statuses + + +def is_retryable(error: BaseException) -> bool: + """Return whether an exception (or its cause chain) is retryable. + + Walks ``error`` and, transitively, its ``__cause__`` (explicit + ``raise ... from ...``) then ``__context__`` (implicit chaining) links, + returning ``True`` as soon as a node presents as retryable via the + ``Retryable`` protocol. The walk tracks visited exceptions by identity, so + a self-referential or cyclic chain terminates rather than looping forever. + + Args: + error: The exception to classify. + + Returns: + ``True`` when ``error`` or any exception reachable through its cause + chain is a ``Retryable`` with a truthy ``retryable`` flag. + """ + seen: set[int] = set() + current: BaseException | None = error + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, Retryable) and current.retryable: + return True + current = current.__cause__ or current.__context__ + return False diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/pacing.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/pacing.py new file mode 100644 index 0000000..0fe0127 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_pure/pacing.py @@ -0,0 +1,213 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Pure parser for server-supplied retry-pacing headers. + +The single, shared home of the ``Retry-After``, ``retry-after-ms`` / +``x-ms-retry-after-ms``, and ``X-RateLimit-Reset`` parsing the sync and async +retry policies both consume. The module is stdlib-only apart from the shared +RFC 1123 date helper in ``util.http_date`` and carries no pipeline, transport, +or error dependencies, so it can sit below the pipeline runners in the +import-linter layering. + +The policy composes these parsers in a fixed precedence (first usable wins): +``Retry-After`` delta-seconds, then ``Retry-After`` as an HTTP-date, then the +``retry-after-ms`` millisecond-delta, then the Microsoft/Azure +``x-ms-retry-after-ms`` millisecond-delta, then ``X-RateLimit-Reset`` as an +absolute Unix-epoch-seconds instant. + +Two properties are load-bearing: + +* **Total.** Every parser returns ``None`` on a missing or malformed value and + never raises, for any string input. Callers treat ``None`` as "no server + signal, fall back to the computed backoff". +* **Strict decimal grammar screened before ``float()``.** A delta-seconds or + epoch token must be bare ASCII digits with at most one optional fractional + part (RFC 7231 ``delta-seconds`` is ``1*DIGIT``). The screen runs *before* + any ``float()`` call because ``float()`` is far more permissive than the + grammar: it accepts ``nan``, ``inf``, underscore-grouped ``1_000``, a leading + ``+``/``-`` sign, scientific ``1e3``, hex floats, and surrounding non-ASCII + whitespace. An unscreened ``float()`` would silently admit every one of + those; the pre-screen rejects them and yields ``None``. + +A resolved delay is clamped to a 365-day sanity ceiling so an absurd or hostile +hint (a delta or HTTP-date far in the future) is capped rather than obeyed +literally. An HTTP-date already in the past floors to ``0`` (retry immediately) +— distinct from a malformed value, which yields ``None``. +""" + +from __future__ import annotations + +import re +from typing import Final + +from ...util.http_date import parse_http_date + +__all__ = [ + "MAX_RETRY_AFTER_SECONDS", + "parse_rate_limit_reset", + "parse_retry_after", + "parse_retry_after_ms", +] + +#: Sanity ceiling, in seconds, on a resolved pacing delay (365 days). A hint +#: beyond this is capped, not obeyed literally, so a buggy or hostile header +#: cannot make the client sleep for years. +MAX_RETRY_AFTER_SECONDS: Final = 365.0 * 24 * 60 * 60 + +#: Strict ``delta-seconds`` / epoch grammar: one run of ASCII digits with at +#: most one optional fractional part, optionally wrapped in ASCII spaces or +#: tabs. Anchored with ``\A`` / ``\Z`` (not ``$``) so a trailing newline cannot +#: sneak past, and ``[0-9]`` (not ``\d``) so Unicode digits are rejected. +_DECIMAL_TOKEN: Final = re.compile(r"\A[ \t]*[0-9]+(?:\.[0-9]+)?[ \t]*\Z") + +#: Strict integer-milliseconds grammar for the ``retry-after-ms`` / +#: ``x-ms-retry-after-ms`` headers: one run of ASCII digits (no fractional +#: part — these headers carry whole milliseconds), optionally wrapped in ASCII +#: spaces or tabs. Anchored and ASCII-only for the same reasons as +#: ``_DECIMAL_TOKEN``. +_INTEGER_TOKEN: Final = re.compile(r"\A[ \t]*[0-9]+[ \t]*\Z") + + +def _screen_decimal(value: str) -> float | None: + """Screen ``value`` against the strict decimal grammar, then ``float()`` it. + + The regex runs before ``float()`` on purpose: only bare ASCII digits with an + optional single fractional part survive, so the follow-up ``float()`` on + CPython always yields a finite value (or ``inf`` for an astronomically large + magnitude, which the caller's clamp then bounds) and never raises. + + Args: + value: The raw token to screen. + + Returns: + The parsed non-negative seconds, or ``None`` when ``value`` is not a + bare decimal token. + """ + if not _DECIMAL_TOKEN.match(value): + return None + return float(value) + + +def _http_date_delay(text: str, now: float) -> float | None: + """Convert an HTTP-date to a non-negative delay relative to ``now``. + + Delegates the tolerant RFC 1123 / RFC 850 / asctime parse to the shared + ``util.http_date.parse_http_date`` so the date grammar lives in one place. + + Args: + text: The candidate HTTP-date string (already stripped). + now: Current wall-clock time in seconds since the epoch. + + Returns: + Seconds until the parsed instant, floored at ``0`` when it is already in + the past, or ``None`` when ``text`` is not a recognisable HTTP-date. + """ + when = parse_http_date(text) + if when is None: + return None + return max(0.0, when.timestamp() - now) + + +def parse_retry_after( + value: str | None, + now: float, + *, + max_delay: float = MAX_RETRY_AFTER_SECONDS, +) -> float | None: + """Parse a ``Retry-After`` header value (delta-seconds or HTTP-date). + + A bare decimal token is read as delta-seconds; otherwise the value is tried + as a tolerant HTTP-date and converted to a delay relative to ``now``. The + resolved delay is clamped to ``max_delay``. A past HTTP-date floors to ``0`` + (retry immediately), which is distinct from a malformed value (``None``). + + Args: + value: Raw header value. ``None``, empty, or whitespace-only yields + ``None``. + now: Current wall-clock time, in seconds since the epoch, used to + convert an HTTP-date into a delay. Injected so the result is + deterministic in tests. + max_delay: Ceiling on the resolved delay in seconds. Defaults to the + 365-day sanity clamp; pass ``math.inf`` to disable clamping. + + Returns: + Seconds to wait (``0 <= result <= max_delay``), or ``None`` when + ``value`` is missing or unparseable. Never raises. + """ + if value is None or not value.strip(): + return None + delay = _screen_decimal(value) + if delay is None: + delay = _http_date_delay(value.strip(), now) + if delay is None: + return None + return min(delay, max_delay) + + +def parse_retry_after_ms( + value: str | None, + *, + max_delay: float = MAX_RETRY_AFTER_SECONDS, +) -> float | None: + """Parse a millisecond-delta pacing header into a delay in seconds. + + Serves both the ``retry-after-ms`` and the Microsoft/Azure + ``x-ms-retry-after-ms`` headers, which carry a whole-millisecond delay. + The value is screened by a strict integer grammar before any ``float()`` + parse — only bare ASCII digits (optionally wrapped in ASCII spaces or + tabs) survive — so ``float()``-permissive forms (``nan``, ``inf``, ``+5``, + ``1_000``, ``1e3``, hex floats, a fractional ``1.5``) all yield ``None`` + and fall through to the next pacing signal instead of being mis-parsed. + The resolved delay is clamped to ``max_delay``. + + Args: + value: Raw header value (whole milliseconds). ``None``, empty, or a + non-integer token yields ``None``. + max_delay: Ceiling on the resolved delay in seconds. Defaults to the + 365-day sanity clamp; pass ``math.inf`` to disable clamping. + + Returns: + Seconds to wait (``0 <= result <= max_delay``), or ``None`` when the + header is missing or not a bare integer of milliseconds. Never raises. + """ + if value is None or not value.strip(): + return None + if not _INTEGER_TOKEN.match(value): + return None + return min(float(value) / 1000.0, max_delay) + + +def parse_rate_limit_reset( + value: str | None, + now: float, + *, + max_delay: float = MAX_RETRY_AFTER_SECONDS, +) -> float | None: + """Parse an ``X-RateLimit-Reset`` epoch header into a delay. + + The header carries the wall-clock second at which the rate-limit window + resets (GitHub, Stripe, Slack). The delay is the difference between that + instant and ``now``, floored at zero (a reset already in the past means + retry immediately) and clamped to ``max_delay``. The value is screened by + the same strict decimal grammar as ``Retry-After`` delta-seconds before any + ``float()`` parse. + + Args: + value: Raw header value (epoch seconds). ``None``, empty, or a + non-decimal token yields ``None``. + now: Current wall-clock time, in seconds since the epoch, used to + compute the delta. Injected so the result is deterministic in tests. + max_delay: Ceiling on the resolved delay in seconds. Defaults to the + 365-day sanity clamp; pass ``math.inf`` to disable clamping. + + Returns: + Seconds to wait (``0 <= result <= max_delay``), or ``None`` when the + header is missing or not a plain epoch number. Never raises. + """ + if value is None or not value.strip(): + return None + epoch = _screen_decimal(value) + if epoch is None: + return None + return min(max(0.0, epoch - now), max_delay) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_recovery.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_recovery.py new file mode 100644 index 0000000..7835677 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_recovery.py @@ -0,0 +1,236 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Synchronous recovery fold — convert step exceptions into a ``Success | Failure``. + +The fold applies the spec's conversion, close, and non-collapse rules: + +- **Conversion.** ``produce`` runs a response-producing callable (a request-side + step or the transport) and folds any ``Exception`` into a ``Failure``. + ``map_success`` runs a response-side step only when the current outcome is a + ``Success``; ``recover`` runs a cleanup step against every outcome. +- **Non-collapse.** A ``BaseException`` (``asyncio.CancelledError``, + ``KeyboardInterrupt``, ``SystemExit``) is a cancellation-class signal, not a + fault, and is never folded into a ``Failure`` — it propagates unconverted. +- **Close-on-error.** When a step throws while a ``Success`` (an open response) + is held, that response is closed before the exception folds or propagates. A + failure from the close itself is chained onto the original error with a note + rather than replacing it. + +Ordering (which steps run, and in what order) and retry/redirect forks are the +pipeline's and the retry policy's job; this module only owns the folding +semantics for a single settled outcome. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import TYPE_CHECKING, Final + +from ..errors import HttpResponseError +from ..http.response.loggable_response_body import LoggableResponseBody +from ..http.response.response_body import ResponseBody +from ._outcome import Failure, Outcome, Success + +if TYPE_CHECKING: + from ..http.response.response import Response + +#: Cap, in bytes, on how much of an error response body is buffered for +#: post-mortem inspection. 1 MiB is generous for a structured error payload +#: while bounding the memory a hostile or runaway error body can pin. +_ERROR_BODY_CAP: Final[int] = 1 << 20 # 1 MiB + + +def produce(source: Callable[[], Response]) -> Outcome[Response]: + """Run a response-producing callable, folding an ``Exception`` into a ``Failure``. + + Use for a request-side step or the transport itself: anything that turns the + in-flight request into a response. A ``BaseException`` propagates unconverted. + + Args: + source: Zero-arg callable returning the produced response. + + Returns: + A ``Success`` wrapping the response, or a ``Failure`` wrapping a raised + ``Exception``. + """ + return _fold(lambda: Success(source()), held=None) + + +def map_success( + outcome: Outcome[Response], + step: Callable[[Response], Response], +) -> Outcome[Response]: + """Apply a response-side step to a ``Success``; pass a ``Failure`` through. + + Response-side steps run only on a ``Success`` outcome. If the step raises an + ``Exception``, the held response is closed before the exception folds into a + new ``Failure``; if it raises a ``BaseException`` the held response is still + closed, but the exception propagates unconverted. + + Args: + outcome: The current outcome. + step: Response-side transform applied to the held response. + + Returns: + The transformed ``Success``, the folded ``Failure``, or the untouched + input ``Failure``. + """ + if isinstance(outcome, Failure): + return outcome + response = outcome.value + return _fold(lambda: Success(step(response)), held=response) + + +def recover( + outcome: Outcome[Response], + cleanup: Callable[[Outcome[Response]], Outcome[Response]], +) -> Outcome[Response]: + """Run a recovery/cleanup step against any outcome. + + Unlike a response-side step, ``cleanup`` runs whether the outcome is a + ``Success`` or a ``Failure``. If it raises an ``Exception`` the result folds + into a new ``Failure`` (closing a held ``Success`` response first); a + ``BaseException`` propagates unconverted. + + Args: + outcome: The current outcome. + cleanup: Callable run against the outcome; may transform it. + + Returns: + The cleanup's outcome, or a ``Failure`` folded from a raised + ``Exception``. + """ + held = outcome.value if isinstance(outcome, Success) else None + return _fold(lambda: cleanup(outcome), held=held) + + +def from_status( + response: Response, + *, + error_map: Mapping[int, type[HttpResponseError]] | None = None, + max_body_bytes: int = _ERROR_BODY_CAP, +) -> Outcome[Response]: + """Map a response's status band to an outcome. + + Only HTTP 400-599 is treated as an error status: a response in that band + folds into a ``Failure`` carrying an ``HttpResponseError`` (a mapped + subclass when ``error_map`` matches), with its body buffered up to + ``max_body_bytes`` for post-mortem inspection inside a scope that still + guarantees the response is closed even if the buffering itself fails. Any + other status yields a ``Success`` carrying the still-open response. + + Args: + response: The response to classify. + error_map: Optional status-code to exception-subclass mapping. + max_body_bytes: Cap on the buffered error-body preview. + + Returns: + A ``Success`` for a non-error status, else a ``Failure``. + """ + if not _is_error_status(int(response.status)): + return Success(response) + return Failure(_to_http_error(response, error_map, max_body_bytes)) + + +def _is_error_status(status: int) -> bool: + """Return whether ``status`` is in the HTTP 400-599 error band.""" + return 400 <= status < 600 + + +def _fold(thunk: Callable[[], Outcome[Response]], held: Response | None) -> Outcome[Response]: + """Run ``thunk``, folding an ``Exception`` into a ``Failure``. + + A ``BaseException`` (cancellation-class) propagates unconverted. On any + failure a ``held`` open response is closed first, with a close failure + chained onto the in-flight exception. + """ + try: + return thunk() + except Exception as exc: + if held is not None: + _close_and_chain(held, exc) + return Failure(exc) + except BaseException as exc: + if held is not None: + _close_and_chain(held, exc) + raise + + +def _close_and_chain(response: Response, primary: BaseException) -> None: + """Close ``response``, chaining any close failure onto ``primary``. + + Guarantees the held response is released before ``primary`` continues to + fold or propagate. A failure from ``close`` is attached to ``primary`` as a + note rather than replacing it, so the original error is never lost. + """ + try: + response.close() + except Exception as close_error: # a close failure must not mask the primary + primary.add_note(f"response close during error handling failed: {close_error!r}") + + +def _to_http_error( + response: Response, + error_map: Mapping[int, type[HttpResponseError]] | None, + max_body_bytes: int, +) -> HttpResponseError: + """Build the ``HttpResponseError`` for an error-status response.""" + buffered, read_error = _buffer_error_response(response, max_body_bytes) + error = _select_error(buffered, error_map) + if read_error is not None: + error.add_note(f"error response body buffering failed: {read_error!r}") + return error + + +def _select_error( + response: Response, + error_map: Mapping[int, type[HttpResponseError]] | None, +) -> HttpResponseError: + """Pick the mapped error subclass for the status, else ``HttpResponseError``.""" + if error_map: + mapped = error_map.get(int(response.status)) + if mapped is not None: + return mapped(response=response) + return HttpResponseError(response=response) + + +def _buffer_error_response( + response: Response, + max_body_bytes: int, +) -> tuple[Response, Exception | None]: + """Buffer the error body up to the cap, guaranteeing the response closes. + + Returns the response re-bodied with a repeatable-read ``LoggableResponseBody`` + holding the captured prefix, plus any ``Exception`` raised while reading (the + partial prefix is retained). A ``BaseException`` from the read propagates, + but the ``finally`` still closes the response first. + """ + body = response.body + if body is None: + return response, None + try: + prefix, read_error = _read_capped(body, max_body_bytes) + finally: + response.close() + captured = ResponseBody.from_bytes(prefix) + return response.with_body(LoggableResponseBody(captured)), read_error + + +def _read_capped(body: ResponseBody, cap: int) -> tuple[bytes, Exception | None]: + """Read up to ``cap`` bytes from ``body``, retaining a partial read on failure.""" + chunks: list[bytes] = [] + total = 0 + try: + for chunk in body.iter_bytes(): + if total >= cap: + break + take = chunk[: cap - total] + chunks.append(take) + total += len(take) + except Exception as exc: # partial capture is retained for post-mortem + return b"".join(chunks), exc + return b"".join(chunks), None + + +__all__ = ["from_status", "map_success", "produce", "recover"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_sansio_runner.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_sansio_runner.py index dae7eca..ad2109f 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_sansio_runner.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/_sansio_runner.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING from ..errors import PipelineAbortedError +from ._recovery import _close_and_chain from .policy import Policy from .step.pipeline_step import PipelineStep @@ -56,8 +57,15 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: response = self.next.send(request, ctx) try: transformed = self._step(response, ctx.call) - except BaseException: - response.close() + except BaseException as exc: + # Close the held response before the exception continues. This + # catch-all is deliberately broad: a step that raises an + # ``Exception`` folds upstream, but a ``BaseException`` — an + # ``asyncio.CancelledError`` or ``KeyboardInterrupt`` — must + # re-raise unconverted, never be swallowed here. A failure from the + # close itself is chained onto ``exc`` so the original exception, + # not the close error, is what propagates. + _close_and_chain(response, exc) raise if transformed is None: response.close() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_pipeline.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_pipeline.py index 9323695..9fa29b6 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_pipeline.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_pipeline.py @@ -103,8 +103,8 @@ async def run( finally: # Evict the call's ``ContextStore`` entry once the chain has fully # unwound — in-chain observers have already read the latest tier. - # The exchange context shares this trace id, so a single close - # clears both tiers and prevents unbounded growth across calls. + # The exchange context shares this call's store key, so a single + # close clears both tiers and prevents unbounded growth across calls. request_ctx.close() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_staged_builder.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_staged_builder.py index 9b256e9..443eaef 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_staged_builder.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_staged_builder.py @@ -3,10 +3,13 @@ """Async twin of `StagedPipelineBuilder`. -Behaviour mirrors the sync builder exactly: pillar enforcement, surgical -edits, ``from_pipeline`` round-trip. The only differences are the policy -types (``AsyncPolicy`` instead of ``Policy``) and the produced pipeline -(``AsyncPipeline`` instead of ``Pipeline``). +Behaviour mirrors the sync builder exactly: pillar enforcement (including +same-instance idempotency), surgical edits, batch adds, and the +``from_pipeline`` round-trip. The only differences are the policy types +(``AsyncPolicy`` instead of ``Policy``) and the produced pipeline +(``AsyncPipeline`` instead of ``Pipeline``). See `StagedPipelineBuilder` for +the full contract, including the ``append_all`` / ``prepend_all`` ordering +asymmetry. """ from __future__ import annotations @@ -19,14 +22,20 @@ from .stage import Stage if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from ..client.async_http_client import AsyncHttpClient class AsyncStagedPipelineBuilder: """Build an `AsyncPipeline` by stage rather than user-specified order. - See `StagedPipelineBuilder` for the full behaviour; this is the - async counterpart. + See `StagedPipelineBuilder` for the full behaviour — pillar single-occupancy + enforced on every mutation path, no relocation of shipped singletons, and + the all-or-nothing ``batch()`` transaction; this is the async counterpart. + + Thread-safety: not thread-safe. Construct on one thread, then call + `build` — the resulting `AsyncPipeline` is independent. """ __slots__ = ("_buckets", "_client", "_pillars") @@ -54,25 +63,90 @@ def prepend(self, policy: AsyncPolicy, *, force: bool = False) -> Self: self._buckets.setdefault(stage, []).insert(0, policy) return self + def append_all(self, policies: Iterable[AsyncPolicy], *, force: bool = False) -> Self: + """Append every policy, preserving iteration order. + + Appending ``[a, b, c]`` yields ``[a, b, c]`` within each stage — the + mirror image of `prepend_all`, which reverses the batch. The batch is + all-or-nothing: if any element is rejected, the builder rolls back to + its pre-call state. See `StagedPipelineBuilder.append_all`. + + Args: + policies: Policies to append, consumed in iteration order. + force: Forwarded to `append` for pillar overwrites. + + Returns: + ``self`` for chaining. + + Raises: + ValueError: If any element cannot be added; the builder is left + unchanged. + """ + with self.batch() as staged: + for policy in policies: + staged.append(policy, force=force) + return self + + def prepend_all(self, policies: Iterable[AsyncPolicy], *, force: bool = False) -> Self: + """Prepend every policy one at a time, reversing order. + + Prepending ``[a, b, c]`` yields ``[c, b, a]`` within each stage, + because each element is prepended to the head in turn. This asymmetry + with `append_all` (which preserves order) is intentional. The batch is + all-or-nothing. See `StagedPipelineBuilder.prepend_all`. + + Args: + policies: Policies to prepend, consumed in iteration order. + force: Forwarded to `prepend` for pillar overwrites. + + Returns: + ``self`` for chaining. + + Raises: + ValueError: If any element cannot be added; the builder is left + unchanged. + """ + with self.batch() as staged: + for policy in policies: + staged.prepend(policy, force=force) + return self + def replace(self, target: type[AsyncPolicy], new: AsyncPolicy) -> Self: - """Replace the first instance of ``target`` with ``new``.""" + """Replace the first instance of ``target`` with ``new``. + + Raises: + ValueError: If no instance of ``target`` exists in the builder, or + if ``target`` occupies a pillar (singleton) stage and ``new`` + declares a different stage — a shipped singleton owns its stage + and may not be relocated. + """ pillar_stage = next( (stage for stage, pillar in self._pillars.items() if isinstance(pillar, target)), None, ) if pillar_stage is not None: - # The lookup above finished iterating before we mutate ``_pillars``. + if pillar_stage != new.STAGE: + raise ValueError( + f"Cannot relocate the {pillar_stage.name} singleton: " + f"replacement {type(new).__name__} declares stage " + f"{new.STAGE.name}. A shipped singleton owns its stage; " + f"pass a replacement declared at {pillar_stage.name}." + ) + # Same-stage swap. The lookup above finished iterating before we + # mutate ``_pillars``; ``force`` overwrites the vacated slot. del self._pillars[pillar_stage] self.append(new, force=True) return self - for stage, bucket in self._buckets.items(): + for stage, bucket in list(self._buckets.items()): for i, p in enumerate(bucket): if isinstance(p, target): if stage == new.STAGE: bucket[i] = new else: - del bucket[i] + # Place ``new`` first so a rejected pillar occupancy + # leaves the incumbent intact — no half-applied swap. self.append(new) + del bucket[i] return self raise ValueError(f"No instance of {target.__name__} in the builder.") @@ -97,6 +171,27 @@ def build(self) -> AsyncPipeline: """Flatten the builder's contents into an `AsyncPipeline`.""" return AsyncPipeline(self._client, policies=self._flatten()) + @contextlib.contextmanager + def batch(self) -> Iterator[Self]: + """Apply a group of edits as an all-or-nothing transaction. + + Yields ``self`` so edits chain naturally inside the ``with`` block. If + any edit raises, the builder rolls back to its pre-batch state, so a + failing batch leaves no partial mutation visible and any pipeline built + beforehand is untouched. + + Yields: + This builder, for chaining edits inside the ``with`` block. + """ + saved_pillars = dict(self._pillars) + saved_buckets = {stage: list(bucket) for stage, bucket in self._buckets.items()} + try: + yield self + except Exception: + self._pillars = saved_pillars + self._buckets = saved_buckets + raise + @classmethod def from_pipeline(cls, pipeline: AsyncPipeline) -> Self: """Seed a builder from an existing `AsyncPipeline`. @@ -141,8 +236,13 @@ def from_pipeline(cls, pipeline: AsyncPipeline) -> Self: return builder def _install_pillar(self, policy: AsyncPolicy, stage: Stage, *, force: bool) -> None: - if stage in self._pillars and not force: - existing = type(self._pillars[stage]).__name__ + incumbent = self._pillars.get(stage) + if incumbent is policy: + # Re-installing the SAME instance is an idempotent no-op: reference + # identity, not value equality, tells 'same' from 'distinct'. + return + if incumbent is not None and not force: + existing = type(incumbent).__name__ raise ValueError( f"Pillar stage {stage.name} is already filled by {existing}. " f"Use replace({type(policy).__name__}, new) to swap, or " @@ -172,13 +272,35 @@ def _flatten(self) -> list[AsyncPolicy]: return out def _reload(self, policies: list[AsyncPolicy]) -> None: - self._pillars.clear() - self._buckets.clear() + """Rebuild the stage buckets from a flat policy list, atomically. + + Validates single-occupancy of every pillar stage across the whole list + before touching any builder state, so a list that would double-fill a + pillar raises without leaving the builder half-reloaded. + + Args: + policies: The full, ordered policy list to re-bucket. + + Raises: + ValueError: If two policies claim the same pillar stage. The error + names the stage and the incumbent policy type. + """ + pillars: dict[Stage, AsyncPolicy] = {} + buckets: dict[Stage, list[AsyncPolicy]] = {} for p in policies: - if p.STAGE.is_pillar: - self._pillars[p.STAGE] = p + stage = p.STAGE + if not stage.is_pillar: + buckets.setdefault(stage, []).append(p) + elif stage in pillars: + raise ValueError( + f"Pillar stage {stage.name} is already filled by " + f"{type(pillars[stage]).__name__}; it cannot also admit " + f"{type(p).__name__}." + ) else: - self._buckets.setdefault(p.STAGE, []).append(p) + pillars[stage] = p + self._pillars = pillars + self._buckets = buckets def _detach(policy: AsyncPolicy) -> None: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/defaults.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/defaults.py index fcc3451..8e635f0 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/defaults.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/defaults.py @@ -10,10 +10,11 @@ from .async_staged_builder import AsyncStagedPipelineBuilder from .policies.async_client_identity import AsyncClientIdentityPolicy from .policies.async_idempotency import AsyncIdempotencyPolicy +from .policies.async_logging_policy import AsyncLoggingPolicy from .policies.async_redirect import AsyncRedirectPolicy from .policies.async_retry import AsyncRetryPolicy from .policies.async_set_date import AsyncSetDatePolicy -from .policies.async_tracing_policy import AsyncOperationTracingPolicy +from .policies.async_tracing_policy import AsyncOperationTracingPolicy, AsyncTracingPolicy from .policies.client_identity import ClientIdentityPolicy from .policies.idempotency import IdempotencyPolicy from .policies.logging_policy import LoggingPolicy @@ -104,6 +105,8 @@ def default_async_pipeline( set_date: AsyncSetDatePolicy | None = None, client_identity: AsyncClientIdentityPolicy | None = None, auth: AsyncPolicy | None = None, + logging: AsyncLoggingPolicy | None = None, + tracing: AsyncTracingPolicy | None = None, ) -> AsyncStagedPipelineBuilder: """Async twin of `default_pipeline`. @@ -111,22 +114,68 @@ def default_async_pipeline( the whole operation from the outermost stage so the per-operation ``HttpTracer`` lifecycle (``operation_started`` / ``operation_succeeded`` / ``operation_failed``) fires once and reflects the final outcome — completing - the attempt-level events the async retry and redirect policies already emit - through the same per-operation tracer. The per-attempt OpenTelemetry span - policy (`TracingPolicy`) and `LoggingPolicy` ship sync-only, so the async - stack omits those two. + the attempt-level events the async retry policy already emits through the + same per-operation tracer. `AsyncLoggingPolicy` and `AsyncTracingPolicy` + then sit inside the retry wrapper (at ``LOGGING`` / ``POST_LOGGING``), so + the async stack emits the same per-attempt wire logs and per-attempt tracing + span as the sync default. + + No redirect policy by default. Unlike the sync `default_pipeline`, this + stack does NOT wire `AsyncRedirectPolicy` unless the caller passes one + explicitly. Neither pillar's redirect handling is yet certified against the + full redirect security battery — `AsyncRedirectPolicy` delegates every + per-hop decision to the same `RedirectPolicy` core the sync default wires + unconditionally, so the gaps are shared, not async-specific: cross-origin + credential handling is judged against the previous hop rather than the seed + origin and strips only ``Authorization`` (not ``Cookie`` / + ``Proxy-Authorization``); an HTTPS→HTTP downgrade is followed rather than + default-denied. (`Location` resolution IS wire-exact — the pure RFC 3986 + query codec fixed that dimension.) See `docs/deviations.md` for the full + battery and status. Gating the async twin off by default — rather than + also removing it from the sync default, which would be a bigger behavior + change — is the narrower fix while the shared core gets certified; until + then, an async caller who accepts the current limitations can opt in by + passing ``redirect=AsyncRedirectPolicy(...)``, which wires it into the + REDIRECT stage exactly as the sync default does. + + Args: + client: Terminal async HTTP transport. + redirect: Opt-in `AsyncRedirectPolicy`. ``None`` (the default) wires no + redirect policy — see the note above. Pass an instance to follow + redirects on the async path. + idempotency: Override for `AsyncIdempotencyPolicy`. ``None`` uses + defaults. + retry: Override for `AsyncRetryPolicy`. ``None`` uses defaults. + set_date: Override for `AsyncSetDatePolicy`. ``None`` uses defaults. + client_identity: Override for `AsyncClientIdentityPolicy`. ``None`` + uses defaults. + auth: Optional async authentication policy. No default — requests pass + without authentication when this is ``None``. + logging: Override for `AsyncLoggingPolicy`. ``None`` uses defaults. + tracing: Override for `AsyncTracingPolicy`. ``None`` uses defaults. + + Returns: + An `AsyncStagedPipelineBuilder` ready for further customisation or + immediate ``.build()``. """ builder = AsyncStagedPipelineBuilder(client) # Sorts to Stage.OPERATION (outermost), bracketing every hop / attempt so # the per-operation lifecycle fires once on the final outcome. builder.append(AsyncOperationTracingPolicy()) - builder.append(redirect or AsyncRedirectPolicy()) + # Gated: the async redirect twin is only wired when the caller opts in. + # See the note above — it is not certified against the full redirect battery. + if redirect is not None: + builder.append(redirect) builder.append(idempotency or AsyncIdempotencyPolicy()) builder.append(retry or AsyncRetryPolicy()) builder.append(set_date or AsyncSetDatePolicy()) builder.append(client_identity or AsyncClientIdentityPolicy()) if auth is not None: builder.append(auth) + # Sit inside the retry wrapper (LOGGING / POST_LOGGING) so wire logs and the + # tracing span are emitted per attempt, matching the sync default's slots. + builder.append(logging or AsyncLoggingPolicy()) + builder.append(tracing or AsyncTracingPolicy()) return builder diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/pipeline.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/pipeline.py index 4a481e5..13729f7 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/pipeline.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/pipeline.py @@ -127,8 +127,8 @@ def run( finally: # Evict the call's ``ContextStore`` entry once the chain has fully # unwound — in-chain observers have already read the latest tier. - # The exchange context shares this trace id, so a single close - # clears both tiers and prevents unbounded growth across calls. + # The exchange context shares this call's store key, so a single + # close clears both tiers and prevents unbounded growth across calls. request_ctx.close() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/__init__.py index d6d70e4..c2fba0a 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/__init__.py @@ -8,26 +8,31 @@ from ._history import RequestHistory from .async_client_identity import AsyncClientIdentityPolicy from .async_idempotency import AsyncIdempotencyPolicy +from .async_logging_policy import AsyncLoggingPolicy from .async_redirect import AsyncRedirectPolicy from .async_retry import AsyncRetryPolicy from .async_set_date import AsyncSetDatePolicy -from .async_tracing_policy import AsyncOperationTracingPolicy +from .async_tracing_policy import AsyncOperationTracingPolicy, AsyncTracingPolicy from .client_identity import ClientIdentityPolicy, default_user_agent from .idempotency import IdempotencyPolicy -from .logging_policy import LoggingPolicy +from .logging_policy import DEFAULT_PREVIEW_BYTES, HttpLogDetailLevel, LoggingPolicy from .redirect import RedirectPolicy from .retry import RetryMode, RetryPolicy from .set_date import SetDatePolicy from .tracing_policy import OperationTracingPolicy, TracingPolicy __all__ = [ + "DEFAULT_PREVIEW_BYTES", "AsyncClientIdentityPolicy", "AsyncIdempotencyPolicy", + "AsyncLoggingPolicy", "AsyncOperationTracingPolicy", "AsyncRedirectPolicy", "AsyncRetryPolicy", "AsyncSetDatePolicy", + "AsyncTracingPolicy", "ClientIdentityPolicy", + "HttpLogDetailLevel", "IdempotencyPolicy", "LoggingPolicy", "OperationTracingPolicy", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_logging_policy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_logging_policy.py new file mode 100644 index 0000000..5bc8a5a --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_logging_policy.py @@ -0,0 +1,195 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Async twin of ``LoggingPolicy`` that emits structured wire logs.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from ...http.request.loggable_request_body import LoggableRequestBody +from ...http.response.async_loggable_response_body import AsyncLoggableResponseBody +from ...instrumentation import ClientLogger, HeaderRedactor, UrlRedactor +from ..async_policy import AsyncPolicy +from ..stage import Stage +from .logging_policy import ( + DEFAULT_PREVIEW_BYTES, + HttpLogDetailLevel, + _error_fields, + _preview_text, + _request_fields, + _response_fields, +) + +if TYPE_CHECKING: + from ...http.common.headers import Headers + from ...http.request.request import Request + from ...http.response.async_response import AsyncResponse + from ..context import PipelineContext + +_REQUEST_EVENT = "http.request" +_RESPONSE_EVENT = "http.response" + + +class AsyncLoggingPolicy(AsyncPolicy): + """Async twin of `LoggingPolicy`: one structured log per request/response. + + Emits the same event model and field vocabulary as the sync policy — + ``http.request`` before the send, ``http.response`` on success, and an + ``http.response`` event carrying ``error.type`` on failure — so log + consumers see one naming across both pillars. The URL is redacted through + the shared ``UrlRedactor`` and header values through the shared + ``HeaderRedactor``, so the sync and async redaction rules cannot drift. + + Body previews are bounded at ``preview_bytes`` (default 8 KiB) and never + truncate what flows onward: + + - The request body (a sync ``RequestBody`` even on the async pillar) is + tapped with ``LoggableRequestBody`` and surfaced as + ``request_body_preview``, exactly as the sync policy does. + - The response body (an ``AsyncResponseBody``) is captured through the + ``AsyncLoggableResponseBody`` two-regime drain and surfaced as + ``response_body_preview`` — but ONLY when the response declares a content + length. A body of unknown size is passed through untouched and left + un-previewed, since it cannot be safely bound-previewed. + + The ``detail_level`` selects granularity exactly as the sync policy does: + ``NONE`` emits nothing, ``HEADERS`` emits events and header fields with no + body capture, ``BODY`` adds the body previews. Disable per-call by setting + ``ctx.options["logging_enabled"] = False``. + + Attributes: + detail_level: The logging granularity (``NONE`` / ``HEADERS`` / ``BODY``). + preview_bytes: Byte ceiling on each captured body preview. + """ + + STAGE = Stage.LOGGING + __slots__ = ("_header_redactor", "_logger", "_redactor", "detail_level", "preview_bytes") + + def __init__( + self, + *, + logger: ClientLogger | None = None, + redactor: UrlRedactor | None = None, + header_redactor: HeaderRedactor | None = None, + detail_level: HttpLogDetailLevel = HttpLogDetailLevel.NONE, + preview_bytes: int = DEFAULT_PREVIEW_BYTES, + ) -> None: + """Configure the policy. + + Args: + logger: Structured logger. Defaults to one named + ``dexpace.sdk.core.http`` (the same name the sync policy uses, + so both pillars log under one logger). + redactor: URL redactor. Defaults to a stock ``UrlRedactor``. + header_redactor: Header-name allow-list + URL-valued-header redactor, + shared with the sync policy. Defaults to a stock + ``HeaderRedactor``. + detail_level: Logging granularity. Defaults to ``NONE`` (no + request/response log events until the caller opts into + ``HEADERS`` or ``BODY``); span and metrics emission are + unaffected by the level. + preview_bytes: Byte ceiling on each body preview. + + Raises: + ValueError: If ``preview_bytes`` is non-positive. + """ + if preview_bytes <= 0: + raise ValueError(f"preview_bytes must be positive, got {preview_bytes}") + self._logger = logger or ClientLogger("dexpace.sdk.core.http") + self._redactor = redactor or UrlRedactor() + self._header_redactor = header_redactor or HeaderRedactor() + self.detail_level = detail_level + self.preview_bytes = preview_bytes + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + if not self.detail_level.logs_events or not ctx.options.get("logging_enabled", True): + return await self.next.send(request, ctx) + url = self._redactor.redact(str(request.url)) + trace_id = ctx.call.instrumentation_context.trace_id.value + method = str(request.method) + tap = self._tap_request(request) if self.detail_level.logs_body else None + if tap is not None: + request = request.with_body(tap) + self._log_request(method, url, request.headers, trace_id) + started = time.monotonic() + try: + response = await self.next.send(request, ctx) + except BaseException as err: + self._log_error(method, url, err, started, trace_id) + raise + return await self._log_response(method, url, response, started, trace_id, tap) + + def _tap_request(self, request: Request) -> LoggableRequestBody | None: + """Wrap the request body so a bounded preview is captured mid-send. + + Returns ``None`` when the request has no body. An already-wrapped body + is reused rather than nested, so a retried send does not stack taps. + """ + body = request.body + if body is None: + return None + if isinstance(body, LoggableRequestBody): + return body + return LoggableRequestBody(body, max_capture_bytes=self.preview_bytes) + + def _tap_response(self, response: AsyncResponse) -> AsyncLoggableResponseBody | None: + """Wrap the response body for capture, skipping undeclared-length bodies. + + Returns ``None`` when there is no body or its content length is unknown + (``-1``): a body whose size is not declared cannot be safely + bound-previewed, so it is streamed to the caller untouched. + """ + body = response.body + if body is None or body.content_length() < 0: + return None + if isinstance(body, AsyncLoggableResponseBody): + return body + return AsyncLoggableResponseBody(body, max_capture_bytes=self.preview_bytes) + + def _log_request(self, method: str, url: str, headers: Headers, trace_id: str) -> None: + fields = _request_fields(method, url, headers, self._header_redactor) + fields["trace_id"] = trace_id + self._logger.info(_REQUEST_EVENT, **fields) + + def _log_error( + self, method: str, url: str, err: BaseException, started: float, trace_id: str + ) -> None: + elapsed_ms = int((time.monotonic() - started) * 1000) + fields = _error_fields(method, url, elapsed_ms, err) + fields["trace_id"] = trace_id + self._logger.error(_RESPONSE_EVENT, **fields) + + async def _log_response( + self, + method: str, + url: str, + response: AsyncResponse, + started: float, + trace_id: str, + request_tap: LoggableRequestBody | None, + ) -> AsyncResponse: + """Emit ``http.response`` with the captured previews; return the response. + + The response body may be re-wrapped for capture, so the (possibly + wrapped) response is returned for the caller to consume. + """ + elapsed_ms = int((time.monotonic() - started) * 1000) + fields = _response_fields( + method, url, int(response.status), elapsed_ms, response.headers, self._header_redactor + ) + fields["trace_id"] = trace_id + if request_tap is not None: + preview = request_tap.snapshot(self.preview_bytes) + fields["request_body_preview"] = _preview_text(preview, request_tap.media_type()) + response_tap = self._tap_response(response) if self.detail_level.logs_body else None + if response_tap is not None: + response = response.with_body(response_tap) + preview = await response_tap.snapshot(self.preview_bytes) + fields["response_body_preview"] = _preview_text(preview, response_tap.media_type()) + self._logger.info(_RESPONSE_EVENT, **fields) + return response + + +__all__ = ["AsyncLoggingPolicy"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_redirect.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_redirect.py index d60227b..bb16e78 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_redirect.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_redirect.py @@ -3,22 +3,24 @@ """Async twin of ``RedirectPolicy``. -Mirrors `RedirectPolicy` exactly — same status-code matrix, same -cross-origin credential stripping (a caller-set ``Authorization`` header is -dropped only when the reissue crosses origin), same loop guard — but -``send`` is ``async`` and operates on ``AsyncResponse``. The per-hop -decision helpers are shared via delegation to a wrapped sync -``RedirectPolicy`` instance, so the cross-origin behaviour is identical. +Mirrors `RedirectPolicy` exactly — same status-code matrix, same credential +stripping (``Authorization`` on every reissue, ``Cookie`` / +``Proxy-Authorization`` on a cross-origin hop judged against the seed origin), +same HTTPS→HTTP downgrade denial, same loop guard — but ``send`` is ``async`` +and operates on ``AsyncResponse``. The per-hop decision is shared via +delegation to a wrapped sync ``RedirectPolicy`` instance, so the behaviour is +identical on both pillars. """ from __future__ import annotations from typing import TYPE_CHECKING, ClassVar, Literal +from ...http.common.url import _origin from ...http.request.method import Method from ..async_policy import AsyncPolicy from ..stage import Stage -from .redirect import _REDIRECT_STATUSES, RedirectPolicy, resolve_http_tracer +from .redirect import RedirectPolicy, RedirectPredicate, resolve_http_tracer if TYPE_CHECKING: from ...http.request.request import Request @@ -52,18 +54,23 @@ def __init__( follow_303: bool = True, allowed_methods: frozenset[Method] = frozenset({Method.GET, Method.HEAD}), strip_authorization: bool = True, + allow_scheme_downgrade: bool = False, + should_redirect: RedirectPredicate | None = None, ) -> None: self.config = RedirectPolicy( max_hops=max_hops, follow_303=follow_303, allowed_methods=allowed_methods, strip_authorization=strip_authorization, + allow_scheme_downgrade=allow_scheme_downgrade, + should_redirect=should_redirect, ) async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: cfg = self.config tracer = resolve_http_tracer(ctx) tracer.request_url_resolved(str(request.url)) + seed_origin = _origin(request.url) visited: dict[str, None] = {str(request.url): None} hops = 0 current_request = request @@ -71,14 +78,10 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: response = await self.next.send(current_request, ctx) if hops >= cfg.max_hops: return response - status = int(response.status) - if status not in _REDIRECT_STATUSES: - return response - location = response.headers.get("Location") - if location is None or not location.strip(): - return response try: - next_request = cfg._build_next_request(current_request, status, location) + next_request = cfg._decide_next( + current_request, response, seed_origin, hops, tuple(visited) + ) except RuntimeError: # A body-preserving redirect with a non-replayable body cannot # be reissued. Close the in-hand 3xx response before the error diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_retry.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_retry.py index 2426ba9..3201cbb 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_retry.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_retry.py @@ -6,7 +6,16 @@ Shares the per-attempt classification helpers with the sync variant by delegating into the same private methods on ``RetryPolicy``. The async twin reimplements only the dispatch loop (using ``await``) and the sleep -helper (using an async sleep callable). +helper (using an async sleep callable). The two-axis re-send safety gate and +the terminal-failure trail (``_annotate_terminal_failure``) are inherited +unchanged from the sync policy, so both stacks refuse to re-send a +non-replayable body and surface the same prior-attempt trail on exhaustion. + +Cancellation on the async stack is native: ``asyncio.CancelledError`` is a +``BaseException``, so the ``except SdkError`` clause cannot catch it and a +cancelled attempt — including one parked in the backoff ``await`` — unwinds +immediately without a retry. No cooperative ``CancellationToken`` is needed +here; task cancellation is the async equivalent. Auto-buffering a single-use body for replay drains a synchronous iterator/stream, which is blocking; the async loop offloads that drain to a @@ -31,7 +40,12 @@ from ..stage import Stage from ._history import RequestHistory from .redirect import resolve_http_tracer -from .retry import RetryMode, RetryPolicy, _StatusRetryError +from .retry import ( + RetryMode, + RetryPolicy, + _annotate_terminal_failure, + _StatusRetryError, +) if TYPE_CHECKING: from ...http.request.request import Request @@ -169,6 +183,7 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: if not cfg._decrement_for_error(settings, request, err): tracer.attempt_retries_exhausted() ctx.data["retry_history"] = tuple(history) + _annotate_terminal_failure(err, history) raise ctx.data["retry_count"] = len(history) delay = cfg._delay_for(settings, None) @@ -185,12 +200,16 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: ctx.data["retry_history"] = tuple(history) return response ctx.data["retry_count"] = len(history) - delay = cfg._delay_for(settings, response) - tracer.attempt_failed(_StatusRetryError(int(response.status)), delay) - # The intermediate response is not handed back to the caller, so - # close it to release the pooled connection before sleeping. The - # return branches above keep the response open — the caller owns it. - await response.close() + status = int(response.status) + # Compute the pacing delay from the still-open response, then close + # it in a ``finally`` so the pooled connection is released even if + # the delay computation throws. The return branches above keep the + # response open — the caller owns it. + try: + delay = cfg._delay_for(settings, response) + finally: + await response.close() + tracer.attempt_failed(_StatusRetryError(status), delay) await self._sleep_bounded(delay, absolute_deadline) async def _sleep_bounded( diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_tracing_policy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_tracing_policy.py index 08b35e7..e42c068 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_tracing_policy.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/async_tracing_policy.py @@ -1,19 +1,35 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Async twin of `OperationTracingPolicy`.""" +"""Async twins of the tracing policies (`OperationTracingPolicy` / `TracingPolicy`).""" from __future__ import annotations from typing import TYPE_CHECKING, ClassVar, Literal +from ...instrumentation import ( + NOOP_HTTP_TRACER, + NOOP_TRACER, + Tracer, + UrlRedactor, + bind_correlation, +) from ..async_policy import AsyncPolicy from ..stage import Stage from .redirect import resolve_http_tracer +from .tracing_policy import ( + _OPERATION_BRACKET_KEY, + _notify_request_sent, + _set_request_attributes, + _span_id, + _trace_id, + _warn_missing_operation_bracket_once, +) if TYPE_CHECKING: from ...http.request.request import Request from ...http.response.async_response import AsyncResponse + from ...instrumentation import HttpTracer, Span from ..context import PipelineContext @@ -69,4 +85,98 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: return response -__all__ = ["AsyncOperationTracingPolicy"] +class AsyncTracingPolicy(AsyncPolicy): + """Async twin of `TracingPolicy`: a per-attempt span plus per-request events. + + Re-entered once per retry attempt / redirect hop (it sits *inside* those + wrappers), so it opens one span per attempt with the same OpenTelemetry + semantic-convention attributes the sync `TracingPolicy` sets + (``http.request.method``, redacted ``url.full``, ``server.address`` / + ``server.port``, ``http.response.status_code``, ``error.type``, + ``http.request.resend_count``). While the span is open the active + trace/span ids are bound into the correlation ``contextvars`` — which + asyncio propagates across ``await`` boundaries — so downstream log records + carry them, and the per-operation ``HttpTracer`` receives the per-request + events (``request_sent``, ``response_headers_received``, + ``response_received``). + + Span emission and the tracer callbacks are independent of the logging + policy: they fire whether or not `AsyncLoggingPolicy` is enabled. The + operation-level lifecycle is emitted by `AsyncOperationTracingPolicy`; if a + real ``HttpTracer`` is installed without that bracket, this policy logs the + same one-time warning the sync twin does. + + Disable per-call by setting ``ctx.options["tracing_enabled"] = False``. + + Attributes: + STAGE: Pinned to `Stage.POST_LOGGING` so it observes the trace id after + logging, mirroring the sync policy's slot. + """ + + STAGE: ClassVar[Literal[Stage.POST_LOGGING]] = Stage.POST_LOGGING + __slots__ = ("_redactor", "_tracer") + + def __init__( + self, + *, + tracer: Tracer | None = None, + redactor: UrlRedactor | None = None, + ) -> None: + self._tracer = tracer or NOOP_TRACER + self._redactor = redactor or UrlRedactor() + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + if not ctx.options.get("tracing_enabled", True): + return await self.next.send(request, ctx) + parent = ctx.call.instrumentation_context + http_tracer = resolve_http_tracer(ctx) + if http_tracer is not NOOP_HTTP_TRACER and _OPERATION_BRACKET_KEY not in ctx.data: + _warn_missing_operation_bracket_once() + span = self._tracer.start_span(f"HTTP {request.method}", parent=parent) + _set_request_attributes(span, request, self._redactor) + with bind_correlation(trace_id=_trace_id(span), span_id=_span_id(span)): + return await self._dispatch(request, ctx, span, http_tracer) + + async def _dispatch( + self, + request: Request, + ctx: PipelineContext, + span: Span, + http_tracer: HttpTracer, + ) -> AsyncResponse: + """Run the downstream chain, emitting per-attempt tracer events around it.""" + _notify_request_sent(http_tracer, request) + try: + with span.make_current(): + response = await self.next.send(request, ctx) + except BaseException as err: + span.set_error(type(err).__name__) + span.end(error=err) + raise + _notify_response(http_tracer, response) + span.set_attribute("http.response.status_code", int(response.status)) + retry_count = ctx.data.get("retry_count") + if isinstance(retry_count, int) and retry_count > 0: + span.set_attribute("http.request.resend_count", retry_count) + span.end() + return response + + +def _notify_response(http_tracer: HttpTracer, response: AsyncResponse) -> None: + """Emit ``response_headers_received`` then ``response_received``. + + The async twin of the sync helper of the same name: identical shape, typed + for `AsyncResponse` (whose body exposes the same ``content_length``). + """ + headers = {name: ", ".join(values) for name, values in response.headers.items()} + http_tracer.response_headers_received(int(response.status), headers) + body = response.body + if body is None: + http_tracer.response_received(0) + return + length = body.content_length() + if length >= 0: + http_tracer.response_received(length) + + +__all__ = ["AsyncOperationTracingPolicy", "AsyncTracingPolicy"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/logging_policy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/logging_policy.py index a086ff0..adc4a9d 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/logging_policy.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/logging_policy.py @@ -6,76 +6,263 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING, Final -from ...instrumentation import ClientLogger, UrlRedactor +from ...http.request.loggable_request_body import LoggableRequestBody +from ...instrumentation import ClientLogger, HeaderRedactor, UrlRedactor from ..policy import Policy from ..stage import Stage if TYPE_CHECKING: + from collections.abc import Mapping + + from ...http.common.headers import Headers + from ...http.common.media_type import MediaType from ...http.request.request import Request from ...http.response.response import Response from ..context import PipelineContext +#: Default byte ceiling for the request-body preview the logging policy +#: captures and emits. Deliberately small (8 KiB): a bounded diagnostic +#: preview, not a full-body mirror. The ``Loggable*`` body wrappers keep their +#: own large, independently configurable ``max_capture_bytes`` ceiling; the +#: logging policy simply requests this tighter bound so a large upload is never +#: copied whole into the log tap. +DEFAULT_PREVIEW_BYTES: Final[int] = 8 * 1024 + +#: Stable structured event names emitted by the logging policies. A failure +#: reuses ``http.response`` (carrying ``error.type``) so a log consumer sees one +#: response event whether the exchange succeeded or failed. +_REQUEST_EVENT: Final[str] = "http.request" +_RESPONSE_EVENT: Final[str] = "http.response" + +#: Field-key prefixes for logged request / response headers. +_REQUEST_HEADER_PREFIX: Final[str] = "http.request.header." +_RESPONSE_HEADER_PREFIX: Final[str] = "http.response.header." + + +class HttpLogDetailLevel(Enum): + """Selectable granularity for HTTP wire logging. + + ``NONE`` emits no request/response log events at all. ``HEADERS`` emits the + events with allow-listed header fields but captures no body. ``BODY`` adds a + bounded body preview on top of the headers. Span lifecycle and metric + recording are owned by the tracing policy, not this one, so they run on every + request independent of this level — ``NONE`` silences log events without + disabling tracing or metrics. + """ + + NONE = "none" + HEADERS = "headers" + BODY = "body" + + @property + def logs_events(self) -> bool: + """True when request/response log events are emitted at this level.""" + return self is not HttpLogDetailLevel.NONE + + @property + def logs_body(self) -> bool: + """True only at ``BODY`` level, where a body preview is captured.""" + return self is HttpLogDetailLevel.BODY + class LoggingPolicy(Policy): """Emit one structured log line per request and per response. - Logged fields include the HTTP method, redacted URL, response status, - duration in milliseconds, and the current trace id. The URL is run - through ``UrlRedactor`` to strip userinfo and sensitive query - parameters. + Emitted fields use a stable vocabulary: ``http.request.method``, + ``url.full`` (always the redacted URL), ``http.response.status_code``, + ``http.response.duration_ms``, allow-listed request/response header fields, + and — at ``BODY`` level — a bounded ``request_body_preview``. A failed + exchange emits an ``http.response`` event carrying ``error.type`` and the + exception cause. The URL is run through ``UrlRedactor`` to strip userinfo + and sensitive query parameters; URL-valued response headers (``Location`` / + ``Content-Location``) are redacted through the shared ``HeaderRedactor``. + + The ``detail_level`` selects granularity: ``NONE`` emits nothing (tracing + and metrics still run via the tracing policy), ``HEADERS`` emits events and + header fields with no body capture, ``BODY`` adds the body preview. It + defaults to ``NONE`` (no request/response log events until the caller opts + into ``HEADERS`` or ``BODY``); span and metrics emission are unaffected by + the level. Disable per-call by setting ``ctx.options["logging_enabled"] = False``. + + Attributes: + detail_level: The logging granularity (``NONE`` / ``HEADERS`` / ``BODY``). + preview_bytes: Byte ceiling on the captured request-body preview. """ STAGE = Stage.LOGGING - __slots__ = ("_logger", "_redactor") + __slots__ = ("_header_redactor", "_logger", "_redactor", "detail_level", "preview_bytes") def __init__( self, *, logger: ClientLogger | None = None, redactor: UrlRedactor | None = None, + header_redactor: HeaderRedactor | None = None, + detail_level: HttpLogDetailLevel = HttpLogDetailLevel.NONE, + preview_bytes: int = DEFAULT_PREVIEW_BYTES, ) -> None: + """Configure the policy. + + Args: + logger: Structured logger. Defaults to one named + ``dexpace.sdk.core.http``. + redactor: URL redactor. Defaults to a stock ``UrlRedactor``. + header_redactor: Header-name allow-list + URL-valued-header redactor, + shared with the async policy. Defaults to a stock + ``HeaderRedactor``. + detail_level: Logging granularity. Defaults to ``NONE`` (no + request/response log events until the caller opts into + ``HEADERS`` or ``BODY``); span and metrics emission are + unaffected by the level. + preview_bytes: Byte ceiling on the request-body preview. + + Raises: + ValueError: If ``preview_bytes`` is non-positive. + """ + if preview_bytes <= 0: + raise ValueError(f"preview_bytes must be positive, got {preview_bytes}") self._logger = logger or ClientLogger("dexpace.sdk.core.http") self._redactor = redactor or UrlRedactor() + self._header_redactor = header_redactor or HeaderRedactor() + self.detail_level = detail_level + self.preview_bytes = preview_bytes def send(self, request: Request, ctx: PipelineContext) -> Response: - if not ctx.options.get("logging_enabled", True): + if not self.detail_level.logs_events or not ctx.options.get("logging_enabled", True): return self.next.send(request, ctx) url = self._redactor.redact(str(request.url)) trace_id = ctx.call.instrumentation_context.trace_id.value - self._logger.info( - "http.request", - method=str(request.method), - url=url, - trace_id=trace_id, - ) + method = str(request.method) + tap = self._tap_body(request) if self.detail_level.logs_body else None + if tap is not None: + request = request.with_body(tap) + self._log_request(method, url, request.headers, trace_id) started = time.monotonic() try: response = self.next.send(request, ctx) except BaseException as err: - elapsed_ms = int((time.monotonic() - started) * 1000) - self._logger.error( - "http.error", - method=str(request.method), - url=url, - error_type=type(err).__name__, - duration_ms=elapsed_ms, - trace_id=trace_id, - ) + self._log_error(method, url, err, started, trace_id) raise + self._log_response(method, url, response, started, trace_id, tap) + return response + + def _tap_body(self, request: Request) -> LoggableRequestBody | None: + """Wrap the request body so a bounded preview is captured mid-send. + + Returns ``None`` when the request has no body. An already-wrapped body + is reused rather than nested, so a retried / redirected send does not + stack taps. + """ + body = request.body + if body is None: + return None + if isinstance(body, LoggableRequestBody): + return body + return LoggableRequestBody(body, max_capture_bytes=self.preview_bytes) + + def _log_request(self, method: str, url: str, headers: Headers, trace_id: str) -> None: + fields = _request_fields(method, url, headers, self._header_redactor) + fields["trace_id"] = trace_id + self._logger.info(_REQUEST_EVENT, **fields) + + def _log_error( + self, method: str, url: str, err: BaseException, started: float, trace_id: str + ) -> None: + elapsed_ms = int((time.monotonic() - started) * 1000) + fields = _error_fields(method, url, elapsed_ms, err) + fields["trace_id"] = trace_id + self._logger.error(_RESPONSE_EVENT, **fields) + + def _log_response( + self, + method: str, + url: str, + response: Response, + started: float, + trace_id: str, + tap: LoggableRequestBody | None, + ) -> None: elapsed_ms = int((time.monotonic() - started) * 1000) - self._logger.info( - "http.response", - method=str(request.method), - url=url, - status=int(response.status), - duration_ms=elapsed_ms, - trace_id=trace_id, + fields = _response_fields( + method, url, int(response.status), elapsed_ms, response.headers, self._header_redactor ) - return response + fields["trace_id"] = trace_id + if tap is not None: + # Bound the preview at ``preview_bytes`` even when the tap captured + # more (a caller-supplied wrapper may carry a larger ceiling): the + # logging preview is a small, fixed diagnostic slice regardless. + preview = tap.snapshot(self.preview_bytes) + fields["request_body_preview"] = _preview_text(preview, tap.media_type()) + self._logger.info(_RESPONSE_EVENT, **fields) + + +def _header_fields(prefix: str, redacted: Mapping[str, str]) -> dict[str, object]: + """Prefix redacted ``name -> value`` header pairs into log field keys.""" + return {f"{prefix}{name}": value for name, value in redacted.items()} + + +def _request_fields( + method: str, url: str, headers: Headers, header_redactor: HeaderRedactor +) -> dict[str, object]: + """Build the stable ``http.request`` field set (method, url, headers).""" + fields: dict[str, object] = {"http.request.method": method, "url.full": url} + fields.update(_header_fields(_REQUEST_HEADER_PREFIX, header_redactor.redact(headers))) + return fields + + +def _response_fields( + method: str, + url: str, + status_code: int, + duration_ms: int, + headers: Headers, + header_redactor: HeaderRedactor, +) -> dict[str, object]: + """Build the stable ``http.response`` field set (excluding the body preview).""" + fields: dict[str, object] = { + "http.request.method": method, + "url.full": url, + "http.response.status_code": status_code, + "http.response.duration_ms": duration_ms, + } + fields.update(_header_fields(_RESPONSE_HEADER_PREFIX, header_redactor.redact(headers))) + return fields + + +def _error_fields(method: str, url: str, duration_ms: int, err: BaseException) -> dict[str, object]: + """Build the failure ``http.response`` field set with the throwable cause.""" + return { + "http.request.method": method, + "url.full": url, + "http.response.duration_ms": duration_ms, + "error.type": type(err).__name__, + "error.message": err, + } + + +def _preview_text(data: bytes, media_type: MediaType | None) -> str: + """Render captured body bytes as a log-safe preview string. + + Decodes with the body's declared charset when present and recognised, + otherwise UTF-8. Invalid or binary byte sequences are replaced with + U+FFFD instead of raising, so the logging path never fails on a binary or + mislabelled payload. + + Args: + data: The captured (already length-capped) body bytes. + media_type: The body's media type, consulted for its ``charset``. + ``None`` (or an unrecognised charset) falls back to UTF-8. + + Returns: + A decoded, never-raising preview string. + """ + charset = media_type.charset if media_type is not None else None + return data.decode(charset or "utf-8", errors="replace") -__all__ = ["LoggingPolicy"] +__all__ = ["DEFAULT_PREVIEW_BYTES", "HttpLogDetailLevel", "LoggingPolicy"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/redirect.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/redirect.py index cb62a2e..b9cd384 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/redirect.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/redirect.py @@ -5,28 +5,47 @@ Walks the response's ``Location`` header through up to ``max_hops`` intermediate responses, following the per-status method/body rules from -RFC 7231 §6.4 and RFC 7538 / RFC 7231 §6.4.7. A caller-set -``Authorization`` header is stripped by default only when the reissue -crosses origin relative to the request being redirected; same-origin hops -keep it. ``userinfo`` in the ``Location`` URL is always discarded. Loops -are detected via a visited-URL set and cause the policy to return the +RFC 7231 §6.4 and RFC 7538 / RFC 7231 §6.4.7. + +Credential hygiene follows the same rules as browsers / OkHttp / the JDK: + +- The ``Authorization`` header is stripped before **every** reissue — even a + same-origin hop and the 303 GET rebuild. Re-stamping a credential for a + known origin is the auth layer's job (a downstream auth policy re-applies it + for same-origin reissues), not the redirect layer's. +- On a **cross-origin** hop the origin-scoped ``Cookie`` and + ``Proxy-Authorization`` headers are additionally stripped. Cross-origin is + judged against the **seed** (original) request origin — the RFC 6454 + ``(scheme, host, effective_port)`` tuple — not the immediately preceding + hop, so a same-origin sub-redirect that lands back on a foreign host cannot + re-expose the caller's credentials. +- ``userinfo`` in the ``Location`` URL is always discarded. +- An HTTPS→HTTP scheme downgrade is rejected by default (the hop is not + followed); ``allow_scheme_downgrade=True`` permits it but logs a warning. + +Loops are detected via a visited-URL set and cause the policy to return the current response instead of raising. Status-code matrix: - ``301`` / ``302``: follow with the original method when it is in ``allowed_methods``; otherwise return the response unchanged. -- ``303``: when ``follow_303`` is ``True`` (default), reissue as ``GET`` - with the body dropped and any ``Content-*`` headers removed. -- ``307`` / ``308``: follow with the original method **and body**; the - body must be replayable or a ``RuntimeError`` is raised. +- ``303``: when ``follow_303`` is ``True`` (default), reissue as ``GET`` with + the body dropped and any ``Content-*`` headers removed. +- ``307`` / ``308``: follow with the original method **and body**; the body + must be replayable or a ``RuntimeError`` is raised. - Any other status (including other 3xx like ``304``): return unchanged. + +A caller may supply a custom ``should_redirect`` predicate that fully +overrides the built-in follow decision (see `RedirectCondition`). """ from __future__ import annotations -from dataclasses import replace -from typing import TYPE_CHECKING, ClassVar, Literal, cast +import logging +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, ClassVar, Literal, Protocol, cast from urllib.parse import urljoin from ...http.common.url import Url, _origin @@ -35,14 +54,24 @@ from ..stage import Stage if TYPE_CHECKING: + from ...http.common.headers import Headers from ...http.request.request import Request from ...http.response.response import Response + from ...http.response.status import Status from ...instrumentation import HttpTracer from ..context import PipelineContext +_LOGGER = logging.getLogger(__name__) + _REDIRECT_STATUSES: frozenset[int] = frozenset({301, 302, 303, 307, 308}) _CONTENT_HEADER_PREFIX: str = "content-" +_AUTHORIZATION_HEADER: str = "authorization" + +#: Origin-scoped credential headers stripped only when a reissue crosses +#: origin relative to the seed request (in addition to ``Authorization``, +#: which is stripped on every reissue). Lower-case canonical form. +_CROSS_ORIGIN_CREDENTIAL_HEADERS: tuple[str, ...] = ("cookie", "proxy-authorization") #: ``ctx.data`` key holding the per-operation ``HttpTracer``. The first policy @@ -74,6 +103,55 @@ def resolve_http_tracer(ctx: PipelineContext) -> HttpTracer: return tracer +class RedirectResponse(Protocol): + """Structural read-only view of a response handed to a redirect predicate. + + Both `Response` and `AsyncResponse` satisfy it: a predicate inspects the + status code and headers to decide whether to follow, without consuming or + closing the body. + + Attributes: + status: The response status. + headers: The response headers. + """ + + @property + def status(self) -> Status: ... + + @property + def headers(self) -> Headers: ... + + +@dataclass(frozen=True, slots=True) +class RedirectCondition: + """Read-only snapshot handed to a custom ``should_redirect`` predicate. + + A predicate returns ``True`` to follow the redirect (the standard per-hop + reissue is then built from the ``Location``) or ``False`` to stop and hand + the current response back. The snapshot is a defensive copy: ``visited_uris`` + is an immutable tuple, so a predicate cannot mutate the live cycle-detection + state. + + Attributes: + response: The current response being evaluated. + redirect_count: The number of redirects already followed on this + operation — ``0`` on the first decision. + visited_uris: Insertion-ordered URIs visited so far, including the + current request's URI. Immutable. + """ + + response: RedirectResponse + redirect_count: int + visited_uris: tuple[str, ...] + + +#: A custom redirect predicate. Given a read-only `RedirectCondition`, returns +#: ``True`` to follow the redirect or ``False`` to stop. When configured it +#: fully overrides the built-in follow decision (status matrix, method +#: allow-list, ``follow_303``). +type RedirectPredicate = Callable[[RedirectCondition], bool] + + class RedirectPolicy(Policy): """Follow HTTP redirects per RFC 7231 §6.4 with credential stripping. @@ -93,13 +171,18 @@ class RedirectPolicy(Policy): allowed_methods: Methods that are followed on ``301`` / ``302`` / ``307`` / ``308``. ``303`` is always rewritten to ``GET`` (which is implicitly allowed). Defaults to ``{GET, HEAD}``. - strip_authorization: When ``True`` (the default), a caller-set - ``Authorization`` header is stripped before a redirect reissue - only when the reissue crosses origin — a change in scheme, host, - or effective port — relative to the request being redirected. - Same-origin hops (e.g. a trailing-slash 301) keep the header. - Set ``False`` to never strip, e.g. when the caller has audited - every destination in the chain. + strip_authorization: When ``True`` (the default), the ``Authorization`` + header is stripped before **every** reissue and the origin-scoped + ``Cookie`` / ``Proxy-Authorization`` headers are additionally + stripped on a cross-origin reissue. Set ``False`` to never strip + any credential, e.g. when the caller has audited every destination + in the chain. + allow_scheme_downgrade: When ``False`` (the default), an HTTPS→HTTP + scheme downgrade on a hop is not followed (the current response is + handed back). When ``True``, the downgrade is followed and a + warning is logged; credential stripping still applies. + should_redirect: Optional custom predicate that fully overrides the + built-in follow decision (see `RedirectCondition`). Example: ```python @@ -112,7 +195,14 @@ class RedirectPolicy(Policy): STAGE: ClassVar[Literal[Stage.REDIRECT]] = Stage.REDIRECT - __slots__ = ("allowed_methods", "follow_303", "max_hops", "strip_authorization") + __slots__ = ( + "allow_scheme_downgrade", + "allowed_methods", + "follow_303", + "max_hops", + "should_redirect", + "strip_authorization", + ) def __init__( self, @@ -121,6 +211,8 @@ def __init__( follow_303: bool = True, allowed_methods: frozenset[Method] = frozenset({Method.GET, Method.HEAD}), strip_authorization: bool = True, + allow_scheme_downgrade: bool = False, + should_redirect: RedirectPredicate | None = None, ) -> None: if max_hops < 0: raise ValueError(f"max_hops must be >= 0, got {max_hops}") @@ -128,12 +220,15 @@ def __init__( self.follow_303 = follow_303 self.allowed_methods = allowed_methods self.strip_authorization = strip_authorization + self.allow_scheme_downgrade = allow_scheme_downgrade + self.should_redirect = should_redirect # ----- main loop ------------------------------------------------------ def send(self, request: Request, ctx: PipelineContext) -> Response: tracer = resolve_http_tracer(ctx) tracer.request_url_resolved(str(request.url)) + seed_origin = _origin(request.url) visited: dict[str, None] = {str(request.url): None} hops = 0 current_request = request @@ -141,14 +236,10 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: response = self.next.send(current_request, ctx) if hops >= self.max_hops: return response - status = int(response.status) - if status not in _REDIRECT_STATUSES: - return response - location = response.headers.get("Location") - if location is None or not location.strip(): - return response try: - next_request = self._build_next_request(current_request, status, location) + next_request = self._decide_next( + current_request, response, seed_origin, hops, tuple(visited) + ) except RuntimeError: # A body-preserving redirect with a non-replayable body cannot # be reissued. Close the in-hand 3xx response before the error @@ -169,6 +260,43 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: current_request = next_request hops += 1 + # ----- per-hop decision ----------------------------------------------- + + def _decide_next( + self, + request: Request, + response: RedirectResponse, + seed_origin: tuple[str, str, int | None], + hops: int, + visited: tuple[str, ...], + ) -> Request | None: + """Compute the reissued request for one hop, or ``None`` to stop. + + ``None`` means "hand the current response back unfollowed". A custom + ``should_redirect`` predicate, when set, fully overrides the built-in + follow decision; otherwise the status-code matrix decides. + + Raises: + RuntimeError: 307/308 (or a predicate-authorised body-preserving + reissue) with a non-replayable body. + """ + status = int(response.status) + predicate = self.should_redirect + if predicate is not None: + condition = RedirectCondition( + response=response, redirect_count=hops, visited_uris=visited + ) + if not predicate(condition): + return None + elif status not in _REDIRECT_STATUSES: + return None + location = response.headers.get("Location") + if location is None or not location.strip(): + return None + return self._build_next_request( + request, status, location, seed_origin, predicate_authorized=predicate is not None + ) + # ----- per-hop reissue construction ----------------------------------- def _build_next_request( @@ -176,32 +304,62 @@ def _build_next_request( request: Request, status: int, location: str, + seed_origin: tuple[str, str, int | None], + *, + predicate_authorized: bool = False, ) -> Request | None: """Construct the reissued request for one redirect hop. - Returns ``None`` when the redirect should not be followed (method - not in ``allowed_methods`` for 301/302/307/308, or ``follow_303`` - is ``False`` for 303). + Returns ``None`` when the redirect should not be followed — a malformed + ``Location`` that cannot be resolved, a denied HTTPS→HTTP downgrade, a + method not in ``allowed_methods`` for 301/302/307/308, or + ``follow_303`` being ``False`` for 303. ``predicate_authorized`` + bypasses the method-allowlist and ``follow_303`` gates (the caller's + predicate already decided to follow). The caller treats ``None`` as + "stop and hand back the current response unfollowed", leaving that + response open. + + Cross-origin is judged against ``seed_origin`` (the original request's + origin), not this hop's origin, so a same-origin sub-redirect on a + foreign host still strips origin-scoped credentials. Raises: - RuntimeError: 307/308 with a non-replayable body. + RuntimeError: 307/308 (or a predicate-authorised reissue) with a + non-replayable body. """ - next_url = self._resolve_location(request.url, location) - cross_origin = _origin(request.url) != _origin(next_url) + try: + next_url = self._resolve_location(request.url, location) + except ValueError: + # A malformed Location header (bad scheme/host, un-parseable + # authority, …) has no resolvable target. Signal "do not follow" so + # the loop hands the response back unfollowed and open — the same + # disposition as loop detection / max-hops exhaustion. + return None + if self._is_scheme_downgrade(request.url, next_url): + if not self.allow_scheme_downgrade: + return None + _LOGGER.warning( + "Following HTTPS->HTTP redirect downgrade from host %s to %s " + "(allow_scheme_downgrade=True)", + request.url.host, + next_url.host, + ) + cross_origin = seed_origin != _origin(next_url) if status == 303: - if not self.follow_303: + if not predicate_authorized and not self.follow_303: return None return self._reissue_as_get(request, next_url, cross_origin=cross_origin) - # 301, 302, 307, 308 all require the original method to be allowed. - if request.method not in self.allowed_methods: + # 301, 302, 307, 308 (and any predicate-authorised status) require the + # original method to be allowed unless a predicate overrode the gate. + if not predicate_authorized and request.method not in self.allowed_methods: return None - if status in (307, 308): - return self._reissue_preserving_body(request, next_url, cross_origin=cross_origin) - # 301 / 302: follow with the original method; body carries over - # (matches Java's DefaultRedirectStep — caller can downgrade to GET - # by setting follow_303 plus allowing only safe methods). return self._reissue_preserving_body(request, next_url, cross_origin=cross_origin) + @staticmethod + def _is_scheme_downgrade(from_url: Url, to_url: Url) -> bool: + """Report whether ``from_url``→``to_url`` drops HTTPS down to HTTP.""" + return from_url.scheme.lower() == "https" and to_url.scheme.lower() == "http" + def _resolve_location(self, base: Url, location: str) -> Url: """Resolve a possibly-relative Location header into an absolute Url. @@ -214,6 +372,26 @@ def _resolve_location(self, base: Url, location: str) -> Url: return parsed return replace(parsed, userinfo=None) + def _strip_credentials(self, request: Request, *, cross_origin: bool) -> Request: + """Strip credential headers from a reissued request. + + ``Authorization`` is stripped on every reissue; the origin-scoped + ``Cookie`` / ``Proxy-Authorization`` headers are additionally stripped + when the reissue crosses origin. All stripping is gated by + ``strip_authorization``. + + Args: + request: The request being reissued. + cross_origin: Whether the reissue crosses the seed origin. + """ + if not self.strip_authorization: + return request + stripped = request.without_header(_AUTHORIZATION_HEADER) + if cross_origin: + for name in _CROSS_ORIGIN_CREDENTIAL_HEADERS: + stripped = stripped.without_header(name) + return stripped + def _reissue_as_get( self, request: Request, @@ -224,23 +402,19 @@ def _reissue_as_get( """Build the reissued GET for a 303 hop. Drops the request body and every ``Content-*`` header (per RFC 7231 - §6.4.4 — the body no longer applies to a GET). A caller-set - ``Authorization`` header is stripped only on a cross-origin hop when - ``strip_authorization`` is enabled. + §6.4.4 — the body no longer applies to a GET), then strips credentials + per `_strip_credentials`. Args: request: The request being redirected (the current hop). next_url: The resolved absolute target of the redirect. - cross_origin: Whether ``next_url`` differs in origin from - ``request.url``. + cross_origin: Whether ``next_url`` differs in origin from the seed. """ stripped = request.with_method(Method.GET).with_url(next_url).with_body(None) for name in tuple(stripped.headers): if name.startswith(_CONTENT_HEADER_PREFIX): stripped = stripped.without_header(name) - if self.strip_authorization and cross_origin: - stripped = stripped.without_header("Authorization") - return stripped + return self._strip_credentials(stripped, cross_origin=cross_origin) def _reissue_preserving_body( self, @@ -254,18 +428,16 @@ def _reissue_preserving_body( 307/308 must preserve the body, so a non-replayable body raises ``RuntimeError`` — sending the same payload twice with a single-use body is not possible. 301/302 also carry the body (matches Java's - ``DefaultRedirectStep``); the same replay requirement applies. A - caller-set ``Authorization`` header is stripped only on a cross-origin - hop when ``strip_authorization`` is enabled. + ``DefaultRedirectStep``); the same replay requirement applies. + Credentials are stripped per `_strip_credentials`. Args: request: The request being redirected (the current hop). next_url: The resolved absolute target of the redirect. - cross_origin: Whether ``next_url`` differs in origin from - ``request.url``. + cross_origin: Whether ``next_url`` differs in origin from the seed. Raises: - RuntimeError: 307/308 with a non-replayable body. + RuntimeError: A non-replayable body cannot be reissued. """ body = request.body if body is not None and not body.is_replayable(): @@ -275,9 +447,14 @@ def _reissue_preserving_body( "expected." ) reissued = request.with_url(next_url) - if self.strip_authorization and cross_origin: - reissued = reissued.without_header("Authorization") - return reissued + return self._strip_credentials(reissued, cross_origin=cross_origin) -__all__ = ["HTTP_TRACER_KEY", "RedirectPolicy", "resolve_http_tracer"] +__all__ = [ + "HTTP_TRACER_KEY", + "RedirectCondition", + "RedirectPolicy", + "RedirectPredicate", + "RedirectResponse", + "resolve_http_tracer", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/retry.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/retry.py index 0b94b73..df7ed06 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/retry.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/retry.py @@ -8,6 +8,20 @@ raised. State is kept in ``ctx.data["retry_settings"]`` for the duration of one ``send`` call; per-attempt history lands in ``ctx.data["retry_history"]``. +The re-send safety gate has two axes and applies uniformly to both retryable +HTTP statuses and transport-level errors: a body-less request is re-sent based +on method idempotency, while a request carrying a body is re-sent only when that +body is replayable (see ``_request_is_resendable``). A non-replayable body is +never re-emitted, regardless of method. + +A caller may pass a ``CancellationToken`` as the ``cancellation`` per-call +option: it is observed at every attempt boundary and drives the backoff wait +through an interruptible sleep, so a cooperative cancel aborts a pending backoff +promptly with ``RequestCancelledError`` rather than running the full delay. When +the budget is exhausted on the error path the surfaced exception carries a +prior-attempt trail (``add_note`` lines plus an ``attempts`` count) that excludes +the exception instance itself. + Single-use request bodies (``RequestBody.from_stream`` / ``RequestBody.from_iter``) are auto-buffered at the top of ``send`` when the *effective* per-call retry total is positive: the policy calls @@ -23,21 +37,26 @@ from __future__ import annotations import logging +import math import random -import re -from collections.abc import Iterable -from email.utils import parsedate_to_datetime +from collections.abc import Iterable, Sequence from enum import StrEnum from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable from ...errors import ( ClientAuthenticationError, + HttpResponseError, + RequestCancelledError, SdkError, ServiceRequestError, ServiceResponseError, ServiceResponseTimeoutError, ) -from ...util.clock import SYSTEM_CLOCK, Clock +from ...http.request.method import _IDEMPOTENT_METHODS +from ...util.clock import SYSTEM_CLOCK, CancellationToken, Clock +from .._pure.backoff import apply_jitter, compute_backoff +from .._pure.classifier import DEFAULT_RETRYABLE_STATUS, is_retryable, status_is_retryable +from .._pure.pacing import parse_rate_limit_reset, parse_retry_after, parse_retry_after_ms from ..policy import Policy from ..stage import Stage from ._history import RequestHistory @@ -59,11 +78,18 @@ #: GitHub, Stripe, Slack, and others alongside (or instead of) ``Retry-After``. _RATE_LIMIT_RESET_HEADER: Final[str] = "X-RateLimit-Reset" +#: Header carrying a whole-millisecond retry delay. The vendor-neutral spelling. +_RETRY_AFTER_MS_HEADER: Final[str] = "retry-after-ms" + +#: The Microsoft/Azure spelling of the whole-millisecond retry delay header. +_MS_RETRY_AFTER_MS_HEADER: Final[str] = "x-ms-retry-after-ms" + #: Upward jitter fraction applied to an ``X-RateLimit-Reset`` wait. The delay is -#: multiplied by a random sample in ``[1.0, 1.0 + this]`` so a client never wakes -#: before the window resets, while a fleet that observed the same reset instant -#: spreads its retries instead of firing in lockstep. -_RATE_LIMIT_RESET_JITTER: Final[float] = 0.1 +#: multiplied by a random sample in ``[1.0, 1.0 + this]`` — i.e. ``[100%, 120%]`` +#: — so a client never wakes before the window resets, while a fleet that +#: observed the same reset instant spreads its retries instead of firing in +#: lockstep. +_RATE_LIMIT_RESET_JITTER: Final[float] = 0.2 @runtime_checkable @@ -81,9 +107,12 @@ def status(self) -> Any: ... def headers(self) -> Any: ... -_DEFAULT_STATUS_RETRIES: Final[frozenset[int]] = frozenset({408, 429, 500, 502, 503, 504}) +# The default retry method allow-list is *derived* from the single idempotent +# classification (`_IDEMPOTENT_METHODS`) rather than hand-maintained here, so +# the allow-list and the inherent replay-safety gate can never diverge — only a +# GET/HEAD/OPTIONS/PUT/DELETE is retried by default (no TRACE, no POST/PATCH). _DEFAULT_METHOD_ALLOWLIST: Final[frozenset[str]] = frozenset( - {"HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"} + method.value for method in _IDEMPOTENT_METHODS ) _POST_PATCH_STATUS_RETRIES: Final[frozenset[int]] = frozenset({500, 503, 504}) @@ -188,7 +217,7 @@ def __init__( retry_mode: RetryMode = RetryMode.EXPONENTIAL, timeout: float = 604_800, # 7 days, mirroring Azure's default method_allowlist: Iterable[str] = _DEFAULT_METHOD_ALLOWLIST, - retry_on_status_codes: Iterable[int] = _DEFAULT_STATUS_RETRIES, + retry_on_status_codes: Iterable[int] = DEFAULT_RETRYABLE_STATUS, respect_retry_after: bool = True, retry_after_max: float = _DEFAULT_RETRY_AFTER_MAX, full_jitter: bool = True, @@ -212,6 +241,26 @@ def __init__( self._jitter = jitter self._clock = clock self._rand = rand if rand is not None else random.Random() + self._validate() + + def _validate(self) -> None: + """Reject invalid scalar configuration at construction time. + + Retry counts must be non-negative integers (``0`` disables that axis), + every duration must be finite and non-negative, and the symmetric + jitter fraction must lie in ``[0.0, 1.0]``. Collection-valued settings + are already defensively copied into ``frozenset`` above, so a later + caller mutation of the source cannot alter behaviour. + + Raises: + ValueError: When any scalar knob is out of range. + """ + for name in ("total_retries", "connect_retries", "read_retries", "status_retries"): + _require_non_negative_int(name, getattr(self, name)) + for name in ("backoff_factor", "backoff_max", "timeout", "retry_after_max"): + _require_non_negative_duration(name, getattr(self, name)) + if not 0.0 <= self._jitter <= 1.0: + raise ValueError(f"jitter must lie in [0.0, 1.0], got {self._jitter}") # ----- public sentinel ------------------------------------------------ @@ -224,12 +273,14 @@ def no_retries(cls) -> RetryPolicy: def send(self, request: Request, ctx: PipelineContext) -> Response: settings = self._configure_settings(ctx.options) + cancellation: CancellationToken | None = ctx.options.get("cancellation") if settings["total"] > 0 and request.body is not None and not request.body.is_replayable(): request = request.with_body(request.body.to_replayable()) absolute_deadline = self._clock.monotonic() + settings["timeout"] history: list[RequestHistory[Response]] = settings["history"] tracer = resolve_http_tracer(ctx) while True: + _raise_if_cancelled(cancellation) tracer.attempt_started(len(history)) try: response = self.next.send(request, ctx) @@ -240,6 +291,7 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: if not self._decrement_for_error(settings, request, err): tracer.attempt_retries_exhausted() ctx.data["retry_history"] = tuple(history) + _annotate_terminal_failure(err, history) raise ctx.data["retry_count"] = len(history) delay = self._delay_for(settings, None) @@ -247,7 +299,7 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: # Sleep outside the try so a deadline crossed during the # backoff raises ``ServiceResponseTimeoutError`` straight to # the caller instead of being swallowed by ``except SdkError``. - self._sleep_bounded(delay, absolute_deadline) + self._sleep_bounded(delay, absolute_deadline, cancellation) _LOGGER.debug("retrying after %s: %s", type(err).__name__, err) continue if not self._is_retry(settings, request, response): @@ -259,13 +311,15 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: ctx.data["retry_history"] = tuple(history) return response ctx.data["retry_count"] = len(history) - delay = self._delay_for(settings, response) - tracer.attempt_failed(_StatusRetryError(int(response.status)), delay) - # The intermediate response is not handed back to the caller, so - # close it to release the pooled connection before sleeping. The - # return branches above keep the response open — the caller owns it. - response.close() - self._sleep_bounded(delay, absolute_deadline) + status = int(response.status) + # Close the intermediate response before the backoff, in a + # ``finally`` so it closes even if the delay computation throws. + try: + delay = self._delay_for(settings, response) + finally: + response.close() + tracer.attempt_failed(_StatusRetryError(status), delay) + self._sleep_bounded(delay, absolute_deadline, cancellation) # ----- configuration -------------------------------------------------- @@ -297,9 +351,11 @@ def _is_retry( status = int(response.status) if status < 400: return False + if not _request_is_resendable(request): + return False if not self._method_is_retryable(settings, request, response): return False - if status in self.retry_on_status_codes: + if status_is_retryable(status, self.retry_on_status_codes): return int(settings["total"]) > 0 # ``Retry-After`` only triggers retry for status codes already in the # allowlist; a malicious server cannot force retries on arbitrary @@ -335,32 +391,41 @@ def _decrement_for_error( ) -> bool: """Decrement counters after a network-side error. - ``ServiceRequestError`` is a connect-phase failure: the request never - left the client, so re-sending it is safe for every method. A - ``ServiceResponseError`` is a read-phase failure — the request may - have been fully processed before the read broke — so re-sending a - non-idempotent method (POST/PATCH not in the allowlist) risks - duplicating the write. Those methods are not retried on the read path, - mirroring the careful status-path rule in ``_method_is_retryable``. + Retry *safety* is decided first, independently of retryability, and + applied UNIFORMLY: a non-replayable body is never re-sent, and a + body-less request is re-sent only when its method is idempotent — so a + bare POST is never retried on a transport error, even a connect-phase + one that never reached the server. Retry *eligibility* is decided + second: ``ServiceRequestError`` and ``ServiceResponseError`` are the + known transport families (charged to the connect / read sub-budgets); + any other error is admitted only through the open retryability + capability (``is_retryable``), charged to the connect sub-budget. A + status-carrying ``HttpResponseError`` is excluded from that capability + path — its eligibility is the configured status set, not its baked flag. Args: settings: Mutable per-call settings dict. - request: The request that triggered the error, used to gate - read-phase retries on the method's idempotency. + request: The request that triggered the error, used to gate the + retry on the method's idempotency and the body's replayability. error: The error raised by the downstream chain. Returns: ``True`` if the budget allows another attempt. """ + if not _request_is_resendable(request): + return False + if not self._method_is_retryable(settings, request, None): + return False if isinstance(error, ServiceRequestError): settings["total"] -= 1 settings["connect"] -= 1 elif isinstance(error, ServiceResponseError): - if not self._method_is_retryable(settings, request, None): - return False settings["total"] -= 1 settings["read"] -= 1 - else: # pragma: no cover - upstream raised something we don't classify + elif not isinstance(error, HttpResponseError) and is_retryable(error): + settings["total"] -= 1 + settings["connect"] -= 1 + else: return False return _has_budget(settings) @@ -395,46 +460,74 @@ def _delay_for( def _server_delay(self, response: _ResponseLike) -> float | None: """Resolve a server-supplied retry delay, capped and jittered. - ``Retry-After`` (seconds or HTTP-date) takes precedence; an - ``X-RateLimit-Reset`` epoch is the fallback and gets a slight *upward* - jitter. The jitter only ever lengthens the wait — retrying before the + Applies the fixed pacing precedence (first usable wins): ``Retry-After`` + (delta-seconds, then HTTP-date), then the ``retry-after-ms`` and + ``x-ms-retry-after-ms`` millisecond-delta headers, then an + ``X-RateLimit-Reset`` epoch. The reset fallback gets a slight *upward* + jitter; the jitter only ever lengthens the wait — retrying before the window actually resets just earns another rate-limit response — while the small positive spread keeps a fleet of clients that observed the - same reset instant from retrying in lockstep. Both are capped at - ``retry_after_max``. + same reset instant from retrying in lockstep. Every resolved delay is + capped at ``retry_after_max``. Returns: - Seconds to wait, or ``None`` when neither header is present. + Seconds to wait, or ``None`` when no recognised header is present. """ - now = self._clock.now() - retry_after = _parse_retry_after(response.headers.get("Retry-After"), now) + headers = response.headers + retry_after = parse_retry_after(headers.get("Retry-After"), self._clock.now()) if retry_after is not None: return min(retry_after, self.retry_after_max) - reset = _parse_rate_limit_reset( - response.headers.get(_RATE_LIMIT_RESET_HEADER), - now, - ) + for header in (_RETRY_AFTER_MS_HEADER, _MS_RETRY_AFTER_MS_HEADER): + delay_ms = parse_retry_after_ms(headers.get(header)) + if delay_ms is not None: + return min(delay_ms, self.retry_after_max) + return self._rate_limit_reset_delay(headers) + + def _rate_limit_reset_delay(self, headers: Any) -> float | None: + """Resolve the jittered, capped ``X-RateLimit-Reset`` fallback delay. + + Args: + headers: The response headers to read ``X-RateLimit-Reset`` from. + + Returns: + Seconds to wait, or ``None`` when the header is absent or malformed. + """ + reset = parse_rate_limit_reset(headers.get(_RATE_LIMIT_RESET_HEADER), self._clock.now()) if reset is None: return None jittered = reset * self._rand.uniform(1.0, 1.0 + _RATE_LIMIT_RESET_JITTER) return min(jittered, self.retry_after_max) def _backoff_seconds(self, settings: dict[str, Any]) -> float: - attempts = len(settings["history"]) - if attempts <= 1: - return 0.0 - if self.retry_mode is RetryMode.FIXED: - backoff = float(settings["backoff"]) - else: - backoff = float(settings["backoff"]) * (2 ** (attempts - 1)) - bounded = min(float(settings["max_backoff"]), backoff) - if self._full_jitter: - return bounded * self._rand.uniform(0.5, 1.0) - if self._jitter == 0: - return bounded - return bounded * self._rand.uniform(1 - self._jitter, 1 + self._jitter) - - def _sleep_bounded(self, duration: float, absolute_deadline: float) -> None: + """Compute the jittered backoff delay for the next attempt, in seconds. + + Delegates the schedule to the shared pure ``compute_backoff`` (the + deterministic exponential/fixed curve) and ``apply_jitter`` so the sync + and async policies share one formula instead of each restating it. + + Args: + settings: Mutable per-call settings dict; reads ``history`` (for + the attempt count), ``backoff``, and ``max_backoff``. + + Returns: + Non-negative seconds to wait before the next attempt. + """ + delay = compute_backoff( + len(settings["history"]), + base=float(settings["backoff"]), + cap=float(settings["max_backoff"]), + fixed=self.retry_mode is RetryMode.FIXED, + ) + return apply_jitter( + delay, self._rand, full_jitter=self._full_jitter, symmetric=self._jitter + ) + + def _sleep_bounded( + self, + duration: float, + absolute_deadline: float, + cancellation: CancellationToken | None = None, + ) -> None: """Sleep for ``duration`` seconds, clamped to the absolute deadline. Both the pre-sleep ("deadline already in the past") and post-sleep @@ -445,23 +538,38 @@ def _sleep_bounded(self, duration: float, absolute_deadline: float) -> None: budget-exhausted condition (the chain ran out of time waiting for a response, not establishing the request). + When a ``CancellationToken`` is supplied the wait runs through its + interruptible ``sleep`` (a ``threading.Event.wait``), so a cooperative + cancel from another thread aborts the backoff promptly with + ``RequestCancelledError`` instead of running the full delay. Cancellation + is discriminated by exception type, never by message text. + Args: duration: Desired sleep length in seconds. Non-positive values return immediately. absolute_deadline: ``Clock.monotonic()`` value beyond which the retry budget is considered spent. + cancellation: Optional cooperative-cancellation handle. When present + and already cancelled, or cancelled during the wait, the backoff + aborts with ``RequestCancelledError``. Raises: + RequestCancelledError: When ``cancellation`` is cancelled before or + during the wait. ServiceResponseTimeoutError: When ``absolute_deadline`` is reached before or during the sleep. """ + _raise_if_cancelled(cancellation) if duration <= 0: return remaining = absolute_deadline - self._clock.monotonic() if remaining <= 0: raise ServiceResponseTimeoutError("Retry budget exhausted (timeout reached)") actual = min(duration, remaining) - self._clock.sleep(actual) + if cancellation is None: + self._clock.sleep(actual) + elif not cancellation.sleep(actual): + raise RequestCancelledError("Retry backoff aborted by cooperative cancellation") if self._clock.monotonic() >= absolute_deadline: raise ServiceResponseTimeoutError("Retry budget exhausted (timeout reached)") @@ -480,62 +588,32 @@ def __init__(self, status: int) -> None: self.status = status -_RETRY_AFTER_DELTA_PATTERN = re.compile(r"^\s*\d+(\.\d+)?\s*$") - - -def _parse_retry_after(value: str | None, now: float) -> float | None: - """Parse a ``Retry-After`` header value (delta-seconds or HTTP-date). +def _require_non_negative_int(name: str, value: int) -> None: + """Raise ``ValueError`` unless ``value`` is a non-negative retry count. Args: - value: Raw header value. ``None`` returns ``None`` directly. - now: Current wall-clock time, in seconds since the epoch, used to - convert an HTTP-date into a delay. Injected so the value is - deterministic in tests. + name: The configuration knob's name, for the error message. + value: The candidate retry count. - Returns: - Seconds to wait (>= 0), or ``None`` when ``value`` is missing or - unparseable. + Raises: + ValueError: When ``value`` is negative. """ - if value is None or not value.strip(): - return None - if _RETRY_AFTER_DELTA_PATTERN.match(value): - return max(0.0, float(value)) - try: - when = parsedate_to_datetime(value) - except (TypeError, ValueError): - return None - if when is None: - return None - delta = when.timestamp() - now - return max(0.0, delta) - - -_RATE_LIMIT_RESET_PATTERN = re.compile(r"^\s*\d+(\.\d+)?\s*$") + if value < 0: + raise ValueError(f"{name} must be >= 0 (0 disables that axis), got {value}") -def _parse_rate_limit_reset(value: str | None, now: float) -> float | None: - """Parse an ``X-RateLimit-Reset`` epoch header into a delay. - - The header carries the wall-clock second at which the rate-limit window - resets (GitHub, Stripe, Slack). The delay is the difference between that - instant and ``now``, floored at zero (a reset already in the past means - retry immediately). +def _require_non_negative_duration(name: str, value: float) -> None: + """Raise ``ValueError`` unless ``value`` is a finite, non-negative duration. Args: - value: Raw header value (epoch seconds). ``None`` or unparseable - returns ``None``. - now: Current wall-clock time, in seconds since the epoch, used to - compute the delta. Injected so the value is deterministic in tests. + name: The configuration knob's name, for the error message. + value: The candidate duration, in seconds. - Returns: - Seconds to wait (>= 0), or ``None`` when the header is missing or not a - plain epoch number. + Raises: + ValueError: When ``value`` is NaN, infinite, or negative. """ - if value is None or not value.strip(): - return None - if not _RATE_LIMIT_RESET_PATTERN.match(value): - return None - return max(0.0, float(value) - now) + if not math.isfinite(value) or value < 0: + raise ValueError(f"{name} must be a finite, non-negative duration in seconds, got {value}") def _has_budget(settings: dict[str, Any]) -> bool: @@ -549,4 +627,80 @@ def _has_budget(settings: dict[str, Any]) -> bool: return min(counts) >= 0 +def _request_is_resendable(request: Request) -> bool: + """Return whether ``request`` can be safely re-sent on a retry. + + The body axis of the two-axis re-send safety gate: a body-less request is + always re-sendable (idempotency is decided separately by method), while a + request carrying a body may only be re-sent when that body is replayable. + A non-replayable body — one the caller declined or is unable to buffer — is + never re-emitted, regardless of the method or which failure triggered the + retry. + + Args: + request: The request whose body is inspected. + + Returns: + ``True`` when the request has no body or a replayable one. + """ + body = request.body + return body is None or body.is_replayable() + + +def _raise_if_cancelled(cancellation: CancellationToken | None) -> None: + """Raise ``RequestCancelledError`` when a cooperative token is cancelled. + + A no-op when ``cancellation`` is ``None`` or still live, so the retry loop + can call it unconditionally at attempt boundaries. + + Args: + cancellation: The cooperative-cancellation handle, if any. + + Raises: + RequestCancelledError: When the token has been cancelled. + """ + if cancellation is not None and cancellation.cancelled: + raise RequestCancelledError("Retry cancelled by cooperative cancellation") + + +def _annotate_terminal_failure[ResponseT]( + error: BaseException, + history: Sequence[RequestHistory[ResponseT]], +) -> None: + """Attach the prior-attempt trail to a terminal (retries-exhausted) failure. + + Records the total attempt count on an ``attempts`` attribute and appends one + ``add_note`` line per PRIOR attempt. The currently-surfacing exception is the + last history entry; it is excluded from its own trail so an exception never + notes itself. + + Args: + error: The exception about to surface to the caller. + history: The full per-attempt trail, ending with ``error``'s own entry. + """ + # Stamp the count as a dynamic instance attribute — the SDK exception types + # carry no static ``attempts`` field, so this writes straight into __dict__. + error.__dict__["attempts"] = len(history) + prior = [entry for entry in history if entry.error is not error] + for index, entry in enumerate(prior, start=1): + error.add_note(f"prior attempt {index}: {_describe_attempt(entry)}") + + +def _describe_attempt[ResponseT](entry: RequestHistory[ResponseT]) -> str: + """Render a one-line summary of a single retry attempt for the trail. + + Args: + entry: The per-attempt history record. + + Returns: + A short human-readable description of how the attempt ended. + """ + if entry.error is not None: + return f"raised {type(entry.error).__name__}: {entry.error}" + status = getattr(entry.response, "status", None) + if status is not None: + return f"returned HTTP {int(status)}" + return "produced no response" + + __all__ = ["RetryMode", "RetryPolicy"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/tracing_policy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/tracing_policy.py index e34523f..6674ae3 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/tracing_policy.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/tracing_policy.py @@ -30,10 +30,11 @@ the sentinel trace ids. Disable either per-call by setting ``ctx.options["tracing_enabled"] = False``. -`OperationTracingPolicy` has an async twin, `AsyncOperationTracingPolicy`, -wired into ``default_async_pipeline`` so the async stack reports the same -per-operation lifecycle. `TracingPolicy`'s per-attempt OpenTelemetry span -machinery is sync-only and has no async counterpart. +Both policies have async twins — `AsyncOperationTracingPolicy` and +`AsyncTracingPolicy` — wired into ``default_async_pipeline`` so the async +stack reports the same per-operation lifecycle and opens the same per-attempt +OpenTelemetry span (identical semantic-convention attributes and per-request +events) inside the redirect and retry wrappers. """ from __future__ import annotations @@ -42,7 +43,13 @@ import threading from typing import TYPE_CHECKING -from ...instrumentation import NOOP_HTTP_TRACER, NOOP_TRACER, Tracer, bind_correlation +from ...instrumentation import ( + NOOP_HTTP_TRACER, + NOOP_TRACER, + Tracer, + UrlRedactor, + bind_correlation, +) from ..policy import Policy from ..stage import Stage from .redirect import resolve_http_tracer @@ -134,8 +141,9 @@ class TracingPolicy(Policy): OpenTelemetry semantic conventions: - ``http.request.method``: HTTP method. - - ``url.full``: Full URL (no redaction — install a separate redactor - if you need it). + - ``url.full``: URL redacted through the shared ``UrlRedactor`` (userinfo + and non-allowlisted query parameters stripped). Pass a custom + ``redactor`` to widen the allow-list or redact the path. - ``server.address`` / ``server.port``: Resolved from the URL. - ``http.response.status_code``: Set on success. - ``error.type``: Set on exception. @@ -160,10 +168,16 @@ class TracingPolicy(Policy): """ STAGE = Stage.POST_LOGGING - __slots__ = ("_tracer",) + __slots__ = ("_redactor", "_tracer") - def __init__(self, *, tracer: Tracer | None = None) -> None: + def __init__( + self, + *, + tracer: Tracer | None = None, + redactor: UrlRedactor | None = None, + ) -> None: self._tracer = tracer or NOOP_TRACER + self._redactor = redactor or UrlRedactor() def send(self, request: Request, ctx: PipelineContext) -> Response: if not ctx.options.get("tracing_enabled", True): @@ -178,7 +192,7 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: if http_tracer is not NOOP_HTTP_TRACER and _OPERATION_BRACKET_KEY not in ctx.data: _warn_missing_operation_bracket_once() span = self._tracer.start_span(f"HTTP {request.method}", parent=parent) - _set_request_attributes(span, request) + _set_request_attributes(span, request, self._redactor) with bind_correlation(trace_id=_trace_id(span), span_id=_span_id(span)): return self._dispatch(request, ctx, span, http_tracer) @@ -212,11 +226,16 @@ def _dispatch( return response -def _set_request_attributes(span: Span, request: Request) -> None: - """Stamp the OpenTelemetry request attributes onto the span.""" +def _set_request_attributes(span: Span, request: Request, redactor: UrlRedactor) -> None: + """Stamp the OpenTelemetry request attributes onto the span. + + The ``url.full`` attribute is routed through the shared ``UrlRedactor`` so + userinfo and non-allowlisted query parameters never reach the tracing + backend. + """ host, port = _split_host(request.url) span.set_attribute("http.request.method", str(request.method)) - span.set_attribute("url.full", str(request.url)) + span.set_attribute("url.full", redactor.redact(request.url)) if host: span.set_attribute("server.address", host) if port is not None: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/staged_builder.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/staged_builder.py index 3590cf2..ca85218 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/staged_builder.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/staged_builder.py @@ -8,9 +8,26 @@ stage buckets and at ``build()`` time flattens to a list in stage order. Pillar stages (`OPERATION`, `REDIRECT`, `RETRY`, `AUTH`, `LOGGING`, `SERDE`) -admit at most one policy. A second `append` of a pillar raises by default — use -``replace(target, new)`` for explicit swaps or ``append(p, force=True)`` -for the rare legitimate use case (test fixtures, runtime composition). +admit at most one policy — the singleton guarantee is enforced on *every* +mutation path (append, prepend, replace, insert, bulk reload), not just +``append``. A second occupant raises a descriptive error naming the stage and +the incumbent policy type; use ``replace(target, new)`` for explicit swaps or +``append(p, force=True)`` for the rare legitimate use case (test fixtures, +runtime composition). Shipped singletons own their stage and cannot be +relocated to a different one. Group several edits with ``batch()`` for +all-or-nothing application. + +Re-installing the *same* policy instance onto its already-occupied pillar is +an idempotent no-op: reference identity, not value equality, distinguishes +"same" from "distinct". A second *distinct* occupant still raises, but +re-adding the incumbent object simply returns without error or duplication. + +Batch adds come in two flavours with a deliberate ordering asymmetry. +``append_all(policies)`` preserves the batch's iteration order within each +stage (``[a, b, c]`` stays ``[a, b, c]``); ``prepend_all(policies)`` prepends +each element individually and therefore REVERSES it (``[a, b, c]`` becomes +``[c, b, a]``, because ``c`` is prepended last and so ends up first). Both are +all-or-nothing. """ from __future__ import annotations @@ -23,6 +40,8 @@ from .stage import Stage if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from ..client.http_client import HttpClient @@ -81,6 +100,62 @@ def prepend(self, policy: Policy, *, force: bool = False) -> Self: self._buckets.setdefault(stage, []).insert(0, policy) return self + def append_all(self, policies: Iterable[Policy], *, force: bool = False) -> Self: + """Append every policy in ``policies``, preserving iteration order. + + Each element is appended in turn, so the batch keeps its order within + its stage: appending ``[a, b, c]`` yields ``[a, b, c]``. This is the + deliberate mirror image of `prepend_all`, which reverses the batch — + see that method for the rationale behind the asymmetry. + + The batch is all-or-nothing: if any element is rejected (for example a + second distinct occupant for a filled pillar) the builder rolls back to + its pre-call state and no element is left applied. + + Args: + policies: Policies to append, consumed in iteration order. + force: Forwarded to `append` for pillar overwrites. + + Returns: + ``self`` for chaining. + + Raises: + ValueError: If any element cannot be added (e.g. it double-fills a + pillar); the builder is left unchanged. + """ + with self.batch() as staged: + for policy in policies: + staged.append(policy, force=force) + return self + + def prepend_all(self, policies: Iterable[Policy], *, force: bool = False) -> Self: + """Prepend every policy in ``policies`` one at a time, reversing order. + + Because each element is prepended to the head in turn, the batch's + order is REVERSED within its stage: prepending ``[a, b, c]`` yields + ``[c, b, a]`` — ``c`` is prepended last, so it ends up first. This + asymmetry with `append_all` (which preserves order) is intentional and + matches prepending elements individually; callers who want the batch to + keep its order should reverse the input or use `append_all`. + + The batch is all-or-nothing, exactly like `append_all`. + + Args: + policies: Policies to prepend, consumed in iteration order. + force: Forwarded to `prepend` for pillar overwrites. + + Returns: + ``self`` for chaining. + + Raises: + ValueError: If any element cannot be added (e.g. it double-fills a + pillar); the builder is left unchanged. + """ + with self.batch() as staged: + for policy in policies: + staged.prepend(policy, force=force) + return self + def replace(self, target: type[Policy], new: Policy) -> Self: """Replace the first instance of ``target`` with ``new``. @@ -90,33 +165,47 @@ def replace(self, target: type[Policy], new: Policy) -> Self: Args: target: Type whose first matching instance is replaced. - new: Replacement policy. Must declare a ``STAGE`` — typically - the same stage as ``target`` but not enforced. + new: Replacement policy. Must declare a ``STAGE``. When ``target`` + occupies a pillar (singleton) stage, ``new`` must declare that + same stage; for non-pillar targets a cross-stage swap is + allowed and re-buckets ``new`` by its declared stage. Returns: ``self`` for chaining. Raises: - ValueError: If no instance of ``target`` exists in the builder. + ValueError: If no instance of ``target`` exists in the builder, or + if ``target`` occupies a pillar (singleton) stage and ``new`` + declares a different stage — a shipped singleton owns its stage + and may not be relocated. """ pillar_stage = next( (stage for stage, pillar in self._pillars.items() if isinstance(pillar, target)), None, ) if pillar_stage is not None: - # Install new at its declared stage; remove the old pillar. The - # lookup above finished iterating before we mutate ``_pillars``. + if pillar_stage != new.STAGE: + raise ValueError( + f"Cannot relocate the {pillar_stage.name} singleton: " + f"replacement {type(new).__name__} declares stage " + f"{new.STAGE.name}. A shipped singleton owns its stage; " + f"pass a replacement declared at {pillar_stage.name}." + ) + # Same-stage swap. The lookup above finished iterating before we + # mutate ``_pillars``; ``force`` overwrites the vacated slot. del self._pillars[pillar_stage] self.append(new, force=True) return self - for stage, bucket in self._buckets.items(): + for stage, bucket in list(self._buckets.items()): for i, p in enumerate(bucket): if isinstance(p, target): if stage == new.STAGE: bucket[i] = new else: - del bucket[i] + # Place ``new`` first so a rejected pillar occupancy + # leaves the incumbent intact — no half-applied swap. self.append(new) + del bucket[i] return self raise ValueError(f"No instance of {target.__name__} in the builder.") @@ -141,6 +230,27 @@ def build(self) -> Pipeline: """Flatten the builder's contents into a `Pipeline`.""" return Pipeline(self._client, policies=self._flatten()) + @contextlib.contextmanager + def batch(self) -> Iterator[Self]: + """Apply a group of edits as an all-or-nothing transaction. + + Yields ``self`` so edits chain naturally inside the ``with`` block. If + any edit raises, the builder rolls back to its pre-batch state, so a + failing batch leaves no partial mutation visible and any pipeline built + beforehand is untouched. + + Yields: + This builder, for chaining edits inside the ``with`` block. + """ + saved_pillars = dict(self._pillars) + saved_buckets = {stage: list(bucket) for stage, bucket in self._buckets.items()} + try: + yield self + except Exception: + self._pillars = saved_pillars + self._buckets = saved_buckets + raise + @classmethod def from_pipeline(cls, pipeline: Pipeline) -> Self: """Seed a builder from an existing `Pipeline`'s policies. @@ -191,8 +301,13 @@ def from_pipeline(cls, pipeline: Pipeline) -> Self: return builder def _install_pillar(self, policy: Policy, stage: Stage, *, force: bool) -> None: - if stage in self._pillars and not force: - existing = type(self._pillars[stage]).__name__ + incumbent = self._pillars.get(stage) + if incumbent is policy: + # Re-installing the SAME instance is an idempotent no-op: reference + # identity, not value equality, tells 'same' from 'distinct'. + return + if incumbent is not None and not force: + existing = type(incumbent).__name__ raise ValueError( f"Pillar stage {stage.name} is already filled by {existing}. " f"Use replace({type(policy).__name__}, new) to swap, or " @@ -223,13 +338,35 @@ def _flatten(self) -> list[Policy]: return out def _reload(self, policies: list[Policy]) -> None: - self._pillars.clear() - self._buckets.clear() + """Rebuild the stage buckets from a flat policy list, atomically. + + Validates single-occupancy of every pillar stage across the whole list + before touching any builder state, so a list that would double-fill a + pillar raises without leaving the builder half-reloaded. + + Args: + policies: The full, ordered policy list to re-bucket. + + Raises: + ValueError: If two policies claim the same pillar stage. The error + names the stage and the incumbent policy type. + """ + pillars: dict[Stage, Policy] = {} + buckets: dict[Stage, list[Policy]] = {} for p in policies: - if p.STAGE.is_pillar: - self._pillars[p.STAGE] = p + stage = p.STAGE + if not stage.is_pillar: + buckets.setdefault(stage, []).append(p) + elif stage in pillars: + raise ValueError( + f"Pillar stage {stage.name} is already filled by " + f"{type(pillars[stage]).__name__}; it cannot also admit " + f"{type(p).__name__}." + ) else: - self._buckets.setdefault(p.STAGE, []).append(p) + pillars[stage] = p + self._pillars = pillars + self._buckets = buckets def _detach(policy: Policy) -> None: diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/__init__.py index 6a99137..33dd83f 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/__init__.py @@ -16,6 +16,13 @@ variant, ) from .json_serde import JSON_SERDE, JsonDeserializer, JsonSerde, JsonSerializer +from .response_handler import ( + DecodeBody, + DecodeSuccess, + ResponseDisposition, + ResponseHandler, + classify_status, +) from .serde import Deserializer, Serde, Serializer from .tristate import ( ABSENT, @@ -29,6 +36,7 @@ of_optional, present, ) +from .type_ref import TypeRef __all__ = [ "ABSENT", @@ -39,14 +47,20 @@ "REGISTRY_KEY", "Codec", "CodecError", + "DecodeBody", + "DecodeSuccess", "Deserializer", "JsonDeserializer", "JsonSerde", "JsonSerializer", "Present", + "ResponseDisposition", + "ResponseHandler", "Serde", "Serializer", "Tristate", + "TypeRef", + "classify_status", "discriminated", "field_alias", "fold", diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/codec.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/codec.py index 80e0afb..c2d7615 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/codec.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/codec.py @@ -31,6 +31,7 @@ from ..errors import DeserializationError, SerializationError from .tristate import ABSENT, NULL, Present, Tristate +from .type_ref import TypeRef if typing.TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -248,15 +249,18 @@ def __init__(self, *, tolerate_unknown: bool = True) -> None: """ self._tolerate_unknown = tolerate_unknown - def decode[T](self, data: object, target: type[T]) -> T: + def decode[T](self, data: object, target: type[T] | TypeRef[T]) -> T: """Decode a plain document into an instance of ``target``. Args: data: A plain document (``dict`` / ``list`` / scalar) as produced by ``Serde.deserializer``. - target: The type to reconstruct — a dataclass, a discriminated base, - ``list[X]`` / ``dict[str, X]``, a datetime/UUID/enum, or a - scalar. + target: The type to reconstruct, given as a runtime type witness — a + dataclass, a discriminated base, ``list[X]`` / ``dict[str, X]``, + a datetime/UUID/enum, or a scalar. A subscripted generic such as + ``list[X]`` may be passed directly; wrap it in a ``TypeRef`` to + have the witness validated (and any PEP 695 alias resolved) + eagerly before decoding. Returns: A fully constructed instance of ``target``. @@ -265,7 +269,8 @@ def decode[T](self, data: object, target: type[T]) -> T: CodecError: On any structural mismatch or conversion failure, with a wire-name path pointing at the offending location. """ - return cast("T", _decode_value(data, target, (), self._tolerate_unknown, 0)) + witness: object = target.witness if isinstance(target, TypeRef) else target + return cast("T", _decode_value(data, witness, (), self._tolerate_unknown, 0)) def encode(self, value: object) -> object: """Encode a typed value into a plain document. @@ -368,6 +373,46 @@ def _decode_atomic( return _decode_temporal(data, target, path) if issubclass(target, uuid.UUID): return _decode_uuid(data, target, path) + return _decode_scalar(data, target, path) + return data + + +def _decode_scalar(data: object, target: type, path: tuple[str, ...]) -> object: + """Bind a wire scalar to a plain scalar target under the no-coercion rule. + + The codec performs no implicit coercion, so a wire scalar whose Python type + does not match ``target`` passes through unchanged — a wire ``'5'`` bound to + ``int`` stays the string ``'5'``, and a wire ``1.5`` bound to ``int`` stays + the float ``1.5``. Two cases are singled out: + + - A wire ``null`` bound to a non-``Optional`` target is a genuine shape + error, not a value to pass through, so it is rejected with the target type + named. An optional target routes through the union branch and never + reaches here for a ``null`` payload. + - A wire ``int`` bound to a ``float`` target is widened losslessly to + ``float`` (``bool`` is excluded, being only nominally an ``int``). This is + the one numeric widening JSON's number model makes safe. + + Args: + data: The wire scalar to bind. + target: The plain scalar type to bind against. + path: Wire-name breadcrumb to this location. + + Returns: + ``data`` unchanged, or its ``float`` widening for an ``int`` under a + ``float`` target. + + Raises: + CodecError: If ``data`` is ``None`` and ``target`` is non-optional. + """ + if data is None: + raise CodecError( + f"expected {target.__name__}, got null", + path=path, + target_name=target.__name__, + ) + if target is float and type(data) is int: + return float(data) return data diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/json_serde.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/json_serde.py index 3a23634..ece67f8 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/json_serde.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/json_serde.py @@ -8,9 +8,13 @@ import json from collections.abc import Callable from datetime import date, datetime, time -from typing import Any, BinaryIO, Final +from typing import TYPE_CHECKING, Any, BinaryIO, Final from ..errors import DeserializationError, SerializationError +from ..http.common import common_media_types + +if TYPE_CHECKING: + from ..http.common.media_type import MediaType class _JsonEncoder(json.JSONEncoder): @@ -129,6 +133,41 @@ def serialize_to_stream(self, value: Any, stream: BinaryIO) -> None: """ stream.write(self.serialize_to_bytes(value)) + def serialize_into(self, value: Any, buffer: bytearray | memoryview, offset: int = 0) -> int: + """Serialise ``value`` into a caller-owned buffer at ``offset``. + + Encodes to bytes first, so an unserialisable value surfaces as + ``SerializationError`` before any byte is written. Overflowing the + buffer is a caller/range error, not a serde-shape problem, so it raises + ``ValueError`` rather than a ``SerdeError``. The buffer is never grown. + + Args: + value: The value to serialise. + buffer: A writable buffer (``bytearray`` or a writable + ``memoryview``) to receive the encoded bytes. + offset: Index at which to begin writing; must be non-negative. + + Returns: + The number of bytes written. + + Raises: + SerializationError: If ``value`` cannot be encoded. + ValueError: If ``offset`` is negative, or the encoded bytes do not + fit in ``buffer`` starting at ``offset``. + """ + if offset < 0: + raise ValueError(f"offset must be non-negative, got {offset}") + data = self.serialize_to_bytes(value) + view = memoryview(buffer) + end = offset + len(data) + if end > len(view): + raise ValueError( + f"buffer too small: {len(data)} byte(s) at offset {offset} need " + f"{end} byte(s), buffer holds {len(view)}", + ) + view[offset:end] = data + return len(data) + class JsonDeserializer: """Deserialise JSON strings / bytes / streams into Python values.""" @@ -184,6 +223,7 @@ class JsonSerde: Attributes: serializer: The JSON encoder. deserializer: The JSON decoder. + media_type: The wire content type, ``application/json``. """ __slots__ = ("_deserializer", "_serializer") @@ -204,6 +244,16 @@ def serializer(self) -> JsonSerializer: def deserializer(self) -> JsonDeserializer: return self._deserializer + @property + def media_type(self) -> MediaType: + """The media type this bundle reads and writes: ``application/json``. + + Declared explicitly by the bundle rather than defaulted at the ``Serde`` + SPI boundary, so a consumer wiring a request's ``Content-Type`` reads it + from the concrete serde instead of assuming JSON. + """ + return common_media_types.APPLICATION_JSON + #: Module-level default ``JsonSerde`` — use for vanilla encoder/decoder needs. JSON_SERDE: Final[JsonSerde] = JsonSerde() diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/response_handler.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/response_handler.py new file mode 100644 index 0000000..cfc20b0 --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/response_handler.py @@ -0,0 +1,292 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Response handlers: turn a received `Response` into a typed result. + +A `ResponseHandler` is the last hop of a call — it consumes the `Response` a +transport produced and returns the caller's result type (or raises). Two +handlers ship, both organised around one pure decision: + +- `classify_status` is the single pure classification function. Given a + response's status it returns a `ResponseDisposition` — ``SUCCESS`` for 2xx, + ``ERROR`` for 4xx/5xx, ``UNEXPECTED`` for the 1xx/3xx in-between. It has no + side effects: it never reads, closes, or raises. This is the *decision*. +- `DecodeBody` and `DecodeSuccess` are the *orchestration*. `DecodeBody` + streams the body into the target type regardless of status, closing the + response on every path. `DecodeSuccess` dispatches on `classify_status`: + a 2xx delegates to `DecodeBody`, a 4xx/5xx raises the mapped typed error + through a `StatusErrorMap`, and a 1xx/3xx raises a status-led error. Both + share the one decode action (`DecodeSuccess` holds a `DecodeBody`), so the + "decode a 2xx body" behaviour is defined once. + +The call signature is positional-only — ``__call__(response, response_type, /)`` +— so an alternate handler (a raw byte download, a HEAD request that decodes +nothing) can be swapped in wherever a `ResponseHandler` is expected. +""" + +from __future__ import annotations + +import enum +from typing import TYPE_CHECKING, Any, NoReturn, Protocol, runtime_checkable + +from ..errors import DeserializationError, HttpResponseError +from .codec import Codec +from .json_serde import JSON_SERDE +from .type_ref import TypeRef + +if TYPE_CHECKING: + from ..codegen.status_error_map import StatusErrorMap + from ..http.response.response import Response + from ..http.response.status import Status + from .serde import Serde + + +class ResponseDisposition(enum.Enum): + """How a response's status classifies for handling. + + The three-way outcome of `classify_status`, keyed off the status band: + + Attributes: + SUCCESS: A 2xx response — decode the body into the target type. + ERROR: A 4xx/5xx response — raise the mapped typed error. + UNEXPECTED: A 1xx/3xx response — neither a success to decode nor a + mapped error; a success-only handler raises a status-led error. + """ + + SUCCESS = enum.auto() + ERROR = enum.auto() + UNEXPECTED = enum.auto() + + +def classify_status(status: Status) -> ResponseDisposition: + """Classify an HTTP status into a handling disposition. + + The single pure decision the response handlers are built over. It reads + only the status band and has no side effects — it never touches the body, + closes the response, or raises — so the decision can be tested in isolation + from the handlers that act on it. + + Args: + status: The response's HTTP status. + + Returns: + `ResponseDisposition.SUCCESS` for a 2xx status, + `ResponseDisposition.ERROR` for a 4xx/5xx status, and + `ResponseDisposition.UNEXPECTED` for the 1xx/3xx in-between. + """ + if status.is_success: + return ResponseDisposition.SUCCESS + if status.is_error: + return ResponseDisposition.ERROR + return ResponseDisposition.UNEXPECTED + + +@runtime_checkable +class ResponseHandler(Protocol): + """A terminal step that turns a `Response` into the caller's result. + + Implementations receive the response and the requested result type and + return whatever they produce (a decoded model, raw bytes, ``None`` for a + HEAD request, ...) or raise. The call is positional-only so handlers stay + interchangeable regardless of how they name their parameters, and the + return type is deliberately `Any` so a non-decoding handler (a raw + download) is just as valid a `ResponseHandler` as a decoding one. + """ + + def __call__(self, response: Response, response_type: type[Any] | TypeRef[Any], /) -> Any: + """Handle ``response``, producing the caller's result. + + Args: + response: The received response. The handler owns closing it. + response_type: The requested result type — a class, a subscripted + generic (``list[Pet]``), or a `TypeRef`. A handler that decodes + nothing may ignore it. + + Returns: + The handler's result. + """ + ... + + +def _type_name(response_type: object) -> str: + """Return a readable name for a target type, for error messages. + + Args: + response_type: The requested result type — a class, a subscripted + generic, or a `TypeRef` wrapping one. + + Returns: + The type's ``__name__`` when it has one, else its ``str()``. A + `TypeRef` is unwrapped to its resolved witness first. + """ + target = response_type.witness if isinstance(response_type, TypeRef) else response_type + name = getattr(target, "__name__", None) + return name if isinstance(name, str) else str(target) + + +class DecodeBody: + """Decode the response body into the target type, whatever the status. + + Reads the body once, deserialises it through the configured `Serde`, and + decodes the resulting document into the target type with the `Codec`. The + response is consumed and closed on every path — a clean decode, a decode + failure, and a missing body alike: + + - a missing body raises `DeserializationError` naming the target type; + - a decode-shape failure (bad wire syntax, a document that does not fit the + target) propagates as its `SerdeError`-family error, chained to its + cause, never swallowed; and + - a genuine `OSError` reading the body (a real I/O failure, not a + decode-shape problem) propagates unwrapped. + """ + + __slots__ = ("_codec", "_serde") + + def __init__(self, *, serde: Serde | None = None, codec: Codec | None = None) -> None: + """Configure the handler. + + Args: + serde: Wire-format decoder for the raw body bytes. Defaults to JSON. + codec: Document → typed-model codec. Defaults to a fresh `Codec`. + """ + self._serde: Serde = serde if serde is not None else JSON_SERDE + self._codec = codec if codec is not None else Codec() + + def __call__[T](self, response: Response, response_type: type[T] | TypeRef[T], /) -> T: + """Decode ``response``'s body into ``response_type`` and close it. + + Args: + response: The received response; closed before this returns or + raises, on every path. + response_type: The type to decode into. + + Returns: + The decoded value. + + Raises: + DeserializationError: If the response has no body (the message + names ``response_type``), or if the body cannot be decoded. + OSError: If reading the body fails with a genuine I/O error. + """ + try: + return self._decode(response, response_type) + finally: + response.close() + + def _decode[T](self, response: Response, response_type: type[T] | TypeRef[T]) -> T: + """Read, deserialise, and decode the body (no closing — the caller does).""" + body = response.body + if body is None: + raise DeserializationError( + f"cannot decode a missing response body into {_type_name(response_type)}" + ) + document = self._serde.deserializer.deserialize_bytes(body.bytes()) + return self._codec.decode(document, response_type) + + +class DecodeSuccess: + """Decode only 2xx responses; raise a typed error for everything else. + + Dispatches on the pure `classify_status` decision: + + - ``SUCCESS`` (2xx): delegates to a `DecodeBody` — the body is decoded into + the target type, and the response is closed on every path. + - ``ERROR`` (4xx/5xx): raises the typed error the `StatusErrorMap` maps the + status to, which carries a bounded, replayable copy of the error body. + - ``UNEXPECTED`` (1xx/3xx): raises an `HttpResponseError` whose message + leads with the status code, since a redirect or informational response is + neither a decodable success nor a mapped error. + + The response is closed on every non-success path here, and on the success + path by the delegated `DecodeBody`. + """ + + __slots__ = ("_decode_body", "_errors") + + def __init__( + self, + errors: StatusErrorMap, + *, + serde: Serde | None = None, + codec: Codec | None = None, + ) -> None: + """Configure the handler. + + Args: + errors: Status → typed-error mapping consulted for 4xx/5xx + responses. Its `raise_for` buffers the bounded error body. + serde: Wire-format decoder for a 2xx body. Defaults to JSON. + codec: Document → typed-model codec for a 2xx body. Defaults to a + fresh `Codec`. + """ + self._errors = errors + self._decode_body = DecodeBody(serde=serde, codec=codec) + + def __call__[T](self, response: Response, response_type: type[T] | TypeRef[T], /) -> T: + """Decode a 2xx ``response`` into ``response_type``, else raise. + + Args: + response: The received response; closed on every path. + response_type: The type a 2xx body is decoded into. + + Returns: + The decoded value for a 2xx response. + + Raises: + HttpResponseError: For a 4xx/5xx response (the mapped typed error, + carrying the bounded body) or a 1xx/3xx response (a status-led + error). + DeserializationError: If a 2xx response has no body or cannot be + decoded. + """ + disposition = classify_status(response.status) + if disposition is ResponseDisposition.SUCCESS: + return self._decode_body(response, response_type) + try: + self._raise_non_success(response, disposition) + finally: + response.close() + + def _raise_non_success(self, response: Response, disposition: ResponseDisposition) -> NoReturn: + """Raise the typed error for a non-success response. + + Args: + response: The received non-success response. + disposition: The `classify_status` decision — ``ERROR`` or + ``UNEXPECTED`` (``SUCCESS`` is handled before this is called). + + Raises: + HttpResponseError: The mapped error for a 4xx/5xx status (via + `StatusErrorMap.raise_for`), or a status-led error for a + 1xx/3xx status. + """ + if disposition is ResponseDisposition.ERROR: + self._errors.raise_for(response) + raise _unexpected_status_error(response) + + +def _unexpected_status_error(response: Response) -> HttpResponseError[Any]: + """Build a status-led error for a 1xx/3xx response a success handler rejects. + + Args: + response: The received informational/redirect response. + + Returns: + An `HttpResponseError` carrying ``response``, whose message leads with + the numeric status code. + """ + status = response.status + return HttpResponseError( + f"{int(status)} {status.name}: unexpected response status; a success handler " + f"decodes only 2xx responses and raises the mapped error for 4xx/5xx", + response=response, + ) + + +__all__ = [ + "DecodeBody", + "DecodeSuccess", + "ResponseDisposition", + "ResponseHandler", + "classify_status", +] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/serde.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/serde.py index 0e1d361..bd0a070 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/serde.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/serde.py @@ -5,7 +5,10 @@ from __future__ import annotations -from typing import Any, BinaryIO, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, BinaryIO, Protocol, runtime_checkable + +if TYPE_CHECKING: + from ..http.common.media_type import MediaType @runtime_checkable @@ -48,6 +51,10 @@ class Serde(Protocol): pull a `Serde` rather than separate serializer and deserializer references, which keeps the dependency surface flat and makes it easy to swap formats at the edge of the SDK. + + Each concrete bundle declares its own `media_type`; the SPI supplies no + default, so a consumer wiring a request's ``Content-Type`` reads the wire + format from the serde rather than assuming one. """ @property @@ -56,5 +63,8 @@ def serializer(self) -> Serializer: ... @property def deserializer(self) -> Deserializer: ... + @property + def media_type(self) -> MediaType: ... + __all__ = ["Deserializer", "Serde", "Serializer"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/tristate.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/tristate.py index cd529e9..3ea83ce 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/tristate.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/tristate.py @@ -70,13 +70,29 @@ def __reduce__(self) -> str: class Present[T]: """A real, present value within a ``Tristate``. + ``None`` is rejected at construction: "present but null" is the ``NULL`` + variant, so ``Present(None)`` would make the three inhabitants overlap and + reintroduce the very ``None``-means-absent ambiguity ``Tristate`` exists to + remove. ``__match_args__`` (supplied by the dataclass) is ``("value",)``, + so ``case Present(v):`` binds the wrapped value in a ``match`` statement. + Attributes: value: The wrapped value. May itself be falsy (``0``, ``""``, ``[]``); - presence is encoded by the wrapper, never by truthiness. + presence is encoded by the wrapper, never by truthiness. Never + ``None``. """ value: T + def __post_init__(self) -> None: + """Reject ``Present(None)``; use ``NULL`` for an explicit null. + + Raises: + ValueError: If ``value`` is ``None``. + """ + if self.value is None: + raise ValueError("Present(None) is invalid; use NULL for an explicit null") + ABSENT: Final[_Absent] = _Absent() """The key was omitted entirely — serialize by skipping the key.""" @@ -94,9 +110,13 @@ def present[T](value: T) -> Present[T]: Args: value: The value to wrap; falsy values are preserved as present. + ``None`` is rejected — use ``NULL`` for an explicit null. Returns: A ``Present`` holding ``value``. + + Raises: + ValueError: If ``value`` is ``None``. """ return Present(value) diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/type_ref.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/type_ref.py new file mode 100644 index 0000000..b1c706d --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/serde/type_ref.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Validated runtime type witnesses for the codec. + +A *type witness* is an ordinary Python runtime object that stands in for the +type a caller wants a document decoded into. A subscripted generic such as +``list[Pet]`` is a real object at runtime — introspectable with +``typing.get_origin`` / ``typing.get_args`` — so it can carry its element type +all the way into the decode path instead of relying on fragile string hints. + +``TypeRef`` is a thin wrapper around such a witness that validates it *eagerly*, +at construction time, so a broken witness fails at the call site that supplied +it rather than deep inside decoding. It rejects two unusable shapes: + +- a bare type parameter (``TypeVar`` / ``ParamSpec`` / ``TypeVarTuple``), which + names no concrete type; and +- an unparameterized generic alias (``typing.List``, ``typing.Dict``, ...), + which carries no element type. + +PEP 695 ``type X = list[Pet]`` alias statements are supported: their +``__value__`` is *lazy*, so ``TypeRef`` forces it (following alias-to-alias +chains) during construction rather than deferring the resolution. + +Witnesses are objects, not annotations +-------------------------------------- + +Because every module in this codebase uses ``from __future__ import +annotations``, user-written annotations on functions and dataclass fields are +*strings* at runtime, not type objects. A witness must therefore be an actual +object passed explicitly at the call site — for example ``response.parse( +list[Pet])`` or ``TypeRef(list[Pet])`` — and can never be harvested from a +function's or class's string annotations. ``TypeRef`` deals exclusively in the +former: live type objects handed to it directly. +""" + +from __future__ import annotations + +import dataclasses +import typing +from typing import ParamSpec, TypeVar, TypeVarTuple, final, get_args, get_origin + +_MAX_ALIAS_DEPTH: typing.Final = 100 +"""Ceiling on PEP 695 alias-chain resolution. + +A self-referential alias (``type X = X``) yields a ``__value__`` that is the +alias itself, so naive resolution would spin forever. The guard trips instead +and surfaces a clean ``TypeError``. +""" + + +@final +@dataclasses.dataclass(frozen=True, slots=True, init=False) +class TypeRef[T]: + """An eagerly validated runtime witness of a target type ``T``. + + Construct one from any concrete type object — a plain class (``Pet``), a + subscripted generic (``list[Pet]``, ``dict[str, Pet]``), or a PEP 695 + ``type`` alias whose value is such a type. The witness is validated and, + for a PEP 695 alias, resolved to its underlying value at construction time; + an unusable witness raises ``TypeError`` immediately. + + The static type parameter ``T`` is a phantom marker: it lets + ``Codec.decode`` return the witnessed type without a cast at the call site, + while the resolved witness object is what the codec actually introspects. + + Attributes: + witness: The resolved runtime type object (aliases already forced). + """ + + witness: object + + def __init__(self, witness: type[T], /) -> None: + """Resolve and validate ``witness``, storing the concrete type object. + + Args: + witness: The type object to wrap — a class, a subscripted generic, + or a PEP 695 ``type`` alias resolving to one. + + Raises: + TypeError: If ``witness`` is a bare type parameter, an + unparameterized generic alias, or a cyclic ``type`` alias. The + message names the offending object. + """ + resolved = _resolve_alias(witness) + _reject_unusable(resolved) + object.__setattr__(self, "witness", resolved) + + @property + def origin(self) -> object | None: + """The witness's generic origin, or ``None`` for a plain type. + + For ``list[Pet]`` this is ``list``; for ``Pet`` it is ``None`` — the + same value ``typing.get_origin`` returns for the resolved witness. + """ + return get_origin(self.witness) + + @property + def args(self) -> tuple[object, ...]: + """The witness's type arguments, e.g. ``(Pet,)`` for ``list[Pet]``. + + Empty for a non-parameterized witness. Mirrors ``typing.get_args`` over + the resolved witness. + """ + return get_args(self.witness) + + +def _resolve_alias(witness: object) -> object: + """Force a PEP 695 ``type`` alias to its underlying value. + + Follows alias-to-alias chains, since one alias may be defined in terms of + another. A non-alias witness is returned unchanged. + + Args: + witness: The candidate witness, possibly a lazy ``TypeAliasType``. + + Returns: + The witness with every ``type`` alias hop resolved. + + Raises: + TypeError: If resolution does not terminate within ``_MAX_ALIAS_DEPTH`` + hops — a cyclic alias (``type X = X``) or one nested absurdly deep. + """ + resolved = witness + for _ in range(_MAX_ALIAS_DEPTH): + if not isinstance(resolved, typing.TypeAliasType): + return resolved + resolved = resolved.__value__ + raise TypeError( + f"TypeRef witness {_display(witness)!r} did not resolve to a concrete type " + f"within {_MAX_ALIAS_DEPTH} alias hops; it may be a cyclic type alias", + ) + + +def _reject_unusable(witness: object) -> None: + """Raise if ``witness`` cannot serve as a concrete type witness. + + Args: + witness: A resolved witness (aliases already forced). + + Raises: + TypeError: If ``witness`` is a bare type parameter or an + unparameterized generic alias, with a message naming it. + """ + if isinstance(witness, (TypeVar, ParamSpec, TypeVarTuple)): + raise TypeError( + f"TypeRef witness {_display(witness)!r} is a bare type parameter; " + f"pass a concrete type such as list[str], not an unbound type variable", + ) + if get_origin(witness) is not None and not get_args(witness): + raise TypeError( + f"TypeRef witness {_display(witness)!r} is an unparameterized generic; " + f"subscript it with concrete type arguments, e.g. list[str] rather than list", + ) + + +def _display(witness: object) -> str: + """Return a readable name for ``witness`` for use in error messages.""" + name = getattr(witness, "__name__", None) + return name if isinstance(name, str) else repr(witness) + + +__all__ = ["TypeRef"] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/__init__.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/__init__.py index 7037ca5..c5f280a 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/__init__.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/__init__.py @@ -3,22 +3,33 @@ """Cross-cutting utilities used throughout the SDK core. -Exports the `Clock` / `AsyncClock` abstractions that let -time-dependent code (retry backoff, token expiry) be driven deterministically -in tests, and the `ProxyOptions` value type used to describe outbound -HTTP / SOCKS proxies in a transport-agnostic way. +Exports the `Clock` / `AsyncClock` abstractions (and the `CancellationToken` +interruptible-sleep handle) that let time-dependent code (retry backoff, token +expiry) be driven deterministically in tests, the `format_http_date` / +`parse_http_date` RFC 1123 date helpers, and the `ProxyOptions` value type used +to describe outbound HTTP / SOCKS proxies in a transport-agnostic way. """ from __future__ import annotations -from .clock import ASYNC_SYSTEM_CLOCK, SYSTEM_CLOCK, AsyncClock, Clock +from .clock import ( + ASYNC_SYSTEM_CLOCK, + SYSTEM_CLOCK, + AsyncClock, + CancellationToken, + Clock, +) +from .http_date import format_http_date, parse_http_date from .proxy import ProxyOptions, ProxyType __all__ = [ "ASYNC_SYSTEM_CLOCK", "SYSTEM_CLOCK", "AsyncClock", + "CancellationToken", "Clock", "ProxyOptions", "ProxyType", + "format_http_date", + "parse_http_date", ] diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/clock.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/clock.py index ff5be3e..7668477 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/clock.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/clock.py @@ -17,6 +17,8 @@ from __future__ import annotations import asyncio +import math +import threading import time from typing import Protocol, runtime_checkable @@ -24,6 +26,7 @@ "ASYNC_SYSTEM_CLOCK", "SYSTEM_CLOCK", "AsyncClock", + "CancellationToken", "Clock", ] @@ -128,3 +131,57 @@ async def sleep(self, duration: float) -> None: ASYNC_SYSTEM_CLOCK: AsyncClock = _AsyncSystemClock() """Process-wide default `AsyncClock` backed by `asyncio`.""" + + +class CancellationToken: + """Cooperative cancellation handle enabling an interruptible sleep. + + Backed by a single shared `threading.Event`. A thread blocked in `sleep` + wakes immediately when another thread (or a scheduled callback) calls + `cancel`, so a long wait can be aborted cooperatively rather than running + to completion. Cancellation is one-way and permanent — once cancelled, the + token stays cancelled. + + Contrast with `Clock.sleep`, which treats a non-positive duration as a + lenient no-op: the interruptible `sleep` here rejects negative (and NaN) + durations, because a negative wait in cancellation-aware code signals a + programming error rather than "don't wait". + """ + + __slots__ = ("_event",) + + def __init__(self) -> None: + """Initialise an un-cancelled token.""" + self._event = threading.Event() + + @property + def cancelled(self) -> bool: + """Return whether `cancel` has been called.""" + return self._event.is_set() + + def cancel(self) -> None: + """Cancel the token, waking any in-progress `sleep` immediately. + + Idempotent — cancelling an already-cancelled token is a no-op. + """ + self._event.set() + + def sleep(self, duration: float) -> bool: + """Block up to ``duration`` seconds, returning early if cancelled. + + Args: + duration: Seconds to wait. Zero returns immediately. A positive + infinity waits until cancelled. Negative or NaN durations are + rejected. + + Returns: + ``True`` if the full duration elapsed without cancellation; + ``False`` if the token was (or became) cancelled first. + + Raises: + ValueError: If ``duration`` is negative or NaN. + """ + if math.isnan(duration) or duration < 0: + raise ValueError(f"sleep duration must be non-negative, got {duration!r}") + cancelled = self._event.wait(duration) + return not cancelled diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/http_date.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/http_date.py new file mode 100644 index 0000000..e1ee27d --- /dev/null +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/http_date.py @@ -0,0 +1,62 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""RFC 1123 HTTP-date rendering and tolerant parsing. + +A single, reusable home for the HTTP-date logic that several components need: +the canonical ``Date`` / ``Expires`` render (RFC 1123 / IMF-fixdate, always in +GMT) and a tolerant parser that accepts the three date formats HTTP permits +(RFC 1123, the obsolete RFC 850, and the C ``asctime`` form) per RFC 9110. + +The functions are pure — no I/O, no clock reads — so callers that already hold +a ``datetime`` can render and parse without pulling in a policy or a transport. +Parsing never raises: an unrecognisable value yields ``None`` so callers decide +how to degrade. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from email.utils import format_datetime, parsedate_to_datetime + +__all__ = ["format_http_date", "parse_http_date"] + + +def format_http_date(value: datetime) -> str: + """Render ``value`` as a canonical RFC 1123 / IMF-fixdate string in GMT. + + A naive ``datetime`` is assumed to already be UTC; an aware ``datetime`` is + converted to UTC. The output always ends in ``GMT`` (for example + ``Sun, 06 Nov 1994 08:49:37 GMT``), the preferred HTTP-date form. + + Args: + value: The timestamp to render. + + Returns: + The RFC 1123 string representation of ``value`` in GMT. + """ + aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + return format_datetime(aware, usegmt=True) + + +def parse_http_date(value: str) -> datetime | None: + """Parse an HTTP-date string into a timezone-aware ``datetime``. + + Accepts all three HTTP-date formats (RFC 1123, RFC 850, and the C + ``asctime`` form), which are GMT by definition. A value without an explicit + timezone is interpreted as UTC. Parsing is tolerant: any unrecognisable or + non-string input yields ``None`` rather than raising. + + Args: + value: A candidate HTTP-date string. + + Returns: + A timezone-aware ``datetime``, or ``None`` if ``value`` did not parse. + """ + try: + parsed = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed diff --git a/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/proxy.py b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/proxy.py index bfa2509..212d5e4 100644 --- a/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/proxy.py +++ b/packages/dexpace-sdk-core/src/dexpace/sdk/core/util/proxy.py @@ -9,35 +9,55 @@ matching is a plain comparison. Bypass entries follow the conventional ``NO_PROXY`` suffix semantics used by -curl, requests, and Go: a bare entry such as ``example.com`` bypasses the -host itself and any dot-delimited subdomain (``api.example.com``); a leading -dot (``.example.com``) is treated identically. A trailing ``:port`` on either -the entry or the candidate host is ignored, so matching is host-only. Entries -containing a glob metacharacter (``*`` / ``?`` / ``[``) keep their ``fnmatch`` -behaviour so existing ``*.example.com`` style patterns continue to work. - -The ``ProxyOptions.from_configuration`` factory bridges the proxy value -type to the layered ``Configuration`` lookup: it reads ``HTTPS_PROXY`` -(preferred) or ``HTTP_PROXY`` as proxy URLs and ``NO_PROXY`` as a -comma-separated bypass list. The URL scheme selects the transport flavour, a -missing port defaults by scheme, scheme-less ``host:port`` forms are accepted, -and percent-encoded credentials are decoded. Bad proxy configuration degrades -to ``None`` rather than raising — but because a silently-unused proxy is an -outage-grade misconfiguration, an unusable value is logged at WARNING. +curl, requests, and Go. Precedence and escaping rules: + +- A single bare ``*`` short-circuits to "bypass everything" — every candidate + host is bypassed and no per-host glob matching runs. +- An entry containing a glob metacharacter (``*`` / ``?`` / ``[``) keeps + ``fnmatch`` semantics, so ``*.example.com`` style patterns work. To match a + *literal* metacharacter, wrap it in a character class — ``[*]`` matches a + literal ``*`` — which is ``fnmatch``'s escape mechanism. +- Every other (bare) entry such as ``example.com`` bypasses the host itself + and any dot-delimited subdomain (``api.example.com``); a leading dot + (``.example.com``) is treated identically. +- A trailing ``:port`` on either the entry or the candidate host is ignored, + so matching is host-only and case-insensitive over the full host string. +- The comma-separated ``NO_PROXY`` list honours a backslash escape before a + literal comma (``a\\,b`` is one entry ``a,b``); parsing splits, drops + zero-length fragments, then unescapes and trims — so a whitespace-only + fragment is retained as an empty token rather than dropped. + +The ``ProxyOptions.from_configuration`` factory bridges the proxy value type +to the layered ``Configuration`` lookup: it reads ``HTTPS_PROXY`` (preferred) +or ``HTTP_PROXY`` as proxy URLs and ``NO_PROXY`` as a comma-separated bypass +list. The URL scheme selects the transport flavour, scheme-less ``host:port`` +forms are accepted, and percent-encoded credentials are decoded. Host and port +are always taken from the *same* resolved URL (never mixed across layers), a +missing port stays ``None`` rather than being fabricated, and credentials are +attached only over an ``https`` proxy connection. Bad proxy configuration +degrades to ``None`` rather than raising — but because a silently-unused proxy +is an outage-grade misconfiguration, an unusable value is logged at WARNING +(with any credentials redacted from the logged URL). + +Environment resolution is opt-in: the plain constructor and matching methods +never read ``os.environ``. Callers that want env-based resolution ask for it +explicitly through ``from_environment`` (or ``from_configuration`` with an +env-backed ``Configuration``); the env seam is injectable for hermetic tests. """ from __future__ import annotations import fnmatch import logging +import os import re from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum -from typing import Self +from typing import Final, Self from urllib.parse import SplitResult, unquote, urlsplit -from ..config.configuration import Configuration +from ..config.configuration import Configuration, EnvSource __all__ = ["ProxyOptions", "ProxyType"] @@ -47,15 +67,29 @@ # Glob metacharacters that switch an entry into ``fnmatch`` mode. _GLOB_CHARS: frozenset[str] = frozenset("*?[") -# Default proxy port per URL scheme when the proxy URL omits one. SOCKS -# proxies conventionally listen on 1080. -_DEFAULT_PORTS: dict[str, int] = { - "http": 80, - "https": 443, - "socks4": 1080, - "socks5": 1080, - "socks5h": 1080, -} +# Matches the ``userinfo`` (``user:pass``) component of a URL — either after a +# ``//`` authority marker or at the start of a scheme-less ``user:pass@host`` +# form — so it can be redacted before a URL is written to a log line. +_USERINFO_RE = re.compile(r"(?:^|//)([^/@]*)@") + + +def _redact_userinfo(url: str) -> str: + """Mask any ``user:pass@`` credentials in a URL so logs never leak them. + + Handles ``scheme://user:pass@host``, ``//user:pass@host``, and scheme-less + ``user:pass@host:port`` forms alike. A URL without userinfo is returned + unchanged. This is a best-effort log-safety redaction, not a URL parser. + + Args: + url: A proxy URL, possibly carrying credentials. + + Returns: + The URL with any userinfo component replaced by ``***``. + """ + match = _USERINFO_RE.search(url) + if match is None: + return url + return url[: match.start(1)] + "***" + url[match.end(1) :] def _strip_port(host: str) -> str: @@ -159,44 +193,135 @@ class ProxyType(StrEnum): } -def _resolve_endpoint(proxy_url: str) -> tuple[SplitResult, ProxyType, str, int] | None: +def _resolve_endpoint(proxy_url: str) -> tuple[SplitResult, ProxyType, str, int | None] | None: """Resolve a proxy URL into its parsed parts, type, host, and port. - Applies scheme→type mapping, scheme-by-default port resolution, and - scheme-less ``host:port`` handling. Any value that cannot yield a usable - endpoint is logged at WARNING (a silently-disabled proxy is outage-grade) - and yields ``None``. + Applies scheme->type mapping and scheme-less ``host:port`` handling. The + port is taken verbatim from the URL: an explicit in-range port, or ``None`` + when the URL omits one — never a fabricated per-scheme default. Any value + that cannot yield a usable endpoint is logged at WARNING (a silently- + disabled proxy is outage-grade) with credentials redacted, and yields + ``None``. Args: proxy_url: The raw proxy URL string. Returns: - A ``(SplitResult, ProxyType, host, port)`` tuple, or ``None`` if the - URL is unusable. + A ``(SplitResult, ProxyType, host, port)`` tuple where ``port`` is an + ``int`` or ``None``, or ``None`` if the URL is unusable. """ + safe = _redact_userinfo(proxy_url) try: split = _split_proxy_url(proxy_url) except ValueError: - _LOG.warning("ignoring proxy URL %r: failed to parse", proxy_url) + _LOG.warning("ignoring proxy URL %s: failed to parse", safe) return None scheme = split.scheme.lower() proxy_type = _SCHEME_TO_TYPE["http"] if scheme == "" else _SCHEME_TO_TYPE.get(scheme) if proxy_type is None: - _LOG.warning("ignoring proxy URL %r: unsupported scheme %r", proxy_url, scheme) + _LOG.warning("ignoring proxy URL %s: unsupported scheme %r", safe, scheme) return None - if not split.hostname: - _LOG.warning("ignoring proxy URL %r: missing hostname", proxy_url) + if not split.hostname or any(char.isspace() for char in split.hostname): + _LOG.warning("ignoring proxy URL %s: missing or invalid hostname", safe) return None try: port = split.port except ValueError: - _LOG.warning("ignoring proxy URL %r: invalid port", proxy_url) + _LOG.warning("ignoring proxy URL %s: invalid port", safe) return None - if port is None: - port = _DEFAULT_PORTS.get(scheme, 80) return split, proxy_type, split.hostname, port +def _extract_credentials(split: SplitResult) -> tuple[str | None, str | None]: + """Return decoded proxy credentials, but only over an ``https`` proxy. + + A proxy credential is attached only when the proxy URL scheme is + ``https``. Sending a Proxy-Authorization credential over a plaintext + proxy connection (``http``, scheme-less, or SOCKS) would expose it on the + wire, so those schemes drop the credential rather than leak it. Present + credentials are percent-decoded. + + Args: + split: The parsed proxy URL. + + Returns: + A ``(username, password)`` tuple; either or both may be ``None``. + """ + if split.scheme.lower() != "https": + return None, None + username = unquote(split.username) if split.username is not None else None + password = unquote(split.password) if split.password is not None else None + return username, password + + +# The environment-variable ``NO_PROXY`` form is comma-separated. A backslash +# immediately before a comma escapes it into a literal (rare, but honoured for +# hosts whose label legitimately carries the separator). +_NO_PROXY_SEPARATOR: Final = "," +_ESCAPED_SEPARATOR: Final = "\\" + _NO_PROXY_SEPARATOR + + +def _split_no_proxy(raw: str) -> list[str]: + """Split a ``NO_PROXY`` value on commas, honouring a backslash escape. + + A backslash immediately preceding a comma escapes it: the comma stays + inside the current fragment (carried through as ``\\,``) instead of ending + it. The escape backslash is left in place here — ``_parse_no_proxy`` + unescapes only after the empty-fragment drop, so the observable order stays + split -> drop empty -> unescape -> trim. + + Args: + raw: The raw comma-separated ``NO_PROXY`` value. + + Returns: + The raw fragments in order, still carrying their escape backslashes. + """ + fragments: list[str] = [] + buffer: list[str] = [] + index = 0 + length = len(raw) + while index < length: + char = raw[index] + if char == "\\" and index + 1 < length and raw[index + 1] == _NO_PROXY_SEPARATOR: + buffer.append(_ESCAPED_SEPARATOR) + index += 2 + elif char == _NO_PROXY_SEPARATOR: + fragments.append("".join(buffer)) + buffer.clear() + index += 1 + else: + buffer.append(char) + index += 1 + fragments.append("".join(buffer)) + return fragments + + +def _parse_no_proxy(raw: str | None) -> tuple[str, ...]: + """Resolve a ``NO_PROXY`` value into its ordered bypass entries. + + The observable order is split -> drop empty (before unescape/trim) -> + unescape -> trim. A backslash escapes a literal comma so an escaped + separator survives as a literal in the emitted token. A zero-length + fragment is dropped, but a whitespace-only fragment survives the drop and + is trimmed to an empty token — retained, not removed — because the drop + happens before the trim. + + Args: + raw: The raw comma-separated ``NO_PROXY`` value, or ``None``. + + Returns: + The bypass entries in order; empty when ``raw`` is ``None``. + """ + if raw is None: + return () + entries: list[str] = [] + for fragment in _split_no_proxy(raw): + if not fragment: # drop empty fragments before unescape/trim + continue + entries.append(fragment.replace(_ESCAPED_SEPARATOR, _NO_PROXY_SEPARATOR).strip()) + return tuple(entries) + + @dataclass(frozen=True, slots=True) class ProxyOptions: """Immutable proxy configuration with pre-compiled bypass matchers. @@ -204,47 +329,64 @@ class ProxyOptions: Attributes: type: Proxy transport flavour (HTTP / SOCKS4 / SOCKS5). host: Proxy host. Must be non-empty. - port: Proxy port in the range ``0..65535``. - non_proxy_hosts: Bypass entries. A bare entry (``example.com`` or - ``.example.com``) matches the host and its subdomains by suffix; - an entry with a glob metacharacter (``*.example.com``) keeps - ``fnmatch`` semantics. A trailing ``:port`` on a bare entry is - ignored. Compiled once in ``__post_init__``. - username: Optional username for proxy auth. Masked in ``repr``. - password: Optional password for proxy auth. Masked in ``repr``. + port: Proxy port in the range ``0..65535``, or ``None`` when + unspecified (the transport applies its own default) — the model + never fabricates one. + non_proxy_hosts: Bypass entries. A single bare ``*`` bypasses every + host; a bare entry (``example.com`` or ``.example.com``) matches + the host and its subdomains by suffix; an entry with a glob + metacharacter (``*.example.com``) keeps ``fnmatch`` semantics. A + trailing ``:port`` on a bare entry is ignored. Compiled once in + ``__post_init__``. + username: Optional username for proxy auth. Masked in every string + form. + password: Optional password for proxy auth. Masked in every string + form. """ type: ProxyType host: str - port: int + port: int | None = None non_proxy_hosts: tuple[str, ...] = () username: str | None = None password: str | None = None - # Compiled bypass matchers. Excluded from ``repr`` / equality / hashing so - # two ``ProxyOptions`` with the same logical fields compare equal even - # though their compiled predicates are distinct objects. + # Compiled bypass matchers and the bare-``*`` short-circuit flag. Excluded + # from ``repr`` / equality / hashing so two ``ProxyOptions`` with the same + # logical fields compare equal even though their compiled predicates are + # distinct objects. _bypass_matchers: tuple[Callable[[str], bool], ...] = field( init=False, repr=False, compare=False, hash=False ) + _bypass_all: bool = field(init=False, repr=False, compare=False, hash=False) def __post_init__(self) -> None: """Validate inputs and pre-compile bypass matchers. + A single bare ``*`` entry sets the bypass-all short-circuit; the + remaining entries are compiled into per-host matchers exactly once. + Raises: - ValueError: If ``host`` is empty or ``port`` is outside 0..65535. + ValueError: If ``host`` is empty or ``port`` is a non-``None`` + value outside 0..65535. """ if not self.host: raise ValueError("host must not be empty") - if not (0 <= self.port <= 65535): + if self.port is not None and not (0 <= self.port <= 65535): raise ValueError(f"port must be in 0..65535, got {self.port}") - compiled = tuple(_compile_bypass(pattern) for pattern in self.non_proxy_hosts) + bypass_all = any(entry.strip() == "*" for entry in self.non_proxy_hosts) + object.__setattr__(self, "_bypass_all", bypass_all) + compiled = tuple( + _compile_bypass(pattern) for pattern in self.non_proxy_hosts if pattern.strip() != "*" + ) object.__setattr__(self, "_bypass_matchers", compiled) def bypasses_proxy(self, host: str) -> bool: """Return ``True`` when ``host`` matches any bypass entry. - Matching is case-insensitive — hostnames on the wire are - case-insensitive per RFC 3986. Bare entries use suffix semantics + A single bare ``*`` in ``non_proxy_hosts`` short-circuits to ``True`` + for every host without running per-host matching. Otherwise matching + is case-insensitive over the full host string — hostnames on the wire + are case-insensitive per RFC 3986. Bare entries use suffix semantics (``example.com`` bypasses ``api.example.com``); glob entries use ``fnmatch``. A trailing ``:port`` on the candidate is stripped before matching, so port-qualified hosts compare on their host part alone. @@ -254,9 +396,12 @@ def bypasses_proxy(self, host: str) -> bool: (stripped) and optional IPv6 brackets. No scheme. Returns: - ``True`` if at least one bypass entry matches; ``False`` - otherwise (including when there are no bypass entries). + ``True`` if the bypass-all short-circuit is set or at least one + bypass entry matches; ``False`` otherwise (including when there + are no bypass entries). """ + if self._bypass_all: + return True candidate = _strip_port(host) return any(matcher(candidate) for matcher in self._bypass_matchers) @@ -277,6 +422,39 @@ def __repr__(self) -> str: f"username={username}, password={password})" ) + def __str__(self) -> str: + """Render the proxy options with credentials masked. + + Delegates to ``__repr__`` so ``str(options)`` and ``f"{options}"`` + carry the same credential masking — no string form ever leaks the + proxy password. + + Returns: + The credential-masked ``ProxyOptions(...)`` rendering. + """ + return self.__repr__() + + @classmethod + def from_environment(cls, env: EnvSource = os.environ.get) -> Self | None: + """Resolve proxy settings from environment variables (opt-in). + + Environment resolution is opt-in: the plain constructor and matching + methods never read ``os.environ`` implicitly. Callers ask for env-based + resolution explicitly here. The ``env`` seam is injectable so tests can + exercise resolution hermetically without mutating the real process + environment. + + Args: + env: Callable mapping a variable name to its value or ``None``. + Defaults to ``os.environ.get``; pass a fake mapping's ``get`` + in tests. + + Returns: + A populated ``ProxyOptions``, or ``None`` when no proxy is + configured or the value is unusable. + """ + return cls.from_configuration(Configuration(env=env)) + @classmethod def from_configuration(cls, config: Configuration) -> Self | None: """Build a ``ProxyOptions`` from layered configuration env vars. @@ -291,13 +469,17 @@ def from_configuration(cls, config: Configuration) -> Self | None: map to ``HTTP``, ``socks4`` to ``SOCKS4``, ``socks5``/``socks5h`` to ``SOCKS5``. An *unsupported* scheme is rejected (logged at WARNING), never silently downgraded to HTTP. - - A missing port defaults by scheme (``http`` 80, ``https`` 443, SOCKS - 1080) instead of dropping the proxy. + - A missing port stays ``None`` instead of being fabricated by scheme; + host and port always come from the same resolved URL. - A scheme-less ``proxy:8080`` is parsed as host:port (assumed HTTP). - - Percent-encoded credentials are ``unquote()``-decoded. + - Credentials are attached only over an ``https`` proxy and are + ``unquote()``-decoded; over a plaintext (``http`` / scheme-less / + SOCKS) proxy they are dropped rather than sent in the clear. Because a silently-unused proxy is an outage-grade misconfiguration, a - genuinely unusable proxy value is logged at WARNING (not DEBUG). + genuinely unusable proxy value is logged at WARNING (with any + credentials redacted). Resolution never raises: a malformed value + degrades to ``None``. Args: config: Layered configuration to read from. @@ -317,22 +499,17 @@ def from_configuration(cls, config: Configuration) -> Self | None: if endpoint is None: return None split, proxy_type, host, port = endpoint - non_proxy_hosts: tuple[str, ...] = () - if no_proxy_raw is not None and no_proxy_raw.strip(): - non_proxy_hosts = tuple( - entry.strip() for entry in no_proxy_raw.split(",") if entry.strip() - ) - username = unquote(split.username) if split.username is not None else None - password = unquote(split.password) if split.password is not None else None + username, password = _extract_credentials(split) try: return cls( type=proxy_type, host=host, port=port, - non_proxy_hosts=non_proxy_hosts, + non_proxy_hosts=_parse_no_proxy(no_proxy_raw), username=username, password=password, ) except ValueError: - _LOG.warning("ignoring proxy URL %r: failed ProxyOptions validation", proxy_url) + safe = _redact_userinfo(proxy_url) + _LOG.warning("ignoring proxy URL %s: failed ProxyOptions validation", safe) return None diff --git a/packages/dexpace-sdk-core/tests/auth/test_challenge_parser.py b/packages/dexpace-sdk-core/tests/auth/test_challenge_parser.py index 8904cae..48792cb 100644 --- a/packages/dexpace-sdk-core/tests/auth/test_challenge_parser.py +++ b/packages/dexpace-sdk-core/tests/auth/test_challenge_parser.py @@ -5,6 +5,8 @@ from __future__ import annotations +import pytest + from dexpace.sdk.core.http.auth import AuthenticateChallenge, parse_challenges @@ -35,6 +37,7 @@ def test_parse_case_insensitive_param_names(self) -> None: assert challenges[0].parameters["realm"] == "x" assert challenges[0].parameters["qop"] == "auth" + @pytest.mark.req("AUTH-13") def test_parse_malformed_skips_gracefully(self) -> None: # Garbage between two valid challenges should not break parsing. challenges = parse_challenges('Basic realm="ok", =badtoken, Digest realm="d"') @@ -42,6 +45,7 @@ def test_parse_malformed_skips_gracefully(self) -> None: assert "Basic" in schemes assert "Digest" in schemes + @pytest.mark.req("AUTH-13") def test_parse_empty_returns_empty(self) -> None: assert parse_challenges("") == [] assert parse_challenges(" ") == [] @@ -66,3 +70,73 @@ def test_authenticate_challenge_is_frozen(self) -> None: pass else: # pragma: no cover raise AssertionError("expected FrozenInstanceError") + + +class TestQuotedCommaBattery: + """Commas inside quoted strings must never split a challenge or param.""" + + def test_quoted_comma_in_first_of_two_challenges(self) -> None: + # The comma inside the Digest ``domain`` quoted-string must not be + # mistaken for the boundary before the trailing Basic challenge. + challenges = parse_challenges( + 'Digest realm="r", domain="/a, /b", qop="auth", Basic realm="b"' + ) + assert [c.scheme for c in challenges] == ["Digest", "Basic"] + assert challenges[0].parameters["domain"] == "/a, /b" + assert challenges[0].parameters["qop"] == "auth" + assert challenges[1].parameters["realm"] == "b" + + def test_multiple_commas_inside_one_quoted_value(self) -> None: + challenges = parse_challenges('Digest realm="a, b, c, d", nonce="x"') + assert len(challenges) == 1 + assert challenges[0].parameters["realm"] == "a, b, c, d" + assert challenges[0].parameters["nonce"] == "x" + + def test_escaped_quote_then_comma_inside_value(self) -> None: + # An escaped quote must not prematurely close the string, so the comma + # that follows is still protected. + challenges = parse_challenges(r'Digest realm="he said \"a, b\"", qop="auth"') + assert len(challenges) == 1 + assert challenges[0].parameters["realm"] == 'he said "a, b"' + assert challenges[0].parameters["qop"] == "auth" + + +class TestMultiHeaderLine: + """Challenges split across several ``WWW-Authenticate`` header lines. + + RFC 7230 §3.2.2 lets a server emit repeated header lines; they are + semantically one comma-joined field. ``parse_challenges`` accepts the raw + lines as an iterable and parses each independently, concatenating the + result — so a Digest challenge on its own line is never dropped in favour + of a Basic challenge on the first line. + """ + + def test_two_lines_basic_then_digest(self) -> None: + lines = ['Basic realm="a"', 'Digest realm="b", qop="auth", nonce="n"'] + challenges = parse_challenges(lines) + assert [c.scheme for c in challenges] == ["Basic", "Digest"] + assert challenges[0].parameters == {"realm": "a"} + assert challenges[1].parameters == {"realm": "b", "qop": "auth", "nonce": "n"} + + def test_each_line_may_carry_multiple_challenges(self) -> None: + lines = [ + 'Basic realm="a", Digest realm="d1", algorithm=MD5', + 'Digest realm="d2", algorithm=SHA-256', + ] + challenges = parse_challenges(lines) + assert [c.scheme for c in challenges] == ["Basic", "Digest", "Digest"] + assert challenges[1].parameters["algorithm"] == "MD5" + assert challenges[2].parameters["algorithm"] == "SHA-256" + + def test_quoted_comma_survives_per_line_parsing(self) -> None: + lines = ['Digest realm="a, b", qop="auth"', 'Basic realm="c"'] + challenges = parse_challenges(lines) + assert challenges[0].parameters["realm"] == "a, b" + assert [c.scheme for c in challenges] == ["Digest", "Basic"] + + def test_empty_iterable_returns_empty(self) -> None: + assert parse_challenges([]) == [] + + def test_single_line_iterable_matches_string_form(self) -> None: + text = 'Basic realm="r1", Digest realm="r2", qop="auth"' + assert parse_challenges([text]) == parse_challenges(text) diff --git a/packages/dexpace-sdk-core/tests/auth/test_credentials.py b/packages/dexpace-sdk-core/tests/auth/test_credentials.py index c207af9..8e0448c 100644 --- a/packages/dexpace-sdk-core/tests/auth/test_credentials.py +++ b/packages/dexpace-sdk-core/tests/auth/test_credentials.py @@ -40,6 +40,7 @@ def test_needs_refresh_via_refresh_on(self) -> None: token = AccessTokenInfo(token="t", expires_on=now + 3600, refresh_on=now - 1) assert token.needs_refresh() + @pytest.mark.req("XCUT-19") def test_repr_redacts_token(self) -> None: token = AccessTokenInfo(token="hunter2", expires_on=0) assert "hunter2" not in repr(token) @@ -54,6 +55,7 @@ def test_needs_refresh_uses_injected_clock(self) -> None: class TestKeyCredential: + @pytest.mark.req("AUTH-9") def test_rejects_empty_key(self) -> None: with pytest.raises(TypeError): KeyCredential("") @@ -79,6 +81,7 @@ def test_round_trip(self) -> None: assert cred.name == "name" assert cred.key == "key" + @pytest.mark.req("AUTH-9") def test_rejects_empty(self) -> None: with pytest.raises(ValueError): NamedKeyCredential("", "k") @@ -97,6 +100,12 @@ def test_encoded(self) -> None: # base64("user:pass") = "dXNlcjpwYXNz" assert cred.encoded == "dXNlcjpwYXNz" + def test_rejects_empty(self) -> None: + with pytest.raises(ValueError): + BasicAuthCredential("user", "") + with pytest.raises(ValueError): + BasicAuthCredential("", "pass") + def test_repr_redacts(self) -> None: cred = BasicAuthCredential("alice", "hunter2") assert "alice" not in repr(cred) @@ -131,6 +140,7 @@ def test_clear(self) -> None: cache.clear() assert cache.get(["s"]) is None + @pytest.mark.req("XCUT-12") def test_concurrent_get_with_writes(self) -> None: """``get`` must be safe under concurrent ``set`` calls. diff --git a/packages/dexpace-sdk-core/tests/auth/test_digest.py b/packages/dexpace-sdk-core/tests/auth/test_digest.py index 7ee618a..b340f36 100644 --- a/packages/dexpace-sdk-core/tests/auth/test_digest.py +++ b/packages/dexpace-sdk-core/tests/auth/test_digest.py @@ -7,6 +7,8 @@ import re +import pytest + from dexpace.sdk.core.http.auth import ( AuthenticateChallenge, BasicChallengeHandler, @@ -46,6 +48,7 @@ def _parse_auth(value: str) -> dict[str, str]: class TestDigestChallengeHandler: + @pytest.mark.req("AUTH-15", "AUTH-17") def test_digest_md5_known_vector(self) -> None: handler = DigestChallengeHandler( _USERNAME, @@ -72,6 +75,7 @@ def test_digest_md5_known_vector(self) -> None: assert params["algorithm"] == "MD5" assert params["opaque"] == _RFC_PARAMS["opaque"] + @pytest.mark.req("AUTH-15") def test_digest_sha256_known_vector(self) -> None: handler = DigestChallengeHandler( _USERNAME, @@ -92,6 +96,7 @@ def test_digest_sha256_known_vector(self) -> None: ) assert params["algorithm"] == "SHA-256" + @pytest.mark.req("AUTH-16") def test_digest_prefers_sha256_when_both_offered(self) -> None: handler = DigestChallengeHandler( _USERNAME, @@ -112,6 +117,7 @@ def test_digest_prefers_sha256_when_both_offered(self) -> None: params = _parse_auth(value) assert params["algorithm"] == "SHA-256" + @pytest.mark.req("AUTH-18") def test_digest_nonce_counter_increments_on_reuse(self) -> None: # Reusing the same server nonce across requests increments ``nc`` # (RFC 7616 §3.4: count of requests sent with this nonce). @@ -131,6 +137,7 @@ def test_digest_nonce_counter_increments_on_reuse(self) -> None: assert _parse_auth(first[1])["nc"] == "00000001" assert _parse_auth(second[1])["nc"] == "00000002" + @pytest.mark.req("AUTH-18") def test_digest_nc_resets_for_new_nonce(self) -> None: # A fresh server nonce restarts ``nc`` at 00000001 (RFC 7616 §3.4), # even after a prior nonce advanced the count. A single global counter @@ -184,6 +191,7 @@ def nc_for(nonce: str) -> str: assert nc_for("nonce-bbb") == "00000002" assert nc_for("nonce-aaa") == "00000003" + @pytest.mark.req("AUTH-19") def test_digest_nonce_count_map_is_bounded(self) -> None: # A long-lived handler hitting many distinct nonces must not grow the # per-nonce map without bound; the oldest entry is evicted past the cap. @@ -203,6 +211,7 @@ def test_digest_nonce_count_map_is_bounded(self) -> None: handler.handle(Method.GET, _URL, [challenge], is_proxy=False) assert len(handler._nonce_counts) == _MAX_TRACKED_NONCES + @pytest.mark.req("AUTH-25") def test_digest_is_proxy_returns_proxy_authorization_header(self) -> None: handler = DigestChallengeHandler( _USERNAME, @@ -220,6 +229,7 @@ def test_digest_is_proxy_returns_proxy_authorization_header(self) -> None: assert name == "Proxy-Authorization" assert value.startswith("Digest ") + @pytest.mark.req("AUTH-25") def test_digest_no_handlable_challenge_returns_none(self) -> None: handler = DigestChallengeHandler(_USERNAME, _PASSWORD) basic = AuthenticateChallenge(scheme="Basic", parameters={"realm": "r"}) @@ -242,6 +252,7 @@ def test_digest_md5_sess_uses_session_ha1(self) -> None: params = _parse_auth(result[1]) assert params["algorithm"] == "MD5-sess" + @pytest.mark.req("AUTH-22") def test_digest_url_uses_path_and_query(self) -> None: from dexpace.sdk.core.http.common.url import QueryParams @@ -268,6 +279,7 @@ def test_digest_url_uses_path_and_query(self) -> None: class TestBasicChallengeHandler: + @pytest.mark.req("AUTH-14") def test_handles_basic_challenge(self) -> None: handler = BasicChallengeHandler("user", "pass") challenge = AuthenticateChallenge(scheme="Basic", parameters={"realm": "r"}) @@ -279,6 +291,7 @@ def test_returns_none_for_non_basic(self) -> None: challenge = AuthenticateChallenge(scheme="Digest", parameters={}) assert handler.handle(Method.GET, _URL, [challenge], is_proxy=False) is None + @pytest.mark.req("AUTH-14") def test_is_proxy_emits_proxy_authorization(self) -> None: handler = BasicChallengeHandler("user", "pass") challenge = AuthenticateChallenge(scheme="Basic", parameters={"realm": "r"}) @@ -288,6 +301,7 @@ def test_is_proxy_emits_proxy_authorization(self) -> None: class TestCompositeChallengeHandler: + @pytest.mark.req("AUTH-23") def test_first_matching_handler_wins(self) -> None: basic = BasicChallengeHandler("u", "p") digest = DigestChallengeHandler("u", "p") @@ -312,6 +326,159 @@ def test_can_handle_delegates_to_children(self) -> None: assert composite.can_handle([AuthenticateChallenge(scheme="Basic", parameters={})]) assert not composite.can_handle([AuthenticateChallenge(scheme="Bearer", parameters={})]) + @pytest.mark.req("AUTH-23") + def test_prefers_strongest_scheme_when_all_offered(self) -> None: + # A server offering Basic + Digest/MD5 + Digest/SHA-256 must be answered + # with the strongest the composite supports: Digest SHA-256. Ordering + # Digest ahead of Basic in the composite makes Digest win over Basic, + # and Digest's own preference makes SHA-256 win over MD5. + composite = CompositeChallengeHandler( + DigestChallengeHandler(_USERNAME, _PASSWORD, cnonce_factory=lambda: _FIXED_CNONCE), + BasicChallengeHandler(_USERNAME, _PASSWORD), + ) + challenges = [ + AuthenticateChallenge(scheme="Basic", parameters={"realm": _RFC_PARAMS["realm"]}), + AuthenticateChallenge(scheme="Digest", parameters={**_RFC_PARAMS, "algorithm": "MD5"}), + AuthenticateChallenge( + scheme="Digest", parameters={**_RFC_PARAMS, "algorithm": "SHA-256"} + ), + ] + result = composite.handle(Method.GET, _URL, challenges, is_proxy=False) + assert result is not None + name, value = result + assert name == "Authorization" + assert value.startswith("Digest ") + assert _parse_auth(value)["algorithm"] == "SHA-256" + + +class TestRfc7616Vectors: + """End-to-end reproduction of the RFC 7616 §3.9.1 worked example. + + The example authenticates ``GET /dir/index.html`` for user ``Mufasa`` with + password ``Circle of Life`` against realm ``http-auth@example.org``, using + the fixed ``nonce``/``cnonce``/``opaque`` the RFC pins. The SHA-256 + ``response`` digest is quoted verbatim from the RFC; the MD5 digest is the + widely-published value for the same inputs. + """ + + def _handle(self, algorithm: str) -> dict[str, str]: + handler = DigestChallengeHandler( + _USERNAME, + _PASSWORD, + preferred_algorithms=(algorithm,), + cnonce_factory=lambda: _FIXED_CNONCE, + ) + challenge = AuthenticateChallenge( + scheme="Digest", + parameters={**_RFC_PARAMS, "algorithm": algorithm}, + ) + result = handler.handle(Method.GET, _URL, [challenge], is_proxy=False) + assert result is not None + return _parse_auth(result[1]) + + @pytest.mark.req("AUTH-17", "AUTH-22") + def test_sha256_full_header_matches_rfc(self) -> None: + params = self._handle("SHA-256") + assert params == { + "username": "Mufasa", + "realm": "http-auth@example.org", + "uri": "/dir/index.html", + "algorithm": "SHA-256", + "nonce": _RFC_PARAMS["nonce"], + "nc": "00000001", + "cnonce": _FIXED_CNONCE, + "qop": "auth", + "response": ("753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"), + "opaque": _RFC_PARAMS["opaque"], + } + + def test_md5_full_header_matches_known_vector(self) -> None: + params = self._handle("MD5") + assert params["algorithm"] == "MD5" + assert params["response"] == "8ca523f5e9506fed4657c9700eebdbec" + assert params["nc"] == "00000001" + assert params["cnonce"] == _FIXED_CNONCE + assert params["opaque"] == _RFC_PARAMS["opaque"] + + +class TestNonceCountRobustness: + """``nc`` is strictly monotonic per nonce and the tracking map is bounded.""" + + def _nc_for(self, handler: DigestChallengeHandler, nonce: str) -> int: + challenge = AuthenticateChallenge( + scheme="Digest", + parameters={**_RFC_PARAMS, "algorithm": "MD5", "nonce": nonce}, + ) + result = handler.handle(Method.GET, _URL, [challenge], is_proxy=False) + assert result is not None + return int(_parse_auth(result[1])["nc"], 16) + + @pytest.mark.req("AUTH-24") + def test_nc_is_strictly_monotonic_over_many_reuses(self) -> None: + handler = DigestChallengeHandler( + _USERNAME, + _PASSWORD, + preferred_algorithms=("MD5",), + cnonce_factory=lambda: _FIXED_CNONCE, + ) + previous = 0 + for expected in range(1, 5001): + current = self._nc_for(handler, "stable-nonce") + assert current == expected + assert current > previous + previous = current + + @pytest.mark.req("AUTH-18") + def test_nc_renders_as_eight_lowercase_hex_digits(self) -> None: + handler = DigestChallengeHandler( + _USERNAME, + _PASSWORD, + preferred_algorithms=("MD5",), + cnonce_factory=lambda: _FIXED_CNONCE, + ) + challenge = AuthenticateChallenge( + scheme="Digest", parameters={**_RFC_PARAMS, "algorithm": "MD5"} + ) + result = handler.handle(Method.GET, _URL, [challenge], is_proxy=False) + assert result is not None + assert re.fullmatch(r"[0-9a-f]{8}", _parse_auth(result[1])["nc"]) + + @pytest.mark.req("AUTH-19") + def test_actively_reused_nonce_survives_eviction_pressure(self) -> None: + # Interleaving a hot nonce with a flood of one-shot nonces must not + # evict the hot nonce: each reuse refreshes its recency, so its count + # keeps climbing rather than resetting to 1. + from dexpace.sdk.core.http.auth.digest import _MAX_TRACKED_NONCES + + handler = DigestChallengeHandler( + _USERNAME, + _PASSWORD, + preferred_algorithms=("MD5",), + cnonce_factory=lambda: _FIXED_CNONCE, + ) + hot_uses = 0 + for index in range(_MAX_TRACKED_NONCES * 2): + self._nc_for(handler, "hot") + hot_uses += 1 + self._nc_for(handler, f"cold-{index}") + assert self._nc_for(handler, "hot") == hot_uses + 1 + assert len(handler._nonce_counts) == _MAX_TRACKED_NONCES + + @pytest.mark.req("AUTH-19") + def test_map_stays_bounded_under_pure_rotation(self) -> None: + from dexpace.sdk.core.http.auth.digest import _MAX_TRACKED_NONCES + + handler = DigestChallengeHandler( + _USERNAME, + _PASSWORD, + preferred_algorithms=("MD5",), + cnonce_factory=lambda: _FIXED_CNONCE, + ) + for index in range(_MAX_TRACKED_NONCES * 3): + self._nc_for(handler, f"rotating-{index}") + assert len(handler._nonce_counts) <= _MAX_TRACKED_NONCES + assert len(handler._nonce_counts) == _MAX_TRACKED_NONCES + def _structural_protocol_check() -> ChallengeHandler: """Compile-time check that BasicChallengeHandler is a ChallengeHandler.""" diff --git a/packages/dexpace-sdk-core/tests/auth/test_digest_auth_int.py b/packages/dexpace-sdk-core/tests/auth/test_digest_auth_int.py new file mode 100644 index 0000000..1c293cd --- /dev/null +++ b/packages/dexpace-sdk-core/tests/auth/test_digest_auth_int.py @@ -0,0 +1,132 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for Digest ``qop`` negotiation and the ``auth-int`` safety guard. + +``auth-int`` folds a hash of the request entity body into ``HA2`` (RFC 7616 +§3.4.3). The ``ChallengeHandler`` contract carries no request body, and a +single-use (non-replayable) body may already be consumed by the time a 401 +challenge is answered — so an ``auth-int`` response cannot be computed here. + +The handler therefore prefers ``qop=auth`` whenever the server offers it and +declines an ``auth-int``-only challenge rather than emit a bogus header. The +underlying digest routine refuses ``auth-int`` loudly so no code path can ever +silently fall through and hash the request as if it were RFC 2069. +""" + +from __future__ import annotations + +import re + +import pytest + +from dexpace.sdk.core.http.auth import ( + AuthenticateChallenge, + DigestChallengeHandler, +) +from dexpace.sdk.core.http.common.url import Url +from dexpace.sdk.core.http.request.method import Method + +_USERNAME = "Mufasa" +_PASSWORD = "Circle of Life" +_REALM = "http-auth@example.org" +_NONCE = "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" +_CNONCE = "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ" +_URL = Url(scheme="https", host="example.org", path="/dir/index.html") + + +def _parse_auth(value: str) -> dict[str, str]: + assert value.startswith("Digest ") + body = value[len("Digest ") :] + out: dict[str, str] = {} + for part in re.split(r",\s*(?=[a-zA-Z][a-zA-Z0-9_-]*=)", body): + key, _, raw = part.partition("=") + raw = raw.strip() + if raw.startswith('"') and raw.endswith('"'): + raw = raw[1:-1] + out[key.strip().lower()] = raw + return out + + +def _handler() -> DigestChallengeHandler: + return DigestChallengeHandler( + _USERNAME, + _PASSWORD, + preferred_algorithms=("MD5",), + cnonce_factory=lambda: _CNONCE, + ) + + +class TestQopNegotiation: + def test_prefers_auth_when_both_auth_and_auth_int_offered(self) -> None: + # A server offering ``qop="auth,auth-int"`` must be answered with + # ``auth`` — the only protection this handler can compute. + handler = _handler() + challenge = AuthenticateChallenge( + scheme="Digest", + parameters={ + "realm": _REALM, + "qop": "auth,auth-int", + "nonce": _NONCE, + "algorithm": "MD5", + }, + ) + result = handler.handle(Method.GET, _URL, [challenge], is_proxy=False) + assert result is not None + assert _parse_auth(result[1])["qop"] == "auth" + + def test_prefers_auth_when_auth_int_listed_first(self) -> None: + # Ordering within the ``qop`` list must not matter — ``auth`` wins even + # when the server lists ``auth-int`` ahead of it. + handler = _handler() + challenge = AuthenticateChallenge( + scheme="Digest", + parameters={ + "realm": _REALM, + "qop": "auth-int, auth", + "nonce": _NONCE, + "algorithm": "MD5", + }, + ) + result = handler.handle(Method.GET, _URL, [challenge], is_proxy=False) + assert result is not None + assert _parse_auth(result[1])["qop"] == "auth" + + @pytest.mark.req("AUTH-15") + def test_auth_int_only_challenge_declines(self) -> None: + # ``auth-int`` alone cannot be satisfied (no body available), so the + # handler declines rather than emitting an unverifiable header. + handler = _handler() + challenge = AuthenticateChallenge( + scheme="Digest", + parameters={ + "realm": _REALM, + "qop": "auth-int", + "nonce": _NONCE, + "algorithm": "MD5", + }, + ) + assert handler.handle(Method.GET, _URL, [challenge], is_proxy=False) is None + + +class TestAuthIntFailsLoudly: + def test_compute_response_rejects_auth_int(self) -> None: + # Defence in depth: if ``auth-int`` ever reaches the digest routine it + # must raise loudly, never fall through to the RFC 2069 branch and + # silently emit a hash the server will reject. + import hashlib + + handler = _handler() + with pytest.raises(ValueError, match="auth-int"): + handler._compute_response( + hasher=hashlib.md5, + algorithm="MD5", + method="GET", + uri="/dir/index.html", + realm=_REALM, + nonce=_NONCE, + nc="00000001", + cnonce=_CNONCE, + qop="auth-int", + charset="iso-8859-1", + ) diff --git a/packages/dexpace-sdk-core/tests/auth/test_digest_charset.py b/packages/dexpace-sdk-core/tests/auth/test_digest_charset.py index 5f042b7..f86a011 100644 --- a/packages/dexpace-sdk-core/tests/auth/test_digest_charset.py +++ b/packages/dexpace-sdk-core/tests/auth/test_digest_charset.py @@ -14,6 +14,8 @@ import re +import pytest + from dexpace.sdk.core.http.auth import ( AuthenticateChallenge, DigestChallengeHandler, @@ -71,6 +73,7 @@ def _base_params() -> dict[str, str]: } +@pytest.mark.req("AUTH-21") def test_uses_utf8_when_charset_advertised() -> None: params = {**_base_params(), "charset": "UTF-8"} @@ -79,6 +82,7 @@ def test_uses_utf8_when_charset_advertised() -> None: assert parsed["response"] == _RESPONSE_UTF8 +@pytest.mark.req("AUTH-21") def test_charset_directive_is_case_insensitive() -> None: params = {**_base_params(), "charset": "utf-8"} @@ -87,6 +91,7 @@ def test_charset_directive_is_case_insensitive() -> None: assert parsed["response"] == _RESPONSE_UTF8 +@pytest.mark.req("AUTH-21") def test_uses_iso_8859_1_when_charset_absent() -> None: parsed = _handle(_base_params()) diff --git a/packages/dexpace-sdk-core/tests/auth/test_digest_cnonce.py b/packages/dexpace-sdk-core/tests/auth/test_digest_cnonce.py new file mode 100644 index 0000000..32d1522 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/auth/test_digest_cnonce.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests proving the Digest client nonce (``cnonce``) uses a CSPRNG. + +RFC 7616 leaves ``cnonce`` generation to the client, but a predictable value +undermines the protection ``qop=auth`` is meant to provide: an attacker who can +guess the ``cnonce`` can precompute or replay responses. The default factory +must therefore draw from ``secrets`` (a cryptographically secure source), never +the ``random`` module (a Mersenne-Twister PRNG that is trivially predictable +once a little output is observed). + +These checks are structural: the module must not import ``random``, and the +generated ``cnonce`` must look like fresh high-entropy hex on every call. +""" + +from __future__ import annotations + +import re + +import pytest + +from dexpace.sdk.core.http.auth import AuthenticateChallenge, DigestChallengeHandler +from dexpace.sdk.core.http.auth import digest as digest_module +from dexpace.sdk.core.http.common.url import Url +from dexpace.sdk.core.http.request.method import Method + +_REALM = "http-auth@example.org" +_NONCE = "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" +_URL = Url(scheme="https", host="example.org", path="/dir/index.html") +_HEX32 = re.compile(r"^[0-9a-f]{32}$") + + +def _cnonce_from_default_factory() -> str: + # Exercise the real default factory (no ``cnonce_factory`` override) and + # read back the value it stamped into the header. + handler = DigestChallengeHandler("Mufasa", "Circle of Life") + challenge = AuthenticateChallenge( + scheme="Digest", + parameters={"realm": _REALM, "qop": "auth", "nonce": _NONCE, "algorithm": "MD5"}, + ) + result = handler.handle(Method.GET, _URL, [challenge], is_proxy=False) + assert result is not None + match = re.search(r'cnonce="([^"]*)"', result[1]) + assert match is not None + return match.group(1) + + +class TestCnonceCsprng: + @pytest.mark.req("AUTH-20", "XCUT-21") + def test_module_imports_secrets_not_random(self) -> None: + # ``import secrets`` binds the name in the module namespace; the absence + # of ``random`` proves the insecure PRNG was never pulled in. + assert getattr(digest_module, "secrets", None) is not None + assert getattr(digest_module, "random", None) is None + + @pytest.mark.req("AUTH-20", "XCUT-21") + def test_default_cnonce_is_128_bit_hex(self) -> None: + # ``secrets.token_hex(16)`` yields 16 CSPRNG bytes rendered as 32 lower + # hex digits — 128 bits of entropy. + cnonce = _cnonce_from_default_factory() + assert _HEX32.match(cnonce) + + @pytest.mark.req("AUTH-20", "XCUT-21") + def test_default_cnonce_differs_across_calls(self) -> None: + # A deterministic factory would repeat; a CSPRNG effectively never does. + seen = {_cnonce_from_default_factory() for _ in range(16)} + assert len(seen) == 16 diff --git a/packages/dexpace-sdk-core/tests/auth/test_digest_edge_cases.py b/packages/dexpace-sdk-core/tests/auth/test_digest_edge_cases.py index 5b91975..7130ccb 100644 --- a/packages/dexpace-sdk-core/tests/auth/test_digest_edge_cases.py +++ b/packages/dexpace-sdk-core/tests/auth/test_digest_edge_cases.py @@ -11,6 +11,8 @@ import re +import pytest + from dexpace.sdk.core.http.auth import ( AuthenticateChallenge, DigestChallengeHandler, @@ -79,6 +81,7 @@ def test_non_latin1_creds_succeed_when_utf8_advertised(self) -> None: class TestQopLessHeader: + @pytest.mark.req("AUTH-22") def test_qopless_header_omits_cnonce_and_nc(self) -> None: # RFC 2069: server omits ``qop``. The response header must not carry # ``cnonce`` or ``nc`` per RFC 7616 §3.4. @@ -161,6 +164,7 @@ def test_sess_algorithm_with_qop_still_works(self) -> None: class TestPreferenceAllowList: + @pytest.mark.req("AUTH-16") def test_sha256_preference_declines_md5_only_server(self) -> None: handler = DigestChallengeHandler( _USERNAME, @@ -175,6 +179,7 @@ def test_sha256_preference_declines_md5_only_server(self) -> None: assert handler.handle(Method.GET, _URL, [md5_only], is_proxy=False) is None assert handler.can_handle([md5_only]) is False + @pytest.mark.req("AUTH-16") def test_default_preference_still_reaches_md5(self) -> None: handler = DigestChallengeHandler( _USERNAME, diff --git a/packages/dexpace-sdk-core/tests/auth/test_policies.py b/packages/dexpace-sdk-core/tests/auth/test_policies.py index 1ff79ea..aa8b298 100644 --- a/packages/dexpace-sdk-core/tests/auth/test_policies.py +++ b/packages/dexpace-sdk-core/tests/auth/test_policies.py @@ -9,12 +9,13 @@ import threading import time from collections.abc import AsyncIterator, Iterator +from concurrent.futures import ThreadPoolExecutor import pytest from dexpace.sdk.core.client.async_http_client import AsyncHttpClient from dexpace.sdk.core.client.http_client import HttpClient -from dexpace.sdk.core.errors import ClientAuthenticationError, ServiceRequestError +from dexpace.sdk.core.errors import ServiceRequestError from dexpace.sdk.core.http.auth import ( AccessTokenInfo, AsyncBearerTokenPolicy, @@ -24,12 +25,13 @@ BasicChallengeHandler, BearerTokenPolicy, DigestChallengeHandler, + InMemoryTokenCache, KeyCredential, KeyCredentialPolicy, ) from dexpace.sdk.core.http.common import Protocol, Url from dexpace.sdk.core.http.context import DispatchContext -from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.request import Method, Request, RequestBody from dexpace.sdk.core.http.response import AsyncResponse, Response, Status from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody from dexpace.sdk.core.http.response.response_body import ResponseBody @@ -87,6 +89,7 @@ def execute(self, request: Request) -> Response: ) +@pytest.mark.req("AUTH-26") def test_key_credential_policy_stamps_header() -> None: client = _CapturingClient() policy = KeyCredentialPolicy(KeyCredential("hunter2"), "X-API-Key") @@ -95,6 +98,7 @@ def test_key_credential_policy_stamps_header() -> None: assert client.calls[0].headers.get("x-api-key") == "hunter2" +@pytest.mark.req("AUTH-26") def test_key_credential_policy_prefix() -> None: client = _CapturingClient() policy = KeyCredentialPolicy(KeyCredential("k"), "Authorization", prefix="SharedKey") @@ -136,6 +140,7 @@ def execute(self, request: Request) -> Response: return Response(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK) +@pytest.mark.req("AUTH-29") def test_key_credential_policy_withholds_credential_cross_origin() -> None: from dexpace.sdk.core.pipeline.policies.redirect import RedirectPolicy @@ -189,6 +194,7 @@ def close(self) -> None: return None +@pytest.mark.req("AUTH-34") def test_bearer_token_policy_stamps_header() -> None: client = _CapturingClient() cred = _StaticCredential() @@ -198,6 +204,7 @@ def test_bearer_token_policy_stamps_header() -> None: assert client.calls[0].headers.get("authorization") == "Bearer abc" +@pytest.mark.req("AUTH-14") def test_bearer_token_policy_caches_token() -> None: client = _CapturingClient() cred = _StaticCredential() @@ -224,6 +231,7 @@ def test_bearer_policy_refreshes_when_clock_advances_past_expiry() -> None: assert cred.calls == 2 +@pytest.mark.req("XCUT-16") def test_bearer_token_policy_enforces_https() -> None: client = _CapturingClient() cred = _StaticCredential() @@ -235,16 +243,23 @@ def test_bearer_token_policy_enforces_https() -> None: ) -def test_bearer_token_policy_raises_on_401_without_challenge() -> None: +@pytest.mark.req("AUTH-33") +def test_bearer_token_policy_surfaces_401_without_challenge() -> None: + """A 401 without ``WWW-Authenticate`` is surfaced unchanged, hook untouched.""" client = _CapturingClient(status=Status.UNAUTHORIZED) cred = _StaticCredential() policy = BearerTokenPolicy(cred, "scope-a") - with Pipeline(client, policies=[policy]) as p, pytest.raises(ClientAuthenticationError): - p.run(_request(), DispatchContext(_instr("0" * 16 + "8"))) + with Pipeline(client, policies=[policy]) as p: + response = p.run(_request(), DispatchContext(_instr("0" * 16 + "8"))) + assert response.status is Status.UNAUTHORIZED + # No challenge header: no re-issue, no extra token acquisition. + assert len(client.calls) == 1 + assert cred.calls == 1 +@pytest.mark.req("AUTH-30") def test_bearer_token_policy_on_challenge_hook() -> None: - """Subclass that handles the challenge by re-requesting once.""" + """Subclass that authorizes one replay; the still-401 result is surfaced.""" client = _CapturingClient(status=Status.UNAUTHORIZED, www_auth=True) cred = _StaticCredential() @@ -254,13 +269,15 @@ def on_challenge(self, request: Request, response: Response) -> bool: return True policy = _Retrying(cred, "scope-a") - with Pipeline(client, policies=[policy]) as p, pytest.raises(ClientAuthenticationError): - # Server keeps responding 401; eventually the policy gives up. - p.run(_request(), DispatchContext(_instr("0" * 16 + "9"))) - # Two attempts: initial + one re-issue after challenge. + with Pipeline(client, policies=[policy]) as p: + # Server keeps responding 401; after one replay the response surfaces. + response = p.run(_request(), DispatchContext(_instr("0" * 16 + "9"))) + assert response.status is Status.UNAUTHORIZED + # Two attempts: initial + one re-issue after challenge, then no loop. assert len(client.calls) == 2 +@pytest.mark.req("AUTH-29") def test_bearer_token_policy_withholds_token_cross_origin() -> None: """A redirect to a foreign host must not receive the bearer token.""" from dexpace.sdk.core.pipeline.policies.redirect import RedirectPolicy @@ -276,16 +293,19 @@ def test_bearer_token_policy_withholds_token_cross_origin() -> None: assert cred.calls == 1 +@pytest.mark.req("AUTH-29", "XCUT-16") def test_bearer_token_policy_skips_https_enforcement_cross_origin() -> None: """A cross-origin reissue to http:// forwards unchanged, no HTTPS error.""" from dexpace.sdk.core.pipeline.policies.redirect import RedirectPolicy # http:// target is cross-origin (scheme + host differ); the bearer policy - # must forward it unchanged rather than raising the HTTPS-only error. + # must forward it unchanged rather than raising the HTTPS-only error. The + # http target is also an HTTPS->HTTP downgrade, which the redirect policy + # denies by default, so opt in here to exercise the bearer-policy path. client = _RedirectingClient("http://other.example.org/next") cred = _StaticCredential() policy = BearerTokenPolicy(cred, "scope-a") - with Pipeline(client, policies=[RedirectPolicy(), policy]) as p: + with Pipeline(client, policies=[RedirectPolicy(allow_scheme_downgrade=True), policy]) as p: response = p.run(_request(), DispatchContext(_instr("0" * 15 + "33"))) assert response.status is Status.OK assert "authorization" not in client.calls[1].headers @@ -316,6 +336,7 @@ def close(self) -> None: return None +@pytest.mark.req("XCUT-12") def test_bearer_token_policy_serializes_concurrent_refresh() -> None: """Concurrent sync sends must issue exactly one token fetch.""" @@ -343,6 +364,28 @@ def _send(trace: str) -> None: assert cred.calls == 1 +@pytest.mark.req("AUTH-34", "XCUT-11", "XCUT-12") +def test_bearer_token_policy_single_flight_under_thread_pool() -> None: + """The sync pillar collapses N concurrent misses into one fetch (pool).""" + + client = _CapturingClient() + cred = _SlowCredential() + policy = BearerTokenPolicy(cred, "scope-a") + trace_ids = [f"{i:032x}" for i in range(200, 208)] + + with Pipeline(client, policies=[policy]) as p: + + def _send(trace: str) -> None: + p.run(_request(), DispatchContext(_instr(trace))) + + with ThreadPoolExecutor(max_workers=8) as pool: + futures = [pool.submit(_send, t) for t in trace_ids] + for future in futures: + future.result(timeout=5) + + assert cred.calls == 1 + + class _SlowAsyncCredential: """AsyncTokenCredential whose token fetch is slow — for asyncio concurrency.""" @@ -513,9 +556,10 @@ def on_challenge(self, request: Request, response: Response) -> bool: return False policy = _Spy(cred, "scope-a", challenge_handler=handler) - with Pipeline(client, policies=[policy]) as p, pytest.raises(ClientAuthenticationError): - p.run(_request(), DispatchContext(_instr("0" * 16 + "d"))) + with Pipeline(client, policies=[policy]) as p: + response = p.run(_request(), DispatchContext(_instr("0" * 16 + "d"))) + assert response.status is Status.UNAUTHORIZED assert handler.can_handle_calls == 1 assert handler.handle_calls == 0 assert on_challenge_calls == 1 @@ -712,9 +756,9 @@ async def on_challenge(self, request: Request, response: AsyncResponse) -> bool: policy = _Spy(cred, "scope-a", challenge_handler=handler) async with AsyncPipeline(client, policies=[policy]) as p: - with pytest.raises(ClientAuthenticationError): - await p.run(_request(), DispatchContext(_instr("0" * 15 + "14"))) + response = await p.run(_request(), DispatchContext(_instr("0" * 15 + "14"))) + assert response.status is Status.UNAUTHORIZED assert handler.can_handle_calls == 1 assert handler.handle_calls == 0 assert on_challenge_calls == 1 @@ -835,6 +879,7 @@ def test_challenge_handler_closes_discarded_401_response() -> None: assert not client.bodies[1].closed, "final response handed to caller must stay open" +@pytest.mark.req("AUTH-30") def test_on_challenge_closes_discarded_401_response() -> None: """The on_challenge retry path also releases the rejected 401.""" client = _BodyScriptedClient([(Status.UNAUTHORIZED, 'Bearer realm="x"'), (Status.OK, None)]) @@ -881,3 +926,490 @@ async def on_challenge(self, request: Request, response: AsyncResponse) -> bool: assert client.bodies[0].closed, "discarded 401 response leaked: body was not closed" assert not client.bodies[1].closed, "final response handed to caller must stay open" + + +# --------------------------------------------------------------------------- # +# Replay is gated on body replayability — identically on both pillars # +# --------------------------------------------------------------------------- # + + +class _AlwaysRetry(BearerTokenPolicy): + """Bearer policy whose hook always authorizes a single replay.""" + + def on_challenge(self, request: Request, response: Response) -> bool: + del request, response + return True + + +class _AsyncAlwaysRetry(AsyncBearerTokenPolicy): + """Async twin of ``_AlwaysRetry``.""" + + async def on_challenge(self, request: Request, response: AsyncResponse) -> bool: + del request, response + return True + + +def test_bearer_replays_replayable_body_after_challenge() -> None: + """A replayable body is re-issued once when the hook authorizes it.""" + client = _ScriptedClient([(Status.UNAUTHORIZED, 'Bearer realm="x"'), (Status.OK, None)]) + policy = _AlwaysRetry(_StaticCredential(), "scope-a") + with Pipeline(client, policies=[policy]) as p: + response = p.run(_request(), DispatchContext(_instr("0" * 15 + "40"))) + assert response.status is Status.OK + assert len(client.calls) == 2 + + +@pytest.mark.req("AUTH-31", "BODY-4") +def test_bearer_surfaces_401_for_nonreplayable_body() -> None: + """A single-use body surfaces the 401 un-replayed instead of resending it.""" + client = _ScriptedClient([(Status.UNAUTHORIZED, 'Bearer realm="x"')]) + body = RequestBody.from_iter(iter([b"hello", b"world"])) + request = Request(method=Method.POST, url=Url.parse("https://api.example.com/"), body=body) + policy = _AlwaysRetry(_StaticCredential(), "scope-a") + with Pipeline(client, policies=[policy]) as p: + response = p.run(request, DispatchContext(_instr("0" * 15 + "41"))) + assert response.status is Status.UNAUTHORIZED + # No replay: the single-use body was never re-sent, so it stays intact. + assert len(client.calls) == 1 + assert not body.is_replayable() + + +async def test_async_bearer_replays_replayable_body_after_challenge() -> None: + """Async twin: a replayable body is re-issued once on challenge.""" + client = _ScriptedAsyncClient( + [(Status.UNAUTHORIZED, "WWW-Authenticate", 'Bearer realm="x"'), (Status.OK, None, None)] + ) + policy = _AsyncAlwaysRetry(_StaticAsyncCredential(), "scope-a") + async with AsyncPipeline(client, policies=[policy]) as p: + response = await p.run(_request(), DispatchContext(_instr("0" * 15 + "42"))) + assert response.status is Status.OK + assert len(client.calls) == 2 + + +@pytest.mark.req("AUTH-31") +async def test_async_bearer_surfaces_401_for_nonreplayable_body() -> None: + """Async twin: a single-use body surfaces the 401 un-replayed.""" + client = _ScriptedAsyncClient([(Status.UNAUTHORIZED, "WWW-Authenticate", 'Bearer realm="x"')]) + body = RequestBody.from_iter(iter([b"hello", b"world"])) + request = Request(method=Method.POST, url=Url.parse("https://api.example.com/"), body=body) + policy = _AsyncAlwaysRetry(_StaticAsyncCredential(), "scope-a") + async with AsyncPipeline(client, policies=[policy]) as p: + response = await p.run(request, DispatchContext(_instr("0" * 15 + "43"))) + assert response.status is Status.UNAUTHORIZED + assert len(client.calls) == 1 + assert not body.is_replayable() + + +@pytest.mark.req("AUTH-33") +async def test_async_bearer_surfaces_401_without_challenge() -> None: + """Async twin: a 401 without ``WWW-Authenticate`` is surfaced unchanged.""" + client = _CapturingAsyncClient(status=Status.UNAUTHORIZED) + cred = _StaticAsyncCredential() + policy = AsyncBearerTokenPolicy(cred, "scope-a") + async with AsyncPipeline(client, policies=[policy]) as p: + response = await p.run(_request(), DispatchContext(_instr("0" * 15 + "44"))) + assert response.status is Status.UNAUTHORIZED + assert len(client.calls) == 1 + assert cred.calls == 1 + + +# --------------------------------------------------------------------------- # +# A throwing challenge hook closes the 401 before the exception propagates # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-32") +def test_sync_on_challenge_throw_closes_401_before_propagating() -> None: + """A raising sync hook releases the open 401 before re-raising.""" + client = _BodyScriptedClient([(Status.UNAUTHORIZED, 'Bearer realm="x"')]) + + class _Boom(BearerTokenPolicy): + def on_challenge(self, request: Request, response: Response) -> bool: + del request, response + raise RuntimeError("boom") + + policy = _Boom(_StaticCredential(), "scope-a") + with ( + Pipeline(client, policies=[policy]) as p, + pytest.raises(RuntimeError, match="boom"), + ): + p.run(_request(), DispatchContext(_instr("0" * 15 + "50"))) + assert client.bodies[0].closed, "throwing on_challenge leaked the 401 body" + + +@pytest.mark.req("AUTH-32") +async def test_async_on_challenge_coroutine_throw_closes_401() -> None: + """A raising async coroutine hook releases the open 401 before re-raising.""" + client = _AsyncBodyScriptedClient([(Status.UNAUTHORIZED, 'Bearer realm="x"')]) + + class _Boom(AsyncBearerTokenPolicy): + async def on_challenge(self, request: Request, response: AsyncResponse) -> bool: + del request, response + raise RuntimeError("boom") + + policy = _Boom(_StaticAsyncCredential(), "scope-a") + async with AsyncPipeline(client, policies=[policy]) as p: + with pytest.raises(RuntimeError, match="boom"): + await p.run(_request(), DispatchContext(_instr("0" * 15 + "51"))) + assert client.bodies[0].closed, "throwing async on_challenge leaked the 401 body" + + +@pytest.mark.req("AUTH-32", "AUTH-38") +async def test_async_on_challenge_synchronous_throw_closes_401() -> None: + """A hook that throws synchronously (before a coroutine exists) still closes.""" + client = _AsyncBodyScriptedClient([(Status.UNAUTHORIZED, 'Bearer realm="x"')]) + + class _Boom(AsyncBearerTokenPolicy): + def on_challenge( # type: ignore[override] + self, request: Request, response: AsyncResponse + ) -> bool: + # Raises when *called*, before any awaitable is produced — the + # third hook-failure shape the async gate must guard against. + del request, response + raise RuntimeError("boom") + + policy = _Boom(_StaticAsyncCredential(), "scope-a") + async with AsyncPipeline(client, policies=[policy]) as p: + with pytest.raises(RuntimeError, match="boom"): + await p.run(_request(), DispatchContext(_instr("0" * 15 + "52"))) + assert client.bodies[0].closed, "synchronous on_challenge throw leaked the 401 body" + + +# --------------------------------------------------------------------------- # +# Eviction matches the stamped token, sparing a concurrently-refreshed one # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-36") +def test_bearer_eviction_matches_stamped_token_only() -> None: + """A stale 401 must not evict a token a concurrent refresh already replaced.""" + cache = InMemoryTokenCache() + fresh = AccessTokenInfo(token="fresh", expires_on=int(time.time()) + 3600) + + class _SwappingClient(HttpClient): + def __init__(self) -> None: + self.calls: list[Request] = [] + + def execute(self, request: Request) -> Response: + from dexpace.sdk.core.http.common import Headers + + self.calls.append(request) + # A concurrent refresh lands before this stale 401 is handled: the + # cache now holds a different, still-valid token. + cache.set(("scope-a",), fresh) + return Response( + request=request, + protocol=Protocol.HTTP_1_1, + status=Status.UNAUTHORIZED, + headers=Headers([]), + ) + + policy = BearerTokenPolicy(_StaticCredential(token="stale"), "scope-a", cache=cache) + with Pipeline(_SwappingClient(), policies=[policy]) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "45"))) + # The stale 401 carried "Bearer stale"; eviction keyed on the stamped + # header must leave the newer "Bearer fresh" token untouched. + surviving = cache.get(("scope-a",)) + assert surviving is not None + assert surviving.token == "fresh" + + +@pytest.mark.req("AUTH-36") +async def test_async_bearer_eviction_matches_stamped_token_only() -> None: + """Async twin: a stale 401 leaves a concurrently-refreshed token intact.""" + cache = InMemoryTokenCache() + fresh = AccessTokenInfo(token="fresh", expires_on=int(time.time()) + 3600) + + class _SwappingAsyncClient(AsyncHttpClient): + def __init__(self) -> None: + self.calls: list[Request] = [] + + async def execute(self, request: Request) -> AsyncResponse: + from dexpace.sdk.core.http.common import Headers + + self.calls.append(request) + cache.set(("scope-a",), fresh) + return AsyncResponse( + request=request, + protocol=Protocol.HTTP_1_1, + status=Status.UNAUTHORIZED, + headers=Headers([]), + ) + + policy = AsyncBearerTokenPolicy(_StaticAsyncCredential(token="stale"), "scope-a", cache=cache) + async with AsyncPipeline(_SwappingAsyncClient(), policies=[policy]) as p: + await p.run(_request(), DispatchContext(_instr("0" * 15 + "46"))) + surviving = cache.get(("scope-a",)) + assert surviving is not None + assert surviving.token == "fresh" + + +# --------------------------------------------------------------------------- # +# Credential hygiene: the HTTPS-only guard fires before any credential is # +# fetched or stamped, and before the transport is ever dialed. # +# --------------------------------------------------------------------------- # + + +class _NeverDialClient(HttpClient): + """Sync transport that fails loudly if it is ever asked to execute.""" + + def __init__(self) -> None: + self.dialed = False + + def execute(self, request: Request) -> Response: + del request + self.dialed = True + raise AssertionError("transport dialed despite the HTTPS-only guard") + + +class _NeverDialAsyncClient(AsyncHttpClient): + """Async twin of ``_NeverDialClient``.""" + + def __init__(self) -> None: + self.dialed = False + + async def execute(self, request: Request) -> AsyncResponse: + del request + self.dialed = True + raise AssertionError("transport dialed despite the HTTPS-only guard") + + +class _RecordingTokenCredential: + """TokenCredential that records whether ``get_token_info`` was called.""" + + def __init__(self, token: str = "s3cr3t-token") -> None: + self.calls = 0 + self._token = token + + def get_token_info(self, *scopes: str, options: object = None) -> AccessTokenInfo: + del scopes, options + self.calls += 1 + return AccessTokenInfo(token=self._token, expires_on=int(time.time()) + 3600) + + def close(self) -> None: + return None + + +class _RecordingAsyncTokenCredential: + """Async twin of ``_RecordingTokenCredential``.""" + + def __init__(self, token: str = "s3cr3t-token") -> None: + self.calls = 0 + self._token = token + + async def get_token_info(self, *scopes: str, options: object = None) -> AccessTokenInfo: + del scopes, options + self.calls += 1 + return AccessTokenInfo(token=self._token, expires_on=int(time.time()) + 3600) + + async def close(self) -> None: + return None + + +class _RecordingKeyCredential(KeyCredential): + """KeyCredential that counts reads of ``key`` to prove the guard runs first.""" + + def __init__(self, key: str) -> None: + super().__init__(key) + self.key_reads = 0 + + @property + def key(self) -> str: + self.key_reads += 1 + return self._key + + +class _RecordingBasicCredential(BasicAuthCredential): + """BasicAuthCredential that counts reads of ``encoded`` to prove the order.""" + + def __init__(self, username: str, password: str) -> None: + super().__init__(username, password) + self.encoded_reads = 0 + + @property + def encoded(self) -> str: + self.encoded_reads += 1 + return self._encoded + + +@pytest.mark.req("AUTH-28", "XCUT-16") +def test_bearer_policy_https_guard_precedes_fetch_and_dial() -> None: + """An http:// URL errors before the credential is fetched or transport dialed.""" + client = _NeverDialClient() + cred = _RecordingTokenCredential() + policy = BearerTokenPolicy(cred, "scope-a") + with Pipeline(client, policies=[policy]) as p, pytest.raises(ServiceRequestError): + p.run( + _request("http://insecure.example.com/"), + DispatchContext(_instr("0" * 15 + "40")), + ) + assert cred.calls == 0, "credential was fetched before the HTTPS guard fired" + assert client.dialed is False, "transport was dialed before the HTTPS guard fired" + + +@pytest.mark.req("AUTH-38") +async def test_async_bearer_policy_https_guard_precedes_fetch_and_dial() -> None: + """Async twin: http:// errors before the async fetch or async dial.""" + client = _NeverDialAsyncClient() + cred = _RecordingAsyncTokenCredential() + policy = AsyncBearerTokenPolicy(cred, "scope-a") + async with AsyncPipeline(client, policies=[policy]) as p: + with pytest.raises(ServiceRequestError): + await p.run( + _request("http://insecure.example.com/"), + DispatchContext(_instr("0" * 15 + "41")), + ) + assert cred.calls == 0, "credential was fetched before the HTTPS guard fired" + assert client.dialed is False, "transport was dialed before the HTTPS guard fired" + + +@pytest.mark.req("AUTH-28") +def test_key_credential_policy_https_guard_precedes_stamp_and_dial() -> None: + """An http:// URL errors before the key is read or the transport dialed.""" + client = _NeverDialClient() + cred = _RecordingKeyCredential("hunter2") + policy = KeyCredentialPolicy(cred, "X-API-Key") + with Pipeline(client, policies=[policy]) as p, pytest.raises(ServiceRequestError): + p.run( + _request("http://insecure.example.com/"), + DispatchContext(_instr("0" * 15 + "42")), + ) + assert cred.key_reads == 0, "key was read before the HTTPS guard fired" + assert client.dialed is False, "transport was dialed before the HTTPS guard fired" + + +@pytest.mark.req("AUTH-28") +def test_basic_auth_policy_https_guard_precedes_stamp_and_dial() -> None: + """An http:// URL errors before the payload is read or the transport dialed.""" + client = _NeverDialClient() + cred = _RecordingBasicCredential("user", "pass") + policy = BasicAuthPolicy(cred) + with Pipeline(client, policies=[policy]) as p, pytest.raises(ServiceRequestError): + p.run( + _request("http://insecure.example.com/"), + DispatchContext(_instr("0" * 15 + "43")), + ) + assert cred.encoded_reads == 0, "payload was read before the HTTPS guard fired" + assert client.dialed is False, "transport was dialed before the HTTPS guard fired" + + +def test_key_credential_policy_https_opt_out_stamps_over_http() -> None: + """``enforce_https=False`` lets the key policy stamp over plain HTTP.""" + client = _CapturingClient() + policy = KeyCredentialPolicy(KeyCredential("hunter2"), "X-API-Key") + with Pipeline(client, policies=[policy]) as p: + p.run( + _request("http://insecure.example.com/"), + DispatchContext(_instr("0" * 15 + "45")), + enforce_https=False, + ) + assert client.calls[0].headers.get("x-api-key") == "hunter2" + + +def test_basic_auth_policy_https_opt_out_stamps_over_http() -> None: + """``enforce_https=False`` lets the basic policy stamp over plain HTTP.""" + client = _CapturingClient() + policy = BasicAuthPolicy(BasicAuthCredential("user", "pass")) + with Pipeline(client, policies=[policy]) as p: + p.run( + _request("http://insecure.example.com/"), + DispatchContext(_instr("0" * 15 + "46")), + enforce_https=False, + ) + assert client.calls[0].headers.get("authorization") == "Basic dXNlcjpwYXNz" + + +# --------------------------------------------------------------------------- # +# Credential hygiene: every credentialed policy redacts secrets in repr/str. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-8") +def test_credentialed_policy_reprs_redact_secrets() -> None: + """Render every credentialed policy and grep for the literal secrets.""" + secret_key = "hunter2-key" + secret_user = "alice-user" + secret_pass = "hunter2-pass" + secret_token = "s3cr3t-token" + encoded = BasicAuthCredential(secret_user, secret_pass).encoded + policies = [ + KeyCredentialPolicy(KeyCredential(secret_key), "X-API-Key", prefix="SharedKey"), + BasicAuthPolicy(BasicAuthCredential(secret_user, secret_pass)), + BearerTokenPolicy(_RecordingTokenCredential(secret_token), "scope-a"), + AsyncBearerTokenPolicy(_RecordingAsyncTokenCredential(secret_token), "scope-a"), + ] + needles = [secret_key, secret_user, secret_pass, secret_token, encoded] + for policy in policies: + for rendered in (repr(policy), str(policy)): + for needle in needles: + assert needle not in rendered, ( + f"{type(policy).__name__} leaked a secret in {rendered!r}" + ) + + +# --------------------------------------------------------------------------- # +# Credential hygiene: blank secrets are rejected at construction, not first use. # +# --------------------------------------------------------------------------- # + + +def test_key_credential_policy_rejects_blank_secret_at_construction() -> None: + with pytest.raises((TypeError, ValueError)): + KeyCredentialPolicy(KeyCredential(""), "X-API-Key") + + +def test_basic_auth_policy_rejects_blank_secret_at_construction() -> None: + with pytest.raises(ValueError): + BasicAuthPolicy(BasicAuthCredential("user", "")) + with pytest.raises(ValueError): + BasicAuthPolicy(BasicAuthCredential("", "pass")) + + +# --------------------------------------------------------------------------- # +# Credential hygiene: KeyCredentialPolicy header/prefix shape. # +# --------------------------------------------------------------------------- # + + +def test_key_credential_policy_rejects_empty_header_name() -> None: + with pytest.raises(ValueError): + KeyCredentialPolicy(KeyCredential("k"), "") + + +def test_key_credential_policy_rejects_non_key_credential() -> None: + with pytest.raises(TypeError): + KeyCredentialPolicy(object(), "X-API-Key") # type: ignore[arg-type] + + +@pytest.mark.req("AUTH-26") +def test_key_credential_policy_no_prefix_by_default() -> None: + client = _CapturingClient() + policy = KeyCredentialPolicy(KeyCredential("k"), "X-API-Key") + with Pipeline(client, policies=[policy]) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "47"))) + assert client.calls[0].headers.get("x-api-key") == "k" + + +# --------------------------------------------------------------------------- # +# Credential hygiene: key-style policies/credentials are identity-equal, while # +# the bearer-style access-token value object keeps value equality. # +# --------------------------------------------------------------------------- # + + +def test_key_style_policies_use_identity_equality() -> None: + """Two policies with the same key value must not compare equal.""" + a = KeyCredentialPolicy(KeyCredential("same"), "X-API-Key") + b = KeyCredentialPolicy(KeyCredential("same"), "X-API-Key") + assert a == a + assert a != b + + +@pytest.mark.req("AUTH-8") +def test_key_style_credentials_use_identity_equality() -> None: + assert KeyCredential("same") != KeyCredential("same") + assert BasicAuthCredential("u", "p") != BasicAuthCredential("u", "p") + + +@pytest.mark.req("AUTH-8") +def test_bearer_style_access_token_uses_value_equality() -> None: + a = AccessTokenInfo(token="t", expires_on=100, token_type="Bearer") + b = AccessTokenInfo(token="t", expires_on=100, token_type="Bearer") + assert a == b diff --git a/packages/dexpace-sdk-core/tests/auth/test_token_cache.py b/packages/dexpace-sdk-core/tests/auth/test_token_cache.py new file mode 100644 index 0000000..71af5b1 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/auth/test_token_cache.py @@ -0,0 +1,356 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the single-flight token caches and their shared refresh logic. + +Exercises the wait-free hot read, single-flight refresh under real +concurrency, the async three-zone strategy (fresh / soft / hard), the +non-fatal background-refresh path, the misbehaving-provider matrix, and the +waiter-cancel guarantee. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import cast + +import pytest + +from dexpace.sdk.core.errors import ClientAuthenticationError +from dexpace.sdk.core.http.auth._refresh import ( + REFRESH_MARGIN_SECONDS, + RefreshZone, + classify, + validate_fetched, +) +from dexpace.sdk.core.http.auth.access_token import AccessTokenInfo +from dexpace.sdk.core.http.auth.token_cache import ( + InMemoryTokenCache, + _AsyncSingleFlight, + _SyncSingleFlight, +) +from dexpace.sdk.core.util.clock import SYSTEM_CLOCK + +from ..conftest import FakeClock + + +class _AsyncStepClock: + """Deterministic ``AsyncClock`` whose time only moves when told to. + + ``now`` is fixed at ``start`` unless ``advance``/``sleep`` moves it, so a + test can pin a token in a specific refresh zone. + """ + + __slots__ = ("_now",) + + def __init__(self, start: float = 0.0) -> None: + self._now = start + + def now(self) -> float: + return self._now + + def monotonic(self) -> float: + return self._now + + async def sleep(self, duration: float) -> None: + if duration > 0: + self._now += duration + + def advance(self, duration: float) -> None: + self._now += duration + + +# --------------------------------------------------------------------------- # +# Pure zone classification + fetched-token validation # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-34") +def test_refresh_margin_default_is_30_seconds() -> None: + assert REFRESH_MARGIN_SECONDS == 30.0 + + +def test_classify_zone_boundaries() -> None: + now = 1000.0 + # No token or past expiry -> HARD (block on fetch). + assert classify(None, now=now) is RefreshZone.HARD + assert classify(AccessTokenInfo("t", 1000), now=now) is RefreshZone.HARD + # Valid but inside the 30s margin -> SOFT. + assert classify(AccessTokenInfo("t", 1020), now=now) is RefreshZone.SOFT + # Exactly on the margin boundary (now == expires_on - margin) -> SOFT. + assert classify(AccessTokenInfo("t", 1030), now=now) is RefreshZone.SOFT + # One second before the margin opens -> FRESH. + assert classify(AccessTokenInfo("t", 1031), now=now) is RefreshZone.FRESH + # Comfortably valid -> FRESH. + assert classify(AccessTokenInfo("t", 5000), now=now) is RefreshZone.FRESH + # A passed ``refresh_on`` hint opens the soft window even when far from expiry. + assert classify(AccessTokenInfo("t", 5000, refresh_on=900), now=now) is RefreshZone.SOFT + + +def test_validate_fetched_rejects_none_and_already_expired() -> None: + with pytest.raises(ClientAuthenticationError): + validate_fetched(None, now=1000.0) + # Already expired at fetch time (no margin applied) -> error. + with pytest.raises(ClientAuthenticationError): + validate_fetched(AccessTokenInfo("t", 1000), now=1000.0) + # Inside the margin but not expired: valid, because no margin is applied here. + ok = validate_fetched(AccessTokenInfo("t", 1005), now=1000.0) + assert ok.token == "t" + + +# --------------------------------------------------------------------------- # +# Sync single-flight cache # +# --------------------------------------------------------------------------- # + + +def test_sync_single_flight_one_fetch_under_thread_pool() -> None: + """N concurrent cache misses collapse to exactly one provider fetch.""" + cache = InMemoryTokenCache() + lock = threading.Lock() + calls = 0 + + def provider() -> AccessTokenInfo: + nonlocal calls + with lock: + calls += 1 + time.sleep(0.02) + return AccessTokenInfo(token="t", expires_on=int(time.time()) + 3600) + + sf = _SyncSingleFlight(cache, ("s",), None, SYSTEM_CLOCK) + with ThreadPoolExecutor(max_workers=8) as pool: + futures = [pool.submit(sf.get_token, provider) for _ in range(8)] + results = [f.result(timeout=5) for f in futures] + + assert calls == 1 + assert all(r.token == "t" for r in results) + + +def test_sync_fresh_token_served_without_fetch() -> None: + clock = FakeClock(start=1000.0) + cache = InMemoryTokenCache() + cache.set(("s",), AccessTokenInfo(token="cached", expires_on=1000 + 3600)) + sf = _SyncSingleFlight(cache, ("s",), None, clock) + + def provider() -> AccessTokenInfo: + raise AssertionError("provider must not be called for a fresh token") + + assert sf.get_token(provider).token == "cached" + + +@pytest.mark.req("AUTH-35") +def test_sync_none_token_raises() -> None: + sf = _SyncSingleFlight(InMemoryTokenCache(), ("s",), None, SYSTEM_CLOCK) + + def provider() -> AccessTokenInfo: + return cast("AccessTokenInfo", None) + + with pytest.raises(ClientAuthenticationError): + sf.get_token(provider) + + +@pytest.mark.req("AUTH-35") +def test_sync_already_expired_token_raises() -> None: + clock = FakeClock(start=1000.0) + sf = _SyncSingleFlight(InMemoryTokenCache(), ("s",), None, clock) + + def provider() -> AccessTokenInfo: + return AccessTokenInfo(token="t", expires_on=1000) + + with pytest.raises(ClientAuthenticationError): + sf.get_token(provider) + + +@pytest.mark.req("AUTH-11", "AUTH-35") +def test_sync_throwing_provider_not_cached_and_retried() -> None: + cache = InMemoryTokenCache() + calls = 0 + + def provider() -> AccessTokenInfo: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("token endpoint down") + return AccessTokenInfo(token="t", expires_on=int(time.time()) + 3600) + + sf = _SyncSingleFlight(cache, ("s",), None, SYSTEM_CLOCK) + with pytest.raises(RuntimeError): + sf.get_token(provider) + # A throwing provider must not be cached: the next call retries it. + assert cache.get(("s",)) is None + assert sf.get_token(provider).token == "t" + assert calls == 2 + + +# --------------------------------------------------------------------------- # +# Async single-flight cache — three-zone strategy # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-37") +async def test_async_single_flight_one_fetch_under_gather() -> None: + """N concurrent cache misses collapse to exactly one provider fetch.""" + clock = _AsyncStepClock(start=1000.0) + cache = InMemoryTokenCache() + calls = 0 + + async def provider() -> AccessTokenInfo: + nonlocal calls + calls += 1 + await asyncio.sleep(0.02) + return AccessTokenInfo(token="t", expires_on=int(clock.now()) + 3600) + + sf = _AsyncSingleFlight(cache, ("s",), None, clock) + results = await asyncio.gather(*(sf.get_token(provider) for _ in range(8))) + + assert calls == 1 + assert all(r.token == "t" for r in results) + + +@pytest.mark.req("AUTH-37") +async def test_async_fresh_zone_serves_without_fetch() -> None: + clock = _AsyncStepClock(start=1000.0) + cache = InMemoryTokenCache() + cache.set(("s",), AccessTokenInfo(token="fresh", expires_on=1000 + 3600)) + sf = _AsyncSingleFlight(cache, ("s",), None, clock) + + async def provider() -> AccessTokenInfo: + raise AssertionError("provider must not be called for a fresh token") + + assert (await sf.get_token(provider)).token == "fresh" + + +@pytest.mark.req("AUTH-37") +async def test_async_soft_zone_serves_stale_and_refreshes_in_background() -> None: + clock = _AsyncStepClock(start=1000.0) + cache = InMemoryTokenCache() + # Expires in 20s: still valid, but inside the 30s margin -> SOFT. + cache.set(("s",), AccessTokenInfo(token="stale", expires_on=1020)) + calls = 0 + + async def provider() -> AccessTokenInfo: + nonlocal calls + calls += 1 + return AccessTokenInfo(token="fresh", expires_on=int(clock.now()) + 3600) + + sf = _AsyncSingleFlight(cache, ("s",), None, clock) + served = await sf.get_token(provider) + + # The still-valid token is served immediately. + assert served.token == "stale" + # A background refresh was scheduled; drain it deterministically. + assert sf._background is not None + await sf._background + assert calls == 1 + entry = cache.get(("s",)) + assert entry is not None and entry.token == "fresh" + + +@pytest.mark.req("AUTH-37") +async def test_async_background_refresh_failure_is_non_fatal() -> None: + clock = _AsyncStepClock(start=1000.0) + cache = InMemoryTokenCache() + cache.set(("s",), AccessTokenInfo(token="stale", expires_on=1020)) # SOFT + + async def provider() -> AccessTokenInfo: + raise RuntimeError("token endpoint down") + + sf = _AsyncSingleFlight(cache, ("s",), None, clock) + # Serving the still-valid token must not raise despite the doomed refresh. + served = await sf.get_token(provider) + assert served.token == "stale" + + assert sf._background is not None + await sf._background # completes without raising (failure swallowed) + # The cache still holds the previously-valid token. + entry = cache.get(("s",)) + assert entry is not None and entry.token == "stale" + + +@pytest.mark.req("AUTH-37") +async def test_async_hard_zone_blocks_on_fetch() -> None: + clock = _AsyncStepClock(start=1000.0) + cache = InMemoryTokenCache() + cache.set(("s",), AccessTokenInfo(token="expired", expires_on=1000)) # HARD + + async def provider() -> AccessTokenInfo: + return AccessTokenInfo(token="fresh", expires_on=int(clock.now()) + 3600) + + sf = _AsyncSingleFlight(cache, ("s",), None, clock) + assert (await sf.get_token(provider)).token == "fresh" + + +async def test_async_none_token_raises() -> None: + sf = _AsyncSingleFlight(InMemoryTokenCache(), ("s",), None, _AsyncStepClock(1000.0)) + + async def provider() -> AccessTokenInfo: + return cast("AccessTokenInfo", None) + + with pytest.raises(ClientAuthenticationError): + await sf.get_token(provider) + + +async def test_async_already_expired_token_raises() -> None: + clock = _AsyncStepClock(start=1000.0) + sf = _AsyncSingleFlight(InMemoryTokenCache(), ("s",), None, clock) + + async def provider() -> AccessTokenInfo: + return AccessTokenInfo(token="t", expires_on=1000) + + with pytest.raises(ClientAuthenticationError): + await sf.get_token(provider) + + +@pytest.mark.req("AUTH-11") +async def test_async_throwing_provider_not_cached_and_retried() -> None: + clock = _AsyncStepClock(start=1000.0) + cache = InMemoryTokenCache() + calls = 0 + + async def provider() -> AccessTokenInfo: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("boom") + return AccessTokenInfo(token="t", expires_on=int(clock.now()) + 3600) + + sf = _AsyncSingleFlight(cache, ("s",), None, clock) + with pytest.raises(RuntimeError): + await sf.get_token(provider) + assert cache.get(("s",)) is None + assert (await sf.get_token(provider)).token == "t" + assert calls == 2 + + +async def test_async_cancelled_waiter_does_not_kill_shared_fetch() -> None: + """One waiter's cancellation propagates to it alone; others still win.""" + clock = _AsyncStepClock(start=1000.0) + cache = InMemoryTokenCache() # cold -> HARD for all waiters + started = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def provider() -> AccessTokenInfo: + nonlocal calls + calls += 1 + started.set() + await release.wait() + return AccessTokenInfo(token="t", expires_on=int(clock.now()) + 3600) + + sf = _AsyncSingleFlight(cache, ("s",), None, clock) + w1 = asyncio.ensure_future(sf.get_token(provider)) + w2 = asyncio.ensure_future(sf.get_token(provider)) + w3 = asyncio.ensure_future(sf.get_token(provider)) + + await started.wait() # the single shared fetch is in flight + w2.cancel() + with pytest.raises(asyncio.CancelledError): + await w2 + + release.set() + tokens = await asyncio.gather(w1, w3) + assert all(t.token == "t" for t in tokens) + # The shared fetch survived the one cancellation and ran exactly once. + assert calls == 1 diff --git a/packages/dexpace-sdk-core/tests/client/__init__.py b/packages/dexpace-sdk-core/tests/client/__init__.py new file mode 100644 index 0000000..a69f5b7 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/client/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. diff --git a/packages/dexpace-sdk-core/tests/client/test_bridges.py b/packages/dexpace-sdk-core/tests/client/test_bridges.py new file mode 100644 index 0000000..e6eb0f2 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/client/test_bridges.py @@ -0,0 +1,331 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Sync/async transport bridges (PY-M3-06). + +Two adapters cross the sync and async transport pillars: + +- ``SyncToAsyncHttpClient`` wraps a blocking ``HttpClient`` for use from an + async pipeline. It refuses to run without a caller-supplied ``Executor`` — a + default shared pool is exactly the exhaustible resource that causes + hard-to-debug starvation, so construction fails immediately without one. +- ``AsyncToSyncHttpClient`` wraps an ``AsyncHttpClient`` for use from a sync + pipeline, driving it on a dedicated background event-loop thread with clean + exception unwrapping, cancel-then-re-raise interrupt handling, and + exactly-once cleanup of orphaned responses. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import threading +from collections.abc import AsyncIterator + +import pytest + +from dexpace.sdk.core.client import AsyncToSyncHttpClient, SyncToAsyncHttpClient +from dexpace.sdk.core.client._async_to_sync import _BridgedCall, _unwrap_exception +from dexpace.sdk.core.client.async_http_client import AsyncHttpClient +from dexpace.sdk.core.client.http_client import HttpClient +from dexpace.sdk.core.errors import ServiceRequestError +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import ( + AsyncResponse, + AsyncResponseBody, + Response, + ResponseBody, + Status, +) +from dexpace.sdk.core.instrumentation.correlation import get_trace_id, set_trace_id + + +def _get() -> Request: + return Request(method=Method.GET, url=Url.parse("https://example.com/")) + + +def _sync_response(request: Request, body: ResponseBody | None = None) -> Response: + return Response( + request=request, + protocol=Protocol.HTTP_1_1, + status=Status.OK, + body=body, + ) + + +def _async_response(request: Request, body: AsyncResponseBody | None = None) -> AsyncResponse: + return AsyncResponse( + request=request, + protocol=Protocol.HTTP_1_1, + status=Status.OK, + body=body, + ) + + +class _CloseTrackingAsyncBody(AsyncResponseBody): + """An ``AsyncResponseBody`` double that counts (and signals) its closes.""" + + def __init__(self, data: bytes = b"payload") -> None: + self._data = data + self.close_count = 0 + self._closed = threading.Event() + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return len(self._data) + + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + async def _gen() -> AsyncIterator[bytes]: + yield self._data + + return _gen() + + async def close(self) -> None: + self.close_count += 1 + self._closed.set() + + def wait_closed(self, timeout: float) -> bool: + return self._closed.wait(timeout) + + +# --------------------------------------------------------------------------- # +# SyncToAsyncHttpClient +# --------------------------------------------------------------------------- # + + +class _RecordingSyncClient(HttpClient): + """Sync transport double that records the thread and correlation it saw.""" + + def __init__(self) -> None: + self.seen_request: Request | None = None + self.seen_thread: int | None = None + self.seen_trace: str | None = None + + def execute(self, request: Request) -> Response: + self.seen_request = request + self.seen_thread = threading.get_ident() + self.seen_trace = get_trace_id() + return _sync_response(request, ResponseBody.from_bytes(b"hello")) + + +class TestSyncToAsyncConstruction: + @pytest.mark.req("SEAM-18") + def test_missing_executor_raises_at_construction(self) -> None: + # Omitting the required keyword-only executor fails immediately, before + # any call — the SDK never fabricates a shared pool. + with pytest.raises(TypeError): + SyncToAsyncHttpClient(_RecordingSyncClient()) # type: ignore[call-arg] + + def test_none_executor_raises_at_construction(self) -> None: + with pytest.raises(TypeError, match="executor"): + SyncToAsyncHttpClient(_RecordingSyncClient(), executor=None) # type: ignore[arg-type] + + +class TestSyncToAsyncExecute: + @pytest.mark.req("ASYNC-19") + async def test_runs_on_supplied_executor(self) -> None: + client = _RecordingSyncClient() + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + bridge = SyncToAsyncHttpClient(client, executor=pool) + request = _get() + response = await bridge.execute(request) + assert isinstance(response, AsyncResponse) + assert response.status is Status.OK + assert client.seen_request is request # request threads through unchanged + assert client.seen_thread is not None + assert client.seen_thread != threading.get_ident() # ran off the loop thread + assert response.body is not None + assert await response.body.bytes() == b"hello" + + @pytest.mark.req("ASYNC-10", "ASYNC-8", "SEAM-24") + async def test_contextvars_captured_at_submission(self) -> None: + client = _RecordingSyncClient() + token = set_trace_id("trace-sync-to-async") + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + bridge = SyncToAsyncHttpClient(client, executor=pool) + await bridge.execute(_get()) + # The worker observed the correlation id bound at submission, not the + # bare context a fresh pool thread would otherwise inherit. + assert client.seen_trace == "trace-sync-to-async" + finally: + set_trace_id(None) + del token + + +# --------------------------------------------------------------------------- # +# AsyncToSyncHttpClient +# --------------------------------------------------------------------------- # + + +class _RecordingAsyncClient(AsyncHttpClient): + """Async transport double that records the thread and correlation it saw.""" + + def __init__(self, body: AsyncResponseBody | None = None) -> None: + self.seen_request: Request | None = None + self.seen_thread: int | None = None + self.seen_trace: str | None = None + self._body = body + + async def execute(self, request: Request) -> AsyncResponse: + self.seen_request = request + self.seen_thread = threading.get_ident() + self.seen_trace = get_trace_id() + body = self._body if self._body is not None else AsyncResponseBody.from_bytes(b"hi") + return _async_response(request, body) + + +class _RaisingAsyncClient(AsyncHttpClient): + def __init__(self, exc: BaseException) -> None: + self._exc = exc + + async def execute(self, request: Request) -> AsyncResponse: + raise self._exc + + +class TestAsyncToSyncExecute: + @pytest.mark.req("ASYNC-19") + def test_runs_async_client_on_background_thread(self) -> None: + client = _RecordingAsyncClient() + with AsyncToSyncHttpClient(client) as bridge: + request = _get() + response = bridge.execute(request) + assert isinstance(response, Response) + assert response.status is Status.OK + assert client.seen_request is request # threads through unchanged + assert client.seen_thread is not None + assert client.seen_thread != threading.get_ident() # a dedicated thread + assert response.body is not None + assert response.body.bytes() == b"hi" + + def test_contextvars_captured_at_submission(self) -> None: + client = _RecordingAsyncClient() + token = set_trace_id("trace-async-to-sync") + try: + with AsyncToSyncHttpClient(client) as bridge: + bridge.execute(_get()) + assert client.seen_trace == "trace-async-to-sync" + finally: + set_trace_id(None) + del token + + @pytest.mark.req("ASYNC-14", "SEAM-17", "SEAM-18") + def test_sdk_exception_surfaces_with_exact_type(self) -> None: + # A caller catching the specific SDK type must see that exact type, not + # a generic threading/future wrapper. + boom = ServiceRequestError("connect failed") + with AsyncToSyncHttpClient(_RaisingAsyncClient(boom)) as bridge: + with pytest.raises(ServiceRequestError) as excinfo: + bridge.execute(_get()) + assert excinfo.value is boom + + @pytest.mark.req("ASYNC-16") + def test_context_manager_shuts_down_background_thread(self) -> None: + bridge = AsyncToSyncHttpClient(_RecordingAsyncClient()) + with bridge: + bridge.execute(_get()) + assert not bridge._loop_bg._thread.is_alive() + + +class TestAsyncToSyncCancellation: + @pytest.mark.req("ASYNC-14", "ASYNC-6", "SEAM-18") + def test_keyboard_interrupt_cancels_then_reraises( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + started = threading.Event() + cancelled = threading.Event() + + class _Hanging(AsyncHttpClient): + async def execute(self, request: Request) -> AsyncResponse: + started.set() + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + cancelled.set() + raise + raise AssertionError("unreachable") # pragma: no cover + + def _block_then_interrupt(self: _BridgedCall) -> None: + assert started.wait(2) # the async op is genuinely in flight + raise KeyboardInterrupt + + monkeypatch.setattr(_BridgedCall, "_block", _block_then_interrupt) + with AsyncToSyncHttpClient(_Hanging()) as bridge: + with pytest.raises(KeyboardInterrupt): + bridge.execute(_get()) + # The in-flight async operation was cancelled, not left dangling. + assert cancelled.wait(2) + + @pytest.mark.req("ASYNC-5", "SEAM-30") + def test_orphaned_response_closed_exactly_once(self, monkeypatch: pytest.MonkeyPatch) -> None: + body = _CloseTrackingAsyncBody() + client = _RecordingAsyncClient(body=body) + + def _block_after_done(self: _BridgedCall) -> None: + # The async op finished (a response exists) but the caller abandons. + assert self._done.wait(2) + raise KeyboardInterrupt + + monkeypatch.setattr(_BridgedCall, "_block", _block_after_done) + with AsyncToSyncHttpClient(client) as bridge: + with pytest.raises(KeyboardInterrupt): + bridge.execute(_get()) + assert body.wait_closed(2) + assert body.close_count == 1 # orphan closed exactly once — no leak, no double-close + + +class TestBridgedCallOwnership: + """White-box ownership contract of the per-call handshake object.""" + + @pytest.mark.req("ASYNC-20", "SEAM-16") + def test_delivered_response_not_closed_on_late_abandon(self) -> None: + body = _CloseTrackingAsyncBody() + with AsyncToSyncHttpClient(_RecordingAsyncClient()) as bridge: + loop = bridge._loop_bg.loop + call = _BridgedCall(bridge._loop_bg) + response = _async_response(_get(), body) + # Mimic the loop-thread completion handshake, then take delivery. + with call._lock: + call._result = response + call._done.set() + assert call.wait() is response + # A cancellation that arrives after delivery must NOT close a + # response the caller already owns. + call._abandon() + # Flush the loop so any (erroneous) scheduled close would run. + asyncio.run_coroutine_threadsafe(asyncio.sleep(0), loop).result(2) + assert body.close_count == 0 + + +class TestUnwrapException: + @pytest.mark.req("ASYNC-13") + def test_passes_through_sdk_error_unchanged(self) -> None: + boom = ServiceRequestError("boom") + assert _unwrap_exception(boom) is boom + + @pytest.mark.req("ASYNC-13", "ASYNC-14") + def test_translates_concurrent_cancelled_to_asyncio(self) -> None: + original = asyncio.CancelledError() + wrapper = concurrent.futures.CancelledError() + wrapper.__cause__ = original + result = _unwrap_exception(wrapper) + assert isinstance(result, asyncio.CancelledError) + assert result is original + + def test_bare_concurrent_cancelled_becomes_asyncio(self) -> None: + result = _unwrap_exception(concurrent.futures.CancelledError()) + assert isinstance(result, asyncio.CancelledError) + + @pytest.mark.req("ASYNC-13") + def test_cycle_in_cause_chain_terminates(self) -> None: + # A self-referential __cause__/__context__ cycle must not spin forever. + wrapper = concurrent.futures.CancelledError() + filler = RuntimeError("noise") + wrapper.__cause__ = filler + filler.__cause__ = wrapper # cycle + filler.__context__ = wrapper + result = _unwrap_exception(wrapper) # must return, not hang or recurse + assert isinstance(result, asyncio.CancelledError) diff --git a/packages/dexpace-sdk-core/tests/codegen/__init__.py b/packages/dexpace-sdk-core/tests/codegen/__init__.py new file mode 100644 index 0000000..a69f5b7 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. diff --git a/packages/dexpace-sdk-core/tests/codegen/support.py b/packages/dexpace-sdk-core/tests/codegen/support.py new file mode 100644 index 0000000..a8b935b --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/support.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Shared fixtures for the executor tests — scripted transports and a model. + +Not a test module (no ``test_`` prefix), so pytest does not collect it; it is +imported by the executor test suites. It supplies sync/async scripted +transports that build fresh responses per call (so single-use bodies never leak +across calls), an ownership-tracking close ledger, an options-probe policy pair, +and a tiny decodable model. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from dexpace.sdk.core.http.common import Protocol +from dexpace.sdk.core.http.response import AsyncResponse, Response, Status +from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody +from dexpace.sdk.core.http.response.response_body import ResponseBody +from dexpace.sdk.core.pipeline.async_policy import AsyncPolicy +from dexpace.sdk.core.pipeline.policy import Policy +from dexpace.sdk.core.pipeline.stage import Stage + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from dexpace.sdk.core.http.request.request import Request + from dexpace.sdk.core.pipeline.context import PipelineContext + + +@dataclass(frozen=True, slots=True) +class Pet: + """A tiny model the codec decodes ``{"name": ...}`` documents into.""" + + name: str + + +@dataclass(frozen=True, slots=True) +class Reply: + """A scripted reply: the status, an optional body, and optional headers.""" + + status: Status + body: bytes | None = None + headers: tuple[tuple[str, str], ...] = () + + +def _sync_response(reply: Reply, request: Request) -> Response: + body = ResponseBody.from_bytes(reply.body) if reply.body is not None else None + response = Response(request=request, protocol=Protocol.HTTP_1_1, status=reply.status, body=body) + for name, value in reply.headers: + response = response.with_header(name, value) + return response + + +def _async_response(reply: Reply, request: Request) -> AsyncResponse: + body = AsyncResponseBody.from_bytes(reply.body) if reply.body is not None else None + response = AsyncResponse( + request=request, protocol=Protocol.HTTP_1_1, status=reply.status, body=body + ) + for name, value in reply.headers: + response = response.with_header(name, value) + return response + + +class ScriptTransport: + """Sync ``HttpClient`` that replays a script, building a fresh response per call. + + The reply is chosen by request index and clamped to the last entry, so a + stream that reconnects past the script re-serves the final reply rather than + raising. ``close`` records its call count for the ownership matrix. + """ + + def __init__(self, script: Sequence[Reply]) -> None: + self._script = list(script) + self.requests: list[Request] = [] + self.close_calls = 0 + + def execute(self, request: Request) -> Response: + reply = self._script[min(len(self.requests), len(self._script) - 1)] + self.requests.append(request) + return _sync_response(reply, request) + + def close(self) -> None: + self.close_calls += 1 + + +class AsyncScriptTransport: + """Async twin of `ScriptTransport` (records ``aclose`` calls).""" + + def __init__(self, script: Sequence[Reply]) -> None: + self._script = list(script) + self.requests: list[Request] = [] + self.aclose_calls = 0 + + async def execute(self, request: Request) -> AsyncResponse: + reply = self._script[min(len(self.requests), len(self._script) - 1)] + self.requests.append(request) + return _async_response(reply, request) + + async def aclose(self) -> None: + self.aclose_calls += 1 + + +class OptionsProbe(Policy): + """Records the ``ctx.options`` mapping seen for each exchange.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self) -> None: + self.seen: list[Mapping[str, Any]] = [] + + def send(self, request: Request, ctx: PipelineContext) -> Response: + self.seen.append(ctx.options) + return self.next.send(request, ctx) + + +class AsyncOptionsProbe(AsyncPolicy): + """Async twin of `OptionsProbe`.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self) -> None: + self.seen: list[Mapping[str, Any]] = [] + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + self.seen.append(ctx.options) + return await self.next.send(request, ctx) diff --git a/packages/dexpace-sdk-core/tests/codegen/test_assemble.py b/packages/dexpace-sdk-core/tests/codegen/test_assemble.py new file mode 100644 index 0000000..190d9a2 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_assemble.py @@ -0,0 +1,198 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for ``assemble`` — turning a declarative operation into a ``Request``.""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.codegen import Operation, OperationInput, assemble +from dexpace.sdk.core.http.common import Headers, QueryParams, Url +from dexpace.sdk.core.http.request import Method, RequestBody + + +def _op(path: str, method: Method = Method.GET, **kw: object) -> Operation: + return Operation(name="advisory", method=method, path=path, **kw) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- # +# Signed / SAS-token base URL: existing query preserved, op query appended. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-27") +def test_sas_query_preserved_and_operation_query_appended() -> None: + base = ( + "https://acct.blob.core.windows.net/container/blob" + "?sv=2021-08-06&sig=abc%2Bdef%2F123%3D&se=2030-01-01" + ) + inp = OperationInput(query=QueryParams([("snapshot", "2020-01-01"), ("comp", "block")])) + req = assemble(_op(""), inp, base) + + # The base query survives byte-for-byte (RFC 3986 wire form) and the + # operation's own parameters are appended strictly after it. + assert str(req.url) == ( + "https://acct.blob.core.windows.net/container/blob" + "?sv=2021-08-06&sig=abc%2Bdef%2F123%3D&se=2030-01-01" + "&snapshot=2020-01-01&comp=block" + ) + + +def test_sas_query_pairs_ordered_base_then_operation() -> None: + base = "https://h.example.com/x?token=t1&token=t2" + inp = OperationInput(query=QueryParams([("page", "2")])) + req = assemble(_op("/data"), inp, base) + assert req.url.query.flatten() == ( + ("token", "t1"), + ("token", "t2"), + ("page", "2"), + ) + + +def test_base_query_preserved_when_operation_adds_none() -> None: + req = assemble(_op("/data"), OperationInput(), "https://h.example.com/?token=abc") + assert req.url.query.flatten() == (("token", "abc"),) + + +# --------------------------------------------------------------------------- # +# Path parameters: encoded as a single segment, missing ones are named. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-27") +def test_path_value_with_slash_stays_single_segment() -> None: + inp = OperationInput(path_params={"path": "a/b/c"}) + req = assemble(_op("/files/{path}/meta"), inp, "https://api.example.com") + # The literal '/' is percent-encoded, so it never introduces a new segment. + assert req.url.path == "/files/a%2Fb%2Fc/meta" + assert req.url.path.split("/") == ["", "files", "a%2Fb%2Fc", "meta"] + + +def test_path_value_encodes_space_and_non_ascii() -> None: + inp = OperationInput(path_params={"term": "hello world/ü"}) + req = assemble(_op("/search/{term}"), inp, "https://api.example.com") + assert req.url.path == "/search/hello%20world%2F%C3%BC" + + +def test_all_placeholders_substituted() -> None: + inp = OperationInput(path_params={"userId": "42", "itemId": "99"}) + req = assemble(_op("/users/{userId}/items/{itemId}"), inp, "https://api.example.com") + assert req.url.path == "/users/42/items/99" + + +@pytest.mark.req("SEAM-27") +def test_missing_placeholder_is_named_in_error() -> None: + inp = OperationInput(path_params={"userId": "42"}) + with pytest.raises(KeyError) as excinfo: + assemble(_op("/users/{userId}/items/{itemId}"), inp, "https://api.example.com") + assert "itemId" in str(excinfo.value) + + +def test_path_param_value_is_not_leaked_when_placeholder_missing() -> None: + # Only the missing name is reported; supplied (possibly secret) values must + # not appear in the error message. + inp = OperationInput(path_params={"userId": "s3cr3t-token"}) + with pytest.raises(KeyError) as excinfo: + assemble(_op("/users/{userId}/items/{itemId}"), inp, "https://api.example.com") + assert "s3cr3t-token" not in str(excinfo.value) + + +# --------------------------------------------------------------------------- # +# Trailing-slash normalisation. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-27") +def test_trailing_slash_on_base_does_not_double() -> None: + req = assemble(_op("/users"), OperationInput(), "https://api.example.com/v1/") + assert req.url.path == "/v1/users" + + +def test_operation_path_without_leading_slash_joins_cleanly() -> None: + req = assemble(_op("users"), OperationInput(), "https://api.example.com/v1") + assert req.url.path == "/v1/users" + + +def test_empty_operation_path_keeps_base_path() -> None: + req = assemble(_op(""), OperationInput(), "https://api.example.com/v1") + assert req.url.path == "/v1" + + +def test_trailing_slash_on_operation_path_preserved() -> None: + req = assemble(_op("/users/"), OperationInput(), "https://api.example.com") + assert req.url.path == "/users/" + + +# --------------------------------------------------------------------------- # +# Base URL validation. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-27") +def test_base_url_with_fragment_is_rejected() -> None: + with pytest.raises(ValueError, match="fragment"): + assemble(_op("/x"), OperationInput(), "https://api.example.com/#section") + + +def test_malformed_base_url_is_rejected() -> None: + with pytest.raises(ValueError): + assemble(_op("/x"), OperationInput(), "not-a-url") + + +def test_base_url_accepts_url_instance() -> None: + base = Url.parse("https://api.example.com/v1?k=v") + req = assemble(_op("/users"), OperationInput(), base) + assert str(req.url) == "https://api.example.com/v1/users?k=v" + + +# --------------------------------------------------------------------------- # +# Method, headers, and body pass-through. # +# --------------------------------------------------------------------------- # + + +def test_method_is_taken_from_operation() -> None: + req = assemble(_op("/items", method=Method.POST), OperationInput(), "https://h.example.com") + assert req.method is Method.POST + + +def test_static_and_per_call_headers_are_merged() -> None: + op = _op("/", headers=Headers({"accept": "application/json"})) + inp = OperationInput(headers=Headers({"x-request-id": "abc"})) + req = assemble(op, inp, "https://h.example.com") + assert req.headers.get("accept") == "application/json" + assert req.headers.get("x-request-id") == "abc" + + +def test_per_call_header_overrides_static_header() -> None: + op = _op("/", headers=Headers({"accept": "application/json"})) + inp = OperationInput(headers=Headers({"accept": "text/plain"})) + req = assemble(op, inp, "https://h.example.com") + assert req.headers.values("accept") == ("text/plain",) + + +@pytest.mark.req("SEAM-26") +def test_body_is_carried_through_unchanged() -> None: + body = RequestBody.from_string("hello") + req = assemble( + _op("/items", method=Method.POST), OperationInput(body=body), "https://h.example.com" + ) + assert req.body is body + + +# --------------------------------------------------------------------------- # +# ``Operation.name`` has zero effect on the assembled request. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-28") +def test_operation_name_has_no_effect_on_assembly() -> None: + a = Operation(name="getUser", method=Method.GET, path="/users/{id}") + b = Operation(name="retrieveUserById", method=Method.GET, path="/users/{id}") + inp = OperationInput(path_params={"id": "7"}, query=QueryParams([("q", "x")])) + ra = assemble(a, inp, "https://api.example.com/v1") + rb = assemble(b, inp, "https://api.example.com/v1") + assert ra == rb + assert str(ra.url) == str(rb.url) + assert ra.headers == rb.headers + assert ra.method is rb.method diff --git a/packages/dexpace-sdk-core/tests/codegen/test_auth_resolution.py b/packages/dexpace-sdk-core/tests/codegen/test_auth_resolution.py new file mode 100644 index 0000000..2b6b344 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_auth_resolution.py @@ -0,0 +1,386 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the tiered auth-requirement resolver over the M4 stamping policies. + +Two layers are exercised. The pure layer (`select_tier` / `resolve_auth`) is +tested without any pipeline: the three-tier matrix (per-call over operation over +client) and — the load-bearing rule — that a present-but-unsatisfiable tier +fails loudly instead of falling through to a satisfiable broader tier. The +executor layer then proves the same rule end-to-end: a bad per-call override +raises before the request is ever sent, so the transport never sees a fallback +request stamped with the wrong (client-default) credential. +""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.codegen import ( + AsyncServiceCore, + AuthDescriptor, + AuthRegistry, + AuthRequirement, + AuthResolutionError, + AuthScheme, + Operation, + OperationInput, + ServiceCore, +) +from dexpace.sdk.core.codegen._pure.options import AUTH_OPTION_KEY +from dexpace.sdk.core.codegen.auth import AuthTier, resolve_auth, select_tier +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, Response, Status +from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline +from dexpace.sdk.core.pipeline.async_policy import AsyncPolicy +from dexpace.sdk.core.pipeline.context import PipelineContext +from dexpace.sdk.core.pipeline.policy import Policy +from dexpace.sdk.core.pipeline.stage import Stage + +from .support import ( + AsyncOptionsProbe, + AsyncScriptTransport, + OptionsProbe, + Pet, + Reply, + ScriptTransport, +) + +_BASE = "https://api.example.com" + + +class _StampPolicy(Policy): + """A labelled stand-in auth policy — only ever parked, never run.""" + + STAGE = Stage.AUTH + __slots__ = ("label",) + + def __init__(self, label: str) -> None: + self.label = label + + def send(self, request: Request, ctx: PipelineContext) -> Response: # pragma: no cover + raise NotImplementedError + + +class _AsyncStampPolicy(AsyncPolicy): + """Async twin of `_StampPolicy`.""" + + STAGE = Stage.AUTH + __slots__ = ("label",) + + def __init__(self, label: str) -> None: + self.label = label + + async def send( + self, request: Request, ctx: PipelineContext + ) -> AsyncResponse: # pragma: no cover + raise NotImplementedError + + +def _scheme(name: str, *, scopes: frozenset[str] = frozenset()) -> AuthScheme[Policy]: + policy: Policy = _StampPolicy(name) + return AuthScheme(name=name, policy=policy, scopes=scopes) + + +def _async_scheme(name: str, *, scopes: frozenset[str] = frozenset()) -> AuthScheme[AsyncPolicy]: + policy: AsyncPolicy = _AsyncStampPolicy(name) + return AuthScheme(name=name, policy=policy, scopes=scopes) + + +def _desc(scheme: str, *scopes: str) -> AuthDescriptor: + return AuthDescriptor.of(AuthRequirement(scheme=scheme, scopes=scopes)) + + +def _get_pet(*, auth: AuthDescriptor | None = None) -> Operation: + return Operation(name="getPet", method=Method.GET, path="/pets/{id}", auth=auth) + + +# --------------------------------------------------------------------------- # +# descriptor invariants # +# --------------------------------------------------------------------------- # + + +def test_auth_descriptor_rejects_empty() -> None: + with pytest.raises(ValueError, match="at least one"): + AuthDescriptor(requirements=()) + with pytest.raises(ValueError, match="at least one"): + AuthDescriptor.of() + + +def test_auth_requirement_rejects_blank_scheme() -> None: + with pytest.raises(ValueError, match="scheme"): + AuthRequirement(scheme="") + + +@pytest.mark.req("AUTH-5") +def test_auth_scheme_satisfies_by_name_and_scope_subset() -> None: + scheme = _scheme("oauth2", scopes=frozenset({"read", "write"})) + assert scheme.satisfies(AuthRequirement("oauth2", ("read",))) + assert scheme.satisfies(AuthRequirement("oauth2")) + assert not scheme.satisfies(AuthRequirement("oauth2", ("admin",))) # scope not granted + assert not scheme.satisfies(AuthRequirement("apikey")) # name mismatch + + +# --------------------------------------------------------------------------- # +# pure tier selection — presence, most-specific-first # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-4", "AUTH-7") +def test_select_tier_prefers_most_specific_present() -> None: + per_call, operation, client = _desc("a"), _desc("b"), _desc("c") + assert select_tier(per_call=per_call, operation=operation, client=client) == ( + AuthTier.PER_CALL, + per_call, + ) + assert select_tier(per_call=None, operation=operation, client=client) == ( + AuthTier.OPERATION, + operation, + ) + assert select_tier(per_call=None, operation=None, client=client) == (AuthTier.CLIENT, client) + assert select_tier(per_call=None, operation=None, client=None) is None + + +# --------------------------------------------------------------------------- # +# pure resolution matrix — the selected tier resolves against the registry # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-7") +def test_resolve_uses_per_call_tier_when_present() -> None: + registry = AuthRegistry(schemes=(_scheme("bearer"), _scheme("apikey"))) + resolved = resolve_auth( + per_call=_desc("bearer"), + operation=_desc("apikey"), + client=_desc("apikey"), + registry=registry, + ) + assert resolved is not None + assert resolved.tier is AuthTier.PER_CALL + assert resolved.requirement.scheme == "bearer" + + +def test_resolve_uses_operation_tier_when_no_per_call() -> None: + registry = AuthRegistry(schemes=(_scheme("bearer"), _scheme("apikey"))) + resolved = resolve_auth( + per_call=None, operation=_desc("bearer"), client=_desc("apikey"), registry=registry + ) + assert resolved is not None + assert resolved.tier is AuthTier.OPERATION + assert resolved.requirement.scheme == "bearer" + + +def test_resolve_uses_client_tier_when_no_per_call_or_operation() -> None: + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + resolved = resolve_auth( + per_call=None, operation=None, client=_desc("apikey"), registry=registry + ) + assert resolved is not None + assert resolved.tier is AuthTier.CLIENT + assert resolved.requirement.scheme == "apikey" + + +def test_resolve_returns_none_when_no_tier_present() -> None: + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + assert resolve_auth(per_call=None, operation=None, client=None, registry=registry) is None + + +@pytest.mark.req("AUTH-5") +def test_resolve_first_satisfiable_requirement_in_ordered_list() -> None: + # The descriptor lists two alternatives; only the second is registered, so + # resolution picks it — the ordered list is an OR of alternatives. + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + descriptor = AuthDescriptor.of(AuthRequirement("bearer"), AuthRequirement("apikey")) + resolved = resolve_auth(per_call=descriptor, operation=None, client=None, registry=registry) + assert resolved is not None + assert resolved.requirement.scheme == "apikey" + + +# --------------------------------------------------------------------------- # +# THE load-bearing rule — a present-but-unsatisfiable tier never falls through # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("AUTH-4") +def test_unsatisfiable_per_call_does_not_fall_through_to_client() -> None: + # per-call names a scheme the registry cannot satisfy; the client tier IS + # satisfiable. Resolution MUST fail against the selected (per-call) tier and + # never silently authenticate with the client default. + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + with pytest.raises(AuthResolutionError) as excinfo: + resolve_auth( + per_call=_desc("bearer"), + operation=None, + client=_desc("apikey"), + registry=registry, + ) + assert excinfo.value.tier is AuthTier.PER_CALL + + +@pytest.mark.req("AUTH-4") +def test_unsatisfiable_operation_does_not_fall_through_to_client() -> None: + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + with pytest.raises(AuthResolutionError) as excinfo: + resolve_auth( + per_call=None, + operation=_desc("bearer"), + client=_desc("apikey"), + registry=registry, + ) + assert excinfo.value.tier is AuthTier.OPERATION + + +def test_unsatisfiable_scope_does_not_fall_through() -> None: + # Same scheme name, but the requirement demands a scope the scheme cannot + # grant — still unsatisfiable, still no fall-through. + registry = AuthRegistry( + schemes=(_scheme("oauth2", scopes=frozenset({"read"})), _scheme("apikey")) + ) + with pytest.raises(AuthResolutionError): + resolve_auth( + per_call=_desc("oauth2", "admin"), + operation=None, + client=_desc("apikey"), + registry=registry, + ) + + +# --------------------------------------------------------------------------- # +# executor integration — sync # +# --------------------------------------------------------------------------- # + + +def test_executor_parks_resolved_per_call_policy_sync() -> None: + probe = OptionsProbe() + registry = AuthRegistry(schemes=(_scheme("bearer"), _scheme("apikey"))) + transport = ScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = ServiceCore( + base_url=_BASE, + pipeline=Pipeline(transport, policies=[probe]), + auth=_desc("apikey"), + auth_registry=registry, + ) + core.execute( + _get_pet(), OperationInput(path_params={"id": "1"}), response_type=Pet, auth=_desc("bearer") + ) + parked = probe.seen[0][AUTH_OPTION_KEY] + assert isinstance(parked, _StampPolicy) + assert parked.label == "bearer" # per-call tier resolved, not the client default + + +def test_executor_parks_resolved_operation_policy_sync() -> None: + probe = OptionsProbe() + registry = AuthRegistry(schemes=(_scheme("bearer"), _scheme("apikey"))) + transport = ScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = ServiceCore( + base_url=_BASE, + pipeline=Pipeline(transport, policies=[probe]), + auth=_desc("apikey"), + auth_registry=registry, + ) + core.execute( + _get_pet(auth=_desc("bearer")), OperationInput(path_params={"id": "1"}), response_type=Pet + ) + parked = probe.seen[0][AUTH_OPTION_KEY] + assert isinstance(parked, _StampPolicy) + assert parked.label == "bearer" # operation tier used (no per-call override) + + +def test_executor_parks_resolved_client_policy_sync() -> None: + probe = OptionsProbe() + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + transport = ScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = ServiceCore( + base_url=_BASE, + pipeline=Pipeline(transport, policies=[probe]), + auth=_desc("apikey"), + auth_registry=registry, + ) + core.execute(_get_pet(), OperationInput(path_params={"id": "1"}), response_type=Pet) + parked = probe.seen[0][AUTH_OPTION_KEY] + assert isinstance(parked, _StampPolicy) + assert parked.label == "apikey" # client default used (no more-specific tier) + + +def test_executor_byo_policy_override_parked_verbatim_sync() -> None: + probe = OptionsProbe() + override = _StampPolicy("byo") + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + transport = ScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = ServiceCore( + base_url=_BASE, + pipeline=Pipeline(transport, policies=[probe]), + auth=_desc("apikey"), + auth_registry=registry, + ) + # A concrete Policy passed per-call is the most-specific tier and is parked + # verbatim without going through descriptor resolution. + core.execute( + _get_pet(), OperationInput(path_params={"id": "1"}), response_type=Pet, auth=override + ) + assert probe.seen[0][AUTH_OPTION_KEY] is override + + +def test_executor_unsatisfiable_per_call_raises_before_send_sync() -> None: + # The client default IS satisfiable, but the per-call override is not: the + # executor must raise and never dispatch a request stamped with the default. + registry = AuthRegistry(schemes=(_scheme("apikey"),)) + transport = ScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = ServiceCore( + base_url=_BASE, + pipeline=Pipeline(transport), + auth=_desc("apikey"), + auth_registry=registry, + ) + with pytest.raises(AuthResolutionError) as excinfo: + core.execute( + _get_pet(), + OperationInput(path_params={"id": "1"}), + response_type=Pet, + auth=_desc("bearer"), + ) + assert excinfo.value.tier is AuthTier.PER_CALL + assert transport.requests == [] # nothing was ever sent + + +# --------------------------------------------------------------------------- # +# executor integration — async # +# --------------------------------------------------------------------------- # + + +async def test_executor_parks_resolved_per_call_policy_async() -> None: + probe = AsyncOptionsProbe() + registry = AuthRegistry(schemes=(_async_scheme("bearer"), _async_scheme("apikey"))) + transport = AsyncScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = AsyncServiceCore( + base_url=_BASE, + pipeline=AsyncPipeline(transport, policies=[probe]), + auth=_desc("apikey"), + auth_registry=registry, + ) + await core.execute( + _get_pet(), OperationInput(path_params={"id": "1"}), response_type=Pet, auth=_desc("bearer") + ) + parked = probe.seen[0][AUTH_OPTION_KEY] + assert isinstance(parked, _AsyncStampPolicy) + assert parked.label == "bearer" + + +async def test_executor_unsatisfiable_per_call_raises_before_send_async() -> None: + registry = AuthRegistry(schemes=(_async_scheme("apikey"),)) + transport = AsyncScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = AsyncServiceCore( + base_url=_BASE, + pipeline=AsyncPipeline(transport), + auth=_desc("apikey"), + auth_registry=registry, + ) + with pytest.raises(AuthResolutionError) as excinfo: + await core.execute( + _get_pet(), + OperationInput(path_params={"id": "1"}), + response_type=Pet, + auth=_desc("bearer"), + ) + assert excinfo.value.tier is AuthTier.PER_CALL + assert transport.requests == [] diff --git a/packages/dexpace-sdk-core/tests/codegen/test_merge_config.py b/packages/dexpace-sdk-core/tests/codegen/test_merge_config.py new file mode 100644 index 0000000..806eb35 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_merge_config.py @@ -0,0 +1,302 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Integration tests for `merge_config` / `merge_async_config`. + +Covers the Tier-2 acceptance bar for PY-M9-04: exactly one pipeline is built +(never two nested), a user override wins field-wise over the service +defaults, the service's User-Agent token prepends to the platform/runtime +tokens via the existing `ClientIdentityPolicy` mechanism, an api-version +header survives a forced redirect + retry walk (re-stamped per hop/attempt), +and a BYO transport/pipeline is never closed by the executor built around it. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from dexpace.sdk.core.codegen import ( + AsyncServiceCore, + ServiceCore, + ServiceDefaults, + merge_async_config, + merge_config, +) +from dexpace.sdk.core.codegen.status_error_map import StatusErrorMap +from dexpace.sdk.core.http.common import Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Status +from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline +from dexpace.sdk.core.pipeline.policies.async_redirect import AsyncRedirectPolicy +from dexpace.sdk.core.pipeline.policies.async_retry import AsyncRetryPolicy +from dexpace.sdk.core.pipeline.policies.retry import RetryPolicy + +from ..conftest import FakeClock +from .support import AsyncScriptTransport, Reply, ScriptTransport + +_BASE = "https://api.example.com" + + +class _AsyncFakeClock: + """Deterministic async clock; ``sleep`` advances simulated time, not real time.""" + + __slots__ = ("_t",) + + def __init__(self, start: float = 0.0) -> None: + self._t = start + + def now(self) -> float: + return self._t + + def monotonic(self) -> float: + return self._t + + async def sleep(self, duration: float) -> None: + self._t += max(0.0, duration) + + +def _get(url: str = _BASE) -> Request: + return Request(method=Method.GET, url=Url.parse(url)) + + +# --------------------------------------------------------------------------- # +# flatten: exactly one pipeline built, user config wins field-wise # +# --------------------------------------------------------------------------- # + + +def test_merge_config_builds_exactly_one_pipeline(monkeypatch: pytest.MonkeyPatch) -> None: + import dexpace.sdk.core.pipeline.pipeline as pipeline_module + + calls: list[object] = [] + original_init = pipeline_module.Pipeline.__init__ + + def _spy(self: pipeline_module.Pipeline, *a: Any, **kw: Any) -> None: + calls.append(self) + original_init(self, *a, **kw) + + monkeypatch.setattr(pipeline_module.Pipeline, "__init__", _spy) + + transport = ScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults(base_url=_BASE, user_agent_token="svc/1.0") + result = merge_config(defaults, transport=transport) + assert len(calls) == 1 # merge_config's own default_pipeline(...).build() call + + with ServiceCore(**result): + pass + assert len(calls) == 1 # ServiceCore(pipeline=...) borrows it; no second pipeline built + + +async def test_merge_async_config_builds_exactly_one_pipeline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import dexpace.sdk.core.pipeline.async_pipeline as async_pipeline_module + + calls: list[object] = [] + original_init = async_pipeline_module.AsyncPipeline.__init__ + + def _spy(self: async_pipeline_module.AsyncPipeline, *a: Any, **kw: Any) -> None: + calls.append(self) + original_init(self, *a, **kw) + + monkeypatch.setattr(async_pipeline_module.AsyncPipeline, "__init__", _spy) + + transport = AsyncScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults(base_url=_BASE, user_agent_token="svc/1.0") + result = merge_async_config(defaults, transport=transport) + assert len(calls) == 1 + + async with AsyncServiceCore(**result): + pass + assert len(calls) == 1 + + +def test_merge_config_user_config_wins_field_wise() -> None: + svc_errors = StatusErrorMap() + user_errors = StatusErrorMap() + defaults = ServiceDefaults( + base_url="https://service-default.example.com", + user_agent_token="svc/1.0", + errors=svc_errors, + ) + user = ServiceDefaults(base_url="https://user-override.example.com", errors=user_errors) + transport = ScriptTransport([Reply(Status.OK)]) + result = merge_config(defaults, user, transport=transport) + assert result["base_url"] == "https://user-override.example.com" # user wins + assert result["errors"] is user_errors # user wins + assert isinstance(result["pipeline"], Pipeline) + + +def test_merge_config_requires_exactly_one_of_transport_or_pipeline() -> None: + defaults = ServiceDefaults(base_url=_BASE) + with pytest.raises(ValueError, match="exactly one"): + merge_config(defaults) + with pytest.raises(ValueError, match="exactly one"): + merge_config( + defaults, + transport=ScriptTransport([Reply(Status.OK)]), + pipeline=Pipeline(ScriptTransport([Reply(Status.OK)])), + ) + + +def test_merge_config_requires_a_resolved_base_url() -> None: + defaults = ServiceDefaults() + with pytest.raises(ValueError, match="base_url"): + merge_config(defaults, transport=ScriptTransport([Reply(Status.OK)])) + + +# --------------------------------------------------------------------------- # +# UA token ordering: service token prepends to platform/runtime # +# --------------------------------------------------------------------------- # + + +def test_service_user_agent_token_prepends_to_platform_and_runtime() -> None: + transport = ScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults(base_url=_BASE, user_agent_token="acme-service/2.3") + result = merge_config(defaults, transport=transport) + with ServiceCore(**result) as core: + core.execute_request(_get()).close() + ua = transport.requests[0].headers.get("User-Agent") + assert ua is not None + service, platform, runtime = ua.split(" ") + assert service == "acme-service/2.3" + assert platform.startswith("dexpace-sdk/") + assert runtime.startswith("python/") + + +def test_user_override_user_agent_token_wins_over_service_default() -> None: + transport = ScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults(base_url=_BASE, user_agent_token="svc-default/1.0") + user = ServiceDefaults(user_agent_token="caller-override/9.9") + result = merge_config(defaults, user, transport=transport) + with ServiceCore(**result) as core: + core.execute_request(_get()).close() + ua = transport.requests[0].headers.get("User-Agent") + assert ua is not None + assert ua.startswith("caller-override/9.9 ") + + +async def test_async_service_user_agent_token_prepends_to_platform_and_runtime() -> None: + transport = AsyncScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults(base_url=_BASE, user_agent_token="acme-async-service/4.5") + result = merge_async_config(defaults, transport=transport) + async with AsyncServiceCore(**result) as core: + response = await core.execute_request(_get()) + await response.close() + ua = transport.requests[0].headers.get("User-Agent") + assert ua is not None + service, platform, runtime = ua.split(" ") + assert service == "acme-async-service/4.5" + assert platform.startswith("dexpace-sdk/") + assert runtime.startswith("python/") + + +# --------------------------------------------------------------------------- # +# api-version header survives a forced redirect + retry walk # +# --------------------------------------------------------------------------- # + + +def test_api_version_header_restamped_on_every_hop_and_attempt() -> None: + # Force a retry (503) then a redirect (301) then success, mirroring the + # pattern in tests/pipeline/test_defaults.py. The api-version header must + # land on every one of the three actual sends, not just the first. + transport = ScriptTransport( + [ + Reply(Status.SERVICE_UNAVAILABLE), + Reply(Status.MOVED_PERMANENTLY, headers=(("Location", f"{_BASE}/new"),)), + Reply(Status.OK), + ] + ) + defaults = ServiceDefaults(base_url=_BASE, api_version="2026-01-01") + result = merge_config( + defaults, + transport=transport, + retry=RetryPolicy(backoff_factor=0.0, clock=FakeClock()), + ) + with ServiceCore(**result) as core: + core.execute_request(_get()).close() + assert len(transport.requests) == 3 # the retry + redirect walk actually ran + assert all(req.headers.get("Api-Version") == "2026-01-01" for req in transport.requests) + + +def test_api_version_header_uses_configured_header_name() -> None: + transport = ScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults( + base_url=_BASE, api_version="v2", api_version_header="X-Service-Api-Version" + ) + result = merge_config(defaults, transport=transport) + with ServiceCore(**result) as core: + core.execute_request(_get()).close() + assert transport.requests[0].headers.get("X-Service-Api-Version") == "v2" + assert transport.requests[0].headers.get("Api-Version") is None + + +async def test_async_api_version_header_restamped_on_every_hop_and_attempt() -> None: + transport = AsyncScriptTransport( + [ + Reply(Status.SERVICE_UNAVAILABLE), + Reply(Status.MOVED_PERMANENTLY, headers=(("Location", f"{_BASE}/new"),)), + Reply(Status.OK), + ] + ) + defaults = ServiceDefaults(base_url=_BASE, api_version="2026-01-01") + result = merge_async_config( + defaults, + transport=transport, + redirect=AsyncRedirectPolicy(), + retry=AsyncRetryPolicy(backoff_factor=0.0, clock=_AsyncFakeClock()), + ) + async with AsyncServiceCore(**result) as core: + response = await core.execute_request(_get()) + await response.close() + assert len(transport.requests) == 3 + assert all(req.headers.get("Api-Version") == "2026-01-01" for req in transport.requests) + + +# --------------------------------------------------------------------------- # +# BYO-nesting close matrix # +# --------------------------------------------------------------------------- # + + +def test_byo_transport_survives_service_executor_close() -> None: + # merge_config always folds a fresh transport into a customized pipeline + # handed to ServiceCore as `pipeline=` — the ownership-aware close means + # the transport backing that customization is never auto-closed. + transport = ScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults(base_url=_BASE, user_agent_token="svc/1.0", api_version="v1") + result = merge_config(defaults, transport=transport) + with ServiceCore(**result): + pass + assert transport.close_calls == 0 + + +async def test_byo_transport_survives_async_service_executor_close() -> None: + transport = AsyncScriptTransport([Reply(Status.OK)]) + defaults = ServiceDefaults(base_url=_BASE, user_agent_token="svc/1.0", api_version="v1") + result = merge_async_config(defaults, transport=transport) + async with AsyncServiceCore(**result): + pass + assert transport.aclose_calls == 0 + + +def test_byo_already_built_pipeline_passes_through_unmodified() -> None: + transport = ScriptTransport([Reply(Status.OK)]) + byo_pipeline = Pipeline(transport) + defaults = ServiceDefaults(base_url=_BASE, api_version="v1") + result = merge_config(defaults, pipeline=byo_pipeline) + assert result["pipeline"] is byo_pipeline + with ServiceCore(**result): + pass + assert transport.close_calls == 0 + + +async def test_byo_already_built_async_pipeline_passes_through_unmodified() -> None: + transport = AsyncScriptTransport([Reply(Status.OK)]) + byo_pipeline = AsyncPipeline(transport) + defaults = ServiceDefaults(base_url=_BASE, api_version="v1") + result = merge_async_config(defaults, pipeline=byo_pipeline) + assert result["pipeline"] is byo_pipeline + async with AsyncServiceCore(**result): + pass + assert transport.aclose_calls == 0 diff --git a/packages/dexpace-sdk-core/tests/codegen/test_operation.py b/packages/dexpace-sdk-core/tests/codegen/test_operation.py new file mode 100644 index 0000000..beeec40 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_operation.py @@ -0,0 +1,79 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the declarative ``Operation`` / ``OperationInput`` value objects.""" + +from __future__ import annotations + +import dataclasses +from dataclasses import FrozenInstanceError + +import pytest + +from dexpace.sdk.core.codegen import Operation, OperationInput +from dexpace.sdk.core.http.common import Headers, QueryParams +from dexpace.sdk.core.http.request import Method, RequestBody + + +def test_operation_fields_are_keyword_only() -> None: + # kw_only lets a future minor version append fields without breaking any + # positional call site — assert every field is genuinely keyword-only. + assert all(f.kw_only for f in dataclasses.fields(Operation)) + + +def test_operation_input_fields_are_keyword_only() -> None: + assert all(f.kw_only for f in dataclasses.fields(OperationInput)) + + +def test_operation_is_frozen() -> None: + op = Operation(name="getUser", method=Method.GET, path="/users/{id}") + with pytest.raises(FrozenInstanceError): + op.name = "other" # type: ignore[misc] + + +def test_operation_input_is_frozen() -> None: + inp = OperationInput() + with pytest.raises(FrozenInstanceError): + inp.body = None # type: ignore[misc] + + +@pytest.mark.req("SEAM-26") +def test_operation_input_defaults_are_empty() -> None: + inp = OperationInput() + assert dict(inp.path_params) == {} + assert inp.query == QueryParams() + assert inp.headers == Headers() + assert inp.body is None + + +@pytest.mark.req("SEAM-28") +def test_name_excluded_from_equality() -> None: + # ``name`` is purely advisory: two operations that differ only in name are + # equal, so nothing keyed on an Operation can ever be split apart by name. + a = Operation(name="getUser", method=Method.GET, path="/users/{id}") + b = Operation(name="retrieveUser", method=Method.GET, path="/users/{id}") + assert a == b + + +def test_name_excluded_from_hash() -> None: + # Equal hashes guarantee that using an Operation as a dict / store key never + # partitions two otherwise-identical operations by their advisory name. + a = Operation(name="getUser", method=Method.GET, path="/users/{id}") + b = Operation(name="retrieveUser", method=Method.GET, path="/users/{id}") + assert hash(a) == hash(b) + + +def test_operation_carries_optional_static_headers() -> None: + op = Operation( + name="getUser", + method=Method.GET, + path="/users/{id}", + headers=Headers({"accept": "application/json"}), + ) + assert op.headers.get("accept") == "application/json" + + +def test_operation_input_carries_body_through() -> None: + body = RequestBody.from_string("payload") + inp = OperationInput(body=body) + assert inp.body is body diff --git a/packages/dexpace-sdk-core/tests/codegen/test_pure_planners.py b/packages/dexpace-sdk-core/tests/codegen/test_pure_planners.py new file mode 100644 index 0000000..eda7bd4 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_pure_planners.py @@ -0,0 +1,91 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Unit tests for the shared pure planners under ``codegen/_pure``. + +These are the awaiting-free functions both executor shells import, so testing +them once certifies the logic for both the sync and async twins. +""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.codegen import Operation, OperationInput +from dexpace.sdk.core.codegen._pure.options import AUTH_OPTION_KEY, plan_options +from dexpace.sdk.core.codegen._pure.requests import plan_request +from dexpace.sdk.core.codegen._pure.results import plan_result +from dexpace.sdk.core.http.request import Method +from dexpace.sdk.core.http.response import Response + +from .support import Pet + +# --------------------------------------------------------------------------- # +# plan_request # +# --------------------------------------------------------------------------- # + + +def test_plan_request_binds_operation_and_input() -> None: + op = Operation(name="getPet", method=Method.GET, path="/pets/{id}") + req = plan_request( + op, OperationInput(path_params={"id": "7"}), "https://api.example.com/v1", None + ) + assert req.method is Method.GET + assert str(req.url) == "https://api.example.com/v1/pets/7" + + +def test_plan_request_reports_missing_path_param() -> None: + op = Operation(name="getPet", method=Method.GET, path="/pets/{id}") + with pytest.raises(KeyError): + plan_request(op, OperationInput(), "https://api.example.com", None) + + +# --------------------------------------------------------------------------- # +# plan_options # +# --------------------------------------------------------------------------- # + + +def test_plan_options_merges_call_options() -> None: + opts = plan_options(None, {"marker": "abc", "retry_total": 5}) + assert opts["marker"] == "abc" + assert opts["retry_total"] == 5 + assert AUTH_OPTION_KEY not in opts + + +def test_plan_options_parks_auth_under_reserved_key() -> None: + sentinel = object() + opts = plan_options(sentinel, {}) + assert opts[AUTH_OPTION_KEY] is sentinel + + +def test_plan_options_auth_wins_over_same_named_call_option() -> None: + sentinel = object() + opts = plan_options(sentinel, {AUTH_OPTION_KEY: "shadowed"}) + assert opts[AUTH_OPTION_KEY] is sentinel + + +def test_plan_options_returns_a_read_only_mapping() -> None: + opts = plan_options(None, {"a": 1}) + with pytest.raises(TypeError): + opts["b"] = 2 # type: ignore[index] + + +# --------------------------------------------------------------------------- # +# plan_result # +# --------------------------------------------------------------------------- # + + +def test_plan_result_returns_none_for_a_raw_call() -> None: + assert plan_result(None, "default", None, Response) is None + + +def test_plan_result_uses_default_handler_and_response_type() -> None: + assert plan_result(None, "default", Pet, Response) == ("default", Pet) + + +def test_plan_result_custom_handler_wins_over_default() -> None: + assert plan_result("custom", "default", Pet, Response) == ("custom", Pet) + + +def test_plan_result_custom_handler_with_no_type_uses_default_witness() -> None: + assert plan_result("custom", "default", None, Response) == ("custom", Response) diff --git a/packages/dexpace-sdk-core/tests/codegen/test_service_core.py b/packages/dexpace-sdk-core/tests/codegen/test_service_core.py new file mode 100644 index 0000000..2c98f6e --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_service_core.py @@ -0,0 +1,256 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Behavioral-parity suite for `ServiceCore` and `AsyncServiceCore`. + +Each scenario — execute a simple op, execute a raw request, paginate, stream +events, and thread per-call options — is run identically against both executors +and asserts equivalent behavior. The sync and async twins share every +non-awaiting decision through the pure planners, so a divergence here means a +shell drifted. +""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.codegen import AsyncServiceCore, Operation, OperationInput, ServiceCore +from dexpace.sdk.core.codegen._pure.options import AUTH_OPTION_KEY +from dexpace.sdk.core.http.common import Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, Response, Status +from dexpace.sdk.core.http.sse.typed import SseSignal +from dexpace.sdk.core.pagination import CursorStrategy +from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline +from dexpace.sdk.core.pipeline.async_policy import AsyncPolicy +from dexpace.sdk.core.pipeline.context import PipelineContext +from dexpace.sdk.core.pipeline.policy import Policy +from dexpace.sdk.core.pipeline.stage import Stage + +from .support import ( + AsyncOptionsProbe, + AsyncScriptTransport, + OptionsProbe, + Pet, + Reply, + ScriptTransport, +) + +_BASE = "https://api.example.com" + + +def _get_pet() -> Operation: + return Operation(name="getPet", method=Method.GET, path="/pets/{id}") + + +def _list_pets() -> Operation: + return Operation(name="listPets", method=Method.GET, path="/pets") + + +def _stream() -> Operation: + return Operation(name="streamPets", method=Method.GET, path="/pets/events") + + +class _PassThroughAuth(Policy): + """A stand-in auth policy passed as the per-call ``auth`` override. + + It is only parked in the options mapping, never wired into a pipeline, so + its ``send`` is never called. + """ + + STAGE = Stage.AUTH + __slots__ = () + + def send(self, request: Request, ctx: PipelineContext) -> Response: # pragma: no cover + raise NotImplementedError + + +class _AsyncPassThroughAuth(AsyncPolicy): + """Async twin of `_PassThroughAuth`.""" + + STAGE = Stage.AUTH + __slots__ = () + + async def send( + self, request: Request, ctx: PipelineContext + ) -> AsyncResponse: # pragma: no cover + raise NotImplementedError + + +def _mapper(event: str, data: str) -> str | SseSignal: + """Map an SSE event to its data, stopping on a ``[DONE]`` sentinel.""" + if data == "[DONE]": + return SseSignal.DONE + return data + + +_SSE_BODY = b"data: one\n\ndata: two\n\ndata: [DONE]\n\n" + + +# --------------------------------------------------------------------------- # +# execute a simple op — decode into a response_type (through the default stack) # +# --------------------------------------------------------------------------- # + + +def test_execute_decodes_response_type_sync() -> None: + transport = ScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + with ServiceCore(base_url=_BASE, transport=transport) as core: + pet = core.execute(_get_pet(), OperationInput(path_params={"id": "7"}), response_type=Pet) + assert pet == Pet("Rex") + assert str(transport.requests[0].url) == f"{_BASE}/pets/7" + assert transport.close_calls == 1 # owned transport closed on context exit + + +async def test_execute_decodes_response_type_async() -> None: + transport = AsyncScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + async with AsyncServiceCore(base_url=_BASE, transport=transport) as core: + pet = await core.execute( + _get_pet(), OperationInput(path_params={"id": "7"}), response_type=Pet + ) + assert pet == Pet("Rex") + assert str(transport.requests[0].url) == f"{_BASE}/pets/7" + assert transport.aclose_calls == 1 + + +# --------------------------------------------------------------------------- # +# execute an error op — the StatusErrorMap-mapped error is raised # +# --------------------------------------------------------------------------- # + + +def test_execute_raises_mapped_error_sync() -> None: + from dexpace.sdk.core.errors import HttpResponseError + + transport = ScriptTransport([Reply(Status.NOT_FOUND, b'{"message": "nope"}')]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport)) + with pytest.raises(HttpResponseError): + core.execute(_get_pet(), OperationInput(path_params={"id": "7"}), response_type=Pet) + + +async def test_execute_raises_mapped_error_async() -> None: + from dexpace.sdk.core.errors import HttpResponseError + + transport = AsyncScriptTransport([Reply(Status.NOT_FOUND, b'{"message": "nope"}')]) + core = AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) + with pytest.raises(HttpResponseError): + await core.execute(_get_pet(), OperationInput(path_params={"id": "7"}), response_type=Pet) + + +# --------------------------------------------------------------------------- # +# execute_request — a raw call returns the live response for the caller # +# --------------------------------------------------------------------------- # + + +def test_execute_request_returns_raw_response_sync() -> None: + transport = ScriptTransport([Reply(Status.OK, b"hello", headers=(("x-trace", "abc"),))]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport)) + request = Request(method=Method.GET, url=Url.parse(f"{_BASE}/raw")) + with core.execute_request(request) as response: + assert response.status is Status.OK + assert response.headers.get("x-trace") == "abc" + assert response.body is not None + assert response.body.string() == "hello" + + +async def test_execute_request_returns_raw_response_async() -> None: + transport = AsyncScriptTransport([Reply(Status.OK, b"hello", headers=(("x-trace", "abc"),))]) + core = AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) + request = Request(method=Method.GET, url=Url.parse(f"{_BASE}/raw")) + response = await core.execute_request(request) + assert isinstance(response, AsyncResponse) + async with response: + assert response.status is Status.OK + assert response.headers.get("x-trace") == "abc" + assert response.body is not None + assert await response.body.string() == "hello" + + +# --------------------------------------------------------------------------- # +# paginate — walk every page's items in order # +# --------------------------------------------------------------------------- # + +_PAGE_1 = b'{"data": [{"name": "a"}, {"name": "b"}], "next_cursor": "c2"}' +_PAGE_2 = b'{"data": [{"name": "c"}], "next_cursor": null}' + + +def _cursor_strategy() -> CursorStrategy[dict[str, str]]: + return CursorStrategy( + items_field="data", cursor_response_field="next_cursor", cursor_param="cursor" + ) + + +def test_paginate_walks_all_pages_sync() -> None: + transport = ScriptTransport([Reply(Status.OK, _PAGE_1), Reply(Status.OK, _PAGE_2)]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport)) + items = list(core.paginate(_list_pets(), OperationInput(), _cursor_strategy())) + assert [item["name"] for item in items] == ["a", "b", "c"] + assert len(transport.requests) == 2 # two pages, one exchange each + + +async def test_paginate_walks_all_pages_async() -> None: + transport = AsyncScriptTransport([Reply(Status.OK, _PAGE_1), Reply(Status.OK, _PAGE_2)]) + core = AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) + items = [ + item async for item in core.paginate(_list_pets(), OperationInput(), _cursor_strategy()) + ] + assert [item["name"] for item in items] == ["a", "b", "c"] + assert len(transport.requests) == 2 + + +# --------------------------------------------------------------------------- # +# events — stream mapped values, stopping on the mapper's DONE signal # +# --------------------------------------------------------------------------- # + + +def test_events_streams_mapped_values_sync() -> None: + transport = ScriptTransport([Reply(Status.OK, _SSE_BODY)]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport)) + with core.events(_stream(), OperationInput(), _mapper) as stream: + values = list(stream) + assert values == ["one", "two"] + + +async def test_events_streams_mapped_values_async() -> None: + transport = AsyncScriptTransport([Reply(Status.OK, _SSE_BODY)]) + core = AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) + async with core.events(_stream(), OperationInput(), _mapper) as stream: + values = [value async for value in stream] + assert values == ["one", "two"] + + +# --------------------------------------------------------------------------- # +# per-call options + auth threading # +# --------------------------------------------------------------------------- # + + +def test_execute_threads_options_and_parks_auth_sync() -> None: + probe = OptionsProbe() + auth = _PassThroughAuth() + transport = ScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = ServiceCore(base_url=_BASE, pipeline=Pipeline(transport, policies=[probe])) + core.execute( + _get_pet(), + OperationInput(path_params={"id": "1"}), + response_type=Pet, + auth=auth, + marker="xyz", + ) + assert len(probe.seen) == 1 + assert probe.seen[0]["marker"] == "xyz" + assert probe.seen[0][AUTH_OPTION_KEY] is auth + + +async def test_execute_threads_options_and_parks_auth_async() -> None: + probe = AsyncOptionsProbe() + auth = _AsyncPassThroughAuth() + transport = AsyncScriptTransport([Reply(Status.OK, b'{"name": "Rex"}')]) + core = AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport, policies=[probe])) + await core.execute( + _get_pet(), + OperationInput(path_params={"id": "1"}), + response_type=Pet, + auth=auth, + marker="xyz", + ) + assert len(probe.seen) == 1 + assert probe.seen[0]["marker"] == "xyz" + assert probe.seen[0][AUTH_OPTION_KEY] is auth diff --git a/packages/dexpace-sdk-core/tests/codegen/test_service_core_close.py b/packages/dexpace-sdk-core/tests/codegen/test_service_core_close.py new file mode 100644 index 0000000..444a2a2 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_service_core_close.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Close-ownership matrix for `ServiceCore` and `AsyncServiceCore`. + +The executors are ownership-aware context managers: closing one closes a +transport it constructed a pipeline around, but never a caller-supplied (BYO) +pipeline, and a double close is a no-op. Both rows of the matrix are asserted +for both executors. +""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.codegen import AsyncServiceCore, ServiceCore +from dexpace.sdk.core.http.response import Status +from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline + +from .support import AsyncScriptTransport, Reply, ScriptTransport + +_BASE = "https://api.example.com" + + +def _reply() -> list[Reply]: + return [Reply(Status.OK)] + + +# --------------------------------------------------------------------------- # +# BYO pipeline: a caller-supplied resource survives the executor's close # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("XCUT-22") +def test_byo_pipeline_survives_close_sync() -> None: + transport = ScriptTransport(_reply()) + with ServiceCore(base_url=_BASE, pipeline=Pipeline(transport)) as core: + assert core is not None + assert transport.close_calls == 0 # the BYO pipeline (and its transport) is untouched + + +@pytest.mark.req("XCUT-22") +async def test_byo_pipeline_survives_close_async() -> None: + transport = AsyncScriptTransport(_reply()) + async with AsyncServiceCore(base_url=_BASE, pipeline=AsyncPipeline(transport)) as core: + assert core is not None + assert transport.aclose_calls == 0 + + +# --------------------------------------------------------------------------- # +# Constructed transport: an executor-built pipeline's transport is closed # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("XCUT-22") +def test_constructed_transport_closed_on_exit_sync() -> None: + transport = ScriptTransport(_reply()) + with ServiceCore(base_url=_BASE, transport=transport): + pass + assert transport.close_calls == 1 + + +async def test_constructed_transport_closed_on_exit_async() -> None: + transport = AsyncScriptTransport(_reply()) + async with AsyncServiceCore(base_url=_BASE, transport=transport): + pass + assert transport.aclose_calls == 1 + + +# --------------------------------------------------------------------------- # +# Double close is a no-op # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("XCUT-13") +def test_double_close_is_a_noop_sync() -> None: + transport = ScriptTransport(_reply()) + core = ServiceCore(base_url=_BASE, transport=transport) + core.close() + core.close() + assert transport.close_calls == 1 + + +@pytest.mark.req("XCUT-13") +async def test_double_close_is_a_noop_async() -> None: + transport = AsyncScriptTransport(_reply()) + core = AsyncServiceCore(base_url=_BASE, transport=transport) + await core.aclose() + await core.aclose() + assert transport.aclose_calls == 1 + + +# --------------------------------------------------------------------------- # +# Construction requires exactly one of pipeline / transport # +# --------------------------------------------------------------------------- # + + +def test_requires_a_source_sync() -> None: + with pytest.raises(ValueError, match="exactly one"): + ServiceCore(base_url=_BASE) + + +def test_rejects_both_sources_sync() -> None: + transport = ScriptTransport(_reply()) + with pytest.raises(ValueError, match="exactly one"): + ServiceCore( + base_url=_BASE, transport=transport, pipeline=Pipeline(ScriptTransport(_reply())) + ) + + +def test_requires_a_source_async() -> None: + with pytest.raises(ValueError, match="exactly one"): + AsyncServiceCore(base_url=_BASE) + + +def test_rejects_both_sources_async() -> None: + transport = AsyncScriptTransport(_reply()) + with pytest.raises(ValueError, match="exactly one"): + AsyncServiceCore( + base_url=_BASE, + transport=transport, + pipeline=AsyncPipeline(AsyncScriptTransport(_reply())), + ) diff --git a/packages/dexpace-sdk-core/tests/codegen/test_service_defaults.py b/packages/dexpace-sdk-core/tests/codegen/test_service_defaults.py new file mode 100644 index 0000000..aa2abca --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_service_defaults.py @@ -0,0 +1,70 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for `ServiceDefaults` and its field-wise merge helper. + +Pure, no pipeline construction involved — the integration-level "exactly one +pipeline" / UA-ordering / api-version / close-matrix scenarios live in +`test_merge_config.py`. +""" + +from __future__ import annotations + +from dexpace.sdk.core.codegen.service_defaults import ServiceDefaults, merge_service_defaults +from dexpace.sdk.core.codegen.status_error_map import StatusErrorMap + + +def test_bare_service_defaults_has_every_field_none() -> None: + defaults = ServiceDefaults() + assert defaults.base_url is None + assert defaults.user_agent_token is None + assert defaults.api_version is None + assert defaults.api_version_header is None + assert defaults.errors is None + assert defaults.serde is None + + +def test_merge_with_no_user_override_returns_defaults_unchanged() -> None: + defaults = ServiceDefaults(base_url="https://svc.example.com", user_agent_token="svc/1.0") + assert merge_service_defaults(defaults, None) is defaults + + +def test_merge_is_field_wise_not_object_wise() -> None: + # The user overrides only base_url; every other field must still resolve + # from the service defaults rather than falling all the way through to + # "unset" just because *some* field on the override was set. + defaults = ServiceDefaults( + base_url="https://svc.example.com", + user_agent_token="svc/1.0", + api_version="2026-01-01", + ) + user = ServiceDefaults(base_url="https://override.example.com") + merged = merge_service_defaults(defaults, user) + assert merged.base_url == "https://override.example.com" + assert merged.user_agent_token == "svc/1.0" + assert merged.api_version == "2026-01-01" + + +def test_merge_user_wins_on_every_field_they_set() -> None: + service_errors = StatusErrorMap() + user_errors = StatusErrorMap() + defaults = ServiceDefaults( + base_url="https://svc.example.com", + user_agent_token="svc/1.0", + api_version="2026-01-01", + api_version_header="Svc-Api-Version", + errors=service_errors, + ) + user = ServiceDefaults( + base_url="https://override.example.com", + user_agent_token="caller/9.9", + api_version="2099-12-31", + api_version_header="X-Api-Version", + errors=user_errors, + ) + merged = merge_service_defaults(defaults, user) + assert merged.base_url == "https://override.example.com" + assert merged.user_agent_token == "caller/9.9" + assert merged.api_version == "2099-12-31" + assert merged.api_version_header == "X-Api-Version" + assert merged.errors is user_errors diff --git a/packages/dexpace-sdk-core/tests/codegen/test_status_error_map.py b/packages/dexpace-sdk-core/tests/codegen/test_status_error_map.py new file mode 100644 index 0000000..875c710 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/codegen/test_status_error_map.py @@ -0,0 +1,258 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for ``StatusErrorMap`` — data-driven status → typed-error mapping. + +Covers the four behaviours the codegen error contract guarantees: + +- ``raise_for`` rejects a non-error (2xx / 3xx / 1xx) response instead of + fabricating a success-looking exception. +- a per-status override wins over the fallback ``default`` class. +- a constructed error carries a bounded, replayable copy of the response body. +- the map stays on the ``HttpResponseError`` branch only, so a downstream + service taxonomy can never catch a ``NetworkError`` and re-wrap it off the + ``OSError`` branch. +""" + +from __future__ import annotations + +import io +from dataclasses import FrozenInstanceError + +import pytest + +from dexpace.sdk.core.codegen import StatusErrorMap +from dexpace.sdk.core.errors import ( + ERROR_BODY_MAX_BYTES, + HttpResponseError, + NetworkError, + ResourceNotFoundError, +) +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Response, ResponseBody, Status + + +def _response(status: Status, *, body: ResponseBody | None = None) -> Response: + request = Request(method=Method.GET, url=Url.parse("https://example.com/")) + return Response(request=request, protocol=Protocol.HTTP_1_1, status=status, body=body) + + +# --------------------------------------------------------------------------- # +# A synthetic downstream service taxonomy — response branch ONLY. # +# --------------------------------------------------------------------------- # + + +class _ExampleServiceError(HttpResponseError): + """Synthetic service base error; extends the response branch only.""" + + +class _ExampleNotFoundError(_ExampleServiceError): + """The example service's 404 error.""" + + +class _ExampleConflictError(_ExampleServiceError): + """The example service's 409 error.""" + + +# --------------------------------------------------------------------------- # +# raise_for rejects a non-error response. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("BODY-31", "XCUT-8") +@pytest.mark.parametrize( + "status", + [Status.OK, Status.CREATED, Status.NO_CONTENT, Status.NOT_MODIFIED, Status.FOUND], + ids=["200", "201", "204", "304", "302"], +) +def test_raise_for_rejects_non_error_status(status: Status) -> None: + # A 2xx/3xx/1xx status is a caller error, never a fabricated exception. + m = StatusErrorMap() + with pytest.raises(ValueError, match="error"): + m.raise_for(_response(status)) + + +# --------------------------------------------------------------------------- # +# Precedence: a per-status override wins over the fallback default. # +# --------------------------------------------------------------------------- # + + +def test_specific_status_wins_over_default_when_raising() -> None: + m = StatusErrorMap( + by_status={Status.NOT_FOUND: _ExampleNotFoundError}, + default=_ExampleServiceError, + ) + # 404 is mapped explicitly -> the override class. + with pytest.raises(_ExampleNotFoundError): + m.raise_for(_response(Status.NOT_FOUND)) + # 400 is not mapped -> the fallback default (but not the override). + with pytest.raises(_ExampleServiceError) as excinfo: + m.raise_for(_response(Status.BAD_REQUEST)) + assert not isinstance(excinfo.value, _ExampleNotFoundError) + + +def test_specific_status_wins_over_default_in_for_status() -> None: + m = StatusErrorMap( + by_status={Status.NOT_FOUND: ResourceNotFoundError}, + default=_ExampleServiceError, + ) + assert m.for_status(Status.NOT_FOUND) is ResourceNotFoundError + assert m.for_status(Status.BAD_REQUEST) is _ExampleServiceError + + +# --------------------------------------------------------------------------- # +# for_status is a permissive, side-effect-free peek. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("BODY-31", "XCUT-8") +@pytest.mark.parametrize( + "status", + [200, 201, 204, 100, 301, 302, 304, 399], + ids=["200", "201", "204", "100", "301", "302", "304", "399"], +) +def test_for_status_returns_none_for_non_error(status: int) -> None: + assert StatusErrorMap(default=_ExampleServiceError).for_status(status) is None + + +@pytest.mark.parametrize("status", [400, 404, 409, 418, 500, 503, 599]) +def test_for_status_returns_default_for_unmapped_error(status: int) -> None: + m = StatusErrorMap(default=_ExampleServiceError) + assert m.for_status(status) is _ExampleServiceError + + +def test_for_status_default_default_is_http_response_error() -> None: + # With no explicit default, an unmapped error status maps to the base class. + assert StatusErrorMap().for_status(500) is HttpResponseError + + +def test_for_status_accepts_plain_int_and_status_enum() -> None: + m = StatusErrorMap(by_status={404: _ExampleNotFoundError}) + assert m.for_status(404) is _ExampleNotFoundError + assert m.for_status(Status.NOT_FOUND) is _ExampleNotFoundError + + +# --------------------------------------------------------------------------- # +# The map is frozen data: defensive copy + read-only view + frozen instance. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("XCUT-15") +def test_map_defensively_copies_source_mapping() -> None: + src: dict[int, type[_ExampleServiceError]] = {404: _ExampleNotFoundError} + m = StatusErrorMap(by_status=src, default=_ExampleServiceError) + src[409] = _ExampleConflictError # mutate the caller's dict after construction + # The map is unaffected: 409 still falls back to the default. + assert m.for_status(409) is _ExampleServiceError + + +def test_map_by_status_is_read_only() -> None: + m = StatusErrorMap(by_status={404: _ExampleNotFoundError}) + with pytest.raises(TypeError): + m.by_status[500] = _ExampleConflictError # type: ignore[index] + + +def test_map_instance_is_frozen() -> None: + m = StatusErrorMap() + with pytest.raises(FrozenInstanceError): + m.default = _ExampleServiceError # type: ignore[misc] + + +# --------------------------------------------------------------------------- # +# A constructed error carries the bounded, replayable buffered body. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("BODY-30") +def test_raise_for_carries_bounded_replayable_body() -> None: + payload = b"x" * (ERROR_BODY_MAX_BYTES + 4096) + m = StatusErrorMap(by_status={Status.BAD_REQUEST: _ExampleNotFoundError}) + with pytest.raises(_ExampleNotFoundError) as excinfo: + m.raise_for(_response(Status.BAD_REQUEST, body=ResponseBody.from_bytes(payload))) + + err = excinfo.value + first = err.read_body() + assert len(first) == ERROR_BODY_MAX_BYTES # bounded to the documented cap + assert err.read_body() == first # replayable: a second read matches + + +@pytest.mark.req("BODY-30") +def test_raise_for_buffers_body_eagerly_before_raising() -> None: + # A single-use stream body is drained by raise_for's eager buffering; the + # caught error still replays the captured copy even though the underlying + # stream is now exhausted. + stream = io.BytesIO(b'{"error":"boom"}') + m = StatusErrorMap() + with pytest.raises(HttpResponseError) as excinfo: + m.raise_for(_response(Status.INTERNAL_SERVER_ERROR, body=ResponseBody.from_stream(stream))) + + err = excinfo.value + assert err.read_body() == b'{"error":"boom"}' + assert err.read_body() == b'{"error":"boom"}' + + +def test_raise_for_handles_error_response_without_a_body() -> None: + m = StatusErrorMap() + with pytest.raises(HttpResponseError) as excinfo: + m.raise_for(_response(Status.SERVICE_UNAVAILABLE)) + assert excinfo.value.read_body() == b"" + + +# --------------------------------------------------------------------------- # +# Invariant: the map never re-wraps a NetworkError onto the response branch. # +# --------------------------------------------------------------------------- # + + +def test_service_hierarchy_stays_on_the_response_branch() -> None: + # The example service base extends HttpResponseError and must NOT be an + # OSError / NetworkError — that is what keeps ``except OSError:`` sites + # matching transport failures after the SDK raises typed service errors. + assert issubclass(_ExampleServiceError, HttpResponseError) + assert not issubclass(_ExampleServiceError, OSError) + assert not issubclass(_ExampleServiceError, NetworkError) + + +def test_map_rejects_an_oserror_branch_error_class() -> None: + # A class straddling both branches would let a service error masquerade as + # a transport failure; the map refuses to register it at build time. + class _StraddlingError(HttpResponseError, OSError): + """Pathological hybrid: must be rejected by StatusErrorMap.""" + + with pytest.raises(TypeError, match="OSError"): + StatusErrorMap(by_status={500: _StraddlingError}) + with pytest.raises(TypeError, match="OSError"): + StatusErrorMap(default=_StraddlingError) + + +def test_map_rejects_a_non_response_error_class() -> None: + with pytest.raises(TypeError, match="HttpResponseError"): + StatusErrorMap(by_status={500: ValueError}) # type: ignore[dict-item] + + +def test_network_error_passes_through_a_service_dispatcher_untouched() -> None: + # A tiny stand-in for a generated dispatcher: received responses are routed + # through the map, but a transport-level NetworkError is never handed to it. + service_map = StatusErrorMap( + by_status={Status.NOT_FOUND: _ExampleNotFoundError}, + default=_ExampleServiceError, + ) + + def dispatch(outcome: Response | BaseException) -> None: + if isinstance(outcome, BaseException): + raise outcome # transport failure — the map is never consulted + service_map.raise_for(outcome) + + # The NetworkError propagates unchanged: still a NetworkError, still an + # OSError, and never re-wrapped into the service's response-branch taxonomy. + with pytest.raises(NetworkError) as excinfo: + dispatch(NetworkError("connection refused")) + assert isinstance(excinfo.value, OSError) + assert not isinstance(excinfo.value, _ExampleServiceError) + + with pytest.raises(OSError): + dispatch(NetworkError("connection refused")) + + # A received error response still routes to the service taxonomy as normal. + with pytest.raises(_ExampleNotFoundError): + dispatch(_response(Status.NOT_FOUND)) diff --git a/packages/dexpace-sdk-core/tests/config/test_build_descriptor.py b/packages/dexpace-sdk-core/tests/config/test_build_descriptor.py new file mode 100644 index 0000000..f467ec5 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/config/test_build_descriptor.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for ``BuildDescriptor`` — distribution build identity with fallback.""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError + +import pytest + +from dexpace.sdk.core.config import BuildDescriptor +from dexpace.sdk.core.config import build_descriptor as _module + + +@pytest.mark.req("CFG-36") +def test_resolve_reads_installed_version() -> None: + # The core distribution is installed in the test venv, so a real version + # resolves — and it is never blank. + descriptor = BuildDescriptor.resolve() + assert descriptor.name == "dexpace-sdk-core" + assert descriptor.version + assert descriptor.version != BuildDescriptor.UNKNOWN + + +@pytest.mark.req("CFG-36") +def test_resolve_falls_back_under_metadata_less_install( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Simulate a zipapp / frozen install where distribution metadata is absent.""" + + def _raise(_name: str) -> str: + raise PackageNotFoundError(_name) + + monkeypatch.setattr(_module, "version", _raise) + descriptor = BuildDescriptor.resolve() + # The fallback engages and is a concrete, non-blank sentinel. + assert descriptor.version == "unknown" + assert descriptor.version.strip() + # The distribution name is still reported (it needs no metadata). + assert descriptor.name == "dexpace-sdk-core" + + +@pytest.mark.req("CFG-36") +def test_resolve_falls_back_on_blank_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_module, "version", lambda _name: " ") + descriptor = BuildDescriptor.resolve() + assert descriptor.version == "unknown" + + +def test_resolve_blank_distribution_name_falls_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_module, "version", lambda _name: "1.2.3") + descriptor = BuildDescriptor.resolve(" ") + assert descriptor.name == "unknown" + assert descriptor.version == "1.2.3" + + +def test_descriptor_is_frozen() -> None: + descriptor = BuildDescriptor(name="x", version="1") + with pytest.raises((AttributeError, TypeError)): + descriptor.version = "2" # type: ignore[misc] diff --git a/packages/dexpace-sdk-core/tests/config/test_configuration.py b/packages/dexpace-sdk-core/tests/config/test_configuration.py index 14c08c1..aa8d780 100644 --- a/packages/dexpace-sdk-core/tests/config/test_configuration.py +++ b/packages/dexpace-sdk-core/tests/config/test_configuration.py @@ -1,16 +1,25 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Tests for ``Configuration`` — layered override + env-var lookup.""" +"""Tests for ``Configuration`` — layered override + env + property lookup.""" from __future__ import annotations +import os +from collections.abc import Callable, Iterator + import pytest -from dexpace.sdk.core.config import Configuration, ConfigurationBuilder +from dexpace.sdk.core.config import ( + Configuration, + ConfigurationBuilder, + default_configuration, + set_default_configuration, +) +from dexpace.sdk.core.config import configuration as _configuration -def _env(mapping: dict[str, str]): # type: ignore[no-untyped-def] +def _env(mapping: dict[str, str]) -> Callable[[str], str | None]: """Build a fake env source closing over ``mapping``.""" def lookup(name: str) -> str | None: @@ -19,6 +28,15 @@ def lookup(name: str) -> str | None: return lookup +def _props(mapping: dict[str, str]) -> Callable[[str], str | None]: + """Build a fake property source closing over ``mapping``.""" + + def lookup(name: str) -> str | None: + return mapping.get(name) + + return lookup + + # --------------------------------------------------------------------------- # Lookup ordering @@ -46,6 +64,7 @@ def test_get_returns_none_when_default_is_none_and_missing() -> None: assert cfg.get("KEY") is None +@pytest.mark.req("CFG-2") def test_empty_env_value_treated_as_absent() -> None: cfg = Configuration(env=_env({"KEY": ""})) assert cfg.get("KEY", "default") == "default" @@ -66,6 +85,7 @@ def test_get_int_happy_path() -> None: assert cfg.get_int("N", 7) == 42 +@pytest.mark.req("CFG-5") def test_get_int_parse_failure_returns_default() -> None: cfg = Configuration(env=_env({"N": "not-a-number"})) assert cfg.get_int("N", 7) == 7 @@ -85,6 +105,7 @@ def test_get_int_override_beats_env() -> None: # get_bool — strict +@pytest.mark.req("CFG-6") @pytest.mark.parametrize( "value,expected", [ @@ -101,6 +122,7 @@ def test_get_bool_recognized_values(value: str, expected: bool) -> None: assert cfg.get_bool("FLAG", not expected) is expected +@pytest.mark.req("CFG-6") @pytest.mark.parametrize("value", ["1", "0", "yes", "no", "on", "off", "", "garbage"]) def test_get_bool_strict_rejects_non_boolean_literals(value: str) -> None: cfg = Configuration(env=_env({"FLAG": value} if value else {})) @@ -140,6 +162,7 @@ def test_get_duration_parses_supported_formats(raw: str, expected_seconds: float assert cfg.get_duration("T", 0.0) == pytest.approx(expected_seconds) +@pytest.mark.req("CFG-5") def test_get_duration_parse_failure_returns_default() -> None: cfg = Configuration(env=_env({"T": "nonsense"})) assert cfg.get_duration("T", 9.0) == 9.0 @@ -197,6 +220,7 @@ def test_well_known_constants_exposed() -> None: assert Configuration.NO_PROXY == "NO_PROXY" +@pytest.mark.req("CFG-11") def test_default_env_is_os_environ(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DEXPACE_TEST_CONFIG_KEY", "value-from-os") cfg = Configuration() @@ -204,6 +228,7 @@ def test_default_env_is_os_environ(monkeypatch: pytest.MonkeyPatch) -> None: class TestOverrideImmutability: + @pytest.mark.req("CFG-8") def test_external_mutation_of_overrides_does_not_leak(self) -> None: """A defensive copy means callers can't mutate config after construction.""" original = {"KEY": "initial"} @@ -211,9 +236,394 @@ def test_external_mutation_of_overrides_does_not_leak(self) -> None: original["KEY"] = "mutated-after-construction" assert cfg.get("KEY") == "initial" + @pytest.mark.req("CFG-12", "CFG-8") def test_builder_build_takes_defensive_copy(self) -> None: builder = Configuration.builder().put("KEY", "v1") cfg = builder.build() builder.put("KEY", "v2") # The originally-built config should not see subsequent builder edits. assert cfg.get("KEY") == "v1" + + +# --------------------------------------------------------------------------- +# Precedence matrix: override > env > property-seam > default +# +# The property seam is keyed by a dotted-lowercase normalisation of the name, +# so the env-style ``FOO_BAR`` resolves against the property key ``foo.bar``. + + +_MATRIX_KEY = "FOO_BAR" +_MATRIX_PROP_KEY = "foo.bar" + + +@pytest.mark.req("CFG-1", "CFG-2") +@pytest.mark.parametrize( + "overrides,env,props,expected", + [ + # All four layers populated -> override wins. + ({_MATRIX_KEY: "ov"}, {_MATRIX_KEY: "en"}, {_MATRIX_PROP_KEY: "pr"}, "ov"), + # No override -> env wins over property and default. + ({}, {_MATRIX_KEY: "en"}, {_MATRIX_PROP_KEY: "pr"}, "en"), + # No override, no env -> property (normalised key) wins over default. + ({}, {}, {_MATRIX_PROP_KEY: "pr"}, "pr"), + # Nothing populated -> default. + ({}, {}, {}, "def"), + # Empty env value is absent -> falls through to property. + ({}, {_MATRIX_KEY: ""}, {_MATRIX_PROP_KEY: "pr"}, "pr"), + # Empty property value is absent -> falls through to default. + ({}, {}, {_MATRIX_PROP_KEY: ""}, "def"), + ], +) +def test_precedence_matrix( + overrides: dict[str, str], + env: dict[str, str], + props: dict[str, str], + expected: str, +) -> None: + cfg = Configuration(overrides=overrides, env=_env(env), props=_props(props)) + assert cfg.get(_MATRIX_KEY, "def") == expected + + +@pytest.mark.req("CFG-1", "CFG-3") +def test_property_seam_is_normalised_by_get() -> None: + # ``get`` normalises the env-style key to the dotted-lowercase property key. + cfg = Configuration(props=_props({_MATRIX_PROP_KEY: "value"})) + assert cfg.get(_MATRIX_KEY, "def") == "value" + + +@pytest.mark.req("CFG-3") +def test_property_seam_not_found_with_unnormalised_key_via_get() -> None: + # A property source keyed by the raw env-style name is NOT seen by ``get``, + # which only queries the normalised key. + cfg = Configuration(props=_props({_MATRIX_KEY: "value"})) + assert cfg.get(_MATRIX_KEY, "def") == "def" + + +# --------------------------------------------------------------------------- +# Raw accessor: skips property-key normalisation + + +@pytest.mark.req("CFG-4") +def test_get_raw_queries_property_with_verbatim_key() -> None: + cfg = Configuration(props=_props({_MATRIX_KEY: "raw-value"})) + # ``get_raw`` looks the property source up under the verbatim name... + assert cfg.get_raw(_MATRIX_KEY, "def") == "raw-value" + # ...while ``get`` normalises and therefore misses it. + assert cfg.get(_MATRIX_KEY, "def") == "def" + + +@pytest.mark.req("CFG-4") +def test_get_raw_still_honours_override_and_env() -> None: + cfg = Configuration( + overrides={_MATRIX_KEY: "ov"}, + env=_env({_MATRIX_KEY: "en"}), + props=_props({_MATRIX_KEY: "raw"}), + ) + assert cfg.get_raw(_MATRIX_KEY, "def") == "ov" + + +def test_get_raw_falls_back_to_env_over_property() -> None: + cfg = Configuration( + env=_env({_MATRIX_KEY: "en"}), + props=_props({_MATRIX_KEY: "raw"}), + ) + assert cfg.get_raw(_MATRIX_KEY, "def") == "en" + + +# --------------------------------------------------------------------------- +# Typed accessors resolve through the FULL layered lookup (incl. property seam) + + +@pytest.mark.req("CFG-38") +def test_get_int_resolves_via_property_seam() -> None: + cfg = Configuration(props=_props({_MATRIX_PROP_KEY: "42"})) + assert cfg.get_int(_MATRIX_KEY, 7) == 42 + + +@pytest.mark.req("CFG-38") +def test_get_bool_resolves_via_property_seam() -> None: + cfg = Configuration(props=_props({_MATRIX_PROP_KEY: "true"})) + assert cfg.get_bool(_MATRIX_KEY, False) is True + + +@pytest.mark.req("CFG-38") +def test_get_duration_resolves_via_property_seam() -> None: + cfg = Configuration(props=_props({_MATRIX_PROP_KEY: "5s"})) + assert cfg.get_duration(_MATRIX_KEY, 0.0) == pytest.approx(5.0) + + +@pytest.mark.req("CFG-5") +def test_get_int_negative_is_valid() -> None: + # Negative values are legitimate for plain int config (unlike durations). + cfg = Configuration(env=_env({"N": "-3"})) + assert cfg.get_int("N", 0) == -3 + + +# --------------------------------------------------------------------------- +# Property seam via the builder + + +@pytest.mark.req("CFG-11") +def test_builder_with_property_source() -> None: + cfg = ConfigurationBuilder().props(_props({_MATRIX_PROP_KEY: "pv"})).build() + assert cfg.get(_MATRIX_KEY, "def") == "pv" + + +# --------------------------------------------------------------------------- +# Derivation (copy-on-write) shares seams + + +@pytest.mark.req("CFG-9") +def test_with_override_is_copy_on_write() -> None: + base = Configuration(env=_env({"KEY": "env"})) + derived = base.with_override("KEY", "derived") + assert derived.get("KEY", "def") == "derived" + # The source is untouched — still resolves via its env layer. + assert base.get("KEY", "def") == "env" + + +@pytest.mark.req("CFG-9") +def test_with_override_shares_source_seams() -> None: + env = _env({}) + props = _props({_MATRIX_PROP_KEY: "shared"}) + base = Configuration(env=env, props=props) + derived = base.with_override("OTHER", "x") + # Same seam objects — a single injected fake drives both configurations. + assert derived.env is env + assert derived.props is props + assert derived.get(_MATRIX_KEY, "def") == "shared" + + +@pytest.mark.req("CFG-9") +def test_with_override_does_not_mutate_source_overrides() -> None: + base = Configuration(overrides={"A": "1"}) + base.with_override("A", "2") + assert base.get("A") == "1" + + +# --------------------------------------------------------------------------- +# Override removal (copy-on-write) — drops only the override layer + + +@pytest.mark.req("CFG-10") +def test_without_override_falls_through_to_env() -> None: + # Removing the override peels back only that layer; the lookup resolves via + # env exactly as if the override had never existed — it is NOT nulled. + cfg = Configuration(overrides={"KEY": "override"}, env=_env({"KEY": "env-value"})) + derived = cfg.without_override("KEY") + assert derived.get("KEY", "def") == "env-value" + + +@pytest.mark.req("CFG-10") +def test_without_override_falls_through_to_property_seam() -> None: + cfg = Configuration( + overrides={_MATRIX_KEY: "override"}, + props=_props({_MATRIX_PROP_KEY: "prop-value"}), + ) + derived = cfg.without_override(_MATRIX_KEY) + assert derived.get(_MATRIX_KEY, "def") == "prop-value" + + +@pytest.mark.req("CFG-10") +def test_without_override_falls_through_to_default() -> None: + # No env or property layer supplies the key, so removal falls through to + # the caller default — removal never forces a resolution to None. + cfg = Configuration(overrides={"KEY": "override"}, env=_env({})) + derived = cfg.without_override("KEY") + assert derived.get("KEY", "def") == "def" + assert derived.get("KEY") is None + + +@pytest.mark.req("CFG-10") +def test_without_override_absent_key_is_harmless_no_op() -> None: + # Removing a key that has no override does not raise and leaves the other + # layers resolving normally. + cfg = Configuration(overrides={"OTHER": "v"}, env=_env({"KEY": "env-value"})) + derived = cfg.without_override("KEY") + assert derived.get("KEY", "def") == "env-value" + assert derived.get("OTHER", "def") == "v" + + +@pytest.mark.req("CFG-10") +def test_without_override_is_copy_on_write() -> None: + base = Configuration(overrides={"KEY": "override"}, env=_env({"KEY": "env-value"})) + derived = base.without_override("KEY") + # Source is untouched — its override still wins. + assert base.get("KEY", "def") == "override" + assert derived.get("KEY", "def") == "env-value" + + +@pytest.mark.req("CFG-10", "CFG-9") +def test_without_override_shares_source_seams() -> None: + env = _env({}) + props = _props({_MATRIX_PROP_KEY: "shared"}) + base = Configuration(overrides={"K": "v"}, env=env, props=props) + derived = base.without_override("K") + assert derived.env is env + assert derived.props is props + + +@pytest.mark.req("CFG-10", "CFG-37") +def test_without_override_rejects_none_key() -> None: + cfg = Configuration() + with pytest.raises(TypeError): + cfg.without_override(None) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# None-rejection for required arguments + + +@pytest.mark.req("CFG-37") +def test_get_rejects_none_key() -> None: + cfg = Configuration() + with pytest.raises(TypeError): + cfg.get(None) # type: ignore[arg-type] + + +def test_get_raw_rejects_none_key() -> None: + cfg = Configuration() + with pytest.raises(TypeError): + cfg.get_raw(None) # type: ignore[arg-type] + + +@pytest.mark.req("CFG-37") +def test_with_override_rejects_none_key() -> None: + cfg = Configuration() + with pytest.raises(TypeError): + cfg.with_override(None, "v") # type: ignore[arg-type] + + +@pytest.mark.req("CFG-37") +def test_set_default_configuration_rejects_none() -> None: + with pytest.raises(TypeError): + set_default_configuration(None) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Global default slot (last-write-wins) + + +@pytest.fixture +def _restore_default_slot() -> Iterator[None]: + """Snapshot and restore the module-global default slot around a test.""" + saved = _configuration._default_configuration + try: + yield + finally: + _configuration._default_configuration = saved + + +@pytest.mark.req("CFG-13") +def test_default_configuration_lazily_initialises(_restore_default_slot: None) -> None: + _configuration._default_configuration = None + cfg = default_configuration() + assert isinstance(cfg, Configuration) + # Repeat access returns the same lazily-created instance. + assert default_configuration() is cfg + + +@pytest.mark.req("CFG-13") +def test_set_default_configuration_last_write_wins(_restore_default_slot: None) -> None: + first = Configuration(overrides={"K": "first"}) + second = Configuration(overrides={"K": "second"}) + set_default_configuration(first) + assert default_configuration() is first + set_default_configuration(second) + assert default_configuration() is second + assert default_configuration().get("K") == "second" + + +# --------------------------------------------------------------------------- +# Hermetic seam substitution — tests never touch real ``os.environ`` + + +@pytest.mark.req("CFG-11") +def test_injected_env_seam_bypasses_real_os_environ() -> None: + sentinel = "DEXPACE_HERMETIC_SENTINEL_KEY" + # Precondition: the sentinel is genuinely absent from the real process env. + assert sentinel not in os.environ + cfg = Configuration(env=_env({sentinel: "from-fake-seam"})) + # The value is resolved purely from the injected seam... + assert cfg.get(sentinel, "def") == "from-fake-seam" + # ...and no test-visible mutation ever leaked into the real environment. + assert sentinel not in os.environ + + +def test_real_os_environ_value_is_invisible_when_seam_injected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Even with a real env var present, an injected empty seam hides it — + # proving the lookup consults the seam, not ``os.environ`` directly. + monkeypatch.setenv("DEXPACE_HERMETIC_REAL_KEY", "real") + cfg = Configuration(env=_env({})) + assert cfg.get("DEXPACE_HERMETIC_REAL_KEY", "def") == "def" + + +# --------------------------------------------------------------------------- +# Duration-grammar corpus +# +# Note: this port interprets a *bare* number as SECONDS (not milliseconds), +# matching Python's ``time.sleep`` / ``Clock`` seconds convention. The +# shorthand ``500ms`` remains the way to express sub-second millisecond values. + + +@pytest.mark.parametrize( + "raw,expected_seconds", + [ + # ISO-8601 components (day / hour / minute / second, and combinations). + ("PT5S", 5.0), + ("PT1M", 60.0), + ("PT2H", 7200.0), + ("P1D", 86400.0), + ("PT0.5S", 0.5), + ("PT1M30S", 90.0), + ("PT1H1M1S", 3661.0), + ("P1DT2H", 93600.0), + ("PT0S", 0.0), + # Shorthand units (case-insensitive), including sub-second ms. + ("500ms", 0.5), + ("250MS", 0.25), + ("5s", 5.0), + ("1m", 60.0), + ("2h", 7200.0), + ("1d", 86400.0), + ("1.5s", 1.5), + (" 5s ", 5.0), + # Bare numbers are seconds. + ("1.5", 1.5), + ("30", 30.0), + ("0", 0.0), + (" 30 ", 30.0), + ], +) +def test_duration_corpus_positive(raw: str, expected_seconds: float) -> None: + cfg = Configuration(env=_env({"T": raw})) + assert cfg.get_duration("T", -1.0) == pytest.approx(expected_seconds) + + +@pytest.mark.parametrize( + "raw", + [ + "nonsense", + "", + " ", + "5x", # unknown unit + "P", # ISO prefix with no components + "PT", # ISO time marker with no components + "P1Y", # years unsupported + "P1W", # weeks unsupported + "PT1H30", # trailing number without unit + "abcms", + "-5s", # negative shorthand rejected + "-1", # negative bare number rejected + "-PT5S", # negative ISO rejected + "inf", + "-inf", + "nan", + "Infinity", + ], +) +def test_duration_corpus_rejects_and_returns_default(raw: str) -> None: + cfg = Configuration(env=_env({"T": raw})) + assert cfg.get_duration("T", 9.0) == 9.0 diff --git a/packages/dexpace-sdk-core/tests/conftest.py b/packages/dexpace-sdk-core/tests/conftest.py index 718e7c0..4898931 100644 --- a/packages/dexpace-sdk-core/tests/conftest.py +++ b/packages/dexpace-sdk-core/tests/conftest.py @@ -5,12 +5,42 @@ from __future__ import annotations +import os from collections.abc import Iterator import pytest +from hypothesis import HealthCheck, settings from dexpace.sdk.core.http.context import ContextStore +# Hypothesis profiles for the property-based suites (``test_*_properties.py``). +# +# The default "ci" profile is capped and deterministic so a normal CI run stays +# fast and never fails on timing noise: ``deadline=None`` disables the +# per-example wall-clock deadline (the usual source of flaky CI failures under +# load) and ``derandomize=True`` pins a fixed seed so runs are reproducible. +# ``too_slow`` health checks are suppressed for the same reason. +# +# TODO: a "nightly" profile (registered below with a much larger +# ``max_examples``) could run in a separate scheduled CI lane for deeper +# coverage. It is intentionally *not* wired up as the default here to keep the +# standard run quick; select it with ``HYPOTHESIS_PROFILE=nightly``. +settings.register_profile( + "ci", + max_examples=100, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], + print_blob=True, +) +settings.register_profile( + "nightly", + max_examples=2000, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "ci")) + @pytest.fixture(autouse=True) def _clean_context_store() -> Iterator[None]: diff --git a/packages/dexpace-sdk-core/tests/context/test_context_store.py b/packages/dexpace-sdk-core/tests/context/test_context_store.py new file mode 100644 index 0000000..b212189 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/context/test_context_store.py @@ -0,0 +1,273 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Hardening tests for `_ContextStore`. + +Certifies the store's safety properties against fresh instances so each test +controls the bound and starts from an empty map: + +* reject-on-duplicate (`put`) loses deterministically; +* identity-conditional removal (`remove_if`) never cross-evicts an + equal-but-distinct successor, while the unconditional shared-key `remove` + the promotion chain's `close` relies on still clears a promoted call; +* post-insert drain-to-cap keeps the map bounded, evicting oldest-first, and + closing an evicted entry never deadlocks even when its close re-enters the + store; +* entries are held by strong reference (no weak-ref eviction). +""" + +from __future__ import annotations + +import gc +import threading +import weakref +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.context import ( + CallContext, + ContextStore, + DispatchContext, +) +from dexpace.sdk.core.http.context.context_store import _ContextStore +from dexpace.sdk.core.http.context.request_context import RequestContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Response, Status +from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN + + +def _instrumentation(trace_id_value: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace_id_value), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + + +def _request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://example.com/")) + + +def _response(request: Request) -> Response: + return Response(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK) + + +def _request_context(store_key: str) -> RequestContext: + """Build a `RequestContext` directly, without touching any store.""" + return RequestContext( + instrumentation_context=_instrumentation("0" * 16 + "1"), + request=_request(), + store_key=store_key, + ) + + +class _StoreCoupledContext(CallContext): + """Minimal `CallContext` whose `close` re-enters a specific store. + + The eviction side effect deliberately calls back into the same store, so + the drain path is exercised against the deadlock trap: if drain held its + lock while running this `close`, the re-entrant `remove` would deadlock. + """ + + def __init__(self, store: _ContextStore, key: str) -> None: + self._store = store + self.store_key = key + self.closed = False + + def close(self) -> None: + # Re-enter the *same* store from the eviction side effect. + key = self.store_key + if key is not None: + self._store.remove(key) + self.closed = True + + +# --- reject-on-duplicate ------------------------------------------------------ + + +@pytest.mark.req("CTX-8") +def test_put_rejects_duplicate_key_deterministically() -> None: + """A second `put` under a registered key loses with a `ValueError`.""" + store = _ContextStore() + ctx = _request_context("k") + store.put("k", ctx) + with pytest.raises(ValueError, match="already registered"): + store.put("k", _request_context("k")) + # The original entry survives the rejected duplicate. + assert store.get("k") is ctx + + +# --- identity-conditional removal vs shared-key close ------------------------- + + +def test_identity_conditional_remove_coexists_with_shared_key_close() -> None: + """`remove_if` is identity-scoped; `close` stays unconditional shared-key. + + Both properties must hold together: + + * `remove_if(key, ctx)` evicts only when the stored entry *is* ``ctx``, so + an equal-but-distinct twin cannot cross-evict the live entry. + * The unconditional `remove` behind `CallContext.close` still clears a + promoted call whose request and exchange tiers share one key, even though + `close` runs on the request-tier object while the exchange-tier object is + what occupies the slot. + """ + # Identity-conditional remove_if on a private instance. + store = _ContextStore() + key = store.new_key("0" * 32, "0" * 16) + real = _request_context(key) + twin = _request_context(key) + assert real == twin and real is not twin # equal but distinct + store.set(key, real) + assert store.remove_if(key, twin) is False # twin must not evict real + assert store.get(key) is real + assert store.remove_if(key, real) is True # the real object evicts + assert store.get(key) is None + assert store.remove_if(key, real) is False # absent key -> False + + # Shared-key unconditional close still clears the promoted exchange. + instr = _instrumentation("0" * 16 + "d") + request_ctx = DispatchContext(instrumentation_context=instr).to_request_context(_request()) + shared_key = request_ctx.store_key + assert shared_key is not None + exchange = request_ctx.to_exchange_context(_response(request_ctx.request)) + # The exchange tier (a distinct object) now occupies the shared key, yet + # close is invoked on the request-tier object. + assert ContextStore.get(shared_key) is exchange + request_ctx.close() # unconditional remove, not identity-conditional + assert ContextStore.get(shared_key) is None # cleared despite identity mismatch + + +@pytest.mark.req("CTX-18", "CTX-8") +def test_remove_is_unconditional_pop_by_key() -> None: + """`remove` clears a key regardless of which object occupies it.""" + store = _ContextStore() + original = _request_context("k") + successor = _request_context("k") + store.set("k", original) + store.set("k", successor) # overwrite in place, same key + store.remove("k") # unconditional -- clears whatever is there + assert store.get("k") is None + + +# --- strong references -------------------------------------------------------- + + +@pytest.mark.req("CTX-19") +def test_store_holds_strong_reference() -> None: + """A stored context is retained by strong reference, not a weak one.""" + store = _ContextStore() + ctx = _request_context("k") + ref = weakref.ref(ctx) + store.set("k", ctx) + del ctx + gc.collect() + assert ref() is not None # survived GC -> the store holds a strong ref + assert store.get("k") is ref() + + +# --- post-insert drain-to-cap ------------------------------------------------- + + +@pytest.mark.req("CTX-11") +def test_nonpositive_cap_is_rejected() -> None: + """A store must be bounded by at least one entry.""" + with pytest.raises(ValueError, match="max_entries"): + _ContextStore(max_entries=0) + + +@pytest.mark.req("CTX-11", "CTX-13", "XCUT-14") +def test_set_drains_to_cap_oldest_first() -> None: + """Inserting past the cap evicts the oldest entries first.""" + store = _ContextStore(max_entries=3) + contexts = {f"k{i}": _StoreCoupledContext(store, f"k{i}") for i in range(5)} + for key, ctx in contexts.items(): + store.set(key, ctx) + # Only the three newest keys remain; the two oldest were drained. + assert store.get("k0") is None + assert store.get("k1") is None + assert store.get("k2") is contexts["k2"] + assert store.get("k3") is contexts["k3"] + assert store.get("k4") is contexts["k4"] + # The evicted entries had their close side effect run; the survivor did not. + assert contexts["k0"].closed and contexts["k1"].closed + assert not contexts["k4"].closed + + +@pytest.mark.req("CTX-11", "XCUT-14") +def test_put_also_drains_to_cap() -> None: + """`put`, like `set`, enforces the cap after inserting.""" + store = _ContextStore(max_entries=2) + a, b, c = (_StoreCoupledContext(store, k) for k in ("a", "b", "c")) + store.put("a", a) + store.put("b", b) + store.put("c", c) + assert store.get("a") is None # oldest drained + assert store.get("b") is b + assert store.get("c") is c + assert a.closed + + +@pytest.mark.req("CTX-12", "NFR-11", "XCUT-11", "XCUT-14") +def test_cap_drain_converges_under_concurrent_inserts() -> None: + """Concurrent inserts converge to exactly the cap, with no lost lock.""" + cap = 64 + store = _ContextStore(max_entries=cap) + workers = 8 + per_worker = 500 + + def worker(base: int) -> None: + for i in range(per_worker): + key = f"{base}:{i}" + store.set(key, _StoreCoupledContext(store, key)) + + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(worker, b) for b in range(workers)] + for future in futures: + future.result(timeout=30) + + # Every insert drains back to the cap, so the final size is exactly the cap. + assert len(store._contexts) == cap + + +@pytest.mark.req("NFR-11") +def test_drain_close_side_effect_reenters_store_without_deadlock() -> None: + """An evicted entry's `close` may re-enter the store without deadlocking. + + Drain must release the store lock before running an eviction side effect, + because that side effect can call back into the store. The insert that + triggers the drain runs in a worker thread so a deadlock surfaces as a + timeout rather than hanging the suite. + """ + store = _ContextStore(max_entries=1) + victim = _StoreCoupledContext(store, "victim") + store.set("victim", victim) # at cap, no drain yet + survivor = _StoreCoupledContext(store, "survivor") + + done = threading.Event() + + def insert() -> None: + store.set("survivor", survivor) # overflow -> drain evicts + closes victim + done.set() + + thread = threading.Thread(target=insert) + thread.start() + assert done.wait(timeout=5.0), "drain deadlocked while closing an evicted entry" + thread.join(timeout=5.0) + + assert victim.closed # eviction side effect ran + assert store.get("victim") is None + assert store.get("survivor") is survivor diff --git a/packages/dexpace-sdk-core/tests/context/test_promotion_chain.py b/packages/dexpace-sdk-core/tests/context/test_promotion_chain.py index 98eb4f4..854f9cf 100644 --- a/packages/dexpace-sdk-core/tests/context/test_promotion_chain.py +++ b/packages/dexpace-sdk-core/tests/context/test_promotion_chain.py @@ -12,9 +12,11 @@ from dexpace.sdk.core.http.common import Protocol, Url from dexpace.sdk.core.http.context import ( + CallContext, ContextStore, DispatchContext, ExchangeContext, + RequestContext, ) from dexpace.sdk.core.http.request import Method, Request from dexpace.sdk.core.http.response import Response, Status @@ -48,28 +50,37 @@ def _response(request: Request) -> Response: return Response(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK) +@pytest.mark.req("CTX-1", "CTX-3") def test_promotion_registers_in_context_store() -> None: instr = _instrumentation("0" * 16 + "1") dispatch = DispatchContext(instrumentation_context=instr) + request_ctx = dispatch.to_request_context(_request()) + key = request_ctx.store_key + assert key is not None try: - request_ctx = dispatch.to_request_context(_request()) - assert ContextStore.get(instr.trace_id.value) is request_ctx + assert ContextStore.get(key) is request_ctx exchange = request_ctx.to_exchange_context(_response(request_ctx.request)) - assert ContextStore.get(instr.trace_id.value) is exchange + # The exchange tier reuses the request tier's key, overwriting in place. + assert exchange.store_key == key + assert ContextStore.get(key) is exchange finally: - ContextStore.remove(instr.trace_id.value) + request_ctx.close() +@pytest.mark.req("CTX-18") def test_close_removes_from_store() -> None: instr = _instrumentation("0" * 16 + "2") dispatch = DispatchContext(instrumentation_context=instr) request_ctx = dispatch.to_request_context(_request()) - assert ContextStore.get(instr.trace_id.value) is request_ctx + key = request_ctx.store_key + assert key is not None + assert ContextStore.get(key) is request_ctx request_ctx.close() - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.get(key) is None +@pytest.mark.req("CTX-17", "CTX-18") def test_close_is_idempotent() -> None: instr = _instrumentation("0" * 16 + "3") dispatch = DispatchContext(instrumentation_context=instr) @@ -78,6 +89,7 @@ def test_close_is_idempotent() -> None: request_ctx.close() +@pytest.mark.req("CTX-8") def test_context_store_rejects_duplicate_put() -> None: instr = _instrumentation("0" * 16 + "4") dispatch = DispatchContext(instrumentation_context=instr) @@ -89,6 +101,7 @@ def test_context_store_rejects_duplicate_put() -> None: ContextStore.remove(instr.trace_id.value) +@pytest.mark.req("CTX-15") def test_dispatch_noop_factory() -> None: dispatch = DispatchContext.noop() # No-op trace id is invalid, but the context is constructible. @@ -96,6 +109,7 @@ def test_dispatch_noop_factory() -> None: assert not dispatch.instrumentation_context.is_valid +@pytest.mark.req("CTX-7") def test_context_store_concurrent_get_with_writes() -> None: """``ContextStore.get`` must be safe under concurrent ``set``/``remove``. @@ -140,6 +154,7 @@ def reader() -> list[bool]: ContextStore.remove(trace_id) +@pytest.mark.req("CTX-1", "CTX-2") def test_exchange_context_carries_request_and_response() -> None: instr = _instrumentation("0" * 16 + "5") dispatch = DispatchContext(instrumentation_context=instr) @@ -154,4 +169,223 @@ def test_exchange_context_carries_request_and_response() -> None: assert exchange.request is req assert exchange.response is resp finally: - ContextStore.remove(instr.trace_id.value) + request_ctx.close() + + +@pytest.mark.req("CTX-4", "CTX-6") +def test_concurrent_same_trace_id_both_register_and_resolve() -> None: + """Two calls sharing a trace id must not collide in the store. + + A single traced operation that fans out produces two calls carrying the + same trace id (and span id). Under a trace-id-only key the second + registration would overwrite the first, and closing either would evict the + survivor. The counter-suffixed key gives each call its own slot so both + register, both resolve, and closing one leaves the other live. + """ + instr = _instrumentation("0" * 16 + "a") + rc1 = DispatchContext(instrumentation_context=instr).to_request_context(_request()) + rc2 = DispatchContext(instrumentation_context=instr).to_request_context(_request()) + key1, key2 = rc1.store_key, rc2.store_key + assert key1 is not None and key2 is not None + try: + # Distinct store keys despite the shared trace id. + assert key1 != key2 + # Both live and independently resolvable. + assert ContextStore.get(key1) is rc1 + assert ContextStore.get(key2) is rc2 + # Closing one leaves the other untouched. + rc1.close() + assert ContextStore.get(key1) is None + assert ContextStore.get(key2) is rc2 + finally: + rc1.close() + rc2.close() + + +def test_prefix_scan_by_trace_id_returns_all_live_contexts() -> None: + """The trace id stays the leading key segment so a prefix scan still works. + + ``contexts_for_trace`` must return every live context for a trace id even + when several calls share it, and each key must carry the trace id as its + leading, colon-delimited segment. + """ + instr = _instrumentation("0" * 16 + "b") + trace_id = instr.trace_id.value + rc1 = DispatchContext(instrumentation_context=instr).to_request_context(_request()) + rc2 = DispatchContext(instrumentation_context=instr).to_request_context(_request()) + try: + found = ContextStore.contexts_for_trace(trace_id) + assert {id(ctx) for ctx in found} == {id(rc1), id(rc2)} + assert rc1.store_key is not None and rc1.store_key.startswith(f"{trace_id}:") + assert rc2.store_key is not None and rc2.store_key.startswith(f"{trace_id}:") + # Closing one shrinks the scan result to just the survivor. + rc1.close() + assert [id(ctx) for ctx in ContextStore.contexts_for_trace(trace_id)] == [id(rc2)] + finally: + rc1.close() + rc2.close() + + +@pytest.mark.req("CTX-15", "CTX-4", "CTX-6", "CTX-7") +def test_context_store_stress_no_lost_or_corrupted_entries() -> None: + """Many threads registering/closing calls that share a trace id. + + Every call draws its own counter-suffixed key, so each thread's + register/resolve/close cycle is isolated: no key collides, no live entry is + lost, and the store holds no leaked entries for the trace id once every + worker finishes. + """ + instr = _instrumentation("0" * 16 + "c") + trace_id = instr.trace_id.value + workers = 8 + per_worker = 250 + + def worker() -> list[str]: + keys: list[str] = [] + for _ in range(per_worker): + rc = DispatchContext(instrumentation_context=instr).to_request_context(_request()) + key = rc.store_key + assert key is not None + keys.append(key) + # Unique key ⇒ this lookup is deterministic even under contention. + assert ContextStore.get(key) is rc + rc.close() + assert ContextStore.get(key) is None + return keys + + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(worker) for _ in range(workers)] + all_keys = [key for future in futures for key in future.result(timeout=30)] + + # No two calls shared a key ⇒ every counter increment was atomic. + assert len(all_keys) == len(set(all_keys)) == workers * per_worker + # Every entry was evicted ⇒ no leak under the shared trace id. + assert ContextStore.contexts_for_trace(trace_id) == [] + + +# --- Promotion-chain conformance certification (PY-M3-01) -------------------- + +_CONTEXT_CLASSES: tuple[type[CallContext], ...] = ( + CallContext, + DispatchContext, + RequestContext, + ExchangeContext, +) + + +@pytest.mark.req("CTX-1") +def test_all_links_are_call_contexts() -> None: + """Every link in the chain is a `CallContext` subclass.""" + assert issubclass(DispatchContext, CallContext) + assert issubclass(RequestContext, CallContext) + assert issubclass(ExchangeContext, CallContext) + + +def test_all_links_are_context_managers() -> None: + """Each concrete context implements the context-manager protocol.""" + for cls in (DispatchContext, RequestContext, ExchangeContext): + assert hasattr(cls, "__enter__") + assert hasattr(cls, "__exit__") + + +@pytest.mark.req("CTX-14") +def test_every_link_carries_instrumentation_context() -> None: + """Each concrete context exposes an `InstrumentationContext`.""" + instr = _instrumentation("0" * 16 + "7") + dispatch = DispatchContext(instrumentation_context=instr) + request_ctx = dispatch.to_request_context(_request()) + try: + exchange = request_ctx.to_exchange_context(_response(request_ctx.request)) + for ctx in (dispatch, request_ctx, exchange): + assert isinstance(ctx.instrumentation_context, InstrumentationContext) + finally: + request_ctx.close() + + +@pytest.mark.req("CTX-2") +def test_promotion_shares_one_bundle_not_three_copies() -> None: + """Promotion reuses the same payload object; it never copies it. + + The `InstrumentationContext` is the single shared bundle threaded through + ``DispatchContext`` -> ``RequestContext`` -> ``ExchangeContext``, and the + per-hop ``Request`` object is likewise shared by reference — proving each + promotion promotes one bundle rather than materialising an independent copy. + """ + instr = _instrumentation("0" * 16 + "8") + req = _request() + dispatch = DispatchContext(instrumentation_context=instr) + request_ctx = dispatch.to_request_context(req) + try: + exchange = request_ctx.to_exchange_context(_response(req)) + # The instrumentation bundle is the *same object* end to end. + assert dispatch.instrumentation_context is instr + assert request_ctx.instrumentation_context is instr + assert exchange.instrumentation_context is instr + # The request reference is shared, not deep-copied, across promotion. + assert request_ctx.request is req + assert exchange.request is req + finally: + request_ctx.close() + + +@pytest.mark.req("CTX-17", "CTX-4") +def test_registration_happens_at_promotion_not_construction() -> None: + """Constructing a `DispatchContext` does not touch the store. + + Registration is a side effect of promotion, so a freshly built dispatch + context is absent from the store until ``to_request_context`` runs. Each + call draws its own counter-suffixed key (CTX-4), so presence is checked via + ``contexts_for_trace`` / the promoted context's own ``store_key`` rather + than a bare trace-id lookup. + """ + instr = _instrumentation("0" * 16 + "9") + trace_id = instr.trace_id.value + dispatch = DispatchContext(instrumentation_context=instr) + assert dispatch.store_key is None + assert ContextStore.contexts_for_trace(trace_id) == [] + try: + request_ctx = dispatch.to_request_context(_request()) + assert request_ctx.store_key is not None + assert ContextStore.get(request_ctx.store_key) is request_ctx + finally: + request_ctx.close() + + +def test_with_block_evicts_context_on_exit() -> None: + """A ``with`` block registers, exposes, then evicts the context on exit.""" + instr = _instrumentation("0" * 16 + "a") + dispatch = DispatchContext(instrumentation_context=instr) + request_ctx = dispatch.to_request_context(_request()) + key = request_ctx.store_key + assert key is not None + assert ContextStore.get(key) is request_ctx + with request_ctx as entered: + assert entered is request_ctx + assert ContextStore.get(key) is request_ctx + assert ContextStore.get(key) is None + + +def test_with_block_evicts_even_when_body_raises() -> None: + """The context is evicted on exit even if the ``with`` body raises.""" + instr = _instrumentation("0" * 16 + "b") + dispatch = DispatchContext(instrumentation_context=instr) + request_ctx = dispatch.to_request_context(_request()) + key = request_ctx.store_key + assert key is not None + with pytest.raises(RuntimeError, match="boom"), request_ctx: + assert ContextStore.get(key) is request_ctx + raise RuntimeError("boom") + assert ContextStore.get(key) is None + + +def test_no_context_class_relies_on_del() -> None: + """No context class defines ``__del__``. + + GC finalisers must never be the eviction mechanism; eviction is explicit + via ``close`` / the ``with`` block. This audits the class namespaces (not + just instances) so an inherited or dataclass-synthesised finaliser would + still be caught. + """ + for cls in _CONTEXT_CLASSES: + assert "__del__" not in cls.__dict__, f"{cls.__name__} must not define __del__" + assert getattr(cls, "__del__", None) is None diff --git a/packages/dexpace-sdk-core/tests/errors/test_error_caps.py b/packages/dexpace-sdk-core/tests/errors/test_error_caps.py index 4c3626d..082b223 100644 --- a/packages/dexpace-sdk-core/tests/errors/test_error_caps.py +++ b/packages/dexpace-sdk-core/tests/errors/test_error_caps.py @@ -12,7 +12,11 @@ import pytest -from dexpace.sdk.core.errors import HttpResponseError, ResourceNotFoundError +from dexpace.sdk.core.errors import ( + ERROR_BODY_MAX_BYTES, + HttpResponseError, + ResourceNotFoundError, +) from dexpace.sdk.core.http.common import MediaType, Protocol, Url from dexpace.sdk.core.http.request import Method, Request from dexpace.sdk.core.http.response import ( @@ -21,6 +25,7 @@ ResponseBody, Status, ) +from dexpace.sdk.core.pipeline._pure import classifier def _response(status: Status, *, body: ResponseBody | None = None) -> Response: @@ -36,6 +41,7 @@ def _response(status: Status, *, body: ResponseBody | None = None) -> Response: # ----- retryable flag ----------------------------------------------------- +@pytest.mark.req("RETRY-3") @pytest.mark.parametrize( "status", [ @@ -83,9 +89,153 @@ def test_retryable_inherited_by_subclasses() -> None: assert err.retryable is False +@pytest.mark.req("RETRY-3", "XCUT-5") +def test_retryable_is_baked_once_and_stable_against_classifier_change( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The flag is derived from the single shared classifier at construction + # time and then baked as a plain attribute, so repointing the classifier + # afterwards must not retroactively change an already-built error. + err = HttpResponseError(response=_response(Status.SERVICE_UNAVAILABLE)) + assert err.retryable is True + baked = err.retryable + + # Repoint the canonical classifier to never-retryable *after* construction. + monkeypatch.setattr(classifier, "default_status_is_retryable", lambda status: False) + + # The already-constructed error keeps its baked value (no recompute). + assert err.retryable is baked is True + # Proof the patch is live: a freshly built error now reflects the repointed + # classifier, confirming the first error's flag was frozen at its own + # construction from the same single source. + fresh = HttpResponseError(response=_response(Status.SERVICE_UNAVAILABLE)) + assert fresh.retryable is False + + +@pytest.mark.req("XCUT-5") +@pytest.mark.parametrize( + "status", + [ + Status.VARIANT_ALSO_NEGOTIATES, + Status.INSUFFICIENT_STORAGE, + Status.LOOP_DETECTED, + Status.NOT_EXTENDED, + Status.NETWORK_AUTHENTICATION_REQUIRED, + ], + ids=["506", "507", "508", "510", "511"], +) +def test_exotic_5xx_bakes_retryable_true(status: Status) -> None: + # XCUT-5: the baked flag treats ALL 5xx as retryable except 501 and 505 — + # not just the curated {500,502,503,504}. The exotic 5xx codes therefore + # bake ``retryable=True`` even though they are absent from the retry + # policy's narrower default eligibility set. + err = HttpResponseError(response=_response(status)) + assert err.retryable is True + + +@pytest.mark.req("XCUT-5") +@pytest.mark.parametrize( + "status", + [Status.NOT_IMPLEMENTED, Status.HTTP_VERSION_NOT_SUPPORTED], + ids=["501", "505"], +) +def test_permanent_5xx_bakes_retryable_false(status: Status) -> None: + # The only two 5xx codes the canonical classifier excludes: retrying a + # 501/505 can never change the outcome, so the baked flag is False. + err = HttpResponseError(response=_response(status)) + assert err.retryable is False + + +def test_retry_policy_custom_set_does_not_rewrite_baked_error_flag() -> None: + # The retry policy's ``retry_on_status_codes`` is the direct injection point + # for a custom set, but it is a *policy-local* override: the error bakes its + # own flag from the shared classifier default at construction and never + # consults a policy instance. Building a policy with an inverted set must + # therefore leave already-built errors' flags untouched — the flag reflects + # the set authoritative at construction time, not a policy's configuration. + from dexpace.sdk.core.pipeline.policies import RetryPolicy + + retryable_by_default = HttpResponseError(response=_response(Status.SERVICE_UNAVAILABLE)) + terminal_by_default = HttpResponseError(response=_response(Status.NOT_IMPLEMENTED)) + assert retryable_by_default.retryable is True # 503 in the default set + assert terminal_by_default.retryable is False # 501 excluded by default + + # A policy whose set inverts both classifications relative to the default. + RetryPolicy(retry_on_status_codes={501}) + + # The errors keep the flags baked at their own construction, independent of + # any policy's set. + assert retryable_by_default.retryable is True + assert terminal_by_default.retryable is False + + +# ----- bounded, replayable error body ------------------------------------- + + +@pytest.mark.req("HTTP-52") +def test_error_body_cap_is_one_mib() -> None: + assert ERROR_BODY_MAX_BYTES == 1 << 20 # documented 1 MiB cap + + +@pytest.mark.req("BODY-30", "HTTP-52") +def test_read_body_captures_bounded_replayable_copy() -> None: + # A body larger than the cap is truncated to the documented ceiling and the + # captured copy replays identically on every subsequent read. + payload = b"x" * (ERROR_BODY_MAX_BYTES + 4096) + body = ResponseBody.from_bytes(payload) + err = HttpResponseError(response=_response(Status.BAD_REQUEST, body=body)) + + first = err.read_body() + assert len(first) == ERROR_BODY_MAX_BYTES + # Replayable: the error carries the bounded copy, so a second read matches. + assert err.read_body() == first + + +@pytest.mark.req("HTTP-52") +def test_read_body_returns_full_body_under_cap() -> None: + body = ResponseBody.from_bytes(b'{"error":"boom"}') + err = HttpResponseError(response=_response(Status.BAD_REQUEST, body=body)) + assert err.read_body() == b'{"error":"boom"}' + assert err.read_body() == b'{"error":"boom"}' # replayable + + +def test_read_body_reads_from_loggable_body_repeatably() -> None: + loggable = LoggableResponseBody( + ResponseBody.from_bytes(b'{"error":"boom"}', MediaType.parse("application/json")), + ) + err = HttpResponseError(response=_response(Status.BAD_REQUEST, body=loggable)) + assert err.read_body() == b'{"error":"boom"}' + # The caller-owned loggable body is still readable afterwards. + assert loggable.bytes() == b'{"error":"boom"}' + + +def test_read_body_honours_explicit_smaller_cap() -> None: + body = ResponseBody.from_bytes(b"0123456789") + err = HttpResponseError(response=_response(Status.BAD_REQUEST, body=body)) + assert err.read_body(4) == b"0123" + + +def test_read_body_returns_empty_when_no_body() -> None: + err = HttpResponseError(response=_response(Status.BAD_REQUEST)) + assert err.read_body() == b"" + + +def test_read_body_returns_empty_when_no_response() -> None: + err = HttpResponseError("no response captured") + assert err.read_body() == b"" + + +def test_read_body_rejects_negative_max_bytes() -> None: + body = ResponseBody.from_bytes(b"data") + err = HttpResponseError(response=_response(Status.BAD_REQUEST, body=body)) + with pytest.raises(ValueError, match="max_bytes must be non-negative"): + err.read_body(-1) + + # ----- body_snapshot ------------------------------------------------------ +@pytest.mark.req("BODY-33") def test_body_snapshot_previews_loggable_body_without_consuming() -> None: loggable = LoggableResponseBody( ResponseBody.from_bytes(b'{"error":"boom"}', MediaType.parse("application/json")), @@ -99,6 +249,7 @@ def test_body_snapshot_previews_loggable_body_without_consuming() -> None: assert loggable.bytes() == b'{"error":"boom"}' +@pytest.mark.req("XCUT-24") def test_body_snapshot_truncates_to_max_bytes() -> None: loggable = LoggableResponseBody(ResponseBody.from_bytes(b"0123456789")) err = HttpResponseError(response=_response(Status.BAD_REQUEST, body=loggable)) @@ -106,6 +257,7 @@ def test_body_snapshot_truncates_to_max_bytes() -> None: assert err.body_snapshot(4) == b"0123" +@pytest.mark.req("BODY-33", "XCUT-24") def test_body_snapshot_returns_empty_for_single_use_body() -> None: # A plain bytes-backed body is single-use; previewing it must not drain it. body = ResponseBody.from_bytes(b"unsafe-to-peek") @@ -116,6 +268,7 @@ def test_body_snapshot_returns_empty_for_single_use_body() -> None: assert body.bytes() == b"unsafe-to-peek" +@pytest.mark.req("BODY-33") def test_body_snapshot_returns_empty_when_no_body() -> None: err = HttpResponseError(response=_response(Status.BAD_REQUEST)) assert err.body_snapshot() == b"" diff --git a/packages/dexpace-sdk-core/tests/errors/test_hierarchy.py b/packages/dexpace-sdk-core/tests/errors/test_hierarchy.py index 18ffff4..794a708 100644 --- a/packages/dexpace-sdk-core/tests/errors/test_hierarchy.py +++ b/packages/dexpace-sdk-core/tests/errors/test_hierarchy.py @@ -15,7 +15,9 @@ DecodeError, DeserializationError, HttpResponseError, + NetworkError, PipelineAbortedError, + RequestCancelledError, ResourceExistsError, ResourceNotFoundError, ResponseNotReadError, @@ -79,9 +81,10 @@ def test_continuation_token_propagates() -> None: def test_sdk_error_rejects_extra_positional_args() -> None: with pytest.raises(TypeError): - SdkError("msg", "extra") # type: ignore[misc,arg-type] + SdkError("msg", "extra") # type: ignore[call-arg,arg-type] +@pytest.mark.req("XCUT-4") def test_http_response_error_carries_status_and_response() -> None: response = _response(Status.NOT_FOUND) err = HttpResponseError(response=response) @@ -102,6 +105,100 @@ def test_request_response_errors_are_distinct() -> None: assert issubclass(ServiceResponseTimeoutError, ServiceResponseError) +# ----- transport branch: NetworkError(OSError) ---------------------------- + + +@pytest.mark.req("XCUT-4") +def test_network_error_is_both_sdk_error_and_os_error() -> None: + # NetworkError joins the SDK root and OSError so ``except OSError:`` sites + # (including user code) keep matching transport-level failures. + assert issubclass(NetworkError, SdkError) + assert issubclass(NetworkError, OSError) + + +def test_service_request_error_is_a_network_error() -> None: + # "Request never reached the service" is the transport / never-got-a-response + # branch, so it lives under NetworkError and is therefore an OSError. + assert issubclass(ServiceRequestError, NetworkError) + assert issubclass(ServiceRequestError, OSError) + assert issubclass(ServiceRequestTimeoutError, NetworkError) + + +@pytest.mark.req("XCUT-4") +def test_service_response_error_stays_off_the_network_branch() -> None: + # A response *was* received (just unparseable); it is not the + # never-got-a-response branch, so it must not be a NetworkError and must + # not be always-retryable. + assert not issubclass(ServiceResponseError, NetworkError) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: NetworkError("boom"), + lambda: ServiceRequestError("connect refused"), + lambda: ServiceRequestTimeoutError("connect timed out"), + lambda: RequestCancelledError("cancelled"), + ], + ids=[ + "NetworkError", + "ServiceRequestError", + "ServiceRequestTimeoutError", + "RequestCancelledError", + ], +) +def test_except_oserror_catches_transport_and_cancel(factory: object) -> None: + with pytest.raises(OSError): + raise factory() # type: ignore[operator] + + +@pytest.mark.req("RETRY-24", "RETRY-4", "XCUT-2", "XCUT-4") +def test_network_error_is_always_retryable_at_class_level() -> None: + # Transport failures that never got a response are always retryable at the + # error-class level — distinct from the per-status classifier. + assert NetworkError("boom").retryable is True + assert ServiceRequestError("connect refused").retryable is True + assert ServiceRequestTimeoutError("connect timed out").retryable is True + + +@pytest.mark.req("XCUT-1") +def test_request_cancelled_is_oserror_but_not_network_or_retryable() -> None: + cancel = RequestCancelledError("cancelled") + assert isinstance(cancel, OSError) + assert isinstance(cancel, SdkError) + # Cancellation is terminal: not a NetworkError and never retryable. + assert not isinstance(cancel, NetworkError) + assert cancel.retryable is False + + +@pytest.mark.req("RETRY-24", "XCUT-2") +def test_timeout_and_cancellation_discriminated_by_class_not_message() -> None: + # Identical message text on purpose: discrimination must be by type only. + timeout = ServiceRequestTimeoutError("deadline exceeded") + cancel = RequestCancelledError("deadline exceeded") + assert str(timeout) == str(cancel) + # Neither is an ancestor of the other, so a subtype-first check (timeout + # before cancellation) never has one swallow the other. + assert not issubclass(ServiceRequestTimeoutError, RequestCancelledError) + assert not issubclass(RequestCancelledError, ServiceRequestTimeoutError) + assert isinstance(timeout, ServiceRequestTimeoutError) + assert not isinstance(timeout, RequestCancelledError) + assert isinstance(cancel, RequestCancelledError) + assert not isinstance(cancel, ServiceRequestTimeoutError) + # The retryable timeout is checked (and wins) before the terminal cancel. + assert timeout.retryable is True + assert cancel.retryable is False + + +@pytest.mark.req("XCUT-2") +def test_sdk_timeout_type_is_not_the_builtin_timeouterror() -> None: + # The SDK's mapping is explicit: a NetworkError-branch timeout is an OSError + # by design, not by coincidental reuse of Python's builtin ``TimeoutError`` + # (which is itself an OSError since 3.10). + assert not issubclass(ServiceRequestTimeoutError, TimeoutError) + assert not issubclass(RequestCancelledError, TimeoutError) + + def test_resource_errors_extend_http_response_error() -> None: assert issubclass(ResourceExistsError, HttpResponseError) assert issubclass(ResourceNotFoundError, HttpResponseError) diff --git a/packages/dexpace-sdk-core/tests/http/test_async_cancellation.py b/packages/dexpace-sdk-core/tests/http/test_async_cancellation.py index 12a0f27..81600f8 100644 --- a/packages/dexpace-sdk-core/tests/http/test_async_cancellation.py +++ b/packages/dexpace-sdk-core/tests/http/test_async_cancellation.py @@ -70,6 +70,7 @@ async def consume() -> None: assert stream.close_completed is True +@pytest.mark.req("BODY-8") async def test_request_aiter_bytes_releases_stream_and_propagates_when_cancelled() -> None: # The request-side stream body must shield its close just like the # response side, so a cancel mid-read still releases the upstream stream. diff --git a/packages/dexpace-sdk-core/tests/http/test_async_loggable_response_body.py b/packages/dexpace-sdk-core/tests/http/test_async_loggable_response_body.py new file mode 100644 index 0000000..a3e5018 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_async_loggable_response_body.py @@ -0,0 +1,74 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for ``AsyncLoggableResponseBody`` (async twin of the sync capture).""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.http.response import AsyncLoggableResponseBody, AsyncResponseBody + + +async def _collect(body: AsyncResponseBody, chunk_size: int = 64 * 1024) -> bytes: + chunks = [chunk async for chunk in body.aiter_bytes(chunk_size)] + return b"".join(chunks) + + +class TestAsyncLoggableResponseBody: + async def test_snapshot_returns_full_payload(self) -> None: + wrapped = AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b"hello world")) + assert await wrapped.snapshot() == b"hello world" + + async def test_iter_bytes_repeatable_after_snapshot(self) -> None: + # Fits-cap: the whole body is captured, so reads are fully repeatable. + wrapped = AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b"abc")) + _ = await wrapped.snapshot() + assert await _collect(wrapped) == b"abc" + assert await _collect(wrapped) == b"abc" + + async def test_snapshot_bounded_by_max_bytes(self) -> None: + wrapped = AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b"abcdefghij")) + assert await wrapped.snapshot(4) == b"abcd" + + async def test_exceeds_cap_streams_full_body_but_caps_snapshot(self) -> None: + # Exceeds-cap: snapshot keeps only the prefix, yet the reader still gets + # every byte (prefix + streamed tail). + wrapped = AsyncLoggableResponseBody( + AsyncResponseBody.from_bytes(b"abcdefghij"), max_capture_bytes=4 + ) + assert await wrapped.snapshot() == b"abcd" + assert await _collect(wrapped) == b"abcdefghij" + + async def test_exceeds_cap_tail_is_single_use(self) -> None: + wrapped = AsyncLoggableResponseBody( + AsyncResponseBody.from_bytes(b"abcdefghij"), max_capture_bytes=4 + ) + assert await _collect(wrapped) == b"abcdefghij" + with pytest.raises(RuntimeError, match="single-use"): + await _collect(wrapped) + + async def test_invalid_cap_raises(self) -> None: + with pytest.raises(ValueError, match="positive"): + AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b""), max_capture_bytes=0) + + async def test_close_is_idempotent(self) -> None: + wrapped = AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b"x")) + await wrapped.close() + await wrapped.close() + + async def test_snapshot_negative_max_bytes_raises(self) -> None: + wrapped = AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b"x")) + with pytest.raises(ValueError, match="non-negative"): + await wrapped.snapshot(-1) + + @pytest.mark.parametrize("size", [0, -1]) + async def test_aiter_bytes_rejects_invalid_chunk_size(self, size: int) -> None: + wrapped = AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b"abcdef")) + with pytest.raises(ValueError, match="chunk_size"): + await _collect(wrapped, size) + + async def test_content_length_reports_captured_size_when_fully_captured(self) -> None: + wrapped = AsyncLoggableResponseBody(AsyncResponseBody.from_bytes(b"abcdef")) + _ = await wrapped.snapshot() + assert wrapped.content_length() == 6 diff --git a/packages/dexpace-sdk-core/tests/http/test_etag.py b/packages/dexpace-sdk-core/tests/http/test_etag.py index 7b991d7..a939a0e 100644 --- a/packages/dexpace-sdk-core/tests/http/test_etag.py +++ b/packages/dexpace-sdk-core/tests/http/test_etag.py @@ -58,6 +58,7 @@ def test_parse_embedded_space_raises() -> None: ETag.parse('"a b"') +@pytest.mark.req("HTTP-48") def test_parse_empty_strong_etag_raises() -> None: with pytest.raises(ValueError, match="empty"): ETag.parse('""') @@ -92,3 +93,90 @@ def test_weak_comparison() -> None: assert weak.matches_weak(weak) different = ETag(value="y") assert not a.matches_weak(different) + + +# --- Conformance battery (HTTP-48; RFC 7232 §2.3, §2.3.2) -------------------- + + +@pytest.mark.req("HTTP-48") +class TestStrongComparison: + """RFC 7232 §2.3.2: strong compare requires exact match and both strong.""" + + def test_two_strong_equal_match(self) -> None: + assert ETag(value="x").matches_strong(ETag(value="x")) + + def test_strong_differing_values_do_not_match(self) -> None: + assert not ETag(value="x").matches_strong(ETag(value="y")) + + def test_weak_vs_strong_never_strong_matches_either_order(self) -> None: + strong = ETag(value="x") + weak = ETag(value="x", weak=True) + assert not strong.matches_strong(weak) + assert not weak.matches_strong(strong) + + def test_two_weak_do_not_strong_match_even_if_equal(self) -> None: + assert not ETag(value="x", weak=True).matches_strong(ETag(value="x", weak=True)) + + def test_strong_match_is_symmetric(self) -> None: + a, b = ETag(value="x"), ETag(value="x") + assert a.matches_strong(b) == b.matches_strong(a) + + +class TestWeakComparison: + """RFC 7232 §2.3.2: weak compare ignores the weak flag on either side.""" + + @pytest.mark.parametrize( + "left,right", + [ + (ETag(value="x"), ETag(value="x")), + (ETag(value="x"), ETag(value="x", weak=True)), + (ETag(value="x", weak=True), ETag(value="x")), + (ETag(value="x", weak=True), ETag(value="x", weak=True)), + ], + ) + def test_equal_values_weak_match_regardless_of_flags(self, left: ETag, right: ETag) -> None: + assert left.matches_weak(right) + + def test_differing_values_never_weak_match(self) -> None: + assert not ETag(value="x").matches_weak(ETag(value="y", weak=True)) + + +class TestEqualityDistinctFromComparison: + """Dataclass ``==`` includes the weak flag; RFC comparison helpers do not.""" + + def test_dataclass_equality_considers_weak_flag(self) -> None: + assert ETag(value="x") == ETag(value="x") + assert ETag(value="x") != ETag(value="x", weak=True) + + def test_weak_match_holds_where_dataclass_equality_does_not(self) -> None: + strong = ETag(value="x") + weak = ETag(value="x", weak=True) + assert strong != weak # different objects by dataclass rules ... + assert strong.matches_weak(weak) # ... yet weak-equivalent per RFC + + +class TestParseCharacterSet: + """RFC 7232 §2.3: etagc = %x21 / %x23-7E / obs-text (%x80-FF).""" + + @pytest.mark.req("HTTP-48") + def test_obs_text_high_bytes_permitted(self) -> None: + # 0x80 and 0xFF are obs-text and stay valid inside the opaque tag. + tag = ETag.parse('"a\x80\xffb"') + assert tag.value == "a\x80\xffb" + + @pytest.mark.req("HTTP-48") + def test_del_char_rejected(self) -> None: + with pytest.raises(ValueError): + ETag.parse('"a\x7fb"') + + def test_surrounding_whitespace_stripped(self) -> None: + assert ETag.parse(' "abc" ') == ETag(value="abc") + + def test_weak_indicator_is_case_sensitive(self) -> None: + # RFC 7232 §2.3: the weak indicator is the exact bytes "W/"; a + # lower-case "w/" is not a valid weak prefix. + with pytest.raises(ValueError): + ETag.parse('w/"abc"') + + def test_single_character_value_valid(self) -> None: + assert ETag.parse('"a"').value == "a" diff --git a/packages/dexpace-sdk-core/tests/http/test_form_encoding.py b/packages/dexpace-sdk-core/tests/http/test_form_encoding.py new file mode 100644 index 0000000..8da108c --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_form_encoding.py @@ -0,0 +1,99 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Corpus vectors for the strict ``x-www-form-urlencoded`` codec used by +``RequestBody.from_form`` / ``AsyncRequestBody.from_form``. + +The codec must percent-encode strictly (space -> ``%20``, never ``+``) so a +literal ``+`` and a space are never conflated, and every reserved delimiter is +escaped. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from dexpace.sdk.core.http.common import common_media_types +from dexpace.sdk.core.http.request import AsyncRequestBody, RequestBody + + +def _drain(body: RequestBody) -> bytes: + return b"".join(body.iter_bytes()) + + +def _adrain(body: AsyncRequestBody) -> bytes: + async def run() -> bytes: + return b"".join([chunk async for chunk in body.aiter_bytes()]) + + return asyncio.run(run()) + + +# One field per case so the expected encoding is unambiguous. +_CORPUS: list[tuple[str, str, bytes]] = [ + ("space encoded as %20", "k", b"a b"), # -> a%20b (never a+b) + ("literal plus escaped", "k", b"a+b"), # -> a%2Bb + ("ampersand escaped", "k", b"a&b"), + ("equals escaped", "k", b"a=b"), + ("slash escaped", "k", b"a/b"), + ("question mark escaped", "k", b"a?b"), + ("hash escaped", "k", b"a#b"), + ("percent escaped", "k", b"100%"), + ("unicode via utf-8", "k", b"na\xc3\xafve"), # naïve +] + +_EXPECTED = { + b"a b": b"k=a%20b", + b"a+b": b"k=a%2Bb", + b"a&b": b"k=a%26b", + b"a=b": b"k=a%3Db", + b"a/b": b"k=a%2Fb", + b"a?b": b"k=a%3Fb", + b"a#b": b"k=a%23b", + b"100%": b"k=100%25", + b"na\xc3\xafve": b"k=na%C3%AFve", +} + + +@pytest.mark.parametrize("label, key, raw", _CORPUS, ids=[c[0] for c in _CORPUS]) +def test_form_corpus_sync(label: str, key: str, raw: bytes) -> None: + value = raw.decode("utf-8") + body = RequestBody.from_form({key: value}) + out = _drain(body) + assert out == _EXPECTED[raw], label + # The plus-for-space trap must never appear: a space is %20, so a raw '+' + # byte can only ever be an escaped literal, never present unescaped. + if b" " in raw: + assert b"+" not in out + + +@pytest.mark.parametrize("label, key, raw", _CORPUS, ids=[c[0] for c in _CORPUS]) +def test_form_corpus_async_matches_sync(label: str, key: str, raw: bytes) -> None: + value = raw.decode("utf-8") + sync = _drain(RequestBody.from_form({key: value})) + asyncbody = _adrain(AsyncRequestBody.from_form({key: value})) + assert sync == asyncbody == _EXPECTED[raw], label + + +def test_key_is_also_strictly_encoded() -> None: + body = RequestBody.from_form({"a b&c": "1"}) + assert _drain(body) == b"a%20b%26c=1" + + +def test_media_type_is_form_urlencoded() -> None: + body = RequestBody.from_form({"a": "1"}) + assert body.media_type() == common_media_types.APPLICATION_FORM_URLENCODED + + +def test_encoding_charset_changes_percent_bytes() -> None: + fields = {"name": "é"} + latin1 = _drain(RequestBody.from_form(fields, encoding="latin-1")) + utf8 = _drain(RequestBody.from_form(fields, encoding="utf-8")) + assert latin1 == b"name=%E9" + assert utf8 == b"name=%C3%A9" + + +def test_multiple_fields_joined_with_ampersand() -> None: + body = RequestBody.from_form({"a": "1", "b": "2"}) + assert _drain(body) == b"a=1&b=2" diff --git a/packages/dexpace-sdk-core/tests/http/test_headers.py b/packages/dexpace-sdk-core/tests/http/test_headers.py index e509822..fa9a358 100644 --- a/packages/dexpace-sdk-core/tests/http/test_headers.py +++ b/packages/dexpace-sdk-core/tests/http/test_headers.py @@ -12,6 +12,7 @@ class TestCaseInsensitiveLookup: + @pytest.mark.req("HTTP-13") def test_get_matches_regardless_of_case(self) -> None: h = Headers([("Content-Type", "application/json")]) assert h.get("content-type") == "application/json" @@ -41,6 +42,7 @@ def test_getitem_raises_keyerror_when_absent(self) -> None: class TestMultiValue: + @pytest.mark.req("HTTP-14") def test_with_added_appends_to_existing_list(self) -> None: h = Headers([("Set-Cookie", "a=1")]) result = h.with_added("set-cookie", "b=2") @@ -51,6 +53,7 @@ def test_with_added_creates_new_entry_when_absent(self) -> None: result = h.with_added("X-New", "value") assert result.values("x-new") == ("value",) + @pytest.mark.req("HTTP-14") def test_with_set_replaces_existing_values(self) -> None: h = Headers([("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")]) result = h.with_set("set-cookie", "only=one") @@ -67,6 +70,7 @@ def test_get_returns_first_value(self) -> None: class TestImmutability: + @pytest.mark.req("HTTP-5") def test_with_added_returns_new_instance(self) -> None: original = Headers([("A", "1")]) new = original.with_added("B", "2") @@ -84,6 +88,7 @@ def test_without_is_noop_when_absent(self) -> None: result = original.without("missing") assert result is original + @pytest.mark.req("HTTP-1") def test_setattr_raises(self) -> None: h = Headers() with pytest.raises(AttributeError): @@ -175,11 +180,13 @@ def test_of_derives_lower(self) -> None: class TestValidation: + @pytest.mark.req("HTTP-17") @pytest.mark.parametrize("name", ["X-Bad\r\n", " X-Bad ", "X Bad", ""]) def test_invalid_header_name_rejected(self, name: str) -> None: with pytest.raises(ValueError, match="invalid header name"): Headers([(name, "v")]) + @pytest.mark.req("HTTP-18") @pytest.mark.parametrize("value", ["v\r\nLoc: bad", "v\nbad", "v\0bad"]) def test_invalid_header_value_rejected(self, value: str) -> None: with pytest.raises(ValueError, match="invalid header value"): @@ -187,6 +194,7 @@ def test_invalid_header_value_rejected(self, value: str) -> None: class TestRepr: + @pytest.mark.req("XCUT-19") @pytest.mark.parametrize( "name", ["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"] ) diff --git a/packages/dexpace-sdk-core/tests/http/test_headers_conformance.py b/packages/dexpace-sdk-core/tests/http/test_headers_conformance.py new file mode 100644 index 0000000..e039a2a --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_headers_conformance.py @@ -0,0 +1,257 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Conformance battery for ``Headers``: canonical fold, multimap, dual paths. + +Certifies the behaviours required of an HTTP header model: + +* lower-case canonical storage with ASCII-invariant, locale-independent folding; +* case-folding lookup, membership, and equality; +* ordered multi-values (the ``get_all`` equivalent); +* removal via a "set to no values" call; +* outbound-strict vs inbound-lenient obs-text handling, kept distinct; +* rejection of CR / LF / NUL header injection on every path; +* error messages that never echo header *values* and escape control characters + in header *names*. + +``hypothesis`` is not a dev dependency of this workspace, so the fold-idempotence +and equality-iff-fold-equality properties are exercised with a seeded, generative +casing sweep that stands in for a property-based test. +""" + +from __future__ import annotations + +import random +import string + +import pytest + +from dexpace.sdk.core.http.common import Headers, HttpHeaderName +from dexpace.sdk.core.http.common.http_header_name import CONTENT_TYPE, X_REQUEST_ID + +# Valid RFC 7230 token names used across the generative sweeps. +_NAMES = ( + "content-type", + "x-request-id", + "accept", + "cache-control", + "x-custom-header", + "etag", + "via", + "www-authenticate", +) + +# A representative obs-text value: bytes 0x80-0xFF decoded as Latin-1, exactly +# what a transport hands back when a server sends a non-ASCII header value. +_OBS_TEXT = "caf\xe9-\x80-\xff" + +_INJECTIONS = ("v\r\nLocation: evil", "v\nsplit", "v\0nul") + +# Non-ASCII names whose naive ``str.lower()`` would either fold to an ASCII +# token (Kelvin sign) or expand via locale-independent special-casing (dotted / +# dotless I). Written as escapes so the source stays pure ASCII (no RUF001). +_NON_ASCII_NAMES = ( + "\u212a-header", # U+212A KELVIN SIGN: str.lower() folds it to "k" + "\u0130-header", # U+0130 dotted capital I -> "i" + combining mark + "\u0131-header", # U+0131 dotless small i (the Turkish trap) + "caf\xe9", # any non-ASCII letter +) + + +def _random_casing(name: str, rng: random.Random) -> str: + """Return ``name`` with each ASCII letter independently upper/lower-cased.""" + return "".join(c.upper() if rng.random() < 0.5 else c.lower() for c in name) + + +class TestCanonicalFoldStorage: + def test_names_stored_lower_and_iteration_yields_canonical(self) -> None: + rng = random.Random(1) + for base in _NAMES: + for _ in range(20): + cased = _random_casing(base, rng) + h = Headers([(cased, "v")]) + assert h.names() == (base,) + assert list(h) == [base] + assert h.get(cased.upper()) == "v" + + def test_fold_is_idempotent(self) -> None: + rng = random.Random(2) + for base in _NAMES: + for _ in range(20): + cased = _random_casing(base, rng) + once = Headers([(cased, "v")]).names()[0] + twice = Headers([(once, "v")]).names()[0] + assert once == twice == base + + @pytest.mark.req("HTTP-13") + def test_fold_is_ascii_and_locale_invariant(self) -> None: + # Every ASCII letter folds to its a-z counterpart regardless of casing. + for letter in string.ascii_letters: + stored = Headers([(f"x-{letter}", "v")]).names()[0] + assert stored == f"x-{letter.lower()}" + assert stored.isascii() + + +class TestEqualityFollowsFold: + def test_equal_iff_names_fold_equal(self) -> None: + rng = random.Random(3) + for base in _NAMES: + for _ in range(30): + a = Headers([(_random_casing(base, rng), "v")]) + b = Headers([(_random_casing(base, rng), "v")]) + # Same fold + same value -> equal and hash-consistent. + assert a == b + assert hash(a) == hash(b) + + def test_distinct_names_not_equal(self) -> None: + a = Headers([("content-type", "v")]) + b = Headers([("x-request-id", "v")]) + assert a != b + + def test_same_fold_distinct_value_not_equal(self) -> None: + a = Headers([("Content-Type", "a")]) + b = Headers([("content-type", "b")]) + assert a != b + assert a.names() == b.names() + + +class TestOrderedMultiValues: + @pytest.mark.req("HTTP-14", "HTTP-5") + def test_get_all_equivalent_preserves_order(self) -> None: + h = Headers([("Set-Cookie", "a=1"), ("Set-Cookie", "b=2"), ("Set-Cookie", "c=3")]) + assert h.values("set-cookie") == ("a=1", "b=2", "c=3") + + @pytest.mark.req("HTTP-16") + def test_distinct_names_keep_insertion_order(self) -> None: + h = Headers([("B", "1"), ("A", "2"), ("C", "3")]) + assert h.names() == ("b", "a", "c") + + +class TestRemovalBySettingNoValues: + @pytest.mark.req("HTTP-15") + def test_with_set_no_values_removes_header(self) -> None: + h = Headers([("A", "1"), ("B", "2")]) + result = h.with_set("a") + assert "a" not in result + assert result.names() == ("b",) + + def test_with_set_no_values_is_noop_when_absent(self) -> None: + h = Headers([("A", "1")]) + assert h.with_set("missing") is h + + def test_outbound_with_set_no_values_removes_header(self) -> None: + h = Headers.outbound([("A", "1"), ("B", "2")]) + assert h.with_set("B").names() == ("a",) + + +class TestInboundLenientObsText: + def test_default_constructor_preserves_obs_text(self) -> None: + h = Headers([("X-Obs", _OBS_TEXT)]) + assert h.get("x-obs") == _OBS_TEXT + + def test_mutators_preserve_obs_text(self) -> None: + h = Headers().with_added("X-Obs", _OBS_TEXT).with_set("X-Other", _OBS_TEXT) + assert h.values("x-obs") == (_OBS_TEXT,) + assert h.values("x-other") == (_OBS_TEXT,) + + @pytest.mark.req("HTTP-19", "XCUT-18") + @pytest.mark.parametrize("value", _INJECTIONS) + def test_inbound_still_rejects_crlf_injection(self, value: str) -> None: + # Lenient about obs-text, but never about the injection-critical bytes. + with pytest.raises(ValueError, match="invalid header value"): + Headers([("X-Test", value)]) + + +class TestOutboundStrictObsText: + def test_outbound_accepts_clean_ascii(self) -> None: + h = Headers.outbound([("Content-Type", "application/json")]) + assert h.get("content-type") == "application/json" + + @pytest.mark.req("HTTP-18") + def test_outbound_rejects_obs_text(self) -> None: + with pytest.raises(ValueError, match="obs-text"): + Headers.outbound([("X-Obs", _OBS_TEXT)]) + + @pytest.mark.req("HTTP-18", "XCUT-18") + @pytest.mark.parametrize("value", ["v\x01ctl", "v\x7fdel", "v\x1fus"]) + def test_outbound_rejects_other_control_bytes(self, value: str) -> None: + with pytest.raises(ValueError, match="invalid header value"): + Headers.outbound([("X-Test", value)]) + + @pytest.mark.parametrize("value", _INJECTIONS) + def test_outbound_rejects_crlf_injection(self, value: str) -> None: + with pytest.raises(ValueError, match="invalid header value"): + Headers.outbound([("X-Test", value)]) + + @pytest.mark.req("HTTP-19") + def test_paths_diverge_only_on_obs_text(self) -> None: + # The exact value the lenient path accepts is what the strict path bars. + assert Headers([("X-Obs", _OBS_TEXT)]).get("x-obs") == _OBS_TEXT + with pytest.raises(ValueError): + Headers.outbound([("X-Obs", _OBS_TEXT)]) + + +class TestErrorMessageHygiene: + @pytest.mark.req("HTTP-20") + def test_value_error_never_echoes_the_value(self) -> None: + secret = "topsecret-token\r\nSet-Cookie: sid=evil" + with pytest.raises(ValueError) as exc: + Headers([("Authorization", secret)]) + assert "topsecret-token" not in str(exc.value) + + def test_strict_value_error_never_echoes_the_value(self) -> None: + with pytest.raises(ValueError) as exc: + Headers.outbound([("X-Test", "secret\x80payload")]) + message = str(exc.value) + assert "secret" not in message + assert "payload" not in message + + @pytest.mark.req("HTTP-20") + def test_name_error_escapes_control_characters(self) -> None: + with pytest.raises(ValueError) as exc: + Headers([("X-\r\nInjected", "v")]) + message = str(exc.value) + # No raw control bytes leak into the message; they appear escaped. + assert "\r" not in message + assert "\n" not in message + assert "\\r" in message + assert "\\n" in message + + +class TestAsciiInvariantFolding: + @pytest.mark.req("HTTP-13", "HTTP-17", "XCUT-18") + @pytest.mark.parametrize("name", _NON_ASCII_NAMES) + def test_non_ascii_names_rejected_not_folded(self, name: str) -> None: + # A locale-sensitive ``str.lower()`` before validation would let some of + # these masquerade as ASCII tokens; validating the original rejects them. + with pytest.raises(ValueError, match="invalid header name"): + Headers([(name, "v")]) + + def test_ascii_capital_i_folds_to_plain_i(self) -> None: + # Never to a dotless-i; the fold is ASCII, not Turkish-locale. + assert Headers([("I-HEADER", "v")]).names() == ("i-header",) + + +class TestHttpHeaderNameHotPath: + def test_constant_key_stored_as_lower(self) -> None: + h = Headers([(CONTENT_TYPE, "application/json")]) + assert h.names() == ("content-type",) + assert CONTENT_TYPE in h + + @pytest.mark.req("HTTP-21") + def test_constant_preserves_wire_casing(self) -> None: + # The stored key folds, but the constant keeps its wire form for output. + assert CONTENT_TYPE.value == "content-type" + assert CONTENT_TYPE.canonical_name == "Content-Type" + assert str(CONTENT_TYPE) == "Content-Type" + assert str(X_REQUEST_ID) == "X-Request-Id" + + def test_constant_bypasses_token_validation(self) -> None: + # An HttpHeaderName is trusted: its precomputed ``value`` is used verbatim + # (skipping the ``.lower()`` and token checks the raw-string path runs). + trusted = HttpHeaderName(value="x-trusted", canonical_name="X-Trusted") + assert Headers([(trusted, "v")]).get(trusted) == "v" + + def test_outbound_accepts_constant_names(self) -> None: + h = Headers.outbound([(CONTENT_TYPE, "text/plain")]) + assert h.get(CONTENT_TYPE) == "text/plain" diff --git a/packages/dexpace-sdk-core/tests/http/test_headers_injection.py b/packages/dexpace-sdk-core/tests/http/test_headers_injection.py index a6b54d9..d4fa48b 100644 --- a/packages/dexpace-sdk-core/tests/http/test_headers_injection.py +++ b/packages/dexpace-sdk-core/tests/http/test_headers_injection.py @@ -59,6 +59,7 @@ def test_non_lowercase_value_normalised_to_lower(self) -> None: name = HttpHeaderName(value="Content-Type", canonical_name="Content-Type") assert name.value == "content-type" + @pytest.mark.req("HTTP-21") def test_non_lowercase_name_matches_lookups(self) -> None: name = HttpHeaderName(value="Content-Type", canonical_name="Content-Type") h = Headers([("content-type", "application/json")]) @@ -66,6 +67,7 @@ def test_non_lowercase_name_matches_lookups(self) -> None: assert name in h assert h.values(name) == ("application/json",) + @pytest.mark.req("HTTP-21", "HTTP-22") def test_non_lowercase_name_equals_lowercase_twin(self) -> None: upper = HttpHeaderName(value="Content-Type", canonical_name="Content-Type") lower = HttpHeaderName(value="content-type", canonical_name="Content-Type") diff --git a/packages/dexpace-sdk-core/tests/http/test_headers_properties.py b/packages/dexpace-sdk-core/tests/http/test_headers_properties.py new file mode 100644 index 0000000..4552685 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_headers_properties.py @@ -0,0 +1,87 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Property-based tests pinning ``Headers`` folding and equality algebra. + +Invariants covered: + +- Name-fold idempotence: names are stored in lower-case canonical form, and + reconstructing a ``Headers`` from its own ``items()`` is the identity. +- Equality is exactly fold-equality: two ``Headers`` compare equal iff their + normalised ``(name, values)`` data is equal, so header-name case never + affects equality, and ``__hash__`` agrees with ``__eq__``. + +The example count and deadline are governed by the capped "ci" Hypothesis +profile registered in ``tests/conftest.py``. +""" + +from __future__ import annotations + +from string import ascii_lowercase, ascii_uppercase + +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.http.common import Headers + +# RFC 7230 ``tchar`` set (both cases accepted on input; stored lower-cased). +_HEADER_NAME_CHARS = "!#$%&'*+-.^_`|~0123456789" + ascii_lowercase + ascii_uppercase + +_header_names = st.text(alphabet=_HEADER_NAME_CHARS, min_size=1, max_size=20) +# Header values may hold any text except the CR / LF / NUL bytes the model +# rejects to prevent header injection. +_header_values = st.text( + st.characters(codec="utf-8", exclude_characters="\r\n\x00"), + max_size=40, +) +_entries = st.lists(st.tuples(_header_names, _header_values), max_size=12) + + +@given(entries=_entries) +def test_reconstruction_from_items_is_identity(entries: list[tuple[str, str]]) -> None: + """Rebuilding a ``Headers`` from its own ``items()`` yields an equal value. + + Pins that normalisation (name lower-casing, value validation) is idempotent: + feeding already-normalised data back through the constructor is a fixed + point, and equal values hash equally. + """ + headers = Headers(entries) + rebuilt = Headers(list(headers.items())) + assert rebuilt == headers + assert hash(rebuilt) == hash(headers) + + +@given(entries=_entries) +def test_stored_names_are_lowercase(entries: list[tuple[str, str]]) -> None: + """Every stored header name equals its own lower-casing (fold idempotence).""" + headers = Headers(entries) + for name in headers.names(): + assert name == name.lower() + + +@given(entries=_entries) +def test_name_case_does_not_affect_equality(entries: list[tuple[str, str]]) -> None: + """Recasing input names produces an equal ``Headers`` (equality folds case).""" + base = Headers(entries) + upper = Headers([(name.upper(), value) for name, value in entries]) + swapped = Headers([(name.swapcase(), value) for name, value in entries]) + assert upper == base + assert swapped == base + assert hash(upper) == hash(base) == hash(swapped) + + +@given(left=_entries, right=_entries) +def test_equality_is_exactly_fold_equality( + left: list[tuple[str, str]], + right: list[tuple[str, str]], +) -> None: + """Two ``Headers`` are equal iff their normalised ``items()`` match. + + Also pins the ``__eq__`` / ``__hash__`` contract: equal instances hash + equally. + """ + first = Headers(left) + second = Headers(right) + assert (first == second) == (first.items() == second.items()) + if first == second: + assert hash(first) == hash(second) diff --git a/packages/dexpace-sdk-core/tests/http/test_http_range.py b/packages/dexpace-sdk-core/tests/http/test_http_range.py index 232b1dc..fe595ca 100644 --- a/packages/dexpace-sdk-core/tests/http/test_http_range.py +++ b/packages/dexpace-sdk-core/tests/http/test_http_range.py @@ -63,3 +63,69 @@ def test_multi_range_header() -> None: def test_format_many_empty_raises() -> None: with pytest.raises(ValueError): HttpRange.format_many(()) + + +# --- Conformance battery (HTTP-49; RFC 7233 §2.1) --------------------------- + + +@pytest.mark.req("HTTP-49") +class TestFormatShapes: + """``format`` renders the value portion; ``to_header_value`` adds ``bytes=``.""" + + @pytest.mark.req("HTTP-49") + def test_bounded_format_omits_prefix(self) -> None: + assert HttpRange(0, 100).format() == "0-99" + + def test_open_ended_format(self) -> None: + assert HttpRange(50).format() == "50-" + + @pytest.mark.req("HTTP-49") + def test_suffix_format(self) -> None: + assert HttpRange.suffix(20).format() == "-20" + + def test_single_byte_range(self) -> None: + r = HttpRange(5, 1) + assert r.end == 5 + assert r.to_header_value() == "bytes=5-5" + + def test_bounded_from_nonzero_start(self) -> None: + assert HttpRange(200, 100).to_header_value() == "bytes=200-299" + + +class TestEndProperty: + def test_end_of_bounded(self) -> None: + assert HttpRange(0, 100).end == 99 + + def test_end_of_open_ended_is_none(self) -> None: + assert HttpRange(50).end is None + + def test_end_of_suffix_is_none(self) -> None: + assert HttpRange.suffix(20).end is None + + +class TestConstructionValidation: + def test_suffix_constructed_directly(self) -> None: + r = HttpRange(count=5, is_suffix=True) + assert r.format() == "-5" + + def test_suffix_without_count_raises(self) -> None: + with pytest.raises(ValueError): + HttpRange(is_suffix=True) + + @pytest.mark.req("HTTP-49") + def test_negative_count_raises(self) -> None: + with pytest.raises(ValueError): + HttpRange(0, -3) + + def test_suffix_negative_raises(self) -> None: + with pytest.raises(ValueError): + HttpRange.suffix(-5) + + +class TestFormatMany: + def test_single_range(self) -> None: + assert HttpRange.format_many([HttpRange(0, 100)]) == "bytes=0-99" + + def test_mixed_bounded_open_and_suffix(self) -> None: + ranges = [HttpRange(0, 100), HttpRange(500), HttpRange.suffix(50)] + assert HttpRange.format_many(ranges) == "bytes=0-99,500-,-50" diff --git a/packages/dexpace-sdk-core/tests/http/test_loggable_body_fixes.py b/packages/dexpace-sdk-core/tests/http/test_loggable_body_fixes.py index 5aae5e6..491cc5f 100644 --- a/packages/dexpace-sdk-core/tests/http/test_loggable_body_fixes.py +++ b/packages/dexpace-sdk-core/tests/http/test_loggable_body_fixes.py @@ -33,6 +33,7 @@ def __init__(self, chunks: list[bytes], error: BaseException) -> None: self._error = error self._consumed = False self.closed = False + self.iter_calls = 0 def media_type(self) -> MediaType | None: return None @@ -41,9 +42,13 @@ def content_length(self) -> int: return -1 def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + self.iter_calls += 1 if self._consumed: raise RuntimeError("ResponseBody has already been consumed") self._consumed = True + return self._emit() + + def _emit(self) -> Iterator[bytes]: yield from self._chunks raise self._error @@ -83,7 +88,49 @@ def close(self) -> None: return None +class _ChunkedResponseBody(ResponseBody): + """A single-use body yielding fixed chunks, with tunable metadata. + + Tracks how often it is iterated and whether it was closed. The declared + content length and an optional ``close`` failure are configurable so the + two-regime drain can be exercised without a real transport. + """ + + def __init__( + self, + chunks: list[bytes], + *, + declared_length: int = -1, + close_error: BaseException | None = None, + ) -> None: + self._chunks = chunks + self._declared_length = declared_length + self._close_error = close_error + self._consumed = False + self.closed = False + self.iter_calls = 0 + + def media_type(self) -> MediaType | None: + return None + + def content_length(self) -> int: + return self._declared_length + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + self.iter_calls += 1 + if self._consumed: + raise RuntimeError("ResponseBody has already been consumed") + self._consumed = True + return iter(self._chunks) + + def close(self) -> None: + self.closed = True + if self._close_error is not None: + raise self._close_error + + class TestResponseErrorPath: + @pytest.mark.req("BODY-26") def test_iter_bytes_reraises_stored_error_on_every_call(self) -> None: boom = ConnectionError("network dropped") inner = _FailingResponseBody([b"abc", b"def"], boom) @@ -98,6 +145,7 @@ def test_iter_bytes_reraises_stored_error_on_every_call(self) -> None: list(body.iter_bytes()) assert second.value is boom + @pytest.mark.req("BODY-26", "IO-27") def test_snapshot_returns_partial_bytes_not_empty(self) -> None: inner = _FailingResponseBody([b"abc", b"def"], ConnectionError("drop")) body = LoggableResponseBody(inner) @@ -153,6 +201,7 @@ def test_keyboard_interrupt_propagates_immediately(self) -> None: class TestResponseThreadSafety: + @pytest.mark.req("BODY-22") def test_concurrent_first_read_drains_exactly_once(self) -> None: threads_count = 8 start = threading.Barrier(threads_count) @@ -192,6 +241,7 @@ def test_snapshot_caps_copy(self) -> None: body = LoggableResponseBody(inner) assert body.snapshot(max_bytes=4) == b"0123" + @pytest.mark.req("BODY-32") def test_snapshot_max_bytes_larger_than_body(self) -> None: inner = ResponseBody.from_bytes(b"abc") body = LoggableResponseBody(inner) @@ -202,6 +252,7 @@ def test_snapshot_none_returns_full(self) -> None: body = LoggableResponseBody(inner) assert body.snapshot() == b"abcde" + @pytest.mark.req("BODY-32") def test_snapshot_negative_max_bytes_rejected(self) -> None: inner = ResponseBody.from_bytes(b"abc") body = LoggableResponseBody(inner) @@ -243,6 +294,7 @@ def test_snapshot_after_cap_does_not_block_further_writes(self) -> None: class TestRequestTapResetOnReplay: """A replayed loggable request body captures one copy, not body+body.""" + @pytest.mark.req("BODY-18") def test_second_iteration_does_not_double_the_capture(self) -> None: # A replayable inner body iterated twice (retry / 307 redirect) must # not accumulate ``body + body`` in the tap. Each iter_bytes resets @@ -253,6 +305,7 @@ def test_second_iteration_does_not_double_the_capture(self) -> None: assert body.snapshot() == b"payload" assert body.captured_size == len(b"payload") + @pytest.mark.req("BODY-18") def test_capture_reflects_only_the_latest_attempt(self) -> None: # After many replays the capture is still one copy, never N copies. body = LoggableRequestBody(RequestBody.from_bytes(b"abc")) @@ -261,6 +314,7 @@ def test_capture_reflects_only_the_latest_attempt(self) -> None: assert body.snapshot() == b"abc" assert body.captured_size == 3 + @pytest.mark.req("BODY-18", "IO-27") def test_tap_reset_is_eager_before_first_chunk(self) -> None: # The reset happens at call time, so a fresh (undrained) iterator from # a replay already shows an empty capture rather than the prior copy. @@ -272,3 +326,175 @@ def test_tap_reset_is_eager_before_first_chunk(self) -> None: body.iter_bytes() assert body.captured_size == 0 assert body.snapshot() == b"" + + +_CAP = 8 + + +class TestResponseRegimeBoundary: + """Regime detection at exactly cap-1, cap, and cap+1 bytes.""" + + @pytest.mark.req("BODY-23") + def test_below_cap_is_fully_captured_and_repeatable(self) -> None: + data = b"x" * (_CAP - 1) + inner = _ChunkedResponseBody([data]) + body = LoggableResponseBody(inner, max_capture_bytes=_CAP) + + assert b"".join(body.iter_bytes()) == data + # A fully-captured body serves fresh, repeatable, non-consuming views. + assert b"".join(body.iter_bytes()) == data + assert body.captured_size == _CAP - 1 + # Fits-cap closes the delegate during the drain, not just on wrapper close. + assert inner.closed is True + + def test_at_cap_is_fully_captured_and_repeatable(self) -> None: + data = b"x" * _CAP + inner = _ChunkedResponseBody([data]) + body = LoggableResponseBody(inner, max_capture_bytes=_CAP) + + # EOF reached exactly at the cap: the entire body fit, so reads repeat. + assert b"".join(body.iter_bytes()) == data + assert b"".join(body.iter_bytes()) == data + assert body.captured_size == _CAP + assert inner.closed is True + + @pytest.mark.req("BODY-24", "OBS-36") + def test_above_cap_is_single_use_and_replays_prefix_then_tail(self) -> None: + data = b"x" * (_CAP + 1) + inner = _ChunkedResponseBody([data]) + body = LoggableResponseBody(inner, max_capture_bytes=_CAP) + + # The single read replays the buffered prefix plus the live tail. + assert b"".join(body.iter_bytes()) == data + # Only the prefix up to the cap is buffered. + assert body.captured_size == _CAP + assert body.snapshot() == b"x" * _CAP + # A second read past the cap must fail — genuinely single-use. + with pytest.raises(RuntimeError, match="single-use"): + list(body.iter_bytes()) + + +class TestResponseExceedsCapRegime: + @pytest.mark.req("BODY-24") + def test_drain_leaves_delegate_open_for_the_tail(self) -> None: + inner = _ChunkedResponseBody([b"0123456789"]) + body = LoggableResponseBody(inner, max_capture_bytes=4) + + # snapshot buffers only the prefix and must NOT close the delegate: + # the tail is still live. + assert body.snapshot() == b"0123" + assert inner.closed is False + # The next read replays prefix + the remaining live tail. + assert b"".join(body.iter_bytes()) == b"0123456789" + + def test_replays_prefix_overflow_then_tail_across_chunks(self) -> None: + inner = _ChunkedResponseBody([b"aaaa", b"bbbb", b"cccc"]) + body = LoggableResponseBody(inner, max_capture_bytes=6) + + assert b"".join(body.iter_bytes()) == b"aaaabbbbcccc" + # Only the 6-byte prefix is ever buffered. + assert body.snapshot() == b"aaaabb" + assert body.captured_size == 6 + + +class TestResponseCloseOnce: + @pytest.mark.req("BODY-27", "BODY-28") + def test_full_capture_survives_failing_delegate_close(self) -> None: + # Fits-cap closes the delegate during the drain; a close failure after + # a successful capture is swallowed, not surfaced. + inner = _ChunkedResponseBody([b"payload"], close_error=OSError("close boom")) + body = LoggableResponseBody(inner) + + assert body.snapshot() == b"payload" # no exception despite failing close + assert inner.closed is True + assert body.error() is None + + @pytest.mark.req("BODY-27", "BODY-28") + def test_snapshot_still_works_after_wrapper_close(self) -> None: + inner = _ChunkedResponseBody([b"payload"], close_error=OSError("close boom")) + body = LoggableResponseBody(inner) + + assert body.snapshot() == b"payload" + body.close() # idempotent; the failing delegate close stays swallowed + # The captured buffer must survive the wrapper's own close. + assert body.snapshot() == b"payload" + assert body.snapshot(max_bytes=3) == b"pay" + + +class TestResponseContentLengthRegime: + @pytest.mark.req("BODY-29") + def test_reports_captured_size_when_fully_captured(self) -> None: + inner = _ChunkedResponseBody([b"abcdef"], declared_length=-1) + body = LoggableResponseBody(inner) + + # Before any drain, the delegate's declared length is reported. + assert body.content_length() == -1 + body.snapshot() # fits-cap drain + # After a full capture, the captured size is authoritative. + assert body.content_length() == 6 + + @pytest.mark.req("BODY-29") + def test_reports_declared_length_when_exceeds_cap(self) -> None: + inner = _ChunkedResponseBody([b"0123456789"], declared_length=10) + body = LoggableResponseBody(inner, max_capture_bytes=4) + + body.snapshot() # exceeds-cap drain: only a prefix is captured + # Not fully captured, so the delegate's original declared length wins. + assert body.content_length() == 10 + assert body.captured_size == 4 + + +class TestResponseErrorAccessor: + def test_returns_none_when_drain_succeeds(self) -> None: + inner = _ChunkedResponseBody([b"ok"]) + body = LoggableResponseBody(inner) + + # The accessor is a first-access drain trigger. + assert body.error() is None + assert inner.closed is True + + @pytest.mark.req("BODY-22", "BODY-26") + def test_surfaces_cached_error_without_a_second_drain(self) -> None: + boom = ConnectionError("drop") + inner = _FailingResponseBody([b"ab"], boom) + body = LoggableResponseBody(inner) + + assert body.error() is boom + assert inner.iter_calls == 1 + # Querying again must not re-drain the single-use delegate. + assert body.error() is boom + assert inner.iter_calls == 1 + # The partial prefix is still available for post-mortem logging. + assert body.snapshot() == b"ab" + + +class TestResponseZeroByteNotEof: + def test_interior_empty_chunk_is_not_end_of_stream(self) -> None: + # An empty chunk mid-stream is a contract quirk, never EOF: only the + # iterator's exhaustion ends the drain, so nothing is truncated. + inner = _ChunkedResponseBody([b"ab", b"", b"cd", b"", b"ef"]) + body = LoggableResponseBody(inner) + + assert body.snapshot() == b"abcdef" + assert b"".join(body.iter_bytes()) == b"abcdef" + + def test_interior_empty_chunk_does_not_fool_regime_detection(self) -> None: + # Empty chunks interleaved with real data must not trip the cap check + # into a premature exceeds-cap decision. + inner = _ChunkedResponseBody([b"aa", b"", b"bb", b"cc"]) + body = LoggableResponseBody(inner, max_capture_bytes=4) + + assert b"".join(body.iter_bytes()) == b"aabbcc" + assert body.snapshot() == b"aabb" + + +class TestRequestFullPayloadDespiteCap: + @pytest.mark.req("BODY-17", "BODY-19", "IO-25", "IO-26") + def test_downstream_gets_full_payload_while_tap_is_capped(self) -> None: + # The primary write path always forwards the full payload; only the + # in-memory tap honours the (independently configurable) cap. + body = LoggableRequestBody(RequestBody.from_bytes(b"0123456789"), max_capture_bytes=3) + + assert b"".join(body.iter_bytes()) == b"0123456789" + assert body.snapshot() == b"012" + assert body.captured_size == 3 diff --git a/packages/dexpace-sdk-core/tests/http/test_media_type.py b/packages/dexpace-sdk-core/tests/http/test_media_type.py index 8bc45df..2fdecad 100644 --- a/packages/dexpace-sdk-core/tests/http/test_media_type.py +++ b/packages/dexpace-sdk-core/tests/http/test_media_type.py @@ -26,6 +26,7 @@ def test_multipart_boundary_with_equals(self) -> None: mt = MediaType.parse("multipart/form-data; boundary=abc=def") assert dict(mt.parameters)["boundary"] == "abc=def" + @pytest.mark.req("HTTP-53") def test_blank_value_raises(self) -> None: with pytest.raises(ValueError): MediaType.parse("") @@ -34,6 +35,7 @@ def test_malformed_raises(self) -> None: with pytest.raises(ValueError): MediaType.parse("not-a-media-type") + @pytest.mark.req("HTTP-53") def test_missing_subtype_raises(self) -> None: with pytest.raises(ValueError): MediaType.parse("application/") @@ -60,6 +62,7 @@ def test_lower_cases_type_and_subtype(self) -> None: mt = MediaType.of("APPLICATION", "JSON") assert mt.full_type == "application/json" + @pytest.mark.req("HTTP-23") def test_parameter_keys_normalised(self) -> None: mt = MediaType.of("text", "plain", {"CHARSET": "UTF-8"}) # Keys are case-folded; values preserve case (multipart boundaries @@ -71,6 +74,7 @@ def test_blank_type_raises(self) -> None: with pytest.raises(ValueError): MediaType.of(" ", "json") + @pytest.mark.req("HTTP-27") def test_wildcard_type_requires_wildcard_subtype(self) -> None: with pytest.raises(ValueError): MediaType.of("*", "json") @@ -82,6 +86,7 @@ def test_exact_match(self) -> None: b = MediaType.of("application", "json") assert a.includes(b) + @pytest.mark.req("HTTP-27") def test_wildcard_subtype(self) -> None: a = MediaType.of("application", "*") b = MediaType.of("application", "json") diff --git a/packages/dexpace-sdk-core/tests/http/test_media_type_conformance.py b/packages/dexpace-sdk-core/tests/http/test_media_type_conformance.py new file mode 100644 index 0000000..6d790a7 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_media_type_conformance.py @@ -0,0 +1,313 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Conformance battery for ``MediaType`` (HTTP-23..27, HTTP-53). + +Certifies the RFC 7231/7230 behaviours the SDK relies on: the normalisation +invariant lives behind unexported fields read through accessors (HTTP-53); the +quoted-string-aware parse/render loop round-trips (HTTP-23/24); type, subtype, +and parameter keys lower-case while parameter values are preserved verbatim +(HTTP-25); construction rejects control characters and non-ASCII bytes so a +media type can always be emitted as a ``Content-Type`` header (HTTP-26); and the +``charset`` accessor degrades an unknown encoding to ``None`` (HTTP-27). + +`hypothesis` is not a workspace dev dependency (see the root ``pyproject.toml`` +``[dependency-groups]``), so the parse/render round-trip is exercised as an +enumerated property over a curated corpus of separator-, quote-, and +escape-bearing values rather than as a generated property test. +""" + +from __future__ import annotations + +import dataclasses +import itertools + +import pytest + +from dexpace.sdk.core.http.common import MediaType + + +@pytest.mark.req("HTTP-53") +class TestInvariantBehindAccessor: + """HTTP-53: the type/subtype/params invariant cannot be bypassed. + + ``__post_init__`` normalises on every construction path — including the + raw dataclass constructor, not just ``of``/``parse`` — so an + un-normalised instance can never be fabricated, and the frozen fields + reject any later reassignment. + + Note: the normalised parts are plain frozen fields rather than private + fields behind read-only properties, because CPython's + ``@dataclass(frozen=True, slots=True)`` generates a broken + ``__setattr__``/``__delattr__`` pair when the class defines a descriptor + beyond its declared slotted fields (reproducible independent of this + codebase, unrelated to the field's specific name) — see the class + docstring in ``media_type.py``. + """ + + def test_fields_are_the_declared_slots(self) -> None: + assert MediaType.__slots__ == ("type", "subtype", "parameters") + + def test_raw_constructor_normalises_type_and_subtype(self) -> None: + # Bypassing ``of``/``parse`` still yields a normalised instance. + mt = MediaType("APPLICATION", "JSON") + assert mt.type == "application" + assert mt.subtype == "json" + + def test_raw_constructor_lowercases_keys_and_sorts_params(self) -> None: + mt = MediaType("text", "plain", (("B", "2"), ("A", "1"))) + assert mt.parameters == (("a", "1"), ("b", "2")) + + def test_raw_constructor_preserves_parameter_value_case(self) -> None: + mt = MediaType("multipart", "form-data", (("Boundary", "AbC"),)) + assert mt.parameters == (("boundary", "AbC"),) + + def test_assigning_field_raises_frozen(self) -> None: + # ``setattr`` via a name variable keeps this a runtime-immutability + # assertion without tripping a static frozen-field check. + mt = MediaType.of("application", "json") + field = "type" + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(mt, field, "text") + + def test_raw_and_factory_construction_are_equal_and_hash_equal(self) -> None: + raw = MediaType("Application", "Json", (("B", "2"), ("A", "1"))) + made = MediaType.of("application", "json", {"a": "1", "b": "2"}) + assert raw == made + assert hash(raw) == hash(made) + + +class TestParameterAccessorReturnsSortedPairs: + def test_parameters_sorted_by_key(self) -> None: + mt = MediaType.of("text", "plain", {"z": "1", "a": "2", "m": "3"}) + keys = [k for k, _ in mt.parameters] + assert keys == sorted(keys) + + def test_parameters_is_tuple_not_mutable(self) -> None: + mt = MediaType.of("text", "plain", {"a": "1"}) + assert isinstance(mt.parameters, tuple) + + +class TestCharsetAccessor: + def test_known_charset_returned(self) -> None: + assert MediaType.parse("text/plain; charset=utf-8").charset == "utf-8" + + def test_charset_case_preserved(self) -> None: + assert MediaType.parse("text/plain; charset=UTF-8").charset == "UTF-8" + + @pytest.mark.req("HTTP-24") + def test_absent_charset_is_none(self) -> None: + assert MediaType.of("application", "json").charset is None + + def test_unknown_charset_degrades_to_none(self) -> None: + mt = MediaType.parse("text/plain; charset=definitely-not-a-codec") + assert dict(mt.parameters)["charset"] == "definitely-not-a-codec" + assert mt.charset is None + + def test_empty_charset_degrades_to_none(self) -> None: + # A quoted empty charset parses to "" and must not raise via codecs. + mt = MediaType.parse('text/plain; charset=""') + assert mt.charset is None + + +class TestFullTypeAndWildcards: + def test_full_type_excludes_parameters(self) -> None: + mt = MediaType.of("text", "plain", {"charset": "utf-8"}) + assert mt.full_type == "text/plain" + + def test_full_type_for_wildcards(self) -> None: + assert MediaType.of("*", "*").full_type == "*/*" + + def test_includes_is_reflexive(self) -> None: + mt = MediaType.of("application", "json") + assert mt.includes(mt) + + @pytest.mark.req("HTTP-27") + def test_includes_ignores_parameters(self) -> None: + a = MediaType.of("text", "plain", {"charset": "utf-8"}) + b = MediaType.of("text", "plain", {"charset": "ascii"}) + assert a.includes(b) + + def test_concrete_type_wildcard_subtype_allowed(self) -> None: + pattern = MediaType.of("application", "*") + assert pattern.includes(MediaType.of("application", "json")) + + +@pytest.mark.req("HTTP-24") +class TestQuotedStringRendering: + """HTTP-24: values needing quoting are quoted and escaped; tokens are bare.""" + + def test_bare_token_value_not_quoted(self) -> None: + assert str(MediaType.of("text", "plain", {"charset": "utf-8"})) == ( + "text/plain;charset=utf-8" + ) + + @pytest.mark.req("HTTP-25") + @pytest.mark.parametrize( + "value", + ["foo bar", "a;b", "a,b", "a/b", "a=b c", "a\tb", 'has"quote', "back\\slash"], + ) + def test_values_needing_quotes_round_trip(self, value: str) -> None: + mt = MediaType.of("multipart", "mixed", {"p": value}) + rendered = str(mt) + # Non-token values must be wrapped in a quoted-string ... + assert rendered.startswith('multipart/mixed;p="') + # ... and survive a parse/render round-trip byte-for-byte. + assert MediaType.parse(rendered) == mt + assert dict(MediaType.parse(rendered).parameters)["p"] == value + + def test_empty_value_renders_as_empty_quoted_string(self) -> None: + mt = MediaType.of("text", "plain", {"p": ""}) + assert str(mt) == 'text/plain;p=""' + assert MediaType.parse(str(mt)) == mt + + +@pytest.mark.req("HTTP-26") +class TestControlAndNonAsciiRejection: + """HTTP-26: construction rejects control / non-ASCII bytes fail-fast. + + Every component — type, subtype, parameter key, parameter value — must be + emittable as part of a ``Content-Type`` header, so a C0 control (except + HTAB), ``DEL``, or a non-ASCII byte is refused at construction rather than + slipping through to be caught late at the header boundary (which is also + where a CR/LF injection would otherwise land). + """ + + # A representative forbidden byte from each class the predicate rejects. + _FORBIDDEN = ["\r", "\n", "\0", "\x1f", "\x7f", "café", "π", "\x80"] + + @pytest.mark.parametrize("bad", _FORBIDDEN) + def test_forbidden_parameter_value_rejected(self, bad: str) -> None: + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType.of("text", "plain", {"charset": bad}) + + @pytest.mark.parametrize("bad", _FORBIDDEN) + def test_forbidden_parameter_key_rejected(self, bad: str) -> None: + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType.of("text", "plain", {f"x{bad}": "utf-8"}) + + @pytest.mark.parametrize("bad", _FORBIDDEN) + def test_forbidden_type_rejected(self, bad: str) -> None: + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType.of(f"text{bad}", "plain") + + @pytest.mark.parametrize("bad", _FORBIDDEN) + def test_forbidden_subtype_rejected(self, bad: str) -> None: + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType.of("text", f"plain{bad}") + + def test_rejection_fires_on_the_raw_constructor_too(self) -> None: + # ``__post_init__`` runs the check on every construction path, not just + # the ``of``/``parse`` factories. + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType("text", "plain", (("charset", "caf\xe9"),)) + + def test_crlf_injection_is_rejected_at_construction(self) -> None: + # The header-splitting payload is refused when the media type is built, + # not deferred to the ``Content-Type`` header assignment. + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType.of("text", "plain", {"boundary": "x\r\nInjected: y"}) + + def test_htab_and_space_are_permitted(self) -> None: + # HTAB and SP are not control characters under the predicate; they only + # force quoting, so a value carrying them is accepted and round-trips. + mt = MediaType.of("multipart", "mixed", {"p": "a\tb c"}) + assert MediaType.parse(str(mt)) == mt + + def test_ascii_media_type_always_survives_as_header_value(self) -> None: + from dexpace.sdk.core.http.common import Headers + from dexpace.sdk.core.http.common.http_header_name import CONTENT_TYPE + + mt = MediaType.of("application", "json", {"charset": "utf-8"}) + headers = Headers.outbound([(CONTENT_TYPE, str(mt))]) + assert headers.get(CONTENT_TYPE) == str(mt) + + +def _corpus() -> list[str]: + """Curated parameter-value fragments for the enumerated round-trip property.""" + fragments = [ + "token", + "UPPER", + "MiXeD-Case_1.0", + "utf-8", + "abc=def", + "YWJj==", + "foo bar", + "a;b;c", + "a,b", + 'has"dquote', + "back\\slash", + "semi;and space", + "colon:and/slash", + "", + " leading-token", + ] + return fragments + + +@pytest.mark.req("HTTP-23") +class TestParseRenderRoundTripProperty: + """HTTP-23: enumerated property — ``parse(str(mt)) == mt`` and str is a fixed point.""" + + @pytest.mark.req("HTTP-23") + @pytest.mark.parametrize("value", _corpus()) + def test_single_parameter_round_trip(self, value: str) -> None: + mt = MediaType.of("multipart", "mixed", {"boundary": value}) + rendered = str(mt) + reparsed = MediaType.parse(rendered) + assert reparsed == mt + # ``str`` is a fixed point of parse: the canonical form re-renders to itself. + assert str(reparsed) == rendered + + @pytest.mark.parametrize( + "first,second", + list(itertools.islice(itertools.product(_corpus(), repeat=2), 0, None, 3)), + ) + def test_two_parameter_round_trip(self, first: str, second: str) -> None: + mt = MediaType.of("multipart", "mixed", {"boundary": first, "tag": second}) + rendered = str(mt) + reparsed = MediaType.parse(rendered) + assert reparsed == mt + assert str(reparsed) == rendered + + def test_type_subtype_case_is_a_fixed_point(self) -> None: + for text in ("Application/JSON", "TEXT/Plain; Charset=UTF-8"): + mt = MediaType.parse(text) + assert MediaType.parse(str(mt)) == mt + + +class TestParseEdgeCases: + def test_duplicate_parameter_last_wins(self) -> None: + mt = MediaType.parse("text/plain; charset=utf-8; charset=ascii") + assert dict(mt.parameters)["charset"] == "ascii" + assert len(mt.parameters) == 1 + + def test_trailing_semicolon_ignored(self) -> None: + mt = MediaType.parse("application/json;") + assert mt.full_type == "application/json" + assert mt.parameters == () + + def test_whitespace_around_equals_tolerated(self) -> None: + mt = MediaType.parse("text/plain; charset = utf-8") + assert mt.charset == "utf-8" + + @pytest.mark.req("HTTP-53") + def test_empty_parameter_value_raises(self) -> None: + with pytest.raises(ValueError): + MediaType.parse("text/plain; charset=") + + def test_empty_parameter_key_raises(self) -> None: + with pytest.raises(ValueError): + MediaType.parse("text/plain; =utf-8") + + def test_parameter_without_equals_raises(self) -> None: + with pytest.raises(ValueError): + MediaType.parse("text/plain; charset") + + def test_leading_slash_raises(self) -> None: + with pytest.raises(ValueError): + MediaType.parse("/json") + + def test_wildcard_type_concrete_subtype_raises(self) -> None: + with pytest.raises(ValueError): + MediaType.of("*", "json") diff --git a/packages/dexpace-sdk-core/tests/http/test_media_type_params.py b/packages/dexpace-sdk-core/tests/http/test_media_type_params.py index 2afe598..2895fa2 100644 --- a/packages/dexpace-sdk-core/tests/http/test_media_type_params.py +++ b/packages/dexpace-sdk-core/tests/http/test_media_type_params.py @@ -18,6 +18,7 @@ class TestSplitOnFirstEquals: + @pytest.mark.req("HTTP-25") def test_boundary_with_multiple_equals_keeps_remainder(self) -> None: mt = MediaType.parse("multipart/form-data; boundary=abc=def") assert dict(mt.parameters)["boundary"] == "abc=def" @@ -36,6 +37,7 @@ def test_strips_surrounding_quotes(self) -> None: mt = MediaType.parse('text/plain; charset="utf-8"') assert mt.charset == "utf-8" + @pytest.mark.req("HTTP-25") def test_unescapes_escaped_quote(self) -> None: # \" -> " mt = MediaType.parse('text/plain; foo="a\\"b"') @@ -99,17 +101,20 @@ def test_parameter_key_lowercased(self) -> None: mt = MediaType.parse("text/plain; CHARSET=utf-8") assert "charset" in dict(mt.parameters) + @pytest.mark.req("HTTP-23") def test_parameter_value_case_preserved(self) -> None: # Boundaries and base64 tags are case-sensitive — only the key folds. mt = MediaType.parse("multipart/form-data; Boundary=AbCdEf") assert dict(mt.parameters)["boundary"] == "AbCdEf" + @pytest.mark.req("HTTP-24") def test_charset_value_case_preserved(self) -> None: mt = MediaType.parse("text/plain; charset=UTF-8") assert mt.charset == "UTF-8" class TestUnknownCharsetDegradesToNone: + @pytest.mark.req("HTTP-24") def test_unknown_charset_returns_none(self) -> None: mt = MediaType.parse("text/plain; charset=not-a-real-encoding") # The parameter is retained verbatim ... diff --git a/packages/dexpace-sdk-core/tests/http/test_media_type_properties.py b/packages/dexpace-sdk-core/tests/http/test_media_type_properties.py new file mode 100644 index 0000000..70caf33 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_media_type_properties.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Property-based tests pinning ``MediaType`` parse/render round-trips. + +Invariants covered: + +- ``MediaType.parse(str(mt)) == mt`` — rendering to wire form and parsing back + is the identity, including parameter values that require quoting (spaces, + separators, and quotes). +- ``MediaType.of`` normalisation is idempotent — constructing from an already + normalised media type's parts reproduces it. + +Parameter values are drawn from the ``field-content`` byte set (HTAB, SP, and +visible ASCII) because `MediaType` construction rejects control characters and +non-ASCII bytes outright (HTTP-26) — the same predicate outbound header-value +validation uses — so a generated value that could not be emitted as a +``Content-Type`` header is never constructed in the first place. + +The example count and deadline are governed by the capped "ci" Hypothesis +profile registered in ``tests/conftest.py``. +""" + +from __future__ import annotations + +from string import ascii_lowercase + +from hypothesis import assume, given +from hypothesis import strategies as st + +from dexpace.sdk.core.http.common import MediaType + +# RFC 7230 ``token`` characters. Generated lower-case because ``of`` lower-cases +# the type, subtype, and parameter keys anyway; keeping keys lower-case also +# avoids case-collision between distinct mapping keys after normalisation. +_TOKEN_CHARS = "!#$%&'*+-.^_`|~0123456789" + ascii_lowercase + +_tokens = st.text(alphabet=_TOKEN_CHARS, min_size=1, max_size=20) +_param_keys = st.text(alphabet=_TOKEN_CHARS, min_size=1, max_size=15) +# Parameter values range over the ``field-content`` byte set — HTAB plus the +# visible-ASCII range 0x20-0x7E. The renderer quotes anything that is not a bare +# token and the parser unquotes it, so the round-trip must survive spaces, +# separators, and quotes; control bytes and non-ASCII are excluded because +# construction rejects them (HTTP-26), matching outbound header validation. +_value_chars = st.characters(min_codepoint=0x20, max_codepoint=0x7E) | st.just("\t") +_param_values = st.text(_value_chars, max_size=25) +_params = st.dictionaries(_param_keys, _param_values, max_size=5) + + +@st.composite +def _media_types(draw: st.DrawFn) -> MediaType: + """Build an arbitrary valid ``MediaType`` via the ``of`` factory.""" + type_ = draw(_tokens) + subtype = draw(_tokens) + # ``of`` rejects a wildcard type paired with a concrete subtype. + assume(not (type_ == "*" and subtype != "*")) + parameters = draw(_params) + return MediaType.of(type_, subtype, parameters) + + +@given(media_type=_media_types()) +def test_parse_render_round_trip(media_type: MediaType) -> None: + """Parsing the rendered wire form reproduces the original media type.""" + assert MediaType.parse(str(media_type)) == media_type + + +@given(media_type=_media_types()) +def test_of_normalization_is_idempotent(media_type: MediaType) -> None: + """Re-running ``of`` on an already-normalised media type's parts is a no-op.""" + rebuilt = MediaType.of( + media_type.type, + media_type.subtype, + dict(media_type.parameters), + ) + assert rebuilt == media_type diff --git a/packages/dexpace-sdk-core/tests/http/test_method_status_protocol.py b/packages/dexpace-sdk-core/tests/http/test_method_status_protocol.py index 425633a..e4b47d2 100644 --- a/packages/dexpace-sdk-core/tests/http/test_method_status_protocol.py +++ b/packages/dexpace-sdk-core/tests/http/test_method_status_protocol.py @@ -9,14 +9,44 @@ from dexpace.sdk.core.http.common import Protocol from dexpace.sdk.core.http.request import Method +from dexpace.sdk.core.http.request.method import _IDEMPOTENT_METHODS from dexpace.sdk.core.http.response import Status +@pytest.mark.req("HTTP-9") def test_method_str_round_trip() -> None: assert Method("GET") is Method.GET assert str(Method.POST) == "POST" +@pytest.mark.req("HTTP-9") +@pytest.mark.parametrize("method", list(Method)) +def test_method_wire_token_equals_uppercase_name(method: Method) -> None: + # The canonical wire token is the member's value, which equals its uppercase + # name — so a method drops straight into a request line without translation. + assert method.value == method.name + assert str(method) == method.name + assert method.value.isupper() + + +@pytest.mark.req("HTTP-9") +def test_idempotent_method_set_is_the_canonical_five() -> None: + expected = {Method.GET, Method.HEAD, Method.OPTIONS, Method.PUT, Method.DELETE} + assert set(_IDEMPOTENT_METHODS) == expected + + +@pytest.mark.req("HTTP-9") +def test_retry_allowlist_derives_from_the_single_idempotency_source() -> None: + # The default retry method allow-list is not an independent list: it is + # derived from `_IDEMPOTENT_METHODS`, so the two can never diverge (and, in + # particular, TRACE is not silently retried). + from dexpace.sdk.core.pipeline.policies.retry import _DEFAULT_METHOD_ALLOWLIST + + expected = {m.value for m in _IDEMPOTENT_METHODS} + assert set(_DEFAULT_METHOD_ALLOWLIST) == expected + assert "TRACE" not in _DEFAULT_METHOD_ALLOWLIST + + def test_method_is_str_compatible() -> None: assert Method.GET == "GET" @@ -59,6 +89,32 @@ def test_protocol_h2_aliases() -> None: assert Protocol.parse("http/2.0") is Protocol.HTTP_2 +@pytest.mark.req("HTTP-33") def test_protocol_unknown_raises() -> None: with pytest.raises(ValueError): Protocol.parse("ftp/1.0") + + +# --- Protocol parse battery (HTTP-33) --------------------------------------- + + +@pytest.mark.req("HTTP-33") +@pytest.mark.parametrize("proto", list(Protocol)) +def test_protocol_str_round_trips_for_every_member(proto: Protocol) -> None: + assert Protocol.parse(str(proto)) is proto + + +@pytest.mark.req("HTTP-33") +@pytest.mark.parametrize( + "text,expected", + [ + ("http/1.0", Protocol.HTTP_1_0), + ("HTTP/1.1", Protocol.HTTP_1_1), + ("h2", Protocol.HTTP_2), + ("HTTP/2.0", Protocol.HTTP_2), + ("H2_PRIOR_KNOWLEDGE", Protocol.H2_PRIOR_KNOWLEDGE), + ("QUIC", Protocol.QUIC), + ], +) +def test_protocol_parse_is_case_insensitive(text: str, expected: Protocol) -> None: + assert Protocol.parse(text) is expected diff --git a/packages/dexpace-sdk-core/tests/http/test_multipart.py b/packages/dexpace-sdk-core/tests/http/test_multipart.py index cf780b8..2e7a23a 100644 --- a/packages/dexpace-sdk-core/tests/http/test_multipart.py +++ b/packages/dexpace-sdk-core/tests/http/test_multipart.py @@ -75,6 +75,7 @@ def test_explicit_boundary() -> None: assert b"--my-boundary--" in drained +@pytest.mark.req("HTTP-51") def test_quotes_in_name_escaped() -> None: body = MultipartRequestBody( [MultipartField(name='odd"name', value="x")], @@ -166,20 +167,21 @@ def test_control_char_in_custom_header_value_rejected(ctrl: str) -> None: MultipartField(name="f", value=b"v", headers=(("X-Ok", f"bad{ctrl}value"),)) +@pytest.mark.req("HTTP-26") @pytest.mark.parametrize("ctrl", ["\r", "\n", "\0"]) def test_control_char_in_media_type_subtype_rejected(ctrl: str) -> None: # The part media type is rendered into a ``Content-Type:`` header, so a - # subtype carrying CR/LF would inject an extra header line. - media_type = MediaType.of("application", f"octet-stream{ctrl}X-Evil: 1") - with pytest.raises(ValueError, match="control characters"): - MultipartField(name="f", value=b"v", media_type=media_type) + # subtype carrying CR/LF would inject an extra header line — rejected at + # ``MediaType`` construction, before it can reach the multipart boundary. + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType.of("application", f"octet-stream{ctrl}X-Evil: 1") +@pytest.mark.req("HTTP-26") @pytest.mark.parametrize("ctrl", ["\r", "\n", "\0"]) def test_control_char_in_media_type_param_rejected(ctrl: str) -> None: - media_type = MediaType.of("text", "plain", {"charset": f"utf-8{ctrl}X-Evil: 1"}) - with pytest.raises(ValueError, match="control characters"): - MultipartField(name="f", value=b"v", media_type=media_type) + with pytest.raises(ValueError, match="control character or non-ASCII"): + MediaType.of("text", "plain", {"charset": f"utf-8{ctrl}X-Evil: 1"}) @pytest.mark.parametrize("ctrl", ["\r", "\n", "\0"]) diff --git a/packages/dexpace-sdk-core/tests/http/test_multipart_framing.py b/packages/dexpace-sdk-core/tests/http/test_multipart_framing.py new file mode 100644 index 0000000..df40f89 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_multipart_framing.py @@ -0,0 +1,225 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Shared-framing, preview, and replayability-propagation tests for multipart. + +These cover PY-M2-06's acceptance criteria: wire bytes equal the shared-framing +preview bytes (and the logging tap that consumes them), replayability +propagates from the fields, and ``content_length`` matches the drained bytes. +""" + +from __future__ import annotations + +from collections.abc import Callable +from io import BytesIO +from pathlib import Path + +import pytest + +from dexpace.sdk.core.http.request import ( + LoggableRequestBody, + MultipartField, + MultipartRequestBody, + RequestBody, +) +from dexpace.sdk.core.http.request import _multipart_framing as framing + + +def _drain(body: RequestBody) -> bytes: + return b"".join(body.iter_bytes()) + + +# --- Shared framing: wire bytes == preview bytes --------------------------- + + +@pytest.mark.req("HTTP-51") +def test_wire_bytes_equal_shared_framing_preview() -> None: + # The wire path and the preview both consume ``iter_frames`` via the same + # module, so a full render must equal a full drain byte for byte. + fields = [ + MultipartField(name="text", value="hello world"), + MultipartField( + name="file", + value=b"<>", + filename="upload.bin", + ), + ] + body = MultipartRequestBody(fields, boundary="BND") + wire = _drain(body) + preview = framing.render_frames(body.fields, body.boundary) + assert wire == preview + + +@pytest.mark.req("BODY-17") +def test_tap_snapshot_equals_wire_via_shared_framing() -> None: + # The logging tap captures exactly what goes on the wire; asserting the tap + # snapshot against the shared-framing render ties the two together. + fields = [MultipartField(name="a", value="b"), MultipartField(name="c", value=b"d")] + body = MultipartRequestBody(fields, boundary="BND") + logged = LoggableRequestBody(body) + wire = _drain(logged) + assert logged.snapshot() == wire + assert wire == framing.render_frames(body.fields, body.boundary) + + +def test_preview_is_truncatable() -> None: + body = MultipartRequestBody([MultipartField(name="a", value="b")], boundary="BND") + full = framing.render_frames(body.fields, body.boundary) + capped = framing.render_frames(body.fields, body.boundary, max_bytes=8) + assert capped == full[:8] + + +def test_preview_negative_cap_rejected() -> None: + body = MultipartRequestBody([MultipartField(name="a", value="b")], boundary="BND") + with pytest.raises(ValueError, match="max_bytes"): + framing.render_frames(body.fields, body.boundary, max_bytes=-1) + + +def test_chunk_size_does_not_change_bytes() -> None: + body = MultipartRequestBody( + [MultipartField(name="big", value=b"0123456789" * 20)], + boundary="BND", + ) + small = b"".join(body.iter_bytes(4)) + large = b"".join(body.iter_bytes(4096)) + assert small == large == framing.render_frames(body.fields, body.boundary) + + +# --- Replayability-propagation matrix -------------------------------------- + + +def _replayable_request_body(tmp_path: Path) -> RequestBody: + path = tmp_path / "part.bin" + path.write_bytes(b"file-part") + return RequestBody.from_file(path) + + +@pytest.mark.req("BODY-2", "BODY-3") +def test_all_replayable_fields_make_body_replayable(tmp_path: Path) -> None: + fields = [ + MultipartField(name="s", value="str"), + MultipartField(name="b", value=b"bytes"), + MultipartField(name="rb", value=RequestBody.from_bytes(b"nested")), + MultipartField(name="file", value=_replayable_request_body(tmp_path)), + ] + body = MultipartRequestBody(fields, boundary="BND") + assert body.is_replayable() + # Replayable bodies survive being drained twice with identical bytes. + assert _drain(body) == _drain(body) + assert body.to_replayable() is body + + +@pytest.mark.req("BODY-2") +@pytest.mark.parametrize( + "single_use_factory", + [ + lambda: RequestBody.from_stream(BytesIO(b"streamed")), + lambda: RequestBody.from_iter([b"chunk-a", b"chunk-b"]), + ], +) +def test_any_non_replayable_field_makes_body_non_replayable( + single_use_factory: Callable[[], RequestBody], +) -> None: + fields = [ + MultipartField(name="ok", value="replayable"), + MultipartField(name="stream", value=single_use_factory()), + ] + body = MultipartRequestBody(fields, boundary="BND") + assert not body.is_replayable() + + +def test_non_replayable_body_second_iter_raises() -> None: + fields = [ + MultipartField(name="ok", value="replayable"), + MultipartField(name="stream", value=RequestBody.from_stream(BytesIO(b"once"))), + ] + body = MultipartRequestBody(fields, boundary="BND") + first = _drain(body) + assert b"once" in first + with pytest.raises(RuntimeError, match="already called"): + body.iter_bytes() + + +def test_non_replayable_second_iter_raises_eagerly() -> None: + # The RuntimeError must fire at call time, before the returned iterator is + # advanced, so a retry loop learns the body is spent immediately. + body = MultipartRequestBody( + [MultipartField(name="stream", value=RequestBody.from_iter([b"x"]))], + boundary="BND", + ) + body.iter_bytes() + with pytest.raises(RuntimeError, match="already called"): + body.iter_bytes() + + +def test_invalid_chunk_size_does_not_consume_single_use_body() -> None: + body = MultipartRequestBody( + [MultipartField(name="stream", value=RequestBody.from_stream(BytesIO(b"payload")))], + boundary="BND", + ) + with pytest.raises(ValueError, match="chunk_size"): + body.iter_bytes(0) + # The eager ValueError must not flip the consumed flag. + assert b"payload" in _drain(body) + + +def test_to_replayable_buffers_single_use_multipart() -> None: + fields = [ + MultipartField(name="ok", value="replayable"), + MultipartField(name="stream", value=RequestBody.from_stream(BytesIO(b"streamed"))), + ] + body = MultipartRequestBody(fields, boundary="BND") + buffered = body.to_replayable() + assert buffered.is_replayable() + # The buffered copy keeps the multipart media type and replays identically. + media = buffered.media_type() + assert media is not None and media.full_type == "multipart/form-data" + assert b"".join(buffered.iter_bytes()) == b"".join(buffered.iter_bytes()) + assert b"streamed" in b"".join(buffered.iter_bytes()) + + +def test_nested_request_body_streams_into_part() -> None: + body = MultipartRequestBody( + [ + MultipartField( + name="file", + value=RequestBody.from_bytes(b"nested-payload"), + filename="a.bin", + ) + ], + boundary="BND", + ) + drained = _drain(body) + assert b'filename="a.bin"' in drained + assert b"nested-payload" in drained + + +# --- content_length -------------------------------------------------------- + + +@pytest.mark.req("BODY-35", "HTTP-51") +def test_content_length_matches_drain_for_known_fields(tmp_path: Path) -> None: + fields = [ + MultipartField(name="s", value="hello"), + MultipartField(name="b", value=b"world!!"), + MultipartField(name="file", value=_replayable_request_body(tmp_path)), + ] + body = MultipartRequestBody(fields, boundary="BND") + assert body.content_length() == len(_drain(body)) + + +@pytest.mark.req("BODY-2", "BODY-35") +def test_content_length_unknown_when_stream_field_present() -> None: + fields = [ + MultipartField(name="ok", value="known"), + MultipartField(name="stream", value=RequestBody.from_stream(BytesIO(b"x"))), + ] + body = MultipartRequestBody(fields, boundary="BND") + assert body.content_length() == -1 + + +def test_empty_value_produces_zero_length_part() -> None: + body = MultipartRequestBody([MultipartField(name="empty", value=b"")], boundary="BND") + drained = _drain(body) + assert b'name="empty"' in drained + assert body.content_length() == len(drained) diff --git a/packages/dexpace-sdk-core/tests/http/test_pagination.py b/packages/dexpace-sdk-core/tests/http/test_pagination.py index 52db323..d44be8d 100644 --- a/packages/dexpace-sdk-core/tests/http/test_pagination.py +++ b/packages/dexpace-sdk-core/tests/http/test_pagination.py @@ -36,6 +36,7 @@ def _extract(page: _Page) -> tuple[str | None, Iterable[int]]: class TestPager: + @pytest.mark.req("PAGE-34") def test_iterates_pages_in_order(self) -> None: pages: dict[str | None, _Page] = { None: _Page(items=[1, 2], next_token="page2"), @@ -84,6 +85,7 @@ def test_max_pages_bounds_iteration(self) -> None: class TestItemPaged: + @pytest.mark.req("PAGE-34") def test_flat_iteration(self) -> None: pages: dict[str | None, _Page] = { None: _Page(items=[1, 2], next_token="x"), diff --git a/packages/dexpace-sdk-core/tests/http/test_query_codec.py b/packages/dexpace-sdk-core/tests/http/test_query_codec.py new file mode 100644 index 0000000..5a8305a --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_query_codec.py @@ -0,0 +1,231 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Conformance + property tests for the pure RFC 3986 query codec. + +The ``query_codec`` (``HTTP-29``) family of the golden wire-exactness corpus is +exercised two ways: directly against the pure, furl-free codec module +(``render_query`` / ``parse_query``) and through the public ``QueryParams`` / +``Url`` API that calls into it. Both must agree with the checked-in byte +fixtures, proving the wire-exact rendering path never routes through furl (whose +normalisations re-encode ``%20`` to ``+``). + +A ``hypothesis`` property pins ``parse_query(render_query(pairs)) == pairs`` as +an exact inverse over arbitrary UTF-8 pairs, complementing the enumerated +corpus. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.http.common import QueryParams, Url +from dexpace.sdk.core.http.common._query_codec import parse_query, render_query +from dexpace.sdk.tck.golden_corpus import QueryCodecVector, load_query_codec + +_VECTORS = load_query_codec() + +# The corpus tags each vector with which relationships it pins; parametrise each +# check over exactly the applicable subset so every case makes a real assertion +# (a parse-only vector such as ``parse-literal-plus-not-space`` deliberately does +# not render back to its wire form, and is not a render case). +_RENDER_VECTORS = [v for v in _VECTORS if v.render_matches] +_PARSE_VECTORS = [v for v in _VECTORS if v.parse_matches] +_ROUND_TRIP_VECTORS = [v for v in _VECTORS if v.round_trippable] + + +def _decoded_params(vector: QueryCodecVector) -> list[tuple[str, str]]: + """The vector's params as decoded ``(name, value)`` string pairs.""" + return [(name.decode(), value.decode()) for name, value in vector.params] + + +# ----- golden corpus: directly against the pure codec module --------------- + + +@pytest.mark.req("HTTP-29") +@pytest.mark.parametrize("vector", _RENDER_VECTORS, ids=lambda v: v.id) +def test_pure_render_matches_golden(vector: QueryCodecVector) -> None: + """Strict render of the params equals the wire query, byte for byte.""" + assert render_query(_decoded_params(vector)) == vector.query.decode("ascii") + + +@pytest.mark.req("HTTP-29", "HTTP-31") +@pytest.mark.parametrize("vector", _PARSE_VECTORS, ids=lambda v: v.id) +def test_pure_parse_matches_golden(vector: QueryCodecVector) -> None: + """Lenient parse of the wire query equals the params (order preserved).""" + assert parse_query(vector.query.decode("ascii")) == _decoded_params(vector) + + +@pytest.mark.req("HTTP-29") +@pytest.mark.parametrize("vector", _ROUND_TRIP_VECTORS, ids=lambda v: v.id) +def test_pure_round_trip_is_exact_inverse(vector: QueryCodecVector) -> None: + """Where the vector claims it, render then parse is the identity.""" + params = _decoded_params(vector) + assert parse_query(render_query(params)) == params + + +# ----- golden corpus: through the public QueryParams API ------------------- + + +@pytest.mark.req("HTTP-29") +@pytest.mark.parametrize("vector", _RENDER_VECTORS, ids=lambda v: v.id) +def test_queryparams_encode_matches_golden(vector: QueryCodecVector) -> None: + """``QueryParams(...).encode()`` renders the wire query byte for byte.""" + params = QueryParams(_decoded_params(vector)) + assert params.encode() == vector.query.decode("ascii") + + +@pytest.mark.req("HTTP-29") +@pytest.mark.parametrize("vector", _PARSE_VECTORS, ids=lambda v: v.id) +def test_queryparams_parse_matches_golden(vector: QueryCodecVector) -> None: + """``QueryParams.parse(...)`` flattens back to the params (order preserved).""" + parsed = QueryParams.parse(vector.query.decode("ascii")) + assert list(parsed.flatten()) == _decoded_params(vector) + + +# ----- named edge cases (the traps the codec must not fall into) ----------- + + +@pytest.mark.req("HTTP-29", "HTTP-32") +def test_space_renders_as_pct20_never_plus() -> None: + assert render_query([("q", "a b")]) == "q=a%20b" + assert QueryParams([("q", "a b")]).encode() == "q=a%20b" + + +def test_literal_plus_renders_as_pct2b() -> None: + assert render_query([("q", "a+b")]) == "q=a%2Bb" + assert QueryParams([("q", "a+b")]).encode() == "q=a%2Bb" + + +@pytest.mark.req("HTTP-32", "PAGE-22") +def test_wire_plus_parses_as_literal_plus_not_space() -> None: + # RFC 3986 lenient parse: a wire '+' is a literal '+', NOT form-decoded. + assert parse_query("q=a+b") == [("q", "a+b")] + assert QueryParams.parse("q=a+b").get("q") == "a+b" + + +@pytest.mark.req("PAGE-22") +def test_pct20_parses_as_space() -> None: + assert parse_query("q=a%20b") == [("q", "a b")] + assert QueryParams.parse("q=a%20b").get("q") == "a b" + + +@pytest.mark.req("PAGE-22") +def test_valueless_flag_is_present_empty_string() -> None: + # A present-but-empty value renders 'flag=' and is distinct from absent. + assert render_query([("flag", "")]) == "flag=" + assert parse_query("flag=") == [("flag", "")] + + +@pytest.mark.req("HTTP-28", "HTTP-31") +def test_bare_flag_parses_to_present_empty_string() -> None: + # A bare flag with no '=' is a present empty string, distinct from absent. + assert parse_query("flag") == [("flag", "")] + parsed = QueryParams.parse("flag") + assert "flag" in parsed + assert parsed.get("flag") == "" + + +def test_reserved_chars_in_value_are_percent_encoded() -> None: + assert render_query([("q", "a/b?c#d&e=f")]) == "q=a%2Fb%3Fc%23d%26e%3Df" + + +@pytest.mark.req("HTTP-32") +def test_unreserved_set_is_emitted_verbatim() -> None: + assert render_query([("q", "AZaz09-._~")]) == "q=AZaz09-._~" + + +def test_non_ascii_is_utf8_then_percent_encoded() -> None: + assert render_query([("name", "café")]) == "name=caf%C3%A9" + + +# ----- order sensitivity --------------------------------------------------- + + +@pytest.mark.req("HTTP-29") +def test_render_preserves_insertion_order_verbatim() -> None: + assert render_query([("a", "1"), ("b", "2")]) == "a=1&b=2" + assert render_query([("b", "2"), ("a", "1")]) == "b=2&a=1" + + +@pytest.mark.req("HTTP-30") +def test_queryparams_equality_is_order_sensitive() -> None: + ab = QueryParams([("a", "1"), ("b", "2")]) + ba = QueryParams([("b", "2"), ("a", "1")]) + assert ab != ba + assert ab.encode() != ba.encode() + + +# ----- Url serialisation: furl must not leak into the query bytes ---------- + + +def test_url_str_preserves_pct20_not_plus() -> None: + # Regression: furl re-encodes %20 -> + when it serialises a query. The + # wire-exact query must be rendered by the pure codec and spliced verbatim. + url = Url(scheme="https", host="h.example", path="/p", query=QueryParams([("q", "a b")])) + assert str(url) == "https://h.example/p?q=a%20b" + assert "+" not in str(url) + + +def test_url_str_places_query_before_fragment() -> None: + url = Url( + scheme="https", + host="h.example", + path="/p", + query=QueryParams([("q", "a b")]), + fragment="sec", + ) + assert str(url) == "https://h.example/p?q=a%20b#sec" + + +def test_url_parse_keeps_literal_plus_and_decodes_pct20() -> None: + url = Url.parse("https://h.example/p?q=a%20b&r=a+b&flag") + assert url.query.get("q") == "a b" + assert url.query.get("r") == "a+b" + assert url.query.get("flag") == "" + + +def test_url_str_parse_round_trip_preserves_encoded_query() -> None: + original = "https://h.example/p?q=a%20b&r=a%2Bb" + assert str(Url.parse(original)) == original + + +# ----- IPv6 + userinfo ----------------------------------------------------- + + +def test_ipv6_brackets_and_userinfo_survive_serialisation() -> None: + url = Url.parse("https://user:pass@[::1]:8080/p?q=a%20b") + assert url.host == "[::1]" + assert url.port == 8080 + assert url.userinfo == "user:pass" + # str() redacts userinfo but keeps the wire-exact query. + assert str(url) == "https://[::1]:8080/p?q=a%20b" + # wire_form() carries credentials and the same wire-exact query. + assert url.wire_form() == "https://user:pass@[::1]:8080/p?q=a%20b" + + +# ----- textual (DNS-free) URL equality ------------------------------------- + + +@pytest.mark.req("HTTP-46") +def test_url_equality_is_textual_not_dns_resolved() -> None: + # Structural field equality: no host resolution is performed. A trailing-dot + # FQDN and a differing port are textually distinct even if they would + # resolve identically. + assert Url.parse("https://example.com/") == Url.parse("https://example.com/") + assert Url.parse("https://example.com./") != Url.parse("https://example.com/") + assert Url.parse("https://example.com:8443/") != Url.parse("https://example.com/") + + +# ----- property: encode/parse inversion over arbitrary UTF-8 pairs --------- + +_component = st.text(st.characters(codec="utf-8"), max_size=24) +_pairs = st.lists(st.tuples(_component, _component), max_size=12) + + +@given(pairs=_pairs) +def test_render_parse_is_exact_inverse(pairs: list[tuple[str, str]]) -> None: + """Parsing a rendered query reproduces the exact ordered pairs.""" + assert parse_query(render_query(pairs)) == pairs diff --git a/packages/dexpace-sdk-core/tests/http/test_request.py b/packages/dexpace-sdk-core/tests/http/test_request.py index 6139a16..081328a 100644 --- a/packages/dexpace-sdk-core/tests/http/test_request.py +++ b/packages/dexpace-sdk-core/tests/http/test_request.py @@ -63,12 +63,18 @@ def test_without_header_drops_it() -> None: assert AUTHORIZATION not in r.headers +def _post_request() -> Request: + return Request(method=Method.POST, url=Url.parse("https://example.com/")) + + +@pytest.mark.req("HTTP-6") def test_with_body() -> None: body = RequestBody.from_string("payload") - r = _request().with_body(body) + r = _post_request().with_body(body) assert r.body is body +@pytest.mark.req("HTTP-1", "XCUT-15") def test_frozen() -> None: from dataclasses import FrozenInstanceError @@ -77,7 +83,137 @@ def test_frozen() -> None: r.url = Url.parse("https://x/") # type: ignore[misc] +@pytest.mark.req("HTTP-6") def test_default_headers_are_empty() -> None: r = _request() assert isinstance(r.headers, Headers) assert len(r.headers) == 0 + + +# --- Validation grammar ----------------------------------------------------- + + +@pytest.mark.req("HTTP-4") +def test_missing_url_raises_field_named_error() -> None: + with pytest.raises(ValueError, match=r"^url is required$"): + Request(method=Method.GET, url=None) # type: ignore[arg-type] + + +@pytest.mark.req("HTTP-4") +def test_missing_method_raises_field_named_error() -> None: + with pytest.raises(ValueError, match=r"^method is required$"): + Request(method=None, url=Url.parse("https://example.com/")) # type: ignore[arg-type] + + +@pytest.mark.req("HTTP-7") +def test_body_on_get_is_rejected() -> None: + body = RequestBody.from_string("payload") + with pytest.raises(ValueError, match="body is not permitted on a GET request"): + Request(method=Method.GET, url=Url.parse("https://example.com/"), body=body) + + +@pytest.mark.req("HTTP-7") +def test_body_on_head_is_rejected() -> None: + body = RequestBody.from_string("payload") + with pytest.raises(ValueError, match="body is not permitted on a HEAD request"): + Request(method=Method.HEAD, url=Url.parse("https://example.com/"), body=body) + + +@pytest.mark.req("HTTP-7") +@pytest.mark.parametrize("method", [Method.GET, Method.HEAD, Method.TRACE, Method.CONNECT]) +def test_body_on_bodyless_method_is_rejected_at_construction(method: Method) -> None: + # Every method whose classification forbids a body — including TRACE and + # CONNECT — rejects a non-null body at construction, never deferring the + # error to the transport. + body = RequestBody.from_string("payload") + with pytest.raises(ValueError, match=f"body is not permitted on a {method} request"): + Request(method=method, url=Url.parse("https://example.com/"), body=body) + + +@pytest.mark.req("HTTP-7") +@pytest.mark.parametrize("method", [Method.TRACE, Method.CONNECT]) +def test_with_method_to_bodyless_drops_body(method: Method) -> None: + # Downgrading a body-carrying request to TRACE/CONNECT clears the body so the + # resulting request stays valid (mirrors the GET/HEAD downgrade path). + post = _post_request().with_body(RequestBody.from_string("payload")) + switched = post.with_method(method) + assert switched.method is method + assert switched.body is None + assert post.body is not None # value semantics: original untouched + + +def test_with_body_on_get_is_rejected() -> None: + body = RequestBody.from_string("payload") + with pytest.raises(ValueError, match="body is not permitted on a GET request"): + _request().with_body(body) + + +def test_replace_revalidates_body_on_get() -> None: + from dataclasses import replace + + post = _post_request().with_body(RequestBody.from_string("payload")) + with pytest.raises(ValueError, match="body is not permitted on a GET request"): + replace(post, method=Method.GET) + + +def test_with_method_to_get_drops_body() -> None: + post = _post_request().with_body(RequestBody.from_string("payload")) + switched = post.with_method(Method.GET) + assert switched.method is Method.GET + assert switched.body is None + # The original is untouched (value semantics). + assert post.body is not None + + +def test_with_method_between_body_methods_keeps_body() -> None: + body = RequestBody.from_string("payload") + post = _post_request().with_body(body) + switched = post.with_method(Method.PUT) + assert switched.method is Method.PUT + assert switched.body is body + + +# --- Per-call boundary ------------------------------------------------------ + + +@pytest.mark.req("HTTP-35") +def test_timeout_zero_is_rejected() -> None: + with pytest.raises(ValueError, match="timeout must be positive"): + Request(method=Method.GET, url=Url.parse("https://example.com/"), timeout=0) + + +def test_timeout_negative_is_rejected() -> None: + with pytest.raises(ValueError, match="timeout must be positive"): + Request(method=Method.GET, url=Url.parse("https://example.com/"), timeout=-1.0) + + +def test_positive_timeout_is_accepted() -> None: + r = Request(method=Method.GET, url=Url.parse("https://example.com/"), timeout=2.5) + assert r.timeout == 2.5 + + +@pytest.mark.req("HTTP-35") +def test_negative_retries_is_rejected() -> None: + with pytest.raises(ValueError, match="retries must not be negative"): + Request(method=Method.GET, url=Url.parse("https://example.com/"), retries=-1) + + +@pytest.mark.req("HTTP-35") +def test_zero_retries_means_no_retries() -> None: + r = Request(method=Method.GET, url=Url.parse("https://example.com/"), retries=0) + assert r.retries == 0 + + +# --- Repr redaction --------------------------------------------------------- + + +def test_repr_redacts_url_and_header_credentials() -> None: + r = Request( + method=Method.GET, + url=Url.parse("https://user:secret@example.com/path?token=abc&page=2"), + ).with_header(AUTHORIZATION, "Bearer super-secret") + rendered = repr(r) + assert "secret" not in rendered # covers both userinfo and the bearer token + assert "abc" not in rendered + assert "REDACTED" in rendered + assert "page=2" in rendered # an allow-listed query key survives diff --git a/packages/dexpace-sdk-core/tests/http/test_request_body.py b/packages/dexpace-sdk-core/tests/http/test_request_body.py index 49b8a88..36b867c 100644 --- a/packages/dexpace-sdk-core/tests/http/test_request_body.py +++ b/packages/dexpace-sdk-core/tests/http/test_request_body.py @@ -24,6 +24,7 @@ def _drain(body: RequestBody) -> bytes: class TestFactories: + @pytest.mark.req("BODY-1", "HTTP-36") def test_from_bytes(self) -> None: body = RequestBody.from_bytes(b"payload") assert body.is_replayable() @@ -52,6 +53,7 @@ def test_from_form_encoding_changes_percent_encoding(self) -> None: assert utf8 == b"name=%C3%A9" assert latin1 != utf8 + @pytest.mark.req("BODY-6") def test_from_stream_single_use(self) -> None: body = RequestBody.from_stream(BytesIO(b"once"), content_length=4) assert not body.is_replayable() @@ -59,6 +61,7 @@ def test_from_stream_single_use(self) -> None: with pytest.raises(RuntimeError): _drain(body) + @pytest.mark.req("BODY-6") def test_from_iter_single_use(self) -> None: body = RequestBody.from_iter([b"a", b"bc"], content_length=3) assert _drain(body) == b"abc" @@ -73,6 +76,7 @@ def test_to_replayable_buffers_stream(self) -> None: class TestWriteTo: + @pytest.mark.req("HTTP-36") def test_write_to_returns_total(self) -> None: body = RequestBody.from_bytes(b"abcdefg") sink = BytesIO() @@ -97,6 +101,7 @@ def test_invalid_cap_raises(self) -> None: with pytest.raises(ValueError): LoggableRequestBody(RequestBody.from_bytes(b""), max_capture_bytes=0) + @pytest.mark.req("BODY-17", "BODY-19") def test_cap_truncates_capture_not_primary(self) -> None: logged = LoggableRequestBody( RequestBody.from_bytes(b"abcdefghij"), @@ -111,6 +116,7 @@ def test_cap_truncates_capture_not_primary(self) -> None: class TestFileRequestBody: + @pytest.mark.req("BODY-11") def test_round_trip(self, tmp_path: Path) -> None: path = tmp_path / "payload.bin" path.write_bytes(b"file contents") @@ -128,22 +134,54 @@ def test_offset_and_count(self, tmp_path: Path) -> None: assert body.content_length() == 5 assert _drain(body) == b"23456" - def test_content_length_clamps_count_past_eof(self, tmp_path: Path) -> None: - # Requesting more bytes than the file holds must not over-report: - # iter_bytes stops at EOF, so content_length must match the drained size. + @pytest.mark.req("HTTP-40") + def test_content_length_reports_exact_upload_size(self, tmp_path: Path) -> None: + # content_length is the exact number of bytes the body will upload, + # derived from the size captured at construction. + path = tmp_path / "payload.bin" + path.write_bytes(b"0123456789") # 10 bytes + assert FileRequestBody(path).content_length() == 10 + assert FileRequestBody(path, offset=4).content_length() == 6 + assert FileRequestBody(path, offset=4, count=3).content_length() == 3 + + @pytest.mark.req("HTTP-40") + def test_count_past_eof_is_rejected_at_construction(self, tmp_path: Path) -> None: + # offset+count beyond the captured file size is refused fail-fast rather + # than silently clamped at read time. path = tmp_path / "short.bin" path.write_bytes(b"0123456789") # 10 bytes - body = FileRequestBody(path, offset=4, count=1000) - drained = _drain(body) - assert drained == b"456789" # only 6 bytes available past the offset - assert body.content_length() == len(drained) == 6 - - def test_content_length_falls_back_to_count_when_stat_fails(self, tmp_path: Path) -> None: - # When stat raises (e.g. the file does not exist yet), fall back to the - # requested count rather than guessing zero. - body = FileRequestBody(tmp_path / "missing.bin", count=7) - assert body.content_length() == 7 + with pytest.raises(ValueError, match="exceeds the file size"): + FileRequestBody(path, offset=4, count=1000) + + @pytest.mark.req("HTTP-40") + def test_offset_past_eof_is_rejected_at_construction(self, tmp_path: Path) -> None: + path = tmp_path / "short.bin" + path.write_bytes(b"0123456789") # 10 bytes + with pytest.raises(ValueError, match="past the file size"): + FileRequestBody(path, offset=11) + @pytest.mark.req("HTTP-40") + def test_offset_at_eof_is_allowed(self, tmp_path: Path) -> None: + # offset == size is an empty-but-valid window (count must be -1 here). + path = tmp_path / "short.bin" + path.write_bytes(b"0123456789") # 10 bytes + body = FileRequestBody(path, offset=10) + assert body.content_length() == 0 + assert _drain(body) == b"" + + @pytest.mark.req("HTTP-40") + def test_missing_file_is_rejected_at_construction(self, tmp_path: Path) -> None: + # The file must exist at construction — no lazy stat, no deferred error. + with pytest.raises(ValueError, match="does not exist or is not accessible"): + FileRequestBody(tmp_path / "missing.bin", count=7) + + @pytest.mark.req("HTTP-40") + def test_directory_is_rejected_at_construction(self, tmp_path: Path) -> None: + # A directory (or any non-regular file) is refused fail-fast. + with pytest.raises(ValueError, match="not a regular file"): + FileRequestBody(tmp_path) + + @pytest.mark.req("BODY-12") def test_from_file_factory(self, tmp_path: Path) -> None: path = tmp_path / "x.bin" path.write_bytes(b"abc") @@ -151,13 +189,84 @@ def test_from_file_factory(self, tmp_path: Path) -> None: assert isinstance(body, FileRequestBody) assert _drain(body) == b"abc" + @pytest.mark.req("HTTP-40") def test_negative_offset_raises(self, tmp_path: Path) -> None: - with pytest.raises(ValueError): - FileRequestBody(tmp_path / "x", offset=-1) + path = tmp_path / "x" + path.write_bytes(b"data") + with pytest.raises(ValueError, match="offset must be non-negative"): + FileRequestBody(path, offset=-1) + @pytest.mark.req("HTTP-40") def test_zero_count_raises(self, tmp_path: Path) -> None: - with pytest.raises(ValueError): - FileRequestBody(tmp_path / "x", count=0) + path = tmp_path / "x" + path.write_bytes(b"data") + with pytest.raises(ValueError, match="count must be -1"): + FileRequestBody(path, count=0) + + @pytest.mark.req("BODY-11") + def test_concurrent_replays_are_independent(self, tmp_path: Path) -> None: + # Each iter_bytes opens its own file handle, so two interleaved + # iterators must not share a read cursor — both read the full content. + path = tmp_path / "payload.bin" + path.write_bytes(b"0123456789") + body = FileRequestBody(path) + it_a = body.iter_bytes(3) + it_b = body.iter_bytes(3) + # Interleave the two iterators; independent handles mean neither steals + # bytes from the other. + got_a = next(it_a) + got_b = next(it_b) + assert got_a == got_b == b"012" + rest_a = got_a + b"".join(it_a) + rest_b = got_b + b"".join(it_b) + assert rest_a == rest_b == b"0123456789" + + @pytest.mark.req("BODY-11") + def test_construction_fail_fast_contract(self, tmp_path: Path) -> None: + # The full BODY-11 construction contract: the path must exist and be a + # regular file, offset must be non-negative, count must be -1 or + # positive, and offset+count must not exceed the captured file size. + path = tmp_path / "payload.bin" + path.write_bytes(b"0123456789") # 10 bytes + with pytest.raises(ValueError, match="offset must be non-negative"): + FileRequestBody(path, offset=-1) + with pytest.raises(ValueError, match="count must be -1"): + FileRequestBody(path, count=0) + with pytest.raises(ValueError, match="does not exist or is not accessible"): + FileRequestBody(tmp_path / "missing.bin") + with pytest.raises(ValueError, match="not a regular file"): + FileRequestBody(tmp_path) + with pytest.raises(ValueError, match="exceeds the file size"): + FileRequestBody(path, offset=6, count=8) + # The 'rest of file' sentinel and an in-window explicit count are valid. + assert FileRequestBody(path).content_length() == 10 + assert FileRequestBody(path, offset=6, count=4).content_length() == 4 + + @pytest.mark.req("BODY-13") + def test_short_read_raises_naming_transferred_of_total(self, tmp_path: Path) -> None: + # A file truncated AFTER construction (TOCTOU) would deliver fewer bytes + # than the declared content length; the transfer must not complete + # silently with a truncated body — it raises naming transferred-of-total. + path = tmp_path / "payload.bin" + path.write_bytes(b"0123456789") # 10 bytes at construction + body = FileRequestBody(path) + assert body.content_length() == 10 # declared to the transport + path.write_bytes(b"012") # shrink to 3 bytes before the transfer + with pytest.raises(OSError, match="transferred 3 of 10 bytes"): + _drain(body) + + @pytest.mark.req("BODY-13") + def test_short_read_with_explicit_count_names_transferred_of_total( + self, tmp_path: Path + ) -> None: + # The same short-write detection applies to an explicit-count window. + path = tmp_path / "payload.bin" + path.write_bytes(b"0123456789") # 10 bytes + body = FileRequestBody(path, offset=2, count=6) # declares 6 bytes + assert body.content_length() == 6 + path.write_bytes(b"0123") # shrink so only 2 bytes remain past offset 2 + with pytest.raises(OSError, match="transferred 2 of 6 bytes"): + _drain(body) def _make_file_body(tmp_path: Path) -> RequestBody: diff --git a/packages/dexpace-sdk-core/tests/http/test_request_conditions.py b/packages/dexpace-sdk-core/tests/http/test_request_conditions.py index cfcfe69..916b37c 100644 --- a/packages/dexpace-sdk-core/tests/http/test_request_conditions.py +++ b/packages/dexpace-sdk-core/tests/http/test_request_conditions.py @@ -7,6 +7,8 @@ from datetime import UTC, datetime, timedelta, timezone +import pytest + from dexpace.sdk.core.http.common import ETag, RequestConditions, Url from dexpace.sdk.core.http.request import Method, Request @@ -70,3 +72,85 @@ def test_apply_to_returns_new_instance() -> None: result = cond.apply_to(request) assert result is not request assert "if-match" not in request.headers + + +# --- Conformance battery (HTTP-50; RFC 7232 §3) ----------------------------- + + +@pytest.mark.req("HTTP-50") +class TestETagListRendering: + @pytest.mark.req("HTTP-50") + def test_if_match_multiple_comma_joined(self) -> None: + cond = RequestConditions(if_match=[ETag(value="a"), ETag(value="b")]) + assert cond.apply_to(_request()).headers.get("if-match") == '"a", "b"' + + def test_if_match_mixes_strong_and_weak(self) -> None: + cond = RequestConditions(if_match=[ETag(value="a"), ETag(value="b", weak=True)]) + assert cond.apply_to(_request()).headers.get("if-match") == '"a", W/"b"' + + def test_if_none_match_wildcard(self) -> None: + cond = RequestConditions(if_none_match=[ETag(value="*")]) + # RFC 7232 §3.2: the wildcard is emitted bare, never quoted. + assert cond.apply_to(_request()).headers.get("if-none-match") == "*" + + def test_wildcard_only_special_cased_when_single_strong(self) -> None: + # A ``*`` alongside another tag is an ordinary (quoted) entity-tag. + cond = RequestConditions(if_match=[ETag(value="*"), ETag(value="a")]) + assert cond.apply_to(_request()).headers.get("if-match") == '"*", "a"' + + def test_empty_etag_list_raises(self) -> None: + cond = RequestConditions(if_match=[]) + with pytest.raises(ValueError): + cond.apply_to(_request()) + + +class TestHeaderSelection: + def test_no_conditions_adds_no_headers(self) -> None: + result = RequestConditions().apply_to(_request()) + for name in ("if-match", "if-none-match", "if-modified-since", "if-unmodified-since"): + assert name not in result.headers + + def test_all_four_conditions_render_all_headers(self) -> None: + when = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + cond = RequestConditions( + if_match=[ETag(value="a")], + if_none_match=[ETag(value="b")], + if_modified_since=when, + if_unmodified_since=when, + ) + headers = cond.apply_to(_request()).headers + assert headers.get("if-match") == '"a"' + assert headers.get("if-none-match") == '"b"' + assert headers.get("if-modified-since") == "Mon, 01 Jan 2024 12:00:00 GMT" + assert headers.get("if-unmodified-since") == "Mon, 01 Jan 2024 12:00:00 GMT" + + @pytest.mark.req("HTTP-50") + def test_apply_to_replaces_existing_conditional_header(self) -> None: + base = _request().with_header("if-match", '"stale"') + result = RequestConditions(if_match=[ETag(value="fresh")]).apply_to(base) + assert result.headers.get("if-match") == '"fresh"' + + +class TestHttpDateFormatting: + @pytest.mark.req("HTTP-50") + def test_utc_datetime_rendered_as_imf_fixdate(self) -> None: + when = datetime(2024, 3, 5, 8, 9, 10, tzinfo=UTC) + cond = RequestConditions(if_modified_since=when) + assert cond.apply_to(_request()).headers.get("if-modified-since") == ( + "Tue, 05 Mar 2024 08:09:10 GMT" + ) + + def test_non_utc_offset_normalized(self) -> None: + plus_five = timezone(timedelta(hours=5)) + when = datetime(2024, 1, 1, 17, 0, 0, tzinfo=plus_five) + cond = RequestConditions(if_unmodified_since=when) + assert cond.apply_to(_request()).headers.get("if-unmodified-since") == ( + "Mon, 01 Jan 2024 12:00:00 GMT" + ) + + def test_naive_datetime_treated_as_utc(self) -> None: + naive = datetime(2024, 1, 1, 12, 0, 0) + cond = RequestConditions(if_modified_since=naive) + assert cond.apply_to(_request()).headers.get("if-modified-since") == ( + "Mon, 01 Jan 2024 12:00:00 GMT" + ) diff --git a/packages/dexpace-sdk-core/tests/http/test_response.py b/packages/dexpace-sdk-core/tests/http/test_response.py index 8d33b0c..020fe0f 100644 --- a/packages/dexpace-sdk-core/tests/http/test_response.py +++ b/packages/dexpace-sdk-core/tests/http/test_response.py @@ -1,19 +1,123 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Tests for ``Response`` immutability and context-manager behaviour.""" +"""Tests for ``Response`` immutability, parsing, reprs, and close semantics.""" from __future__ import annotations +import threading +import time +from collections.abc import Iterator +from dataclasses import FrozenInstanceError, dataclass + +import pytest + from dexpace.sdk.core.http.common import Headers, Protocol, Url from dexpace.sdk.core.http.request import Method, Request from dexpace.sdk.core.http.response import Response, ResponseBody, Status +@dataclass(frozen=True, slots=True) +class _Pet: + name: str + + def _request() -> Request: return Request(method=Method.GET, url=Url.parse("https://example.com/")) +def _response(body: ResponseBody | None = None) -> Response: + return Response( + request=_request(), + protocol=Protocol.HTTP_1_1, + status=Status.OK, + body=body, + ) + + +class _CountingBody(ResponseBody): + """Response body that counts how many times it is read (drained).""" + + __slots__ = ("_data", "_lock", "closed", "reads") + + def __init__(self, data: bytes) -> None: + self._data = data + self._lock = threading.Lock() + self.reads = 0 + self.closed = False + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return len(self._data) + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + with self._lock: + self.reads += 1 + # Widen the race window: while the parse latch holds its lock during + # this single drain, every other thread is parked at the latch. + time.sleep(0.02) + yield self._data + self.close() + + def close(self) -> None: + self.closed = True + + +class _SingleUseFailingBody(ResponseBody): + """Single-use body of invalid JSON that refuses a second read. + + Drives the failure-memoization path: the first drain succeeds and decoding + it raises, and any second drain raises a *different* error — so a test can + tell whether the parse latch re-read the body or replayed the cached failure. + """ + + __slots__ = ("reads",) + + def __init__(self) -> None: + self.reads = 0 + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return -1 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + self.reads += 1 + if self.reads > 1: + raise RuntimeError("body already consumed") + yield b"not valid json{" + + def close(self) -> None: + pass + + +class _TrackingBody(ResponseBody): + """Response body that records whether it was read and/or closed.""" + + __slots__ = ("closed", "read") + + def __init__(self) -> None: + self.closed = False + self.read = False + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return 0 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + self.read = True + yield b"" + self.close() + + def close(self) -> None: + self.closed = True + + def test_is_success_property() -> None: r = Response(request=_request(), protocol=Protocol.HTTP_1_1, status=Status.OK) assert r.is_success @@ -21,6 +125,7 @@ def test_is_success_property() -> None: assert not r2.is_success +@pytest.mark.req("HTTP-11") def test_status_group_properties() -> None: req = _request() redirect = Response(request=req, protocol=Protocol.HTTP_1_1, status=Status.FOUND) @@ -40,6 +145,7 @@ def test_status_group_properties() -> None: assert not server_err.is_client_error +@pytest.mark.req("HTTP-43") def test_close_idempotent_with_no_body() -> None: r = Response(request=_request(), protocol=Protocol.HTTP_1_1, status=Status.OK) r.close() @@ -74,7 +180,211 @@ def test_with_status() -> None: assert r.status is Status.OK +@pytest.mark.req("HTTP-6") def test_default_headers_empty() -> None: r = Response(request=_request(), protocol=Protocol.HTTP_1_1, status=Status.OK) assert isinstance(r.headers, Headers) assert len(r.headers) == 0 + + +# --- Validation grammar ----------------------------------------------------- + + +@pytest.mark.req("HTTP-1", "XCUT-15") +def test_frozen_rejects_attribute_assignment() -> None: + resp = _response() + with pytest.raises(FrozenInstanceError): + resp.status = Status.NOT_FOUND # type: ignore[misc] + + +@pytest.mark.req("HTTP-4") +def test_missing_request_raises_field_named_error() -> None: + with pytest.raises(ValueError, match=r"^request is required$"): + Response(request=None, protocol=Protocol.HTTP_1_1, status=Status.OK) # type: ignore[arg-type] + + +def test_missing_protocol_raises_field_named_error() -> None: + with pytest.raises(ValueError, match=r"^protocol is required$"): + Response(request=_request(), protocol=None, status=Status.OK) # type: ignore[arg-type] + + +def test_missing_status_raises_field_named_error() -> None: + with pytest.raises(ValueError, match=r"^status is required$"): + Response(request=_request(), protocol=Protocol.HTTP_1_1, status=None) # type: ignore[arg-type] + + +# --- parse: decoding + memoizing latch -------------------------------------- + + +def test_parse_decodes_json_body_with_default_serde() -> None: + resp = _response(ResponseBody.from_bytes(b'{"a": 1, "b": 2}')) + assert resp.parse(dict[str, int]) == {"a": 1, "b": 2} + + +def test_parse_decodes_generic_list() -> None: + resp = _response(ResponseBody.from_bytes(b"[1, 2, 3]")) + assert resp.parse(list[int]) == [1, 2, 3] + + +def test_parse_decodes_list_of_dataclasses() -> None: + resp = _response(ResponseBody.from_bytes(b'[{"name": "rex"}, {"name": "mia"}]')) + assert resp.parse(list[_Pet]) == [_Pet("rex"), _Pet("mia")] + + +def test_parse_without_body_raises() -> None: + resp = _response() + with pytest.raises(ValueError, match="no body to parse"): + resp.parse(list[int]) + + +def test_parse_memoizes_and_reads_body_once() -> None: + body = _CountingBody(b"[1, 2, 3]") + resp = _response(body) + first = resp.parse(list[int]) + second = resp.parse(list[int]) + assert first == [1, 2, 3] + assert second is first # cached object, not re-decoded + assert body.reads == 1 + + +def test_replace_resets_the_parse_latch() -> None: + resp = _response(ResponseBody.from_bytes(b"[1]")) + assert resp.parse(list[int]) == [1] + # A derived response carries its own fresh, unlatched cache (state is not + # aliased across ``replace``). + resp2 = resp.with_body(ResponseBody.from_bytes(b"[2]")) + assert resp2.parse(list[int]) == [2] + + +@pytest.mark.req("HTTP-44") +def test_raw_metadata_is_readable_without_consuming_the_body() -> None: + body = _CountingBody(b"[1, 2, 3]") + resp = _response(body) + # status / headers / protocol / reason / request are plain fields, reachable + # with the single-use body still intact. + assert resp.status is Status.OK + assert resp.protocol is Protocol.HTTP_1_1 + assert isinstance(resp.headers, Headers) + assert resp.reason is None + assert resp.request.method is Method.GET + assert body.reads == 0 # none of the metadata reads drained the body + + +@pytest.mark.req("HTTP-44") +def test_parse_memoizes_a_thrown_failure_and_reraises_it() -> None: + from dexpace.sdk.core.errors import DeserializationError + + body = _SingleUseFailingBody() + resp = _response(body) + with pytest.raises(DeserializationError) as first: + resp.parse(list[int]) + assert body.reads == 1 + # A second parse replays the memoized failure verbatim — the same exception + # object — without re-running the decode or re-reading the single-use body + # (a re-read would raise the "body already consumed" RuntimeError instead). + with pytest.raises(DeserializationError) as second: + resp.parse(list[int]) + assert second.value is first.value + assert body.reads == 1 + + +@pytest.mark.req("HTTP-44") +def test_parse_memoizes_a_none_success() -> None: + # A null (``None``) success is memoized just like any other value: the body + # is read exactly once and the cached ``None`` is returned on later calls. + body = _CountingBody(b"null") + resp = _response(body) + assert resp.parse(object) is None + assert resp.parse(object) is None + assert body.reads == 1 + + +@pytest.mark.req("HTTP-45") +def test_parse_latch_deserializes_exactly_once_under_concurrent_first_access() -> None: + thread_count = 24 + body = _CountingBody(b"[1, 2, 3]") + resp = _response(body) + results: list[list[int]] = [] + errors: list[BaseException] = [] + barrier = threading.Barrier(thread_count) + + def worker() -> None: + try: + barrier.wait() + results.append(resp.parse(list[int])) + except Exception as exc: # pragma: no cover - recorded for assertion + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(thread_count)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert not errors + assert body.reads == 1 # exactly one deserialization despite the race + assert len(results) == thread_count + first = results[0] + assert all(result is first for result in results) # every caller shares it + assert first == [1, 2, 3] + + +# --- Repr redaction --------------------------------------------------------- + + +def test_repr_redacts_url_and_credential_headers() -> None: + req = Request( + method=Method.GET, + url=Url.parse("https://user:secret@example.com/x?token=abc&page=2"), + ).with_header("Authorization", "Bearer super-secret") + resp = Response(request=req, protocol=Protocol.HTTP_1_1, status=Status.OK).with_header( + "Set-Cookie", "sid=topsecret" + ) + rendered = repr(resp) + assert "secret" not in rendered # userinfo, bearer token, and cookie + assert "abc" not in rendered # non-allow-listed query value + assert "REDACTED" in rendered + assert "page=2" in rendered # allow-listed query key survives + + +def test_repr_omits_the_private_parse_latch() -> None: + rendered = repr(_response(ResponseBody.from_bytes(b"[]"))) + assert "_latch" not in rendered + assert "_ParseLatch" not in rendered + + +# --- Close-once semantics across paths -------------------------------------- + + +@pytest.mark.req("BODY-15", "HTTP-41") +def test_close_releases_body_without_reading() -> None: + body = _TrackingBody() + _response(body).close() + assert body.closed + assert not body.read # connection released even though nothing was read + + +@pytest.mark.req("BODY-15", "HTTP-41") +def test_close_is_idempotent() -> None: + body = _TrackingBody() + resp = _response(body) + resp.close() + resp.close() + assert body.closed # repeated close does not raise + + +@pytest.mark.req("HTTP-43") +def test_context_manager_closes_on_exit() -> None: + body = _TrackingBody() + resp = _response(body) + with resp as same: + assert same is resp + assert body.closed + + +@pytest.mark.req("HTTP-43") +def test_context_manager_closes_on_exception() -> None: + body = _TrackingBody() + with pytest.raises(RuntimeError, match="boom"), _response(body): + raise RuntimeError("boom") + assert body.closed diff --git a/packages/dexpace-sdk-core/tests/http/test_response_body.py b/packages/dexpace-sdk-core/tests/http/test_response_body.py index cc177ce..424918f 100644 --- a/packages/dexpace-sdk-core/tests/http/test_response_body.py +++ b/packages/dexpace-sdk-core/tests/http/test_response_body.py @@ -19,6 +19,7 @@ def test_from_bytes_returns_bytes() -> None: assert body.bytes() == b"hello" +@pytest.mark.req("BODY-16", "HTTP-42") def test_string_uses_media_type_charset() -> None: body = ResponseBody.from_bytes( "héllo".encode("latin-1"), @@ -27,6 +28,7 @@ def test_string_uses_media_type_charset() -> None: assert body.string() == "héllo" +@pytest.mark.req("HTTP-42") def test_string_defaults_to_utf8() -> None: body = ResponseBody.from_bytes("héllo".encode()) assert body.string() == "héllo" @@ -38,6 +40,7 @@ def test_context_manager_closes() -> None: assert b is body +@pytest.mark.req("BODY-14", "BODY-16", "HTTP-41") def test_single_use_after_bytes() -> None: body = ResponseBody.from_bytes(b"once") assert body.bytes() == b"once" @@ -61,6 +64,7 @@ def test_snapshot_returns_full_payload(self) -> None: wrapped = LoggableResponseBody(ResponseBody.from_bytes(b"hello world")) assert wrapped.snapshot() == b"hello world" + @pytest.mark.req("BODY-14", "BODY-23") def test_iter_bytes_repeatable_after_snapshot(self) -> None: wrapped = LoggableResponseBody(ResponseBody.from_bytes(b"abc")) _ = wrapped.snapshot() diff --git a/packages/dexpace-sdk-core/tests/http/test_single_use_bodies.py b/packages/dexpace-sdk-core/tests/http/test_single_use_bodies.py new file mode 100644 index 0000000..58494b7 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_single_use_bodies.py @@ -0,0 +1,413 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Certification battery for single-use body semantics + the lock-protected once-guard. + +Covers, for the stream/iter-backed ``RequestBody`` / ``ResponseBody`` families +and their async twins: + +* double-consume raises ``RuntimeError`` with a consistent, documented message; +* the "consumed" decision is an *eager*, lock-protected check-then-act — a + second ``iter_bytes`` / ``aiter_bytes`` call raises at call time, before the + returned iterator is advanced, so two undrained iterators can never both + believe the body is fresh; +* N threads racing the very first consumption yield exactly one winner. The + guard is a ``threading.Lock``, never a bare ``bool``, so this holds without + relying on GIL atomicity of the flag read/write. (A free-threaded CPython CI + row is future infra; the code is correct regardless of interpreter build.) +* ``to_replayable`` round-trips identical bytes when called before first + consumption and errors when called after; +* ``FileRequestBody`` opens a fresh handle per read and holds no descriptor + open across reads; +* ``content_length`` / ``media_type`` / ``is_replayable`` contracts stay + consistent. +""" + +from __future__ import annotations + +import threading +from collections.abc import AsyncIterator, Callable +from io import BytesIO +from pathlib import Path + +import pytest + +from dexpace.sdk.core.http._once_guard import _OnceGuard +from dexpace.sdk.core.http.request import ( + AsyncRequestBody, + FileRequestBody, + RequestBody, +) +from dexpace.sdk.core.http.response import AsyncResponseBody, ResponseBody + +_PAYLOAD = b"payload" + + +# ----- helpers ------------------------------------------------------------ + + +def _drain(body: RequestBody | ResponseBody) -> bytes: + return b"".join(body.iter_bytes()) + + +async def _adrain(body: AsyncRequestBody | AsyncResponseBody) -> bytes: + return b"".join([chunk async for chunk in body.aiter_bytes()]) + + +class _AsyncBytesStream: + """Minimal ``SupportsAsyncRead`` over an in-memory buffer.""" + + def __init__(self, data: bytes) -> None: + self._buf = BytesIO(data) + self.closed = False + + async def read(self, size: int = -1) -> bytes: + return self._buf.read(size) + + async def close(self) -> object: + self.closed = True + return None + + +async def _one_chunk(data: bytes) -> AsyncIterator[bytes]: + yield data + + +# ----- the guard itself --------------------------------------------------- + + +class TestOnceGuard: + def test_first_claim_wins_second_raises(self) -> None: + guard = _OnceGuard() + guard.claim("boom") + with pytest.raises(RuntimeError, match="boom"): + guard.claim("boom") + + @pytest.mark.req("BODY-7", "HTTP-37") + def test_race_yields_exactly_one_winner(self) -> None: + # N threads race the very first claim. The lock — not GIL atomicity — + # guarantees exactly one winner and N-1 RuntimeErrors. + n = 64 + guard = _OnceGuard() + barrier = threading.Barrier(n) + lock = threading.Lock() + wins = 0 + losses = 0 + + def worker() -> None: + nonlocal wins, losses + barrier.wait(timeout=5) + try: + guard.claim("already claimed") + except RuntimeError: + with lock: + losses += 1 + else: + with lock: + wins += 1 + + threads = [threading.Thread(target=worker) for _ in range(n)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert wins == 1 + assert losses == n - 1 + + +# ----- sync request bodies ------------------------------------------------ + +_SyncReqFactory = Callable[[], RequestBody] + +_SYNC_REQUEST_SINGLE_USE: list[tuple[str, _SyncReqFactory]] = [ + ("stream", lambda: RequestBody.from_stream(BytesIO(_PAYLOAD), content_length=7)), + ("iter", lambda: RequestBody.from_iter([b"pay", b"load"], content_length=7)), +] + + +class TestSyncRequestSingleUse: + @pytest.mark.req("BODY-6", "HTTP-37") + @pytest.mark.parametrize(("_name", "factory"), _SYNC_REQUEST_SINGLE_USE) + def test_double_consume_raises_documented_message( + self, _name: str, factory: _SyncReqFactory + ) -> None: + body = factory() + assert _drain(body) == _PAYLOAD + with pytest.raises(RuntimeError, match="already called"): + _drain(body) + + @pytest.mark.parametrize(("_name", "factory"), _SYNC_REQUEST_SINGLE_USE) + def test_second_iter_bytes_raises_eagerly_without_draining( + self, _name: str, factory: _SyncReqFactory + ) -> None: + body = factory() + first = body.iter_bytes() + with pytest.raises(RuntimeError, match="already called"): + body.iter_bytes() + # The single valid iterator still drains end to end. + assert b"".join(first) == _PAYLOAD + + @pytest.mark.req("BODY-1") + @pytest.mark.parametrize(("_name", "factory"), _SYNC_REQUEST_SINGLE_USE) + def test_contracts_present_and_consistent(self, _name: str, factory: _SyncReqFactory) -> None: + body = factory() + assert body.is_replayable() is False + assert body.content_length() == 7 + assert body.media_type() is None + + @pytest.mark.req("BODY-35", "HTTP-36") + def test_content_length_unknown_is_minus_one(self) -> None: + stream = RequestBody.from_stream(BytesIO(_PAYLOAD)) + iters = RequestBody.from_iter([_PAYLOAD]) + assert stream.content_length() == -1 + assert iters.content_length() == -1 + + @pytest.mark.req("BODY-21", "BODY-3") + @pytest.mark.parametrize(("_name", "factory"), _SYNC_REQUEST_SINGLE_USE) + def test_to_replayable_before_consumption_round_trips( + self, _name: str, factory: _SyncReqFactory + ) -> None: + replayable = factory().to_replayable() + assert replayable.is_replayable() is True + assert _drain(replayable) == _PAYLOAD + # Identical bytes on replay. + assert _drain(replayable) == _PAYLOAD + + @pytest.mark.req("BODY-3", "HTTP-37") + @pytest.mark.parametrize(("_name", "factory"), _SYNC_REQUEST_SINGLE_USE) + def test_to_replayable_after_consumption_errors( + self, _name: str, factory: _SyncReqFactory + ) -> None: + body = factory() + assert _drain(body) == _PAYLOAD + with pytest.raises(RuntimeError, match="already called"): + body.to_replayable() + + @pytest.mark.req("BODY-7") + def test_n_threads_racing_first_consume_one_winner(self) -> None: + n = 32 + body = RequestBody.from_stream(BytesIO(_PAYLOAD)) + barrier = threading.Barrier(n) + lock = threading.Lock() + winners: list[bytes] = [] + errors: list[BaseException] = [] + + def worker() -> None: + barrier.wait(timeout=5) + try: + data = b"".join(body.iter_bytes()) + except RuntimeError as exc: + with lock: + errors.append(exc) + else: + with lock: + winners.append(data) + + threads = [threading.Thread(target=worker) for _ in range(n)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert winners == [_PAYLOAD] + assert len(errors) == n - 1 + assert all(isinstance(exc, RuntimeError) for exc in errors) + + +# ----- sync response bodies ----------------------------------------------- + +_SyncRespFactory = Callable[[], ResponseBody] + +_SYNC_RESPONSE_SINGLE_USE: list[tuple[str, _SyncRespFactory]] = [ + ("bytes", lambda: ResponseBody.from_bytes(_PAYLOAD)), + ("stream", lambda: ResponseBody.from_stream(BytesIO(_PAYLOAD), content_length=7)), +] + + +class TestSyncResponseSingleUse: + @pytest.mark.parametrize(("_name", "factory"), _SYNC_RESPONSE_SINGLE_USE) + def test_double_consume_raises_documented_message( + self, _name: str, factory: _SyncRespFactory + ) -> None: + body = factory() + assert _drain(body) == _PAYLOAD + with pytest.raises(RuntimeError, match="already been consumed"): + _drain(body) + + @pytest.mark.parametrize(("_name", "factory"), _SYNC_RESPONSE_SINGLE_USE) + def test_invalid_chunk_size_raises_before_iteration( + self, _name: str, factory: _SyncRespFactory + ) -> None: + # Eager validation: the ValueError must fire at call time, not on the + # first ``next()`` of the returned iterator. + body = factory() + with pytest.raises(ValueError, match="chunk_size"): + body.iter_bytes(0) + + @pytest.mark.parametrize(("_name", "factory"), _SYNC_RESPONSE_SINGLE_USE) + def test_second_iter_bytes_raises_eagerly_without_draining( + self, _name: str, factory: _SyncRespFactory + ) -> None: + body = factory() + first = body.iter_bytes() + with pytest.raises(RuntimeError, match="already been consumed"): + body.iter_bytes() + assert b"".join(first) == _PAYLOAD + + def test_n_threads_racing_first_consume_one_winner(self) -> None: + n = 32 + body = ResponseBody.from_stream(BytesIO(_PAYLOAD)) + barrier = threading.Barrier(n) + lock = threading.Lock() + winners: list[bytes] = [] + errors: list[BaseException] = [] + + def worker() -> None: + barrier.wait(timeout=5) + try: + data = b"".join(body.iter_bytes()) + except RuntimeError as exc: + with lock: + errors.append(exc) + else: + with lock: + winners.append(data) + + threads = [threading.Thread(target=worker) for _ in range(n)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert winners == [_PAYLOAD] + assert len(errors) == n - 1 + + +# ----- file request body ownership ---------------------------------------- + + +class TestFileRequestBodyOwnership: + def test_fresh_handle_per_read_reflects_mutated_file(self, tmp_path: Path) -> None: + # A fresh handle per read means the body caches no descriptor and no + # bytes: rewriting the file between reads is visible on the next read. + # The rewrite keeps the same length so the declared content length + # (captured at construction) still matches — only the bytes change. + path = tmp_path / "payload.bin" + path.write_bytes(b"first") + body = FileRequestBody(path) + assert _drain(body) == b"first" + path.write_bytes(b"third") # same length, different content + assert _drain(body) == b"third" + + @pytest.mark.req("BODY-8") + def test_holds_no_descriptor_open_across_reads(self, tmp_path: Path) -> None: + # After a full read the file can be deleted and recreated — proof that + # no caller-supplied descriptor is held open across reads. + path = tmp_path / "payload.bin" + path.write_bytes(b"abc") + body = FileRequestBody(path) + assert _drain(body) == b"abc" + path.unlink() + path.write_bytes(b"xyz") + assert _drain(body) == b"xyz" + + def test_replayable_flag(self, tmp_path: Path) -> None: + path = tmp_path / "payload.bin" + path.write_bytes(b"abc") + assert FileRequestBody(path).is_replayable() is True + + +# ----- async request bodies ----------------------------------------------- + +_AsyncReqFactory = Callable[[], AsyncRequestBody] + + +def _async_request_single_use() -> list[tuple[str, _AsyncReqFactory]]: + return [ + ("stream", lambda: AsyncRequestBody.from_async_stream(_AsyncBytesStream(_PAYLOAD))), + ("iter", lambda: AsyncRequestBody.from_async_iter(_one_chunk(_PAYLOAD))), + ] + + +class TestAsyncRequestSingleUse: + @pytest.mark.parametrize(("_name", "factory"), _async_request_single_use()) + async def test_double_consume_raises_documented_message( + self, _name: str, factory: _AsyncReqFactory + ) -> None: + body = factory() + assert await _adrain(body) == _PAYLOAD + with pytest.raises(RuntimeError, match="already called"): + await _adrain(body) + + @pytest.mark.parametrize(("_name", "factory"), _async_request_single_use()) + async def test_invalid_chunk_size_raises_before_iteration( + self, _name: str, factory: _AsyncReqFactory + ) -> None: + body = factory() + with pytest.raises(ValueError, match="chunk_size"): + body.aiter_bytes(0) + + @pytest.mark.parametrize(("_name", "factory"), _async_request_single_use()) + async def test_second_aiter_bytes_raises_eagerly_without_draining( + self, _name: str, factory: _AsyncReqFactory + ) -> None: + body = factory() + first = body.aiter_bytes() + with pytest.raises(RuntimeError, match="already called"): + body.aiter_bytes() + assert b"".join([chunk async for chunk in first]) == _PAYLOAD + + async def test_to_replayable_before_consumption_round_trips(self) -> None: + body = AsyncRequestBody.from_async_iter(_one_chunk(_PAYLOAD)) + replayable = await body.to_replayable() + assert replayable.is_replayable() is True + assert await _adrain(replayable) == _PAYLOAD + assert await _adrain(replayable) == _PAYLOAD + + async def test_to_replayable_after_consumption_errors(self) -> None: + body = AsyncRequestBody.from_async_stream(_AsyncBytesStream(_PAYLOAD)) + assert await _adrain(body) == _PAYLOAD + with pytest.raises(RuntimeError, match="already called"): + await body.to_replayable() + + +# ----- async response bodies ---------------------------------------------- + +_AsyncRespFactory = Callable[[], AsyncResponseBody] + + +def _async_response_single_use() -> list[tuple[str, _AsyncRespFactory]]: + return [ + ("bytes", lambda: AsyncResponseBody.from_bytes(_PAYLOAD)), + ("stream", lambda: AsyncResponseBody.from_async_stream(_AsyncBytesStream(_PAYLOAD))), + ] + + +class TestAsyncResponseSingleUse: + @pytest.mark.parametrize(("_name", "factory"), _async_response_single_use()) + async def test_double_consume_raises_documented_message( + self, _name: str, factory: _AsyncRespFactory + ) -> None: + body = factory() + assert await _adrain(body) == _PAYLOAD + with pytest.raises(RuntimeError, match="already been consumed"): + await _adrain(body) + + @pytest.mark.parametrize(("_name", "factory"), _async_response_single_use()) + async def test_invalid_chunk_size_raises_before_iteration( + self, _name: str, factory: _AsyncRespFactory + ) -> None: + body = factory() + with pytest.raises(ValueError, match="chunk_size"): + body.aiter_bytes(0) + + @pytest.mark.parametrize(("_name", "factory"), _async_response_single_use()) + async def test_second_aiter_bytes_raises_eagerly_without_draining( + self, _name: str, factory: _AsyncRespFactory + ) -> None: + body = factory() + first = body.aiter_bytes() + with pytest.raises(RuntimeError, match="already been consumed"): + body.aiter_bytes() + assert b"".join([chunk async for chunk in first]) == _PAYLOAD diff --git a/packages/dexpace-sdk-core/tests/http/test_status_properties.py b/packages/dexpace-sdk-core/tests/http/test_status_properties.py new file mode 100644 index 0000000..638c3ff --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_status_properties.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Property-based tests pinning ``Status`` totality and code-equality. + +Invariants covered: + +- Totality over *any* integer: ``Status(code)`` never raises for an integer + code, in or out of the registered 100..599 band — an out-of-band code + synthesizes an ``UNKNOWN_`` pseudo-member carrying the raw value + rather than being rejected, so vendor/unusual codes flow through the SDK + intact. Only a non-integer lookup raises ``ValueError``. +- Equality is by numeric code: ``Status(code) == code`` and + ``Status(a) == Status(b)`` iff ``a == b``; construction is idempotent. +- Band predicates agree with the arithmetic of the code's leading digit. + +The example count and deadline are governed by the capped "ci" Hypothesis +profile registered in ``tests/conftest.py``. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.http.response import Status + +_in_range = st.integers(min_value=100, max_value=599) + + +@given(code=st.integers()) +def test_totality_over_ints(code: int) -> None: + """Any integer constructs a ``Status`` without raising, in or out of band.""" + status = Status(code) + assert int(status) == code + assert status == code + assert Status(int(status)) == status # construction is idempotent + + +def test_non_integer_lookup_raises() -> None: + """Only a non-integer lookup is rejected — the totality is over ints.""" + with pytest.raises(ValueError): + Status("200") # type: ignore[arg-type] + + +@given(code=_in_range) +def test_band_classification_matches_arithmetic(code: int) -> None: + """Each band predicate matches the arithmetic range of the code.""" + status = Status(code) + assert status.is_informational == (100 <= code < 200) + assert status.is_success == (200 <= code < 300) + assert status.is_redirect == (300 <= code < 400) + assert status.is_client_error == (400 <= code < 500) + assert status.is_server_error == (500 <= code < 600) + assert status.is_error == (status.is_client_error or status.is_server_error) + + +@pytest.mark.req("HTTP-12") +@given(left=_in_range, right=_in_range) +def test_equality_is_by_code(left: int, right: int) -> None: + """Two ``Status`` values are equal iff their integer codes are equal.""" + assert (Status(left) == Status(right)) == (left == right) diff --git a/packages/dexpace-sdk-core/tests/http/test_status_unknown.py b/packages/dexpace-sdk-core/tests/http/test_status_unknown.py index b8af9bd..754ee64 100644 --- a/packages/dexpace-sdk-core/tests/http/test_status_unknown.py +++ b/packages/dexpace-sdk-core/tests/http/test_status_unknown.py @@ -1,7 +1,14 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Tests for lenient `Status` lookup of unregistered HTTP codes.""" +"""Conformance battery for ``Status`` (HTTP-10, HTTP-11, HTTP-12). + +`Status` lookup is total over integers (HTTP-10): a vendor ``499``, a +Cloudflare ``520``..``526``, or any other unregistered — even out-of-band — +code constructs without raising and carries the raw int, so a broken server +response is never discarded before a policy can react. Equality is by code +(HTTP-12) and the band helpers classify by hundreds range (HTTP-11). +""" from __future__ import annotations @@ -10,52 +17,146 @@ from dexpace.sdk.core.http.response import Status -def test_unknown_in_range_code_is_synthesized() -> None: - status = Status(218) - assert isinstance(status, Status) - assert int(status) == 218 - assert status == 218 - assert status.name == "UNKNOWN_218" - assert status.value == 218 - - -def test_unknown_success_code_classifies_as_success() -> None: - status = Status(218) - assert status.is_success - assert not status.is_redirect - assert not status.is_error - - -def test_unknown_server_error_code_classifies_as_server_error() -> None: - status = Status(599) - assert int(status) == 599 - assert status.name == "UNKNOWN_599" - assert status.is_server_error - assert status.is_error - assert not status.is_success - - -@pytest.mark.parametrize( - "code,predicate", - [ - (199, "is_informational"), - (250, "is_success"), - (399, "is_redirect"), - (450, "is_client_error"), - (550, "is_server_error"), - ], -) -def test_unknown_codes_band_classification(code: int, predicate: str) -> None: - status = Status(code) - assert getattr(status, predicate) - - -def test_known_code_still_returns_named_member() -> None: - assert Status(200) is Status.OK - assert Status(404) is Status.NOT_FOUND - - -@pytest.mark.parametrize("code", [42, 99, 600, 1000, -1]) -def test_out_of_range_code_still_raises(code: int) -> None: - with pytest.raises(ValueError): - Status(code) +class TestUnregisteredCodesSynthesized: + def test_unknown_in_range_code_is_synthesized(self) -> None: + status = Status(218) + assert isinstance(status, Status) + assert int(status) == 218 + assert status == 218 + assert status.name == "UNKNOWN_218" + assert status.value == 218 + + def test_unknown_success_code_classifies_as_success(self) -> None: + status = Status(218) + assert status.is_success + assert not status.is_redirect + assert not status.is_error + + def test_unknown_server_error_code_classifies_as_server_error(self) -> None: + status = Status(599) + assert int(status) == 599 + assert status.name == "UNKNOWN_599" + assert status.is_server_error + assert status.is_error + assert not status.is_success + + @pytest.mark.parametrize( + "code", + [499, 520, 521, 522, 523, 524, 525, 526], + ids=["nginx-499", *[f"cf-{c}" for c in range(520, 527)]], + ) + def test_vendor_codes_pass_through_construction(self, code: int) -> None: + # HTTP-10: vendor codes outside the IANA registry must not be rejected. + status = Status(code) + assert int(status) == code + assert status.name == f"UNKNOWN_{code}" + + @pytest.mark.req("HTTP-10") + def test_known_code_still_returns_named_member(self) -> None: + assert Status(200) is Status.OK + assert Status(404) is Status.NOT_FOUND + + +@pytest.mark.req("HTTP-10") +class TestTotalityOverInts: + """HTTP-10: any integer constructs without raising; equality is by code.""" + + @pytest.mark.req("HTTP-10") + def test_out_of_band_codes_construct_instead_of_raising(self) -> None: + # These previously raised; a total lookup surfaces them with the raw int + # so an odd status never costs the caller the live response. + for code in (42, 99, 600, 700, 999, 1000, 0, -1): + status = Status(code) + assert int(status) == code + assert status == code + assert status.name == f"UNKNOWN_{code}" + + def test_totality_property_over_wide_range(self) -> None: + codes = [*range(-5, 605), 700, 999, 1000, 100_000, -1_000] + for code in codes: + status = Status(code) # must never raise + assert isinstance(status, Status) + assert int(status) == code + assert status == code # HTTP-12: equality by code + + def test_repeated_lookups_compare_equal(self) -> None: + # Synthesized pseudo-members are not interned, but equality is by code + # so repeated lookups of the same code compare (and hash) equal. + assert Status(499) == Status(499) + assert hash(Status(499)) == hash(Status(499)) + assert Status(499) != Status(500) + + def test_non_integer_lookup_raises(self) -> None: + # A non-integer that does not coincide with a registered code value has + # no member and is rejected (only ints are synthesized). + for bad in ("200", 4.5, None, b"200"): + with pytest.raises(ValueError): + Status(bad) # type: ignore[arg-type] + + +@pytest.mark.req("HTTP-12") +class TestEqualityByCode: + """HTTP-12: a Status compares and hashes as its integer code.""" + + def test_named_member_equals_int(self) -> None: + # ``ok_code`` is typed ``int`` (not a literal) so the equality is checked + # via the IntEnum's int-ness rather than rejected as a literal mismatch. + ok_code: int = 200 + assert ok_code == Status.OK + assert Status(200) == Status.OK + + @pytest.mark.req("HTTP-12") + def test_synthesized_member_equals_int(self) -> None: + assert Status(499) == 499 + assert Status(499) != 500 + + def test_usable_as_dict_key_and_set_member(self) -> None: + seen = {Status.OK: "ok", Status(499): "vendor"} + # A freshly synthesized member retrieves the entry stored under an equal + # one — the hash/eq-by-code guarantee holds even without interning. + assert seen[Status(200)] == "ok" + assert seen[Status(499)] == "vendor" + assert Status(200) in {Status.OK} + + +@pytest.mark.req("HTTP-11") +class TestBandClassification: + """HTTP-11: is_informational/success/redirect/client_error/server_error/error.""" + + @pytest.mark.req("HTTP-11") + @pytest.mark.parametrize( + "code,predicate", + [ + (100, "is_informational"), + (199, "is_informational"), + (200, "is_success"), + (299, "is_success"), + (300, "is_redirect"), + (399, "is_redirect"), + (400, "is_client_error"), + (499, "is_client_error"), + (500, "is_server_error"), + (599, "is_server_error"), + ], + ) + def test_boundary_codes_land_in_expected_band(self, code: int, predicate: str) -> None: + status = Status(code) + assert getattr(status, predicate) is True + + @pytest.mark.parametrize("code", [400, 404, 499, 500, 503, 599]) + def test_error_covers_client_and_server(self, code: int) -> None: + assert Status(code).is_error is True + + @pytest.mark.parametrize("code", [100, 200, 204, 301, 304]) + def test_error_false_for_non_error_bands(self, code: int) -> None: + assert Status(code).is_error is False + + @pytest.mark.parametrize("code", [0, 42, 99, 600, 700, 999]) + def test_out_of_band_codes_match_no_band(self, code: int) -> None: + status = Status(code) + assert not status.is_informational + assert not status.is_success + assert not status.is_redirect + assert not status.is_client_error + assert not status.is_server_error + assert not status.is_error diff --git a/packages/dexpace-sdk-core/tests/http/test_streaming_lifecycle.py b/packages/dexpace-sdk-core/tests/http/test_streaming_lifecycle.py new file mode 100644 index 0000000..7179a51 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_streaming_lifecycle.py @@ -0,0 +1,178 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Lifecycle discipline for the jsonl / chunked-frame streaming iterators. + +Mirrors the discipline the SSE parser already enforces: the decoders wrap an +underlying byte stream and must eagerly release it — on clean exhaustion, on a +mid-iteration error, and when the consumer abandons iteration part-way — while +never letting a teardown-time close error mask the primary error that ended +iteration. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Generator, Iterable +from typing import Any, cast + +import pytest + +from dexpace.sdk.core.errors import DeserializationError +from dexpace.sdk.core.http.common import ( + aiter_chunked_frame, + aiter_jsonl, + chunked_frame, + iter_jsonl, +) + + +class _ClosableChunks: + """Sync byte source exposing an explicit ``close`` the decoder must call. + + Unlike a generator (whose ``finally`` runs on exhaustion), a plain iterator + is only released when its ``close`` is invoked, so it pins down that the + decoder itself performs the release. + """ + + def __init__(self, chunks: Iterable[bytes], *, fail_close: bool = False) -> None: + self._it = iter(chunks) + self.closed = False + self._fail_close = fail_close + + def __iter__(self) -> _ClosableChunks: + return self + + def __next__(self) -> bytes: + return next(self._it) + + def close(self) -> None: + self.closed = True + if self._fail_close: + raise RuntimeError("upstream close failed") + + +class _AsyncClosableChunks: + """Async twin of `_ClosableChunks`.""" + + def __init__(self, chunks: Iterable[bytes], *, fail_close: bool = False) -> None: + self._chunks = list(chunks) + self._index = 0 + self.closed = False + self._fail_close = fail_close + + def __aiter__(self) -> _AsyncClosableChunks: + return self + + async def __anext__(self) -> bytes: + if self._index >= len(self._chunks): + raise StopAsyncIteration + chunk = self._chunks[self._index] + self._index += 1 + return chunk + + async def aclose(self) -> None: + self.closed = True + if self._fail_close: + raise RuntimeError("upstream aclose failed") + + +def _context_chain(exc: BaseException) -> list[BaseException]: + chain: list[BaseException] = [] + seen: set[int] = set() + node: BaseException | None = exc + while node is not None and id(node) not in seen: + chain.append(node) + seen.add(id(node)) + node = node.__context__ + return chain + + +class TestIterJsonlLifecycle: + def test_closes_upstream_on_exhaustion(self) -> None: + source = _ClosableChunks([b'{"a": 1}\n{"b": 2}\n']) + assert list(iter_jsonl(source)) == [{"a": 1}, {"b": 2}] + assert source.closed is True + + def test_closes_upstream_when_abandoned_mid_iteration(self) -> None: + source = _ClosableChunks([b'{"a": 1}\n{"b": 2}\n{"c": 3}\n']) + stream = cast(Generator[Any, None, None], iter_jsonl(source)) + assert next(stream) == {"a": 1} + stream.close() + assert source.closed is True + + def test_closes_upstream_on_parse_error(self) -> None: + source = _ClosableChunks([b"{not json}\n"]) + with pytest.raises(DeserializationError): + list(iter_jsonl(source)) + assert source.closed is True + + def test_close_error_does_not_mask_primary_error(self) -> None: + source = _ClosableChunks([b"{not json}\n"], fail_close=True) + with pytest.raises(DeserializationError) as exc_info: + list(iter_jsonl(source)) + # The parse error wins; the close failure is preserved on the chain. + chain = _context_chain(exc_info.value) + assert any(isinstance(node, RuntimeError) for node in chain) + + def test_close_error_surfaces_when_no_primary_error(self) -> None: + source = _ClosableChunks([b'{"a": 1}\n'], fail_close=True) + with pytest.raises(RuntimeError, match="upstream close failed"): + list(iter_jsonl(source)) + + +class TestChunkedFrameLifecycle: + def test_closes_upstream_on_exhaustion(self) -> None: + source = _ClosableChunks([b"hello", b"world"]) + list(chunked_frame(source)) + assert source.closed is True + + def test_closes_upstream_when_abandoned_mid_iteration(self) -> None: + source = _ClosableChunks([b"hello", b"world"]) + stream = cast(Generator[bytes, None, None], chunked_frame(source)) + next(stream) + stream.close() + assert source.closed is True + + +class TestAsyncIterJsonlLifecycle: + async def test_closes_upstream_on_exhaustion(self) -> None: + source = _AsyncClosableChunks([b'{"a": 1}\n{"b": 2}\n']) + result = [value async for value in aiter_jsonl(source)] + assert result == [{"a": 1}, {"b": 2}] + assert source.closed is True + + async def test_closes_upstream_when_abandoned_mid_iteration(self) -> None: + source = _AsyncClosableChunks([b'{"a": 1}\n{"b": 2}\n{"c": 3}\n']) + stream = cast(AsyncGenerator[Any, None], aiter_jsonl(source)) + assert await stream.__anext__() == {"a": 1} + await stream.aclose() + assert source.closed is True + + async def test_closes_upstream_on_parse_error(self) -> None: + source = _AsyncClosableChunks([b"{not json}\n"]) + with pytest.raises(DeserializationError): + async for _ in aiter_jsonl(source): + pass + assert source.closed is True + + async def test_close_error_does_not_mask_primary_error(self) -> None: + source = _AsyncClosableChunks([b"{not json}\n"], fail_close=True) + with pytest.raises(DeserializationError) as exc_info: + async for _ in aiter_jsonl(source): + pass + chain = _context_chain(exc_info.value) + assert any(isinstance(node, RuntimeError) for node in chain) + + +class TestAsyncChunkedFrameLifecycle: + async def test_closes_upstream_on_exhaustion(self) -> None: + source = _AsyncClosableChunks([b"hello", b"world"]) + _ = [piece async for piece in aiter_chunked_frame(source)] + assert source.closed is True + + async def test_closes_upstream_when_abandoned_mid_iteration(self) -> None: + source = _AsyncClosableChunks([b"hello", b"world"]) + stream = cast(AsyncGenerator[bytes, None], aiter_chunked_frame(source)) + await stream.__anext__() + await stream.aclose() + assert source.closed is True diff --git a/packages/dexpace-sdk-core/tests/http/test_streaming_properties.py b/packages/dexpace-sdk-core/tests/http/test_streaming_properties.py new file mode 100644 index 0000000..3060e4d --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_streaming_properties.py @@ -0,0 +1,81 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Property-based tests pinning ``iter_jsonl`` chunk-boundary invariance. + +Invariant covered: + +- Re-splitting the same byte stream at arbitrary chunk boundaries feeds the + JSONL decoder identical output: ``iter_jsonl`` buffers across chunk splits, + so the parsed values must not depend on where the transport happened to cut + the stream (including cuts in the middle of a multi-byte UTF-8 codepoint). + +The example count and deadline are governed by the capped "ci" Hypothesis +profile registered in ``tests/conftest.py``. +""" + +from __future__ import annotations + +import json +from typing import Any + +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.http.common import iter_jsonl + +# JSON values that survive ``dumps`` -> ``loads`` exactly: NaN / Infinity are +# excluded because they either break value equality (NaN) or are not standard +# JSON, neither of which is relevant to the chunk-splitting invariant. Integers +# are bounded to 64-bit magnitudes so encoding never trips CPython's +# integer-string-conversion digit cap, which is unrelated to the invariant. +_json_values = st.recursive( + st.none() + | st.booleans() + | st.integers(min_value=-(2**63), max_value=2**63) + | st.floats(allow_nan=False, allow_infinity=False) + | st.text(st.characters(codec="utf-8")), + lambda children: ( + st.lists(children, max_size=4) + | st.dictionaries(st.text(st.characters(codec="utf-8"), max_size=8), children, max_size=4) + ), + max_leaves=10, +) + + +@st.composite +def _payload_and_chunks(draw: st.DrawFn) -> tuple[list[bytes], list[Any]]: + """Render JSON values as a JSONL byte payload split at arbitrary boundaries. + + Returns: + A ``(chunks, values)`` pair: ``chunks`` re-split the encoded payload at + arbitrary byte boundaries, and ``values`` are the source Python values + the decoder must reproduce. + """ + values = draw(st.lists(_json_values, max_size=6)) + # ``ensure_ascii=False`` keeps multi-byte codepoints as UTF-8 bytes so the + # re-split can land inside a codepoint; ``dumps`` never emits a literal + # newline, so every value stays on its own line. + payload = "".join(json.dumps(value, ensure_ascii=False) + "\n" for value in values).encode( + "utf-8" + ) + chunks: list[bytes] = [] + index = 0 + while index < len(payload): + size = draw(st.integers(min_value=1, max_value=len(payload) - index)) + chunks.append(payload[index : index + size]) + index += size + return chunks, values + + +@given(payload_and_chunks=_payload_and_chunks()) +def test_iter_jsonl_invariant_under_chunk_resplits( + payload_and_chunks: tuple[list[bytes], list[Any]], +) -> None: + """Chunk boundaries do not change the decoded JSONL values.""" + chunks, values = payload_and_chunks + whole = b"".join(chunks) + baseline = list(iter_jsonl([whole])) + resplit = list(iter_jsonl(chunks)) + assert resplit == baseline + assert baseline == values diff --git a/packages/dexpace-sdk-core/tests/http/test_url.py b/packages/dexpace-sdk-core/tests/http/test_url.py index 8e788a8..01cd15d 100644 --- a/packages/dexpace-sdk-core/tests/http/test_url.py +++ b/packages/dexpace-sdk-core/tests/http/test_url.py @@ -40,10 +40,12 @@ def test_str_round_trip(self) -> None: u = Url.parse(original) assert str(u) == original + @pytest.mark.req("HTTP-47") def test_empty_url_raises(self) -> None: with pytest.raises(ValueError): Url.parse("") + @pytest.mark.req("HTTP-47") def test_missing_scheme_raises(self) -> None: with pytest.raises(ValueError): Url.parse("//api.example.com/path") @@ -90,6 +92,7 @@ def test_get_first_value(self) -> None: assert q.get("a") == "1" assert q.get("b") == "x" + @pytest.mark.req("HTTP-28") def test_values_returns_all(self) -> None: q = QueryParams([("a", "1"), ("a", "2")]) assert q.values("a") == ("1", "2") @@ -105,6 +108,7 @@ def test_with_set_replaces(self) -> None: result = q.with_set("a", "only") assert result.values("a") == ("only",) + @pytest.mark.req("HTTP-30") def test_with_set_no_values_removes_param(self) -> None: # ``with_set`` with zero values must remove the parameter entirely # (mirroring ``Headers.with_set``), not leave a phantom empty entry @@ -135,6 +139,7 @@ def test_without(self) -> None: assert "a" not in result assert "b" in result + @pytest.mark.req("HTTP-28") def test_case_sensitive(self) -> None: q = QueryParams([("FOO", "1")]) assert q.get("foo") is None @@ -151,6 +156,7 @@ def test_parse_round_trips(self) -> None: assert q.get("a") == "1" assert q.get("b") == "hello world" + @pytest.mark.req("HTTP-31") def test_parse_leading_question_mark(self) -> None: q = QueryParams.parse("?a=1") assert q.get("a") == "1" diff --git a/packages/dexpace-sdk-core/tests/http/test_url_properties.py b/packages/dexpace-sdk-core/tests/http/test_url_properties.py new file mode 100644 index 0000000..32c2eed --- /dev/null +++ b/packages/dexpace-sdk-core/tests/http/test_url_properties.py @@ -0,0 +1,94 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Property-based tests pinning ``QueryParams`` / ``Url`` encode/parse inversion. + +Invariants covered: + +- ``QueryParams.parse(qp.encode()) == qp`` — percent-encoding to + ``application/x-www-form-urlencoded`` and parsing back is the identity for + arbitrary names and values (including empty, reserved, and non-ASCII text). +- ``QueryParams(qp.flatten()) == qp`` — regrouping the flattened pairs is + idempotent. +- ``Url.parse(str(url)) == url`` for URLs assembled from unreserved-character + components, so serialise/parse round-trips the structural URL fields. + +The example count and deadline are governed by the capped "ci" Hypothesis +profile registered in ``tests/conftest.py``. +""" + +from __future__ import annotations + +from string import ascii_letters, ascii_lowercase, digits + +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.http.common import QueryParams, Url + +# Arbitrary query names/values: percent-encoding must invert regardless of +# reserved characters, whitespace, ``+``, ``&``, ``=`` or non-ASCII text. +_qp_text = st.text(st.characters(codec="utf-8"), max_size=20) +_qp_entries = st.lists(st.tuples(_qp_text, _qp_text), max_size=10) + + +@given(entries=_qp_entries) +def test_encode_parse_round_trip(entries: list[tuple[str, str]]) -> None: + """Encoding then parsing reproduces the original query parameters.""" + params = QueryParams(entries) + assert QueryParams.parse(params.encode()) == params + + +@given(entries=_qp_entries) +def test_flatten_reconstruction_is_identity(entries: list[tuple[str, str]]) -> None: + """Rebuilding ``QueryParams`` from its flattened pairs is the identity.""" + params = QueryParams(entries) + assert QueryParams(params.flatten()) == params + + +# Components restricted to RFC 3986 ``unreserved`` characters so no encoder +# ever rewrites them, isolating the round-trip from percent-encoding +# canonicalisation differences between the serialiser and ``furl``. +_UNRESERVED = ascii_letters + digits + "-._~" + +_label = st.text(alphabet=ascii_lowercase + digits, min_size=1, max_size=8) +_host = st.lists(_label, min_size=1, max_size=3).map(".".join) +_segment = st.text(alphabet=_UNRESERVED, min_size=1, max_size=8).filter( + lambda segment: segment not in (".", "..") +) +_path = st.lists(_segment, max_size=3).map(lambda segments: "".join("/" + s for s in segments)) +_scheme = st.sampled_from(["http", "https"]) +# Exclude the scheme default ports (80 / 443): serialisers legitimately omit +# them, which is a separate normalisation from the structural round-trip. +_port = st.none() | st.integers(min_value=1, max_value=65535).filter(lambda p: p not in (80, 443)) +_url_query = st.lists( + st.tuples( + st.text(alphabet=_UNRESERVED, min_size=1, max_size=6), + st.text(alphabet=_UNRESERVED, min_size=1, max_size=6), + ), + max_size=4, +) + + +@given( + scheme=_scheme, + host=_host, + path=_path, + port=_port, + query=_url_query, +) +def test_url_str_parse_round_trip( + scheme: str, + host: str, + path: str, + port: int | None, + query: list[tuple[str, str]], +) -> None: + """Serialising a URL and parsing it back preserves the structural fields.""" + url = Url(scheme=scheme, host=host, path=path, port=port, query=QueryParams(query)) + parsed = Url.parse(str(url)) + assert parsed.scheme == url.scheme + assert parsed.host == url.host + assert parsed.port == url.port + assert parsed.path == url.path + assert parsed.query == url.query diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_correlation.py b/packages/dexpace-sdk-core/tests/instrumentation/test_correlation.py index 545fc1d..5f8d409 100644 --- a/packages/dexpace-sdk-core/tests/instrumentation/test_correlation.py +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_correlation.py @@ -8,14 +8,26 @@ import asyncio import logging import threading +from collections.abc import Iterator import pytest from _pytest.logging import LogCaptureFixture from dexpace.sdk.core.instrumentation import ( + NOOP_SPAN, ClientLogger, CorrelationFilter, + InstrumentationContext, + Span, + SpanId, + TraceId, + TraceIdType, + activate_span, bind_correlation, + bind_to_context, + capture_context, + correlation_scope, + get_current_span, get_span_id, get_trace_id, set_span_id, @@ -23,13 +35,55 @@ ) +class _FakeRecordingSpan(Span): + """A recording span carrying a fixed context, for correlation-scope tests.""" + + def __init__(self, context: InstrumentationContext) -> None: + self._context = context + + @property + def is_recording(self) -> bool: + return True + + @property + def context(self) -> InstrumentationContext: + return self._context + + def set_attribute(self, key: str, value: object) -> _FakeRecordingSpan: + return self + + def set_error(self, error_type: str) -> _FakeRecordingSpan: + return self + + def end(self, error: BaseException | None = None) -> None: + return None + + +def _recording_context(trace: str, span: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId(span), + span=NOOP_SPAN, + ) + + @pytest.fixture(autouse=True) -def _clear_correlation() -> None: - """Each test starts with no bound ids (contextvars default to ``None``).""" +def _clear_correlation() -> Iterator[None]: + """Isolate each test's correlation state. + + Clears the trace/span ids before the test so it starts with the contextvar + defaults (``None``), and clears them again afterwards so a test that binds + ids on the main thread never leaks that state into other test modules. + """ + set_trace_id(None) + set_span_id(None) + yield set_trace_id(None) set_span_id(None) +@pytest.mark.req("ASYNC-11") def test_getters_default_to_none() -> None: assert get_trace_id() is None assert get_span_id() is None @@ -150,3 +204,130 @@ async def _run() -> tuple[str | None, str | None]: trace_id, span_id = asyncio.run(_run()) assert trace_id == "async-trace" assert span_id == "async-span" + + +# ----- contextvars bridge across threads (OBS-24, ASYNC-8..12) -------------- + + +@pytest.mark.req("ASYNC-12", "ASYNC-8", "OBS-24") +def test_capture_context_propagates_submission_snapshot_across_threads() -> None: + # Capture on the ORIGINATING side (submission time), reinstall on a worker + # thread. The worker observes the value as it was AT CAPTURE, not the value + # the calling thread later moved to, and a fresh thread would otherwise see + # only the ``None`` default. + set_trace_id("at-submission") + snapshot = capture_context() + set_trace_id("after-submission") + + seen: dict[str, str | None] = {} + started = threading.Event() + may_read = threading.Event() + + def worker() -> None: + started.set() + may_read.wait() # read strictly AFTER the caller changed the id + seen["trace"] = get_trace_id() + set_trace_id("mutated-in-worker") # must not leak back to the caller + + thread = threading.Thread(target=lambda: snapshot.run(worker)) + thread.start() + started.wait() + may_read.set() + thread.join() + + assert seen["trace"] == "at-submission" + assert get_trace_id() == "after-submission" + + +@pytest.mark.req("ASYNC-10", "ASYNC-11", "ASYNC-9") +def test_bind_to_context_captures_at_submission_not_at_thread_start() -> None: + set_trace_id("submission") + bound = bind_to_context(_read_then_mutate) # snapshot captured HERE + set_trace_id("after") # changed before the worker even starts + + result: list[str | None] = [] + thread = threading.Thread(target=lambda: result.append(bound())) + thread.start() + thread.join() + + assert result == ["submission"] # snapshot value, not "after" + assert get_trace_id() == "after" # worker mutation stayed isolated + + +def _read_then_mutate() -> str | None: + observed = get_trace_id() + set_trace_id("mutated-in-worker") + return observed + + +@pytest.mark.req("ASYNC-9", "OBS-24") +def test_capture_context_isolates_mutations_from_caller() -> None: + set_trace_id("caller") + snapshot = capture_context() + + def mutate() -> None: + set_trace_id("inside-snapshot") + assert get_trace_id() == "inside-snapshot" + + snapshot.run(mutate) + assert get_trace_id() == "caller" + + +# ----- current span scope (OBS-22) ------------------------------------------ + + +def test_get_current_span_defaults_to_noop() -> None: + assert get_current_span() is NOOP_SPAN + + +@pytest.mark.req("OBS-22") +def test_activate_span_restores_prior_span_including_on_throw() -> None: + outer = _FakeRecordingSpan(_recording_context("t-o", "s-o")) + inner = _FakeRecordingSpan(_recording_context("t-i", "s-i")) + with activate_span(outer): + assert get_current_span() is outer + with pytest.raises(RuntimeError), activate_span(inner): + assert get_current_span() is inner + raise RuntimeError("boom") + assert get_current_span() is outer + assert get_current_span() is NOOP_SPAN + + +# ----- log-correlation scope, skipped for non-recording spans (OBS-23) ------ + + +@pytest.mark.req("OBS-23") +def test_correlation_scope_binds_recording_span_ids() -> None: + span = _FakeRecordingSpan(_recording_context("rec-trace", "rec-span")) + set_trace_id("outer-t") + set_span_id("outer-s") + with correlation_scope(span): + assert get_trace_id() == "rec-trace" + assert get_span_id() == "rec-span" + assert get_trace_id() == "outer-t" + assert get_span_id() == "outer-s" + + +@pytest.mark.req("OBS-23") +def test_correlation_scope_restores_ids_on_throw() -> None: + span = _FakeRecordingSpan(_recording_context("rec-trace", "rec-span")) + set_trace_id("outer-t") + set_span_id("outer-s") + with pytest.raises(RuntimeError), correlation_scope(span): + assert get_trace_id() == "rec-trace" + raise RuntimeError("boom") + assert get_trace_id() == "outer-t" + assert get_span_id() == "outer-s" + + +@pytest.mark.req("OBS-23") +def test_correlation_scope_is_noop_for_non_recording_span() -> None: + # A span that isn't recording must NOT overwrite the ambient ids: the + # inherited correlation context is left untouched for the block. + set_trace_id("inherited-t") + set_span_id("inherited-s") + with correlation_scope(NOOP_SPAN): + assert get_trace_id() == "inherited-t" + assert get_span_id() == "inherited-s" + assert get_trace_id() == "inherited-t" + assert get_span_id() == "inherited-s" diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_header_redactor.py b/packages/dexpace-sdk-core/tests/instrumentation/test_header_redactor.py new file mode 100644 index 0000000..ddbf36c --- /dev/null +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_header_redactor.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for ``HeaderRedactor`` — header-name gating and URL-valued redaction.""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.instrumentation import ( + DEFAULT_LOGGED_HEADER_ALLOWLIST, + DEFAULT_URL_VALUED_HEADERS, + HeaderRedactor, +) + + +def _headers(*pairs: tuple[str, str]) -> Headers: + return Headers.outbound(list(pairs)) + + +@pytest.mark.req("OBS-17") +def test_url_valued_response_headers_are_redacted() -> None: + # Location and Content-Location values are routed through the URL-value + # redactor, so a secret in the query never reaches the log. + redactor = HeaderRedactor() + out = redactor.redact( + _headers( + ("Location", "https://idp.example.com/cb?code=SEKRET&api-version=1"), + ("Content-Location", "/mirror?signature=SEKRET"), + ) + ) + assert "SEKRET" not in out["location"] + assert "REDACTED=REDACTED" in out["location"] + assert "api-version=1" in out["location"] + assert out["content-location"] == "/mirror?***" + + +@pytest.mark.req("OBS-17") +def test_non_url_allowlisted_header_passes_through_unchanged() -> None: + out = HeaderRedactor().redact(_headers(("Content-Type", "application/json; charset=utf-8"))) + assert out["content-type"] == "application/json; charset=utf-8" + + +@pytest.mark.req("OBS-17") +def test_url_valued_names_are_configurable() -> None: + # A custom URL-valued name is redacted; the defaults still apply too. + redactor = HeaderRedactor( + allowed_names={"x-next-url", "location"}, + url_valued_names={"x-next-url"}, + ) + out = redactor.redact(_headers(("X-Next-Url", "/next?token=SEKRET"))) + assert out["x-next-url"] == "/next?***" + + +@pytest.mark.req("OBS-18") +def test_disallowed_header_marked_redacted_by_default() -> None: + # A non-allowlisted (credential) name has its value replaced by the fixed + # marker; its value is never logged. + out = HeaderRedactor().redact(_headers(("Authorization", "Bearer super-secret-token"))) + assert out["authorization"] == "REDACTED" + assert "super-secret-token" not in out["authorization"] + + +@pytest.mark.req("OBS-18") +def test_disallowed_header_omitted_when_configured() -> None: + out = HeaderRedactor(redact_disallowed=False).redact( + _headers(("Authorization", "Bearer super-secret-token"), ("Date", "now")) + ) + assert "authorization" not in out # omitted entirely + assert out["date"] == "now" # allow-listed name still logged + + +@pytest.mark.req("OBS-18") +def test_default_allowlist_is_diagnostic_and_non_credential() -> None: + # Only diagnostic, non-credential names are logged by default. + for credential in ( + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "www-authenticate", + "proxy-authenticate", + ): + assert credential not in DEFAULT_LOGGED_HEADER_ALLOWLIST + for diagnostic in ("content-length", "content-type", "date", "location", "content-location"): + assert diagnostic in DEFAULT_LOGGED_HEADER_ALLOWLIST + # URL-valued defaults must themselves be allow-listed so they are logged. + assert DEFAULT_URL_VALUED_HEADERS <= DEFAULT_LOGGED_HEADER_ALLOWLIST + + +@pytest.mark.req("OBS-18") +def test_name_matching_is_case_insensitive_and_values_joined() -> None: + # Header names arrive lower-cased; a multi-valued header is joined. + out = HeaderRedactor().redact(_headers(("Via", "1.1 a"), ("Via", "1.1 b"))) + assert out["via"] == "1.1 a, 1.1 b" diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_http_tracer.py b/packages/dexpace-sdk-core/tests/instrumentation/test_http_tracer.py index 39da958..93179cf 100644 --- a/packages/dexpace-sdk-core/tests/instrumentation/test_http_tracer.py +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_http_tracer.py @@ -5,6 +5,8 @@ from __future__ import annotations +import pytest + from dexpace.sdk.core.instrumentation import ( NOOP_HTTP_TRACER, NOOP_HTTP_TRACER_FACTORY, @@ -14,6 +16,7 @@ ) +@pytest.mark.req("CTX-20", "OBS-28") def test_noop_tracer_callbacks_are_silent_and_return_none() -> None: tracer = NOOP_HTTP_TRACER @@ -36,6 +39,7 @@ def test_noop_tracer_is_an_http_tracer() -> None: assert isinstance(NOOP_HTTP_TRACER, HttpTracer) +@pytest.mark.req("CTX-20", "OBS-25") def test_noop_factory_creates_the_shared_noop_tracer() -> None: created = NOOP_HTTP_TRACER_FACTORY.create() assert created is NOOP_HTTP_TRACER @@ -45,10 +49,12 @@ def test_noop_factory_satisfies_the_factory_protocol() -> None: assert isinstance(NOOP_HTTP_TRACER_FACTORY, HttpTracerFactory) +@pytest.mark.req("CTX-14", "CTX-20") def test_instrumentation_context_defaults_to_noop_factory() -> None: assert NOOP_INSTRUMENTATION_CONTEXT.http_tracer_factory is NOOP_HTTP_TRACER_FACTORY +@pytest.mark.req("OBS-28") def test_subclass_overrides_only_chosen_events() -> None: events: list[tuple[str, object]] = [] @@ -88,3 +94,38 @@ def request_sent(self, byte_count: int | None) -> None: tracer.request_sent(None) # unknown length, e.g. an unsized streamed body assert received == [64, None] + + +def test_retry_exhaustion_pairs_with_operation_failure() -> None: + # Lifecycle ordering: when the retry budget is exhausted the operation fails + # permanently. A tracer therefore observes ``attempt_retries_exhausted`` + # before the terminal ``operation_failed`` carrying the escaping error. + events: list[str] = [] + boom = TimeoutError("exhausted") + + class _RecordingTracer(HttpTracer): + def attempt_retries_exhausted(self) -> None: + events.append("attempt_retries_exhausted") + + def operation_failed(self, error: BaseException) -> None: + events.append(f"operation_failed:{type(error).__name__}") + + tracer = _RecordingTracer() + tracer.attempt_retries_exhausted() + tracer.operation_failed(boom) + + assert events == ["attempt_retries_exhausted", "operation_failed:TimeoutError"] + + +@pytest.mark.req("OBS-20", "OBS-30") +def test_tracer_callback_exception_is_not_wrapped() -> None: + # A raising callback propagates unchanged: the SDK never guards these. + sentinel = RuntimeError("callback boom") + + class _BoomTracer(HttpTracer): + def operation_started(self) -> None: + raise sentinel + + with pytest.raises(RuntimeError) as excinfo: + _BoomTracer().operation_started() + assert excinfo.value is sentinel diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_identifiers.py b/packages/dexpace-sdk-core/tests/instrumentation/test_identifiers.py new file mode 100644 index 0000000..8f96cde --- /dev/null +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_identifiers.py @@ -0,0 +1,133 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for W3C trace/span identifiers and their generators (OBS-26/27).""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.instrumentation import ( + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) + +_GENERATIONS = 10_000 + + +@pytest.mark.req("CTX-15", "OBS-26") +def test_trace_id_invalid_sentinel_is_all_zero() -> None: + # The documented INVALID sentinel is the all-zero id. + assert TraceId.NOOP.value == "0" * 32 + assert TraceId.NOOP.is_valid is False + + +@pytest.mark.req("OBS-26") +def test_span_id_invalid_sentinel_is_all_zero() -> None: + assert SpanId.NOOP.value == "0" * 16 + assert SpanId.NOOP.is_valid is False + + +@pytest.mark.req("CTX-15", "OBS-26") +def test_flags_and_state_sentinels() -> None: + assert TraceFlags.NOOP.value == "00" + assert TraceState.NOOP.value == "" + + +def test_generated_trace_id_shape() -> None: + trace_id = TraceId.generate() + assert len(trace_id.value) == 32 + assert trace_id.value == trace_id.value.lower() + int(trace_id.value, 16) # parses as hex or raises + assert trace_id.is_valid is True + + +def test_generated_span_id_shape() -> None: + span_id = SpanId.generate() + assert len(span_id.value) == 16 + assert span_id.value == span_id.value.lower() + int(span_id.value, 16) + assert span_id.is_valid is True + + +def test_generate_never_yields_all_zero_trace_id() -> None: + # Not just theoretically true: exercise the generator many times and prove + # it never produces the all-zero invalid sentinel. + for _ in range(_GENERATIONS): + generated = TraceId.generate() + assert generated != TraceId.NOOP + assert generated.value != "0" * 32 + assert generated.is_valid is True + + +def test_generate_never_yields_all_zero_span_id() -> None: + for _ in range(_GENERATIONS): + generated = SpanId.generate() + assert generated != SpanId.NOOP + assert generated.value != "0" * 16 + assert generated.is_valid is True + + +def test_generated_ids_are_distinct() -> None: + # A cheap randomness sanity check: a large sample has no collisions. + trace_ids = {TraceId.generate().value for _ in range(1000)} + span_ids = {SpanId.generate().value for _ in range(1000)} + assert len(trace_ids) == 1000 + assert len(span_ids) == 1000 + + +def test_ids_are_frozen_value_objects() -> None: + a = TraceId("abc") + b = TraceId("abc") + assert a == b + assert hash(a) == hash(b) + + +@pytest.mark.req("CTX-14") +def test_trace_id_type_members() -> None: + assert set(TraceIdType) == {TraceIdType.NOOP, TraceIdType.W3C, TraceIdType.DATADOG} + assert str(TraceIdType.W3C) == "w3c" + + +@pytest.mark.req("OBS-27") +def test_generate_w3c_flavour_is_32_lowercase_hex() -> None: + # The W3C flavour is a 128-bit value rendered as 32 lowercase hex chars, and + # the bare (argument-less) call keeps yielding it for backwards compatibility. + for trace_id in (TraceId.generate(), TraceId.generate(TraceIdType.W3C)): + assert len(trace_id.value) == 32 + assert trace_id.value == trace_id.value.lower() + assert int(trace_id.value, 16) != 0 # parses as hex, never all-zero + assert trace_id.is_valid is True + + +@pytest.mark.req("OBS-27") +def test_generate_datadog_flavour_is_decimal_uint64() -> None: + for _ in range(_GENERATIONS): + trace_id = TraceId.generate(TraceIdType.DATADOG) + assert trace_id.value == str(int(trace_id.value)) # canonical decimal, no leading zeros + as_int = int(trace_id.value) + assert 1 <= as_int <= (1 << 64) - 1 # non-zero 64-bit unsigned + assert trace_id.is_valid is True + + +@pytest.mark.req("OBS-27") +def test_generate_noop_flavour_always_yields_invalid_sentinel() -> None: + for _ in range(100): + trace_id = TraceId.generate(TraceIdType.NOOP) + assert trace_id == TraceId.NOOP + assert trace_id.value == "0" * 32 + assert trace_id.is_valid is False + + +@pytest.mark.req("OBS-27") +def test_datadog_zero_draw_is_coerced_to_non_zero(monkeypatch: pytest.MonkeyPatch) -> None: + # The reserved all-zero id must never be produced: a zero draw is coerced. + from dexpace.sdk.core.instrumentation import identifiers + + monkeypatch.setattr(identifiers, "_draw_datadog_bits", lambda: 0) + trace_id = TraceId.generate(TraceIdType.DATADOG) + assert trace_id.value == "1" + assert trace_id.is_valid is True diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_logger.py b/packages/dexpace-sdk-core/tests/instrumentation/test_logger.py index a50f7af..ed507e7 100644 --- a/packages/dexpace-sdk-core/tests/instrumentation/test_logger.py +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_logger.py @@ -6,11 +6,31 @@ from __future__ import annotations import logging +import threading +from typing import NoReturn +import pytest from _pytest.logging import LogCaptureFixture -from dexpace.sdk.core.instrumentation import ClientLogger, LogLevel -from dexpace.sdk.core.instrumentation.client_logger import _format_fields +from dexpace.sdk.core.instrumentation import ClientLogger, LogEvent, LogLevel +from dexpace.sdk.core.instrumentation.client_logger import ( + _INSTRUMENTATION_ERROR_EVENT, + _LEVEL_MAP, + _NOOP_EVENT, + _TRUNCATION_MARKER, + _UNRENDERABLE, + _format_fields, + _render_value, +) + + +def _parse_fields(rendered: str) -> dict[str, str]: + """Split a rendered logfmt string of unquoted tokens into a dict.""" + fields: dict[str, str] = {} + for token in rendered.split(): + key, _, value = token.partition("=") + fields[key] = value + return fields def test_emits_at_info(caplog: LogCaptureFixture) -> None: @@ -98,3 +118,349 @@ def test_carriage_return_escaped(caplog: LogCaptureFixture) -> None: assert "\\r" in rendered assert "\\n" in rendered assert 'note="line1\\r\\nline2"' in rendered + + +# --- OBS-2: four structured levels map onto stdlib levels -------------------- + + +@pytest.mark.req("OBS-2") +def test_levels_map_to_stdlib_levels() -> None: + assert _LEVEL_MAP == { + LogLevel.ERROR: logging.ERROR, + LogLevel.WARNING: logging.WARNING, + LogLevel.INFO: logging.INFO, + LogLevel.VERBOSE: logging.DEBUG, + } + + +@pytest.mark.req("OBS-2") +def test_at_helpers_dispatch_to_matching_level(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="dexpace.test.at") + logger = ClientLogger("dexpace.test.at") + logger.at_error().log("e") + logger.at_warning().log("w") + logger.at_info().log("i") + logger.at_verbose().log("v") + + levels = [rec.levelname for rec in caplog.records] + assert levels == ["ERROR", "WARNING", "INFO", "DEBUG"] + + +# --- OBS-1: disabled level returns a shared inert event (zero allocation) ----- + + +@pytest.mark.req("OBS-1") +def test_disabled_level_returns_shared_inert_singleton() -> None: + logging.getLogger("dexpace.test.inert").setLevel(logging.ERROR) + logger = ClientLogger("dexpace.test.inert") + + first = logger.at_info() + second = logger.at_info() + + # Same object every time on the disabled path: no allocation. + assert first is _NOOP_EVENT + assert first is second + + +@pytest.mark.req("OBS-1") +def test_inert_event_chaining_stays_the_singleton_and_never_emits( + caplog: LogCaptureFixture, +) -> None: + caplog.set_level(logging.ERROR, logger="dexpace.test.inert2") + logging.getLogger("dexpace.test.inert2").setLevel(logging.ERROR) + logger = ClientLogger("dexpace.test.inert2") + + event = logger.at_info() + assert event.add("k", "v") is _NOOP_EVENT + assert event.event("x") is _NOOP_EVENT + event.log("nothing") # inert: does not raise, does not emit + + assert not [rec for rec in caplog.records if "nothing" in rec.getMessage()] + + +# --- OBS-3: empty key rejected; None renders literal 'null' ------------------- + + +@pytest.mark.req("OBS-3") +def test_empty_key_is_rejected(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="dexpace.test.emptykey") + logger = ClientLogger("dexpace.test.emptykey") + with pytest.raises(ValueError, match="non-empty"): + logger.at_info().add("", "value") + + +def test_none_value_renders_null_literal() -> None: + assert _render_value(None, 8192) == "null" + + +@pytest.mark.req("OBS-3") +def test_none_value_renders_null_through_logger(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.null") + logger = ClientLogger("dexpace.test.null") + logger.info("msg", token=None) + + assert "token=null" in caplog.records[-1].getMessage() + + +# --- OBS-4: reserved `event` tag emitted exactly once; empty clears ----------- + + +@pytest.mark.req("OBS-4") +def test_reserved_event_tag_emitted_exactly_once(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.event") + logger = ClientLogger("dexpace.test.event") + logger.at_info().event("http.request").add("method", "GET").log("sent") + + rendered = caplog.records[-1].getMessage() + assert rendered.count("event=") == 1 + assert "event=http.request" in rendered + assert "method=GET" in rendered + + +@pytest.mark.req("OBS-4") +def test_reserved_event_empty_value_clears_tag(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.event2") + logger = ClientLogger("dexpace.test.event2") + logger.at_info().event("first").event("").add("k", "v").log("msg") + + rendered = caplog.records[-1].getMessage() + assert "event=" not in rendered + assert "k=v" in rendered + + +# --- OBS-5: field > global > diagnostic-context, one occurrence per key ------- + + +@pytest.mark.req("OBS-5") +def test_precedence_field_over_global_over_diagnostic(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.prec") + logger = ClientLogger( + "dexpace.test.prec", + diagnostic_allow_list=None, # fold every ambient key + diagnostic_context=lambda: {"a": "diag", "b": "diag", "c": "diag"}, + a="global", + b="global", + ) + logger.at_info().add("a", "field").log("") + + fields = _parse_fields(caplog.records[-1].getMessage()) + assert fields == {"a": "field", "b": "global", "c": "diag"} + + +# --- OBS-6: total rendering — a throwing __str__ yields a placeholder --------- + + +class _BoomStr: + def __str__(self) -> str: + raise RuntimeError("boom") + + +class _BoomBaseStr: + def __str__(self) -> str: + raise KeyboardInterrupt("boom") + + +@pytest.mark.req("OBS-6", "XCUT-20") +def test_throwing_str_yields_placeholder(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.boom") + logger = ClientLogger("dexpace.test.boom") + logger.at_info().add("k", _BoomStr()).log("msg") # must not raise + + assert f"k={_UNRENDERABLE}" in caplog.records[-1].getMessage() + + +def test_base_exception_from_str_propagates(caplog: LogCaptureFixture) -> None: + # OBS-20 boundary: the logger swallows Exception for rendering, but a + # BaseException (the propagate contract that also governs tracer/meter + # callbacks) is never swallowed by its own error handling. + caplog.set_level(logging.INFO, logger="dexpace.test.boombase") + logger = ClientLogger("dexpace.test.boombase") + with pytest.raises(KeyboardInterrupt): + logger.at_info().add("k", _BoomBaseStr()).log("msg") + + +# --- OBS-7: bounded truncation, primitives exempt ---------------------------- + + +@pytest.mark.req("OBS-7") +def test_long_value_is_truncated() -> None: + rendered = _render_value("x" * 100, 10) + assert rendered == "x" * 10 + _TRUNCATION_MARKER + assert "x" * 11 not in rendered + + +@pytest.mark.req("OBS-6") +def test_primitives_are_exempt_from_truncation() -> None: + assert _render_value(123456789, 3) == "123456789" + assert _render_value(True, 1) == "True" + assert _render_value(10**50, 3) == str(10**50) + assert _TRUNCATION_MARKER not in _render_value(10**50, 3) + + +@pytest.mark.req("OBS-7") +def test_truncation_bounds_value_through_logger(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.trunc") + logger = ClientLogger("dexpace.test.trunc", max_field_length=8) + logger.info("msg", blob="y" * 200) + + rendered = caplog.records[-1].getMessage() + assert _TRUNCATION_MARKER in rendered + assert "y" * 200 not in rendered + assert "y" * 8 + _TRUNCATION_MARKER in rendered + + +# --- OBS-8: emit-once latch correct under real thread concurrency ------------ + + +@pytest.mark.req("OBS-8", "XCUT-11") +def test_emit_once_latch_under_thread_race(caplog: LogCaptureFixture) -> None: + # Designed to hold under free-threaded CPython too: the latch is guarded by + # an explicit threading.Lock, not the GIL. + caplog.set_level(logging.INFO, logger="dexpace.test.race") + logger = ClientLogger("dexpace.test.race") + event = logger.at_info().add("k", "v") + + thread_count = 64 + barrier = threading.Barrier(thread_count) + + def worker() -> None: + barrier.wait() # maximise contention on the single event + event.log("race") + + threads = [threading.Thread(target=worker) for _ in range(thread_count)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + emitted = [rec for rec in caplog.records if "race" in rec.getMessage()] + assert len(emitted) == 1 + + +# --- OBS-9: global context attaches to every event -------------------------- + + +@pytest.mark.req("OBS-9") +def test_global_context_on_every_event(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.global") + logger = ClientLogger("dexpace.test.global", client="dexpace.foo", region="eu") + logger.at_info().log("first") + logger.at_info().add("extra", "1").log("second") + + for rec in caplog.records: + rendered = rec.getMessage() + assert "client=dexpace.foo" in rendered + assert "region=eu" in rendered + + +# --- OBS-10: diagnostic-context allow-list ----------------------------------- + + +@pytest.mark.req("OBS-10") +def test_diagnostic_allow_list_defaults_to_trace_and_span( + caplog: LogCaptureFixture, +) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.diag") + logger = ClientLogger( + "dexpace.test.diag", + diagnostic_context=lambda: {"trace.id": "T", "span.id": "S", "secret": "X"}, + ) + logger.at_info().log("msg") + + rendered = caplog.records[-1].getMessage() + assert "trace.id=T" in rendered + assert "span.id=S" in rendered + assert "secret" not in rendered # not allow-listed by default + + +@pytest.mark.req("OBS-10") +def test_diagnostic_allow_list_none_folds_all(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.diagall") + logger = ClientLogger( + "dexpace.test.diagall", + diagnostic_allow_list=None, + diagnostic_context=lambda: {"trace.id": "T", "custom": "C"}, + ) + logger.at_info().log("msg") + + rendered = caplog.records[-1].getMessage() + assert "trace.id=T" in rendered + assert "custom=C" in rendered + + +@pytest.mark.req("OBS-10") +def test_diagnostic_null_values_are_skipped(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.diagnull") + logger = ClientLogger( + "dexpace.test.diagnull", + diagnostic_context=lambda: {"trace.id": None, "span.id": "S"}, + ) + logger.at_info().log("msg") + + rendered = caplog.records[-1].getMessage() + assert "trace.id" not in rendered # null value skipped entirely + assert "span.id=S" in rendered + + +# --- OBS-20: logging failures re-emitted; tracer/meter throws propagate ------- + + +def _broken_provider() -> NoReturn: + raise RuntimeError("provider down") + + +@pytest.mark.req("OBS-20", "XCUT-20") +def test_logging_failure_reemitted_as_instrumentation_error( + caplog: LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="dexpace.test.fail") + logger = ClientLogger("dexpace.test.fail", diagnostic_context=_broken_provider) + + logger.info("http.request", method="GET") # a logging failure must not crash + + messages = [rec.getMessage() for rec in caplog.records] + assert any(_INSTRUMENTATION_ERROR_EVENT in msg for msg in messages) + assert any("error_type=RuntimeError" in msg for msg in messages) + + +# --- OBS-40: reserved-key collision warns once per logger -------------------- + + +@pytest.mark.req("OBS-40") +def test_reserved_key_collision_warns_once_per_logger( + caplog: LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="dexpace.test.collide") + logger = ClientLogger("dexpace.test.collide") + + logger.info("m1", event="a") + logger.info("m2", event="b") + logger.at_info().add("event", "c").log("m3") + + warnings = [ + rec + for rec in caplog.records + if rec.levelno == logging.WARNING and "reserved" in rec.getMessage() + ] + assert len(warnings) == 1 + + +# --- API surface / typing sanity --------------------------------------------- + + +def test_active_event_is_a_logevent(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.test.api") + logger = ClientLogger("dexpace.test.api") + event = logger.at_info() + assert isinstance(event, LogEvent) + + +def test_static_fields_still_supported(caplog: LogCaptureFixture) -> None: + # Backwards-compatible: positional name plus **global_context kwargs. + caplog.set_level(logging.INFO, logger="dexpace.test.compat") + logger = ClientLogger("dexpace.test.compat", client="test") + logger.info("hello", trace_id="abc") + + rendered = caplog.records[-1].getMessage() + assert "client=test" in rendered + assert "trace_id=abc" in rendered diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_metrics.py b/packages/dexpace-sdk-core/tests/instrumentation/test_metrics.py new file mode 100644 index 0000000..a8ec1b7 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_metrics.py @@ -0,0 +1,206 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the metrics SPI: counter/histogram semantics and no-op discards.""" + +from __future__ import annotations + +import math +import sys +import tracemalloc +from collections.abc import Mapping + +import pytest + +from dexpace.sdk.core.instrumentation import ( + NOOP_COUNTER, + NOOP_HISTOGRAM, + NOOP_METRICS_CONTEXT, + NOOP_UPDOWN_COUNTER, + Counter, + Histogram, + UpDownCounter, +) + + +class _RecordingCounter(Counter): + """A counter test double that captures the values that pass validation.""" + + def __init__(self) -> None: + self.calls: list[tuple[float, Mapping[str, str] | None]] = [] + + def _record(self, value: float, attributes: Mapping[str, str] | None) -> None: + self.calls.append((value, attributes)) + + +class _RecordingHistogram(Histogram): + def __init__(self) -> None: + self.values: list[float] = [] + + def record(self, value: float, attributes: Mapping[str, str] | None = None) -> None: + self.values.append(value) + + +# ----- Counter: rejects negative (OBS-33) ---------------------------------- + + +@pytest.mark.req("OBS-33") +def test_counter_default_increment_is_one() -> None: + counter = _RecordingCounter() + counter.add() + assert counter.calls == [(1.0, None)] + + +@pytest.mark.req("OBS-33") +def test_counter_accepts_zero_and_positive() -> None: + counter = _RecordingCounter() + counter.add(0.0) + counter.add(2.5, {"route": "/v1"}) + assert counter.calls == [(0.0, None), (2.5, {"route": "/v1"})] + + +def test_counter_rejects_negative_increment() -> None: + counter = _RecordingCounter() + with pytest.raises(ValueError, match="non-negative"): + counter.add(-1.0) + # Rejection happens before the value reaches the recording seam. + assert counter.calls == [] + + +def test_noop_counter_also_rejects_negative() -> None: + # A monotonic counter can never decrease; the invariant holds even when the + # counter discards, so misuse surfaces whether or not metrics are enabled. + with pytest.raises(ValueError, match="non-negative"): + NOOP_COUNTER.add(-0.01) + + +@pytest.mark.req("OBS-31") +def test_noop_counter_discards_valid_increments_silently() -> None: + NOOP_COUNTER.add() + NOOP_COUNTER.add(3.0) + NOOP_COUNTER.add(3.0, {"k": "v"}) + + +# ----- UpDownCounter: allows negative -------------------------------------- + + +def test_up_down_counter_allows_negative() -> None: + NOOP_UPDOWN_COUNTER.add(-5.0) + NOOP_UPDOWN_COUNTER.add(5.0, {"k": "v"}) + + +# ----- Histogram: tolerates any input (OBS-33) ----------------------------- + + +@pytest.mark.req("OBS-33") +def test_histogram_tolerates_any_numeric_input() -> None: + hist = _RecordingHistogram() + hist.record(-1.0) + hist.record(0.0) + hist.record(1_000_000.0) + hist.record(math.inf) + hist.record(math.nan) + assert len(hist.values) == 5 + assert math.isnan(hist.values[-1]) + + +@pytest.mark.req("OBS-33") +def test_noop_histogram_tolerates_any_input() -> None: + NOOP_HISTOGRAM.record(-1.0) + NOOP_HISTOGRAM.record(math.nan) + NOOP_HISTOGRAM.record(math.inf, {"k": "v"}) + + +# ----- Meter/MetricsContext: OTel names + units (OBS-31/32) ---------------- + + +@pytest.mark.req("OBS-31") +def test_noop_metrics_context_builds_instruments_with_units() -> None: + counter = NOOP_METRICS_CONTEXT.counter( + "http.client.request.count", unit="{request}", description="requests" + ) + hist = NOOP_METRICS_CONTEXT.histogram( + "http.client.request.duration", unit="s", description="latency" + ) + updown = NOOP_METRICS_CONTEXT.up_down_counter( + "http.client.open_connections", unit="{connection}" + ) + assert isinstance(counter, Counter) + assert isinstance(hist, Histogram) + assert isinstance(updown, UpDownCounter) + + +# ----- No-op allocation / identity (OBS-25) -------------------------------- + + +@pytest.mark.req("OBS-31") +def test_noop_metrics_context_returns_shared_singletons() -> None: + assert NOOP_METRICS_CONTEXT.counter("a") is NOOP_COUNTER + assert NOOP_METRICS_CONTEXT.counter("b", unit="s") is NOOP_COUNTER + assert NOOP_METRICS_CONTEXT.histogram("c") is NOOP_HISTOGRAM + assert NOOP_METRICS_CONTEXT.up_down_counter("d") is NOOP_UPDOWN_COUNTER + + +@pytest.mark.req("OBS-20", "OBS-30") +def test_meter_callback_exception_is_not_wrapped() -> None: + # Meter callbacks are contractually never-throwing; a raising recording seam + # propagates unchanged rather than being wrapped or suppressed. + sentinel = RuntimeError("meter boom") + + class _BoomCounter(Counter): + def _record(self, value: float, attributes: Mapping[str, str] | None) -> None: + raise sentinel + + with pytest.raises(RuntimeError) as excinfo: + _BoomCounter().add(1.0) + assert excinfo.value is sentinel + + +@pytest.mark.req("OBS-25") +def _measurement_tracer_active() -> bool: + """Return whether a coverage/line tracer is active alongside the test. + + A ``tracemalloc`` allocation delta only isolates the code under test when no + other tracer allocates during the measured loop. ``sys.gettrace`` catches + ``settrace``-based tracers and debuggers; coverage on Python 3.12+ drives + ``sys.monitoring`` (PEP 669) instead of ``settrace``, so ask coverage directly + whether a measurement is in progress. + """ + if sys.gettrace() is not None: + return True + try: + from coverage import Coverage + except ImportError: + return False + return Coverage.current() is not None + + +def test_noop_metric_calls_retain_no_memory() -> None: + if _measurement_tracer_active(): + pytest.skip( + "a coverage/line tracer allocates alongside the code under test, so the " + "tracemalloc delta cannot isolate a genuine per-call leak" + ) + # A genuine no-op retains nothing PER CALL: repeating the loop must not + # grow traced memory further. The first loop is a warm-up round, not just + # a single warm-up call — it settles any one-time cost that isn't the + # code under test (tracemalloc's own bookkeeping table sizing itself up, + # small-object allocator arena growth, etc.), so the measured delta + # isolates a genuine per-call leak from a one-time constant overhead. + add = NOOP_COUNTER.add + record = NOOP_HISTOGRAM.record + + def run() -> None: + for _ in range(50_000): + add(1.0) + record(1.0) + + tracemalloc.start() + try: + run() # warm-up round: settles one-time allocator/tracemalloc costs + before, _ = tracemalloc.get_traced_memory() + run() + after, _ = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert after <= before diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_span.py b/packages/dexpace-sdk-core/tests/instrumentation/test_span.py new file mode 100644 index 0000000..79b3483 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_span.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for Span recording/end semantics and the current-span scope.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from dexpace.sdk.core.instrumentation import ( + NOOP_INSTRUMENTATION_CONTEXT, + NOOP_SPAN, + NOOP_TRACER, + InstrumentationContext, + Span, + TracingScope, + get_current_span, +) + + +class _FakeSpan(Span): + """Minimal recording span honouring the recording-flag + idempotent-end + contract (OBS-21). Reuses the default `make_current` from the base ABC.""" + + def __init__(self, context: InstrumentationContext | None = None) -> None: + self._context = context or NOOP_INSTRUMENTATION_CONTEXT + self.end_calls = 0 + self.recorded_error: BaseException | None = None + + @property + def is_recording(self) -> bool: + return True + + @property + def context(self) -> InstrumentationContext: + return self._context + + def set_attribute(self, key: str, value: Any) -> _FakeSpan: + return self + + def set_error(self, error_type: str) -> _FakeSpan: + return self + + def end(self, error: BaseException | None = None) -> None: + if self.end_calls == 0 and error is not None: + self.recorded_error = error + self.end_calls += 1 + + +# ----- Recording flag + idempotent end (OBS-21) ---------------------------- + + +@pytest.mark.req("OBS-21") +def test_noop_span_is_not_recording() -> None: + assert NOOP_SPAN.is_recording is False + + +@pytest.mark.req("OBS-21") +def test_noop_span_end_is_idempotent() -> None: + NOOP_SPAN.end() + NOOP_SPAN.end() + NOOP_SPAN.end(error=RuntimeError("boom")) + + +@pytest.mark.req("OBS-21") +def test_noop_span_mutators_return_self() -> None: + assert NOOP_SPAN.set_attribute("k", "v") is NOOP_SPAN + assert NOOP_SPAN.set_error("ValueError") is NOOP_SPAN + + +def test_noop_span_context_is_the_noop_context() -> None: + assert NOOP_SPAN.context is NOOP_INSTRUMENTATION_CONTEXT + assert NOOP_SPAN.context.is_valid is False + + +@pytest.mark.req("OBS-21") +def test_recording_span_end_records_error_once() -> None: + span = _FakeSpan() + err = RuntimeError("boom") + span.end(error=err) + span.end(error=RuntimeError("second")) + assert span.end_calls == 2 + assert span.recorded_error is err + + +# ----- make_current restores the prior span, incl. on throw (OBS-22) ------- + + +@pytest.mark.req("OBS-22") +def test_default_current_span_is_the_noop_span() -> None: + assert get_current_span() is NOOP_SPAN + + +def test_make_current_activates_and_restores_prior_span() -> None: + outer = _FakeSpan() + inner = _FakeSpan() + assert get_current_span() is NOOP_SPAN + with outer.make_current(): + assert get_current_span() is outer + with inner.make_current(): + assert get_current_span() is inner + assert get_current_span() is outer + assert get_current_span() is NOOP_SPAN + + +@pytest.mark.req("OBS-22") +def test_make_current_restores_prior_span_on_throw() -> None: + outer = _FakeSpan() + inner = _FakeSpan() + with outer.make_current(): + assert get_current_span() is outer + with pytest.raises(RuntimeError): # noqa: SIM117 - nested scope under test + with inner.make_current(): + assert get_current_span() is inner + raise RuntimeError("boom") + # The prior span is restored even though the body raised. + assert get_current_span() is outer + assert get_current_span() is NOOP_SPAN + + +@pytest.mark.req("OBS-22") +def test_make_current_scope_close_is_idempotent() -> None: + span = _FakeSpan() + scope = span.make_current() + assert get_current_span() is span + scope.close() + assert get_current_span() is NOOP_SPAN + scope.close() # second close is a documented no-op + assert get_current_span() is NOOP_SPAN + + +def test_make_current_returns_a_tracing_scope() -> None: + span = _FakeSpan() + with span.make_current() as scope: + assert isinstance(scope, TracingScope) + + +# ----- No-op allocation / identity (OBS-25) -------------------------------- + + +@pytest.mark.req("OBS-25") +def test_noop_span_make_current_reuses_the_shared_scope() -> None: + first = NOOP_SPAN.make_current() + second = NOOP_SPAN.make_current() + assert first is second + + +def test_noop_span_make_current_does_not_touch_current_span() -> None: + # A non-recording span never becomes the ambient current span. + with NOOP_SPAN.make_current(): + assert get_current_span() is NOOP_SPAN + + +def test_noop_tracer_start_span_is_the_shared_noop_span() -> None: + assert NOOP_TRACER.start_span("op") is NOOP_SPAN + assert NOOP_TRACER.start_span("op", parent=NOOP_INSTRUMENTATION_CONTEXT) is NOOP_SPAN diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_tracer.py b/packages/dexpace-sdk-core/tests/instrumentation/test_tracer.py new file mode 100644 index 0000000..f180bc4 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_tracer.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the Tracer span factory: no-op default and never-wrapped contract.""" + +from __future__ import annotations + +import pytest + +from dexpace.sdk.core.instrumentation import ( + NOOP_SPAN, + NOOP_TRACER, + InstrumentationContext, + Span, + Tracer, +) + + +def test_noop_tracer_is_a_tracer() -> None: + assert isinstance(NOOP_TRACER, Tracer) + + +def test_noop_tracer_returns_the_shared_noop_span() -> None: + assert NOOP_TRACER.start_span("op") is NOOP_SPAN + assert NOOP_TRACER.start_span("op", parent=None) is NOOP_SPAN + + +@pytest.mark.req("OBS-30") +def test_tracer_exception_is_not_wrapped_or_suppressed() -> None: + # Tracer/meter callbacks are contractually never-throwing; when one does + # raise, the SDK does NOT wrap or swallow it — the original propagates. + sentinel = RuntimeError("tracer boom") + + class _BoomTracer(Tracer): + def start_span(self, name: str, parent: InstrumentationContext | None = None) -> Span: + raise sentinel + + with pytest.raises(RuntimeError) as excinfo: + _BoomTracer().start_span("op") + assert excinfo.value is sentinel diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_url_redactor.py b/packages/dexpace-sdk-core/tests/instrumentation/test_url_redactor.py index 851fc18..bacbc28 100644 --- a/packages/dexpace-sdk-core/tests/instrumentation/test_url_redactor.py +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_url_redactor.py @@ -1,115 +1,461 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE.md in the repository root for details. -"""Tests for ``UrlRedactor``.""" +"""Conformance battery for ``UrlRedactor``. + +Certifies the one default-deny URL redactor against every technical note in its +contract — userinfo stripping, query allow-listing, atomic multi-value +redaction, fragment scrubbing, component preservation, and total (never-raising) +parsing of malformed input — plus a fuzz check that ``redact`` cannot throw and +a "used-by-all" audit that the repr, logging, and tracing emitters all route +through this same redactor rather than reimplementing their own. +""" from __future__ import annotations -from dexpace.sdk.core.http.common import Url +import random +import string +from typing import Self + +import pytest + +from dexpace.sdk.core.client.http_client import HttpClient +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.common.url import QueryParams +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Response, Status from dexpace.sdk.core.instrumentation import UrlRedactor +from dexpace.sdk.core.instrumentation.instrumentation_context import InstrumentationContext +from dexpace.sdk.core.instrumentation.noop import NOOP_INSTRUMENTATION_CONTEXT, NOOP_SPAN +from dexpace.sdk.core.instrumentation.span import Span +from dexpace.sdk.core.instrumentation.tracer import Tracer +from dexpace.sdk.core.instrumentation.tracing_scope import TracingScope +from dexpace.sdk.core.pipeline import Pipeline +from dexpace.sdk.core.pipeline.policies import HttpLogDetailLevel, LoggingPolicy +from dexpace.sdk.core.pipeline.policies.tracing_policy import TracingPolicy, _set_request_attributes + +_MALFORMED = "[malformed url]" + + +class TestUserinfo: + """Userinfo (embedded credentials) is always redacted.""" + + @pytest.mark.req("OBS-11", "XCUT-19") + def test_strips_userinfo_from_string(self) -> None: + redacted = UrlRedactor().redact("https://alice:s3cr3t@api.example.com/path") + assert "alice" not in redacted + assert "s3cr3t" not in redacted + assert redacted == "https://api.example.com/path" + + def test_strips_userinfo_from_parsed_url(self) -> None: + parsed = Url(scheme="https", host="api.example.com", userinfo="alice:s3cr3t") + redacted = UrlRedactor().redact(parsed) + assert "alice" not in redacted + assert "s3cr3t" not in redacted + assert "api.example.com" in redacted + + @pytest.mark.req("OBS-11") + def test_strips_userinfo_with_at_in_password(self) -> None: + # The last '@' delimits userinfo, so an '@' inside the password does not + # leave credential fragments behind. + redacted = UrlRedactor().redact("https://user:p@ss@api.example.com/path") + assert redacted == "https://api.example.com/path" + + +class TestHeaderValueRedaction: + """``redact_header_value`` handles both absolute and relative URL values.""" + + @pytest.mark.req("OBS-16") + def test_absolute_value_redacted_like_a_request_url(self) -> None: + # A parseable absolute value is redacted exactly like a request URL: + # userinfo stripped, non-allowlisted query collapsed. + out = UrlRedactor().redact_header_value( + "https://alice:pw@api.example.com/cb?code=SEKRET&api-version=1.0" + ) + assert "alice" not in out + assert "SEKRET" not in out + assert "REDACTED=REDACTED" in out + assert "api-version=1.0" in out + assert out.startswith("https://api.example.com/cb?") + + @pytest.mark.req("OBS-16") + def test_relative_value_with_query_keeps_path_and_marks(self) -> None: + # A relative value keeps its path and drops the query, since the query + # can carry an OAuth code / pre-signed signature. + out = UrlRedactor().redact_header_value("/callback?code=SEKRET&state=xyz") + assert out == "/callback?***" + assert "SEKRET" not in out + + @pytest.mark.req("OBS-16") + def test_relative_value_with_fragment_only_keeps_path_and_marks(self) -> None: + # An implicit-flow token can ride in the fragment, so a fragment alone + # still triggers the drop-and-mark behaviour. + out = UrlRedactor().redact_header_value("/callback#access_token=SEKRET") + assert out == "/callback?***" + assert "SEKRET" not in out + + @pytest.mark.req("OBS-16") + def test_relative_value_without_query_or_fragment_is_verbatim(self) -> None: + assert UrlRedactor().redact_header_value("/callback/next") == "/callback/next" + + @pytest.mark.req("OBS-16") + def test_unparseable_value_with_query_is_dropped(self) -> None: + # ``?`` inside a fragment must not be mistaken for a query separator. + assert UrlRedactor().redact_header_value("mailto:x#a?b") == "mailto:x?***" + assert UrlRedactor().redact_header_value("garbage value") == "garbage value" + + @pytest.mark.req("OBS-16") + def test_never_raises_on_adversarial_values(self) -> None: + for value in ("", "#", "?", "://", "http://", "/a?b#c?d", "\x00\x01"): + assert isinstance(UrlRedactor().redact_header_value(value), str) + + +class TestQueryParameters: + """Query values are redacted unless explicitly allow-listed.""" + + @pytest.mark.req("OBS-12", "XCUT-19") + def test_allowlisted_query_kept_others_redacted(self) -> None: + out = UrlRedactor().redact("https://api.example.com/v1?api-version=1.0&token=hunter2") + assert "api-version=1.0" in out + # Non-allowlisted params collapse to REDACTED=REDACTED so the parameter + # name ("token") never leaks either. + assert "REDACTED=REDACTED" in out + assert "token" not in out + assert "hunter2" not in out + + def test_custom_allowlist(self) -> None: + out = UrlRedactor(allowed_query_keys={"plain"}).redact( + "https://example.com/?plain=ok&api-version=1.0" + ) + assert "plain=ok" in out + # api-version is not in the custom allow-list; key and value are redacted. + assert "REDACTED=REDACTED" in out + assert "api-version" not in out + + def test_bare_token_query_redacts_key_and_value(self) -> None: + # A bare token with no '=' parses as a key with an empty value; without + # key redaction the secret would survive verbatim as the parameter name. + token = "eyJhbGciOiJIUzI1NiJ9.payload.signature" + out = UrlRedactor().redact(f"https://api.example.com/v1?{token}") + assert token not in out + assert "REDACTED=REDACTED" in out + + def test_empty_query_introduces_no_separator(self) -> None: + out = UrlRedactor().redact("https://api.example.com/v1?") + assert out == "https://api.example.com/v1" + + +class TestMultiValueAtomicRedaction: + """A multi-value parameter is redacted all-or-nothing, per key.""" + + @pytest.mark.req("OBS-12") + def test_non_allowlisted_multivalue_all_redacted(self) -> None: + out = UrlRedactor(allowed_query_keys=set()).redact("https://example.com/?a=1&a=2") + assert out.count("REDACTED=REDACTED") == 2 + assert "a=" not in out + assert "1" not in out and "2" not in out + + @pytest.mark.req("OBS-12") + def test_allowlisted_multivalue_all_kept(self) -> None: + # The allow-list decision is atomic at the key level: every value of an + # allow-listed key survives, none are dropped or partially redacted. + out = UrlRedactor(allowed_query_keys={"sort"}).redact( + "https://example.com/?sort=asc&sort=desc&sig=secret" + ) + assert "sort=asc" in out + assert "sort=desc" in out + assert "secret" not in out + assert "REDACTED=REDACTED" in out + + +class TestFragment: + """Fragment key=value pairs are scrubbed; plain fragments are preserved.""" + + def test_key_value_fragment_scrubbed_by_default(self) -> None: + out = UrlRedactor().redact("https://x/api?api-version=1#access_token=abc") + assert "abc" not in out + assert out.endswith("#REDACTED=REDACTED") + + @pytest.mark.req("OBS-13") + def test_multi_segment_fragment_scrubs_only_pairs(self) -> None: + out = UrlRedactor().redact("https://x/p#access_token=abc&state=xyz&plainpart") + assert "abc" not in out + assert "xyz" not in out + # The plain, non-key/value segment survives verbatim. + assert out == "https://x/p#REDACTED=REDACTED&REDACTED=REDACTED&plainpart" + + @pytest.mark.req("OBS-13") + def test_plain_fragment_preserved_as_is(self) -> None: + out = UrlRedactor().redact("https://x/page#section-heading") + assert out == "https://x/page#section-heading" + + def test_fragment_preserved_when_redaction_disabled(self) -> None: + out = UrlRedactor(redact_fragment=False).redact("https://x/api?api-version=1#token=abc") + assert "token=abc" in out + + +class TestComponentPreservation: + """Scheme, host, port, and path survive redaction unchanged.""" + + @pytest.mark.req("OBS-14") + def test_scheme_host_port_path_preserved(self) -> None: + out = UrlRedactor().redact("https://api.example.com:8443/v1/users/42") + assert out == "https://api.example.com:8443/v1/users/42" + + def test_path_preserved_by_default(self) -> None: + out = UrlRedactor().redact("https://x/users/123/secret/abc") + assert "/users/123/secret/abc" in out + + def test_path_redacted_when_enabled(self) -> None: + out = UrlRedactor(redact_path=True).redact("https://x/users/123/secret/abc") + assert "123" not in out + assert "abc" not in out + assert out == "https://x/REDACTED" + + +class TestNoSpuriousQuerySeparator: + """A literal '?' inside a fragment must not be re-split as a query.""" + + @pytest.mark.req("OBS-14") + def test_question_mark_in_fragment_stays_in_fragment(self) -> None: + out = UrlRedactor().redact("https://x/path#section?still-fragment") + # Exactly one '?', and it sits after the '#' (i.e. inside the fragment). + assert out == "https://x/path#section?still-fragment" + assert out.count("?") == 1 + assert out.index("#") < out.index("?") + + @pytest.mark.req("OBS-14") + def test_real_query_plus_question_mark_in_fragment(self) -> None: + out = UrlRedactor(allowed_query_keys={"a"}).redact("https://x/p?a=1#frag?x") + # The query '?' and the fragment '?' both survive; no third is injected. + assert out == "https://x/p?a=1#frag?x" + + +class TestMalformedInput: + """Malformed input redacts to a literal placeholder and never throws.""" + + @pytest.mark.req("XCUT-20") + def test_not_a_url(self) -> None: + assert UrlRedactor().redact("not a url") == _MALFORMED + + def test_missing_scheme_secret_does_not_leak(self) -> None: + secret = "Bearer sk-live-abc123def456" + out = UrlRedactor().redact(f"://{secret}") + assert secret not in out + assert out == _MALFORMED + + def test_invalid_port(self) -> None: + assert UrlRedactor().redact("http://host:notaport/path") == _MALFORMED + + def test_out_of_range_port(self) -> None: + assert UrlRedactor().redact("http://host:99999/path") == _MALFORMED + + def test_unterminated_ipv6(self) -> None: + assert UrlRedactor().redact("http://[bad") == _MALFORMED + + def test_empty_string(self) -> None: + assert UrlRedactor().redact("") == _MALFORMED + + def test_scheme_only(self) -> None: + assert UrlRedactor().redact("https://") == _MALFORMED + + def test_non_ascii_digit_port(self) -> None: + # Superscript '2' is str.isdigit() but not int()-parseable; the redactor + # must reject it without raising rather than trusting isdigit alone. + assert UrlRedactor().redact("http://host:²/path") == _MALFORMED + + +class TestParsedUrlInput: + """A pre-parsed ``Url`` is redacted directly, without re-parsing.""" + + def test_accepts_parsed_url(self) -> None: + parsed = Url.parse("https://api.example.com/v1?secret=value") + out = UrlRedactor().redact(parsed) + assert "REDACTED=REDACTED" in out + assert "secret" not in out + assert "value" not in out + + def test_parsed_url_full_surface(self) -> None: + parsed = Url( + scheme="https", + host="api.example.com", + port=8443, + path="/v1", + query=QueryParams([("token", "s3cr3t")]), + fragment="access_token=abc", + userinfo="alice:pw", + ) + out = UrlRedactor().redact(parsed) + for leaked in ("alice", "pw", "s3cr3t", "abc", "token"): + assert leaked not in out + assert "https://api.example.com:8443/v1" in out + + +class TestNeverThrows: + """``redact`` is total: no input string makes it raise.""" + + _ADVERSARIAL = ( + "", + "://", + "http://", + "https://:@", + "http://[", + "http://]", + "http://[::1", + "http://host:", + "http://host:-1/x", + "http://host:99999999999999999999/x", + "%%%", + "?#", + "#?", + "https://h/p?%zz=%gg#%", + "https://h/p?a=1;b=2", + "\x00\x01\x02", + "https://\U0001f600/\U0001f4a9?☃=❤#⚡", + " https://host /p ", + "ht tp://host/p", + "://secret-token", + "a" * 5000, + ) + + @pytest.mark.req("OBS-15", "XCUT-20") + def test_adversarial_corpus_never_raises(self) -> None: + redactor = UrlRedactor() + for raw in self._ADVERSARIAL: + result = redactor.redact(raw) + assert isinstance(result, str) + + def test_random_fuzz_never_raises(self) -> None: + # A stdlib fuzz stands in for a hypothesis property test: hypothesis is + # not a dev dependency, so a seeded RNG drives a deterministic sweep of + # arbitrary strings and asserts the redactor cannot throw on any of them. + alphabet = string.ascii_letters + string.digits + ":/?#[]@!$&'()*+,;=%.-_~ \t\n\U0001f4a9" + rng = random.Random(0xC0FFEE) + configs = [ + UrlRedactor(), + UrlRedactor(redact_path=True), + UrlRedactor(redact_fragment=False), + UrlRedactor(allowed_query_keys=set()), + ] + for _ in range(4000): + raw = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 60))) + for redactor in configs: + result = redactor.redact(raw) + assert isinstance(result, str) + + +class _RecordingSpan(Span): + """Minimal `Span` that records only the attributes set on it.""" + def __init__(self) -> None: + self.attributes: dict[str, object] = {} -def test_strips_userinfo() -> None: - redactor = UrlRedactor() - redacted = redactor.redact("https://user:secret@api.example.com/path") - assert "user" not in redacted - assert "secret" not in redacted - assert "api.example.com" in redacted + @property + def is_recording(self) -> bool: + return True + @property + def context(self) -> InstrumentationContext: + return NOOP_INSTRUMENTATION_CONTEXT -def test_allowlisted_query_unredacted() -> None: - redactor = UrlRedactor() - redacted = redactor.redact("https://api.example.com/v1?api-version=1.0&token=hunter2") - assert "api-version=1.0" in redacted - # Non-allowlisted params collapse to a canonical REDACTED=REDACTED so the - # parameter name ("token") never leaks either. - assert "REDACTED=REDACTED" in redacted - assert "token" not in redacted - assert "hunter2" not in redacted + def set_attribute(self, key: str, value: object) -> Self: + self.attributes[key] = value + return self + def set_error(self, error_type: str) -> Self: + return self -def test_accepts_parsed_url() -> None: - redactor = UrlRedactor() - parsed = Url.parse("https://api.example.com/v1?secret=value") - out = redactor.redact(parsed) - assert "REDACTED=REDACTED" in out - assert "secret" not in out - assert "value" not in out + def make_current(self) -> TracingScope: + return NOOP_SPAN.make_current() + def end(self, error: BaseException | None = None) -> None: + return None -def test_unparseable_input_fails_closed() -> None: - # A URL that cannot be parsed must never reach the log verbatim: it may - # embed a secret. The redactor fails closed to a constant placeholder. - redactor = UrlRedactor() - assert redactor.redact("not a url") == "REDACTED:unparseable" +class _RecordingTracer(Tracer): + """`Tracer` handing out one shared recording span.""" -def test_unparseable_secret_does_not_leak() -> None: - redactor = UrlRedactor() - secret = "Bearer sk-live-abc123def456" - out = redactor.redact(f"://{secret}") - assert secret not in out - assert out == "REDACTED:unparseable" + def __init__(self, span: Span) -> None: + self._span = span + def start_span(self, name: str, parent: InstrumentationContext | None = None) -> Span: + return self._span -def test_invalid_port_fails_closed() -> None: - # furl raises ValueError("Invalid port ...") for a non-numeric port; the - # redactor catches it and fails closed rather than crashing the log path. - redactor = UrlRedactor() - assert redactor.redact("http://host:notaport/path") == "REDACTED:unparseable" +class _StubClient(HttpClient): + """Terminal client returning a fixed 200 response.""" -def test_invalid_ipv6_fails_closed() -> None: - # An unterminated IPv6 literal makes furl raise ValueError; fail closed. - redactor = UrlRedactor() - assert redactor.redact("http://[bad") == "REDACTED:unparseable" + def execute(self, request: Request) -> Response: + return Response(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK) -def test_custom_allowlist() -> None: - redactor = UrlRedactor(allowed_query_keys={"plain"}) - out = redactor.redact("https://example.com/?plain=ok&api-version=1.0") - assert "plain=ok" in out - # api-version was not in the custom allow-list; key and value are redacted. - assert "REDACTED=REDACTED" in out - assert "api-version" not in out +class _RecordingLogger: + """`ClientLogger`-shaped double that captures structured log fields.""" + def __init__(self) -> None: + self.records: list[dict[str, object]] = [] -def test_multiple_values_per_key() -> None: - redactor = UrlRedactor(allowed_query_keys=set()) - out = redactor.redact("https://example.com/?a=1&a=2") - assert out.count("REDACTED=REDACTED") == 2 - assert "a=" not in out + def info(self, message: str, **fields: object) -> None: + self.records.append({"message": message, **fields}) + def error(self, message: str, **fields: object) -> None: + self.records.append({"message": message, **fields}) -def test_bare_token_query_redacts_key_and_value() -> None: - # A bare token with no '=' parses as a key with an empty value. Without - # key redaction the secret would survive verbatim as the parameter name. - redactor = UrlRedactor() - token = "eyJhbGciOiJIUzI1NiJ9.payload.signature" - out = redactor.redact(f"https://api.example.com/v1?{token}") - assert token not in out - assert "REDACTED=REDACTED" in out +class TestUsedByAllEmitters: + """Every URL emitter routes through the one shared ``UrlRedactor``.""" -def test_redactor_redacts_fragment_by_default() -> None: - redactor = UrlRedactor() - out = redactor.redact("https://x/api?api-version=1#token=abc") - assert "abc" not in out + _SECRET_URL = "https://alice:pw@api.example.com/v1?token=s3cr3t#access_token=abc" + def _request(self) -> Request: + return Request(method=Method.GET, url=Url.parse(self._SECRET_URL)) -def test_redactor_redacts_path_when_enabled() -> None: - redactor = UrlRedactor(redact_path=True) - out = redactor.redact("https://x/users/123/secret/abc") - assert "abc" not in out and "123" not in out + def test_request_repr_redacts_url(self) -> None: + rendered = repr(self._request()) + for leaked in ("alice", "pw", "s3cr3t", "abc"): + assert leaked not in rendered + assert "REDACTED" in rendered + def test_response_repr_redacts_url(self) -> None: + # Response has no URL of its own; it must inherit redaction via the + # request repr rather than re-exposing the raw URL. + response = Response( + request=self._request(), + protocol=Protocol.HTTP_1_1, + status=Status.OK, + ) + rendered = repr(response) + for leaked in ("alice", "pw", "s3cr3t", "abc"): + assert leaked not in rendered -def test_fragment_preserved_when_redact_fragment_false() -> None: - redactor = UrlRedactor(redact_fragment=False) - out = redactor.redact("https://x/api?api-version=1#token=abc") - assert "token=abc" in out + def test_tracing_url_full_attribute_redacted(self) -> None: + span = _RecordingSpan() + _set_request_attributes(span, self._request(), UrlRedactor()) + url_full = span.attributes["url.full"] + assert isinstance(url_full, str) + assert "s3cr3t" not in url_full + assert "alice" not in url_full + assert "REDACTED" in url_full + def test_tracing_policy_wires_redactor_end_to_end(self) -> None: + span = _RecordingSpan() + policy = TracingPolicy(tracer=_RecordingTracer(span)) + with Pipeline(_StubClient(), policies=[policy]) as pipeline: + pipeline.run(self._request(), DispatchContext(NOOP_INSTRUMENTATION_CONTEXT)) + url_full = span.attributes["url.full"] + assert isinstance(url_full, str) + assert "s3cr3t" not in url_full + assert "REDACTED" in url_full -def test_path_preserved_by_default() -> None: - redactor = UrlRedactor() - out = redactor.redact("https://x/users/123/secret/abc") - assert "/users/123/secret/abc" in out + def test_logging_policy_redacts_url(self) -> None: + logger = _RecordingLogger() + policy = LoggingPolicy(logger=logger, detail_level=HttpLogDetailLevel.HEADERS) # type: ignore[arg-type] + with Pipeline(_StubClient(), policies=[policy]) as pipeline: + pipeline.run(self._request(), DispatchContext(NOOP_INSTRUMENTATION_CONTEXT)) + assert logger.records + for record in logger.records: + url = record.get("url.full") + assert isinstance(url, str) + assert "s3cr3t" not in url + assert "alice" not in url diff --git a/packages/dexpace-sdk-core/tests/instrumentation/test_url_redactor_properties.py b/packages/dexpace-sdk-core/tests/instrumentation/test_url_redactor_properties.py new file mode 100644 index 0000000..fa1c0cf --- /dev/null +++ b/packages/dexpace-sdk-core/tests/instrumentation/test_url_redactor_properties.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Property-based tests pinning ``UrlRedactor`` totality on arbitrary input. + +Invariant covered: + +- ``UrlRedactor.redact`` never raises and always returns a ``str`` for any + input string, under any redaction configuration. The redactor sits on the + logging path and must fail closed on malformed input (returning the + ``REDACTED:unparseable`` sentinel) rather than letting an exotic parse error + escape into the log sink. + +The example count and deadline are governed by the capped "ci" Hypothesis +profile registered in ``tests/conftest.py``. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.instrumentation import UrlRedactor + +# Arbitrary text, plus URL-shaped strings assembled from parts, to stress the +# ``furl`` parse path (schemes, authority, userinfo, ports, query, fragment). +_text = st.text(st.characters(codec="utf-8"), max_size=40) +_url_shaped = st.builds( + "{}://{}@{}:{}/{}?{}#{}".format, + _text, + _text, + _text, + _text, + _text, + _text, + _text, +) +_any_input = st.one_of(_text, _url_shaped) + + +@given(raw=_any_input) +def test_redact_never_raises_on_arbitrary_strings(raw: str) -> None: + """Redacting any string returns a ``str`` and never raises.""" + result = UrlRedactor().redact(raw) + assert isinstance(result, str) + + +@pytest.mark.req("OBS-15") +@given( + raw=_any_input, + redact_path=st.booleans(), + redact_fragment=st.booleans(), + allowlist=st.frozensets(st.text(max_size=8), max_size=5), +) +def test_redact_totality_across_configs( + raw: str, + redact_path: bool, + redact_fragment: bool, + allowlist: frozenset[str], +) -> None: + """Redaction stays total under any path/fragment/allowlist configuration.""" + redactor = UrlRedactor( + allowlist, + redact_path=redact_path, + redact_fragment=redact_fragment, + ) + assert isinstance(redactor.redact(raw), str) diff --git a/packages/dexpace-sdk-core/tests/pagination/test_async_paginator_battery.py b/packages/dexpace-sdk-core/tests/pagination/test_async_paginator_battery.py new file mode 100644 index 0000000..54f4160 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pagination/test_async_paginator_battery.py @@ -0,0 +1,454 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Full async certification battery for `AsyncPaginator` / `Page`. + +The async twin of ``test_paginator_battery.py``. It certifies the lifecycle +guarantees an async paged operation must uphold — laziness, one exchange per +page, a close-once ledger over every termination path (success, error, +cancellation, early break), a single-use context-managed ``by_page`` view, and +the two ``__context__``-chaining behaviours around a failing close — plus the +async-only invariants the sync side cannot express: native cancellation +propagation (an ``asyncio.CancelledError`` is never converted), page-granular +drop-and-close of a staged page whose parse is cancelled, stack-safety over +many thousands of synchronously-completing pages, and original-cause surfacing +for a transport that throws eagerly before any ``await``. +""" + +from __future__ import annotations + +import asyncio +import json +import traceback +from collections.abc import AsyncIterator + +import pytest + +from dexpace.sdk.core.errors import SdkError +from dexpace.sdk.core.http.common import Headers, MediaType, Protocol, Url +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, Status +from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody +from dexpace.sdk.core.pagination import AsyncPaginator, CursorStrategy, Page +from dexpace.sdk.core.pagination.strategy import HasHeaders + + +class _LedgerBody(AsyncResponseBody): + """Async body that records only lifecycle (`close`) invocations. + + Reading it does not close it — ``bytes`` / ``string`` are overridden to + hand back the buffered data without routing through the base class's + close-on-exhaustion path. That isolates the close ledger to the closes + driven by `Page.aclose` / `AsyncResponse.close`, so a count of exactly one + per page proves the page owns and performs its close exactly once. + """ + + def __init__(self, data: bytes) -> None: + self._data = data + self.closes = 0 + + def media_type(self) -> MediaType | None: + return None + + def content_length(self) -> int: + return len(self._data) + + async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + yield self._data + + async def bytes(self) -> bytes: + return self._data + + async def string(self, encoding: str | None = None) -> str: + return self._data.decode(encoding or "utf-8") + + async def close(self) -> None: + self.closes += 1 + + +class _ExplodingCloseBody(_LedgerBody): + """A ledger body whose `close` records the call and then raises.""" + + def __init__(self, data: bytes, error: BaseException) -> None: + super().__init__(data) + self._error = error + + async def close(self) -> None: + self.closes += 1 + raise self._error + + +class _BlockingReadBody(AsyncResponseBody): + """A body whose read parks on a gate and never self-closes on read. + + ``string`` blocks awaiting the gate, so a task parked mid-parse can be + cancelled there. Because the read path does not close the body, the only + close observable is the one the paginator drives when it drops the staged + response — which is exactly what the mid-parse cancellation test asserts. + """ + + def __init__(self, gate: asyncio.Event) -> None: + self._gate = gate + self.closes = 0 + + def media_type(self) -> MediaType | None: + return None + + def content_length(self) -> int: + return -1 + + async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + await self._gate.wait() + yield b'{"data": []}' + + async def bytes(self) -> bytes: + await self._gate.wait() + return b'{"data": []}' + + async def string(self, encoding: str | None = None) -> str: + return (await self.bytes()).decode(encoding or "utf-8") + + async def close(self) -> None: + self.closes += 1 + + +class _Source: + """Exchange-counting async pipeline mapping a cursor to a canned page body. + + Modelled as an ``AsyncPipelineLike`` (an ``async run``) rather than a bare + callable: ``inspect.iscoroutinefunction`` reports ``False`` for a callable + *instance* whose ``__call__`` is async, so a class with an async ``run`` is + the async paginator's supported recording shape. Records every request it is + handed so tests can assert exactly how many exchanges the paginator drove, + and retains each body so the close ledger is observable. + """ + + def __init__(self, pages: dict[str | None, dict[str, object]]) -> None: + self._pages = pages + self.calls: list[Request] = [] + self.bodies: list[_LedgerBody] = [] + + async def run(self, request: Request, _dispatch: DispatchContext) -> AsyncResponse: + self.calls.append(request) + cursor = request.url.query.get("cursor") + body = _LedgerBody(json.dumps(self._pages[cursor]).encode("utf-8")) + self.bodies.append(body) + return _response(request, body) + + +class _BoomStrategy: + """Strategy that always raises when parsing — drives the parse-failure path.""" + + def __init__(self, error: BaseException) -> None: + self._error = error + + def parse( + self, + response: HasHeaders, + payload: object, + template_request: Request, + ) -> Page[int]: + raise self._error + + +def _response(request: Request, body: AsyncResponseBody) -> AsyncResponse: + return AsyncResponse( + request=request, + protocol=Protocol.HTTP_1_1, + status=Status.OK, + headers=Headers(), + body=body, + ) + + +def _first_request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://api.example.com/items")) + + +def _strategy() -> CursorStrategy[int]: + return CursorStrategy( + items_field="data", + cursor_response_field="next_cursor", + cursor_param="cursor", + ) + + +def _three_pages() -> dict[str | None, dict[str, object]]: + return { + None: {"data": [1, 2], "next_cursor": "c1"}, + "c1": {"data": [3, 4], "next_cursor": "c2"}, + "c2": {"data": [5], "next_cursor": None}, + } + + +async def _drain(paginator: AsyncPaginator[int]) -> list[int]: + return [item async for item in paginator] + + +# --- laziness / exchange counting ----------------------------------------- + + +async def test_no_exchange_until_consumption_starts() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + paginator.__aiter__() # obtaining the iterator must not fetch + assert source.calls == [] + + +async def test_by_page_does_not_fetch_until_iterated() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + paginator.by_page() # building the page view must not fetch + assert source.calls == [] + + +@pytest.mark.req("PAGE-29") +async def test_exactly_one_exchange_per_page_and_no_overrun() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + assert await _drain(paginator) == [1, 2, 3, 4, 5] + assert len(source.calls) == 3 + + +@pytest.mark.req("PAGE-8") +async def test_each_aiter_restarts_pagination_from_the_start() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + assert await _drain(paginator) == [1, 2, 3, 4, 5] + assert await _drain(paginator) == [1, 2, 3, 4, 5] + # Two independent full walks, each with its own exchanges. + assert len(source.calls) == 6 + + +# --- close-once ledger over every abandon path ---------------------------- + + +@pytest.mark.req("PAGE-27") +async def test_flat_iteration_closes_each_response_exactly_once() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + await _drain(paginator) + assert [body.closes for body in source.bodies] == [1, 1, 1] + + +async def test_flat_item_view_closes_page_before_yielding_its_items() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + ait = paginator.__aiter__() + assert await ait.__anext__() == 1 + # The first page's response is closed by the time its first item reaches the + # caller — items are detached from any live connection. + assert source.bodies[0].closes == 1 + + +async def test_by_page_context_exit_closes_each_page_once() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + async with paginator.by_page() as pages: + async for page in pages: + async with page: + _ = list(page.items) + assert [body.closes for body in source.bodies] == [1, 1, 1] + + +async def test_by_page_early_break_releases_held_page_once() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + async with paginator.by_page() as pages: + async for _page in pages: + break # hold the first page without closing it + # Only the first page was fetched (laziness) and it is closed exactly once. + assert len(source.bodies) == 1 + assert source.bodies[0].closes == 1 + + +async def test_by_page_exhaustion_closes_every_page_once() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + async with paginator.by_page() as pages: + async for page in pages: + await page.aclose() + assert [body.closes for body in source.bodies] == [1, 1, 1] + + +async def test_fetcher_does_not_close_pages_it_yields() -> None: + # by_page yields live pages; closing is the caller's / page's job, never the + # fetcher's. Without a close from the caller the responses stay open. + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + pages = [page async for page in paginator.by_page()] + assert [body.closes for body in source.bodies] == [0, 0, 0] + assert len(pages) == 3 + + +# --- single-use, context-managed page view -------------------------------- + + +async def test_by_page_is_single_use() -> None: + source = _Source(_three_pages()) + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + paginator.by_page() + with pytest.raises(RuntimeError, match="single-use"): + paginator.by_page() + + +# --- error / __context__ chaining ----------------------------------------- + + +@pytest.mark.req("PAGE-27", "PAGE-28", "PAGE-32") +async def test_parse_failure_closes_inline_with_parse_error_primary() -> None: + parse_error = RuntimeError("parse boom") + close_error = OSError("close boom") + body = _ExplodingCloseBody(b'{"data": []}', close_error) + + async def source(request: Request) -> AsyncResponse: + return _response(request, body) + + paginator: AsyncPaginator[int] = AsyncPaginator( + source, + _BoomStrategy(parse_error), + _first_request(), + ) + with pytest.raises(RuntimeError) as info: + await _drain(paginator) + # Parse error is primary; the close-time error is suppressed onto its + # __context__, never masking the parse failure. The response is closed once. + assert info.value is parse_error + assert info.value.__context__ is close_error + assert body.closes == 1 + + +@pytest.mark.req("PAGE-15") +async def test_held_page_close_error_wraps_the_original_failure() -> None: + close_error = OSError("close boom") + payload = json.dumps({"data": [1], "next_cursor": None}).encode() + body = _ExplodingCloseBody(payload, close_error) + + async def source(request: Request) -> AsyncResponse: + return _response(request, body) + + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + original = RuntimeError("work boom") + with pytest.raises(OSError) as info: + async with paginator.by_page() as pages: + async for _page in pages: + raise original # fails while holding a fetched-but-unconsumed page + # The close-time error surfaces (wrapped); the original failure is the + # secondary note on __context__. + assert info.value is close_error + assert info.value.__context__ is original + + +@pytest.mark.req("PAGE-32") +async def test_successful_path_close_error_is_surfaced() -> None: + close_error = OSError("close boom") + payload = json.dumps({"data": [1], "next_cursor": None}).encode() + body = _ExplodingCloseBody(payload, close_error) + + async def source(request: Request) -> AsyncResponse: + return _response(request, body) + + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + # The page parses and its items materialise fine; only its eager close on + # the flat item view throws. That close error must surface to the caller + # rather than being silently swallowed. + with pytest.raises(OSError) as info: + await _drain(paginator) + assert info.value is close_error + assert body.closes == 1 + + +# --- eager (pre-await) transport throw ------------------------------------ + + +@pytest.mark.req("PAGE-28") +async def test_eager_transport_throw_surfaces_original_cause() -> None: + boom = RuntimeError("eager transport boom") + + async def source(request: Request) -> AsyncResponse: + raise boom # raised eagerly, before the coroutine ever awaits + + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + with pytest.raises(RuntimeError) as info: + await _drain(paginator) + assert info.value is boom + + +# --- cancellation matrix --------------------------------------------------- + + +@pytest.mark.req("PAGE-25", "PAGE-33") +async def test_mid_fetch_cancel_propagates_natively() -> None: + gate = asyncio.Event() + + async def source(request: Request) -> AsyncResponse: + await gate.wait() # parked in-flight; the exchange never completes + raise AssertionError("unreachable") + + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + task = asyncio.ensure_future(_drain(paginator)) + await asyncio.sleep(0) # let the task park in the fetch + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.req("PAGE-26", "PAGE-27", "PAGE-33") +async def test_mid_parse_cancel_drops_and_closes_staged_page() -> None: + gate = asyncio.Event() + body = _BlockingReadBody(gate) + + async def source(request: Request) -> AsyncResponse: + return _response(request, body) + + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + task = asyncio.ensure_future(_drain(paginator)) + await asyncio.sleep(0) # let the task park mid-parse on the blocked read + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + # The staged response — never yielded as a page — is dropped and closed. + assert body.closes == 1 + + +@pytest.mark.req("PAGE-28") +async def test_cancelled_error_is_never_converted() -> None: + async def source(request: Request) -> AsyncResponse: + raise asyncio.CancelledError + + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + with pytest.raises(asyncio.CancelledError) as info: + await _drain(paginator) + # Exact type — never widened to an SdkError or any other exception type. + assert type(info.value) is asyncio.CancelledError + assert not isinstance(info.value, SdkError) + + +# --- trampoline over many synchronously-completing pages ------------------ + + +@pytest.mark.req("PAGE-29", "PAGE-31") +async def test_trampoline_over_many_pages_has_no_stack_growth() -> None: + page_count = 8000 + depths: list[int] = [] + + async def source(request: Request) -> AsyncResponse: + depths.append(len(traceback.extract_stack())) + cursor = request.url.query.get("cursor") + idx = 0 if cursor is None else int(cursor) + nxt = None if idx >= page_count - 1 else str(idx + 1) + payload = {"data": [idx], "next_cursor": nxt} + return _response(request, _LedgerBody(json.dumps(payload).encode("utf-8"))) + + paginator: AsyncPaginator[int] = AsyncPaginator(source, _strategy(), _first_request()) + items = await _drain(paginator) + assert items == list(range(page_count)) + assert len(depths) == page_count + # Constant stack depth across every page proves iteration, not per-page + # recursion: a recursive drive would grow the stack linearly in the count. + assert max(depths) - min(depths) <= 2 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/packages/dexpace-sdk-core/tests/pagination/test_link_header.py b/packages/dexpace-sdk-core/tests/pagination/test_link_header.py index 1ecda02..172f675 100644 --- a/packages/dexpace-sdk-core/tests/pagination/test_link_header.py +++ b/packages/dexpace-sdk-core/tests/pagination/test_link_header.py @@ -30,6 +30,7 @@ def test_parse_multiple_link_values_in_order() -> None: assert parsed[1][1]["rel"] == "last" +@pytest.mark.req("PAGE-18") def test_comma_inside_quoted_param_does_not_split_link_values() -> None: header = '; rel="next"; title="one, two"' parsed = parse_link_header(header) @@ -61,6 +62,7 @@ def test_param_names_are_case_insensitive() -> None: "whitespace-header", ], ) +@pytest.mark.req("PAGE-18") def test_find_rel_returns_matching_target(header: str, rel: str, expected: str | None) -> None: assert find_rel(header, rel) == expected @@ -70,6 +72,7 @@ def test_unquoted_param_value_is_accepted() -> None: assert find_rel(header, "next") == "https://x/1" +@pytest.mark.req("PAGE-18") def test_escaped_quote_inside_quoted_value_is_unescaped() -> None: header = r'; rel="next"; title="a \"quoted\" word"' parsed = parse_link_header(header) @@ -83,6 +86,7 @@ def test_malformed_segment_without_brackets_is_ignored() -> None: assert parsed[0][0] == "https://x/1" +@pytest.mark.req("PAGE-18") def test_comma_inside_uri_target_does_not_split_link_value() -> None: # A comma is a legal unencoded URI sub-delim; splitting inside <...> would # shred the target and drop the whole link-value. diff --git a/packages/dexpace-sdk-core/tests/pagination/test_paginator.py b/packages/dexpace-sdk-core/tests/pagination/test_paginator.py index b73baca..539f4b7 100644 --- a/packages/dexpace-sdk-core/tests/pagination/test_paginator.py +++ b/packages/dexpace-sdk-core/tests/pagination/test_paginator.py @@ -88,6 +88,7 @@ def _three_page_pipeline() -> _MockPipeline: ) +@pytest.mark.req("PAGE-1") def test_iterates_items_across_all_pages() -> None: pipeline = _three_page_pipeline() paginator: Paginator[int] = Paginator(pipeline, _strategy(), _first_request()) @@ -103,6 +104,7 @@ def test_drives_the_pipeline_once_per_page() -> None: assert pipeline.calls[2].url.query.get("cursor") == "c2" +@pytest.mark.req("PAGE-1") def test_by_page_yields_pages_with_raw_response() -> None: pipeline = _three_page_pipeline() paginator: Paginator[int] = Paginator(pipeline, _strategy(), _first_request()) @@ -147,6 +149,43 @@ def test_single_page_sequence_terminates() -> None: assert len(pipeline.calls) == 1 +@pytest.mark.req("PAGE-35", "PAGE-36") +def test_same_options_instance_threads_through_every_page_fetch() -> None: + # The paginator uses the caller's fetcher verbatim: the first fetch runs it + # once, later fetches send whatever ``next_request`` the strategy returned, + # and a ``None`` next request terminates. Whatever per-call options the + # fetcher closes over are the SAME object on every fetch (never copied per + # page), and a mutation the caller makes mid-walk is observed by the next + # fetch. Modelled on the pipeline options-identity proof in test_defaults.py. + options: dict[str, object] = {"marker": "start"} + seen_options: list[dict[str, object]] = [] + observed_markers: list[object] = [] + bodies: dict[str | None, dict[str, object]] = { + None: {"data": [1], "next_cursor": "c1"}, + "c1": {"data": [2], "next_cursor": "c2"}, + "c2": {"data": [3], "next_cursor": None}, + } + + def fetch(request: Request) -> Response: + seen_options.append(options) # capture the options identity per fetch + observed_markers.append(options["marker"]) # read caller-visible state + options["marker"] = f"page-{len(seen_options)}" # mutate; next fetch sees it + cursor = request.url.query.get("cursor") + return Response( + request=request, + protocol=Protocol.HTTP_1_1, + status=Status.OK, + headers=Headers(), + body=_TrackingBody(json.dumps(bodies[cursor]).encode("utf-8")), + ) + + paginator: Paginator[int] = Paginator(fetch, _strategy(), _first_request()) + assert list(paginator) == [1, 2, 3] + assert len(seen_options) == 3 # one fetch per page, terminated by the None cursor + assert all(opts is options for opts in seen_options) # same object every fetch + assert observed_markers == ["start", "page-1", "page-2"] # mutations seen downstream + + class _RawBodyPipeline: """Pipeline returning a fixed, possibly non-JSON, body for every request.""" diff --git a/packages/dexpace-sdk-core/tests/pagination/test_paginator_battery.py b/packages/dexpace-sdk-core/tests/pagination/test_paginator_battery.py new file mode 100644 index 0000000..6c7f894 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pagination/test_paginator_battery.py @@ -0,0 +1,386 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Full sync certification battery for `Paginator` / `Page`. + +The lifecycle guarantees a paged operation must uphold — laziness, one +exchange per page, no fetch past the terminal page, idempotent end probes, +restart-per-``iter``, a positive-only page cap, eager per-page close on the +flat item view, a single-use context-managed ``by_page`` view, and the two +distinct ``__context__``-chaining behaviours around a failing close — all live +on the `Paginator` / `Page` pair in ``core.pagination`` (the only pagination +machinery that owns a response and is therefore responsible for closing it). +The callable-based ``ItemPaged`` / ``Pager`` in ``http.common.pagination`` has +no response to close and is exercised separately in ``tests/http``. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator + +import pytest + +from dexpace.sdk.core.http.common import Headers, MediaType, Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Response, Status +from dexpace.sdk.core.http.response.response_body import ResponseBody +from dexpace.sdk.core.pagination import CursorStrategy, Page, Paginator +from dexpace.sdk.core.pagination.strategy import HasHeaders + + +class _LedgerBody(ResponseBody): + """Test body that records only lifecycle (`close`) invocations. + + Unlike production bodies, reading it does not close it. That isolates the + close ledger to the closes driven by `Page.close` / `Response.close`, so a + count of exactly one per page proves the page owns and performs its close + exactly once — separate from the orthogonal read-time close. + """ + + def __init__(self, data: bytes) -> None: + self._data = data + self.closes = 0 + + def media_type(self) -> MediaType | None: + return None + + def content_length(self) -> int: + return len(self._data) + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + yield self._data + + def bytes(self) -> bytes: + return self._data + + def string(self, encoding: str | None = None) -> str: + return self._data.decode(encoding or "utf-8") + + def close(self) -> None: + self.closes += 1 + + +class _ExplodingCloseBody(_LedgerBody): + """A ledger body whose `close` records the call and then raises.""" + + def __init__(self, data: bytes, error: BaseException) -> None: + super().__init__(data) + self._error = error + + def close(self) -> None: + self.closes += 1 + raise self._error + + +class _Source: + """Exchange-counting send-callable mapping a cursor to a canned page body. + + Records every request it is handed so tests can assert exactly how many + exchanges the paginator drove, and retains each body so the close ledger is + observable. + """ + + def __init__(self, pages: dict[str | None, dict[str, object]]) -> None: + self._pages = pages + self.calls: list[Request] = [] + self.bodies: list[_LedgerBody] = [] + + def __call__(self, request: Request) -> Response: + self.calls.append(request) + cursor = request.url.query.get("cursor") + body = _LedgerBody(json.dumps(self._pages[cursor]).encode("utf-8")) + self.bodies.append(body) + return _response(request, body) + + +class _BoomStrategy: + """Strategy that always raises when parsing — drives the parse-failure path.""" + + def __init__(self, error: BaseException) -> None: + self._error = error + + def parse( + self, + response: HasHeaders, + payload: object, + template_request: Request, + ) -> Page[int]: + raise self._error + + +def _response(request: Request, body: ResponseBody) -> Response: + return Response( + request=request, + protocol=Protocol.HTTP_1_1, + status=Status.OK, + headers=Headers(), + body=body, + ) + + +def _first_request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://api.example.com/items")) + + +def _strategy() -> CursorStrategy[int]: + return CursorStrategy( + items_field="data", + cursor_response_field="next_cursor", + cursor_param="cursor", + ) + + +def _three_pages() -> dict[str | None, dict[str, object]]: + return { + None: {"data": [1, 2], "next_cursor": "c1"}, + "c1": {"data": [3, 4], "next_cursor": "c2"}, + "c2": {"data": [5], "next_cursor": None}, + } + + +def _looping_pages() -> dict[str | None, dict[str, object]]: + # A non-advancing server: the cursor never resolves to None, so the + # sequence would run forever without a cap. + return { + None: {"data": [0], "next_cursor": "loop"}, + "loop": {"data": [0], "next_cursor": "loop"}, + } + + +def _finite_pages(n: int) -> dict[str | None, dict[str, object]]: + pages: dict[str | None, dict[str, object]] = {} + for i in range(n): + key = None if i == 0 else f"c{i}" + nxt: str | None = f"c{i + 1}" if i < n - 1 else None + pages[key] = {"data": [i], "next_cursor": nxt} + return pages + + +# --- laziness / exchange counting ----------------------------------------- + + +@pytest.mark.req("PAGE-6") +def test_no_exchange_until_consumption_starts() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + iter(paginator) # obtaining the iterator must not fetch + assert source.calls == [] + + +@pytest.mark.req("PAGE-6") +def test_by_page_does_not_fetch_until_iterated() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + paginator.by_page() # building the page view must not fetch + assert source.calls == [] + + +@pytest.mark.req("PAGE-6", "PAGE-7") +def test_exactly_one_exchange_per_page_and_no_overrun() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + assert list(paginator) == [1, 2, 3, 4, 5] + # Three pages consumed, three exchanges — nothing fetched past the + # terminal (``next_cursor: None``) page. + assert len(source.calls) == 3 + + +@pytest.mark.req("PAGE-7") +def test_end_of_pagination_probe_is_idempotent() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + with paginator.by_page() as pages: + assert [list(page.items) for page in pages] == [[1, 2], [3, 4], [5]] + assert len(source.calls) == 3 + # Probing the exhausted view again fires no further exchange. + with pytest.raises(StopIteration): + next(pages) + with pytest.raises(StopIteration): + next(pages) + assert len(source.calls) == 3 + + +@pytest.mark.req("PAGE-8") +def test_each_iter_restarts_pagination_from_the_start() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + assert list(paginator) == [1, 2, 3, 4, 5] + assert list(paginator) == [1, 2, 3, 4, 5] + # Two independent full walks, each with its own exchanges. + assert len(source.calls) == 6 + + +# --- page metadata survives response close -------------------------------- + + +@pytest.mark.req("PAGE-2") +def test_page_metadata_survives_after_response_close() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + with paginator.by_page() as pages: + page = next(pages) + page.close() + # Items were captured, not lazily read off the now-closed response. + assert list(page.items) == [1, 2] + # Status / headers remain readable off the raw response after close. + assert isinstance(page.raw, Response) + assert page.raw.status is Status.OK + assert page.raw.headers == Headers() + + +# --- close-once ledger over every abandon path ---------------------------- + + +def test_flat_iteration_closes_each_response_exactly_once() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + list(paginator) + assert [body.closes for body in source.bodies] == [1, 1, 1] + + +@pytest.mark.req("PAGE-11") +def test_flat_item_view_closes_page_before_yielding_its_items() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + it = iter(paginator) + assert next(it) == 1 + # The first page's response is already closed by the time its first item + # reaches the caller — items are detached from any live connection. + assert source.bodies[0].closes == 1 + + +@pytest.mark.req("PAGE-12") +def test_by_page_context_exit_closes_each_page_once() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + with paginator.by_page() as pages: + for page in pages: + with page: + _ = list(page.items) + assert [body.closes for body in source.bodies] == [1, 1, 1] + + +@pytest.mark.req("PAGE-12") +def test_by_page_early_break_releases_held_page_once() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + with paginator.by_page() as pages: + for _page in pages: + break # hold the first page without closing it + # Only the first page was fetched (laziness) and it is closed exactly once. + assert len(source.bodies) == 1 + assert source.bodies[0].closes == 1 + + +@pytest.mark.req("PAGE-3") +def test_by_page_exhaustion_closes_every_page_once() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + with paginator.by_page() as pages: + for page in pages: + page.close() + assert [body.closes for body in source.bodies] == [1, 1, 1] + + +@pytest.mark.req("PAGE-3") +def test_fetcher_does_not_close_pages_it_yields() -> None: + # by_page yields live pages; closing is the caller's / page's job, never + # the fetcher's. Without a close from the caller the responses stay open. + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + pages = list(paginator.by_page()) + assert [body.closes for body in source.bodies] == [0, 0, 0] + assert len(pages) == 3 + + +# --- single-use, context-managed page view -------------------------------- + + +@pytest.mark.req("PAGE-14") +def test_by_page_is_single_use() -> None: + source = _Source(_three_pages()) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + paginator.by_page() + with pytest.raises(RuntimeError, match="single-use"): + paginator.by_page() + + +# --- pagination cap -------------------------------------------------------- + + +@pytest.mark.req("PAGE-9") +@pytest.mark.parametrize("bad_cap", [0, -1, -100]) +def test_non_positive_cap_raises_at_construction(bad_cap: int) -> None: + source = _Source(_three_pages()) + with pytest.raises(ValueError, match="max_pages"): + Paginator(source, _strategy(), _first_request(), max_pages=bad_cap) + + +@pytest.mark.req("PAGE-9") +def test_non_advancing_server_stops_at_exactly_the_cap() -> None: + source = _Source(_looping_pages()) + paginator: Paginator[int] = Paginator( + source, + _strategy(), + _first_request(), + max_pages=4, + ) + items = list(paginator) + assert len(source.calls) == 4 # exactly the cap — not 3, not 5 + assert items == [0, 0, 0, 0] + + +@pytest.mark.req("PAGE-10") +def test_default_cap_is_effectively_unbounded() -> None: + source = _Source(_finite_pages(50)) + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + assert list(paginator) == list(range(50)) + assert len(source.calls) == 50 + + +# --- error / __context__ chaining ----------------------------------------- + + +@pytest.mark.req("PAGE-13") +def test_parse_failure_closes_inline_with_parse_error_primary() -> None: + parse_error = RuntimeError("parse boom") + close_error = OSError("close boom") + + def source(request: Request) -> Response: + return _response(request, _ExplodingCloseBody(b'{"data": []}', close_error)) + + paginator: Paginator[int] = Paginator( + source, + _BoomStrategy(parse_error), + _first_request(), + ) + with pytest.raises(RuntimeError) as info: + list(paginator) + # Parse error is primary; the close-time error is suppressed onto its + # __context__, never masking the parse failure. + assert info.value is parse_error + assert info.value.__context__ is close_error + + +@pytest.mark.req("PAGE-15") +def test_held_page_close_error_wraps_the_original_failure() -> None: + close_error = OSError("close boom") + body = json.dumps({"data": [1], "next_cursor": None}).encode() + + def source(request: Request) -> Response: + return _response(request, _ExplodingCloseBody(body, close_error)) + + paginator: Paginator[int] = Paginator(source, _strategy(), _first_request()) + original = RuntimeError("work boom") + with pytest.raises(OSError) as info, paginator.by_page() as pages: + for _page in pages: + raise original # fails while holding a fetched-but-unconsumed page + # The close-time error surfaces (wrapped); the original failure that led to + # holding the page is the secondary note on __context__. + assert info.value is close_error + assert info.value.__context__ is original + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/packages/dexpace-sdk-core/tests/pagination/test_splice.py b/packages/dexpace-sdk-core/tests/pagination/test_splice.py new file mode 100644 index 0000000..a534ef1 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pagination/test_splice.py @@ -0,0 +1,127 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Conformance tests for the verbatim raw-query pagination splice. + +The ``pagination_splice`` (``PAGE-21``) family of the golden wire-exactness +corpus is driven directly through the production splice (``splice_query``). The +splice is a byte-for-byte token-level rewrite: every untouched query parameter +survives verbatim (a literal ``+`` is neither re-encoded to ``%2B`` nor decoded +to a space, a pre-encoded ``%20`` is preserved), so the corpus proves the splice +never performs a lossy ``parse_qs`` / ``urlencode`` round-trip. + +Non-query URL components (scheme, host, path, fragment) are peeled off with +``urlsplit`` / ``urlunsplit`` — raw-component operations that never decode — so +only the query string reaches the splice, exactly as a strategy rewrites the +query of an otherwise-untouched request URL. +""" + +from __future__ import annotations + +from urllib.parse import urlsplit, urlunsplit + +import pytest + +from dexpace.sdk.core.http.common._query_codec import remove_query_param, splice_query +from dexpace.sdk.tck.golden_corpus import PaginationSpliceVector, load_pagination_splice + +_VECTORS = load_pagination_splice() + + +def _splice_url(base: str, name: str, value: str) -> str: + """Splice ``name=value`` into ``base``'s query, preserving other components.""" + parts = urlsplit(base) + new_query = splice_query(parts.query, name, value) + return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) + + +# ----- golden corpus: PAGE-21 byte-for-byte ------------------------------- + + +@pytest.mark.req("PAGE-21", "PAGE-24") +@pytest.mark.parametrize("vector", _VECTORS, ids=lambda v: v.id) +def test_splice_matches_golden_byte_for_byte(vector: PaginationSpliceVector) -> None: + """The production splice reproduces every corpus vector byte for byte.""" + spliced = _splice_url( + vector.base.decode("ascii"), + vector.param.decode("ascii"), + vector.value.decode("ascii"), + ) + assert spliced == vector.spliced.decode("ascii"), vector.id + + +@pytest.mark.req("PAGE-21") +@pytest.mark.parametrize("vector", _VECTORS, ids=lambda v: v.id) +def test_splice_preserves_every_untouched_param_verbatim( + vector: PaginationSpliceVector, +) -> None: + """Each raw ``name=value`` the vector pins as untouched survives verbatim.""" + spliced = _splice_url( + vector.base.decode("ascii"), + vector.param.decode("ascii"), + vector.value.decode("ascii"), + ) + for untouched in vector.untouched: + assert untouched.decode("ascii") in spliced, (vector.id, untouched) + + +# ----- splice_query unit semantics ---------------------------------------- + + +def test_splice_appends_to_empty_query() -> None: + assert splice_query("", "cursor", "c1") == "cursor=c1" + + +def test_splice_appends_absent_key_with_ampersand() -> None: + assert splice_query("limit=50", "cursor", "c1") == "limit=50&cursor=c1" + + +@pytest.mark.req("PAGE-21") +def test_splice_replaces_first_occurrence_in_place() -> None: + # Position is preserved: the replaced key keeps its slot; trailing params + # do not shift, and a second occurrence is left untouched by a "set". + assert splice_query("cursor=c1&limit=50", "cursor", "c2") == "cursor=c2&limit=50" + assert splice_query("a=1&cursor=old&b=2&cursor=stale", "cursor", "new") == ( + "a=1&cursor=new&b=2&cursor=stale" + ) + + +@pytest.mark.req("PAGE-21", "PAGE-22") +def test_splice_leaves_a_literal_plus_untouched() -> None: + # A literal '+' in an untouched param is neither re-encoded to %2B nor + # decoded to a space — the token is copied through byte for byte. + assert splice_query("tag=a+b", "cursor", "c2") == "tag=a+b&cursor=c2" + + +def test_splice_leaves_pre_encoded_bytes_untouched() -> None: + assert splice_query("q=a%20b&sort=desc", "cursor", "c2") == "q=a%20b&sort=desc&cursor=c2" + + +@pytest.mark.req("PAGE-22") +def test_splice_encodes_only_the_new_pair() -> None: + # The freshly-spliced value is strictly RFC 3986 encoded (space -> %20), + # while the untouched neighbour keeps its own raw bytes. + assert splice_query("tag=a+b", "q", "x y") == "tag=a+b&q=x%20y" + + +def test_splice_preserves_repeated_untouched_keys() -> None: + assert splice_query("tag=x&tag=y", "cursor", "c2") == "tag=x&tag=y&cursor=c2" + + +# ----- remove_query_param unit semantics ---------------------------------- + + +def test_remove_drops_every_occurrence() -> None: + assert remove_query_param("a=1&cursor=c1&b=2&cursor=c2", "cursor") == "a=1&b=2" + + +def test_remove_absent_key_is_a_noop() -> None: + assert remove_query_param("a=1&b=2", "cursor") == "a=1&b=2" + + +def test_remove_on_empty_query_is_empty() -> None: + assert remove_query_param("", "cursor") == "" + + +def test_remove_preserves_untouched_bytes_verbatim() -> None: + assert remove_query_param("q=a%20b&drop=x&tag=a+b", "drop") == "q=a%20b&tag=a+b" diff --git a/packages/dexpace-sdk-core/tests/pagination/test_strategies.py b/packages/dexpace-sdk-core/tests/pagination/test_strategies.py index 2ef91e1..71e08c4 100644 --- a/packages/dexpace-sdk-core/tests/pagination/test_strategies.py +++ b/packages/dexpace-sdk-core/tests/pagination/test_strategies.py @@ -5,6 +5,8 @@ from __future__ import annotations +import pytest + from dexpace.sdk.core.http.common import Headers, Protocol, Url from dexpace.sdk.core.http.request import Method, Request from dexpace.sdk.core.http.response import Response, Status @@ -29,6 +31,7 @@ def _response(req: Request, *, headers: Headers | None = None) -> Response: class TestCursorStrategy: + @pytest.mark.req("PAGE-16") def test_extracts_items_and_builds_next_with_cursor_param(self) -> None: strategy: CursorStrategy[int] = CursorStrategy( items_field="data", @@ -54,6 +57,7 @@ def test_token_convention_uses_configured_field_names(self) -> None: assert page.next_request is not None assert page.next_request.url.query.get("page_token") == "tok" + @pytest.mark.req("PAGE-4") def test_absent_cursor_ends_sequence(self) -> None: strategy: CursorStrategy[int] = CursorStrategy(items_field="data") req = _request() @@ -61,6 +65,7 @@ def test_absent_cursor_ends_sequence(self) -> None: assert page.next_request is None assert not page.has_next + @pytest.mark.req("PAGE-16") def test_empty_cursor_string_ends_sequence(self) -> None: strategy: CursorStrategy[int] = CursorStrategy(items_field="data") req = _request() @@ -93,6 +98,7 @@ def test_zero_integer_cursor_is_coerced(self) -> None: assert page.next_request is not None assert page.next_request.url.query.get("cursor") == "0" + @pytest.mark.req("PAGE-16", "PAGE-4") def test_null_cursor_ends_sequence(self) -> None: strategy: CursorStrategy[int] = CursorStrategy(items_field="data") req = _request() @@ -107,8 +113,38 @@ def test_boolean_cursor_ends_sequence(self) -> None: page = strategy.parse(_response(req), {"data": [1], "next_cursor": True}, req) assert page.next_request is None + @pytest.mark.req("PAGE-5") + def test_end_decision_reads_only_the_decoded_payload(self) -> None: + # The null-vs-empty-cursor termination is decided from the single + # pre-decoded payload the paginator already read; the strategy never + # reaches back into the response body for a second read. + class _BodyExplodes: + @property + def headers(self) -> Headers: + return Headers() + + @property + def body(self) -> object: # pragma: no cover - must never be hit + raise AssertionError("strategy must not read the response body") + + strategy: CursorStrategy[int] = CursorStrategy(items_field="data") + req = _request() + page = strategy.parse(_BodyExplodes(), {"data": [1], "next_cursor": None}, req) + assert page.next_request is None + + @pytest.mark.req("PAGE-24") + def test_next_request_preserves_untouched_encoded_param(self) -> None: + # An unrelated, pre-encoded query parameter on the template request + # survives verbatim into the next request when the cursor is spliced. + strategy: CursorStrategy[int] = CursorStrategy(items_field="data") + req = _request("https://api.example.com/items?q=a%20b") + page = strategy.parse(_response(req), {"data": [1], "next_cursor": "c2"}, req) + assert page.next_request is not None + assert str(page.next_request.url) == "https://api.example.com/items?q=a%20b&cursor=c2" + class TestPageNumberStrategy: + @pytest.mark.req("PAGE-17") def test_increments_page_param_when_full_page(self) -> None: strategy: PageNumberStrategy[int] = PageNumberStrategy( items_field="data", @@ -128,6 +164,7 @@ def test_short_page_ends_sequence(self) -> None: page = strategy.parse(_response(req), {"data": [1]}, req) assert page.next_request is None + @pytest.mark.req("PAGE-17", "PAGE-4") def test_empty_page_ends_sequence(self) -> None: strategy: PageNumberStrategy[int] = PageNumberStrategy(items_field="data") req = _request("https://api.example.com/items?page=4") @@ -147,6 +184,7 @@ def test_total_pages_field_bounds_iteration(self) -> None: assert more.next_request is not None assert more.next_request.url.query.get("page") == "3" + @pytest.mark.req("PAGE-17") def test_defaults_to_start_page_when_param_absent(self) -> None: strategy: PageNumberStrategy[int] = PageNumberStrategy( items_field="data", @@ -158,8 +196,23 @@ def test_defaults_to_start_page_when_param_absent(self) -> None: assert page.next_request is not None assert page.next_request.url.query.get("page") == "2" + @pytest.mark.req("PAGE-17") + def test_garbage_page_value_falls_back_to_start_page(self) -> None: + # An unparseable ``page`` value must not raise; it falls back to the + # documented start page so the next index is derived deterministically. + strategy: PageNumberStrategy[int] = PageNumberStrategy( + items_field="data", + start_page=1, + page_size=2, + ) + req = _request("https://api.example.com/items?page=notanumber") + page = strategy.parse(_response(req), {"data": [1, 2]}, req) + assert page.next_request is not None + assert page.next_request.url.query.get("page") == "2" + class TestLinkHeaderStrategy: + @pytest.mark.req("PAGE-18") def test_follows_rel_next_target(self) -> None: strategy: LinkHeaderStrategy[int] = LinkHeaderStrategy(items_field="data") headers = Headers( @@ -186,6 +239,7 @@ def test_exposes_prev_request_when_present(self) -> None: assert page.prev_request is not None assert str(page.prev_request.url) == "https://api.example.com/items?page=1" + @pytest.mark.req("PAGE-20") def test_no_link_header_ends_sequence(self) -> None: strategy: LinkHeaderStrategy[int] = LinkHeaderStrategy(items_field="data") req = _request() @@ -193,6 +247,7 @@ def test_no_link_header_ends_sequence(self) -> None: assert page.next_request is None assert page.prev_request is None + @pytest.mark.req("PAGE-36") def test_next_request_preserves_method_and_headers(self) -> None: strategy: LinkHeaderStrategy[int] = LinkHeaderStrategy(items_field="data") headers = Headers([("Link", '; rel="next"')]) @@ -206,6 +261,7 @@ def test_next_request_preserves_method_and_headers(self) -> None: assert page.next_request.method is Method.GET assert page.next_request.headers.get("authorization") == "Bearer t" + @pytest.mark.req("PAGE-19") def test_relative_target_is_resolved_against_request_url(self) -> None: # RFC 5988 permits a relative target; it must be resolved against the # request URL rather than raising on a missing scheme. @@ -216,6 +272,7 @@ def test_relative_target_is_resolved_against_request_url(self) -> None: assert page.next_request is not None assert str(page.next_request.url) == "https://api.example.com/items?page=2" + @pytest.mark.req("PAGE-20") def test_next_across_multiple_link_header_lines(self) -> None: # RFC 9110 permits the Link header to be split across separate header # lines; reading only the first line drops the rel="next" on the @@ -249,3 +306,40 @@ def test_next_target_with_comma_in_query_is_followed(self) -> None: query = page.next_request.url.query assert query.get("fields") == "a,b" assert query.get("page") == "2" + + @pytest.mark.req("PAGE-19") + def test_query_only_target_resolves_against_base_path(self) -> None: + # A query-only reference (RFC 3986 §5.4) replaces only the query; the + # base request's path is preserved rather than dropped. + strategy: LinkHeaderStrategy[int] = LinkHeaderStrategy(items_field="data") + headers = Headers([("Link", '; rel="next"')]) + req = _request("https://api.example.com/v2/items?page=1") + page = strategy.parse(_response(req, headers=headers), {"data": [1]}, req) + assert page.next_request is not None + assert str(page.next_request.url) == "https://api.example.com/v2/items?page=2" + + def test_multi_token_rel_value_resolves_next_and_prev(self) -> None: + # RFC 5988 §5.3 lets one link-value carry a space-separated rel set; a + # single ``rel="next prev"`` value must satisfy both relations. + strategy: LinkHeaderStrategy[int] = LinkHeaderStrategy(items_field="data") + headers = Headers( + [("Link", '; rel="next prev"')], + ) + req = _request() + page = strategy.parse(_response(req, headers=headers), {"data": [1]}, req) + assert page.next_request is not None + assert page.prev_request is not None + assert str(page.next_request.url) == "https://api.example.com/items?page=2" + assert str(page.prev_request.url) == "https://api.example.com/items?page=2" + + @pytest.mark.req("PAGE-19") + def test_unparseable_target_ends_sequence_without_error(self) -> None: + # A malformed ``rel="next"`` target (here one that resolves to a + # scheme-only, host-less URI) must terminate pagination — never raise + # and abort a walk that had already yielded good pages. + strategy: LinkHeaderStrategy[int] = LinkHeaderStrategy(items_field="data") + headers = Headers([("Link", '; rel="next"')]) + req = _request() + page = strategy.parse(_response(req, headers=headers), {"data": [1]}, req) + assert page.next_request is None + assert not page.has_next diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_observability_policies.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_observability_policies.py new file mode 100644 index 0000000..31d30bd --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_observability_policies.py @@ -0,0 +1,574 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for ``AsyncLoggingPolicy`` and ``AsyncTracingPolicy``. + +Async twin of ``test_observability_policies.py``. Verifies the async pillar +emits the same wire-log event model as the sync policy, previews the request +body (reusing the sync ``LoggableRequestBody`` tap) and — via the async +``AsyncLoggableResponseBody`` twin — the response body, and drives the same +per-attempt tracing span / ``HttpTracer`` callbacks the sync ``TracingPolicy`` +does. It also pins the async-specific behaviours the sync side lacks: +capture is skipped for a response whose content length is undeclared, and the +tracing span / metrics fire independently of whether logging is enabled. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Mapping +from typing import Any + +import pytest +from _pytest.logging import LogCaptureFixture + +from dexpace.sdk.core.client.async_http_client import AsyncHttpClient +from dexpace.sdk.core.errors import ServiceRequestError +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.media_type import MediaType +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request, RequestBody +from dexpace.sdk.core.http.response import AsyncResponse, AsyncResponseBody, Status +from dexpace.sdk.core.instrumentation import ( + HttpTracer, + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + Tracer, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN +from dexpace.sdk.core.pipeline import AsyncPipeline +from dexpace.sdk.core.pipeline.policies import AsyncRetryPolicy, HttpLogDetailLevel +from dexpace.sdk.core.pipeline.policies.async_logging_policy import AsyncLoggingPolicy +from dexpace.sdk.core.pipeline.policies.async_tracing_policy import AsyncTracingPolicy + + +def _instr(trace: str, *, tracer_factory: Any | None = None) -> InstrumentationContext: + kwargs: dict[str, Any] = {} + if tracer_factory is not None: + kwargs["http_tracer_factory"] = tracer_factory + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + **kwargs, + ) + + +def _request(url: str = "https://api.example.com/v1?token=secret&api-version=1.0") -> Request: + return Request(method=Method.GET, url=Url.parse(url)) + + +def _post(body: RequestBody) -> Request: + return Request(method=Method.POST, url=Url.parse("https://api.example.com/v1"), body=body) + + +class _UnknownLengthResponseBody(AsyncResponseBody): + """Async response body whose declared content length is unknown (-1).""" + + def __init__(self, data: bytes, media_type: MediaType | None = None) -> None: + self._data = data + self._media_type = media_type + self._closed = False + + def media_type(self) -> MediaType | None: + return self._media_type + + def content_length(self) -> int: + return -1 + + def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + return self._aiter(chunk_size) + + async def _aiter(self, chunk_size: int) -> AsyncIterator[bytes]: + view = memoryview(self._data) + for start in range(0, len(view), chunk_size): + yield bytes(view[start : start + chunk_size]) + await self.close() + + async def close(self) -> None: + self._closed = True + + +class _StubAsyncClient(AsyncHttpClient): + """Async transport: drains the request body and returns a fresh response.""" + + def __init__( + self, + *, + status: Status = Status.OK, + raise_exc: BaseException | None = None, + body_factory: Any | None = None, + headers: Headers | None = None, + ) -> None: + self.status = status + self.raise_exc = raise_exc + self.body_factory = body_factory + self.headers = headers or Headers() + self.received = bytearray() + self.calls = 0 + + async def execute(self, request: Request) -> AsyncResponse: + self.calls += 1 + if self.raise_exc is not None: + raise self.raise_exc + if request.body is not None: + for chunk in request.body.iter_bytes(): + self.received.extend(chunk) + body = self.body_factory() if self.body_factory is not None else None + return AsyncResponse( + request=request, + protocol=Protocol.HTTP_1_1, + status=self.status, + headers=self.headers, + body=body, + ) + + +def _messages(caplog: LogCaptureFixture) -> list[str]: + return [rec.getMessage() for rec in caplog.records] + + +class TestAsyncLoggingPolicy: + async def test_emits_request_and_response(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + async with AsyncPipeline( + _StubAsyncClient(), + policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)], + ) as p: + await p.run(_request(), DispatchContext(_instr("0" * 16 + "1"))) + messages = _messages(caplog) + assert any("http.request" in m for m in messages) + assert any("http.response" in m for m in messages) + joined = " ".join(messages) + assert "secret" not in joined + assert "REDACTED" in joined + + @pytest.mark.req("OBS-39") + async def test_logs_error_on_exception(self, caplog: LogCaptureFixture) -> None: + # Parity with the sync policy: a failure emits an ``http.response`` event + # carrying ``error.type`` and the throwable cause. + caplog.set_level(logging.ERROR, logger="dexpace.sdk.core.http") + boom = ServiceRequestError("connect failed") + async with AsyncPipeline( + _StubAsyncClient(raise_exc=boom), + policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)], + ) as p: + with pytest.raises(ServiceRequestError): + await p.run(_request(), DispatchContext(_instr("0" * 16 + "2"))) + assert any( + m.split()[0] == "http.response" + and "error.type=ServiceRequestError" in m + and "connect failed" in m + for m in _messages(caplog) + ) + + async def test_opt_out_via_options(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + async with AsyncPipeline( + _StubAsyncClient(), + policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY)], + ) as p: + await p.run( + _request(), + DispatchContext(_instr("0" * 16 + "3")), + logging_enabled=False, + ) + assert not caplog.records + + def test_default_preview_size_is_8_kib(self) -> None: + assert AsyncLoggingPolicy().preview_bytes == 8 * 1024 + + def test_preview_bytes_must_be_positive(self) -> None: + with pytest.raises(ValueError, match="positive"): + AsyncLoggingPolicy(preview_bytes=0) + + async def test_full_request_body_forwarded_uncut_while_preview_truncates( + self, caplog: LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = b"x" * (8 * 1024 + 500) + client = _StubAsyncClient() + async with AsyncPipeline( + client, policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY)] + ) as p: + await p.run( + _post(RequestBody.from_bytes(payload)), DispatchContext(_instr("0" * 16 + "8")) + ) + # The transport received every byte — only the diagnostic tap truncates. + assert bytes(client.received) == payload + responses = [m for m in _messages(caplog) if "http.response" in m] + assert responses + preview = responses[0].split("request_body_preview=")[1].split()[0] + assert len(preview) == 8 * 1024 + + async def test_binary_request_body_preview_never_throws( + self, caplog: LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = bytes(range(256)) * 40 # invalid UTF-8 sequences, > 8 KiB + client = _StubAsyncClient() + async with AsyncPipeline( + client, policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY)] + ) as p: + await p.run( + _post(RequestBody.from_bytes(payload)), DispatchContext(_instr("0" * 16 + "9")) + ) + assert bytes(client.received) == payload + assert any("http.response" in m for m in _messages(caplog)) + + async def test_response_body_preview_captured_when_length_known( + self, caplog: LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = b'{"ok":true}' + client = _StubAsyncClient(body_factory=lambda: AsyncResponseBody.from_bytes(payload)) + async with AsyncPipeline( + client, policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY)] + ) as p: + response = await p.run(_request(), DispatchContext(_instr("0" * 16 + "a"))) + # The full body is still available to the caller after previewing. + assert await response.body.bytes() == payload # type: ignore[union-attr] + responses = [m for m in _messages(caplog) if "http.response" in m] + assert responses + assert "response_body_preview=" in responses[0] + + @pytest.mark.req("OBS-36") + async def test_response_full_body_forwarded_while_preview_truncates( + self, caplog: LogCaptureFixture + ) -> None: + # A known-length response larger than the preview cap: the caller still + # reads every byte (prefix + streamed tail), only the preview truncates. + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = b"R" * 1024 + client = _StubAsyncClient(body_factory=lambda: AsyncResponseBody.from_bytes(payload)) + async with AsyncPipeline( + client, + policies=[AsyncLoggingPolicy(preview_bytes=256, detail_level=HttpLogDetailLevel.BODY)], + ) as p: + response = await p.run(_request(), DispatchContext(_instr("0" * 15 + "a1"))) + assert await response.body.bytes() == payload # type: ignore[union-attr] + responses = [m for m in _messages(caplog) if "http.response" in m] + assert responses + preview = responses[0].split("response_body_preview=")[1].split()[0] + assert len(preview) == 256 + + @pytest.mark.req("OBS-37") + async def test_response_capture_skipped_for_unknown_length( + self, caplog: LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = b"streamed-unknown-length-body" + client = _StubAsyncClient(body_factory=lambda: _UnknownLengthResponseBody(payload)) + async with AsyncPipeline( + client, policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY)] + ) as p: + response = await p.run(_request(), DispatchContext(_instr("0" * 16 + "b"))) + # The body is passed through untouched; the caller drains it in full. + assert await response.body.bytes() == payload # type: ignore[union-attr] + responses = [m for m in _messages(caplog) if "http.response" in m] + assert responses + # No preview for a body whose size was not declared. + assert "response_body_preview=" not in responses[0] + + @pytest.mark.req("OBS-17") + async def test_url_valued_response_header_is_redacted(self, caplog: LogCaptureFixture) -> None: + # Parity with the sync policy via the shared HeaderRedactor: a Location + # secret is redacted and a credential header value is never logged. + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + headers = Headers.outbound( + [ + ("Location", "https://idp.example.com/cb?code=SEKRET"), + ("Authorization", "Bearer super-secret-token"), + ] + ) + client = _StubAsyncClient(headers=headers) + async with AsyncPipeline( + client, policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)] + ) as p: + await p.run(_request(), DispatchContext(_instr("0" * 15 + "d2"))) + response_event = next(m for m in _messages(caplog) if m.split()[0] == "http.response") + assert "SEKRET" not in response_event + assert "super-secret-token" not in response_event + assert "http.response.header.location=" in response_event + assert "http.response.header.authorization=REDACTED" in response_event + + @pytest.mark.req("OBS-38") + async def test_binary_response_body_preview_never_throws( + self, caplog: LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = bytes(range(256)) # invalid UTF-8, known length + client = _StubAsyncClient(body_factory=lambda: AsyncResponseBody.from_bytes(payload)) + async with AsyncPipeline( + client, policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY)] + ) as p: + response = await p.run(_request(), DispatchContext(_instr("0" * 16 + "c"))) + assert await response.body.bytes() == payload # type: ignore[union-attr] + assert any("http.response" in m for m in _messages(caplog)) + + +class _RecordingHttpTracer(HttpTracer): + def __init__(self) -> None: + self.events: list[str] = [] + + def operation_started(self) -> None: + self.events.append("operation_started") + + def operation_succeeded(self) -> None: + self.events.append("operation_succeeded") + + def operation_failed(self, error: BaseException) -> None: + self.events.append("operation_failed") + + def request_sent(self, byte_count: int | None) -> None: + self.events.append("request_sent") + + def response_headers_received(self, status: int, headers: Mapping[str, str]) -> None: + self.events.append("response_headers_received") + + def response_received(self, byte_count: int) -> None: + self.events.append("response_received") + + +class _RecordingTracerFactory: + def __init__(self, tracer: HttpTracer) -> None: + self._tracer = tracer + + def create(self) -> HttpTracer: + return self._tracer + + +class _RecordingSpan: + def __init__(self) -> None: + self.attributes: dict[str, Any] = {} + self.error_type: str | None = None + self.ended = False + self.end_error: BaseException | None = None + + @property + def is_recording(self) -> bool: + return True + + @property + def context(self) -> InstrumentationContext: + return _instr("0" * 16 + "f") + + def set_attribute(self, key: str, value: Any) -> _RecordingSpan: + self.attributes[key] = value + return self + + def set_error(self, error_type: str) -> _RecordingSpan: + self.error_type = error_type + return self + + def make_current(self) -> _RecordingScope: + return _RecordingScope() + + def end(self, error: BaseException | None = None) -> None: + self.ended = True + self.end_error = error + + +class _RecordingScope: + def __enter__(self) -> _RecordingScope: + return self + + def __exit__(self, *exc_info: object) -> None: + pass + + +class _RecordingTracer(Tracer): + def __init__(self) -> None: + self.spans: list[_RecordingSpan] = [] + + def start_span(self, name: str, parent: InstrumentationContext | None = None) -> Any: + del name, parent + span = _RecordingSpan() + self.spans.append(span) + return span + + +class TestAsyncTracingPolicy: + async def test_records_attributes_on_success(self) -> None: + tracer = _RecordingTracer() + async with AsyncPipeline( + _StubAsyncClient(), policies=[AsyncTracingPolicy(tracer=tracer)] + ) as p: + await p.run(_request(), DispatchContext(_instr("0" * 16 + "4"))) + span = tracer.spans[0] + assert span.attributes["http.request.method"] == "GET" + assert span.attributes["http.response.status_code"] == 200 + assert span.attributes["server.address"] == "api.example.com" + assert span.ended + + async def test_records_error_on_exception(self) -> None: + tracer = _RecordingTracer() + boom = ServiceRequestError("nope") + async with AsyncPipeline( + _StubAsyncClient(raise_exc=boom), policies=[AsyncTracingPolicy(tracer=tracer)] + ) as p: + with pytest.raises(ServiceRequestError): + await p.run(_request(), DispatchContext(_instr("0" * 16 + "5"))) + span = tracer.spans[0] + assert span.error_type == "ServiceRequestError" + assert span.end_error is boom + + async def test_records_resend_count_with_retry(self) -> None: + from .test_async_pipeline import _AsyncFakeClock + + tracer = _RecordingTracer() + + class _ScriptedClient(AsyncHttpClient): + def __init__(self) -> None: + self.statuses = iter([Status.SERVICE_UNAVAILABLE, Status.OK]) + + async def execute(self, request: Request) -> AsyncResponse: + return AsyncResponse( + request=request, protocol=Protocol.HTTP_1_1, status=next(self.statuses) + ) + + retry = AsyncRetryPolicy(status_retries=2, backoff_factor=0, clock=_AsyncFakeClock()) + async with AsyncPipeline( + _ScriptedClient(), policies=[AsyncTracingPolicy(tracer=tracer), retry] + ) as p: + await p.run(_request(), DispatchContext(_instr("0" * 16 + "6"))) + span = tracer.spans[0] + assert span.attributes.get("http.request.resend_count", 0) >= 1 + + async def test_opt_out_via_options(self) -> None: + tracer = _RecordingTracer() + async with AsyncPipeline( + _StubAsyncClient(), policies=[AsyncTracingPolicy(tracer=tracer)] + ) as p: + await p.run( + _request(), + DispatchContext(_instr("0" * 16 + "7")), + tracing_enabled=False, + ) + assert tracer.spans == [] + + +class TestLevelIndependence: + @pytest.mark.req("OBS-34") + async def test_span_and_tracer_events_fire_when_logging_disabled( + self, caplog: LogCaptureFixture + ) -> None: + # Span emission and HttpTracer (metrics-driving) callbacks are owned by + # the tracing policy, not gated by the logging policy's enabled state: + # disabling logging must not silence tracing or metrics. + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + http_tracer = _RecordingHttpTracer() + span_tracer = _RecordingTracer() + instr = _instr("0" * 16 + "e", tracer_factory=_RecordingTracerFactory(http_tracer)) + async with AsyncPipeline( + _StubAsyncClient(), + policies=[ + AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY), + AsyncTracingPolicy(tracer=span_tracer), + ], + ) as p: + await p.run(_request(), DispatchContext(instr), logging_enabled=False) + # No wire logs (logging disabled) ... + assert not any("http.request" in m or "http.response" in m for m in _messages(caplog)) + # ... yet the span opened and closed, and the per-request tracer events fired. + assert span_tracer.spans and span_tracer.spans[0].ended + assert "request_sent" in http_tracer.events + assert "response_received" in http_tracer.events + + @pytest.mark.req("OBS-34") + async def test_default_detail_level_is_none(self, caplog: LogCaptureFixture) -> None: + # Security default: a policy built with no explicit ``detail_level`` + # defaults to NONE, so merely raising the logger to INFO does not leak + # request/response events (or their body previews) without an explicit + # opt-in. An explicit BODY level still emits both events. + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + async with AsyncPipeline(_StubAsyncClient(), policies=[AsyncLoggingPolicy()]) as p: + await p.run(_request(), DispatchContext(_instr("0" * 15 + "e4"))) + assert not any("http.request" in m or "http.response" in m for m in _messages(caplog)) + caplog.clear() + async with AsyncPipeline( + _StubAsyncClient(), + policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.BODY)], + ) as p: + await p.run(_request(), DispatchContext(_instr("0" * 15 + "e5"))) + messages = _messages(caplog) + assert any("http.request" in m for m in messages) + assert any("http.response" in m for m in messages) + + +def _event_names(caplog: LogCaptureFixture) -> set[str]: + return {m.split()[0] for m in _messages(caplog) if m.split()[0].startswith("http.")} + + +class TestEventNameParity: + async def test_async_and_sync_emit_the_same_event_names( + self, caplog: LogCaptureFixture + ) -> None: + # For an identical GET-then-200 scenario, both pillars must emit the same + # wire-log event names so a log consumer sees one naming across sync/async. + from dexpace.sdk.core.client.http_client import HttpClient + from dexpace.sdk.core.http.response import Response + from dexpace.sdk.core.pipeline import Pipeline + from dexpace.sdk.core.pipeline.policies import LoggingPolicy + + class _SyncOkClient(HttpClient): + def execute(self, request: Request) -> Response: + return Response(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK) + + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + with Pipeline( + _SyncOkClient(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)] + ) as sp: + sp.run(_request(), DispatchContext(_instr("0" * 15 + "11"))) + sync_events = _event_names(caplog) + caplog.clear() + async with AsyncPipeline( + _StubAsyncClient(), + policies=[AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)], + ) as ap: + await ap.run(_request(), DispatchContext(_instr("0" * 15 + "12"))) + async_events = _event_names(caplog) + assert async_events == sync_events == {"http.request", "http.response"} + + +class TestPerAttemptLogging: + async def test_wire_logs_are_emitted_per_attempt_inside_retry( + self, caplog: LogCaptureFixture + ) -> None: + # Logging sits inside the retry wrapper in the default async stack, so a + # request that retries once produces two request/response log pairs. + from dexpace.sdk.core.pipeline.defaults import default_async_pipeline + + from .test_async_pipeline import _AsyncFakeClock + + class _ScriptedClient(AsyncHttpClient): + def __init__(self) -> None: + self.statuses = iter([Status.SERVICE_UNAVAILABLE, Status.OK]) + + async def execute(self, request: Request) -> AsyncResponse: + return AsyncResponse( + request=request, protocol=Protocol.HTTP_1_1, status=next(self.statuses) + ) + + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + retry = AsyncRetryPolicy(backoff_factor=0, clock=_AsyncFakeClock()) + pipeline = default_async_pipeline( + _ScriptedClient(), + retry=retry, + logging=AsyncLoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS), + ).build() + async with pipeline as p: + await p.run(_request(), DispatchContext(_instr("0" * 15 + "13"))) + messages = _messages(caplog) + requests = [m for m in messages if m.split()[0] == "http.request"] + responses = [m for m in messages if m.split()[0] == "http.response"] + assert len(requests) == 2 # one per attempt, not one per call + assert len(responses) == 2 diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_pipeline.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_pipeline.py index d35c4ff..c177577 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_async_pipeline.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_pipeline.py @@ -318,6 +318,7 @@ def to_replayable(self) -> RequestBody: assert body.replay_thread_id != loop_thread_id +@pytest.mark.req("PIPE-29") def test_invalid_step_raises_type_error() -> None: with pytest.raises(TypeError): AsyncPipeline(_StubAsyncClient(), policies=["not callable"]) # type: ignore[list-item] diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_redirect.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_redirect.py index 8c85ee5..9aeff7a 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_async_redirect.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_redirect.py @@ -274,14 +274,16 @@ async def test_loop_detection_returns_current_response(self) -> None: class TestSecurity: - async def test_authorization_kept_on_same_origin_redirect(self) -> None: - # Same-origin hop keeps the caller-set Authorization header. + @pytest.mark.req("REDIR-7", "XCUT-17") + async def test_authorization_stripped_on_same_origin_redirect(self) -> None: + # Authorization is stripped before EVERY reissue — even same-origin. + # Re-stamping a credential for a known origin is the auth layer's job. client = _ScriptedAsyncClient( [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], ) policy = AsyncRedirectPolicy(strip_authorization=True) await _run(client, policy, _request(auth="Bearer secret")) - assert client.requests[1].headers.get("Authorization") == "Bearer secret" + assert "Authorization" not in client.requests[1].headers async def test_authorization_stripped_on_cross_origin_redirect(self) -> None: # A different host is a cross-origin reissue; the header is dropped. diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_redirect_battery.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_redirect_battery.py new file mode 100644 index 0000000..181854f --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_redirect_battery.py @@ -0,0 +1,458 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Full redirect security battery run against ``AsyncRedirectPolicy``. + +This is the battery referenced by the async-redirect gate decision (see +``docs/deviations.md``). It exercises every dimension the async twin must +satisfy before it may be wired into ``default_async_pipeline()`` by default: + +- loop / max-hops / malformed-``Location`` handling — the current response is + handed back unfollowed and *open* (never closed) so the caller can still read + or close it; +- superseded (intermediate) responses closed before each hop, abandon paths + leave the final response open; +- ``303`` rebuild strips the body and every ``Content-*`` header; +- cross-origin credential stripping; +- ``Location`` resolution; +- HTTPS→HTTP downgrade handling. + +Every dimension is now asserted directly. The credential-hygiene dimensions once +committed as ``xfail(strict=True)`` specs — HTTPS→HTTP downgrade denial, +cross-origin ``Cookie`` / ``Proxy-Authorization`` stripping, seed-origin +cross-origin judgment — are certified on the shared core the twin delegates to, +so they pass in lock-step with the sync battery. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence + +import pytest + +from dexpace.sdk.core.client.async_http_client import AsyncHttpClient +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.common.url import _origin +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.request.request_body import RequestBody +from dexpace.sdk.core.http.response import AsyncResponse, AsyncResponseBody, Status +from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN +from dexpace.sdk.core.pipeline import AsyncPipeline +from dexpace.sdk.core.pipeline.policies.async_redirect import AsyncRedirectPolicy +from dexpace.sdk.core.pipeline.policies.redirect import RedirectCondition, RedirectPolicy + + +def _instr(trace: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + + +def _request( + method: Method = Method.GET, + url: str = "https://example.com/start", + body: RequestBody | None = None, + headers: dict[str, str] | None = None, +) -> Request: + req = Request(method=method, url=Url.parse(url), body=body) + for name, value in (headers or {}).items(): + req = req.with_header(name, value) + return req + + +class _TrackingAsyncBody(AsyncResponseBody): + """Async response body that records whether ``close`` was called.""" + + __slots__ = ("closed",) + + def __init__(self) -> None: + self.closed = False + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return 0 + + async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + await self.close() + return + yield b"" # pragma: no cover - makes this an async generator + + async def close(self) -> None: + self.closed = True + + +class _Hop: + """One scripted transport response: status, Location, extra headers, body.""" + + __slots__ = ("body", "extra_headers", "location", "status") + + def __init__( + self, + status: Status, + location: str | None = None, + extra_headers: tuple[tuple[str, str], ...] = (), + body: AsyncResponseBody | None = None, + ) -> None: + self.status = status + self.location = location + self.extra_headers = extra_headers + self.body = body + + +class _ScriptedAsyncClient(AsyncHttpClient): + """Returns one scripted async response per call; records each request.""" + + def __init__(self, hops: Sequence[_Hop]) -> None: + self._hops = list(hops) + self.requests: list[Request] = [] + + async def execute(self, request: Request) -> AsyncResponse: + idx = len(self.requests) + self.requests.append(request) + hop = self._hops[idx] + response = AsyncResponse( + request=request, + protocol=Protocol.HTTP_1_1, + status=hop.status, + body=hop.body, + ) + if hop.location is not None: + response = response.with_header("Location", hop.location) + for name, value in hop.extra_headers: + response = response.with_header(name, value) + return response + + +async def _run( + client: _ScriptedAsyncClient, + policy: AsyncRedirectPolicy, + request: Request, +) -> AsyncResponse: + async with AsyncPipeline(client, policies=[policy]) as p: + return await p.run(request, DispatchContext(_instr("0" * 15 + "3"))) + + +# --------------------------------------------------------------------------- # +# Resource lifecycle: superseded responses closed; abandon paths leave open. # +# --------------------------------------------------------------------------- # + + +class TestResourceLifecycle: + async def test_superseded_responses_closed_before_each_hop(self) -> None: + # Two intermediate 301 responses precede the terminal 200. Each + # intermediate body must be closed as the policy moves off it; the + # terminal response is handed back open for the caller to consume. + b0 = _TrackingAsyncBody() + b1 = _TrackingAsyncBody() + final = _TrackingAsyncBody() + client = _ScriptedAsyncClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/a", body=b0), + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/b", body=b1), + _Hop(Status.OK, body=final), + ], + ) + response = await _run(client, AsyncRedirectPolicy(), _request()) + assert b0.closed + assert b1.closed + assert not final.closed + assert response.status is Status.OK + + async def test_loop_abandon_leaves_final_response_open(self) -> None: + final = _TrackingAsyncBody() + client = _ScriptedAsyncClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/b"), + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/a", body=final), + ], + ) + response = await _run(client, AsyncRedirectPolicy(), _request(url="https://example.com/a")) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + + async def test_max_hops_abandon_leaves_final_response_open(self) -> None: + final = _TrackingAsyncBody() + client = _ScriptedAsyncClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/a"), + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/b", body=final), + _Hop(Status.OK), + ], + ) + response = await _run(client, AsyncRedirectPolicy(max_hops=1), _request()) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + assert len(client.requests) == 2 + + async def test_missing_location_abandon_leaves_final_response_open(self) -> None: + final = _TrackingAsyncBody() + client = _ScriptedAsyncClient([_Hop(Status.MOVED_PERMANENTLY, None, body=final)]) + response = await _run(client, AsyncRedirectPolicy(), _request()) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + + async def test_method_not_allowed_abandon_leaves_final_response_open(self) -> None: + final = _TrackingAsyncBody() + client = _ScriptedAsyncClient( + [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new", body=final)], + ) + # POST is not in the default allowed_methods, so a 301 is not followed. + response = await _run(client, AsyncRedirectPolicy(), _request(method=Method.POST)) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + + async def test_follow_303_false_abandon_leaves_final_response_open(self) -> None: + final = _TrackingAsyncBody() + client = _ScriptedAsyncClient( + [_Hop(Status.SEE_OTHER, "https://example.com/new", body=final)], + ) + response = await _run(client, AsyncRedirectPolicy(follow_303=False), _request()) + assert response.status is Status.SEE_OTHER + assert not final.closed + + async def test_malformed_location_returns_response_open_not_closed(self) -> None: + # A malformed Location cannot resolve to a target. The policy must hand + # the current 3xx response back unfollowed and OPEN — not raise and leak + # the connection, and not close the body it is returning. + final = _TrackingAsyncBody() + client = _ScriptedAsyncClient([_Hop(Status.FOUND, "http://[malformed", body=final)]) + response = await _run(client, AsyncRedirectPolicy(), _request()) + assert response.status is Status.FOUND + assert not final.closed + assert len(client.requests) == 1 + + async def test_non_replayable_body_closes_intermediate_then_raises(self) -> None: + # A body-preserving reissue with a single-use body cannot be replayed. + # The in-hand 3xx response is closed before the RuntimeError escapes so + # the connection is not leaked (an error abandon, not a graceful one). + intermediate = _TrackingAsyncBody() + request = _request(method=Method.POST, body=RequestBody.from_iter(iter([b"chunk"]))) + client = _ScriptedAsyncClient( + [_Hop(Status.TEMPORARY_REDIRECT, "https://example.com/new", body=intermediate)], + ) + policy = AsyncRedirectPolicy( + allowed_methods=frozenset({Method.GET, Method.HEAD, Method.POST}), + ) + with pytest.raises(RuntimeError): + await _run(client, policy, request) + assert intermediate.closed + + +# --------------------------------------------------------------------------- # +# Battery dimensions the async twin already satisfies. # +# --------------------------------------------------------------------------- # + + +class TestBatterySatisfied: + async def test_303_rebuild_strips_body_and_content_headers(self) -> None: + request = _request( + method=Method.POST, + body=RequestBody.from_bytes(b"payload"), + headers={ + "Content-Type": "application/json", + "Content-Length": "7", + "Content-Encoding": "gzip", + }, + ) + client = _ScriptedAsyncClient( + [_Hop(Status.SEE_OTHER, "https://example.com/new"), _Hop(Status.OK)], + ) + await _run(client, AsyncRedirectPolicy(), request) + reissued = client.requests[1] + assert reissued.method is Method.GET + assert reissued.body is None + assert "Content-Type" not in reissued.headers + assert "Content-Length" not in reissued.headers + assert "Content-Encoding" not in reissued.headers + + async def test_relative_location_resolved_against_base(self) -> None: + client = _ScriptedAsyncClient( + [_Hop(Status.MOVED_PERMANENTLY, "/elsewhere"), _Hop(Status.OK)], + ) + await _run(client, AsyncRedirectPolicy(), _request(url="https://example.com/start")) + assert str(client.requests[1].url) == "https://example.com/elsewhere" + + async def test_userinfo_in_location_dropped(self) -> None: + client = _ScriptedAsyncClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://attacker:pw@example.com/new"), + _Hop(Status.OK), + ], + ) + await _run(client, AsyncRedirectPolicy(), _request()) + assert client.requests[1].url.userinfo is None + + async def test_authorization_stripped_on_cross_origin(self) -> None: + client = _ScriptedAsyncClient( + [_Hop(Status.FOUND, "https://other.org/new"), _Hop(Status.OK)], + ) + await _run( + client, AsyncRedirectPolicy(), _request(headers={"Authorization": "Bearer secret"}) + ) + assert "Authorization" not in client.requests[1].headers + + @pytest.mark.req("REDIR-7", "XCUT-17") + async def test_authorization_stripped_on_same_origin(self) -> None: + # Authorization is stripped before EVERY reissue — even same-origin. + # Re-stamping a credential for a known origin is the auth layer's job. + client = _ScriptedAsyncClient( + [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], + ) + await _run( + client, AsyncRedirectPolicy(), _request(headers={"Authorization": "Bearer secret"}) + ) + assert "Authorization" not in client.requests[1].headers + + +# --------------------------------------------------------------------------- # +# Shared resolution logic — no duplicated per-hop construction. # +# --------------------------------------------------------------------------- # + + +class TestSharedResolutionLogic: + def test_config_is_sync_policy_carrying_the_knobs(self) -> None: + policy = AsyncRedirectPolicy( + max_hops=3, + follow_303=False, + allowed_methods=frozenset({Method.GET}), + strip_authorization=False, + ) + cfg = policy.config + assert isinstance(cfg, RedirectPolicy) + assert cfg.max_hops == 3 + assert cfg.follow_303 is False + assert cfg.allowed_methods == frozenset({Method.GET}) + assert cfg.strip_authorization is False + + async def test_reissue_matches_wrapped_sync_policy(self) -> None: + # The request the async pipeline actually sends on a hop is byte-for-byte + # what the wrapped sync RedirectPolicy computes for the same inputs — + # proof the twin delegates rather than restating the resolution logic. + policy = AsyncRedirectPolicy(allowed_methods=frozenset({Method.GET, Method.POST})) + client = _ScriptedAsyncClient( + [_Hop(Status.FOUND, "/next?a=b%20c"), _Hop(Status.OK)], + ) + seed = _request(url="https://example.com/start") + await _run(client, policy, seed) + reissued = client.requests[1] + direct = policy.config._build_next_request(seed, 302, "/next?a=b%20c", _origin(seed.url)) + assert direct is not None + assert str(reissued.url) == str(direct.url) + assert reissued.method is direct.method + + +# --------------------------------------------------------------------------- # +# Credential-hygiene battery dimensions — now certified on the shared core. # +# # +# These once encoded the async gate's reasons as ``xfail(strict=True)`` specs. # +# The shared resolution core the twin delegates to now provides them, so they # +# assert directly and pass in lock-step with the sync battery. # +# --------------------------------------------------------------------------- # + + +class TestCredentialHygieneBattery: + @pytest.mark.req("REDIR-15", "XCUT-17") + async def test_https_to_http_downgrade_default_denied(self) -> None: + # A scheme downgrade is not followed by default: the current response is + # handed back unfollowed so the TLS guarantee cannot silently vanish. + client = _ScriptedAsyncClient( + [_Hop(Status.FOUND, "http://example.com/new"), _Hop(Status.OK)], + ) + await _run(client, AsyncRedirectPolicy(), _request(url="https://example.com/start")) + assert len(client.requests) == 1 # not followed + + @pytest.mark.req("REDIR-15", "XCUT-17") + async def test_https_to_http_downgrade_followed_when_opted_in(self) -> None: + client = _ScriptedAsyncClient( + [_Hop(Status.FOUND, "http://example.com/new"), _Hop(Status.OK)], + ) + await _run( + client, + AsyncRedirectPolicy(allow_scheme_downgrade=True), + _request(url="https://example.com/start"), + ) + assert len(client.requests) == 2 # followed + assert str(client.requests[1].url) == "http://example.com/new" + + @pytest.mark.req("REDIR-9", "XCUT-17") + async def test_cross_origin_strips_cookie(self) -> None: + client = _ScriptedAsyncClient( + [_Hop(Status.FOUND, "https://other.org/new"), _Hop(Status.OK)], + ) + await _run(client, AsyncRedirectPolicy(), _request(headers={"Cookie": "session=abc"})) + assert "Cookie" not in client.requests[1].headers + + @pytest.mark.req("REDIR-9", "XCUT-17") + async def test_cross_origin_strips_proxy_authorization(self) -> None: + client = _ScriptedAsyncClient( + [_Hop(Status.FOUND, "https://other.org/new"), _Hop(Status.OK)], + ) + await _run( + client, + AsyncRedirectPolicy(), + _request(headers={"Proxy-Authorization": "Basic xyz"}), + ) + assert "Proxy-Authorization" not in client.requests[1].headers + + @pytest.mark.req("REDIR-8", "XCUT-17") + async def test_cross_origin_judged_against_seed_not_previous_hop(self) -> None: + # a.example.com -> b.example.com -> b.example.com: the last hop is + # same-origin vs the previous hop but cross-origin vs the seed, so the + # origin-scoped Cookie stays stripped for the whole chain. + client = _ScriptedAsyncClient( + [ + _Hop(Status.FOUND, "https://b.example.com/step"), + _Hop(Status.FOUND, "https://b.example.com/again"), + _Hop(Status.OK), + ], + ) + await _run( + client, + AsyncRedirectPolicy(), + _request(url="https://a.example.com/start", headers={"Cookie": "session=abc"}), + ) + assert "Cookie" not in client.requests[1].headers + assert "Cookie" not in client.requests[2].headers + + @pytest.mark.req("REDIR-20") + async def test_custom_predicate_overrides_follow_decision(self) -> None: + conditions: list[RedirectCondition] = [] + + def never(condition: RedirectCondition) -> bool: + conditions.append(condition) + return False + + client = _ScriptedAsyncClient( + [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], + ) + response = await _run(client, AsyncRedirectPolicy(should_redirect=never), _request()) + assert response.status is Status.MOVED_PERMANENTLY + assert len(client.requests) == 1 + assert conditions[0].redirect_count == 0 + assert conditions[0].visited_uris == ("https://example.com/start",) + + async def test_location_resolution_is_wire_exact(self) -> None: + # A %20 in the target query must survive verbatim. Fixed by the pure + # RFC 3986 query codec (PY-M2-02), which closed this battery gap — + # no longer xfail; see docs/deviations.md. + client = _ScriptedAsyncClient( + [_Hop(Status.FOUND, "https://example.com/p?x=%20y"), _Hop(Status.OK)], + ) + await _run(client, AsyncRedirectPolicy(), _request()) + assert "%20" in str(client.requests[1].url) diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_retry_cancel.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_retry_cancel.py index d4759d7..80e43d7 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_async_retry_cancel.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_retry_cancel.py @@ -96,6 +96,7 @@ async def test_cancelled_error_propagates_immediately(self) -> None: # Exactly one attempt — no retry of a cancellation. assert client.attempts == 1 + @pytest.mark.req("RETRY-23", "RETRY-32", "XCUT-1") async def test_cancelled_after_a_retryable_failure_still_not_retried(self) -> None: # First attempt is a retryable 503, second attempt is cancelled. client = _CancellingClient(cancel_on=2) @@ -106,6 +107,7 @@ async def test_cancelled_after_a_retryable_failure_still_not_retried(self) -> No # Two attempts total: the 503 was retried, the cancellation was not. assert client.attempts == 2 + @pytest.mark.req("RECOV-27", "RETRY-32") async def test_task_cancellation_propagates_through_policy(self) -> None: started = asyncio.Event() diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_retry_status_set.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_retry_status_set.py new file mode 100644 index 0000000..f1af658 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_retry_status_set.py @@ -0,0 +1,140 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""End-to-end retryable-status-set wiring for ``AsyncRetryPolicy``. + +The async twin reuses the sync ``RetryPolicy`` for configuration and the +per-attempt classification, so the configurable ``retry_on_status_codes`` set +must reach the async dispatch loop unchanged. These tests drive the real +``AsyncPipeline`` and assert the retry *behaviour* (attempt counts), not just +the stored set, so a regression that dropped the set on the async path would +be caught here — mirroring ``test_retry.TestCustomRetryStatusSet`` for sync. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import pytest + +from dexpace.sdk.core.client.async_http_client import AsyncHttpClient +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, Status +from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN +from dexpace.sdk.core.pipeline import AsyncPipeline +from dexpace.sdk.core.pipeline._pure.classifier import DEFAULT_RETRYABLE_STATUS +from dexpace.sdk.core.pipeline.policies.async_retry import AsyncRetryPolicy + + +class _AsyncFakeClock: + """Deterministic ``AsyncClock`` for tests; advances time on sleep.""" + + __slots__ = ("_t",) + + def __init__(self, start: float = 0.0) -> None: + self._t = start + + def now(self) -> float: + return self._t + + def monotonic(self) -> float: + return self._t + + async def sleep(self, duration: float) -> None: + self._t += max(0.0, duration) + + +def _instr(trace: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + + +def _get() -> Request: + return Request(method=Method.GET, url=Url.parse("https://example.com/")) + + +class _ScriptedAsyncClient(AsyncHttpClient): + """Returns one status per call, in order.""" + + def __init__(self, outcomes: Sequence[Status]) -> None: + self._outcomes = list(outcomes) + self.attempts = 0 + + async def execute(self, request: Request) -> AsyncResponse: + outcome = self._outcomes[self.attempts] + self.attempts += 1 + return AsyncResponse(request=request, protocol=Protocol.HTTP_1_1, status=outcome) + + +class TestAsyncCustomRetryStatusSet: + def test_default_set_is_exactly_the_curated_codes(self) -> None: + # The async policy defers configuration to the inner ``RetryPolicy``, so + # its default set is the shared classifier default verbatim — the + # permanent 501/505 stay excluded. + retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) + assert retry.config.retry_on_status_codes == DEFAULT_RETRYABLE_STATUS + assert retry.config.retry_on_status_codes == frozenset({408, 429, 500, 502, 503, 504}) + assert 501 not in retry.config.retry_on_status_codes + assert 505 not in retry.config.retry_on_status_codes + + async def test_custom_set_honors_explicitly_included_501(self) -> None: + # A caller-supplied set containing the normally-permanent 501 is honored + # on the async path: the GET is retried and eventually succeeds. + client = _ScriptedAsyncClient([Status.NOT_IMPLEMENTED, Status.OK]) + retry = AsyncRetryPolicy(retry_on_status_codes={501}, clock=_AsyncFakeClock()) + async with AsyncPipeline(client, policies=[retry]) as p: + response = await p.run(_get(), DispatchContext(_instr("0" * 15 + "60"))) + assert client.attempts == 2 + assert response.status is Status.OK + + async def test_custom_set_excludes_a_default_code(self) -> None: + # Narrowing the async set to ``{500}`` drops 503, so a 503 the default + # set would retry is returned on the first attempt instead. + client = _ScriptedAsyncClient([Status.SERVICE_UNAVAILABLE]) + retry = AsyncRetryPolicy(retry_on_status_codes={500}, clock=_AsyncFakeClock()) + async with AsyncPipeline(client, policies=[retry]) as p: + response = await p.run(_get(), DispatchContext(_instr("0" * 15 + "61"))) + assert client.attempts == 1 + assert response.status is Status.SERVICE_UNAVAILABLE + + +class TestAsyncRetryConfigValidation: + """Scalar configuration is validated at construction on the async twin too. + + ``AsyncRetryPolicy`` delegates configuration to the inner ``RetryPolicy``, + so the same construction-time rejection of invalid scalars applies (RECOV-34). + """ + + @pytest.mark.req("RECOV-34") + @pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"total_retries": -1}, "total_retries"), + ({"connect_retries": -1}, "connect_retries"), + ({"backoff_factor": -0.1}, "backoff_factor"), + ({"timeout": -1.0}, "timeout"), + ({"backoff_max": float("inf")}, "backoff_max"), + ({"jitter": 1.5}, "jitter"), + ({"jitter": -0.1}, "jitter"), + ], + ) + def test_rejects_invalid_scalar(self, kwargs: dict[str, Any], match: str) -> None: + with pytest.raises(ValueError, match=match): + AsyncRetryPolicy(**kwargs) diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_set_date.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_set_date.py index 2c1724d..752053c 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_async_set_date.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_set_date.py @@ -23,6 +23,7 @@ ) from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN from dexpace.sdk.core.pipeline import AsyncPipeline +from dexpace.sdk.core.pipeline.policies.async_retry import AsyncRetryPolicy from dexpace.sdk.core.pipeline.policies.async_set_date import AsyncSetDatePolicy _RFC7231 = re.compile(r"^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$") @@ -125,3 +126,39 @@ async def test_restamps_on_each_attempt() -> None: assert first != second assert _RFC7231.match(first) is not None assert _RFC7231.match(second) is not None + + +class _AdvancingAsyncClient(AsyncHttpClient): + """Records requests and advances the injected clock after each send.""" + + def __init__(self, clock: _AsyncFakeClock, *fail_with: Status) -> None: + self.calls: list[Request] = [] + self._clock = clock + self._fail = list(fail_with) + + async def execute(self, request: Request) -> AsyncResponse: + self.calls.append(request) + status = self._fail.pop(0) if self._fail else Status.OK + self._clock.advance(3600) + return AsyncResponse(request=request, protocol=Protocol.HTTP_1_1, status=status) + + +async def test_date_refreshed_on_each_retry_attempt() -> None: + """One retried async call re-stamps a fresh ``Date`` on each attempt. + + The async policy sits inside the retry wrapper (`Stage.POST_RETRY`), so a + retried ``503`` re-enters it and reads the clock again — mirroring the + sync twin. + """ + clock = _AsyncFakeClock(start=1_700_000_000.0) + client = _AdvancingAsyncClient(clock, Status.SERVICE_UNAVAILABLE) + policies = [AsyncRetryPolicy(backoff_factor=0), AsyncSetDatePolicy(clock=clock)] + async with AsyncPipeline(client, policies=policies) as p: + await p.run(_request(), DispatchContext(_instr("0" * 16 + "6"))) + assert len(client.calls) == 2 + first = client.calls[0].headers.get("Date") + second = client.calls[1].headers.get("Date") + assert first is not None and second is not None + assert first != second + assert _RFC7231.match(first) is not None + assert _RFC7231.match(second) is not None diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_async_staged_builder.py b/packages/dexpace-sdk-core/tests/pipeline/test_async_staged_builder.py index 4a241d6..41b4a35 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_async_staged_builder.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_async_staged_builder.py @@ -51,6 +51,19 @@ async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: return await self.next.send(request, ctx) +class _AsyncSecondMarkerPolicy(AsyncPolicy): + """Non-pillar policy at POST_AUTH for batch/stacking tests.""" + + STAGE = Stage.POST_AUTH + __slots__ = ("tag",) + + def __init__(self, tag: str) -> None: + self.tag = tag + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + return await self.next.send(request, ctx) + + @pytest.fixture def async_transport() -> _AsyncStubTransport: return _AsyncStubTransport() @@ -63,6 +76,7 @@ def test_build_orders_by_stage(async_transport: _AsyncStubTransport) -> None: assert isinstance(pipeline, AsyncPipeline) +@pytest.mark.req("PIPE-28") def test_pillar_double_append_raises(async_transport: _AsyncStubTransport) -> None: b = AsyncStagedPipelineBuilder(async_transport) b.append(AsyncRetryPolicy()) @@ -79,6 +93,36 @@ def test_pillar_force_overwrites(async_transport: _AsyncStubTransport) -> None: assert b._pillars[Stage.RETRY] is second +@pytest.mark.req("PIPE-6") +def test_reinstall_same_pillar_instance_is_idempotent( + async_transport: _AsyncStubTransport, +) -> None: + b = AsyncStagedPipelineBuilder(async_transport) + retry = AsyncRetryPolicy() + b.append(retry) + # Re-adding the same instance is a no-op; a distinct one still raises. + b.append(retry) + b.prepend(retry) + assert b._pillars[Stage.RETRY] is retry + assert sum(1 for p in b._flatten() if isinstance(p, AsyncRetryPolicy)) == 1 + with pytest.raises(ValueError, match="already filled"): + b.append(AsyncRetryPolicy()) + + +@pytest.mark.req("PIPE-38") +def test_append_all_preserves_prepend_all_reverses( + async_transport: _AsyncStubTransport, +) -> None: + appended = AsyncStagedPipelineBuilder(async_transport) + appended.append_all([_AsyncMarkerPolicy(t) for t in ("a", "b", "c")]) + prepended = AsyncStagedPipelineBuilder(async_transport) + prepended.prepend_all([_AsyncMarkerPolicy(t) for t in ("a", "b", "c")]) + app = [p.tag for p in appended._flatten() if isinstance(p, _AsyncMarkerPolicy)] + pre = [p.tag for p in prepended._flatten() if isinstance(p, _AsyncMarkerPolicy)] + assert app == ["a", "b", "c"] + assert pre == ["c", "b", "a"] + + def test_replace_pillar(async_transport: _AsyncStubTransport) -> None: b = AsyncStagedPipelineBuilder(async_transport) first = AsyncRetryPolicy() @@ -122,3 +166,63 @@ def stamp(request: Request, ctx: CallContext) -> Request: original = AsyncPipeline(async_transport, policies=[stamp]) with pytest.raises(ValueError, match="SansIO"): AsyncStagedPipelineBuilder.from_pipeline(original) + + +def test_insert_second_pillar_via_splice_raises( + async_transport: _AsyncStubTransport, +) -> None: + b = AsyncStagedPipelineBuilder(async_transport) + original = AsyncRetryPolicy() + b.append(original).append(_AsyncMarkerPolicy("m")) + # ``insert_after`` re-buckets through ``_reload``; a second retry pillar + # must not silently clobber the incumbent. + with pytest.raises(ValueError, match="RETRY"): + b.insert_after(_AsyncMarkerPolicy, AsyncRetryPolicy()) + assert b._pillars[Stage.RETRY] is original + assert [p.tag for p in b._flatten() if isinstance(p, _AsyncMarkerPolicy)] == ["m"] + + +@pytest.mark.req("PIPE-28") +def test_replace_pillar_with_different_stage_rejected( + async_transport: _AsyncStubTransport, +) -> None: + b = AsyncStagedPipelineBuilder(async_transport) + retry = AsyncRetryPolicy() + b.append(retry) + with pytest.raises(ValueError, match="relocate"): + b.replace(AsyncRetryPolicy, _AsyncMarkerPolicy("x")) + assert b._pillars[Stage.RETRY] is retry + assert not any(isinstance(p, _AsyncMarkerPolicy) for p in b._flatten()) + + +def test_replace_cross_stage_conflict_is_atomic( + async_transport: _AsyncStubTransport, +) -> None: + b = AsyncStagedPipelineBuilder(async_transport) + b.append(AsyncRetryPolicy()).append(_AsyncMarkerPolicy("m")) + with pytest.raises(ValueError, match="RETRY"): + b.replace(_AsyncMarkerPolicy, AsyncRetryPolicy()) + assert [p.tag for p in b._flatten() if isinstance(p, _AsyncMarkerPolicy)] == ["m"] + + +def test_batch_rolls_back_on_failure(async_transport: _AsyncStubTransport) -> None: + b = AsyncStagedPipelineBuilder(async_transport) + original = AsyncRetryPolicy() + b.append(original) + with pytest.raises(ValueError, match="RETRY"), b.batch() as bb: + bb.append(_AsyncMarkerPolicy("m1")) + bb.append(_AsyncSecondMarkerPolicy("m2")) + bb.append(AsyncRetryPolicy()) # duplicate pillar → raises + flat = b._flatten() + assert b._pillars[Stage.RETRY] is original + assert not any(isinstance(p, _AsyncMarkerPolicy) for p in flat) + assert not any(isinstance(p, _AsyncSecondMarkerPolicy) for p in flat) + + +def test_batch_commits_on_success(async_transport: _AsyncStubTransport) -> None: + b = AsyncStagedPipelineBuilder(async_transport) + with b.batch() as bb: + bb.append(_AsyncMarkerPolicy("m1")).append(_AsyncSecondMarkerPolicy("m2")) + flat = b._flatten() + assert any(isinstance(p, _AsyncMarkerPolicy) for p in flat) + assert any(isinstance(p, _AsyncSecondMarkerPolicy) for p in flat) diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_client_identity_policy.py b/packages/dexpace-sdk-core/tests/pipeline/test_client_identity_policy.py index 71d7765..9758dde 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_client_identity_policy.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_client_identity_policy.py @@ -25,6 +25,7 @@ ) from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline +from dexpace.sdk.core.pipeline.policies import client_identity as _ci from dexpace.sdk.core.pipeline.policies.async_client_identity import AsyncClientIdentityPolicy from dexpace.sdk.core.pipeline.policies.client_identity import ( ClientIdentityPolicy, @@ -71,10 +72,12 @@ async def execute(self, request: Request) -> AsyncResponse: return AsyncResponse(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK) +@pytest.mark.req("NFR-15") def test_default_user_agent_shape() -> None: assert _DEFAULT_UA.match(default_user_agent()) is not None +@pytest.mark.req("NFR-15") def test_default_user_agent_never_blank() -> None: ua = default_user_agent() assert ua.strip() @@ -90,6 +93,7 @@ def test_stamps_default_user_agent() -> None: assert _DEFAULT_UA.match(ua) is not None +@pytest.mark.req("RECOV-33") def test_append_preserves_caller_value() -> None: client = _RecordingClient() policy = ClientIdentityPolicy(user_agent="my-token") @@ -98,6 +102,7 @@ def test_append_preserves_caller_value() -> None: assert client.calls[0].headers.get(_UA) == "caller/1.0 my-token" +@pytest.mark.req("RECOV-33") def test_replace_overwrites_caller_value() -> None: client = _RecordingClient() policy = ClientIdentityPolicy(user_agent="my-token", replace=True) @@ -114,6 +119,7 @@ def test_append_with_no_caller_value_uses_token_alone() -> None: assert client.calls[0].headers.get(_UA) == "my-token" +@pytest.mark.req("RECOV-33") def test_blank_caller_value_replaced_not_appended() -> None: client = _RecordingClient() policy = ClientIdentityPolicy(user_agent="my-token") @@ -128,6 +134,47 @@ def test_blank_token_rejected(bad: str) -> None: ClientIdentityPolicy(user_agent=bad) +@pytest.mark.req("NFR-15") +def test_sdk_version_falls_back_when_metadata_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing package distribution never yields a blank platform token. + + Running from a source tree without an editable install raises + ``PackageNotFoundError`` from ``importlib.metadata.version``; the policy + must swap in a non-blank fallback rather than emit ``dexpace-sdk/`` with an + empty version. + """ + from importlib.metadata import PackageNotFoundError + + def _raise(_name: str) -> str: + raise PackageNotFoundError + + monkeypatch.setattr(_ci, "version", _raise) + ua = default_user_agent() + assert ua.strip() + assert _DEFAULT_UA.match(ua) is not None + assert "dexpace-sdk/0.0.0" in ua + + +def test_token_order_is_service_then_platform_then_runtime() -> None: + """Append mode yields the ordered token list: service, platform, runtime. + + The caller's ``User-Agent`` is the service token and stays first; the + policy's own default (``dexpace-sdk/`` platform token followed by + ``python/`` runtime token) follows, space-separated. + """ + client = _RecordingClient() + with Pipeline(client, policies=[ClientIdentityPolicy()]) as p: + p.run(_request(user_agent="acme-service/2.3"), DispatchContext(_instr("0" * 15 + "10"))) + ua = client.calls[0].headers.get(_UA) + assert ua is not None + service, platform, runtime = ua.split(" ") + assert service == "acme-service/2.3" + assert platform.startswith("dexpace-sdk/") + assert runtime.startswith("python/") + + async def test_async_stamps_default_user_agent() -> None: client = _RecordingAsyncClient() async with AsyncPipeline(client, policies=[AsyncClientIdentityPolicy()]) as p: diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_context_eviction.py b/packages/dexpace-sdk-core/tests/pipeline/test_context_eviction.py index f8e27eb..792eda6 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_context_eviction.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_context_eviction.py @@ -4,8 +4,8 @@ """Pipeline must evict its ``ContextStore`` entry once a call completes. The promotion chain registers the call's context in the process-wide -``ContextStore`` keyed by trace id. Without eviction the store grows without -bound across calls with distinct trace ids. ``Pipeline.run`` / +``ContextStore`` under a process-unique per-call key. Without eviction the store +grows without bound across calls. ``Pipeline.run`` / ``AsyncPipeline.run`` evict the entry after the chain has fully unwound — i.e. after every in-chain observer has read the latest snapshot. """ @@ -85,7 +85,7 @@ def test_run_evicts_context_store_entry() -> None: instr = _instr("0" * 16 + "1") with Pipeline(_StubClient()) as pipe: pipe.run(_request(), DispatchContext(instr)) - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.contexts_for_trace(instr.trace_id.value) == [] assert ContextStore._contexts == {} @@ -93,7 +93,7 @@ def test_run_evicts_even_when_chain_raises() -> None: instr = _instr("0" * 16 + "2") with Pipeline(_FailingClient()) as pipe, pytest.raises(ServiceRequestError): pipe.run(_request(), DispatchContext(instr)) - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.contexts_for_trace(instr.trace_id.value) == [] def test_in_chain_observer_still_sees_exchange_context() -> None: @@ -104,7 +104,8 @@ def test_in_chain_observer_still_sees_exchange_context() -> None: class _Observer(Policy): def send(self, request: Request, ctx: PipelineContext) -> Response: response = self.next.send(request, ctx) - seen.append(ContextStore.get(instr.trace_id.value)) + live = ContextStore.contexts_for_trace(instr.trace_id.value) + seen.append(live[0] if live else None) return response with Pipeline(_StubClient(), policies=[_Observer()]) as pipe: @@ -113,7 +114,7 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: assert len(seen) == 1 assert isinstance(seen[0], ExchangeContext) # Once run() returns, the entry is gone. - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.contexts_for_trace(instr.trace_id.value) == [] def test_async_run_evicts_context_store_entry() -> None: @@ -124,7 +125,7 @@ async def run() -> None: await pipe.run(_request(), DispatchContext(instr)) asyncio.run(run()) - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.contexts_for_trace(instr.trace_id.value) == [] assert ContextStore._contexts == {} @@ -137,7 +138,7 @@ async def run() -> None: with pytest.raises(ServiceRequestError): asyncio.run(run()) - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.contexts_for_trace(instr.trace_id.value) == [] def test_async_in_chain_observer_still_sees_exchange_context() -> None: @@ -147,7 +148,8 @@ def test_async_in_chain_observer_still_sees_exchange_context() -> None: class _Observer(AsyncPolicy): async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: response = await self.next.send(request, ctx) - seen.append(ContextStore.get(instr.trace_id.value)) + live = ContextStore.contexts_for_trace(instr.trace_id.value) + seen.append(live[0] if live else None) return response async def run() -> None: @@ -157,4 +159,4 @@ async def run() -> None: asyncio.run(run()) assert len(seen) == 1 assert isinstance(seen[0], ExchangeContext) - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.contexts_for_trace(instr.trace_id.value) == [] diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_defaults.py b/packages/dexpace-sdk-core/tests/pipeline/test_defaults.py index 06d920f..aa21c4f 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_defaults.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_defaults.py @@ -5,26 +5,61 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from collections.abc import Sequence +from typing import Any + +import pytest from dexpace.sdk.core.http.auth.credentials import KeyCredential from dexpace.sdk.core.http.auth.policies import KeyCredentialPolicy -from dexpace.sdk.core.pipeline import Pipeline, Stage, StagedPipelineBuilder +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, Response, Status +from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN +from dexpace.sdk.core.pipeline import ( + AsyncPipeline, + AsyncStagedPipelineBuilder, + Pipeline, + PipelineContext, + Stage, + StagedPipelineBuilder, +) +from dexpace.sdk.core.pipeline._async_transport_runner import _AsyncTransportRunner from dexpace.sdk.core.pipeline._transport_runner import _TransportRunner -from dexpace.sdk.core.pipeline.async_pipeline import AsyncPipeline -from dexpace.sdk.core.pipeline.async_staged_builder import AsyncStagedPipelineBuilder +from dexpace.sdk.core.pipeline.async_policy import AsyncPolicy from dexpace.sdk.core.pipeline.defaults import default_async_pipeline, default_pipeline +from dexpace.sdk.core.pipeline.policies.async_client_identity import AsyncClientIdentityPolicy +from dexpace.sdk.core.pipeline.policies.async_idempotency import AsyncIdempotencyPolicy +from dexpace.sdk.core.pipeline.policies.async_logging_policy import AsyncLoggingPolicy +from dexpace.sdk.core.pipeline.policies.async_redirect import AsyncRedirectPolicy +from dexpace.sdk.core.pipeline.policies.async_retry import AsyncRetryPolicy +from dexpace.sdk.core.pipeline.policies.async_set_date import AsyncSetDatePolicy +from dexpace.sdk.core.pipeline.policies.async_tracing_policy import ( + AsyncOperationTracingPolicy, + AsyncTracingPolicy, +) +from dexpace.sdk.core.pipeline.policies.client_identity import ClientIdentityPolicy +from dexpace.sdk.core.pipeline.policies.idempotency import IdempotencyPolicy from dexpace.sdk.core.pipeline.policies.logging_policy import LoggingPolicy from dexpace.sdk.core.pipeline.policies.redirect import RedirectPolicy from dexpace.sdk.core.pipeline.policies.retry import RetryPolicy from dexpace.sdk.core.pipeline.policies.set_date import SetDatePolicy -from dexpace.sdk.core.pipeline.policies.tracing_policy import TracingPolicy +from dexpace.sdk.core.pipeline.policies.tracing_policy import OperationTracingPolicy, TracingPolicy +from dexpace.sdk.core.pipeline.policy import Policy + +from ..conftest import FakeClock -if TYPE_CHECKING: - from dexpace.sdk.core.http.request.request import Request - from dexpace.sdk.core.http.response.async_response import AsyncResponse - from dexpace.sdk.core.http.response.response import Response - from dexpace.sdk.core.pipeline.policy import Policy +#: One scripted hop: the status to return and an optional ``Location`` header. +type _Hop = tuple[Status, str | None] class _StubTransport: @@ -37,6 +72,147 @@ async def execute(self, request: Request) -> AsyncResponse: # pragma: no cover raise NotImplementedError +class _AsyncFakeClock: + """Deterministic async clock; ``sleep`` advances simulated time.""" + + __slots__ = ("_t",) + + def __init__(self, start: float = 0.0) -> None: + self._t = start + + def now(self) -> float: + return self._t + + def monotonic(self) -> float: + return self._t + + async def sleep(self, duration: float) -> None: + self._t += max(0.0, duration) + + +class _OwnershipTransport: + """Sync transport mirroring the adapter close contract: ownership-aware + idempotent. + + ``owns`` models a transport adapter that constructed its own underlying + client (the adapters' ``_owns_client`` flag): ``close`` tears the underlying + resource down only when the transport owns it, and is idempotent (guarded + like the adapters' ``_closed`` flag) so a second close never doubles the + teardown. A borrowed (BYO) resource is left intact — closing the pipeline + delegates to this method, which is the seam that keeps a caller's transport + alive. + """ + + def __init__(self, script: Sequence[_Hop] | None = None, *, owns: bool) -> None: + self._script: list[_Hop] = list(script or [(Status.OK, None)]) + self._owns = owns + self._closed = False + self.requests: list[Request] = [] + self.close_calls = 0 + self.underlying_torn_down = False + + def execute(self, request: Request) -> Response: + status, location = self._script[len(self.requests)] + self.requests.append(request) + response = Response(request=request, protocol=Protocol.HTTP_1_1, status=status) + if location is not None: + response = response.with_header("Location", location) + return response + + def close(self) -> None: + self.close_calls += 1 + if self._closed: + return # idempotent: a second close is a no-op + self._closed = True + if self._owns: + self.underlying_torn_down = True + + +class _AsyncOwnershipTransport: + """Async twin of ``_OwnershipTransport`` (tears down via ``aclose``).""" + + def __init__(self, script: Sequence[_Hop] | None = None, *, owns: bool) -> None: + self._script: list[_Hop] = list(script or [(Status.OK, None)]) + self._owns = owns + self._closed = False + self.requests: list[Request] = [] + self.aclose_calls = 0 + self.underlying_torn_down = False + + async def execute(self, request: Request) -> AsyncResponse: + status, location = self._script[len(self.requests)] + self.requests.append(request) + response = AsyncResponse(request=request, protocol=Protocol.HTTP_1_1, status=status) + if location is not None: + response = response.with_header("Location", location) + return response + + async def aclose(self) -> None: + self.aclose_calls += 1 + if self._closed: + return # idempotent: a second close is a no-op + self._closed = True + if self._owns: + self.underlying_torn_down = True + + +class _OptionsProbe(Policy): + """Records the ``ctx.options`` mapping seen at the transport boundary.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self, seen: list[dict[str, Any]]) -> None: + self.seen = seen + + def send(self, request: Request, ctx: PipelineContext) -> Response: + self.seen.append(ctx.options) + return self.next.send(request, ctx) + + +class _AsyncOptionsProbe(AsyncPolicy): + """Async twin of ``_OptionsProbe``.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self, seen: list[dict[str, Any]]) -> None: + self.seen = seen + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + self.seen.append(ctx.options) + return await self.next.send(request, ctx) + + +class _StubAsyncAuth(AsyncPolicy): + """Minimal async credential policy occupying the AUTH pillar for introspection.""" + + STAGE = Stage.AUTH + __slots__ = () + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + return await self.next.send(request, ctx) + + +def _instr(trace: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + + +def _dispatch(trace: str) -> DispatchContext: + """Build a fresh ``DispatchContext`` for a unique 32-hex-char trace id.""" + return DispatchContext(_instr(trace)) + + +def _get(url: str = "https://example.com/start") -> Request: + return Request(method=Method.GET, url=Url.parse(url)) + + def _stages_of(pipeline: Pipeline) -> list[Stage]: out: list[Stage] = [] node: Policy | None = pipeline._chain @@ -46,11 +222,39 @@ def _stages_of(pipeline: Pipeline) -> list[Stage]: return out +def _async_stages_of(pipeline: AsyncPipeline) -> list[Stage]: + out: list[Stage] = [] + node: AsyncPolicy | None = pipeline._chain + while node is not None and not isinstance(node, _AsyncTransportRunner): + out.append(node.STAGE) + node = getattr(node, "next", None) + return out + + +def _policies_of(pipeline: Pipeline) -> list[type[Policy]]: + out: list[type[Policy]] = [] + node: Policy | None = pipeline._chain + while node is not None and not isinstance(node, _TransportRunner): + out.append(type(node)) + node = getattr(node, "next", None) + return out + + +def _async_policies_of(pipeline: AsyncPipeline) -> list[type[AsyncPolicy]]: + out: list[type[AsyncPolicy]] = [] + node: AsyncPolicy | None = pipeline._chain + while node is not None and not isinstance(node, _AsyncTransportRunner): + out.append(type(node)) + node = getattr(node, "next", None) + return out + + def test_default_pipeline_returns_builder() -> None: builder = default_pipeline(_StubTransport()) assert isinstance(builder, StagedPipelineBuilder) +@pytest.mark.req("PIPE-24", "PIPE-39") def test_default_pipeline_wires_canonical_stack() -> None: pipeline = default_pipeline(_StubTransport()).build() stages = _stages_of(pipeline) @@ -69,6 +273,51 @@ def test_default_pipeline_wires_canonical_stack() -> None: ] +@pytest.mark.req("PIPE-2", "PIPE-25", "REDIR-24", "REDIR-25") +def test_default_pipeline_exact_policy_order() -> None: + # Introspect the concrete policy types (stronger than stage-only): the + # fixed order is operation-tracing -> redirect -> idempotency -> retry -> + # set-date -> client-identity -> logging -> tracing -> transport. + pipeline = default_pipeline(_StubTransport()).build() + assert _policies_of(pipeline) == [ + OperationTracingPolicy, + RedirectPolicy, + IdempotencyPolicy, + RetryPolicy, + SetDatePolicy, + ClientIdentityPolicy, + LoggingPolicy, + TracingPolicy, + ] + + +@pytest.mark.req("AUTH-27") +def test_default_pipeline_with_auth_exact_policy_order() -> None: + # Auth slots between client-identity and logging — i.e. INSIDE the retry + # wrapper (Stage.AUTH > Stage.RETRY), so a 401 refresh replays the request. + auth = KeyCredentialPolicy(KeyCredential("secret"), "X-API-Key") + pipeline = default_pipeline(_StubTransport(), auth=auth).build() + assert _policies_of(pipeline) == [ + OperationTracingPolicy, + RedirectPolicy, + IdempotencyPolicy, + RetryPolicy, + SetDatePolicy, + ClientIdentityPolicy, + KeyCredentialPolicy, + LoggingPolicy, + TracingPolicy, + ] + + +@pytest.mark.req("AUTH-27") +def test_default_pipeline_idempotency_outside_retry_auth_inside() -> None: + # Idempotency wraps retry so a write's key is minted once and stays stable + # across attempts; auth sits inside retry so a 401 refresh can replay. + assert IdempotencyPolicy.STAGE < RetryPolicy.STAGE + assert KeyCredentialPolicy.STAGE > RetryPolicy.STAGE + + def test_default_pipeline_with_auth_inserts_at_AUTH_stage() -> None: # noqa: N802 auth = KeyCredentialPolicy(KeyCredential("secret"), "X-API-Key") pipeline = default_pipeline(_StubTransport(), auth=auth).build() @@ -135,5 +384,217 @@ def test_default_async_pipeline_wires_operation_tracing() -> None: # Mirrors the sync default: the per-operation lifecycle policy occupies the # outermost OPERATION pillar, so async callers get the same # operation_started / operation_succeeded / operation_failed bracket around - # the attempt-level events the retry/redirect policies already emit. + # the attempt-level events the retry policy emits. assert isinstance(builder._pillars[Stage.OPERATION], AsyncOperationTracingPolicy) + + +@pytest.mark.req("PIPE-28", "PIPE-32", "REDIR-25") +def test_default_async_pipeline_exact_policy_order() -> None: + # The async default omits redirect (opt-in only) — the shared resolution + # core isn't yet certified against the full redirect security battery + # (docs/deviations.md) — but DOES carry the async logging + per-attempt + # tracing policies inside the retry wrapper, matching the sync default's + # instrumentation slots. The exact-order assertion certifies both the + # redirect omission and the instrumentation policies' positions. + pipeline = default_async_pipeline(_AsyncStubTransport()).build() + assert _async_policies_of(pipeline) == [ + AsyncOperationTracingPolicy, + AsyncIdempotencyPolicy, + AsyncRetryPolicy, + AsyncSetDatePolicy, + AsyncClientIdentityPolicy, + AsyncLoggingPolicy, + AsyncTracingPolicy, + ] + + +def test_default_async_pipeline_opt_in_redirect_exact_policy_order() -> None: + pipeline = default_async_pipeline(_AsyncStubTransport(), redirect=AsyncRedirectPolicy()).build() + assert _async_policies_of(pipeline) == [ + AsyncOperationTracingPolicy, + AsyncRedirectPolicy, + AsyncIdempotencyPolicy, + AsyncRetryPolicy, + AsyncSetDatePolicy, + AsyncClientIdentityPolicy, + AsyncLoggingPolicy, + AsyncTracingPolicy, + ] + + +def test_default_async_pipeline_with_auth_exact_policy_order() -> None: + pipeline = default_async_pipeline(_AsyncStubTransport(), auth=_StubAsyncAuth()).build() + assert _async_policies_of(pipeline) == [ + AsyncOperationTracingPolicy, + AsyncIdempotencyPolicy, + AsyncRetryPolicy, + AsyncSetDatePolicy, + AsyncClientIdentityPolicy, + _StubAsyncAuth, + AsyncLoggingPolicy, + AsyncTracingPolicy, + ] + + +def test_default_async_pipeline_wires_logging_and_tracing() -> None: + builder = default_async_pipeline(_AsyncStubTransport()) + # The async wire-logging gap is closed: the LOGGING pillar holds the async + # logging policy and the per-attempt tracing span sits at POST_LOGGING, + # matching the sync default's instrumentation slots. + assert isinstance(builder._pillars[Stage.LOGGING], AsyncLoggingPolicy) + stages = _async_stages_of(builder.build()) + assert Stage.LOGGING in stages + assert Stage.POST_LOGGING in stages + # Logging / tracing sit inside the retry wrapper (per attempt), not before it. + assert stages.index(Stage.RETRY) < stages.index(Stage.LOGGING) + assert stages.index(Stage.LOGGING) < stages.index(Stage.POST_LOGGING) + + +def test_default_async_pipeline_logging_tracing_overrides_replace_defaults() -> None: + logging = AsyncLoggingPolicy(preview_bytes=256) + tracing = AsyncTracingPolicy() + builder = default_async_pipeline(_AsyncStubTransport(), logging=logging, tracing=tracing) + assert builder._pillars[Stage.LOGGING] is logging + + +@pytest.mark.req("PIPE-32") +def test_default_async_pipeline_omits_redirect_by_default() -> None: + # Gate: the async redirect twin is not certified against the full redirect + # security battery (see test_async_redirect_battery.py and docs/deviations.md), + # so the async default must not silently follow redirects. The REDIRECT + # pillar stays empty unless the caller opts in. + builder = default_async_pipeline(_AsyncStubTransport()) + assert Stage.REDIRECT not in builder._pillars + assert Stage.REDIRECT not in _async_stages_of(builder.build()) + + +def test_default_async_pipeline_opt_in_redirect_wires_at_redirect_stage() -> None: + # Opt-in path stays fully functional: an explicit AsyncRedirectPolicy is + # wired into the REDIRECT pillar exactly as the sync default wires its own. + redirect = AsyncRedirectPolicy(max_hops=3) + builder = default_async_pipeline(_AsyncStubTransport(), redirect=redirect) + assert builder._pillars[Stage.REDIRECT] is redirect + assert Stage.REDIRECT in _async_stages_of(builder.build()) + + +@pytest.mark.req("PIPE-17") +def test_default_pipeline_options_identity_across_retry_and_redirect() -> None: + # Force a retry (503) then a redirect (301) then success through the full + # default stack. The per-call options mapping must be the same object at + # the transport boundary on every fork, threaded through unchanged. + seen: list[dict[str, Any]] = [] + client = _OwnershipTransport( + [ + (Status.SERVICE_UNAVAILABLE, None), # retry fork + (Status.MOVED_PERMANENTLY, "https://example.com/new"), # redirect fork + (Status.OK, None), # final + ], + owns=False, + ) + builder = default_pipeline(client, retry=RetryPolicy(backoff_factor=0.0, clock=FakeClock())) + builder.append(_OptionsProbe(seen)) + response = builder.build().run(_get(), _dispatch(f"{1:032x}"), retry_total=5, marker="abc") + response.close() + assert len(client.requests) == 3 # the retry + redirect walk actually ran + assert len(seen) == 3 # one observation per fork reaching the transport + assert all(observed is seen[0] for observed in seen) # same mapping object every fork + assert seen[0]["retry_total"] == 5 # caller override threaded through unchanged + assert seen[0]["marker"] == "abc" + assert client.close_calls == 0 # the run/fork machinery never closed the transport + + +async def test_default_async_pipeline_options_identity_across_retry() -> None: + # The async default has no redirect to force, so exercise retry: a 503 then + # success. The options mapping stays the same object across both attempts. + seen: list[dict[str, Any]] = [] + client = _AsyncOwnershipTransport( + [(Status.SERVICE_UNAVAILABLE, None), (Status.OK, None)], + owns=False, + ) + builder = default_async_pipeline( + client, retry=AsyncRetryPolicy(backoff_factor=0.0, clock=_AsyncFakeClock()) + ) + builder.append(_AsyncOptionsProbe(seen)) + response = await builder.build().run( + _get(), _dispatch(f"{2:032x}"), retry_total=5, marker="abc" + ) + await response.close() + assert len(client.requests) == 2 + assert len(seen) == 2 + assert all(observed is seen[0] for observed in seen) + assert seen[0]["retry_total"] == 5 + assert seen[0]["marker"] == "abc" + assert client.aclose_calls == 0 + + +def test_default_pipeline_run_never_closes_transport() -> None: + # Driven without the context manager, the run/fork machinery never closes + # the transport — the caller retains ownership throughout normal operation. + client = _OwnershipTransport(owns=True) + pipeline = default_pipeline(client).build() + pipeline.run(_get(), _dispatch(f"{3:032x}")).close() + assert client.close_calls == 0 + assert client.underlying_torn_down is False + + +def test_default_pipeline_byo_transport_survives_close() -> None: + # A borrowed (BYO) transport: the deliberate context-manager exit delegates + # to transport.close(), but an ownership-aware close leaves the caller's + # underlying resource intact. + client = _OwnershipTransport(owns=False) + with default_pipeline(client).build() as pipeline: + pipeline.run(_get(), _dispatch(f"{4:032x}")).close() + assert client.close_calls == 1 # close was delegated to the transport + assert client.underlying_torn_down is False # ... but the BYO resource survives + + +def test_default_pipeline_owned_transport_closed_on_exit() -> None: + # The other side of the ownership seam: a transport that owns its underlying + # resource does tear it down on the deliberate context-manager exit. + client = _OwnershipTransport(owns=True) + with default_pipeline(client).build() as pipeline: + pipeline.run(_get(), _dispatch(f"{5:032x}")).close() + assert client.underlying_torn_down is True + + +def test_default_pipeline_close_is_idempotent() -> None: + # Double close is a no-op: a second context-manager exit re-invokes + # transport.close(), but the idempotency guard means teardown never doubles. + client = _OwnershipTransport(owns=True) + pipeline = default_pipeline(client).build() + with pipeline: + pipeline.run(_get(), _dispatch(f"{6:032x}")).close() + with pipeline: # second deliberate exit -> second close() + pass + assert client.close_calls == 2 # the pipeline delegated the close twice + assert client.underlying_torn_down is True # ... teardown happened exactly once + + +async def test_default_async_pipeline_run_never_closes_transport() -> None: + client = _AsyncOwnershipTransport(owns=True) + pipeline = default_async_pipeline(client).build() + response = await pipeline.run(_get(), _dispatch(f"{7:032x}")) + await response.close() + assert client.aclose_calls == 0 + assert client.underlying_torn_down is False + + +async def test_default_async_pipeline_byo_transport_survives_close() -> None: + client = _AsyncOwnershipTransport(owns=False) + async with default_async_pipeline(client).build() as pipeline: + response = await pipeline.run(_get(), _dispatch(f"{8:032x}")) + await response.close() + assert client.aclose_calls == 1 + assert client.underlying_torn_down is False + + +async def test_default_async_pipeline_close_is_idempotent() -> None: + client = _AsyncOwnershipTransport(owns=True) + pipeline = default_async_pipeline(client).build() + async with pipeline: + response = await pipeline.run(_get(), _dispatch(f"{9:032x}")) + await response.close() + async with pipeline: # second deliberate exit -> second aclose() + pass + assert client.aclose_calls == 2 + assert client.underlying_torn_down is True diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_exchange_per_hop.py b/packages/dexpace-sdk-core/tests/pipeline/test_exchange_per_hop.py index bbe0d47..7bf58dc 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_exchange_per_hop.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_exchange_per_hop.py @@ -112,7 +112,9 @@ def test_exchange_records_per_hop_request_after_redirect() -> None: class _Observer(Policy): def send(self, request: Request, ctx: PipelineContext) -> Response: response = self.next.send(request, ctx) - stored = ContextStore.get(instr.trace_id.value) + live = ContextStore.contexts_for_trace(instr.trace_id.value) + assert len(live) == 1 + stored = live[0] assert isinstance(stored, ExchangeContext) seen.append(stored) return response @@ -142,7 +144,9 @@ def test_exchange_request_and_response_traveled_together() -> None: class _Observer(Policy): def send(self, request: Request, ctx: PipelineContext) -> Response: response = self.next.send(request, ctx) - stored = ContextStore.get(instr.trace_id.value) + live = ContextStore.contexts_for_trace(instr.trace_id.value) + assert len(live) == 1 + stored = live[0] assert isinstance(stored, ExchangeContext) seen.append((stored.request, stored.response)) return response @@ -165,7 +169,9 @@ def test_async_exchange_records_per_hop_request_after_redirect() -> None: class _Observer(AsyncPolicy): async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: response = await self.next.send(request, ctx) - stored = ContextStore.get(instr.trace_id.value) + live = ContextStore.contexts_for_trace(instr.trace_id.value) + assert len(live) == 1 + stored = live[0] assert isinstance(stored, ExchangeContext) seen.append(stored) return response diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_fork_lifecycle.py b/packages/dexpace-sdk-core/tests/pipeline/test_fork_lifecycle.py new file mode 100644 index 0000000..3aaa441 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_fork_lifecycle.py @@ -0,0 +1,672 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Fork / re-drive and response-lifecycle battery for both pipeline drivers. + +Pins the pipeline runtime's per-``send`` semantics on the sync ``Pipeline`` and +the async ``AsyncPipeline``: + +- **Independent forks.** Each ``self.next.send`` re-drive (retry / redirect + re-issuing a request) re-runs the whole downstream chain; a substituted + request sticks downstream for that fork; per-call ``options`` ride unchanged + across every fork. +- **Supersede close ordering.** A policy that supersedes a response closes the + superseded response *before* re-driving and hands the final response back + *open*. +- **Abandon paths stay open.** Redirect loop / max-hops and an exhausted retry + budget return the in-flight response open, never closed. +- **Transport ownership.** The ``send`` / fork machinery never closes the + transport; only the deliberate context-manager exit does. +- **Concurrency.** 100 parallel sends through one built pipeline never corrupt + each other's per-call state. +""" + +from __future__ import annotations + +import asyncio +import threading +from collections.abc import AsyncIterator, Iterator, Sequence +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import pytest + +from dexpace.sdk.core.client.async_http_client import AsyncHttpClient +from dexpace.sdk.core.client.http_client import HttpClient +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, Response, ResponseBody, Status +from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody +from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN +from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline, PipelineContext, Policy, Stage +from dexpace.sdk.core.pipeline.async_policy import AsyncPolicy +from dexpace.sdk.core.pipeline.policies.async_redirect import AsyncRedirectPolicy +from dexpace.sdk.core.pipeline.policies.async_retry import AsyncRetryPolicy +from dexpace.sdk.core.pipeline.policies.redirect import RedirectPolicy +from dexpace.sdk.core.pipeline.policies.retry import RetryPolicy + +from ..conftest import FakeClock + +# --------------------------------------------------------------------------- # +# Shared fixtures / factories # +# --------------------------------------------------------------------------- # + +#: One scripted hop: the status to return and an optional ``Location`` header. +type _Hop = tuple[Status, str | None] + + +def _instr(trace: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + + +def _dispatch(trace: str) -> DispatchContext: + """Build a fresh ``DispatchContext`` for a unique 32-hex-char trace id.""" + return DispatchContext(_instr(trace)) + + +def _get(url: str = "https://example.com/start") -> Request: + return Request(method=Method.GET, url=Url.parse(url)) + + +class _AsyncFakeClock: + """Deterministic async clock; ``sleep`` advances simulated time.""" + + __slots__ = ("_t",) + + def __init__(self, start: float = 0.0) -> None: + self._t = start + + def now(self) -> float: + return self._t + + def monotonic(self) -> float: + return self._t + + async def sleep(self, duration: float) -> None: + self._t += max(0.0, duration) + + +# --------------------------------------------------------------------------- # +# Close-counting response bodies # +# --------------------------------------------------------------------------- # + + +class _CountingBody(ResponseBody): + """Sync response body that counts how many times ``close`` was called.""" + + __slots__ = ("close_count",) + + def __init__(self) -> None: + self.close_count = 0 + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return 0 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + yield b"" + + def close(self) -> None: + self.close_count += 1 + + @property + def closed(self) -> bool: + return self.close_count > 0 + + +class _AsyncCountingBody(AsyncResponseBody): + """Async twin of ``_CountingBody``.""" + + __slots__ = ("close_count",) + + def __init__(self) -> None: + self.close_count = 0 + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return 0 + + async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + yield b"" + + async def close(self) -> None: + self.close_count += 1 + + @property + def closed(self) -> bool: + return self.close_count > 0 + + +# --------------------------------------------------------------------------- # +# Close-tracking scripted transports # +# --------------------------------------------------------------------------- # + + +class _ScriptClient(HttpClient): + """Scripted sync transport; records requests, bodies, and ``close`` calls. + + The transport is the fork counter: every re-drive re-runs the full + downstream chain and reaches ``execute`` exactly once, so ``len(requests)`` + equals the number of forks. ``close_calls`` proves whether the pipeline + ever closed its transport. + """ + + def __init__(self, script: Sequence[_Hop]) -> None: + self._script = list(script) + self.requests: list[Request] = [] + self.bodies: list[_CountingBody] = [] + self.close_calls = 0 + + def execute(self, request: Request) -> Response: + status, location = self._script[len(self.requests)] + self.requests.append(request) + body = _CountingBody() + self.bodies.append(body) + response = Response(request=request, protocol=Protocol.HTTP_1_1, status=status, body=body) + if location is not None: + response = response.with_header("Location", location) + return response + + def close(self) -> None: + self.close_calls += 1 + + +class _AsyncScriptClient(AsyncHttpClient): + """Async twin of ``_ScriptClient`` (records ``aclose`` calls).""" + + def __init__(self, script: Sequence[_Hop]) -> None: + self._script = list(script) + self.requests: list[Request] = [] + self.bodies: list[_AsyncCountingBody] = [] + self.aclose_calls = 0 + + async def execute(self, request: Request) -> AsyncResponse: + status, location = self._script[len(self.requests)] + self.requests.append(request) + body = _AsyncCountingBody() + self.bodies.append(body) + response = AsyncResponse( + request=request, protocol=Protocol.HTTP_1_1, status=status, body=body + ) + if location is not None: + response = response.with_header("Location", location) + return response + + async def aclose(self) -> None: + self.aclose_calls += 1 + + +# --------------------------------------------------------------------------- # +# Probe policies # +# --------------------------------------------------------------------------- # + + +class _TeapotRetryPolicy(Policy): + """Supersedes each 418 response by re-driving the downstream chain. + + On a 418 (I'M A TEAPOT) the probe closes the superseded response, stamps a + fork counter onto the request, and re-drives ``self.next`` — an independent + fork whose substituted request sticks downstream. The loop stops at the + first non-418 response, which is returned open. Per-fork state lives in + ``ctx.data``, never on ``self``, so the probe is concurrency-safe. + """ + + STAGE = Stage.RETRY + __slots__ = () + + def send(self, request: Request, ctx: PipelineContext) -> Response: + fork = 0 + current = request + while True: + response = self.next.send(current, ctx) + if response.status is not Status.IM_A_TEAPOT: + ctx.data["teapot_forks"] = fork + return response + response.close() # supersede: close before re-driving + fork += 1 + current = current.with_header("X-Teapot-Fork", str(fork)) + + +class _AsyncTeapotRetryPolicy(AsyncPolicy): + """Async twin of ``_TeapotRetryPolicy``.""" + + STAGE = Stage.RETRY + __slots__ = () + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + fork = 0 + current = request + while True: + response = await self.next.send(current, ctx) + if response.status is not Status.IM_A_TEAPOT: + ctx.data["teapot_forks"] = fork + return response + await response.close() + fork += 1 + current = current.with_header("X-Teapot-Fork", str(fork)) + + +class _OptionsProbe(Policy): + """Records the ``ctx.options`` mapping seen at the transport boundary.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self, seen: list[dict[str, Any]]) -> None: + self.seen = seen + + def send(self, request: Request, ctx: PipelineContext) -> Response: + self.seen.append(ctx.options) + return self.next.send(request, ctx) + + +class _AsyncOptionsProbe(AsyncPolicy): + """Async twin of ``_OptionsProbe``.""" + + STAGE = Stage.PRE_AUTH + __slots__ = ("seen",) + + def __init__(self, seen: list[dict[str, Any]]) -> None: + self.seen = seen + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + self.seen.append(ctx.options) + return await self.next.send(request, ctx) + + +class _CrossTalkProbe(Policy): + """Round-trips a per-call id through ``ctx.data`` to detect cross-talk.""" + + STAGE = Stage.PRE_AUTH + __slots__ = () + + def send(self, request: Request, ctx: PipelineContext) -> Response: + call_id = ctx.options["call_id"] + ctx.data["seen"] = call_id + response = self.next.send(request.with_header("X-Call-Id", str(call_id)), ctx) + # If ``ctx.data`` leaked between concurrent calls, this would echo a + # different call's id. + return response.with_header("X-Echo", str(ctx.data["seen"])) + + +class _AsyncCrossTalkProbe(AsyncPolicy): + """Async twin of ``_CrossTalkProbe`` with a yield to force interleaving.""" + + STAGE = Stage.PRE_AUTH + __slots__ = () + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + call_id = ctx.options["call_id"] + ctx.data["seen"] = call_id + await asyncio.sleep(0) # cede control so calls interleave + response = await self.next.send(request.with_header("X-Call-Id", str(call_id)), ctx) + return response.with_header("X-Echo", str(ctx.data["seen"])) + + +class _EchoClient(HttpClient): + """Echoes the request's ``X-Call-Id`` into a response header; counts closes.""" + + def __init__(self) -> None: + self.close_calls = 0 + + def execute(self, request: Request) -> Response: + saw = request.headers.get("X-Call-Id") or "" + return Response(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK).with_header( + "X-Transport-Saw", saw + ) + + def close(self) -> None: + self.close_calls += 1 + + +class _AsyncEchoClient(AsyncHttpClient): + """Async twin of ``_EchoClient``.""" + + def __init__(self) -> None: + self.aclose_calls = 0 + + async def execute(self, request: Request) -> AsyncResponse: + saw = request.headers.get("X-Call-Id") or "" + return AsyncResponse( + request=request, protocol=Protocol.HTTP_1_1, status=Status.OK + ).with_header("X-Transport-Saw", saw) + + async def aclose(self) -> None: + self.aclose_calls += 1 + + +# --------------------------------------------------------------------------- # +# Supersede: closed-exactly-once + final-open, on both drivers # +# --------------------------------------------------------------------------- # + + +class TestSupersedeCloseOrdering: + """A superseding policy closes each superseded response once; final stays open.""" + + @pytest.mark.req("PIPE-16", "PIPE-40") + def test_sync_superseded_closed_once_final_open(self) -> None: + client = _ScriptClient( + [(Status.IM_A_TEAPOT, None), (Status.IM_A_TEAPOT, None), (Status.OK, None)] + ) + with Pipeline(client, policies=[_TeapotRetryPolicy()]) as p: + response = p.run(_get(), _dispatch("0" * 31 + "1")) + assert response.status is Status.OK + # Both superseded 418s were closed exactly once. + assert [b.close_count for b in client.bodies[:-1]] == [1, 1] + # The final response is handed back open — the caller owns it. + assert client.bodies[-1].close_count == 0 + response.close() + assert client.bodies[-1].close_count == 1 + + async def test_async_superseded_closed_once_final_open(self) -> None: + client = _AsyncScriptClient( + [(Status.IM_A_TEAPOT, None), (Status.IM_A_TEAPOT, None), (Status.OK, None)] + ) + async with AsyncPipeline(client, policies=[_AsyncTeapotRetryPolicy()]) as p: + response = await p.run(_get(), _dispatch("0" * 31 + "2")) + assert response.status is Status.OK + assert [b.close_count for b in client.bodies[:-1]] == [1, 1] + assert client.bodies[-1].close_count == 0 + await response.close() + assert client.bodies[-1].close_count == 1 + + +# --------------------------------------------------------------------------- # +# Fork counter: full downstream re-run + substituted request sticks # +# --------------------------------------------------------------------------- # + + +class TestForkCounter: + """Each re-drive re-runs the full downstream chain with its substitution.""" + + @pytest.mark.req("PIPE-13", "PIPE-14", "PIPE-15", "PIPE-16", "PIPE-36") + def test_sync_full_rerun_and_substitution_sticks(self) -> None: + client = _ScriptClient( + [(Status.IM_A_TEAPOT, None), (Status.IM_A_TEAPOT, None), (Status.OK, None)] + ) + with Pipeline(client, policies=[_TeapotRetryPolicy()]) as p: + p.run(_get(), _dispatch("0" * 31 + "3")).close() + # Three transport hits == original send + two independent forks. + assert len(client.requests) == 3 + # The fork stamp sticks downstream for that fork and only that fork. + assert client.requests[0].headers.get("X-Teapot-Fork") is None + assert client.requests[1].headers.get("X-Teapot-Fork") == "1" + assert client.requests[2].headers.get("X-Teapot-Fork") == "2" + + async def test_async_full_rerun_and_substitution_sticks(self) -> None: + client = _AsyncScriptClient( + [(Status.IM_A_TEAPOT, None), (Status.IM_A_TEAPOT, None), (Status.OK, None)] + ) + async with AsyncPipeline(client, policies=[_AsyncTeapotRetryPolicy()]) as p: + response = await p.run(_get(), _dispatch("0" * 31 + "4")) + await response.close() + assert len(client.requests) == 3 + assert client.requests[0].headers.get("X-Teapot-Fork") is None + assert client.requests[1].headers.get("X-Teapot-Fork") == "1" + assert client.requests[2].headers.get("X-Teapot-Fork") == "2" + + +# --------------------------------------------------------------------------- # +# Abandon paths return the in-flight response OPEN # +# --------------------------------------------------------------------------- # + + +class TestAbandonPathsReturnOpen: + """Loop / max-hops / exhausted-budget hand the in-flight response back open.""" + + @pytest.mark.req("PIPE-40") + def test_sync_retry_budget_exhausted_returns_open(self) -> None: + # ``status_retries=1`` exhausts the status sub-budget on the second 503 + # while ``total`` is still positive, forcing the explicit + # budget-exhausted return branch (not the "no longer retryable" one). + client = _ScriptClient( + [(Status.SERVICE_UNAVAILABLE, None), (Status.SERVICE_UNAVAILABLE, None)] + ) + retry = RetryPolicy( + total_retries=5, status_retries=1, backoff_factor=0.0, clock=FakeClock() + ) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_get(), _dispatch("0" * 31 + "5")) + assert response.status is Status.SERVICE_UNAVAILABLE + assert client.bodies[0].closed is True # intermediate closed before sleep + assert client.bodies[-1].closed is False # abandoned response returned open + response.close() + + @pytest.mark.req("PIPE-40") + def test_sync_redirect_loop_returns_open(self) -> None: + client = _ScriptClient( + [ + (Status.MOVED_PERMANENTLY, "https://example.com/b"), + (Status.MOVED_PERMANENTLY, "https://example.com/a"), + ] + ) + with Pipeline(client, policies=[RedirectPolicy()]) as p: + response = p.run(_get("https://example.com/a"), _dispatch("0" * 31 + "6")) + assert response.status is Status.MOVED_PERMANENTLY + assert client.bodies[0].closed is True # first hop superseded and closed + assert client.bodies[-1].closed is False # loop-detected response open + response.close() + + def test_sync_redirect_max_hops_returns_open(self) -> None: + client = _ScriptClient( + [ + (Status.MOVED_PERMANENTLY, "https://example.com/a"), + (Status.MOVED_PERMANENTLY, "https://example.com/b"), + ] + ) + with Pipeline(client, policies=[RedirectPolicy(max_hops=1)]) as p: + response = p.run(_get(), _dispatch("0" * 31 + "7")) + assert response.status is Status.MOVED_PERMANENTLY + assert client.bodies[0].closed is True + assert client.bodies[-1].closed is False + response.close() + + async def test_async_retry_budget_exhausted_returns_open(self) -> None: + client = _AsyncScriptClient( + [(Status.SERVICE_UNAVAILABLE, None), (Status.SERVICE_UNAVAILABLE, None)] + ) + retry = AsyncRetryPolicy( + total_retries=5, status_retries=1, backoff_factor=0.0, clock=_AsyncFakeClock() + ) + async with AsyncPipeline(client, policies=[retry]) as p: + response = await p.run(_get(), _dispatch("0" * 31 + "8")) + assert response.status is Status.SERVICE_UNAVAILABLE + assert client.bodies[0].closed is True + assert client.bodies[-1].closed is False + await response.close() + + async def test_async_redirect_loop_returns_open(self) -> None: + client = _AsyncScriptClient( + [ + (Status.MOVED_PERMANENTLY, "https://example.com/b"), + (Status.MOVED_PERMANENTLY, "https://example.com/a"), + ] + ) + async with AsyncPipeline(client, policies=[AsyncRedirectPolicy()]) as p: + response = await p.run(_get("https://example.com/a"), _dispatch("0" * 31 + "9")) + assert response.status is Status.MOVED_PERMANENTLY + assert client.bodies[0].closed is True + assert client.bodies[-1].closed is False + await response.close() + + +# --------------------------------------------------------------------------- # +# Transport ownership: the run/fork machinery never closes the transport # +# --------------------------------------------------------------------------- # + + +class TestTransportNeverClosedByRun: + """The ``send`` path never closes the transport; only ``__exit__`` opts in.""" + + @pytest.mark.req("PIPE-8") + def test_sync_run_walk_never_closes_transport(self) -> None: + # A full retry -> redirect walk driven WITHOUT the context manager. + client = _ScriptClient( + [ + (Status.SERVICE_UNAVAILABLE, None), + (Status.MOVED_PERMANENTLY, "https://example.com/new"), + (Status.OK, None), + ] + ) + pipeline = Pipeline( + client, + policies=[RedirectPolicy(), RetryPolicy(backoff_factor=0.0, clock=FakeClock())], + ) + response = pipeline.run(_get(), _dispatch("0" * 30 + "10")) + assert response.status is Status.OK + assert len(client.requests) == 3 # the fork walk actually ran + assert client.close_calls == 0 # ... and never touched transport.close + response.close() + + def test_sync_context_manager_exit_closes_transport_once(self) -> None: + # The deliberate opt-in: using the pipeline as a context manager closes + # the transport exactly once on exit. + client = _ScriptClient([(Status.OK, None)]) + with Pipeline(client) as p: + p.run(_get(), _dispatch("0" * 30 + "11")).close() + assert client.close_calls == 1 + + async def test_async_run_walk_never_closes_transport(self) -> None: + client = _AsyncScriptClient( + [ + (Status.SERVICE_UNAVAILABLE, None), + (Status.MOVED_PERMANENTLY, "https://example.com/new"), + (Status.OK, None), + ] + ) + pipeline = AsyncPipeline( + client, + policies=[ + AsyncRedirectPolicy(), + AsyncRetryPolicy(backoff_factor=0.0, clock=_AsyncFakeClock()), + ], + ) + response = await pipeline.run(_get(), _dispatch("0" * 30 + "12")) + assert response.status is Status.OK + assert len(client.requests) == 3 + assert client.aclose_calls == 0 + await response.close() + + async def test_async_context_manager_exit_closes_transport_once(self) -> None: + client = _AsyncScriptClient([(Status.OK, None)]) + async with AsyncPipeline(client) as p: + response = await p.run(_get(), _dispatch("0" * 30 + "13")) + await response.close() + assert client.aclose_calls == 1 + + +# --------------------------------------------------------------------------- # +# Options identity preserved across a forced retry + redirect walk # +# --------------------------------------------------------------------------- # + + +class TestOptionsIdentityAcrossForks: + """The per-call ``options`` mapping is the same object at every fork.""" + + _SCRIPT: list[_Hop] = [ + (Status.SERVICE_UNAVAILABLE, None), # retry fork + (Status.MOVED_PERMANENTLY, "https://example.com/new"), # redirect fork + (Status.OK, None), # final + ] + + @pytest.mark.req("PIPE-10", "PIPE-17") + def test_sync_options_identity_stable(self) -> None: + seen: list[dict[str, Any]] = [] + client = _ScriptClient(self._SCRIPT) + pipeline = Pipeline( + client, + policies=[ + RedirectPolicy(), + RetryPolicy(backoff_factor=0.0, clock=FakeClock()), + _OptionsProbe(seen), + ], + ) + response = pipeline.run(_get(), _dispatch("0" * 30 + "14"), retry_total=5, marker="abc") + response.close() + assert len(seen) == 3 # one observation per fork reaching the transport + assert all(observed is seen[0] for observed in seen) # same object every fork + assert seen[0] == {"retry_total": 5, "marker": "abc"} + + async def test_async_options_identity_stable(self) -> None: + seen: list[dict[str, Any]] = [] + client = _AsyncScriptClient(self._SCRIPT) + pipeline = AsyncPipeline( + client, + policies=[ + AsyncRedirectPolicy(), + AsyncRetryPolicy(backoff_factor=0.0, clock=_AsyncFakeClock()), + _AsyncOptionsProbe(seen), + ], + ) + response = await pipeline.run( + _get(), _dispatch("0" * 30 + "15"), retry_total=5, marker="abc" + ) + await response.close() + assert len(seen) == 3 + assert all(observed is seen[0] for observed in seen) + assert seen[0] == {"retry_total": 5, "marker": "abc"} + + +# --------------------------------------------------------------------------- # +# Concurrency: 100 parallel sends through one built pipeline, no cross-talk # +# --------------------------------------------------------------------------- # + + +class TestConcurrentSendsNoCrossTalk: + """Concurrent calls through one built pipeline keep per-call state isolated.""" + + _N = 100 + + @pytest.mark.req("PIPE-10", "PIPE-11") + def test_sync_100_parallel_sends(self) -> None: + client = _EchoClient() + pipeline = Pipeline(client, policies=[_CrossTalkProbe()]) + barrier = threading.Barrier(self._N) + + def one(index: int) -> tuple[int, Response]: + barrier.wait() # release all threads simultaneously + return index, pipeline.run(_get(), _dispatch(f"{index:032x}"), call_id=index) + + with ThreadPoolExecutor(max_workers=self._N) as pool: + results = list(pool.map(one, range(self._N))) + + for index, response in results: + assert response.headers.get("X-Echo") == str(index) # ctx.data isolated + assert response.headers.get("X-Transport-Saw") == str(index) # request routed right + response.close() + assert client.close_calls == 0 # no close during 100 concurrent runs + + async def test_async_100_parallel_sends(self) -> None: + client = _AsyncEchoClient() + pipeline = AsyncPipeline(client, policies=[_AsyncCrossTalkProbe()]) + + async def one(index: int) -> tuple[int, AsyncResponse]: + return index, await pipeline.run(_get(), _dispatch(f"{index:032x}"), call_id=index) + + results = await asyncio.gather(*(one(index) for index in range(self._N))) + + for index, response in results: + assert response.headers.get("X-Echo") == str(index) + assert response.headers.get("X-Transport-Saw") == str(index) + await response.close() + assert client.aclose_calls == 0 diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_idempotency_policy.py b/packages/dexpace-sdk-core/tests/pipeline/test_idempotency_policy.py index ceac030..e2e5827 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_idempotency_policy.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_idempotency_policy.py @@ -24,7 +24,9 @@ from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline from dexpace.sdk.core.pipeline.policies.async_idempotency import AsyncIdempotencyPolicy +from dexpace.sdk.core.pipeline.policies.async_retry import AsyncRetryPolicy from dexpace.sdk.core.pipeline.policies.idempotency import IdempotencyPolicy +from dexpace.sdk.core.pipeline.policies.retry import RetryPolicy from dexpace.sdk.core.pipeline.stage import Stage _HEADER = "Idempotency-Key" @@ -101,6 +103,7 @@ def test_key_added_to_write_methods(method: Method) -> None: assert client.calls[0].headers.get(_HEADER) is not None +@pytest.mark.req("RECOV-32") @pytest.mark.parametrize("method", [Method.GET, Method.DELETE, Method.HEAD, Method.OPTIONS]) def test_key_not_added_to_non_write_methods(method: Method) -> None: client = _RecordingClient() @@ -109,6 +112,7 @@ def test_key_not_added_to_non_write_methods(method: Method) -> None: assert client.calls[0].headers.get(_HEADER) is None +@pytest.mark.req("RECOV-32") def test_caller_set_key_preserved() -> None: client = _RecordingClient() with Pipeline(client, policies=[IdempotencyPolicy()]) as p: @@ -128,6 +132,7 @@ def test_default_key_is_uuid4_shaped() -> None: assert parsed.version == 4 +@pytest.mark.req("RECOV-32") def test_existing_key_not_regenerated() -> None: """A request that already carries a key is forwarded untouched. @@ -181,3 +186,89 @@ async def test_async_get_not_stamped() -> None: async with AsyncPipeline(client, policies=[AsyncIdempotencyPolicy()]) as p: await p.run(_request(Method.GET), DispatchContext(_instr("0" * 16 + "c"))) assert client.calls[0].headers.get(_HEADER) is None + + +# ----- ordering-sensitive semantics: composed with a real RetryPolicy -------- +# +# The policy sits at Stage.POST_REDIRECT (outside the retry wrapper), so the +# key must be minted exactly once per call and reused on every retry attempt. +# Composing with a real RetryPolicy — rather than manually re-sending a stamped +# request — is what actually proves the placement: were the policy moved inside +# the retry loop, the counting factory below would mint a fresh key per attempt +# and the assertions would fail. + + +class _FlakyClient(HttpClient): + """Fails with the given statuses, then returns ``200`` on later calls.""" + + def __init__(self, *fail_with: Status) -> None: + self.calls: list[Request] = [] + self._fail = list(fail_with) + + def execute(self, request: Request) -> Response: + self.calls.append(request) + status = self._fail.pop(0) if self._fail else Status.OK + return Response(request=request, protocol=Protocol.HTTP_1_1, status=status) + + +class _FlakyAsyncClient(AsyncHttpClient): + def __init__(self, *fail_with: Status) -> None: + self.calls: list[Request] = [] + self._fail = list(fail_with) + + async def execute(self, request: Request) -> AsyncResponse: + self.calls.append(request) + status = self._fail.pop(0) if self._fail else Status.OK + return AsyncResponse(request=request, protocol=Protocol.HTTP_1_1, status=status) + + +def test_key_stable_across_retry_attempts() -> None: + """One retried call carries a single key across every attempt. + + ``PUT`` is a retryable write method, so a ``503`` drives a second attempt. + The counting factory would yield ``key-1`` then ``key-2`` if it were called + per attempt; a single ``key-1`` across both captured requests proves the + key is minted once, outside the retry wrapper. + """ + factory = _CountingFactory() + client = _FlakyClient(Status.SERVICE_UNAVAILABLE) + policies = [IdempotencyPolicy(key_factory=factory), RetryPolicy(backoff_factor=0)] + with Pipeline(client, policies=policies) as p: + p.run(_request(Method.PUT), DispatchContext(_instr("0" * 16 + "d"))) + assert len(client.calls) == 2 # one failure + one success + keys = {req.headers.get(_HEADER) for req in client.calls} + assert keys == {"key-1"} + + +def test_keys_distinct_across_separate_calls() -> None: + """Different calls get different keys even when the request is identical.""" + factory = _CountingFactory() + client = _RecordingClient() + with Pipeline(client, policies=[IdempotencyPolicy(key_factory=factory)]) as p: + p.run(_request(Method.POST), DispatchContext(_instr("0" * 16 + "e"))) + p.run(_request(Method.POST), DispatchContext(_instr("0" * 15 + "10"))) + assert client.calls[0].headers.get(_HEADER) == "key-1" + assert client.calls[1].headers.get(_HEADER) == "key-2" + + +def test_default_uuid_keys_distinct_across_calls() -> None: + """The default UUID4 factory also yields a fresh key per call.""" + client = _RecordingClient() + with Pipeline(client, policies=[IdempotencyPolicy()]) as p: + p.run(_request(Method.POST), DispatchContext(_instr("0" * 16 + "f"))) + p.run(_request(Method.POST), DispatchContext(_instr("0" * 15 + "11"))) + first = client.calls[0].headers.get(_HEADER) + second = client.calls[1].headers.get(_HEADER) + assert first is not None and second is not None + assert first != second + + +async def test_async_key_stable_across_retry_attempts() -> None: + factory = _CountingFactory() + client = _FlakyAsyncClient(Status.SERVICE_UNAVAILABLE) + policies = [AsyncIdempotencyPolicy(key_factory=factory), AsyncRetryPolicy(backoff_factor=0)] + async with AsyncPipeline(client, policies=policies) as p: + await p.run(_request(Method.PUT), DispatchContext(_instr("0" * 15 + "12"))) + assert len(client.calls) == 2 + keys = {req.headers.get(_HEADER) for req in client.calls} + assert keys == {"key-1"} diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_observability_policies.py b/packages/dexpace-sdk-core/tests/pipeline/test_observability_policies.py index a91dc53..165e6fd 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_observability_policies.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_observability_policies.py @@ -14,10 +14,17 @@ from dexpace.sdk.core.client.http_client import HttpClient from dexpace.sdk.core.errors import ServiceRequestError from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.common.headers import Headers from dexpace.sdk.core.http.context import DispatchContext -from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.request import ( + LoggableRequestBody, + Method, + Request, + RequestBody, +) from dexpace.sdk.core.http.response import Response, Status from dexpace.sdk.core.instrumentation import ( + HeaderRedactor, InstrumentationContext, SpanId, TraceFlags, @@ -28,7 +35,12 @@ ) from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN from dexpace.sdk.core.pipeline import Pipeline -from dexpace.sdk.core.pipeline.policies import LoggingPolicy, RetryPolicy, TracingPolicy +from dexpace.sdk.core.pipeline.policies import ( + HttpLogDetailLevel, + LoggingPolicy, + RetryPolicy, + TracingPolicy, +) from ..conftest import FakeClock @@ -50,21 +62,51 @@ def _request(url: str = "https://api.example.com/v1?token=secret&api-version=1.0 class _OkClient(HttpClient): def __init__( - self, *, status: Status = Status.OK, raise_exc: BaseException | None = None + self, + *, + status: Status = Status.OK, + raise_exc: BaseException | None = None, + headers: Headers | None = None, ) -> None: self.status = status self.raise_exc = raise_exc + self.headers = headers or Headers() def execute(self, request: Request) -> Response: if self.raise_exc is not None: raise self.raise_exc - return Response(request=request, protocol=Protocol.HTTP_1_1, status=self.status) + return Response( + request=request, protocol=Protocol.HTTP_1_1, status=self.status, headers=self.headers + ) + + +class _CapturingClient(HttpClient): + """Transport that drains and records the full request body it receives.""" + + def __init__(self) -> None: + self.received = bytearray() + + def execute(self, request: Request) -> Response: + if request.body is not None: + for chunk in request.body.iter_bytes(): + self.received.extend(chunk) + return Response(request=request, protocol=Protocol.HTTP_1_1, status=Status.OK) + + +def _post(body: RequestBody) -> Request: + return Request( + method=Method.POST, + url=Url.parse("https://api.example.com/v1"), + body=body, + ) class TestLoggingPolicy: def test_emits_request_and_response(self, caplog: LogCaptureFixture) -> None: caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") - with Pipeline(_OkClient(), policies=[LoggingPolicy()]) as p: + with Pipeline( + _OkClient(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)] + ) as p: p.run(_request(), DispatchContext(_instr("0" * 16 + "1"))) messages = [rec.getMessage() for rec in caplog.records] assert any("http.request" in m for m in messages) @@ -74,22 +116,33 @@ def test_emits_request_and_response(self, caplog: LogCaptureFixture) -> None: assert "secret" not in joined assert "REDACTED" in joined + @pytest.mark.req("OBS-39") def test_logs_error_on_exception(self, caplog: LogCaptureFixture) -> None: + # A failure emits an ``http.response`` event carrying ``error.type`` and + # the throwable cause, not a separate error event name. caplog.set_level(logging.ERROR, logger="dexpace.sdk.core.http") boom = ServiceRequestError("connect failed") with ( - Pipeline(_OkClient(raise_exc=boom), policies=[LoggingPolicy()]) as p, + Pipeline( + _OkClient(raise_exc=boom), + policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)], + ) as p, pytest.raises(ServiceRequestError), ): p.run(_request(), DispatchContext(_instr("0" * 16 + "2"))) assert any( - "http.error" in rec.getMessage() and "ServiceRequestError" in rec.getMessage() - for rec in caplog.records + m.split()[0] == "http.response" + and "error.type=ServiceRequestError" in m + and "connect failed" in m + for m in (rec.getMessage() for rec in caplog.records) ) + @pytest.mark.req("BODY-34") def test_opt_out_via_options(self, caplog: LogCaptureFixture) -> None: caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") - with Pipeline(_OkClient(), policies=[LoggingPolicy()]) as p: + with Pipeline( + _OkClient(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.BODY)] + ) as p: p.run( _request(), DispatchContext(_instr("0" * 16 + "3")), @@ -97,6 +150,230 @@ def test_opt_out_via_options(self, caplog: LogCaptureFixture) -> None: ) assert not caplog.records + @pytest.mark.req("XCUT-19") + def test_default_preview_size_is_8_kib(self) -> None: + # The logging policy defaults to a small, bounded 8 KiB body preview. + assert LoggingPolicy().preview_bytes == 8 * 1024 + + def test_preview_bytes_is_configurable(self) -> None: + assert LoggingPolicy(preview_bytes=256).preview_bytes == 256 + + def test_preview_bytes_must_be_positive(self) -> None: + with pytest.raises(ValueError, match="positive"): + LoggingPolicy(preview_bytes=0) + + @pytest.mark.req("BODY-34", "OBS-36", "XCUT-24") + def test_full_body_forwarded_uncut_while_preview_truncates( + self, caplog: LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = b"x" * (8 * 1024 + 500) # larger than the 8 KiB preview cap + client = _CapturingClient() + with Pipeline(client, policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.BODY)]) as p: + p.run(_post(RequestBody.from_bytes(payload)), DispatchContext(_instr("0" * 16 + "8"))) + # The transport received every byte — only the diagnostic tap truncates. + assert bytes(client.received) == payload + # The emitted preview is capped at the 8 KiB default. + responses = [m for m in (r.getMessage() for r in caplog.records) if "http.response" in m] + assert responses + preview = responses[0].split("request_body_preview=")[1] + assert len(preview) == 8 * 1024 + + @pytest.mark.req("OBS-38") + def test_binary_body_preview_never_throws(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = bytes(range(256)) * 40 # invalid UTF-8 sequences, > 8 KiB + client = _CapturingClient() + with Pipeline(client, policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.BODY)]) as p: + p.run(_post(RequestBody.from_bytes(payload)), DispatchContext(_instr("0" * 16 + "9"))) + assert bytes(client.received) == payload + assert any("http.response" in r.getMessage() for r in caplog.records) + + def test_preview_bounded_even_when_wrapper_ceiling_is_larger( + self, caplog: LogCaptureFixture + ) -> None: + # A caller-supplied wrapper with a large ceiling captures more than the + # preview, yet the emitted preview is still bounded to the 8 KiB default. + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = b"y" * (16 * 1024) + prewrapped = LoggableRequestBody( + RequestBody.from_bytes(payload), max_capture_bytes=32 * 1024 + ) + client = _CapturingClient() + with Pipeline(client, policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.BODY)]) as p: + p.run(_post(prewrapped), DispatchContext(_instr("0" * 16 + "a"))) + assert bytes(client.received) == payload + assert prewrapped.captured_size == 16 * 1024 # tap kept the larger slice + responses = [m for m in (r.getMessage() for r in caplog.records) if "http.response" in m] + assert responses + preview = responses[0].split("request_body_preview=")[1] + assert len(preview) == 8 * 1024 # preview stays capped + + def test_wrapper_ceiling_independent_of_logging_preview(self) -> None: + # The wrapper's own default cap stays large and is not lowered to the + # logging policy's 8 KiB preview default. + body = LoggableRequestBody(RequestBody.from_bytes(b"z" * (9 * 1024))) + list(body.iter_bytes()) + assert body.captured_size == 9 * 1024 + + +def _response_events(caplog: LogCaptureFixture) -> list[str]: + return [m for m in (r.getMessage() for r in caplog.records) if m.split()[0] == "http.response"] + + +def _request_events(caplog: LogCaptureFixture) -> list[str]: + return [m for m in (r.getMessage() for r in caplog.records) if m.split()[0] == "http.request"] + + +class TestLogDetailLevel: + """OBS-34: selectable none / headers / body granularity.""" + + @pytest.mark.req("OBS-34") + def test_none_emits_no_events(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + policy = LoggingPolicy(detail_level=HttpLogDetailLevel.NONE) + with Pipeline(_OkClient(), policies=[policy]) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "b0"))) + # Even with the logger explicitly enabled, NONE emits nothing. + assert not any( + m.split()[0].startswith("http.") for m in (r.getMessage() for r in caplog.records) + ) + + @pytest.mark.req("OBS-34") + def test_default_detail_level_is_none(self, caplog: LogCaptureFixture) -> None: + # Security default: a policy built with no explicit ``detail_level`` + # defaults to NONE, so merely raising the logger to INFO does not leak + # request/response events (or their body previews) without an explicit + # opt-in. An explicit BODY level still emits both events. + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + with Pipeline(_OkClient(), policies=[LoggingPolicy()]) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "b4"))) + assert not _request_events(caplog) and not _response_events(caplog) + caplog.clear() + with Pipeline( + _OkClient(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.BODY)] + ) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "b5"))) + assert _request_events(caplog) and _response_events(caplog) + + @pytest.mark.req("OBS-34") + def test_headers_level_omits_body_preview(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + payload = b"x" * 4096 + client = _CapturingClient() + policy = LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS) + with Pipeline(client, policies=[policy]) as p: + p.run(_post(RequestBody.from_bytes(payload)), DispatchContext(_instr("0" * 15 + "b1"))) + # Events fire, but body capture happens only at BODY level. + assert _request_events(caplog) and _response_events(caplog) + assert "request_body_preview=" not in _response_events(caplog)[0] + # The transport still received the full body untouched. + assert bytes(client.received) == payload + + @pytest.mark.req("OBS-34") + def test_body_level_captures_body_preview(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + client = _CapturingClient() + policy = LoggingPolicy(detail_level=HttpLogDetailLevel.BODY) + with Pipeline(client, policies=[policy]) as p: + p.run(_post(RequestBody.from_bytes(b"body")), DispatchContext(_instr("0" * 15 + "b2"))) + assert "request_body_preview=" in _response_events(caplog)[0] + + @pytest.mark.req("OBS-34") + def test_none_silences_logs_but_tracing_still_records(self, caplog: LogCaptureFixture) -> None: + # Span lifecycle runs on every request independent of the log level: + # NONE silences log events without disabling tracing. + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + tracer = _RecordingTracer() + policies = [ + TracingPolicy(tracer=tracer), + LoggingPolicy(detail_level=HttpLogDetailLevel.NONE), + ] + with Pipeline(_OkClient(), policies=policies) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "b3"))) + assert not _request_events(caplog) and not _response_events(caplog) + assert tracer.spans and tracer.spans[0].ended + + +class TestNormativeFieldVocabulary: + """OBS-39: stable event names and OTel-style field keys.""" + + @pytest.mark.req("OBS-39") + def test_success_events_use_normative_field_keys(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + with Pipeline( + _OkClient(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)] + ) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "c0"))) + request_event = _request_events(caplog)[0] + response_event = _response_events(caplog)[0] + assert "http.request.method=GET" in request_event + assert "url.full=" in request_event + assert "http.request.method=GET" in response_event + assert "url.full=" in response_event + assert "http.response.status_code=200" in response_event + assert "http.response.duration_ms=" in response_event + + @pytest.mark.req("OBS-39") + def test_url_full_is_always_redacted(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + with Pipeline( + _OkClient(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)] + ) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "c1"))) + joined = " ".join(r.getMessage() for r in caplog.records) + assert "secret" not in joined + assert "url.full=" in joined and "REDACTED" in joined + + +class TestHeaderLogging: + """OBS-17/18: response-header logging, URL-value redaction, name allow-list.""" + + def _client(self) -> _OkClient: + headers = Headers.outbound( + [ + ("Location", "https://idp.example.com/cb?code=SEKRET&api-version=1"), + ("Content-Type", "application/json"), + ("Authorization", "Bearer super-secret-token"), + ] + ) + return _OkClient(headers=headers) + + @pytest.mark.req("OBS-17") + def test_url_valued_response_header_is_redacted(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + with Pipeline( + self._client(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)] + ) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "d0"))) + response_event = _response_events(caplog)[0] + assert "SEKRET" not in response_event + assert "http.response.header.location=" in response_event + assert "REDACTED=REDACTED" in response_event + + @pytest.mark.req("OBS-18") + def test_credential_header_value_is_never_logged(self, caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.INFO, logger="dexpace.sdk.core.http") + with Pipeline( + self._client(), policies=[LoggingPolicy(detail_level=HttpLogDetailLevel.HEADERS)] + ) as p: + p.run(_request(), DispatchContext(_instr("0" * 15 + "d1"))) + response_event = _response_events(caplog)[0] + assert "super-secret-token" not in response_event + assert "http.response.header.authorization=REDACTED" in response_event + assert "http.response.header.content-type=application/json" in response_event + + @pytest.mark.req("OBS-17") + def test_header_redactor_is_shared_injectable_into_both_pillars(self) -> None: + from dexpace.sdk.core.pipeline.policies.async_logging_policy import AsyncLoggingPolicy + + shared = HeaderRedactor() + sync_policy = LoggingPolicy(header_redactor=shared) + async_policy = AsyncLoggingPolicy(header_redactor=shared) + # One redaction policy, wired into both pillars, so it cannot drift. + assert sync_policy._header_redactor is shared + assert async_policy._header_redactor is shared + class _RecordingSpan: """In-memory ``Span`` that captures every method call for assertions.""" diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_pipeline.py b/packages/dexpace-sdk-core/tests/pipeline/test_pipeline.py index 12fcba6..5e864ff 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_pipeline.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_pipeline.py @@ -72,6 +72,7 @@ def add_header(request: Request, _ctx: CallContext) -> Request: assert response.is_success assert client.calls[0].headers.get("x-probe") == "1" + @pytest.mark.req("PIPE-12") def test_response_step_modifies_incoming_response(self) -> None: def stamp_response(response: Response, _ctx: CallContext) -> Response: return response.with_header("X-Trace", "abc") @@ -83,6 +84,7 @@ def stamp_response(response: Response, _ctx: CallContext) -> Response: response = p.run(_request(), DispatchContext(_instr("0" * 16 + "b"))) assert response.headers.get("x-trace") == "abc" + @pytest.mark.req("PIPE-12") def test_step_returning_none_aborts(self) -> None: def abort(_request: Request, _ctx: CallContext) -> Request | None: return None @@ -108,6 +110,7 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: class TestPolicyChaining: + @pytest.mark.req("PIPE-12", "PIPE-13") def test_policies_run_in_declared_order(self) -> None: log: list[str] = [] client = _StubClient() @@ -115,6 +118,7 @@ def test_policies_run_in_declared_order(self) -> None: p.run(_request(), DispatchContext(_instr("0" * 16 + "d"))) assert log == ["a-pre", "b-pre", "b-post", "a-post"] + @pytest.mark.req("PIPE-13", "PIPE-39", "PIPE-9") def test_empty_policy_list_passes_through_to_transport(self) -> None: client = _StubClient() with Pipeline(client) as p: @@ -124,6 +128,7 @@ def test_empty_policy_list_passes_through_to_transport(self) -> None: class TestPipelineContext: + @pytest.mark.req("PIPE-9") def test_options_forwarded_via_kwargs(self) -> None: seen: dict[str, object] = {} diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_pure_resilience.py b/packages/dexpace-sdk-core/tests/pipeline/test_pure_resilience.py new file mode 100644 index 0000000..df37cd2 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_pure_resilience.py @@ -0,0 +1,496 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the shared pure resilience modules. + +Covers the single-sourced formulas that the sync and async retry policies (and +error construction) import instead of restating: the retryability classifier +(status table + cause-chain walk), the backoff calculator (deterministic curve ++ jitter), and the pacing-header parsers. +""" + +from __future__ import annotations + +import math +import random + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.pipeline._pure.backoff import ( + DEFAULT_BASE_DELAY, + DEFAULT_MAX_DELAY, + DEFAULT_MULTIPLIER, + apply_jitter, + compute_backoff, +) +from dexpace.sdk.core.pipeline._pure.classifier import ( + DEFAULT_RETRYABLE_STATUS, + Retryable, + default_status_is_retryable, + is_retryable, + status_is_retryable, +) +from dexpace.sdk.core.pipeline._pure.pacing import ( + MAX_RETRY_AFTER_SECONDS, + parse_rate_limit_reset, + parse_retry_after, + parse_retry_after_ms, +) +from dexpace.sdk.tck.golden_corpus import RetryPacingVector, load_retry_pacing + +# ----- classifier: status table ------------------------------------------- + + +class TestStatusIsRetryable: + @pytest.mark.parametrize("status", sorted(DEFAULT_RETRYABLE_STATUS)) + def test_default_set_members_are_retryable(self, status: int) -> None: + assert status_is_retryable(status, DEFAULT_RETRYABLE_STATUS) is True + + def test_default_set_is_the_curated_transient_codes(self) -> None: + # 408 (timeout), 429 (rate limit), and the transient 5xx family; the + # permanent 501/505 are deliberately excluded. + assert frozenset({408, 429, 500, 502, 503, 504}) == DEFAULT_RETRYABLE_STATUS + + @pytest.mark.parametrize("status", [200, 301, 400, 404, 409, 501, 505]) + def test_non_default_codes_are_not_retryable(self, status: int) -> None: + assert status_is_retryable(status, DEFAULT_RETRYABLE_STATUS) is False + + def test_none_status_is_never_retryable(self) -> None: + assert status_is_retryable(None, DEFAULT_RETRYABLE_STATUS) is False + + @pytest.mark.req("RECOV-17") + def test_configurable_set_is_the_authority(self) -> None: + # The passed-in set is consulted, not a hard-coded literal: a bespoke + # set flips both directions relative to the default. + custom = frozenset({418}) + assert status_is_retryable(418, custom) is True + assert status_is_retryable(503, custom) is False + + def test_empty_set_disables_retry(self) -> None: + assert status_is_retryable(503, frozenset()) is False + + +# ----- classifier: canonical baked-flag rule (408, 429, all 5xx but 501/505) - + + +class TestDefaultStatusIsRetryable: + """The single canonical classifier behind a protocol error's baked flag. + + Distinct from ``status_is_retryable`` (the configurable policy gate): this + is the fixed rule ``408, 429, all 5xx EXCEPT 501/505`` (XCUT-5), broader + than the curated ``DEFAULT_RETRYABLE_STATUS`` policy default. + """ + + @pytest.mark.req("XCUT-5") + @pytest.mark.parametrize("status", [408, 429]) + def test_two_retryable_client_codes(self, status: int) -> None: + assert default_status_is_retryable(status) is True + + @pytest.mark.req("XCUT-5") + @pytest.mark.parametrize("status", list(range(500, 600))) + def test_every_5xx_except_501_and_505_is_retryable(self, status: int) -> None: + expected = status not in (501, 505) + assert default_status_is_retryable(status) is expected + + @pytest.mark.req("XCUT-5") + def test_exotic_5xx_are_retryable_unlike_the_narrow_policy_default(self) -> None: + # The exotic 5xx codes the narrow policy default omits still bake True: + # the canonical classifier is a strict superset of the policy default. + for status in (506, 507, 508, 510, 511): + assert default_status_is_retryable(status) is True + assert status not in DEFAULT_RETRYABLE_STATUS + + @pytest.mark.req("XCUT-5") + @pytest.mark.parametrize("status", [200, 301, 400, 404, 409, 428, 499, 600]) + def test_non_retryable_codes(self, status: int) -> None: + assert default_status_is_retryable(status) is False + + def test_none_is_never_retryable(self) -> None: + assert default_status_is_retryable(None) is False + + def test_canonical_rule_is_a_superset_of_the_policy_default(self) -> None: + # Every code the narrow policy default retries is also retryable under + # the canonical rule; the reverse does not hold (the exotic 5xx above). + for status in DEFAULT_RETRYABLE_STATUS: + assert default_status_is_retryable(status) is True + + +# ----- classifier: cause-chain walk ---------------------------------------- + + +class _FlaggedError(Exception): + """Exception carrying an explicit ``retryable`` flag (satisfies Retryable).""" + + def __init__(self, message: str, *, retryable: bool) -> None: + super().__init__(message) + self.retryable = retryable + + +class TestRetryableProtocol: + def test_object_with_flag_satisfies_protocol(self) -> None: + assert isinstance(_FlaggedError("x", retryable=True), Retryable) + + def test_plain_exception_does_not_satisfy_protocol(self) -> None: + assert not isinstance(ValueError("x"), Retryable) + + +class TestIsRetryable: + def test_direct_retryable_true(self) -> None: + assert is_retryable(_FlaggedError("boom", retryable=True)) is True + + def test_direct_retryable_false(self) -> None: + assert is_retryable(_FlaggedError("boom", retryable=False)) is False + + def test_plain_exception_is_not_retryable(self) -> None: + assert is_retryable(ValueError("boom")) is False + + def test_retryable_via_explicit_cause(self) -> None: + outer = ValueError("outer") + outer.__cause__ = _FlaggedError("inner", retryable=True) + assert is_retryable(outer) is True + + def test_retryable_via_implicit_context(self) -> None: + outer = ValueError("outer") + outer.__context__ = _FlaggedError("inner", retryable=True) + assert is_retryable(outer) is True + + @pytest.mark.req("RETRY-2") + def test_retryable_deep_in_chain(self) -> None: + deepest = _FlaggedError("deepest", retryable=True) + middle = ValueError("middle") + middle.__cause__ = deepest + top = ValueError("top") + top.__cause__ = middle + assert is_retryable(top) is True + + @pytest.mark.req("RETRY-2", "XCUT-9") + def test_self_referential_chain_terminates(self) -> None: + exc = ValueError("loop") + exc.__cause__ = exc + assert is_retryable(exc) is False + + @pytest.mark.req("RETRY-2", "XCUT-9") + def test_two_node_cycle_terminates(self) -> None: + first = ValueError("first") + second = ValueError("second") + first.__cause__ = second + second.__cause__ = first + assert is_retryable(first) is False + + @pytest.mark.req("XCUT-9") + def test_cycle_with_retryable_node_returns_true(self) -> None: + retryable = _FlaggedError("retryable", retryable=True) + other = ValueError("other") + other.__cause__ = retryable + retryable.__cause__ = other # cycle, but a retryable node is reachable + assert is_retryable(other) is True + + +# ----- backoff: deterministic curve ---------------------------------------- + + +class TestComputeBackoff: + def test_first_attempt_is_zero(self) -> None: + assert compute_backoff(0, base=1.0, cap=100.0) == 0.0 + assert compute_backoff(1, base=1.0, cap=100.0) == 0.0 + + def test_exponential_growth(self) -> None: + assert compute_backoff(2, base=1.0, cap=1000.0, multiplier=2.0) == 2.0 + assert compute_backoff(3, base=1.0, cap=1000.0, multiplier=2.0) == 4.0 + assert compute_backoff(4, base=1.0, cap=1000.0, multiplier=2.0) == 8.0 + + @pytest.mark.req("RETRY-11") + def test_cap_saturates(self) -> None: + assert compute_backoff(20, base=1.0, cap=8.0, multiplier=2.0) == 8.0 + + @pytest.mark.req("RETRY-43") + def test_fixed_mode_is_constant(self) -> None: + assert compute_backoff(1, base=0.5, cap=100.0, fixed=True) == 0.0 + assert compute_backoff(2, base=0.5, cap=100.0, fixed=True) == 0.5 + assert compute_backoff(9, base=0.5, cap=100.0, fixed=True) == 0.5 + + def test_module_defaults_match_spec(self) -> None: + # 0.2s initial, x2 multiplier, 8s cap. + assert (DEFAULT_BASE_DELAY, DEFAULT_MULTIPLIER, DEFAULT_MAX_DELAY) == (0.2, 2.0, 8.0) + assert compute_backoff(2) == pytest.approx(0.4) + assert compute_backoff(3) == pytest.approx(0.8) + + def test_hand_enumerated_schedule_is_non_decreasing(self) -> None: + delays = [compute_backoff(n, base=0.2, cap=8.0, multiplier=2.0) for n in range(10)] + assert delays == sorted(delays) + assert delays[0] == 0.0 + assert delays[-1] == 8.0 # saturated at the cap + + @pytest.mark.req("RETRY-11") + @given( + attempt=st.integers(min_value=1, max_value=40), + base=st.floats(min_value=0.0, max_value=100.0, allow_nan=False, allow_infinity=False), + cap=st.floats(min_value=0.0, max_value=1000.0, allow_nan=False, allow_infinity=False), + multiplier=st.floats(min_value=1.0, max_value=5.0, allow_nan=False, allow_infinity=False), + ) + def test_monotonic_pre_jitter( + self, attempt: int, base: float, cap: float, multiplier: float + ) -> None: + # The pre-jitter schedule never decreases as the attempt count rises. + current = compute_backoff(attempt, base=base, cap=cap, multiplier=multiplier) + following = compute_backoff(attempt + 1, base=base, cap=cap, multiplier=multiplier) + assert current <= following + + +# ----- backoff: jitter ----------------------------------------------------- + + +class TestApplyJitter: + def test_zero_delay_returns_zero_without_drawing(self) -> None: + class _ExplodingRandom(random.Random): + def uniform(self, a: float, b: float) -> float: # pragma: no cover - must not run + raise AssertionError("jitter must not draw for a zero delay") + + assert apply_jitter(0.0, _ExplodingRandom(), full_jitter=True) == 0.0 + + @pytest.mark.req("RETRY-31") + def test_full_jitter_stays_within_band(self) -> None: + rng = random.Random(7) + for _ in range(200): + result = apply_jitter(10.0, rng, full_jitter=True) + assert 5.0 <= result <= 10.0 + + @pytest.mark.req("RETRY-10") + def test_symmetric_zero_returns_delay_unchanged(self) -> None: + assert apply_jitter(4.0, random.Random(1), full_jitter=False, symmetric=0.0) == 4.0 + + @pytest.mark.req("RETRY-10") + def test_symmetric_band(self) -> None: + rng = random.Random(3) + for _ in range(200): + result = apply_jitter(8.0, rng, full_jitter=False, symmetric=0.25) + assert 6.0 <= result <= 10.0 + + def test_seeded_jitter_is_reproducible(self) -> None: + first = [apply_jitter(5.0, random.Random(99), full_jitter=True) for _ in range(1)] + second = [apply_jitter(5.0, random.Random(99), full_jitter=True) for _ in range(1)] + assert first == second + + +# ----- pacing -------------------------------------------------------------- + +#: 365-day sanity ceiling, restated as a literal so a drift in the production +#: constant is caught here rather than tautologically re-derived from it. +_365_DAYS_SECONDS = 365 * 24 * 60 * 60 + + +class TestParseRetryAfter: + def test_delta_seconds(self) -> None: + assert parse_retry_after("5", now=0.0) == pytest.approx(5.0) + assert parse_retry_after("0", now=0.0) == pytest.approx(0.0) + assert parse_retry_after("0.5", now=0.0) == pytest.approx(0.5) + + def test_surrounding_ascii_whitespace_is_tolerated(self) -> None: + # RFC 7231 OWS around the numeric core is stripped; the core stays strict. + assert parse_retry_after(" 30 ", now=0.0) == pytest.approx(30.0) + assert parse_retry_after("\t30\t", now=0.0) == pytest.approx(30.0) + + def test_http_date_uses_injected_now(self) -> None: + epoch_2000 = 946_684_800.0 + assert parse_retry_after("Sat, 01 Jan 2000 00:00:00 GMT", now=epoch_2000 - 60.0) == ( + pytest.approx(60.0) + ) + + @pytest.mark.req("CFG-30") + def test_http_date_tolerates_zone_aliases_and_informational_weekday(self) -> None: + # The date grammar is shared with util.http_date: tolerant on the + # weekday name / zone alias, strict on the numeric structure. + epoch = 946_684_800.0 # Sat, 01 Jan 2000 00:00:00 UTC + assert parse_retry_after("Sat, 01 Jan 2000 00:00:00 UTC", now=epoch - 10.0) == ( + pytest.approx(10.0) + ) + # A wrong-but-informational weekday name is ignored (only the date counts). + assert parse_retry_after("Mon, 01 Jan 2000 00:00:00 GMT", now=epoch - 10.0) == ( + pytest.approx(10.0) + ) + + @pytest.mark.req("RECOV-23", "RETRY-17") + def test_past_http_date_floors_to_zero_not_none(self) -> None: + # NOTE (past-date -> 0 vs malformed -> None): a date already in the past + # is a *valid* signal meaning "retry now" and floors to 0.0; only an + # unparseable value is the absence-of-signal None. + assert parse_retry_after("Mon, 01 Jan 1990 00:00:00 GMT", now=2_000_000_000.0) == 0.0 + assert parse_retry_after("not-a-date", now=2_000_000_000.0) is None + + @pytest.mark.req("RECOV-23", "RETRY-16") + def test_invalid_and_missing_return_none(self) -> None: + assert parse_retry_after("not-a-date", now=0.0) is None + assert parse_retry_after("", now=0.0) is None + assert parse_retry_after(" ", now=0.0) is None + assert parse_retry_after(None, now=0.0) is None + + @pytest.mark.req("RETRY-19") + @pytest.mark.parametrize( + "token", + # float() would accept every one of these; the strict pre-screen must not. + # The last two are non-ASCII digits (Arabic-Indic and fullwidth "3") + # that float() happily reads as 3 but the ASCII-only grammar rejects. + ["nan", "inf", "+30", "-30", "1_000", "1e3", "0x1p4", "\u0663", "\uff13"], + ) + def test_strict_screen_rejects_what_float_over_accepts(self, token: str) -> None: + assert parse_retry_after(token, now=0.0) is None + + def test_multi_dot_and_trailing_unit_rejected(self) -> None: + assert parse_retry_after("1.2.3", now=0.0) is None + assert parse_retry_after("30d", now=0.0) is None + + @pytest.mark.req("RECOV-26", "RETRY-18") + def test_365_day_clamp_caps_absurd_delta(self) -> None: + # NOTE (365-day clamp): a hint far in the future is capped, not obeyed. + assert parse_retry_after("999999999", now=0.0) == pytest.approx(_365_DAYS_SECONDS) + assert MAX_RETRY_AFTER_SECONDS == _365_DAYS_SECONDS + + def test_max_delay_override_disables_and_tightens_clamp(self) -> None: + # math.inf yields the raw un-clamped delta; a tighter ceiling caps sooner. + assert parse_retry_after("999999999", now=0.0, max_delay=math.inf) == pytest.approx( + 999_999_999.0 + ) + assert parse_retry_after("999999999", now=0.0, max_delay=10.0) == pytest.approx(10.0) + + +class TestParseRetryAfterMs: + """The millisecond-delta parser shared by retry-after-ms / x-ms-retry-after-ms.""" + + @pytest.mark.req("RETRY-15", "RECOV-24") + def test_whole_milliseconds_convert_to_seconds(self) -> None: + assert parse_retry_after_ms("1500") == pytest.approx(1.5) + assert parse_retry_after_ms("0") == pytest.approx(0.0) + assert parse_retry_after_ms("250") == pytest.approx(0.25) + + def test_surrounding_ascii_whitespace_is_tolerated(self) -> None: + assert parse_retry_after_ms(" 2000 ") == pytest.approx(2.0) + assert parse_retry_after_ms("\t2000\t") == pytest.approx(2.0) + + @pytest.mark.req("RETRY-15", "RECOV-24") + def test_missing_blank_or_non_integer_returns_none(self) -> None: + assert parse_retry_after_ms(None) is None + assert parse_retry_after_ms("") is None + assert parse_retry_after_ms(" ") is None + assert parse_retry_after_ms("soon") is None + + @pytest.mark.req("RECOV-24") + @pytest.mark.parametrize( + "token", + # Integer grammar: fractional and every float()-permissive form reject. + # The last two are non-ASCII digits (Arabic-Indic and fullwidth "3"). + ["1.5", "nan", "inf", "+30", "-30", "1_000", "1e3", "0x1p4", "\u0663", "\uff13"], + ) + def test_strict_integer_screen_rejects_non_integers(self, token: str) -> None: + assert parse_retry_after_ms(token) is None + + def test_365_day_clamp_caps_absurd_delay(self) -> None: + assert parse_retry_after_ms(str(10**15)) == pytest.approx(_365_DAYS_SECONDS) + assert parse_retry_after_ms(str(10**15), max_delay=math.inf) == pytest.approx(10**12) + + +class TestParseRateLimitReset: + def test_future_epoch_positive_delay(self) -> None: + assert parse_rate_limit_reset("150", now=100.0) == 50.0 + + @pytest.mark.req("RETRY-17") + def test_past_epoch_floors_at_zero(self) -> None: + assert parse_rate_limit_reset("90", now=100.0) == 0.0 + + def test_missing_blank_or_non_numeric_returns_none(self) -> None: + assert parse_rate_limit_reset(None, now=0.0) is None + assert parse_rate_limit_reset(" ", now=0.0) is None + assert parse_rate_limit_reset("soon", now=0.0) is None + + def test_strict_screen_rejects_float_over_accepted_epochs(self) -> None: + for token in ("nan", "inf", "1_000", "+150", "1e3"): + assert parse_rate_limit_reset(token, now=0.0) is None + + @pytest.mark.req("RETRY-18") + def test_365_day_clamp_caps_absurd_reset(self) -> None: + # A reset epoch absurdly far in the future is capped at the 365-day ceiling. + assert parse_rate_limit_reset("999999999999", now=0.0) == pytest.approx(_365_DAYS_SECONDS) + + +class TestPacingPrecedence: + @pytest.mark.req("RETRY-21") + def test_retry_after_is_authoritative_over_rate_limit_reset(self) -> None: + # NOTE (precedence order): the resolution order is Retry-After (seconds + # or HTTP-date), then X-RateLimit-Reset. Enforced in + # RetryPolicy._server_delay and proven end-to-end in + # test_retry_tuning.TestRateLimitResetHonored; here we pin the pure + # building blocks the policy composes in that order. + now = 1_000.0 + retry_after = parse_retry_after("5", now) + reset = parse_rate_limit_reset("9999999", now) + assert retry_after is not None and reset is not None + # A present Retry-After wins; the reset value is only the fallback. + resolved = retry_after if retry_after is not None else reset + assert resolved == pytest.approx(5.0) + # With Retry-After absent/unparseable, the reset fallback is consulted. + absent = parse_retry_after("garbage", now) + assert (absent if absent is not None else reset) == reset + + +class TestPacingGoldenCorpus: + """Point the production parser at every ``retry_pacing`` corpus vector. + + The corpus (RETRY-19 family) is the byte-exact source of truth for the + pacing grammar; ``test_golden_corpus`` validates the fixtures with stdlib + oracles, while this suite proves the shipped ``parse_retry_after`` agrees + with every one of them — including that no reject vector ever raises. + """ + + @pytest.mark.req("RETRY-19") + @pytest.mark.parametrize("vector", load_retry_pacing(), ids=lambda v: v.id) + def test_vector_matches_production_parser(self, vector: RetryPacingVector) -> None: + token = vector.raw.decode("ascii") + now = vector.now if vector.now is not None else 0.0 + # max_delay=inf recovers the pre-clamp seconds the vector pins; the call + # must never raise, for any vector (rejects included). + raw_parsed = parse_retry_after(token, now, max_delay=math.inf) + if vector.kind == "reject": + assert raw_parsed is None, vector.id + assert parse_retry_after(token, now) is None, vector.id + return + assert raw_parsed == pytest.approx(vector.seconds), vector.id + if vector.clamp_max is not None: + clamped = parse_retry_after(token, now, max_delay=vector.clamp_max) + assert clamped == pytest.approx(vector.clamped), vector.id + # The default 365-day ceiling reproduces the pinned clamp result. + assert parse_retry_after(token, now) == pytest.approx(vector.clamped), vector.id + else: + # No clamp expected: the default ceiling leaves the value untouched. + assert parse_retry_after(token, now) == pytest.approx(vector.seconds), vector.id + + def test_reject_vectors_diverge_from_float(self) -> None: + # Directly pins the "screened before float()" property against the + # corpus's own float_accepts flag: every reject that float would take. + for vector in load_retry_pacing(): + if vector.kind != "reject" or not vector.float_accepts: + continue + token = vector.raw.decode("ascii") + assert isinstance(float(token), float) # float() accepts it (no raise)... + assert parse_retry_after(token, now=0.0) is None, vector.id # ...the parser won't + + +class TestPacingTotality: + """The parsers are total: they return None or a sane delay, never raise.""" + + @pytest.mark.req("RECOV-23", "RECOV-26", "RECOV-29", "RETRY-16", "RETRY-22") + @given( + value=st.one_of( + st.none(), + st.text(), + st.binary().map(lambda b: b.decode("latin-1")), + ), + now=st.floats(allow_nan=False, allow_infinity=False, min_value=-1e12, max_value=1e12), + ) + def test_never_raises_for_arbitrary_input(self, value: str | None, now: float) -> None: + for result in (parse_retry_after(value, now), parse_rate_limit_reset(value, now)): + assert result is None or ( + isinstance(result, float) + and math.isfinite(result) + and 0.0 <= result <= MAX_RETRY_AFTER_SECONDS + ) diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_recovery.py b/packages/dexpace-sdk-core/tests/pipeline/test_recovery.py new file mode 100644 index 0000000..bd0ce2e --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_recovery.py @@ -0,0 +1,609 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Recovery-fold battery: outcome sum type, conversion / close / non-collapse. + +Pins the internal recovery machinery that folds request/response-step +exceptions into a ``Success | Failure`` outcome: + +- **Conversion.** Every ``Exception`` from a producer / response-side step + converts to a ``Failure``; a recovery step runs on every outcome; a step + that throws while handling an outcome converts-and-continues. +- **Non-collapse (the sharp edge).** ``asyncio.CancelledError`` and + ``KeyboardInterrupt`` are ``BaseException``s, not ``Exception``s, and must + never fold into a ``Failure``; they propagate unconverted, with any held + response closed first. +- **Identity.** The final unwrap re-raises the exact original exception + instance, not a copy or wrapper. +- **Close-during-error-handling.** A close failure while unwinding a held + response is chained onto the original error, never lost or promoted. +- **Status mapping.** Only 400-599 is an error status; the error body is + buffered up to a shared cap inside a scope that still guarantees close. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Iterator + +import pytest + +from dexpace.sdk.core.errors import HttpResponseError, ResourceNotFoundError +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, Response, ResponseBody, Status +from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody +from dexpace.sdk.core.http.response.loggable_response_body import LoggableResponseBody +from dexpace.sdk.core.pipeline import PipelineContext +from dexpace.sdk.core.pipeline._async_recovery import ( + afrom_status, + amap_success, + aproduce, + arecover, +) +from dexpace.sdk.core.pipeline._async_sansio_runner import _AsyncSansIOResponseRunner +from dexpace.sdk.core.pipeline._outcome import Failure, Success, unwrap +from dexpace.sdk.core.pipeline._recovery import ( + from_status, + map_success, + produce, + recover, +) +from dexpace.sdk.core.pipeline._sansio_runner import _SansIOResponseRunner + +# --------------------------------------------------------------------------- # +# Close-tracking / failing response bodies # +# --------------------------------------------------------------------------- # + + +class _Body(ResponseBody): + """Sync response body: counts closes; can fail on ``close`` / ``iter_bytes``.""" + + __slots__ = ("_data", "_fail_close", "_fail_read", "close_count") + + def __init__( + self, + data: bytes = b"", + *, + fail_close: bool = False, + fail_read: bool = False, + ) -> None: + self._data = data + self._fail_close = fail_close + self._fail_read = fail_read + self.close_count = 0 + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return len(self._data) + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + if self._fail_read: + raise OSError("read blew up") + for start in range(0, len(self._data), chunk_size): + yield self._data[start : start + chunk_size] + + def close(self) -> None: + self.close_count += 1 + if self._fail_close: + raise RuntimeError("close blew up") + + @property + def closed(self) -> bool: + return self.close_count > 0 + + +class _AsyncBody(AsyncResponseBody): + """Async twin of ``_Body``.""" + + __slots__ = ("_data", "_fail_close", "_fail_read", "close_count") + + def __init__( + self, + data: bytes = b"", + *, + fail_close: bool = False, + fail_read: bool = False, + ) -> None: + self._data = data + self._fail_close = fail_close + self._fail_read = fail_read + self.close_count = 0 + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return len(self._data) + + async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + if self._fail_read: + raise OSError("read blew up") + for start in range(0, len(self._data), chunk_size): + yield self._data[start : start + chunk_size] + + async def close(self) -> None: + self.close_count += 1 + if self._fail_close: + raise RuntimeError("close blew up") + + @property + def closed(self) -> bool: + return self.close_count > 0 + + +def _request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://example.com/x")) + + +def _response(status: Status = Status.OK, body: ResponseBody | None = None) -> Response: + return Response(request=_request(), protocol=Protocol.HTTP_1_1, status=status, body=body) + + +def _async_response( + status: Status = Status.OK, body: AsyncResponseBody | None = None +) -> AsyncResponse: + return AsyncResponse(request=_request(), protocol=Protocol.HTTP_1_1, status=status, body=body) + + +# --------------------------------------------------------------------------- # +# produce: Exception -> Failure; BaseException propagates unconverted # +# --------------------------------------------------------------------------- # + + +class TestProduce: + @pytest.mark.req("RECOV-2") + def test_success_wraps_response(self) -> None: + response = _response() + outcome = produce(lambda: response) + assert isinstance(outcome, Success) + assert outcome.value is response + + @pytest.mark.req("RECOV-1", "RECOV-2") + def test_exception_folds_to_failure_preserving_identity(self) -> None: + boom = ValueError("boom") + + def source() -> Response: + raise boom + + outcome = produce(source) + assert isinstance(outcome, Failure) + assert outcome.error is boom + + def test_keyboard_interrupt_propagates_unconverted(self) -> None: + def source() -> Response: + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + produce(source) + + @pytest.mark.req("RECOV-11") + def test_cancelled_error_propagates_unconverted(self) -> None: + def source() -> Response: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + produce(source) + + +# --------------------------------------------------------------------------- # +# map_success: runs only on Success; closes held response on failure # +# --------------------------------------------------------------------------- # + + +class TestMapSuccess: + @pytest.mark.req("RECOV-4") + def test_runs_on_success(self) -> None: + stamped = _response(Status.ACCEPTED) + outcome = map_success(Success(_response()), lambda _r: stamped) + assert isinstance(outcome, Success) + assert outcome.value is stamped + + @pytest.mark.req("RECOV-4") + def test_skips_a_failure(self) -> None: + failure: Failure = Failure(ValueError("x")) + called = False + + def step(_r: Response) -> Response: + nonlocal called + called = True + return _r + + assert map_success(failure, step) is failure + assert called is False + + @pytest.mark.req("RECOV-7") + def test_exception_closes_held_response_and_folds(self) -> None: + body = _Body() + held = _response(body=body) + boom = ValueError("step failed") + + def step(_r: Response) -> Response: + raise boom + + outcome = map_success(Success(held), step) + assert isinstance(outcome, Failure) + assert outcome.error is boom + assert body.close_count == 1 # held response closed before folding + + def test_keyboard_interrupt_closes_held_then_propagates(self) -> None: + body = _Body() + held = _response(body=body) + + def step(_r: Response) -> Response: + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + map_success(Success(held), step) + assert body.close_count == 1 # held response closed FIRST + + @pytest.mark.req("RECOV-12") + def test_close_failure_during_error_handling_is_chained(self) -> None: + body = _Body(fail_close=True) + held = _response(body=body) + boom = ValueError("step failed") + + def step(_r: Response) -> Response: + raise boom + + outcome = map_success(Success(held), step) + assert isinstance(outcome, Failure) + assert outcome.error is boom # original wins, not the close error + notes = getattr(outcome.error, "__notes__", []) + assert any("close" in note for note in notes) # close error chained on + + +# --------------------------------------------------------------------------- # +# recover: runs on every outcome # +# --------------------------------------------------------------------------- # + + +class TestRecover: + @pytest.mark.req("RECOV-5", "RECOV-9") + def test_runs_on_both_outcomes(self) -> None: + seen: list[object] = [] + + def cleanup(outcome: Success[Response] | Failure) -> Success[Response] | Failure: + seen.append(outcome) + return outcome + + success = Success(_response()) + failure = Failure(ValueError("x")) + assert recover(success, cleanup) is success + assert recover(failure, cleanup) is failure + assert seen == [success, failure] + + @pytest.mark.req("RECOV-8", "RECOV-9") + def test_exception_on_success_closes_held_and_folds(self) -> None: + body = _Body() + held = Success(_response(body=body)) + boom = RuntimeError("cleanup failed") + + def cleanup(_o: Success[Response] | Failure) -> Success[Response] | Failure: + raise boom + + outcome = recover(held, cleanup) + assert isinstance(outcome, Failure) + assert outcome.error is boom + assert body.close_count == 1 + + +# --------------------------------------------------------------------------- # +# unwrap: identity-preserving re-raise # +# --------------------------------------------------------------------------- # + + +class TestUnwrap: + @pytest.mark.req("RECOV-10") + def test_success_returns_response(self) -> None: + response = _response() + assert unwrap(Success(response)) is response + + @pytest.mark.req("PIPE-31", "RECOV-1", "RECOV-10") + def test_failure_reraises_original_instance(self) -> None: + original = ValueError("the one true error") + outcome = produce(_raise(original)) + try: + unwrap(outcome) + except ValueError as caught: + assert caught is original # exact object, not a copy/wrapper + else: # pragma: no cover - unwrap must raise + pytest.fail("unwrap did not re-raise the failure") + + +def _raise(exc: BaseException) -> Callable[[], Response]: + def _source() -> Response: + raise exc + + return _source + + +async def _async_error_body(error: HttpResponseError) -> bytes: + """Read the buffered body off an async error response for assertions.""" + response = error.response + assert isinstance(response, AsyncResponse) + assert response.body is not None + return await response.body.bytes() + + +# --------------------------------------------------------------------------- # +# from_status: 400-599 error band, buffered body, guaranteed close # +# --------------------------------------------------------------------------- # + + +class TestFromStatus: + @pytest.mark.req("PIPE-37", "RECOV-13", "RECOV-15") + def test_success_status_passes_through_open(self) -> None: + body = _Body(b"ok") + response = _response(Status.OK, body) + outcome = from_status(response) + assert isinstance(outcome, Success) + assert outcome.value is response + assert body.close_count == 0 # success is handed back open + + @pytest.mark.req("RECOV-15") + def test_redirect_status_is_not_an_error(self) -> None: + response = _response(Status.MOVED_PERMANENTLY, _Body()) + assert isinstance(from_status(response), Success) + + @pytest.mark.req("RECOV-13", "RECOV-15") + def test_client_error_folds_and_closes(self) -> None: + body = _Body(b'{"detail": "nope"}') + response = _response(Status.NOT_FOUND, body) + outcome = from_status(response) + assert isinstance(outcome, Failure) + assert isinstance(outcome.error, HttpResponseError) + assert outcome.error.status is Status.NOT_FOUND + assert body.close_count == 1 # error response closed + + def test_server_error_folds(self) -> None: + response = _response(Status.INTERNAL_SERVER_ERROR, _Body(b"kaboom")) + outcome = from_status(response) + assert isinstance(outcome, Failure) + assert isinstance(outcome.error, HttpResponseError) + + @pytest.mark.req("RECOV-16") + def test_error_body_is_buffered_for_postmortem(self) -> None: + response = _response(Status.BAD_REQUEST, _Body(b"the error detail")) + outcome = from_status(response) + assert isinstance(outcome, Failure) + error = outcome.error + assert isinstance(error, HttpResponseError) + buffered = error.response + assert buffered is not None + assert isinstance(buffered.body, LoggableResponseBody) + assert error.body_snapshot() == b"the error detail" + + @pytest.mark.req("RECOV-16") + def test_error_body_is_capped(self) -> None: + response = _response(Status.BAD_REQUEST, _Body(b"x" * 50)) + outcome = from_status(response, max_body_bytes=8) + assert isinstance(outcome, Failure) + assert isinstance(outcome.error, HttpResponseError) + assert outcome.error.body_snapshot() == b"x" * 8 + + def test_error_map_selects_subclass(self) -> None: + response = _response(Status.NOT_FOUND, _Body(b"")) + outcome = from_status(response, error_map={404: ResourceNotFoundError}) + assert isinstance(outcome, Failure) + assert isinstance(outcome.error, ResourceNotFoundError) + + def test_close_guaranteed_even_if_buffering_fails(self) -> None: + body = _Body(fail_read=True) + response = _response(Status.SERVICE_UNAVAILABLE, body) + outcome = from_status(response) + assert isinstance(outcome, Failure) # still produces the HTTP error + assert isinstance(outcome.error, HttpResponseError) + assert body.close_count == 1 # ... and the response was still closed + notes = getattr(outcome.error, "__notes__", []) + assert any("buffer" in note.lower() for note in notes) # read failure noted + + +# --------------------------------------------------------------------------- # +# Async twin: CancelledError immunity + close-first # +# --------------------------------------------------------------------------- # + + +class TestAsyncRecovery: + async def test_aproduce_success(self) -> None: + response = _async_response() + + async def source() -> AsyncResponse: + return response + + outcome = await aproduce(source) + assert isinstance(outcome, Success) + assert outcome.value is response + + @pytest.mark.req("PIPE-29", "PIPE-30") + async def test_aproduce_exception_folds(self) -> None: + boom = ValueError("boom") + + async def source() -> AsyncResponse: + raise boom + + outcome = await aproduce(source) + assert isinstance(outcome, Failure) + assert outcome.error is boom + + @pytest.mark.req("PIPE-30", "RECOV-11") + async def test_amap_cancelled_closes_held_then_propagates(self) -> None: + body = _AsyncBody() + held = _async_response(body=body) + + async def step(_r: AsyncResponse) -> AsyncResponse: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await amap_success(Success(held), step) + assert body.close_count == 1 # held response closed FIRST, never folded + + @pytest.mark.req("PIPE-31") + async def test_amap_exception_closes_and_folds(self) -> None: + body = _AsyncBody() + held = _async_response(body=body) + boom = ValueError("async step failed") + + async def step(_r: AsyncResponse) -> AsyncResponse: + raise boom + + outcome = await amap_success(Success(held), step) + assert isinstance(outcome, Failure) + assert outcome.error is boom + assert body.close_count == 1 + + @pytest.mark.req("PIPE-31") + async def test_amap_close_failure_chained(self) -> None: + body = _AsyncBody(fail_close=True) + held = _async_response(body=body) + boom = ValueError("async step failed") + + async def step(_r: AsyncResponse) -> AsyncResponse: + raise boom + + outcome = await amap_success(Success(held), step) + assert isinstance(outcome, Failure) + assert outcome.error is boom + notes = getattr(outcome.error, "__notes__", []) + assert any("close" in note for note in notes) + + async def test_arecover_runs_on_both(self) -> None: + seen: list[object] = [] + + async def cleanup( + outcome: Success[AsyncResponse] | Failure, + ) -> Success[AsyncResponse] | Failure: + seen.append(outcome) + return outcome + + success = Success(_async_response()) + failure = Failure(ValueError("x")) + assert await arecover(success, cleanup) is success + assert await arecover(failure, cleanup) is failure + assert seen == [success, failure] + + async def test_afrom_status_error_buffers_and_closes(self) -> None: + body = _AsyncBody(b"async detail") + response = _async_response(Status.NOT_FOUND, body) + outcome = await afrom_status(response) + assert isinstance(outcome, Failure) + assert isinstance(outcome.error, HttpResponseError) + assert body.close_count == 1 + # No async LoggableResponseBody exists, so the buffered prefix is exposed + # on the re-bodied response rather than through the sync-only body_snapshot. + assert await _async_error_body(outcome.error) == b"async detail" + + async def test_afrom_status_error_body_is_capped(self) -> None: + response = _async_response(Status.BAD_REQUEST, _AsyncBody(b"y" * 40)) + outcome = await afrom_status(response, max_body_bytes=6) + assert isinstance(outcome, Failure) + assert isinstance(outcome.error, HttpResponseError) + assert await _async_error_body(outcome.error) == b"y" * 6 + + async def test_afrom_status_close_guaranteed_when_buffering_fails(self) -> None: + body = _AsyncBody(fail_read=True) + response = _async_response(Status.INTERNAL_SERVER_ERROR, body) + outcome = await afrom_status(response) + assert isinstance(outcome, Failure) + assert body.close_count == 1 + + +# --------------------------------------------------------------------------- # +# Hardened SansIO response runners: original error wins over a close failure # +# --------------------------------------------------------------------------- # + + +class _Chain(_SansIOResponseRunner): + """Wire a synthetic ``next`` onto a response runner for direct testing.""" + + +class TestResponseRunnerCloseChaining: + @pytest.mark.req("RECOV-12") + def test_sync_step_error_wins_over_close_failure(self) -> None: + body = _Body(fail_close=True) + response = _response(body=body) + boom = ValueError("response step failed") + + def step(_r: Response, _ctx: object) -> Response: + raise boom + + runner = _SansIOResponseRunner(step) # type: ignore[arg-type] + runner.next = _StubNext(response) # type: ignore[assignment] + with pytest.raises(ValueError) as excinfo: + runner.send(_request(), _ctx()) + assert excinfo.value is boom # original propagates, not the close error + assert body.close_count == 1 + assert any("close" in note for note in getattr(boom, "__notes__", [])) + + def test_sync_cancelled_propagates_and_closes(self) -> None: + body = _Body() + response = _response(body=body) + + def step(_r: Response, _ctx: object) -> Response: + raise asyncio.CancelledError + + runner = _SansIOResponseRunner(step) # type: ignore[arg-type] + runner.next = _StubNext(response) # type: ignore[assignment] + with pytest.raises(asyncio.CancelledError): + runner.send(_request(), _ctx()) + assert body.close_count == 1 + + async def test_async_step_error_wins_over_close_failure(self) -> None: + body = _AsyncBody(fail_close=True) + response = _async_response(body=body) + boom = ValueError("async response step failed") + + async def step(_r: AsyncResponse, _ctx: object) -> AsyncResponse: + raise boom + + runner = _AsyncSansIOResponseRunner(step) + runner.next = _AsyncStubNext(response) # type: ignore[assignment] + with pytest.raises(ValueError) as excinfo: + await runner.send(_request(), _ctx()) + assert excinfo.value is boom + assert body.close_count == 1 + assert any("close" in note for note in getattr(boom, "__notes__", [])) + + +class _StubNext: + """A minimal ``next`` node returning a canned response.""" + + def __init__(self, response: Response) -> None: + self._response = response + + def send(self, request: Request, ctx: PipelineContext) -> Response: + return self._response + + +class _AsyncStubNext: + def __init__(self, response: AsyncResponse) -> None: + self._response = response + + async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: + return self._response + + +def _ctx() -> PipelineContext: + from dexpace.sdk.core.http.context import DispatchContext + from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, + ) + from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN + + instr = InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId("0" * 32), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + dispatch = DispatchContext(instr) + return PipelineContext(call=dispatch.to_request_context(_request())) diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_redirect.py b/packages/dexpace-sdk-core/tests/pipeline/test_redirect.py index 1a45b37..9f71aec 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_redirect.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_redirect.py @@ -122,6 +122,7 @@ def _run(client: _ScriptedClient, policy: RedirectPolicy, request: Request) -> R class TestStatusCodeMatrix: + @pytest.mark.req("REDIR-1", "REDIR-3") def test_301_follows_get(self) -> None: client = _ScriptedClient( [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], @@ -133,6 +134,7 @@ def test_301_follows_get(self) -> None: assert client.requests[1].method is Method.GET assert str(client.requests[1].url) == "https://example.com/new" + @pytest.mark.req("REDIR-3") def test_301_does_not_follow_post_when_not_allowed(self) -> None: client = _ScriptedClient( [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new")], @@ -142,6 +144,7 @@ def test_301_does_not_follow_post_when_not_allowed(self) -> None: assert response.status is Status.MOVED_PERMANENTLY assert len(client.requests) == 1 + @pytest.mark.req("REDIR-3") def test_302_follows_with_original_method(self) -> None: client = _ScriptedClient( [_Hop(Status.FOUND, "https://example.com/new"), _Hop(Status.OK)], @@ -177,6 +180,7 @@ def test_303_follow_303_false_does_not_follow(self) -> None: assert response.status is Status.SEE_OTHER assert len(client.requests) == 1 + @pytest.mark.req("REDIR-4") def test_307_follows_with_original_method_and_body(self) -> None: body = RequestBody.from_bytes(b"payload") request = _request(method=Method.POST, body=body).with_header( @@ -196,6 +200,7 @@ def test_307_follows_with_original_method_and_body(self) -> None: assert reissued.body is not None assert b"".join(reissued.body.iter_bytes()) == b"payload" + @pytest.mark.req("BODY-4", "REDIR-6") def test_307_with_non_replayable_body_raises(self) -> None: body = RequestBody.from_iter(iter([b"chunk"])) request = _request(method=Method.POST, body=body) @@ -232,6 +237,7 @@ def test_307_non_replayable_body_closes_intermediate_response(self) -> None: _run(client, policy, request) assert tracking_body.closed + @pytest.mark.req("REDIR-4") def test_308_follows_with_original_method_and_body(self) -> None: body = RequestBody.from_bytes(b"payload") request = _request(method=Method.PUT, body=body) @@ -248,6 +254,7 @@ def test_308_follows_with_original_method_and_body(self) -> None: assert reissued.body is not None assert b"".join(reissued.body.iter_bytes()) == b"payload" + @pytest.mark.req("REDIR-1", "REDIR-21") def test_non_3xx_pass_through(self) -> None: client = _ScriptedClient([_Hop(Status.OK)]) policy = RedirectPolicy() @@ -255,6 +262,7 @@ def test_non_3xx_pass_through(self) -> None: assert response.status is Status.OK assert len(client.requests) == 1 + @pytest.mark.req("REDIR-1", "REDIR-2") def test_other_3xx_not_followed(self) -> None: # 304 NOT MODIFIED is 3xx but not in the redirect matrix. client = _ScriptedClient([_Hop(Status.NOT_MODIFIED)]) @@ -265,6 +273,7 @@ def test_other_3xx_not_followed(self) -> None: class TestHopAndLoopGuards: + @pytest.mark.req("REDIR-17") def test_max_hops_respected(self) -> None: # Three redirects, but max_hops=2 — second redirect response is final. hops = [ @@ -280,6 +289,7 @@ def test_max_hops_respected(self) -> None: assert len(client.requests) == 3 assert response.status is Status.MOVED_PERMANENTLY + @pytest.mark.req("REDIR-16") def test_loop_detection_returns_current_response(self) -> None: # /a -> /b -> /a (already visited; stop without raising) hops = [ @@ -296,18 +306,19 @@ def test_loop_detection_returns_current_response(self) -> None: class TestSecurity: - def test_authorization_kept_on_same_origin_redirect(self) -> None: - # A same-origin hop (here a path change on the same host/scheme/port) - # keeps a caller-set Authorization header: stripping it would - # de-authenticate the request against the very service that issued the - # redirect (e.g. a trailing-slash 301). + @pytest.mark.req("REDIR-7", "XCUT-17") + def test_authorization_stripped_on_same_origin_redirect(self) -> None: + # The Authorization header is stripped before EVERY reissue — even a + # same-origin path change. Re-stamping a credential for a known origin + # is the auth layer's job (a downstream auth policy re-applies it for a + # same-origin reissue), not the redirect layer's. client = _ScriptedClient( [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], ) policy = RedirectPolicy(strip_authorization=True) response = _run(client, policy, _request(auth="Bearer secret")) assert response.is_success - assert client.requests[1].headers.get("Authorization") == "Bearer secret" + assert "Authorization" not in client.requests[1].headers # Original request kept its header. assert client.requests[0].headers.get("Authorization") == "Bearer secret" @@ -322,34 +333,64 @@ def test_authorization_stripped_on_cross_origin_redirect(self) -> None: assert response.is_success assert "Authorization" not in client.requests[1].headers - def test_authorization_stripped_on_scheme_downgrade(self) -> None: - # http -> https (or vice versa) is a cross-origin change even with the - # same host, so the header is stripped. + @pytest.mark.req("REDIR-15", "XCUT-17") + def test_scheme_downgrade_not_followed_by_default(self) -> None: + # An HTTPS->HTTP downgrade is rejected by default: the hop is not + # followed and the current 3xx response is handed back, so the TLS + # guarantee cannot silently disappear across a redirect. client = _ScriptedClient( [_Hop(Status.FOUND, "http://example.com/new"), _Hop(Status.OK)], ) policy = RedirectPolicy(strip_authorization=True) - _run(client, policy, _request(url="https://example.com/start", auth="Bearer secret")) + response = _run( + client, policy, _request(url="https://example.com/start", auth="Bearer secret") + ) + assert response.status is Status.FOUND + assert len(client.requests) == 1 + + @pytest.mark.req("REDIR-15", "XCUT-17") + def test_scheme_downgrade_followed_when_opted_in_strips_credentials(self) -> None: + # allow_scheme_downgrade=True follows the downgrade; credential + # stripping still applies (the scheme change is cross-origin). + client = _ScriptedClient( + [_Hop(Status.FOUND, "http://example.com/new"), _Hop(Status.OK)], + ) + policy = RedirectPolicy(strip_authorization=True, allow_scheme_downgrade=True) + request = _request(url="https://example.com/start", auth="Bearer secret").with_header( + "Cookie", "session=abc" + ) + _run(client, policy, request) + assert len(client.requests) == 2 assert "Authorization" not in client.requests[1].headers + assert "Cookie" not in client.requests[1].headers - def test_authorization_stripped_on_port_change(self) -> None: - # Same scheme and host but a non-default port is a different origin. + @pytest.mark.req("REDIR-8", "REDIR-9") + def test_cookie_stripped_on_port_change(self) -> None: + # Same scheme and host but a non-default port is a different origin, so + # the origin-scoped Cookie is stripped. client = _ScriptedClient( [_Hop(Status.FOUND, "https://example.com:8443/new"), _Hop(Status.OK)], ) policy = RedirectPolicy(strip_authorization=True) - _run(client, policy, _request(url="https://example.com/start", auth="Bearer secret")) - assert "Authorization" not in client.requests[1].headers + request = _request(url="https://example.com/start").with_header("Cookie", "session=abc") + _run(client, policy, request) + assert "Cookie" not in client.requests[1].headers - def test_authorization_kept_on_explicit_default_port_redirect(self) -> None: + @pytest.mark.req("REDIR-8") + def test_cookie_kept_on_explicit_default_port_redirect(self) -> None: # An explicit :443 against an implied default https port is the same - # origin, so the header survives. + # origin (port normalized to the scheme default), so the origin-scoped + # Cookie survives while Authorization is still stripped every reissue. client = _ScriptedClient( [_Hop(Status.MOVED_PERMANENTLY, "https://example.com:443/new"), _Hop(Status.OK)], ) policy = RedirectPolicy(strip_authorization=True) - _run(client, policy, _request(url="https://example.com/start", auth="Bearer secret")) - assert client.requests[1].headers.get("Authorization") == "Bearer secret" + request = _request(url="https://example.com/start", auth="Bearer secret").with_header( + "Cookie", "session=abc" + ) + _run(client, policy, request) + assert client.requests[1].headers.get("Cookie") == "session=abc" + assert "Authorization" not in client.requests[1].headers def test_authorization_stripped_on_cross_origin_303_get(self) -> None: # The 303 GET-rewrite path also honours cross-origin stripping. @@ -360,23 +401,29 @@ def test_authorization_stripped_on_cross_origin_303_get(self) -> None: _run(client, policy, _request(method=Method.POST, auth="Bearer secret")) assert "Authorization" not in client.requests[1].headers - def test_authorization_kept_on_same_origin_303_get(self) -> None: + @pytest.mark.req("REDIR-7", "XCUT-17") + def test_authorization_stripped_on_same_origin_303_get(self) -> None: + # The 303 GET rebuild strips Authorization too — even same-origin. client = _ScriptedClient( [_Hop(Status.SEE_OTHER, "https://example.com/new"), _Hop(Status.OK)], ) policy = RedirectPolicy(strip_authorization=True) _run(client, policy, _request(method=Method.POST, auth="Bearer secret")) - assert client.requests[1].headers.get("Authorization") == "Bearer secret" + assert "Authorization" not in client.requests[1].headers - def test_strip_authorization_false_preserves_header_cross_origin(self) -> None: - # strip_authorization=False never strips, even across origins. + def test_strip_authorization_false_preserves_headers_cross_origin(self) -> None: + # strip_authorization=False never strips any credential, even across + # origins — the caller has audited every destination in the chain. client = _ScriptedClient( [_Hop(Status.MOVED_PERMANENTLY, "https://other.example.org/new"), _Hop(Status.OK)], ) policy = RedirectPolicy(strip_authorization=False) - _run(client, policy, _request(auth="Bearer secret")) + request = _request(auth="Bearer secret").with_header("Cookie", "session=abc") + _run(client, policy, request) assert client.requests[1].headers.get("Authorization") == "Bearer secret" + assert client.requests[1].headers.get("Cookie") == "session=abc" + @pytest.mark.req("REDIR-12", "XCUT-17") def test_userinfo_in_location_dropped(self) -> None: client = _ScriptedClient( [ @@ -391,6 +438,7 @@ def test_userinfo_in_location_dropped(self) -> None: assert reissued.url.host == "example.com" assert reissued.url.path == "/new" + @pytest.mark.req("REDIR-14") def test_relative_location_resolved(self) -> None: client = _ScriptedClient( [_Hop(Status.MOVED_PERMANENTLY, "/elsewhere"), _Hop(Status.OK)], @@ -406,6 +454,7 @@ def test_stage_is_redirect(self) -> None: class TestMissingLocation: + @pytest.mark.req("REDIR-19") def test_redirect_without_location_returns_response(self) -> None: # 301 with no Location header — nothing to redirect to. client = _ScriptedClient([_Hop(Status.MOVED_PERMANENTLY)]) @@ -413,3 +462,18 @@ def test_redirect_without_location_returns_response(self) -> None: response = _run(client, policy, _request()) assert response.status is Status.MOVED_PERMANENTLY assert len(client.requests) == 1 + + +class TestMalformedLocation: + @pytest.mark.req("REDIR-18") + def test_malformed_location_returns_response_open_not_closed(self) -> None: + # A Location that cannot be resolved to an absolute URL must not raise + # (leaking the connection). The policy returns the current 3xx response + # unfollowed and open — the same disposition as loop / max-hops. + tracking_body = _TrackingBody() + client = _ScriptedClient([_Hop(Status.FOUND, "http://[malformed", body=tracking_body)]) + policy = RedirectPolicy() + response = _run(client, policy, _request()) + assert response.status is Status.FOUND + assert not tracking_body.closed + assert len(client.requests) == 1 diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_redirect_auth_origin.py b/packages/dexpace-sdk-core/tests/pipeline/test_redirect_auth_origin.py index d06bd25..cb983e5 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_redirect_auth_origin.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_redirect_auth_origin.py @@ -7,8 +7,9 @@ auth policies cannot reach in isolation: when the redirect policy (outermost) reissues a request to a different origin and the auth policy (inner) sees that reissued request, the credential must not follow the request to the foreign -host. They also pin that a caller-set ``Authorization`` header survives a -same-origin hop but is dropped on a cross-origin one. +host. On a same-origin hop the auth policy re-stamps the credential the redirect +layer stripped, so an ordinary authed redirect still carries it; a caller-set +``Authorization`` with no auth policy is stripped on every reissue. """ from __future__ import annotations @@ -16,6 +17,8 @@ import time from collections.abc import Sequence +import pytest + from dexpace.sdk.core.client.http_client import HttpClient from dexpace.sdk.core.http.auth import ( AccessTokenInfo, @@ -113,6 +116,7 @@ def _run(client: _ScriptedClient, policies: Sequence[object], trace: str) -> Res # --------------------------------------------------------------------------- # +@pytest.mark.req("REDIR-24") def test_bearer_token_not_reissued_to_cross_origin_redirect_target() -> None: # The redirect policy (outermost) 302s to a foreign host; the bearer policy # (inner) sees the reissued request but must NOT re-stamp the token onto it. @@ -230,9 +234,14 @@ def test_basic_auth_reissued_same_origin() -> None: assert client.requests[1].headers.get("authorization") == "Basic dXNlcjpwYXNz" -def test_caller_authorization_kept_same_origin_dropped_cross_origin() -> None: - # No auth policy at all: a caller-set Authorization header. The redirect - # policy keeps it on a same-origin 301 and drops it on a cross-origin 302. +@pytest.mark.req("REDIR-7", "XCUT-17") +def test_caller_authorization_stripped_on_every_reissue_without_auth_policy() -> None: + # No auth policy at all: a caller-set Authorization header is stripped + # before EVERY reissue — same-origin included — because re-stamping a + # credential for a known origin is the auth layer's job, not the redirect + # layer's. With no auth policy in the chain, nothing re-applies it, so the + # reissued request carries no Authorization on either a same-origin or a + # cross-origin hop. same_origin = _ScriptedClient( [ _Hop(Status.MOVED_PERMANENTLY, "https://api.example.com/start/"), @@ -242,7 +251,7 @@ def test_caller_authorization_kept_same_origin_dropped_cross_origin() -> None: req = _request().with_header("Authorization", "Bearer caller-set") with Pipeline(same_origin, policies=[RedirectPolicy()]) as p: p.run(req, DispatchContext(_instr("0" * 15 + "8"))) - assert same_origin.requests[1].headers.get("authorization") == "Bearer caller-set" + assert "authorization" not in same_origin.requests[1].headers cross_origin = _ScriptedClient( [ diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_retry.py b/packages/dexpace-sdk-core/tests/pipeline/test_retry.py index 6ab44a6..018de11 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_retry.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_retry.py @@ -5,7 +5,9 @@ from __future__ import annotations +import logging import random +import threading import time from collections.abc import Sequence from typing import Any @@ -15,6 +17,7 @@ from dexpace.sdk.core.client.http_client import HttpClient from dexpace.sdk.core.errors import ( ClientAuthenticationError, + SdkError, ServiceRequestError, ServiceResponseError, ) @@ -33,8 +36,9 @@ ) from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN from dexpace.sdk.core.pipeline import Pipeline +from dexpace.sdk.core.pipeline._pure.classifier import DEFAULT_RETRYABLE_STATUS +from dexpace.sdk.core.pipeline._pure.pacing import parse_retry_after from dexpace.sdk.core.pipeline.policies import RetryPolicy -from dexpace.sdk.core.pipeline.policies.retry import _parse_retry_after from ..conftest import FakeClock @@ -82,6 +86,7 @@ def execute(self, request: Request) -> Response: class TestRetryOnStatus: + @pytest.mark.req("RECOV-19") def test_retries_503_on_get(self) -> None: client = _ScriptedClient([Status.SERVICE_UNAVAILABLE, Status.OK]) retry = RetryPolicy(clock=FakeClock()) @@ -90,6 +95,7 @@ def test_retries_503_on_get(self) -> None: assert response.status is Status.OK assert client.attempts == 2 + @pytest.mark.req("RECOV-19") def test_does_not_retry_404(self) -> None: client = _ScriptedClient([Status.NOT_FOUND]) retry = RetryPolicy(clock=FakeClock()) @@ -98,6 +104,7 @@ def test_does_not_retry_404(self) -> None: assert response.status is Status.NOT_FOUND assert client.attempts == 1 + @pytest.mark.req("BODY-5") def test_post_retried_only_on_500_503_504(self) -> None: client = _ScriptedClient([Status.BAD_REQUEST]) retry = RetryPolicy(clock=FakeClock()) @@ -124,7 +131,68 @@ def test_status_retry_budget_exhausted(self) -> None: assert response.status is Status.SERVICE_UNAVAILABLE +class TestCustomRetryStatusSet: + """The configurable ``retry_on_status_codes`` set drives the retry decision. + + The default set (``408/429/500/502/503/504``) is the single authority + shared with the classifier and ``HttpResponseError.retryable``; a caller + may override it end to end. These tests exercise the actual retry loop, not + just the pure classifier, so a regression that ignored the configured set + would be caught here. + """ + + @pytest.mark.req("XCUT-7") + def test_default_set_is_exactly_the_curated_codes(self) -> None: + # The policy default is the shared classifier default verbatim — the + # permanent 501/505 are deliberately absent so retrying a request that + # can never succeed is not attempted. + retry = RetryPolicy(clock=FakeClock()) + assert retry.retry_on_status_codes == DEFAULT_RETRYABLE_STATUS + assert retry.retry_on_status_codes == frozenset({408, 429, 500, 502, 503, 504}) + assert 501 not in retry.retry_on_status_codes + assert 505 not in retry.retry_on_status_codes + + @pytest.mark.parametrize( + "status", + [Status.NOT_IMPLEMENTED, Status.HTTP_VERSION_NOT_SUPPORTED], + ids=["501", "505"], + ) + def test_default_policy_does_not_retry_permanent_5xx(self, status: Status) -> None: + # The permanent-failure exclusion survives end to end: an idempotent GET + # returning 501/505 is passed straight back, never retried. + client = _ScriptedClient([status]) + retry = RetryPolicy(clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_get(), DispatchContext(_instr("0" * 15 + "50"))) + assert client.attempts == 1 + assert response.status is status + + @pytest.mark.req("RETRY-37", "XCUT-7") + def test_custom_set_honors_explicitly_included_501(self) -> None: + # A caller who *explicitly* opts 501 into their own set is honored — the + # default merely excludes it, it is not hard-blocked. The GET is retried + # and eventually succeeds. + client = _ScriptedClient([Status.NOT_IMPLEMENTED, Status.OK]) + retry = RetryPolicy(retry_on_status_codes={501}, clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_get(), DispatchContext(_instr("0" * 15 + "51"))) + assert client.attempts == 2 + assert response.status is Status.OK + + @pytest.mark.req("RECOV-17", "RETRY-37", "XCUT-7") + def test_custom_set_excludes_a_default_code(self) -> None: + # Narrowing the set to ``{500}`` drops 503 from the retryable codes, so a + # 503 that the default set *would* retry is now returned on first sight. + client = _ScriptedClient([Status.SERVICE_UNAVAILABLE]) + retry = RetryPolicy(retry_on_status_codes={500}, clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_get(), DispatchContext(_instr("0" * 15 + "52"))) + assert client.attempts == 1 + assert response.status is Status.SERVICE_UNAVAILABLE + + class TestRetryOnError: + @pytest.mark.req("RECOV-17", "RETRY-37", "RETRY-39", "RETRY-4") def test_retries_connect_error(self) -> None: client = _ScriptedClient( [ServiceRequestError("dns fail"), Status.OK], @@ -135,6 +203,7 @@ def test_retries_connect_error(self) -> None: assert response.is_success assert client.attempts == 2 + @pytest.mark.req("RETRY-4") def test_retries_response_error(self) -> None: client = _ScriptedClient( [ServiceResponseError("connection reset"), Status.OK], @@ -157,6 +226,7 @@ def test_short_circuits_client_authentication_error(self) -> None: # Only one attempt — auth failures are not retried. assert client.attempts == 1 + @pytest.mark.req("RECOV-29") def test_connect_retry_budget_exhausted(self) -> None: client = _ScriptedClient( [ServiceRequestError("fail")] * 5, @@ -168,15 +238,18 @@ def test_connect_retry_budget_exhausted(self) -> None: class TestReadPhaseMethodGating: - """Read-phase errors are retried per the same idempotency rule as status. - - A ``ServiceResponseError`` is a read-phase failure: the request may have - been fully processed before the read broke, so re-sending a non-idempotent - method (POST/PATCH) risks duplicating the write. A ``ServiceRequestError`` - is a connect-phase failure — the request never left the client — so it is - safe to retry for every method. + """Retry-safety is decided uniformly for every transport failure. + + The method-idempotency and body-replayability axes gate every retry + candidate, connect-phase and read-phase alike. A body-less non-idempotent + method (a bare POST/PATCH) is therefore never retried on *any* transport + error — not even a connect-phase ``ServiceRequestError`` that never left + the client — because retry-safety is a property of the request, decided + independently of which transport phase failed (XCUT-10). An idempotent GET + stays retryable on both phases. """ + @pytest.mark.req("BODY-5", "RETRY-7") def test_post_not_retried_on_read_phase_error(self) -> None: # A ``ServiceResponseError`` on a POST must propagate after the first # attempt rather than re-sending and risking a duplicate write. @@ -186,16 +259,30 @@ def test_post_not_retried_on_read_phase_error(self) -> None: p.run(_post(), DispatchContext(_instr("0" * 15 + "40"))) assert client.attempts == 1 - def test_post_still_retried_on_connect_phase_error(self) -> None: - # A ``ServiceRequestError`` means the request never left the client, so - # re-sending a POST is safe and must still happen. + @pytest.mark.req("XCUT-10") + def test_post_not_retried_on_connect_phase_error(self) -> None: + # XCUT-10: the safety gate MUST NOT special-case transport errors. A + # bare POST is not in the idempotent-method set, so even a connect-phase + # ``ServiceRequestError`` — which never reached the server — must NOT be + # retried; it propagates after the first attempt. + client = _ScriptedClient([ServiceRequestError("dns fail"), Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p, pytest.raises(ServiceRequestError): + p.run(_post(), DispatchContext(_instr("0" * 15 + "41"))) + assert client.attempts == 1 + + @pytest.mark.req("XCUT-10") + def test_get_still_retried_on_connect_phase_error(self) -> None: + # The uniform gate does not suppress idempotent methods: a GET is in the + # allowlist, so a connect-phase error is still retried to success. client = _ScriptedClient([ServiceRequestError("dns fail"), Status.OK]) retry = RetryPolicy(clock=FakeClock()) with Pipeline(client, policies=[retry]) as p: - response = p.run(_post(), DispatchContext(_instr("0" * 15 + "41"))) + response = p.run(_get(), DispatchContext(_instr("0" * 15 + "43"))) assert response.is_success assert client.attempts == 2 + @pytest.mark.req("BODY-5") def test_get_still_retried_on_read_phase_error(self) -> None: # GET is idempotent and in the allowlist, so a read-phase error is # still retried — the gating only suppresses non-idempotent methods. @@ -209,20 +296,20 @@ def test_get_still_retried_on_read_phase_error(self) -> None: class TestRetryAfterHeader: def test_parse_delta_seconds(self) -> None: - assert _parse_retry_after("5", now=0.0) == pytest.approx(5.0) - assert _parse_retry_after("0", now=0.0) == pytest.approx(0.0) - assert _parse_retry_after("0.5", now=0.0) == pytest.approx(0.5) + assert parse_retry_after("5", now=0.0) == pytest.approx(5.0) + assert parse_retry_after("0", now=0.0) == pytest.approx(0.0) + assert parse_retry_after("0.5", now=0.0) == pytest.approx(0.5) def test_parse_http_date_future(self) -> None: # Far-future date should be a positive delta. - result = _parse_retry_after("Sun, 06 Nov 2099 08:49:37 GMT", now=0.0) + result = parse_retry_after("Sun, 06 Nov 2099 08:49:37 GMT", now=0.0) assert result is not None assert result > 0 def test_parse_http_date_past_clamps_to_zero(self) -> None: # ``now`` is well past the 1990 header instant, so the delta is # negative and clamps to zero. - result = _parse_retry_after("Mon, 01 Jan 1990 00:00:00 GMT", now=2_000_000_000.0) + result = parse_retry_after("Mon, 01 Jan 1990 00:00:00 GMT", now=2_000_000_000.0) assert result == pytest.approx(0.0) def test_parse_http_date_uses_injected_now(self) -> None: @@ -230,17 +317,18 @@ def test_parse_http_date_uses_injected_now(self) -> None: # injected clock, not the wall clock. 2000-01-01T00:00:00Z is epoch # 946684800; with ``now`` set 60s before it, the delay is exactly 60s. epoch_2000 = 946_684_800.0 - result = _parse_retry_after( + result = parse_retry_after( "Sat, 01 Jan 2000 00:00:00 GMT", now=epoch_2000 - 60.0, ) assert result == pytest.approx(60.0) def test_parse_invalid_returns_none(self) -> None: - assert _parse_retry_after("not-a-date", now=0.0) is None - assert _parse_retry_after("", now=0.0) is None - assert _parse_retry_after(None, now=0.0) is None + assert parse_retry_after("not-a-date", now=0.0) is None + assert parse_retry_after("", now=0.0) is None + assert parse_retry_after(None, now=0.0) is None + @pytest.mark.req("RECOV-22", "RETRY-20", "RETRY-39") def test_respected_during_retry(self) -> None: sleeps: list[float] = [] @@ -280,6 +368,7 @@ def test_http_date_uses_injected_clock_not_wall_clock(self) -> None: class TestRetryHistory: + @pytest.mark.req("RECOV-28") def test_history_recorded_in_ctx_data(self) -> None: from dexpace.sdk.core.pipeline import PipelineContext, Policy @@ -303,6 +392,7 @@ def test_no_retries_factory(self) -> None: retry = RetryPolicy.no_retries() assert retry.total_retries == 0 + @pytest.mark.req("RETRY-41") def test_no_retries_lets_first_failure_through(self) -> None: client = _ScriptedClient([Status.SERVICE_UNAVAILABLE]) retry = RetryPolicy.no_retries() @@ -314,6 +404,7 @@ def test_no_retries_lets_first_failure_through(self) -> None: class TestRetryTimeout: + @pytest.mark.req("RETRY-41") def test_per_call_override_via_options(self) -> None: client = _ScriptedClient([Status.SERVICE_UNAVAILABLE] * 5) retry = RetryPolicy(clock=FakeClock()) @@ -352,6 +443,7 @@ def execute(self, request: Request) -> Response: class TestRetryAutoReplaysBody: + @pytest.mark.req("BODY-4", "RETRY-44") def test_retry_with_single_use_body_auto_replays(self) -> None: consumed: list[bytes] = [] body = RequestBody.from_iter(iter([b"hello", b"world"])) @@ -504,6 +596,7 @@ def send(self, request: Request, ctx: PipelineContext) -> Response: # ``retry_count`` is never written into ``ctx.data``. assert captured["retry_count"] is None + @pytest.mark.req("RECOV-28") def test_retry_count_set_when_retry_happens(self) -> None: from dexpace.sdk.core.pipeline import PipelineContext, Policy @@ -566,6 +659,7 @@ def test_monotonic_clock_available() -> None: assert time.monotonic() > 0 +@pytest.mark.req("RETRY-36") def test_retry_advances_clock_by_backoff_seconds() -> None: """``RetryPolicy`` advances the injected clock by the computed backoff. @@ -625,3 +719,243 @@ def test_retry_clock_jitter_seeded() -> None: assert sequence_a == sequence_b # And the values are non-trivial (jitter actually varied them). assert len(set(sequence_a)) > 1 + + +class _CustomRetryableError(SdkError): + """A non-protocol custom SDK error advertising retryability via capability. + + It is neither a ``ServiceRequestError``/``ServiceResponseError`` nor a + status-carrying ``HttpResponseError`` — only the structural ``retryable`` + flag marks it retryable, exercising the open-capability path. + """ + + retryable = True + + +class _CustomTerminalError(SdkError): + """A custom SDK error that declares itself non-retryable via the capability.""" + + retryable = False + + +class TestOpenCapabilityRetry: + """Custom/transport-family errors participate via the retryability capability. + + The retry step queries the capability (``is_retryable`` over the cause + chain), not a concrete-type match, so an error type the classifier has + never heard of can opt into retries (XCUT-6). A status-carrying protocol + error is handled by the configured status set instead, never by this path. + """ + + @pytest.mark.req("XCUT-6") + def test_custom_error_advertising_retryable_is_retried(self) -> None: + client = _ScriptedClient([_CustomRetryableError("boom"), Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_get(), DispatchContext(_instr("0" * 15 + "80"))) + assert response.is_success + assert client.attempts == 2 + + @pytest.mark.req("XCUT-6") + def test_custom_error_not_advertising_retryable_is_terminal(self) -> None: + client = _ScriptedClient([_CustomTerminalError("nope"), Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p, pytest.raises(_CustomTerminalError): + p.run(_get(), DispatchContext(_instr("0" * 15 + "81"))) + assert client.attempts == 1 + + @pytest.mark.req("XCUT-6") + def test_capability_retry_is_bounded_by_the_connect_budget(self) -> None: + # The capability path is charged to the connect sub-budget, so a + # never-clearing custom retryable error stops after connect_retries. + client = _ScriptedClient([_CustomRetryableError("boom")] * 6) + retry = RetryPolicy(connect_retries=2, clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p, pytest.raises(_CustomRetryableError): + p.run(_get(), DispatchContext(_instr("0" * 15 + "82"))) + # 1 initial + 2 connect retries = 3 attempts before the budget is spent. + assert client.attempts == 3 + + @pytest.mark.req("XCUT-6", "XCUT-10") + def test_capability_retry_still_honours_the_uniform_safety_gate(self) -> None: + # A capability-retryable error on a bare POST is NOT retried: the + # method-idempotency safety gate applies uniformly to this path too. + client = _ScriptedClient([_CustomRetryableError("boom"), Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p, pytest.raises(_CustomRetryableError): + p.run(_post(), DispatchContext(_instr("0" * 15 + "83"))) + assert client.attempts == 1 + + +class TestRetryConfigValidation: + """Scalar configuration is validated and rejected at construction (RECOV-34).""" + + @pytest.mark.req("RECOV-34") + @pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"total_retries": -1}, "total_retries"), + ({"connect_retries": -1}, "connect_retries"), + ({"read_retries": -1}, "read_retries"), + ({"status_retries": -1}, "status_retries"), + ({"backoff_factor": -0.1}, "backoff_factor"), + ({"backoff_max": -1.0}, "backoff_max"), + ({"timeout": -1.0}, "timeout"), + ({"retry_after_max": -1.0}, "retry_after_max"), + ({"backoff_factor": float("nan")}, "backoff_factor"), + ({"backoff_max": float("inf")}, "backoff_max"), + ({"jitter": 1.5}, "jitter"), + ({"jitter": -0.1}, "jitter"), + ], + ) + def test_rejects_invalid_scalar(self, kwargs: dict[str, Any], match: str) -> None: + with pytest.raises(ValueError, match=match): + RetryPolicy(**kwargs) + + @pytest.mark.req("RECOV-34") + def test_boundary_values_are_accepted(self) -> None: + # Zero counts/durations disable an axis but are valid; jitter is valid at + # both ends of the closed interval [0.0, 1.0]. + RetryPolicy( + total_retries=0, + connect_retries=0, + read_retries=0, + status_retries=0, + backoff_factor=0.0, + backoff_max=0.0, + timeout=0.0, + retry_after_max=0.0, + jitter=0.0, + ) + RetryPolicy(jitter=1.0) + + @pytest.mark.req("RECOV-34") + def test_collection_settings_are_defensively_copied(self) -> None: + # Later mutation of the caller's source collections cannot alter the + # already-constructed policy's behaviour. + statuses = {500, 503} + methods = {"GET", "PUT"} + retry = RetryPolicy(retry_on_status_codes=statuses, method_allowlist=methods) + statuses.add(418) + methods.add("POST") + assert 418 not in retry.retry_on_status_codes + assert "POST" not in retry.method_allowlist + assert retry.retry_on_status_codes == frozenset({500, 503}) + + +class TestFatalErrorPropagation: + """Non-SDK fatal errors surface unchanged, unretried, and untrailed (RETRY-25).""" + + @pytest.mark.req("RETRY-25") + @pytest.mark.parametrize( + "fatal", + [MemoryError("out of memory"), RecursionError("stack overflow")], + ids=["MemoryError", "RecursionError"], + ) + def test_non_sdk_fatal_propagates_unretried_untrailed( + self, fatal: BaseException, caplog: pytest.LogCaptureFixture + ) -> None: + client = _ScriptedClient([fatal, Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with ( + caplog.at_level(logging.DEBUG), + Pipeline(client, policies=[retry]) as p, + pytest.raises(type(fatal)) as excinfo, + ): + p.run(_get(), DispatchContext(_instr("0" * 15 + "90"))) + # Surfaced unchanged (exact instance), never retried, and never + # classified as a retryable condition. + assert excinfo.value is fatal + assert client.attempts == 1 + # No suppressed-trail attachment: neither the ``attempts`` count nor the + # per-attempt ``add_note`` trail is stamped onto a fatal error. + assert not hasattr(fatal, "attempts") + assert getattr(fatal, "__notes__", []) == [] + # Not logged by the retry loop (the "retrying after ..." debug line only + # fires on the retry path, which a fatal error never takes). + assert not any( + record.name == "dexpace.sdk.core.pipeline.policies.retry" for record in caplog.records + ) + + +class _ConcurrentClient(HttpClient): + """Thread-safe client: fails a request ``fail_times`` then succeeds. + + Keyed by request URL so each concurrent caller (distinct URL) has an + independent attempt tally — the shared counter is guarded by a lock. + """ + + def __init__(self, fail_times: int) -> None: + self._fail_times = fail_times + self._seen: dict[str, int] = {} + self._lock = threading.Lock() + self.total_attempts = 0 + + def execute(self, request: Request) -> Response: + key = str(request.url) + with self._lock: + seen = self._seen.get(key, 0) + self._seen[key] = seen + 1 + self.total_attempts += 1 + status = Status.OK if seen >= self._fail_times else Status.SERVICE_UNAVAILABLE + return Response(request=request, protocol=Protocol.HTTP_1_1, status=status) + + +class TestRetryStatelessness: + """One policy instance is stateless and safe to invoke concurrently (RETRY-42).""" + + @pytest.mark.req("RETRY-42") + def test_single_instance_safe_under_concurrent_calls(self) -> None: + # backoff_factor=0 keeps every delay zero, so no thread mutates the + # shared clock — the run isolates policy statelessness, not clock races. + retry = RetryPolicy(backoff_factor=0.0, clock=FakeClock()) + client = _ConcurrentClient(fail_times=1) + results: dict[int, Status] = {} + failures: list[BaseException] = [] + lock = threading.Lock() + + with Pipeline(client, policies=[retry]) as pipeline: + + def worker(index: int) -> None: + try: + request = Request( + method=Method.GET, url=Url.parse(f"https://example.com/{index}") + ) + response = pipeline.run(request, DispatchContext(_instr(f"{index:032x}"))) + with lock: + results[index] = response.status + except BaseException as error: # surfaced to the assertion + with lock: + failures.append(error) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(24)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert not failures + assert len(results) == 24 + assert all(status is Status.OK for status in results.values()) + # Each call: one 503 then one 200 = two transport attempts, independently. + assert client.total_attempts == 48 + # The shared instance's frozen knobs are untouched by the concurrent run. + assert retry.total_retries == 10 + assert retry.connect_retries == 3 + + @pytest.mark.req("RETRY-42") + def test_repeated_calls_on_one_instance_are_independent(self) -> None: + # Repeatedly invoking one policy instance (one pipeline) carries no + # per-call state — attempt count, deadline, and history — between + # sequential calls: each distinct URL fails once then succeeds cleanly. + retry = RetryPolicy(backoff_factor=0.0, clock=FakeClock()) + client = _ConcurrentClient(fail_times=1) + with Pipeline(client, policies=[retry]) as pipeline: + for index in range(3): + request = Request( + method=Method.GET, url=Url.parse(f"https://example.com/seq/{index}") + ) + response = pipeline.run(request, DispatchContext(_instr(f"{index:032x}"))) + assert response.is_success + # Three calls, each one 503 + one 200 = six independent attempts. + assert client.total_attempts == 6 + assert retry.total_retries == 10 diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_retry_completion.py b/packages/dexpace-sdk-core/tests/pipeline/test_retry_completion.py new file mode 100644 index 0000000..034eea2 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_retry_completion.py @@ -0,0 +1,480 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Completion contracts for the sync and async retry loops. + +Certifies the finished retry mechanics that both stacks must share: + +- exact attempt accounting (``max_retries=2`` sends exactly three times); +- prompt cancellation of the backoff wait — cooperative (sync + ``CancellationToken``) and native (async task cancellation); +- the two-axis re-send safety gate applied uniformly to HTTP-status- and + transport-error-triggered retries (a bare POST is not retried, a + non-replayable body is never re-sent regardless of method); +- a terminal-failure attempt trail carried on the surfaced exception (an + ``attempts`` count plus prior-attempt notes, excluding the exception itself); +- byte-identical backoff schedules across the two stacks for a shared seed. +""" + +from __future__ import annotations + +import asyncio +import random +import threading +import time +from collections.abc import Iterator, Sequence + +import pytest + +from dexpace.sdk.core.client.async_http_client import AsyncHttpClient +from dexpace.sdk.core.client.http_client import HttpClient +from dexpace.sdk.core.errors import ( + RequestCancelledError, + ServiceRequestError, + ServiceResponseError, +) +from dexpace.sdk.core.http.common import MediaType, Protocol, Url +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.request.request_body import RequestBody +from dexpace.sdk.core.http.response import AsyncResponse, Response, Status +from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN +from dexpace.sdk.core.pipeline import AsyncPipeline, Pipeline +from dexpace.sdk.core.pipeline.policies import RetryPolicy +from dexpace.sdk.core.pipeline.policies.async_retry import AsyncRetryPolicy +from dexpace.sdk.core.util.clock import ASYNC_SYSTEM_CLOCK, CancellationToken + +from ..conftest import FakeClock + + +def _instr(trace: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + + +def _get() -> Request: + return Request(method=Method.GET, url=Url.parse("https://example.com/")) + + +def _post(body: RequestBody | None = None) -> Request: + return Request(method=Method.POST, url=Url.parse("https://example.com/"), body=body) + + +def _attempts(error: BaseException) -> int: + """Read the ``attempts`` count the retry loop stamps on a terminal failure. + + The count is set dynamically via ``setattr`` (no static attribute exists on + the exception types), so the test reads it out of the instance ``__dict__``. + """ + return int(error.__dict__["attempts"]) + + +class _AsyncFakeClock: + """Deterministic ``AsyncClock``; advances simulated time on sleep.""" + + __slots__ = ("_t",) + + def __init__(self, start: float = 0.0) -> None: + self._t = start + + def now(self) -> float: + return self._t + + def monotonic(self) -> float: + return self._t + + async def sleep(self, duration: float) -> None: + self._t += max(0.0, duration) + + +class _ScriptedClient(HttpClient): + """Returns one response or raises one error per call, in order.""" + + def __init__( + self, + outcomes: Sequence[Status | BaseException], + retry_after: str | None = None, + ) -> None: + self._outcomes = list(outcomes) + self._retry_after = retry_after + self.attempts = 0 + + def execute(self, request: Request) -> Response: + outcome = self._outcomes[self.attempts] + self.attempts += 1 + if isinstance(outcome, BaseException): + raise outcome + response = Response(request=request, protocol=Protocol.HTTP_1_1, status=outcome) + if self._retry_after is not None and not outcome.is_success: + response = response.with_header("Retry-After", self._retry_after) + return response + + +class _AsyncScriptedClient(AsyncHttpClient): + """Async twin of ``_ScriptedClient``.""" + + def __init__( + self, + outcomes: Sequence[Status | BaseException], + retry_after: str | None = None, + ) -> None: + self._outcomes = list(outcomes) + self._retry_after = retry_after + self.attempts = 0 + + async def execute(self, request: Request) -> AsyncResponse: + outcome = self._outcomes[self.attempts] + self.attempts += 1 + if isinstance(outcome, BaseException): + raise outcome + response = AsyncResponse(request=request, protocol=Protocol.HTTP_1_1, status=outcome) + if self._retry_after is not None and not outcome.is_success: + response = response.with_header("Retry-After", self._retry_after) + return response + + +class _NonReplayableBody(RequestBody): + """A body that cannot be buffered — ``to_replayable`` refuses to copy it. + + Models a payload the caller declared un-bufferable (an unbounded stream that + must not be held in memory). ``is_replayable`` stays ``False`` even after + ``to_replayable``, so the retry loop must never re-send it. + """ + + def __init__(self) -> None: + self.consumed = 0 + + def media_type(self) -> MediaType | None: + return None + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + self.consumed += 1 + yield b"payload" + + def is_replayable(self) -> bool: + return False + + def to_replayable(self) -> RequestBody: + return self + + +# ----- exact attempt accounting ------------------------------------------- + + +class TestExactlyThreeSends: + @pytest.mark.req("RECOV-19", "RETRY-14", "RETRY-44") + def test_sync_max_retries_two_sends_exactly_three_times(self) -> None: + client = _ScriptedClient([Status.SERVICE_UNAVAILABLE] * 5) + retry = RetryPolicy(total_retries=2, backoff_factor=0.0, clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_get(), DispatchContext(_instr("0" * 15 + "50"))) + # 1 initial + 2 retries = exactly 3 sends; no off-by-one. + assert client.attempts == 3 + assert response.status is Status.SERVICE_UNAVAILABLE + + @pytest.mark.req("RETRY-14", "RETRY-30") + async def test_async_max_retries_two_sends_exactly_three_times(self) -> None: + client = _AsyncScriptedClient([Status.SERVICE_UNAVAILABLE] * 5) + retry = AsyncRetryPolicy(total_retries=2, backoff_factor=0.0, clock=_AsyncFakeClock()) + async with AsyncPipeline(client, policies=[retry]) as p: + response = await p.run(_get(), DispatchContext(_instr("0" * 15 + "51"))) + assert client.attempts == 3 + assert response.status is Status.SERVICE_UNAVAILABLE + + +# ----- prompt cancellation of the backoff wait ---------------------------- + + +class TestCancelDuringBackoff: + @pytest.mark.req("XCUT-3") + def test_sync_precancelled_token_aborts_at_attempt_boundary(self) -> None: + # An already-cancelled token is observed at the attempt boundary before + # the first send: cooperative cancellation means "do not start". + token = CancellationToken() + token.cancel() + client = _ScriptedClient([Status.SERVICE_UNAVAILABLE, Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with ( + Pipeline(client, policies=[retry]) as p, + pytest.raises(RequestCancelledError), + ): + p.run( + _get(), + DispatchContext(_instr("0" * 15 + "52")), + cancellation=token, + ) + assert client.attempts == 0 + + @pytest.mark.req("RECOV-27", "RETRY-23", "RETRY-26", "XCUT-3") + def test_sync_cancel_mid_backoff_aborts_promptly(self) -> None: + token = CancellationToken() + # Real 30s backoff via a server ``Retry-After``; a timer cancels the + # token ~50ms in. ``CancellationToken.sleep`` (threading.Event.wait) + # must wake immediately, so the wait aborts far inside the 30s window. + client = _ScriptedClient([Status.SERVICE_UNAVAILABLE, Status.OK], retry_after="30") + retry = RetryPolicy(clock=FakeClock()) + timer = threading.Timer(0.05, token.cancel) + timer.start() + start = time.monotonic() + try: + with ( + Pipeline(client, policies=[retry]) as p, + pytest.raises(RequestCancelledError), + ): + p.run( + _get(), + DispatchContext(_instr("0" * 15 + "53")), + cancellation=token, + ) + finally: + timer.cancel() + elapsed = time.monotonic() - start + # Generous epsilon: the point is it did not wait the full 30s. + assert elapsed < 5.0 + assert client.attempts == 1 + + @pytest.mark.req("RECOV-27", "RETRY-26", "RETRY-31", "XCUT-3") + async def test_async_cancel_mid_backoff_aborts_immediately(self) -> None: + # Real ``asyncio.sleep`` backoff of 30s (server ``Retry-After``); the + # task is cancelled while parked in it and must unwind at once. + client = _AsyncScriptedClient( + [Status.SERVICE_UNAVAILABLE, Status.OK], + retry_after="30", + ) + retry = AsyncRetryPolicy(clock=ASYNC_SYSTEM_CLOCK) + + async def _drive() -> AsyncResponse: + async with AsyncPipeline(client, policies=[retry]) as p: + return await p.run(_get(), DispatchContext(_instr("0" * 15 + "54"))) + + task = asyncio.ensure_future(_drive()) + # Let the first send land and the loop enter the 30s backoff. + await asyncio.sleep(0.1) + start = time.monotonic() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + elapsed = time.monotonic() - start + assert elapsed < 1.0 + assert client.attempts == 1 + + +# ----- two-axis re-send safety gate --------------------------------------- + + +class TestBarePostNeverRetried: + @pytest.mark.req("RECOV-18", "RETRY-5", "RETRY-7", "RETRY-8") + def test_sync_bare_post_not_retried_on_retryable_status(self) -> None: + # 429 is in the default retryable set, but POST is only ever retried on + # 500/503/504 — a bare POST on 429 is not re-sent. + client = _ScriptedClient([Status.TOO_MANY_REQUESTS, Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_post(), DispatchContext(_instr("0" * 15 + "55"))) + assert client.attempts == 1 + assert response.status is Status.TOO_MANY_REQUESTS + + async def test_async_bare_post_not_retried_on_retryable_status(self) -> None: + client = _AsyncScriptedClient([Status.TOO_MANY_REQUESTS, Status.OK]) + retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) + async with AsyncPipeline(client, policies=[retry]) as p: + response = await p.run(_post(), DispatchContext(_instr("0" * 15 + "56"))) + assert client.attempts == 1 + assert response.status is Status.TOO_MANY_REQUESTS + + +class TestNonReplayableBodyNeverResent: + @pytest.mark.req("RECOV-18", "RETRY-5", "RETRY-8") + def test_sync_status_trigger_does_not_resend_non_replayable_body(self) -> None: + body = _NonReplayableBody() + client = _ScriptedClient([Status.SERVICE_UNAVAILABLE, Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p: + response = p.run(_post(body), DispatchContext(_instr("0" * 15 + "57"))) + # 503 would retry a replayable POST, but the body cannot be replayed. + assert client.attempts == 1 + assert response.status is Status.SERVICE_UNAVAILABLE + + def test_sync_transport_trigger_does_not_resend_non_replayable_body(self) -> None: + # PUT is idempotent and a connect-phase error is otherwise always + # retried — but a non-replayable body overrides method idempotency. + body = _NonReplayableBody() + client = _ScriptedClient([ServiceRequestError("dns fail"), Status.OK]) + retry = RetryPolicy(clock=FakeClock()) + request = Request(method=Method.PUT, url=Url.parse("https://example.com/"), body=body) + with ( + Pipeline(client, policies=[retry]) as p, + pytest.raises(ServiceRequestError), + ): + p.run(request, DispatchContext(_instr("0" * 15 + "58"))) + assert client.attempts == 1 + + @pytest.mark.req("RETRY-5") + async def test_async_status_trigger_does_not_resend_non_replayable_body(self) -> None: + body = _NonReplayableBody() + client = _AsyncScriptedClient([Status.SERVICE_UNAVAILABLE, Status.OK]) + retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) + async with AsyncPipeline(client, policies=[retry]) as p: + response = await p.run(_post(body), DispatchContext(_instr("0" * 15 + "59"))) + assert client.attempts == 1 + assert response.status is Status.SERVICE_UNAVAILABLE + + async def test_async_transport_trigger_does_not_resend_non_replayable_body(self) -> None: + body = _NonReplayableBody() + client = _AsyncScriptedClient([ServiceRequestError("dns fail"), Status.OK]) + retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) + request = Request(method=Method.PUT, url=Url.parse("https://example.com/"), body=body) + with pytest.raises(ServiceRequestError): + async with AsyncPipeline(client, policies=[retry]) as p: + await p.run(request, DispatchContext(_instr("0" * 15 + "5a"))) + assert client.attempts == 1 + + +# ----- terminal-failure attempt trail ------------------------------------- + + +class TestTerminalFailureTrail: + @pytest.mark.req("RETRY-22", "RETRY-34") + def test_sync_surfaced_error_carries_attempts_and_prior_notes(self) -> None: + errors: list[BaseException] = [ + ServiceRequestError("boom-1"), + ServiceRequestError("boom-2"), + ServiceRequestError("boom-3"), + ] + client = _ScriptedClient(errors) + retry = RetryPolicy(connect_retries=2, backoff_factor=0.0, clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p, pytest.raises(ServiceRequestError) as info: + p.run(_get(), DispatchContext(_instr("0" * 15 + "5b"))) + err = info.value + # 1 initial + 2 retries = 3 attempts, all failed. + assert _attempts(err) == 3 + notes = getattr(err, "__notes__", []) + # Two PRIOR attempts noted; the surfaced error never notes itself. + assert len(notes) == 2 + assert any("boom-1" in note for note in notes) + assert any("boom-2" in note for note in notes) + assert all("boom-3" not in note for note in notes) + + @pytest.mark.req("RETRY-34") + async def test_async_surfaced_error_carries_attempts_and_prior_notes(self) -> None: + errors: list[BaseException] = [ + ServiceRequestError("boom-1"), + ServiceRequestError("boom-2"), + ServiceRequestError("boom-3"), + ] + client = _AsyncScriptedClient(errors) + retry = AsyncRetryPolicy(connect_retries=2, backoff_factor=0.0, clock=_AsyncFakeClock()) + with pytest.raises(ServiceRequestError) as info: + async with AsyncPipeline(client, policies=[retry]) as p: + await p.run(_get(), DispatchContext(_instr("0" * 15 + "5c"))) + err = info.value + assert _attempts(err) == 3 + notes = getattr(err, "__notes__", []) + assert len(notes) == 2 + assert all("boom-3" not in note for note in notes) + + @pytest.mark.req("RETRY-34") + def test_status_then_error_trail_excludes_only_the_surfaced_error(self) -> None: + # A 503 status retry precedes the terminal connect error: the prior + # status attempt is in the trail, the surfaced error is not. + outcomes: list[Status | BaseException] = [ + Status.SERVICE_UNAVAILABLE, + ServiceResponseError("read broke"), + ] + client = _ScriptedClient(outcomes) + retry = RetryPolicy(read_retries=0, backoff_factor=0.0, clock=FakeClock()) + with Pipeline(client, policies=[retry]) as p, pytest.raises(ServiceResponseError) as info: + p.run(_get(), DispatchContext(_instr("0" * 15 + "5d"))) + err = info.value + assert _attempts(err) == 2 + notes = getattr(err, "__notes__", []) + assert len(notes) == 1 + assert "503" in notes[0] + + +# ----- schedule parity across stacks -------------------------------------- + + +class _RecordingClock(FakeClock): + """FakeClock that records every slept duration.""" + + def __init__(self, sink: list[float]) -> None: + super().__init__() + self._sink = sink + + def sleep(self, duration: float) -> None: + self._sink.append(duration) + super().sleep(duration) + + +class _RecordingAsyncClock: + """AsyncClock that records every slept duration.""" + + __slots__ = ("_sink", "_t") + + def __init__(self, sink: list[float]) -> None: + self._sink = sink + self._t = 0.0 + + def now(self) -> float: + return self._t + + def monotonic(self) -> float: + return self._t + + async def sleep(self, duration: float) -> None: + self._sink.append(duration) + self._t += max(0.0, duration) + + +class TestScheduleParity: + @pytest.mark.req("RECOV-30", "RETRY-13", "RETRY-30", "RETRY-31") + def test_both_stacks_produce_identical_delay_schedules(self) -> None: + # Both policies delegate to the same ``_pure/backoff`` and draw the same + # seeded RNG in the same order, so their slept schedules are identical. + seed = 20260714 + sync_sleeps: list[float] = [] + sync_client = _ScriptedClient([Status.SERVICE_UNAVAILABLE] * 3 + [Status.OK]) + sync_retry = RetryPolicy( + backoff_factor=1.5, + backoff_max=100.0, + rand=random.Random(seed), + clock=_RecordingClock(sync_sleeps), + ) + with Pipeline(sync_client, policies=[sync_retry]) as p: + p.run(_get(), DispatchContext(_instr("0" * 15 + "5e"))) + + async_sleeps: list[float] = [] + + async def _drive() -> None: + async_client = _AsyncScriptedClient([Status.SERVICE_UNAVAILABLE] * 3 + [Status.OK]) + async_retry = AsyncRetryPolicy( + backoff_factor=1.5, + backoff_max=100.0, + rand=random.Random(seed), + clock=_RecordingAsyncClock(async_sleeps), + ) + async with AsyncPipeline(async_client, policies=[async_retry]) as p: + await p.run(_get(), DispatchContext(_instr("0" * 15 + "5f"))) + + asyncio.run(_drive()) + + assert sync_sleeps == async_sleeps + # The first retry's delay is zero (attempt 1) and is skipped by the + # bounded sleep, so two non-zero, jittered backoffs are recorded. + assert len(sync_sleeps) == 2 + assert all(delay > 0 for delay in sync_sleeps) diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_retry_resource_cleanup.py b/packages/dexpace-sdk-core/tests/pipeline/test_retry_resource_cleanup.py index 74dfe49..c368b48 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_retry_resource_cleanup.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_retry_resource_cleanup.py @@ -24,6 +24,7 @@ from dexpace.sdk.core.client.async_http_client import AsyncHttpClient from dexpace.sdk.core.client.http_client import HttpClient from dexpace.sdk.core.errors import ( + SdkError, ServiceRequestError, ServiceResponseError, ServiceResponseTimeoutError, @@ -205,6 +206,7 @@ async def execute(self, request: Request) -> AsyncResponse: class TestIntermediateResponseClosed: + @pytest.mark.req("RETRY-35", "RETRY-36") def test_sync_status_retry_closes_intermediate_response(self) -> None: client = _BodyTrackingClient([Status.SERVICE_UNAVAILABLE, Status.OK]) retry = RetryPolicy(clock=FakeClock()) @@ -221,6 +223,7 @@ def test_sync_status_retry_closes_intermediate_response(self) -> None: response.close() assert client.bodies[1].closed is True + @pytest.mark.req("RETRY-33") async def test_async_status_retry_closes_intermediate_response(self) -> None: client = _AsyncBodyTrackingClient([Status.SERVICE_UNAVAILABLE, Status.OK]) retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) @@ -243,6 +246,7 @@ class TestStatusPathDeadlinePropagates: very first attempt, matching the async twin. """ + @pytest.mark.req("RETRY-20", "RETRY-27") def test_sync_propagates_after_single_attempt(self) -> None: # ``backoff_factor=0`` makes the buggy swallow-and-retry path sleep zero # seconds (which skips the deadline check), so the regression would keep @@ -258,6 +262,7 @@ def test_sync_propagates_after_single_attempt(self) -> None: p.run(_get(), DispatchContext(_instr("0" * 16 + "3"))) assert client.attempts == 1 + @pytest.mark.req("RETRY-27", "RETRY-33") async def test_async_propagates_after_single_attempt(self) -> None: client = _AsyncDeadlineClient(retry_after="100") retry = AsyncRetryPolicy(timeout=1.0, backoff_factor=0.0, clock=_AsyncFakeClock()) @@ -291,6 +296,7 @@ async def _drive_async() -> None: assert sync_client.attempts == async_client.attempts == 1 +@pytest.mark.req("RETRY-35") def test_returned_response_not_closed_on_no_retry() -> None: """A non-retryable response is handed back open; the close fix is scoped to the status-retry path only.""" @@ -373,11 +379,13 @@ async def test_per_call_retry_total_over_zero_instance_buffers_body(self) -> Non class TestAsyncReadPhaseMethodGating: - """Async twin of the read-phase idempotency gating. + """Async twin of the uniform retry-safety gate. - A read-phase ``ServiceResponseError`` is not retried for POST (the write - may already have landed), but a connect-phase ``ServiceRequestError`` is - — the request never left the client. + Retry-safety is decided uniformly on the async path too (the async loop + reuses the sync ``_decrement_for_error``): a bare POST is not retried on a + read-phase ``ServiceResponseError`` nor on a connect-phase + ``ServiceRequestError`` — the method is non-idempotent regardless of which + transport phase failed (XCUT-10). An idempotent GET stays retryable. """ async def test_post_not_retried_on_read_phase_error(self) -> None: @@ -388,10 +396,69 @@ async def test_post_not_retried_on_read_phase_error(self) -> None: await p.run(_post(), DispatchContext(_instr("0" * 16 + "b"))) assert client.attempts == 1 - async def test_post_still_retried_on_connect_phase_error(self) -> None: + @pytest.mark.req("XCUT-10") + async def test_post_not_retried_on_connect_phase_error(self) -> None: + # XCUT-10 on the async path: a bare POST is body-less and non-idempotent, + # so a connect-phase error that never reached the server is still NOT + # retried — the safety gate does not special-case transport errors. + client = _AsyncErrorClient([ServiceRequestError("dns fail"), Status.OK]) + retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) + with pytest.raises(ServiceRequestError): + async with AsyncPipeline(client, policies=[retry]) as p: + await p.run(_post(), DispatchContext(_instr("0" * 16 + "c"))) + assert client.attempts == 1 + + @pytest.mark.req("XCUT-10") + async def test_get_still_retried_on_connect_phase_error(self) -> None: + # The uniform gate leaves idempotent methods retryable on both phases. client = _AsyncErrorClient([ServiceRequestError("dns fail"), Status.OK]) retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) async with AsyncPipeline(client, policies=[retry]) as p: - response = await p.run(_post(), DispatchContext(_instr("0" * 16 + "c"))) + response = await p.run(_get(), DispatchContext(_instr("0" * 16 + "d"))) assert response.status is Status.OK assert client.attempts == 2 + + +class _AsyncCustomRetryableError(SdkError): + """Custom non-protocol async error advertising retryability via capability.""" + + retryable = True + + +class TestAsyncOpenCapabilityRetry: + """The async loop honours the open retryability capability too (XCUT-6). + + ``AsyncRetryPolicy`` delegates the error classification to the sync + ``_decrement_for_error``, so a custom transport/error type advertising + ``retryable`` participates in retries on the async path without a + concrete-type match. + """ + + @pytest.mark.req("XCUT-6") + async def test_custom_error_advertising_retryable_is_retried(self) -> None: + client = _AsyncErrorClient([_AsyncCustomRetryableError("boom"), Status.OK]) + retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) + async with AsyncPipeline(client, policies=[retry]) as p: + response = await p.run(_get(), DispatchContext(_instr("0" * 16 + "e"))) + assert response.status is Status.OK + assert client.attempts == 2 + + +class TestAsyncFatalErrorPropagation: + """Non-SDK fatal errors surface unchanged and unretried on the async path (RETRY-25).""" + + @pytest.mark.req("RETRY-25") + async def test_non_sdk_fatal_propagates_unretried_untrailed(self) -> None: + # A MemoryError (CPython's OOM analog) is a BaseException that is not an + # SdkError, so the ``except SdkError`` clause never catches it: it + # propagates at the throw site, unretried, with no suppressed trail. + fatal = MemoryError("out of memory") + client = _AsyncErrorClient([fatal, Status.OK]) + retry = AsyncRetryPolicy(clock=_AsyncFakeClock()) + with pytest.raises(MemoryError) as excinfo: + async with AsyncPipeline(client, policies=[retry]) as p: + await p.run(_get(), DispatchContext(_instr("0" * 16 + "f"))) + assert excinfo.value is fatal + assert client.attempts == 1 + assert not hasattr(fatal, "attempts") + assert getattr(fatal, "__notes__", []) == [] diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_retry_tuning.py b/packages/dexpace-sdk-core/tests/pipeline/test_retry_tuning.py index 4bcccd7..592cb76 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_retry_tuning.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_retry_tuning.py @@ -32,15 +32,13 @@ from dexpace.sdk.core.instrumentation.http_tracer import HttpTracer, HttpTracerFactory from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN from dexpace.sdk.core.pipeline import Pipeline +from dexpace.sdk.core.pipeline._pure.pacing import parse_rate_limit_reset from dexpace.sdk.core.pipeline.policies import ( OperationTracingPolicy, RetryPolicy, TracingPolicy, ) -from dexpace.sdk.core.pipeline.policies.retry import ( - _parse_rate_limit_reset, - _StatusRetryError, -) +from dexpace.sdk.core.pipeline.policies.retry import _StatusRetryError from ..conftest import FakeClock @@ -121,27 +119,28 @@ def create(self) -> HttpTracer: class TestRateLimitResetParsing: def test_epoch_in_future_yields_positive_delay(self) -> None: - assert _parse_rate_limit_reset("150", now=100.0) == 50.0 + assert parse_rate_limit_reset("150", now=100.0) == 50.0 def test_epoch_in_past_floors_at_zero(self) -> None: - assert _parse_rate_limit_reset("90", now=100.0) == 0.0 + assert parse_rate_limit_reset("90", now=100.0) == 0.0 def test_missing_or_blank_returns_none(self) -> None: - assert _parse_rate_limit_reset(None, now=0.0) is None - assert _parse_rate_limit_reset(" ", now=0.0) is None + assert parse_rate_limit_reset(None, now=0.0) is None + assert parse_rate_limit_reset(" ", now=0.0) is None def test_non_numeric_returns_none(self) -> None: - assert _parse_rate_limit_reset("soon", now=0.0) is None + assert parse_rate_limit_reset("soon", now=0.0) is None class TestRateLimitResetHonored: + @pytest.mark.req("RECOV-25", "RETRY-15") def test_never_wakes_before_reset(self) -> None: clock = FakeClock(start=1_000.0) client = _ScriptedClient( [Status.TOO_MANY_REQUESTS, Status.OK], headers={"X-RateLimit-Reset": "1040"}, ) - # Bottom of the upward jitter band [1.0, 1.1] -> exactly the reset wait. + # Bottom of the upward jitter band [1.0, 1.2] -> exactly the reset wait. retry = RetryPolicy(clock=clock, rand=_FixedRandom(0.0)) with Pipeline(client, policies=[retry]) as p: response = p.run(_get(), DispatchContext(_instr("0" * 16 + "1"))) @@ -149,18 +148,20 @@ def test_never_wakes_before_reset(self) -> None: # 40s until reset, jitter * 1.0 -> waits to the reset instant, never before. assert clock.monotonic() == pytest.approx(1_040.0) + @pytest.mark.req("RECOV-25", "RETRY-15") def test_reset_jitter_only_lengthens_the_wait(self) -> None: clock = FakeClock(start=1_000.0) client = _ScriptedClient( [Status.TOO_MANY_REQUESTS, Status.OK], headers={"X-RateLimit-Reset": "1040"}, ) - # Top of the band [1.0, 1.1] -> 40s * 1.1 = 44s, i.e. slightly past reset. + # Top of the band [1.0, 1.2] -> 40s * 1.2 = 48s, i.e. slightly past reset. retry = RetryPolicy(clock=clock, rand=_FixedRandom(1.0)) with Pipeline(client, policies=[retry]) as p: p.run(_get(), DispatchContext(_instr("0" * 16 + "1"))) - assert clock.monotonic() == pytest.approx(1_044.0) + assert clock.monotonic() == pytest.approx(1_048.0) + @pytest.mark.req("RECOV-22", "RETRY-21") def test_retry_after_takes_precedence_over_reset(self) -> None: clock = FakeClock(start=1_000.0) client = _ScriptedClient( @@ -173,6 +174,86 @@ def test_retry_after_takes_precedence_over_reset(self) -> None: assert clock.monotonic() == pytest.approx(1_005.0) +# ----- millisecond-delta headers (retry-after-ms / x-ms-retry-after-ms) ---- + + +class TestRetryAfterMsHeaders: + """The two millisecond-delta pacing headers drive the sleep, in precedence. + + The policy composes the pacing parsers in the fixed order Retry-After, + retry-after-ms, x-ms-retry-after-ms, X-RateLimit-Reset (RETRY-15 / + RECOV-24). These drive the real retry loop, not just the pure parsers. + """ + + @pytest.mark.req("RETRY-15", "RECOV-24") + def test_retry_after_ms_drives_the_sleep(self) -> None: + clock = FakeClock() + client = _ScriptedClient( + [Status.SERVICE_UNAVAILABLE, Status.OK], + headers={"retry-after-ms": "1500"}, + ) + retry = RetryPolicy(clock=clock) + with Pipeline(client, policies=[retry]) as p: + p.run(_get(), DispatchContext(_instr("0" * 15 + "a0"))) + assert clock.monotonic() == pytest.approx(1.5) # 1500 ms + + @pytest.mark.req("RETRY-15", "RECOV-24") + def test_x_ms_retry_after_ms_drives_the_sleep(self) -> None: + clock = FakeClock() + client = _ScriptedClient( + [Status.SERVICE_UNAVAILABLE, Status.OK], + headers={"x-ms-retry-after-ms": "3000"}, + ) + retry = RetryPolicy(clock=clock) + with Pipeline(client, policies=[retry]) as p: + p.run(_get(), DispatchContext(_instr("0" * 15 + "a1"))) + assert clock.monotonic() == pytest.approx(3.0) # 3000 ms + + @pytest.mark.req("RECOV-24") + def test_retry_after_seconds_beats_the_ms_variants(self) -> None: + clock = FakeClock() + client = _ScriptedClient( + [Status.SERVICE_UNAVAILABLE, Status.OK], + headers={ + "Retry-After": "5", + "retry-after-ms": "1000", + "x-ms-retry-after-ms": "2000", + }, + ) + retry = RetryPolicy(clock=clock) + with Pipeline(client, policies=[retry]) as p: + p.run(_get(), DispatchContext(_instr("0" * 15 + "a2"))) + assert clock.monotonic() == pytest.approx(5.0) + + @pytest.mark.req("RECOV-24") + def test_retry_after_ms_beats_x_ms_and_reset(self) -> None: + clock = FakeClock(start=1_000.0) + client = _ScriptedClient( + [Status.TOO_MANY_REQUESTS, Status.OK], + headers={ + "retry-after-ms": "1000", + "x-ms-retry-after-ms": "2000", + "X-RateLimit-Reset": "9999999", + }, + ) + retry = RetryPolicy(clock=clock) + with Pipeline(client, policies=[retry]) as p: + p.run(_get(), DispatchContext(_instr("0" * 15 + "a3"))) + assert clock.monotonic() == pytest.approx(1_001.0) # 1000 ms wins + + @pytest.mark.req("RECOV-24") + def test_x_ms_retry_after_ms_beats_reset(self) -> None: + clock = FakeClock(start=1_000.0) + client = _ScriptedClient( + [Status.TOO_MANY_REQUESTS, Status.OK], + headers={"x-ms-retry-after-ms": "2000", "X-RateLimit-Reset": "9999999"}, + ) + retry = RetryPolicy(clock=clock) + with Pipeline(client, policies=[retry]) as p: + p.run(_get(), DispatchContext(_instr("0" * 15 + "a4"))) + assert clock.monotonic() == pytest.approx(1_002.0) # 2000 ms wins over reset + + # ----- full jitter -------------------------------------------------------- diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_review_fixes.py b/packages/dexpace-sdk-core/tests/pipeline/test_review_fixes.py index 870e8d5..d54385a 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_review_fixes.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_review_fixes.py @@ -121,7 +121,8 @@ async def execute(self, request: Request) -> AsyncResponse: class _Observer(AsyncPolicy): async def send(self, request: Request, ctx: PipelineContext) -> AsyncResponse: response = await self.next.send(request, ctx) - seen.append(ContextStore.get(instr.trace_id.value)) + live = ContextStore.contexts_for_trace(instr.trace_id.value) + seen.append(live[0] if live else None) return response async def run() -> None: @@ -132,4 +133,4 @@ async def run() -> None: # The exchange context was the latest snapshot while the call was live. assert isinstance(seen[0], ExchangeContext) # Eviction-on-completion clears the entry afterwards. - assert ContextStore.get(instr.trace_id.value) is None + assert ContextStore.contexts_for_trace(instr.trace_id.value) == [] diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_set_date.py b/packages/dexpace-sdk-core/tests/pipeline/test_set_date.py index b620b9b..5465e49 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_set_date.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_set_date.py @@ -23,6 +23,7 @@ ) from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN from dexpace.sdk.core.pipeline import Pipeline +from dexpace.sdk.core.pipeline.policies.retry import RetryPolicy from dexpace.sdk.core.pipeline.policies.set_date import SetDatePolicy from ..conftest import FakeClock @@ -103,3 +104,49 @@ def test_restamps_on_each_attempt() -> None: assert first != second assert _RFC7231.match(first) is not None assert _RFC7231.match(second) is not None + + +class _AdvancingClient(HttpClient): + """Records requests and moves the injected clock forward after each send. + + Modelling the real world where wall-clock time advances between retry + attempts, this lets a single retried call produce a distinct ``Date`` per + attempt when `SetDatePolicy` sits inside the retry wrapper. + """ + + def __init__(self, clock: FakeClock, *fail_with: Status) -> None: + self.calls: list[Request] = [] + self._clock = clock + self._fail = list(fail_with) + + def execute(self, request: Request) -> Response: + self.calls.append(request) + status = self._fail.pop(0) if self._fail else Status.OK + self._clock.advance(3600) # an hour passes before the next attempt + return Response(request=request, protocol=Protocol.HTTP_1_1, status=status) + + +def test_date_refreshed_on_each_retry_attempt() -> None: + """One retried call re-stamps a fresh ``Date`` on each attempt. + + The policy sits at `Stage.POST_RETRY` (inside the retry wrapper), so a + ``503`` that triggers a retry re-enters the policy and reads the clock + again. Were it outside the wrapper, the retry would re-send the same + already-stamped request and both attempts would carry an identical + (stale) ``Date``. + """ + clock = FakeClock(start=1_700_000_000.0) + client = _AdvancingClient(clock, Status.SERVICE_UNAVAILABLE) + # RetryPolicy (Stage.RETRY) wraps SetDatePolicy (Stage.POST_RETRY): pass in + # outer-to-inner order. backoff_factor=0 keeps the (already zero) first + # retry delay from ever touching a real sleep. + policies = [RetryPolicy(backoff_factor=0), SetDatePolicy(clock=clock)] + with Pipeline(client, policies=policies) as p: + p.run(_request(), DispatchContext(_instr("0" * 16 + "6"))) + assert len(client.calls) == 2 # one 503 + one 200 + first = client.calls[0].headers.get("Date") + second = client.calls[1].headers.get("Date") + assert first is not None and second is not None + assert first != second + assert _RFC7231.match(first) is not None + assert _RFC7231.match(second) is not None diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_stage.py b/packages/dexpace-sdk-core/tests/pipeline/test_stage.py index 43a29a1..83448cb 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_stage.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_stage.py @@ -13,6 +13,7 @@ class TestStage: + @pytest.mark.req("PIPE-2", "PIPE-3", "PIPE-4") @pytest.mark.parametrize( "stage", [ @@ -28,6 +29,7 @@ class TestStage: def test_pillar_stages(self, stage: Stage) -> None: assert stage.is_pillar + @pytest.mark.req("PIPE-3") @pytest.mark.parametrize( "stage", [ @@ -45,6 +47,7 @@ def test_pillar_stages(self, stage: Stage) -> None: def test_non_pillar_stages(self, stage: Stage) -> None: assert not stage.is_pillar + @pytest.mark.req("PIPE-2", "PIPE-37") def test_stage_order(self) -> None: assert Stage.OPERATION < Stage.REDIRECT < Stage.RETRY < Stage.AUTH assert Stage.AUTH < Stage.LOGGING < Stage.SEND diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_staged_builder.py b/packages/dexpace-sdk-core/tests/pipeline/test_staged_builder.py index 0b15622..65ebeed 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_staged_builder.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_staged_builder.py @@ -60,12 +60,14 @@ def transport() -> _StubTransport: class TestAppendPrepend: + @pytest.mark.req("PIPE-1", "PIPE-7") def test_append_non_pillar_stacks(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) b.append(_MarkerPolicy("a")).append(_MarkerPolicy("b")) flat = b._flatten() assert [p.tag for p in flat if isinstance(p, _MarkerPolicy)] == ["a", "b"] + @pytest.mark.req("PIPE-7") def test_prepend_non_pillar_inserts_at_head(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) b.append(_MarkerPolicy("a")).prepend(_MarkerPolicy("z")) @@ -78,6 +80,7 @@ def test_append_pillar_slots_once(self, transport: _StubTransport) -> None: b.append(retry) assert b._pillars[Stage.RETRY] is retry + @pytest.mark.req("PIPE-24", "PIPE-4", "PIPE-5") def test_append_pillar_second_time_raises(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) b.append(RetryPolicy()) @@ -93,7 +96,90 @@ def test_append_pillar_with_force_overwrites(self, transport: _StubTransport) -> assert b._pillars[Stage.RETRY] is replacement +class TestSameInstancePillarIdempotency: + """Re-installing the SAME step onto its pillar is an idempotent no-op.""" + + @pytest.mark.req("PIPE-6") + def test_reappend_same_pillar_instance_is_noop(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + retry = RetryPolicy() + b.append(retry) + # Re-appending the *same* object must not raise and must not duplicate. + b.append(retry) + assert b._pillars[Stage.RETRY] is retry + flat = b._flatten() + assert sum(1 for p in flat if isinstance(p, RetryPolicy)) == 1 + + @pytest.mark.req("PIPE-6") + def test_reprepend_same_pillar_instance_is_noop(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + retry = RetryPolicy() + b.append(retry) + # The prepend path must be idempotent for the same instance too. + b.prepend(retry) + assert b._pillars[Stage.RETRY] is retry + assert sum(1 for p in b._flatten() if isinstance(p, RetryPolicy)) == 1 + + @pytest.mark.req("PIPE-6") + def test_distinct_pillar_instance_still_raises(self, transport: _StubTransport) -> None: + # Reference identity, not value equality: a *distinct* RetryPolicy + # (a different object) still collides with the incumbent. + b = StagedPipelineBuilder(transport) + b.append(RetryPolicy()) + with pytest.raises(ValueError, match="Pillar stage RETRY is already filled"): + b.append(RetryPolicy()) + + +class TestBatchAdd: + """append_all preserves batch order; prepend_all reverses it (PIPE-38).""" + + @pytest.mark.req("PIPE-38") + def test_append_all_preserves_order(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + b.append_all([_MarkerPolicy("a"), _MarkerPolicy("b"), _MarkerPolicy("c")]) + tags = [p.tag for p in b._flatten() if isinstance(p, _MarkerPolicy)] + assert tags == ["a", "b", "c"] + + @pytest.mark.req("PIPE-38") + def test_prepend_all_reverses_order(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + b.prepend_all([_MarkerPolicy("a"), _MarkerPolicy("b"), _MarkerPolicy("c")]) + tags = [p.tag for p in b._flatten() if isinstance(p, _MarkerPolicy)] + # Each element prepended individually → reversed batch order. + assert tags == ["c", "b", "a"] + + @pytest.mark.req("PIPE-38") + def test_append_all_and_prepend_all_are_asymmetric(self, transport: _StubTransport) -> None: + batch = ["a", "b", "c"] + appended = StagedPipelineBuilder(transport) + appended.append_all([_MarkerPolicy(t) for t in batch]) + prepended = StagedPipelineBuilder(transport) + prepended.prepend_all([_MarkerPolicy(t) for t in batch]) + app_tags = [p.tag for p in appended._flatten() if isinstance(p, _MarkerPolicy)] + pre_tags = [p.tag for p in prepended._flatten() if isinstance(p, _MarkerPolicy)] + assert app_tags == batch + assert pre_tags == list(reversed(batch)) + + @pytest.mark.req("PIPE-38") + def test_append_all_returns_self_for_chaining(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + assert b.append_all([_MarkerPolicy("a")]) is b + assert b.prepend_all([_MarkerPolicy("z")]) is b + + @pytest.mark.req("PIPE-38") + def test_append_all_is_atomic_on_pillar_conflict(self, transport: _StubTransport) -> None: + # A rejected element (second distinct pillar) rolls the whole batch back. + b = StagedPipelineBuilder(transport) + original = RetryPolicy() + b.append(original) + with pytest.raises(ValueError, match="RETRY"): + b.append_all([_MarkerPolicy("m"), RetryPolicy()]) + assert b._pillars[Stage.RETRY] is original + assert not any(isinstance(p, _MarkerPolicy) for p in b._flatten()) + + class TestSurgicalEdits: + @pytest.mark.req("PIPE-19") def test_replace_pillar(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) original = RetryPolicy() @@ -125,11 +211,13 @@ def test_replace_non_pillar(self, transport: _StubTransport) -> None: flat = b._flatten() assert any(p is new for p in flat) + @pytest.mark.req("PIPE-21") def test_replace_missing_raises(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) with pytest.raises(ValueError, match="No instance of RetryPolicy"): b.replace(RetryPolicy, RetryPolicy()) + @pytest.mark.req("PIPE-18") def test_insert_after(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) a = _MarkerPolicy("a") @@ -141,6 +229,7 @@ def test_insert_after(self, transport: _StubTransport) -> None: # insert_after first match (a) → inserted slots between a and b assert tags == ["a", "inserted", "b"] + @pytest.mark.req("PIPE-18") def test_insert_before(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) a = _MarkerPolicy("a") @@ -151,6 +240,7 @@ def test_insert_before(self, transport: _StubTransport) -> None: tags = [p.tag for p in b._flatten() if isinstance(p, _MarkerPolicy)] assert tags == ["inserted", "a", "b"] + @pytest.mark.req("PIPE-20", "PIPE-7") def test_remove_all_of_type(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) b.append(_MarkerPolicy("a")).append(_MarkerPolicy("b")).append(_SecondMarkerPolicy("c")) @@ -161,6 +251,7 @@ def test_remove_all_of_type(self, transport: _StubTransport) -> None: class TestBuild: + @pytest.mark.req("PIPE-1", "PIPE-25", "PIPE-8") def test_build_orders_by_stage(self, transport: _StubTransport) -> None: b = StagedPipelineBuilder(transport) # Append in scrambled order @@ -184,6 +275,7 @@ def test_build_empty(self, transport: _StubTransport) -> None: class TestFromPipeline: + @pytest.mark.req("PIPE-22") def test_round_trip(self, transport: _StubTransport) -> None: original = Pipeline( transport, @@ -226,9 +318,132 @@ def stamp(request: Request, ctx: CallContext) -> Request: StagedPipelineBuilder.from_pipeline(original) +class TestOccupancyGuards: + """A singleton (pillar) stage admits at most one occupant, on every path.""" + + @pytest.mark.req("PIPE-18", "PIPE-23", "PIPE-5") + def test_insert_second_pillar_via_splice_raises(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + original = RetryPolicy() + marker = _MarkerPolicy("m") + b.append(original).append(marker) + # ``insert_after`` re-buckets through ``_reload``; a second RetryPolicy + # must not silently clobber the incumbent. + with pytest.raises(ValueError, match="RETRY"): + b.insert_after(_MarkerPolicy, RetryPolicy()) + # Occupancy + atomicity: incumbent survives, marker untouched. + assert b._pillars[Stage.RETRY] is original + assert [p.tag for p in b._flatten() if isinstance(p, _MarkerPolicy)] == ["m"] + + def test_insert_second_pillar_error_names_incumbent_type( + self, + transport: _StubTransport, + ) -> None: + b = StagedPipelineBuilder(transport) + b.append(RetryPolicy()).append(_MarkerPolicy("m")) + with pytest.raises(ValueError, match="RetryPolicy"): + b.insert_before(_MarkerPolicy, RetryPolicy()) + + +class TestNoRelocation: + """Shipped singleton policies own their stage; replace can't relocate them.""" + + @pytest.mark.req("PIPE-19", "PIPE-36") + def test_replace_pillar_with_different_stage_rejected( + self, + transport: _StubTransport, + ) -> None: + b = StagedPipelineBuilder(transport) + retry = RetryPolicy() + b.append(retry) + # ``_MarkerPolicy`` declares PRE_AUTH — replacing the RETRY singleton + # with it would relocate the slot's occupant to a different stage. + with pytest.raises(ValueError, match="relocate"): + b.replace(RetryPolicy, _MarkerPolicy("x")) + # Atomic: the retry pillar is untouched, no marker leaked in. + assert b._pillars[Stage.RETRY] is retry + assert not any(isinstance(p, _MarkerPolicy) for p in b._flatten()) + + def test_replace_pillar_same_stage_still_allowed(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + b.append(RetryPolicy()) + new = RetryPolicy() + b.replace(RetryPolicy, new) + assert b._pillars[Stage.RETRY] is new + + +class TestAtomicEdits: + """A failing edit or batch leaves no partial mutation visible.""" + + def test_replace_cross_stage_conflict_is_atomic(self, transport: _StubTransport) -> None: + # replace(marker -> RetryPolicy) while RETRY is already filled: the + # append of the second retry must fail *before* the marker is removed. + b = StagedPipelineBuilder(transport) + b.append(RetryPolicy()).append(_MarkerPolicy("m")) + with pytest.raises(ValueError, match="RETRY"): + b.replace(_MarkerPolicy, RetryPolicy()) + assert [p.tag for p in b._flatten() if isinstance(p, _MarkerPolicy)] == ["m"] + + @pytest.mark.req("PIPE-23") + def test_batch_rolls_back_on_failure(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + original = RetryPolicy() + b.append(original) + with pytest.raises(ValueError, match="RETRY"), b.batch() as bb: + bb.append(_MarkerPolicy("m1")) + bb.append(_SecondMarkerPolicy("m2")) + bb.append(RetryPolicy()) # duplicate pillar → raises + flat = b._flatten() + assert b._pillars[Stage.RETRY] is original + assert not any(isinstance(p, _MarkerPolicy) for p in flat) + assert not any(isinstance(p, _SecondMarkerPolicy) for p in flat) + + def test_batch_commits_on_success(self, transport: _StubTransport) -> None: + b = StagedPipelineBuilder(transport) + with b.batch() as bb: + bb.append(_MarkerPolicy("m1")).append(_SecondMarkerPolicy("m2")) + flat = b._flatten() + assert any(isinstance(p, _MarkerPolicy) for p in flat) + assert any(isinstance(p, _SecondMarkerPolicy) for p in flat) + + def test_failed_batch_leaves_prior_build_intact(self, transport: _StubTransport) -> None: + from dexpace.sdk.core.pipeline._transport_runner import _TransportRunner + + def stages(p: Pipeline) -> list[Stage]: + out: list[Stage] = [] + node: Policy | None = p._chain + while node is not None and not isinstance(node, _TransportRunner): + out.append(node.STAGE) + node = getattr(node, "next", None) + return out + + b = StagedPipelineBuilder(transport) + b.append(RetryPolicy()) + built = b.build() + before = stages(built) + with pytest.raises(ValueError), b.batch() as bb: + bb.append(_MarkerPolicy("m")).append(RetryPolicy()) + assert stages(built) == before + + +class TestPillarOrderFixed: + @pytest.mark.req("PIPE-1", "PIPE-22") + def test_pillar_relative_order_is_fixed_regardless_of_insert_order( + self, + transport: _StubTransport, + ) -> None: + b = StagedPipelineBuilder(transport) + # Append pillars out of stage order: LOGGING before RETRY. + b.append(LoggingPolicy()).append(RetryPolicy()) + stages = [p.STAGE for p in b._flatten()] + # RETRY (200) must precede LOGGING (700) in the flattened order. + assert stages.index(Stage.RETRY) < stages.index(Stage.LOGGING) + + class TestPillarReplacementSafety: """Default raises; force=True is the explicit escape.""" + @pytest.mark.req("PIPE-5") def test_double_pillar_without_force_surfaces_loud_error( self, transport: _StubTransport, diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_sync_redirect_battery.py b/packages/dexpace-sdk-core/tests/pipeline/test_sync_redirect_battery.py new file mode 100644 index 0000000..353cb30 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/pipeline/test_sync_redirect_battery.py @@ -0,0 +1,556 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Full redirect security battery run against the sync ``RedirectPolicy``. + +Sync counterpart to ``test_async_redirect_battery.py``. The async twin +delegates every per-hop decision to a wrapped sync ``RedirectPolicy``, so both +share one resolution core; this file certifies that core directly on the sync +pillar, driving it through a real ``Pipeline`` with a close-tracking transport +double and the golden ``redirect`` corpus (``REDIR-12``). + +Dimensions exercised: + +- loop / max-hops / malformed-``Location`` / missing-``Location`` / disallowed + method / ``follow_303=False`` all hand the current response back unfollowed + and *open* (never closed) so the caller can still read or close it; +- superseded (intermediate) responses are closed before each subsequent hop; + the abandon paths above leave the final response open; +- a body-preserving reissue with a single-use body closes the in-hand response + before the ``RuntimeError`` escapes (an error abandon, not a graceful one); +- ``303`` rebuild strips the body and every ``Content-*`` header; +- cross-origin ``Authorization`` stripping (kept same-origin); +- ``userinfo`` in a redirected URL is dropped; +- ``Location`` resolution is wire-exact against every golden ``redirect`` vector + — percent-encoding preserved verbatim and IPv6 ``[brackets]`` kept intact. + +The credential-hygiene dimensions once committed as ``xfail(strict=True)`` +executable specs are now certified directly on the shared core: ``Authorization`` +is stripped before every reissue, an HTTPS→HTTP downgrade is default-denied (and +follow-on-opt-in), ``Cookie`` / ``Proxy-Authorization`` are stripped on a +cross-origin hop, cross-origin is judged against the seed origin, and a custom +predicate can fully override the follow decision. The async battery's twins pass +in lock-step because it delegates every per-hop decision to this same core. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence + +import pytest + +from dexpace.sdk.core.client.http_client import HttpClient +from dexpace.sdk.core.http.common import Protocol, Url +from dexpace.sdk.core.http.common.url import _origin +from dexpace.sdk.core.http.context import DispatchContext +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.request.request_body import RequestBody +from dexpace.sdk.core.http.response import Response, ResponseBody, Status +from dexpace.sdk.core.instrumentation import ( + InstrumentationContext, + SpanId, + TraceFlags, + TraceId, + TraceIdType, + TraceState, +) +from dexpace.sdk.core.instrumentation.noop import NOOP_SPAN +from dexpace.sdk.core.pipeline import Pipeline +from dexpace.sdk.core.pipeline.policies.redirect import RedirectCondition, RedirectPolicy +from dexpace.sdk.tck.golden_corpus import RedirectVector, load_redirect + + +def _instr(trace: str) -> InstrumentationContext: + return InstrumentationContext( + trace_id_type=TraceIdType.W3C, + trace_id=TraceId(trace), + span_id=SpanId("0" * 16), + span=NOOP_SPAN, + trace_flags=TraceFlags.NOOP, + trace_state=TraceState.NOOP, + ) + + +def _request( + method: Method = Method.GET, + url: str = "https://example.com/start", + body: RequestBody | None = None, + headers: dict[str, str] | None = None, +) -> Request: + req = Request(method=method, url=Url.parse(url), body=body) + for name, value in (headers or {}).items(): + req = req.with_header(name, value) + return req + + +class _TrackingBody(ResponseBody): + """Response body that records whether ``close`` was called.""" + + __slots__ = ("closed",) + + def __init__(self) -> None: + self.closed = False + + def media_type(self) -> None: + return None + + def content_length(self) -> int: + return 0 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + self.close() + return iter(()) + + def close(self) -> None: + self.closed = True + + +class _Hop: + """One scripted transport response: status, Location, extra headers, body.""" + + __slots__ = ("body", "extra_headers", "location", "status") + + def __init__( + self, + status: Status, + location: str | None = None, + extra_headers: tuple[tuple[str, str], ...] = (), + body: ResponseBody | None = None, + ) -> None: + self.status = status + self.location = location + self.extra_headers = extra_headers + self.body = body + + +class _ScriptedClient(HttpClient): + """Returns one scripted response per call; records each request.""" + + def __init__(self, hops: Sequence[_Hop]) -> None: + self._hops = list(hops) + self.requests: list[Request] = [] + + def execute(self, request: Request) -> Response: + idx = len(self.requests) + self.requests.append(request) + hop = self._hops[idx] + response = Response( + request=request, + protocol=Protocol.HTTP_1_1, + status=hop.status, + body=hop.body, + ) + if hop.location is not None: + response = response.with_header("Location", hop.location) + for name, value in hop.extra_headers: + response = response.with_header(name, value) + return response + + +def _run(client: _ScriptedClient, policy: RedirectPolicy, request: Request) -> Response: + with Pipeline(client, policies=[policy]) as p: + return p.run(request, DispatchContext(_instr("0" * 15 + "1"))) + + +# --------------------------------------------------------------------------- # +# Resource lifecycle: superseded responses closed; abandon paths leave open. # +# --------------------------------------------------------------------------- # + + +class TestResourceLifecycle: + @pytest.mark.req("REDIR-22") + def test_superseded_responses_closed_before_each_hop(self) -> None: + # Two intermediate 301 responses precede the terminal 200. Each + # intermediate body must be closed as the policy moves off it; the + # terminal response is handed back open for the caller to consume. + b0 = _TrackingBody() + b1 = _TrackingBody() + final = _TrackingBody() + client = _ScriptedClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/a", body=b0), + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/b", body=b1), + _Hop(Status.OK, body=final), + ], + ) + response = _run(client, RedirectPolicy(), _request()) + assert b0.closed + assert b1.closed + assert not final.closed + assert response.status is Status.OK + + @pytest.mark.req("REDIR-16", "REDIR-22") + def test_loop_abandon_leaves_final_response_open(self) -> None: + final = _TrackingBody() + client = _ScriptedClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/b"), + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/a", body=final), + ], + ) + response = _run(client, RedirectPolicy(), _request(url="https://example.com/a")) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + + @pytest.mark.req("REDIR-17") + def test_max_hops_abandon_leaves_final_response_open(self) -> None: + final = _TrackingBody() + client = _ScriptedClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/a"), + _Hop(Status.MOVED_PERMANENTLY, "https://example.com/b", body=final), + _Hop(Status.OK), + ], + ) + response = _run(client, RedirectPolicy(max_hops=1), _request()) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + assert len(client.requests) == 2 + + @pytest.mark.req("REDIR-19") + def test_missing_location_abandon_leaves_final_response_open(self) -> None: + final = _TrackingBody() + client = _ScriptedClient([_Hop(Status.MOVED_PERMANENTLY, None, body=final)]) + response = _run(client, RedirectPolicy(), _request()) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + + def test_method_not_allowed_abandon_leaves_final_response_open(self) -> None: + final = _TrackingBody() + client = _ScriptedClient( + [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new", body=final)], + ) + # POST is not in the default allowed_methods, so a 301 is not followed. + response = _run(client, RedirectPolicy(), _request(method=Method.POST)) + assert response.status is Status.MOVED_PERMANENTLY + assert not final.closed + + def test_follow_303_false_abandon_leaves_final_response_open(self) -> None: + final = _TrackingBody() + client = _ScriptedClient( + [_Hop(Status.SEE_OTHER, "https://example.com/new", body=final)], + ) + response = _run(client, RedirectPolicy(follow_303=False), _request()) + assert response.status is Status.SEE_OTHER + assert not final.closed + + def test_malformed_location_returns_response_open_not_closed(self) -> None: + # A malformed Location cannot resolve to a target. The policy must hand + # the current 3xx response back unfollowed and OPEN — not raise and leak + # the connection, and not close the body it is returning. + final = _TrackingBody() + client = _ScriptedClient([_Hop(Status.FOUND, "http://[malformed", body=final)]) + response = _run(client, RedirectPolicy(), _request()) + assert response.status is Status.FOUND + assert not final.closed + assert len(client.requests) == 1 + + @pytest.mark.req("REDIR-22", "REDIR-6") + def test_non_replayable_body_closes_intermediate_then_raises(self) -> None: + # A body-preserving reissue with a single-use body cannot be replayed. + # The in-hand 3xx response is closed before the RuntimeError escapes so + # the connection is not leaked (an error abandon, not a graceful one). + intermediate = _TrackingBody() + request = _request(method=Method.POST, body=RequestBody.from_iter(iter([b"chunk"]))) + client = _ScriptedClient( + [_Hop(Status.TEMPORARY_REDIRECT, "https://example.com/new", body=intermediate)], + ) + policy = RedirectPolicy( + allowed_methods=frozenset({Method.GET, Method.HEAD, Method.POST}), + ) + with pytest.raises(RuntimeError): + _run(client, policy, request) + assert intermediate.closed + + +# --------------------------------------------------------------------------- # +# Battery dimensions the shared resolution core already satisfies. # +# --------------------------------------------------------------------------- # + + +class TestBatterySatisfied: + def test_303_rebuild_strips_body_and_content_headers(self) -> None: + request = _request( + method=Method.POST, + body=RequestBody.from_bytes(b"payload"), + headers={ + "Content-Type": "application/json", + "Content-Length": "7", + "Content-Encoding": "gzip", + }, + ) + client = _ScriptedClient( + [_Hop(Status.SEE_OTHER, "https://example.com/new"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), request) + reissued = client.requests[1] + assert reissued.method is Method.GET + assert reissued.body is None + assert "Content-Type" not in reissued.headers + assert "Content-Length" not in reissued.headers + assert "Content-Encoding" not in reissued.headers + + def test_relative_location_resolved_against_base(self) -> None: + client = _ScriptedClient( + [_Hop(Status.MOVED_PERMANENTLY, "/elsewhere"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(url="https://example.com/start")) + assert str(client.requests[1].url) == "https://example.com/elsewhere" + + def test_userinfo_in_location_dropped(self) -> None: + client = _ScriptedClient( + [ + _Hop(Status.MOVED_PERMANENTLY, "https://attacker:pw@example.com/new"), + _Hop(Status.OK), + ], + ) + _run(client, RedirectPolicy(), _request()) + assert client.requests[1].url.userinfo is None + + def test_authorization_stripped_on_cross_origin(self) -> None: + client = _ScriptedClient( + [_Hop(Status.FOUND, "https://other.org/new"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(headers={"Authorization": "Bearer secret"})) + assert "Authorization" not in client.requests[1].headers + + @pytest.mark.req("REDIR-7", "XCUT-17") + def test_authorization_stripped_on_same_origin(self) -> None: + # Authorization is stripped before EVERY reissue — even same-origin. + # Re-stamping a credential for a known origin is the auth layer's job. + client = _ScriptedClient( + [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(headers={"Authorization": "Bearer secret"})) + assert "Authorization" not in client.requests[1].headers + + +# --------------------------------------------------------------------------- # +# Golden Location-resolution corpus (REDIR-12): wire-exact end-to-end. # +# --------------------------------------------------------------------------- # + +_REDIRECT_VECTORS: tuple[RedirectVector, ...] = load_redirect() +_VECTOR_IDS: list[str] = [vector.id for vector in _REDIRECT_VECTORS] + + +class TestGoldenLocationResolution: + @pytest.mark.req("REDIR-12", "REDIR-13") + @pytest.mark.parametrize("vector", _REDIRECT_VECTORS, ids=_VECTOR_IDS) + def test_golden_location_resolves_wire_exact(self, vector: RedirectVector) -> None: + # Drive each golden vector end-to-end: a GET seed at ``base`` gets a 302 + # to ``location``, and the URL the policy actually reissues must equal + # the corpus's expected ``resolved`` byte-for-byte — no decode, no + # re-encode, no normalisation. + client = _ScriptedClient( + [_Hop(Status.FOUND, vector.location.decode()), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(url=vector.base.decode())) + assert len(client.requests) == 2 + assert str(client.requests[1].url) == vector.resolved.decode() + + @pytest.mark.req("REDIR-12", "REDIR-13") + def test_golden_percent_encoding_preserved(self) -> None: + by_id = {vector.id: vector for vector in _REDIRECT_VECTORS} + vector = by_id["preserve-percent-encoding"] + client = _ScriptedClient( + [_Hop(Status.FOUND, vector.location.decode()), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(url=vector.base.decode())) + reissued = str(client.requests[1].url) + assert "%2F" in reissued and "%20" in reissued # never decoded/normalised + + @pytest.mark.req("REDIR-12", "REDIR-13") + @pytest.mark.parametrize( + "vector_id", ["ipv6-brackets-kept-absolute", "ipv6-brackets-kept-relative"] + ) + def test_golden_ipv6_brackets_kept(self, vector_id: str) -> None: + vector = {v.id: v for v in _REDIRECT_VECTORS}[vector_id] + client = _ScriptedClient( + [_Hop(Status.FOUND, vector.location.decode()), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(url=vector.base.decode())) + reissued = str(client.requests[1].url) + assert "[" in reissued and "]" in reissued + assert reissued == vector.resolved.decode() + + +# --------------------------------------------------------------------------- # +# Shared resolution logic — the send loop uses the per-hop helper. # +# --------------------------------------------------------------------------- # + + +class TestSharedResolutionLogic: + def test_send_loop_reissues_via_build_next_request(self) -> None: + # The request the pipeline actually sends on a hop is byte-for-byte what + # ``_build_next_request`` computes for the same inputs — proof the loop + # delegates to the single per-hop resolution helper rather than + # restating the logic inline (the same helper the async twin wraps). + policy = RedirectPolicy(allowed_methods=frozenset({Method.GET, Method.POST})) + client = _ScriptedClient([_Hop(Status.FOUND, "/next?a=b%20c"), _Hop(Status.OK)]) + seed = _request(url="https://example.com/start") + _run(client, policy, seed) + reissued = client.requests[1] + direct = policy._build_next_request(seed, 302, "/next?a=b%20c", _origin(seed.url)) + assert direct is not None + assert str(reissued.url) == str(direct.url) + assert reissued.method is direct.method + + +# --------------------------------------------------------------------------- # +# Credential-hygiene battery dimensions — now certified on the shared core. # +# # +# These once stood as ``xfail(strict=True)`` executable specs (HTTPS->HTTP # +# downgrade denial, cross-origin Cookie / Proxy-Authorization stripping, and # +# seed-origin cross-origin judgment). The shared resolution core now provides # +# them, so they assert directly — the async battery's twins flip in lock-step # +# because it delegates every per-hop decision to this same core. # +# --------------------------------------------------------------------------- # + + +class TestCredentialHygieneBattery: + @pytest.mark.req("REDIR-15", "XCUT-17") + def test_https_to_http_downgrade_default_denied(self) -> None: + # A scheme downgrade is not followed by default: the current response is + # handed back unfollowed so the TLS guarantee cannot silently vanish. + client = _ScriptedClient( + [_Hop(Status.FOUND, "http://example.com/new"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(url="https://example.com/start")) + assert len(client.requests) == 1 # not followed + + @pytest.mark.req("REDIR-15", "XCUT-17") + def test_https_to_http_downgrade_followed_when_opted_in(self) -> None: + # allow_scheme_downgrade=True follows the downgrade (and logs a warning). + client = _ScriptedClient( + [_Hop(Status.FOUND, "http://example.com/new"), _Hop(Status.OK)], + ) + _run( + client, + RedirectPolicy(allow_scheme_downgrade=True), + _request(url="https://example.com/start"), + ) + assert len(client.requests) == 2 # followed + assert str(client.requests[1].url) == "http://example.com/new" + + @pytest.mark.req("REDIR-9", "XCUT-17") + def test_cross_origin_strips_cookie(self) -> None: + client = _ScriptedClient( + [_Hop(Status.FOUND, "https://other.org/new"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(headers={"Cookie": "session=abc"})) + assert "Cookie" not in client.requests[1].headers + + @pytest.mark.req("REDIR-9", "XCUT-17") + def test_cross_origin_strips_proxy_authorization(self) -> None: + client = _ScriptedClient( + [_Hop(Status.FOUND, "https://other.org/new"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request(headers={"Proxy-Authorization": "Basic xyz"})) + assert "Proxy-Authorization" not in client.requests[1].headers + + @pytest.mark.req("REDIR-9") + def test_same_origin_keeps_cookie_and_proxy_authorization(self) -> None: + # A same-origin hop keeps origin-scoped credentials — only Authorization + # is stripped on a same-origin reissue. + client = _ScriptedClient( + [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], + ) + headers = {"Cookie": "session=abc", "Proxy-Authorization": "Basic xyz"} + _run(client, RedirectPolicy(), _request(headers=headers)) + assert client.requests[1].headers.get("Cookie") == "session=abc" + assert client.requests[1].headers.get("Proxy-Authorization") == "Basic xyz" + + @pytest.mark.req("REDIR-8", "XCUT-17") + def test_cross_origin_judged_against_seed_not_previous_hop(self) -> None: + # Seed a.example.com -> b.example.com (foreign) -> a.example.com again. + # The final hop is same-origin relative to the PREVIOUS hop (b) but + # cross-origin relative to the SEED (a). Because origin is judged against + # the seed, the origin-scoped Cookie stays stripped for the whole chain + # rather than being re-exposed when the chain lands back on a's host. + client = _ScriptedClient( + [ + _Hop(Status.FOUND, "https://b.example.com/step"), + _Hop(Status.FOUND, "https://b.example.com/again"), + _Hop(Status.OK), + ], + ) + _run( + client, + RedirectPolicy(), + _request(url="https://a.example.com/start", headers={"Cookie": "session=abc"}), + ) + # Hop 1 (a -> b): cross-origin, Cookie stripped. + assert "Cookie" not in client.requests[1].headers + # Hop 2 (b -> b): same-origin vs the previous hop, but the request no + # longer carries a Cookie to leak; the seed-origin rule kept it stripped. + assert "Cookie" not in client.requests[2].headers + + @pytest.mark.req("REDIR-20") + def test_custom_predicate_overrides_follow_decision(self) -> None: + # A predicate returning False stops a redirect the built-in matrix would + # otherwise follow (a plain 301 for a GET). + conditions: list[RedirectCondition] = [] + + def never(condition: RedirectCondition) -> bool: + conditions.append(condition) + return False + + client = _ScriptedClient( + [_Hop(Status.MOVED_PERMANENTLY, "https://example.com/new"), _Hop(Status.OK)], + ) + response = _run(client, RedirectPolicy(should_redirect=never), _request()) + assert response.status is Status.MOVED_PERMANENTLY + assert len(client.requests) == 1 + assert len(conditions) == 1 + # First decision: zero redirects followed, visited holds only the seed. + assert conditions[0].redirect_count == 0 + assert conditions[0].visited_uris == ("https://example.com/start",) + + @pytest.mark.req("REDIR-20") + def test_custom_predicate_follows_non_default_method(self) -> None: + # A predicate returning True follows a POST 302 the default allow-list + # would refuse — proof it fully overrides the built-in follow decision. + client = _ScriptedClient( + [_Hop(Status.FOUND, "https://example.com/new"), _Hop(Status.OK)], + ) + response = _run( + client, + RedirectPolicy(should_redirect=lambda _condition: True), + _request(method=Method.POST), + ) + assert response.status is Status.OK + assert len(client.requests) == 2 + assert client.requests[1].method is Method.POST + + @pytest.mark.req("REDIR-20") + def test_custom_predicate_snapshot_is_defensive_copy(self) -> None: + # The visited-URI snapshot is an immutable tuple, so a predicate cannot + # mutate the live cycle-detection state. It also includes the current + # request's URI, and the count advances per followed hop. + seen: list[tuple[int, tuple[str, ...]]] = [] + + def record(condition: RedirectCondition) -> bool: + assert isinstance(condition.visited_uris, tuple) # immutable copy + seen.append((condition.redirect_count, condition.visited_uris)) + return True + + client = _ScriptedClient( + [ + _Hop(Status.FOUND, "https://example.com/a"), + _Hop(Status.FOUND, "https://example.com/b"), + _Hop(Status.OK), + ], + ) + _run(client, RedirectPolicy(should_redirect=record), _request()) + assert seen[0] == (0, ("https://example.com/start",)) + assert seen[1] == (1, ("https://example.com/start", "https://example.com/a")) + + def test_location_resolution_is_wire_exact(self) -> None: + # A %20 in the target query must survive verbatim. Fixed by the pure + # RFC 3986 query codec (PY-M2-02), which closed this battery gap — + # no longer xfail; see docs/deviations.md. + client = _ScriptedClient( + [_Hop(Status.FOUND, "https://example.com/p?x=%20y"), _Hop(Status.OK)], + ) + _run(client, RedirectPolicy(), _request()) + assert "%20" in str(client.requests[1].url) diff --git a/packages/dexpace-sdk-core/tests/pipeline/test_tracer_emission.py b/packages/dexpace-sdk-core/tests/pipeline/test_tracer_emission.py index 5de81fa..893d0a3 100644 --- a/packages/dexpace-sdk-core/tests/pipeline/test_tracer_emission.py +++ b/packages/dexpace-sdk-core/tests/pipeline/test_tracer_emission.py @@ -401,6 +401,7 @@ def execute(self, request: Request) -> Response: class TestOperationEventsFireOncePerOperation: + @pytest.mark.req("OBS-29") def test_retry_emits_operation_lifecycle_once_across_attempts(self) -> None: # TracingPolicy sits *inside* RetryPolicy (retry is outer), so it is # re-entered once per attempt. The operation_* events must still fire @@ -427,6 +428,7 @@ def test_retry_emits_operation_lifecycle_once_across_attempts(self) -> None: assert names[0] == "operation_started" assert names.index("operation_started") < names.index("operation_succeeded") + @pytest.mark.req("OBS-29") def test_retry_exhaustion_emits_operation_failed_once(self) -> None: tracer = _RecordingHttpTracer() instr = _instr(tracer) @@ -499,6 +501,7 @@ def test_retry_then_success_reports_operation_succeeded(self) -> None: # request_sent still fires per attempt (the failed one and the retry). assert names.count("request_sent") == 2 + @pytest.mark.req("OBS-29") def test_redirect_then_failure_reports_operation_failed(self) -> None: # When a later redirect hop fails, the operation outcome is the failure # that escapes — not the success of the earlier 3xx hop. diff --git a/packages/dexpace-sdk-core/tests/serde/test_codec.py b/packages/dexpace-sdk-core/tests/serde/test_codec.py index cc169ab..861d5ad 100644 --- a/packages/dexpace-sdk-core/tests/serde/test_codec.py +++ b/packages/dexpace-sdk-core/tests/serde/test_codec.py @@ -112,6 +112,7 @@ def test_decode_raises_when_dataclass_target_is_not_a_mapping(codec: Codec) -> N # --------------------------------------------------------------------------- # +@pytest.mark.req("SERDE-23") def test_decode_tolerates_unknown_keys_by_default(codec: Codec) -> None: model = codec.decode({**_BASE_DOC, "future_field": 1}, _Model) assert model.name == "alice" @@ -129,6 +130,7 @@ def test_decode_rejects_unknown_keys_when_configured() -> None: # --------------------------------------------------------------------------- # +@pytest.mark.req("SERDE-16", "SERDE-17") def test_decode_missing_tristate_key_uses_default_absent(codec: Codec) -> None: model = codec.decode(_BASE_DOC, _Model) assert model.note is ABSENT @@ -139,16 +141,19 @@ def test_decode_present_tristate_value_wraps_in_present(codec: Codec) -> None: assert model.note == Present("hi") +@pytest.mark.req("SERDE-16") def test_decode_null_tristate_value_becomes_null(codec: Codec) -> None: model = codec.decode({**_BASE_DOC, "note": None}, _Model) assert model.note is NULL +@pytest.mark.req("SERDE-15") def test_encode_omits_absent_tristate_field(codec: Codec) -> None: model = codec.decode(_BASE_DOC, _Model) assert "note" not in codec.encode(model) # type: ignore[operator] +@pytest.mark.req("SERDE-15") def test_encode_writes_null_for_null_tristate_field(codec: Codec) -> None: model = codec.decode({**_BASE_DOC, "note": None}, _Model) encoded = codec.encode(model) @@ -156,6 +161,7 @@ def test_encode_writes_null_for_null_tristate_field(codec: Codec) -> None: assert encoded["note"] is None +@pytest.mark.req("SERDE-15") def test_encode_writes_value_for_present_tristate_field(codec: Codec) -> None: model = codec.decode({**_BASE_DOC, "note": "hi"}, _Model) encoded = codec.encode(model) @@ -163,6 +169,7 @@ def test_encode_writes_value_for_present_tristate_field(codec: Codec) -> None: assert encoded["note"] == "hi" +@pytest.mark.req("SERDE-16") def test_decode_tristate_recurses_into_inner_type() -> None: @dataclass(frozen=True, slots=True) class Wrapped: @@ -203,6 +210,7 @@ def test_encode_uses_wire_name_for_aliased_field(codec: Codec) -> None: assert "nick" not in encoded +@pytest.mark.req("SERDE-24") def test_encode_emits_iso_string_for_datetime(codec: Codec) -> None: model = codec.decode(_BASE_DOC, _Model) encoded = codec.encode(model) @@ -244,6 +252,7 @@ def test_encode_non_utf8_bytes_raises_serialization_error(codec: Codec) -> None: # --------------------------------------------------------------------------- # +@pytest.mark.req("SERDE-5", "SERDE-7") def test_decode_list_of_models(codec: Codec) -> None: decoded = codec.decode([{"x": 1}, {"x": 2}], list[_Inner]) assert decoded == [_Inner(1), _Inner(2)] @@ -839,6 +848,7 @@ class Clashing: # --------------------------------------------------------------------------- # +@pytest.mark.req("SERDE-17") def test_tristate_field_without_default_is_absent_when_key_omitted(codec: Codec) -> None: @dataclass(frozen=True, slots=True) class HasTristate: @@ -858,6 +868,7 @@ class HasTristate: assert codec.decode({"note": "hi"}, HasTristate).note == Present("hi") +@pytest.mark.req("SERDE-17") def test_defaultless_annotated_tristate_field_is_absent_when_key_omitted(codec: Codec) -> None: # An ``Annotated[Tristate[X], meta]`` field with no default must still # decode to ABSENT on an omitted key, exactly like the bare Tristate form. diff --git a/packages/dexpace-sdk-core/tests/serde/test_json_serde.py b/packages/dexpace-sdk-core/tests/serde/test_json_serde.py index c858d81..f5dc690 100644 --- a/packages/dexpace-sdk-core/tests/serde/test_json_serde.py +++ b/packages/dexpace-sdk-core/tests/serde/test_json_serde.py @@ -21,6 +21,7 @@ def test_round_trip_basic_values() -> None: assert JSON_SERDE.deserializer.deserialize(text) == payload +@pytest.mark.req("SERDE-24") def test_datetime_encoded_as_iso() -> None: when = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) text = JSON_SERDE.serializer.serialize({"at": when}) @@ -40,6 +41,7 @@ def test_serialize_to_stream() -> None: assert stream.getvalue() == b'{"a":1}' +@pytest.mark.req("SERDE-3") def test_deserialize_stream() -> None: stream = BytesIO(b'{"x": "y"}') assert JSON_SERDE.deserializer.deserialize_stream(stream) == {"x": "y"} @@ -79,6 +81,7 @@ def test_sort_keys_emitted_sorted() -> None: assert text == '{"a":2,"b":1}' +@pytest.mark.req("SEAM-19", "SERDE-1") def test_serde_uses_provided_components() -> None: custom_ser = JsonSerializer(sort_keys=True) custom_des = JsonDeserializer() diff --git a/packages/dexpace-sdk-core/tests/serde/test_response_handler.py b/packages/dexpace-sdk-core/tests/serde/test_response_handler.py new file mode 100644 index 0000000..ab4258d --- /dev/null +++ b/packages/dexpace-sdk-core/tests/serde/test_response_handler.py @@ -0,0 +1,322 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the response-handler family. + +Two axes are exercised separately, matching the design split: + +- the pure `classify_status` decision — a status band maps to a + `ResponseDisposition` with no side effects (no reading, closing, or raising); + and +- the `DecodeBody` / `DecodeSuccess` orchestration — reading and decoding the + body, closing the response on every path, and raising the right typed error + for non-success responses. + +Imports come straight from `dexpace.sdk.core.serde.response_handler` so the +suite does not depend on the package re-export landing. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any + +import pytest + +from dexpace.sdk.core.codegen import StatusErrorMap +from dexpace.sdk.core.errors import ( + ERROR_BODY_MAX_BYTES, + DeserializationError, + HttpResponseError, + SerdeError, +) +from dexpace.sdk.core.http.common import MediaType, Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Response, ResponseBody, Status +from dexpace.sdk.core.serde.response_handler import ( + DecodeBody, + DecodeSuccess, + ResponseDisposition, + ResponseHandler, + classify_status, +) + +# --------------------------------------------------------------------------- # +# Fixtures / helpers # +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class _Pet: + name: str + age: int + + +def _request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://example.com/")) + + +def _response(status: Status, *, body: ResponseBody | None = None) -> Response: + return Response(request=_request(), protocol=Protocol.HTTP_1_1, status=status, body=body) + + +def _tracking_response( + status: Status, *, body: ResponseBody | None = None +) -> tuple[Response, list[int]]: + """Return a response whose ``close`` appends to the returned list. + + Lets a test observe that the handler closed the response even on the + missing-body path, where there is no body to spy on. + """ + closes: list[int] = [] + + class _Tracking(Response): + __slots__ = () + + def close(self) -> None: + closes.append(1) + super().close() + + resp = _Tracking(request=_request(), protocol=Protocol.HTTP_1_1, status=status, body=body) + return resp, closes + + +class _IoErrorBody(ResponseBody): + """A body whose read fails with a genuine ``OSError`` (real I/O failure).""" + + def __init__(self) -> None: + self.closed = False + + def media_type(self) -> MediaType | None: + return None + + def content_length(self) -> int: + return -1 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + raise OSError("the socket went away mid-read") + + def close(self) -> None: + self.closed = True + + +def _json_body(data: bytes) -> ResponseBody: + return ResponseBody.from_bytes(data, MediaType.parse("application/json")) + + +# --------------------------------------------------------------------------- # +# The pure classification decision — status band only, no side effects. # +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("status", [200, 201, 204, 206, 226, 299], ids=str) +def test_classify_status_maps_2xx_to_success(status: int) -> None: + assert classify_status(Status(status)) is ResponseDisposition.SUCCESS + + +@pytest.mark.parametrize("status", [400, 404, 409, 418, 451, 500, 503, 599], ids=str) +def test_classify_status_maps_4xx_5xx_to_error(status: int) -> None: + assert classify_status(Status(status)) is ResponseDisposition.ERROR + + +@pytest.mark.parametrize("status", [100, 101, 103, 300, 301, 302, 304, 308, 399], ids=str) +def test_classify_status_maps_1xx_3xx_to_unexpected(status: int) -> None: + assert classify_status(Status(status)) is ResponseDisposition.UNEXPECTED + + +def test_classify_status_is_side_effect_free() -> None: + # The pure decision never touches the body: classifying a response backed + # by a body that would explode on read must not read (or close) it. + resp = _response(Status.OK, body=_IoErrorBody()) + assert classify_status(resp.status) is ResponseDisposition.SUCCESS + assert isinstance(resp.body, _IoErrorBody) + assert resp.body.closed is False + + +# --------------------------------------------------------------------------- # +# DecodeBody orchestration — decode regardless of status; close on EVERY path. # +# --------------------------------------------------------------------------- # + + +def test_decode_body_decodes_into_target() -> None: + resp = _response(Status.OK, body=_json_body(b'{"name":"Rex","age":3}')) + assert DecodeBody()(resp, _Pet) == _Pet(name="Rex", age=3) + + +def test_decode_body_decodes_generic_target() -> None: + resp = _response(Status.OK, body=_json_body(b'[{"name":"Rex","age":3}]')) + assert DecodeBody()(resp, list[_Pet]) == [_Pet(name="Rex", age=3)] + + +@pytest.mark.parametrize( + ("make_body", "expected"), + [ + (lambda: _json_body(b'{"name":"Rex","age":3}'), None), # success + (lambda: _json_body(b'{"name":"Rex"}'), DeserializationError), # codec failure + (lambda: None, DeserializationError), # missing body + ], + ids=["success", "codec-failure", "missing-body"], +) +def test_decode_body_closes_response_on_every_path( + make_body: Any, expected: type[Exception] | None +) -> None: + resp, closes = _tracking_response(Status.OK, body=make_body()) + if expected is None: + assert DecodeBody()(resp, _Pet) == _Pet(name="Rex", age=3) + else: + with pytest.raises(expected): + DecodeBody()(resp, _Pet) + assert closes == [1] # closed exactly once, on every path + + +def test_decode_body_missing_body_names_the_target_type() -> None: + resp = _response(Status.OK, body=None) + with pytest.raises(DeserializationError, match="_Pet") as excinfo: + DecodeBody()(resp, _Pet) + # A serde-family error, not a response-branch HttpResponseError. + assert isinstance(excinfo.value, SerdeError) + assert not isinstance(excinfo.value, HttpResponseError) + + +def test_decode_body_codec_failure_is_chained_not_swallowed() -> None: + resp = _response(Status.OK, body=_json_body(b"not valid json")) + with pytest.raises(SerdeError) as excinfo: + DecodeBody()(resp, _Pet) + # Chained: the underlying serde/codec failure is preserved as the cause. + assert excinfo.value.__cause__ is not None + + +@pytest.mark.req("SERDE-12") +def test_decode_body_propagates_genuine_oserror_unwrapped() -> None: + resp, closes = _tracking_response(Status.OK, body=_IoErrorBody()) + with pytest.raises(OSError) as excinfo: + DecodeBody()(resp, _Pet) + # A real I/O failure is NOT a decode-shape problem: it stays an OSError and + # is never re-wrapped onto the serde branch. + assert not isinstance(excinfo.value, SerdeError) + assert closes == [1] # still closed + + +def test_decode_body_is_positional_only() -> None: + resp = _response(Status.OK, body=_json_body(b'{"name":"Rex","age":3}')) + with pytest.raises(TypeError): + DecodeBody()(response=resp, response_type=_Pet) # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- # +# DecodeSuccess orchestration — 2xx decodes; 4xx/5xx mapped; 1xx/3xx status-led.# +# --------------------------------------------------------------------------- # + + +class _ServiceError(HttpResponseError[Any]): + """Synthetic service base error on the response branch only.""" + + +class _NotFoundError(_ServiceError): + """The example service's 404 error.""" + + +@pytest.mark.req("SERDE-28") +def test_decode_success_decodes_2xx_body() -> None: + resp, closes = _tracking_response(Status.OK, body=_json_body(b'{"name":"Rex","age":3}')) + handler = DecodeSuccess(StatusErrorMap()) + assert handler(resp, _Pet) == _Pet(name="Rex", age=3) + assert closes == [1] # the success path still closes the response + + +def test_decode_success_2xx_without_body_names_target() -> None: + resp, closes = _tracking_response(Status.NO_CONTENT, body=None) + handler = DecodeSuccess(StatusErrorMap()) + with pytest.raises(DeserializationError, match="_Pet"): + handler(resp, _Pet) + assert closes == [1] + + +@pytest.mark.req("SERDE-28") +def test_decode_success_raises_mapped_error_for_client_error() -> None: + errors = StatusErrorMap(by_status={Status.NOT_FOUND: _NotFoundError}) + resp, closes = _tracking_response(Status.NOT_FOUND, body=_json_body(b'{"error":"missing"}')) + handler = DecodeSuccess(errors) + with pytest.raises(_NotFoundError): + handler(resp, _Pet) + assert closes == [1] + + +@pytest.mark.req("SERDE-28") +def test_decode_success_error_carries_bounded_replayable_body() -> None: + # The 4xx/5xx path buffers a bounded, replayable copy of the body via + # StatusErrorMap.raise_for. + payload = b"x" * (ERROR_BODY_MAX_BYTES + 4096) + resp = _response(Status.INTERNAL_SERVER_ERROR, body=ResponseBody.from_bytes(payload)) + handler = DecodeSuccess(StatusErrorMap()) + with pytest.raises(HttpResponseError) as excinfo: + handler(resp, _Pet) + err = excinfo.value + first = err.read_body() + assert len(first) == ERROR_BODY_MAX_BYTES # bounded to the documented cap + assert err.read_body() == first # replayable + + +@pytest.mark.req("SERDE-28") +@pytest.mark.parametrize( + "status", + [Status.CONTINUE, Status.SWITCHING_PROTOCOLS, Status.MOVED_PERMANENTLY, Status.NOT_MODIFIED], + ids=["100", "101", "301", "304"], +) +def test_decode_success_unexpected_status_leads_message_with_code(status: Status) -> None: + resp, closes = _tracking_response(status, body=_json_body(b'{"name":"Rex","age":3}')) + handler = DecodeSuccess(StatusErrorMap()) + with pytest.raises(HttpResponseError) as excinfo: + handler(resp, _Pet) + # The status code LEADS the error message. + assert str(excinfo.value).startswith(str(int(status))) + assert closes == [1] # closed on the unexpected path too + + +def test_decode_success_unexpected_status_does_not_consult_the_error_map() -> None: + # raise_for rejects a non-error status; the unexpected branch must not route + # a 3xx through it (that would surface a ValueError instead of a clean raise). + resp = _response(Status.FOUND, body=_json_body(b"{}")) + handler = DecodeSuccess(StatusErrorMap(default=_ServiceError)) + with pytest.raises(HttpResponseError) as excinfo: + handler(resp, _Pet) + assert not isinstance(excinfo.value, ValueError) + assert str(excinfo.value).startswith("302") + + +def test_decode_success_dispatch_matches_the_pure_classifier() -> None: + # The handler dispatches on exactly the pure decision — a 2xx is SUCCESS and + # decodes; a 4xx is ERROR and raises; the two never disagree. + assert classify_status(Status.OK) is ResponseDisposition.SUCCESS + assert classify_status(Status.BAD_REQUEST) is ResponseDisposition.ERROR + ok = _response(Status.OK, body=_json_body(b'{"name":"Rex","age":3}')) + assert DecodeSuccess(StatusErrorMap())(ok, _Pet) == _Pet("Rex", 3) + bad = _response(Status.BAD_REQUEST, body=_json_body(b"{}")) + with pytest.raises(HttpResponseError): + DecodeSuccess(StatusErrorMap())(bad, _Pet) + + +# --------------------------------------------------------------------------- # +# Protocol conformance — handlers are swappable ResponseHandlers. # +# --------------------------------------------------------------------------- # + + +def test_shipped_handlers_satisfy_the_protocol() -> None: + assert isinstance(DecodeBody(), ResponseHandler) + assert isinstance(DecodeSuccess(StatusErrorMap()), ResponseHandler) + + +def test_alternate_handler_can_be_swapped_in() -> None: + # A raw-download handler ignores the target and returns bytes — a valid + # ResponseHandler that does no decoding. + class _RawBytes: + def __call__(self, response: Response, response_type: Any, /) -> Any: + try: + return response.body.bytes() if response.body is not None else b"" + finally: + response.close() + + handler: ResponseHandler = _RawBytes() + resp = _response(Status.OK, body=_json_body(b"raw-payload")) + assert handler(resp, _Pet) == b"raw-payload" diff --git a/packages/dexpace-sdk-core/tests/serde/test_serde_conformance.py b/packages/dexpace-sdk-core/tests/serde/test_serde_conformance.py new file mode 100644 index 0000000..aa2d3f0 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/serde/test_serde_conformance.py @@ -0,0 +1,423 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Conformance suite for the strict JSON binder and the serde failure model. + +Certifies the shipped ``Codec`` + ``JsonSerde`` against the strict coercion +table, the ``SerdeError`` failure taxonomy, codec hygiene, and the streaming / +buffer profile. Imports go through the package ``__init__`` re-exports, which +form the public serde surface. +""" + +from __future__ import annotations + +import json +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import UTC, datetime +from io import BytesIO +from typing import Any + +import pytest + +from dexpace.sdk.core.errors import ( + DeserializationError, + SdkError, + SerdeError, + SerializationError, +) +from dexpace.sdk.core.http.common import common_media_types +from dexpace.sdk.core.serde import ( + JSON_SERDE, + Codec, + CodecError, + JsonDeserializer, + JsonSerde, + JsonSerializer, +) + + +@dataclass(frozen=True, slots=True) +class _Inner: + x: int + + +@dataclass(frozen=True, slots=True) +class _Event: + name: str + at: datetime + + +@pytest.fixture +def codec() -> Codec: + return Codec() + + +# --------------------------------------------------------------------------- # +# Strict coercion table # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SERDE-22") +@pytest.mark.parametrize( + ("wire", "target", "expected", "expected_type"), + [ + ("5", int, "5", str), # string is NOT coerced into int + (1.5, int, 1.5, float), # float is NOT truncated into int + (1, float, 1.0, float), # int IS widened into float + (1.0, float, 1.0, float), # float stays float + (5, int, 5, int), # int stays int + ("", str, "", str), # empty string is a permitted str + ("hello", str, "hello", str), # non-empty string passes through + ], +) +def test_coercion_matrix( + codec: Codec, + wire: object, + target: type, + expected: object, + expected_type: type, +) -> None: + result: object = codec.decode(wire, target) + assert result == expected + # Type identity matters: no-coercion rules must not silently change the + # Python type, and int->float widening must actually yield a float. + assert type(result) is expected_type + + +@pytest.mark.req("SERDE-22") +def test_int_into_float_field_is_widened(codec: Codec) -> None: + @dataclass(frozen=True, slots=True) + class HasFloat: + ratio: float + + decoded = codec.decode({"ratio": 3}, HasFloat) + assert decoded.ratio == 3.0 + assert type(decoded.ratio) is float + + +@pytest.mark.req("SERDE-22") +def test_bool_into_float_is_not_widened(codec: Codec) -> None: + # ``bool`` is only nominally an ``int``; widening it to ``float`` would be a + # coercion, so it passes through untouched. + result: object = codec.decode(True, float) + assert result is True + + +@pytest.mark.req("SERDE-23") +def test_unknown_wire_fields_are_ignored(codec: Codec) -> None: + decoded = codec.decode({"x": 1, "future_field": "ignored"}, _Inner) + assert decoded == _Inner(1) + + +@pytest.mark.req("SERDE-24") +def test_iso8601_datetime_round_trips(codec: Codec) -> None: + model = _Event("launch", datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC)) + document = codec.encode(model) + assert isinstance(document, dict) + assert document["at"] == "2026-01-02T03:04:05+00:00" + assert codec.decode(document, _Event) == model + + +# --------------------------------------------------------------------------- # +# null into a non-Optional target names the target type (every overload) # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SERDE-13") +@pytest.mark.parametrize("target", [str, int, float, bool, _Inner]) +def test_null_into_non_optional_type_target_names_target(codec: Codec, target: type) -> None: + # The direct type-target path: decoding ``null`` against a concrete, + # non-optional target names the target type in the error. + with pytest.raises(CodecError) as info: + codec.decode(None, target) + assert target.__name__ in str(info.value) + + +@pytest.mark.req("SERDE-13") +def test_null_into_non_optional_dataclass_field_names_target(codec: Codec) -> None: + @dataclass(frozen=True, slots=True) + class HasName: + name: str + + with pytest.raises(CodecError) as info: + codec.decode({"name": None}, HasName) + assert "str" in str(info.value) + + +@pytest.mark.req("SERDE-13") +def test_null_into_non_optional_list_element_names_target(codec: Codec) -> None: + with pytest.raises(CodecError) as info: + codec.decode([None], list[str]) + assert "str" in str(info.value) + + +def test_null_into_non_optional_tuple_element_names_target(codec: Codec) -> None: + with pytest.raises(CodecError) as info: + codec.decode([None], tuple[str]) + assert "str" in str(info.value) + + +def test_null_into_non_optional_set_element_names_target(codec: Codec) -> None: + with pytest.raises(CodecError) as info: + codec.decode([None], set[str]) + assert "str" in str(info.value) + + +@pytest.mark.req("SERDE-13") +def test_null_into_non_optional_mapping_value_names_target(codec: Codec) -> None: + with pytest.raises(CodecError) as info: + codec.decode({"a": None}, dict[str, int]) + assert "int" in str(info.value) + + +def test_null_into_optional_field_is_accepted(codec: Codec) -> None: + @dataclass(frozen=True, slots=True) + class HasOptional: + name: str | None = None + + assert codec.decode({"name": None}, HasOptional).name is None + + +# --------------------------------------------------------------------------- # +# SerdeError failure taxonomy # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-23", "SERDE-10") +def test_serde_error_roots_the_taxonomy() -> None: + assert issubclass(SerializationError, SerdeError) + assert issubclass(DeserializationError, SerdeError) + assert issubclass(SerdeError, SdkError) + assert issubclass(SerdeError, ValueError) + # CodecError stays a decode-side failure. + assert issubclass(CodecError, DeserializationError) + + +@pytest.mark.req("SERDE-10") +def test_serde_error_subtypes_are_directional() -> None: + assert not issubclass(SerializationError, DeserializationError) + assert not issubclass(DeserializationError, SerializationError) + + +@pytest.mark.req("SEAM-23", "SERDE-11") +def test_serde_errors_are_unchecked() -> None: + # "Unchecked" in the Python sense: an ordinary exception nothing forces a + # caller to catch. + assert issubclass(SerdeError, Exception) + + +@pytest.mark.req("SERDE-10") +def test_catching_serde_error_catches_both_directions() -> None: + caught: list[str] = [] + try: + JSON_SERDE.deserializer.deserialize("{not json") + except SerdeError: + caught.append("decode") + try: + JsonSerializer().serialize({"x": object()}) + except SerdeError: + caught.append("encode") + assert caught == ["decode", "encode"] + + +@pytest.mark.req("SEAM-21", "SERDE-9") +def test_deserialization_error_chains_cause() -> None: + with pytest.raises(DeserializationError) as info: + JSON_SERDE.deserializer.deserialize("{not json") + assert isinstance(info.value.__cause__, json.JSONDecodeError) + + +@pytest.mark.req("SEAM-20", "SERDE-9") +def test_serialization_error_chains_cause() -> None: + with pytest.raises(SerializationError) as info: + JsonSerializer().serialize({"k": b"\xff\xfe"}) + assert info.value.__cause__ is not None + + +@pytest.mark.req("SEAM-23", "SERDE-9") +def test_unserializable_value_is_wrapped_not_leaked() -> None: + # A stdlib TypeError from json must not escape the serde boundary unwrapped. + with pytest.raises(SerializationError): + JsonSerializer().serialize({"x": object()}) + + +# --------------------------------------------------------------------------- # +# OSError passes through the serde boundary unwrapped # +# --------------------------------------------------------------------------- # + + +class _BoomStream: + """A stream whose I/O always fails with a genuine ``OSError``.""" + + def read(self, size: int = -1, /) -> bytes: + raise OSError("simulated read failure") + + def write(self, data: Any, /) -> int: + raise OSError("simulated write failure") + + +@pytest.mark.req("SEAM-20", "SERDE-12") +def test_serialize_to_stream_propagates_oserror_unwrapped() -> None: + with pytest.raises(OSError) as info: + JsonSerializer().serialize_to_stream({"a": 1}, _BoomStream()) # type: ignore[arg-type] + assert not isinstance(info.value, SerdeError) + + +@pytest.mark.req("SEAM-21", "SERDE-12") +def test_deserialize_stream_propagates_oserror_unwrapped() -> None: + with pytest.raises(OSError) as info: + JsonDeserializer().deserialize_stream(_BoomStream()) # type: ignore[arg-type] + assert not isinstance(info.value, SerdeError) + + +# --------------------------------------------------------------------------- # +# Round-trip through the SDK's own serializer / deserializer pair # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SERDE-1") +def test_round_trip_through_serde_pair() -> None: + serde = JsonSerde() + payload = {"name": "alice", "nums": [1, 2, 3], "nested": {"ok": True}} + text = serde.serializer.serialize(payload) + assert serde.deserializer.deserialize(text) == payload + + +def test_round_trip_model_through_codec_and_serde_pair() -> None: + serde = JsonSerde() + codec = Codec() + model = _Event("launch", datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC)) + text = serde.serializer.serialize(codec.encode(model)) + restored = codec.decode(serde.deserializer.deserialize(text), _Event) + assert restored == model + + +def test_round_trip_through_bytes_pair() -> None: + serde = JsonSerde() + payload = {"hello": "world"} + raw = serde.serializer.serialize_to_bytes(payload) + assert serde.deserializer.deserialize_bytes(raw) == payload + + +# --------------------------------------------------------------------------- # +# Codec hygiene: fresh instances, no mutation, thread-safety # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SERDE-26") +def test_serializing_does_not_mutate_caller_supplied_codec() -> None: + codec = Codec(tolerate_unknown=False) + before = codec._tolerate_unknown + for _ in range(4): + codec.encode(_Inner(1)) + assert codec.decode({"x": 2}, _Inner) == _Inner(2) + # The only instance state is unchanged, and no stray attribute was added + # (slots make an accidental write raise, but assert intent explicitly). + assert codec._tolerate_unknown == before + assert Codec.__slots__ == ("_tolerate_unknown",) + + +@pytest.mark.req("SERDE-25") +def test_distinct_codec_instances_do_not_share_state() -> None: + tolerant = Codec(tolerate_unknown=True) + strict = Codec(tolerate_unknown=False) + assert tolerant is not strict + assert tolerant.decode({"x": 1, "extra": 2}, _Inner) == _Inner(1) + with pytest.raises(CodecError): + strict.decode({"x": 1, "extra": 2}, _Inner) + + +@pytest.mark.req("SERDE-25") +def test_json_serde_factory_returns_independent_bundles() -> None: + one = JsonSerde() + two = JsonSerde() + assert one is not two + assert one.serializer is not two.serializer + assert one.deserializer is not two.deserializer + + +@pytest.mark.req("SERDE-29") +def test_configured_codec_is_thread_safe() -> None: + codec = Codec() + doc = {"x": 7} + + def decode_many() -> list[_Inner]: + return [codec.decode(doc, _Inner) for _ in range(200)] + + with ThreadPoolExecutor(max_workers=8) as pool: + results = [f.result() for f in [pool.submit(decode_many) for _ in range(8)]] + assert all(item == _Inner(7) for batch in results for item in batch) + + +# --------------------------------------------------------------------------- # +# Media type is declared by the bundle, not defaulted at the SPI boundary # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-19", "SERDE-2") +def test_json_serde_declares_application_json_media_type() -> None: + assert JsonSerde().media_type is common_media_types.APPLICATION_JSON + + +@pytest.mark.req("SERDE-2") +def test_singleton_serde_declares_media_type() -> None: + assert JSON_SERDE.media_type is common_media_types.APPLICATION_JSON + + +# --------------------------------------------------------------------------- # +# Streaming / buffer profile # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-20", "SERDE-3") +def test_serialize_to_stream_does_not_close_stream() -> None: + stream = BytesIO() + JsonSerializer().serialize_to_stream({"a": 1}, stream) + assert not stream.closed + stream.write(b"!") + assert stream.getvalue() == b'{"a":1}!' + + +@pytest.mark.req("SERDE-4") +def test_serialize_into_writes_and_returns_length() -> None: + buffer = bytearray(32) + written = JsonSerializer().serialize_into({"a": 1}, buffer) + assert written == len(b'{"a":1}') + assert bytes(buffer[:written]) == b'{"a":1}' + + +@pytest.mark.req("SERDE-3", "SERDE-4") +def test_serialize_into_honors_offset() -> None: + buffer = bytearray(b"HEAD" + b"\x00" * 16) + written = JsonSerializer().serialize_into({"a": 1}, buffer, offset=4) + assert bytes(buffer[4 : 4 + written]) == b'{"a":1}' + assert bytes(buffer[:4]) == b"HEAD" # region before the offset is untouched + + +def test_serialize_into_accepts_writable_memoryview() -> None: + backing = bytearray(16) + written = JsonSerializer().serialize_into({"a": 1}, memoryview(backing)) + assert bytes(backing[:written]) == b'{"a":1}' + + +@pytest.mark.req("SEAM-20", "SERDE-4") +def test_serialize_into_overflow_raises_range_error_not_serde_error() -> None: + buffer = bytearray(3) + with pytest.raises(ValueError) as info: + JsonSerializer().serialize_into({"a": 1}, buffer) + # An overflow is a caller/range error, not a serde-shape failure. + assert not isinstance(info.value, SerdeError) + + +@pytest.mark.req("SERDE-4") +def test_serialize_into_negative_offset_raises_value_error() -> None: + with pytest.raises(ValueError): + JsonSerializer().serialize_into({"a": 1}, bytearray(16), offset=-1) + + +def test_serialize_into_unserializable_value_raises_before_writing() -> None: + buffer = bytearray(64) + with pytest.raises(SerializationError): + JsonSerializer().serialize_into({"x": object()}, buffer) + assert bytes(buffer) == bytes(64) # nothing was written diff --git a/packages/dexpace-sdk-core/tests/serde/test_tristate.py b/packages/dexpace-sdk-core/tests/serde/test_tristate.py index 0fc6607..6e7d807 100644 --- a/packages/dexpace-sdk-core/tests/serde/test_tristate.py +++ b/packages/dexpace-sdk-core/tests/serde/test_tristate.py @@ -7,12 +7,15 @@ import copy import pickle +from dataclasses import dataclass import pytest from dexpace.sdk.core.serde import ( ABSENT, + JSON_SERDE, NULL, + Codec, Present, Tristate, fold, @@ -59,6 +62,7 @@ def test_absent_and_null_are_singletons() -> None: assert type(NULL)() is NULL +@pytest.mark.req("SERDE-30") def test_sentinel_reprs() -> None: assert repr(ABSENT) == "ABSENT" assert repr(NULL) == "NULL" @@ -76,6 +80,7 @@ def test_singletons_survive_pickle() -> None: assert pickle.loads(pickle.dumps(NULL)) is NULL +@pytest.mark.req("SERDE-18") @pytest.mark.parametrize( ("value", "expected"), [ @@ -91,12 +96,14 @@ def test_of_optional_maps_none_to_null(value: object, expected: Tristate[object] assert of_optional(value) == expected +@pytest.mark.req("SERDE-18") def test_of_optional_never_returns_absent() -> None: # of_optional never yields ABSENT; the identity check can't statically overlap. assert of_optional(None) is not ABSENT # type: ignore[comparison-overlap] assert of_optional("x") is not ABSENT # type: ignore[comparison-overlap] +@pytest.mark.req("SERDE-14") def test_guards_are_mutually_exclusive() -> None: cases: list[Tristate[int]] = [ABSENT, NULL, present(7)] for state in cases: @@ -173,6 +180,7 @@ def test_fold_present_passes_falsy_value() -> None: assert result == 0 +@pytest.mark.req("SERDE-18") def test_fold_is_exhaustive_over_all_inhabitants() -> None: def describe(state: Tristate[int]) -> str: return fold( @@ -201,3 +209,106 @@ def encode(field: str, state: Tristate[object]) -> dict[str, object]: assert encode("name", ABSENT) == {} assert encode("name", NULL) == {"name": None} assert encode("name", present("Ada")) == {"name": "Ada"} + + +# --------------------------------------------------------------------------- # +# Present(None) is unrepresentable # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SERDE-14") +def test_present_rejects_none() -> None: + # "Present but null" is the NULL variant, never Present(None); the wrapper + # must refuse None so the absent/null/present partition stays disjoint. + with pytest.raises(ValueError, match="Present"): + Present(None) + + +@pytest.mark.req("SERDE-14") +def test_present_helper_rejects_none() -> None: + with pytest.raises(ValueError, match="Present"): + present(None) + + +# --------------------------------------------------------------------------- # +# Pattern-matching ergonomics # +# --------------------------------------------------------------------------- # + + +def test_present_exposes_match_args() -> None: + assert Present.__match_args__ == ("value",) + + +def test_match_statement_distinguishes_all_three() -> None: + def describe(state: Tristate[int]) -> str: + match state: + case Present(v): + return f"present:{v}" + case _ if is_null(state): + return "null" + case _: + return "absent" + + assert describe(present(7)) == "present:7" + assert describe(NULL) == "null" + assert describe(ABSENT) == "absent" + # Falsy present values still match the Present arm, not a null/absent arm. + assert describe(present(0)) == "present:0" + + +# --------------------------------------------------------------------------- # +# Codec / wire round-trip over the three shapes # +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class _Patch: + """A minimal PATCH-style model with a single tristate field.""" + + x: Tristate[str] = ABSENT + + +@pytest.mark.req("SERDE-19") +@pytest.mark.parametrize( + ("wire", "state"), + [ + ({}, ABSENT), + ({"x": None}, NULL), + ({"x": "v"}, Present("v")), + ], + ids=["absent-omits-key", "null-explicit", "present-value"], +) +def test_codec_round_trip_triple(wire: dict[str, object], state: Tristate[str]) -> None: + # Decode inverts the three wire shapes back to their tristate inhabitant, + # and encoding that inhabitant reproduces the exact same wire shape. + codec = Codec() + decoded = codec.decode(wire, _Patch) + assert decoded.x == state + assert codec.encode(decoded) == wire + + +@pytest.mark.req("SERDE-19") +def test_default_serde_round_trips_tristate_over_json() -> None: + # The default codec handles Tristate with no opt-in, and pairing it with + # the default JSON serde preserves each shape across a full wire round-trip. + codec = Codec() + serializer = JSON_SERDE.serializer + deserializer = JSON_SERDE.deserializer + for wire in ({}, {"x": None}, {"x": "v"}): + model = codec.decode(wire, _Patch) + text = serializer.serialize(codec.encode(model)) + assert deserializer.deserialize(text) == wire + + +@pytest.mark.req("SERDE-20") +def test_tristate_as_array_element_degrades_absent_to_null() -> None: + # As a dataclass field the absent/null distinction is fully observable, but + # an array element has no key to omit, so ABSENT degrades to an explicit + # null on encode; decode only ever yields NULL / Present for array elements. + @dataclass(frozen=True, slots=True) + class _Box: + items: list[Tristate[str]] + + codec = Codec() + assert codec.decode({"items": ["a", None]}, _Box).items == [Present("a"), NULL] + assert codec.encode(_Box([Present("a"), NULL, ABSENT])) == {"items": ["a", None, None]} diff --git a/packages/dexpace-sdk-core/tests/serde/test_type_ref.py b/packages/dexpace-sdk-core/tests/serde/test_type_ref.py new file mode 100644 index 0000000..a347eba --- /dev/null +++ b/packages/dexpace-sdk-core/tests/serde/test_type_ref.py @@ -0,0 +1,184 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for ``TypeRef`` — validated runtime type witnesses. + +Imports come straight from ``dexpace.sdk.core.serde.type_ref`` and +``...serde.codec`` rather than the package ``__init__`` so the suite does not +depend on the re-export landing. +""" + +from __future__ import annotations + +import typing +from dataclasses import dataclass +from typing import ParamSpec, TypeVar, TypeVarTuple + +import pytest + +from dexpace.sdk.core.serde.codec import Codec +from dexpace.sdk.core.serde.type_ref import TypeRef + + +@dataclass(frozen=True, slots=True) +class _Pet: + name: str + + +# PEP 695 alias statements — their ``__value__`` is lazy and must be forced by +# ``TypeRef`` at construction time. +type _Pets = list[_Pet] +type _PetsAlias = _Pets + + +@pytest.fixture +def codec() -> Codec: + return Codec() + + +# --------------------------------------------------------------------------- # +# Element-type information is preserved and usable # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-22") +def test_subscripted_generic_witness_exposes_origin_and_args() -> None: + ref = TypeRef(list[_Pet]) + assert ref.origin is list + assert ref.args == (_Pet,) + assert ref.witness == list[_Pet] + + +def test_mapping_witness_exposes_key_and_value_args() -> None: + ref = TypeRef(dict[str, _Pet]) + assert ref.origin is dict + assert ref.args == (str, _Pet) + + +@pytest.mark.req("SEAM-21", "SERDE-5", "SERDE-6", "SERDE-7") +def test_decode_through_type_ref_recovers_element_models(codec: Codec) -> None: + # The whole point: the element type survives through to decoding, so each + # wire object is reconstructed as a ``_Pet`` rather than left as a dict. + ref = TypeRef(list[_Pet]) + decoded = codec.decode([{"name": "rex"}, {"name": "mia"}], ref) + assert decoded == [_Pet("rex"), _Pet("mia")] + assert all(isinstance(p, _Pet) for p in decoded) + + +def test_decode_through_dict_witness_recovers_values(codec: Codec) -> None: + ref = TypeRef(dict[str, _Pet]) + decoded = codec.decode({"a": {"name": "rex"}}, ref) + assert decoded == {"a": _Pet("rex")} + + +@pytest.mark.req("SERDE-6") +def test_plain_class_witness_decodes_single_model(codec: Codec) -> None: + ref = TypeRef(_Pet) + assert codec.decode({"name": "rex"}, ref) == _Pet("rex") + + +def test_plain_scalar_witness_is_accepted() -> None: + # A concrete, non-generic type is a perfectly good witness; guard against + # the rejection rule being over-broad. + assert TypeRef(int).witness is int + assert TypeRef(str).origin is None + + +# --------------------------------------------------------------------------- # +# Bare type variables are rejected at construction # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-22", "SERDE-8") +def test_bare_typevar_is_rejected_naming_it() -> None: + Elem = TypeVar("Elem") + with pytest.raises(TypeError, match="Elem") as info: + TypeRef(Elem) # type: ignore[arg-type] + assert "type parameter" in str(info.value) + + +@pytest.mark.req("SERDE-8") +def test_bare_paramspec_is_rejected() -> None: + PS = ParamSpec("PS") # noqa: N806 + with pytest.raises(TypeError, match="PS"): + TypeRef(PS) # type: ignore[arg-type] + + +def test_bare_typevartuple_is_rejected() -> None: + TT = TypeVarTuple("TT") # noqa: N806 + with pytest.raises(TypeError, match="TT"): + TypeRef(TT) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- # +# Unparameterized generic aliases are rejected at construction # +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SERDE-6", "SERDE-8") +def test_unparameterized_typing_alias_is_rejected() -> None: + with pytest.raises(TypeError, match="List") as info: + TypeRef(typing.List) # noqa: UP006 + assert "unparameterized" in str(info.value) + + +def test_unparameterized_typing_dict_is_rejected() -> None: + with pytest.raises(TypeError): + TypeRef(typing.Dict) # noqa: UP006 + + +# --------------------------------------------------------------------------- # +# PEP 695 type-alias statements resolve through TypeRef # +# --------------------------------------------------------------------------- # + + +def test_pep695_alias_resolves_to_underlying_witness() -> None: + # A PEP 695 ``type`` alias is a live value, but statically it is not a + # ``type[T]``; the runtime resolution is exactly what is under test here. + ref: TypeRef[list[_Pet]] = TypeRef(_Pets) # type: ignore[arg-type] + assert ref.origin is list + assert ref.args == (_Pet,) + + +def test_pep695_alias_decodes_like_the_underlying_generic(codec: Codec) -> None: + ref: TypeRef[list[_Pet]] = TypeRef(_Pets) # type: ignore[arg-type] + decoded = codec.decode([{"name": "rex"}], ref) + assert decoded == [_Pet("rex")] + + +def test_pep695_alias_to_alias_resolves_transitively() -> None: + # ``_PetsAlias`` -> ``_Pets`` -> ``list[_Pet]``; every hop must be forced. + ref: TypeRef[list[_Pet]] = TypeRef(_PetsAlias) # type: ignore[arg-type] + assert ref.origin is list + assert ref.args == (_Pet,) + + +def test_pathologically_deep_alias_chain_is_rejected_not_hung() -> None: + # A cyclic alias (``type X = X``) or an absurdly deep alias chain must not + # spin forever; the resolution guard trips with a clear error. Built at + # runtime so the pathological nesting stays invisible to the type-checker. + deep: object = int + for i in range(150): + deep = typing.TypeAliasType(f"_Deep{i}", deep) # type: ignore[misc] + with pytest.raises(TypeError, match="cyclic"): + TypeRef(deep) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- # +# Value semantics # +# --------------------------------------------------------------------------- # + + +def test_equal_witnesses_compare_equal() -> None: + assert TypeRef(list[_Pet]) == TypeRef(list[_Pet]) + # Distinct witnesses are unequal; the static params differ, so silence the + # (correct) non-overlap note — the runtime inequality is the point. + assert TypeRef(list[_Pet]) != TypeRef(list[int]) # type: ignore[comparison-overlap] + + +def test_type_ref_is_hashable() -> None: + assert TypeRef(list[_Pet]) in {TypeRef(list[_Pet])} + + +def test_repr_names_the_witness() -> None: + assert "_Pet" in repr(TypeRef(list[_Pet])) diff --git a/packages/dexpace-sdk-core/tests/sse/test_async_connection.py b/packages/dexpace-sdk-core/tests/sse/test_async_connection.py index 584ac6b..c0e12d9 100644 --- a/packages/dexpace-sdk-core/tests/sse/test_async_connection.py +++ b/packages/dexpace-sdk-core/tests/sse/test_async_connection.py @@ -122,6 +122,7 @@ async def test_async_non_success_status_raises() -> None: assert len(script.requests) == 1 +@pytest.mark.req("SSE-41") async def test_async_cancellation_propagates_and_closes() -> None: body = _AsyncDropBody([b"data: hi\n\n"], block=True) script = _AsyncScript([_response(body)]) @@ -179,3 +180,17 @@ async def test_async_honours_retry_then_budget() -> None: pass assert clock.sleeps == [1.0, 2.0] + + +@pytest.mark.req("SSE-32") +async def test_async_success_response_without_body_fails_loudly() -> None: + # A body-less (but successful) SSE response is a protocol violation: the + # async connection fails loudly rather than silently reconnecting. + script = _AsyncScript([_response(None, status=Status.OK)]) + conn = AsyncSseConnection(script, _request(), clock=_RecordingAsyncClock(), rand=_LowJitter()) + + with pytest.raises(ServiceResponseError, match="no body"): + async for _ in conn: + pass + + assert len(script.requests) == 1 # no reconnect attempted diff --git a/packages/dexpace-sdk-core/tests/sse/test_async_connection_lifecycle.py b/packages/dexpace-sdk-core/tests/sse/test_async_connection_lifecycle.py new file mode 100644 index 0000000..104a807 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/sse/test_async_connection_lifecycle.py @@ -0,0 +1,204 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Lifecycle certification for the asynchronous SSE connection facade. + +Mirrors the synchronous lifecycle guarantees for `AsyncSseConnection`: +release-exactly-once across termination paths, single-pass iteration, and a +mid-stream failure that releases before propagating without a close-time error +masking the primary failure. +""" + +from __future__ import annotations + +import random +from collections.abc import AsyncIterator + +import pytest + +from dexpace.sdk.core.errors import ServiceResponseError +from dexpace.sdk.core.http.common import MediaType, Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import AsyncResponse, AsyncResponseBody, Status +from dexpace.sdk.core.http.sse.connection import AsyncSseConnection + + +def _request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://api.example.com/stream")) + + +class _AsyncLedgerBody(AsyncResponseBody): + """Async response body that counts its closes.""" + + def __init__(self, chunks: list[bytes], *, error: BaseException | None = None) -> None: + self._chunks = chunks + self._error = error + self.closes = 0 + + def media_type(self) -> MediaType | None: + return MediaType.parse("text/event-stream") + + def content_length(self) -> int: + return -1 + + async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + for chunk in self._chunks: + yield chunk + if self._error is not None: + raise self._error + + async def close(self) -> None: + self.closes += 1 + + +def _response(body: AsyncResponseBody | None, *, status: Status = Status.OK) -> AsyncResponse: + return AsyncResponse(request=_request(), protocol=Protocol.HTTP_1_1, status=status, body=body) + + +class _AsyncScript: + def __init__(self, responses: list[AsyncResponse]) -> None: + self._responses = list(responses) + self.requests: list[Request] = [] + + async def __call__(self, request: Request) -> AsyncResponse: + self.requests.append(request) + if not self._responses: + raise AssertionError("send called more times than scripted") + return self._responses.pop(0) + + +class _RecordingAsyncClock: + def now(self) -> float: + return 0.0 + + def monotonic(self) -> float: + return 0.0 + + async def sleep(self, duration: float) -> None: + return None + + +class _LowJitter(random.Random): + def uniform(self, a: float, b: float) -> float: + return a + + +def _conn(responses: list[AsyncResponse]) -> AsyncSseConnection: + return AsyncSseConnection( + _AsyncScript(responses), _request(), clock=_RecordingAsyncClock(), rand=_LowJitter() + ) + + +@pytest.mark.req("SSE-25") +async def test_release_once_on_partial_consumption() -> None: + body = _AsyncLedgerBody([b"data: one\n\n"]) + conn = _conn([_response(body)]) + + async with conn as events: + async for event in events: + assert event.data == "one" + break + + assert body.closes == 1 + + +async def test_release_once_per_response_on_clean_end_of_stream() -> None: + first = _AsyncLedgerBody([b"data: a\n\n"]) + second = _AsyncLedgerBody([b"data: b\n\n"]) + conn = _conn([_response(first), _response(second)]) + + seen: list[str] = [] + async with conn as events: + async for event in events: + seen.append(event.data) + if len(seen) == 2: + break + + assert seen == ["a", "b"] + assert first.closes == 1 + assert second.closes == 1 + + +async def test_release_once_on_transient_mid_stream_failure() -> None: + dropped = _AsyncLedgerBody([b"data: a\n\n"], error=ServiceResponseError("dropped")) + resumed = _AsyncLedgerBody([b"data: b\n\n"]) + conn = _conn([_response(dropped), _response(resumed)]) + + seen: list[str] = [] + async with conn as events: + async for event in events: + seen.append(event.data) + if len(seen) == 2: + break + + assert dropped.closes == 1 + assert resumed.closes == 1 + + +@pytest.mark.req("SSE-28") +async def test_aclose_is_idempotent() -> None: + body = _AsyncLedgerBody([b"data: one\n\n"]) + conn = _conn([_response(body)]) + + events = aiter(conn) + assert (await anext(events)).data == "one" + await conn.aclose() + await conn.aclose() # idempotent + assert body.closes == 1 + + +@pytest.mark.req("SSE-26") +async def test_second_iterator_raises() -> None: + conn = _conn([_response(_AsyncLedgerBody([b"data: one\n\n"]))]) + events = aiter(conn) + assert (await anext(events)).data == "one" + + with pytest.raises(RuntimeError, match="single iteration"): + aiter(conn) + + await conn.aclose() + + +@pytest.mark.req("SSE-27") +async def test_iteration_after_close_raises() -> None: + conn = _conn([]) + await conn.aclose() + + with pytest.raises(RuntimeError, match="closed"): + aiter(conn) + + +class _PrimaryError(Exception): + """A non-transient mid-stream failure that must propagate.""" + + +class _SecondaryError(Exception): + """A failure raised while releasing; must not mask the primary failure.""" + + +class _AsyncMaskingBody(AsyncResponseBody): + """Fails mid-stream, then fails again on close, to test error precedence.""" + + def media_type(self) -> MediaType | None: + return MediaType.parse("text/event-stream") + + def content_length(self) -> int: + return -1 + + async def aiter_bytes(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]: + raise _PrimaryError("mid-stream boom") + yield b"" # pragma: no cover — marks this an async generator + + async def close(self) -> None: + raise _SecondaryError("teardown failed") + + +@pytest.mark.req("SSE-29") +async def test_close_time_error_is_demoted_onto_context() -> None: + conn = _conn([_response(_AsyncMaskingBody())]) + + with pytest.raises(_PrimaryError) as exc_info: + async for _ in conn: + pass + + assert isinstance(exc_info.value.__context__, _SecondaryError) diff --git a/packages/dexpace-sdk-core/tests/sse/test_connection.py b/packages/dexpace-sdk-core/tests/sse/test_connection.py index ad41d40..1d15a7f 100644 --- a/packages/dexpace-sdk-core/tests/sse/test_connection.py +++ b/packages/dexpace-sdk-core/tests/sse/test_connection.py @@ -253,3 +253,18 @@ def test_budget_exhaustion_chains_last_transport_error() -> None: for _ in conn: pass assert exc_info.value.__cause__ is boom + + +@pytest.mark.req("SSE-32") +def test_success_response_without_body_fails_loudly() -> None: + # Opening a stream over a body-less (but successful) response is a protocol + # violation: the connection fails loudly rather than treating it as an + # immediately-empty stream that would silently reconnect forever. + script = _Script([_response(None, status=Status.OK)]) + conn = SseConnection(script, _request(), clock=_RecordingClock(), rand=_LowJitter()) + + with pytest.raises(ServiceResponseError, match="no body"): + for _ in conn: + pass + + assert len(script.requests) == 1 # no reconnect attempted diff --git a/packages/dexpace-sdk-core/tests/sse/test_connection_lifecycle.py b/packages/dexpace-sdk-core/tests/sse/test_connection_lifecycle.py new file mode 100644 index 0000000..3f046e3 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/sse/test_connection_lifecycle.py @@ -0,0 +1,365 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Lifecycle certification for the synchronous SSE connection facade. + +Covers the resource-ownership contract the facade must uphold regardless of how +a stream ends: + +- The owned response is released **exactly once** across every termination path + (clean end-of-stream, an explicit ``close``, exiting a ``with`` block, a + caller that stops iterating early, and a mid-stream failure). +- ``close`` is idempotent and safe to call from another thread while iteration + is in progress, with an explicit lock guarding the close-once flag (no + GIL-atomicity assumption, so the guarantee survives free-threaded CPython). +- The facade is single-pass: a second iterator, or iterating after ``close``, + raises rather than silently opening a fresh request. +- A mid-stream failure releases the resource before propagating, and a + close-time secondary error is demoted onto ``__context__`` rather than masking + the primary failure. +""" + +from __future__ import annotations + +import random +import threading +from collections.abc import Iterator + +import pytest + +from dexpace.sdk.core.errors import ServiceResponseError +from dexpace.sdk.core.http.common import MediaType, Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Response, ResponseBody, Status +from dexpace.sdk.core.http.sse.connection import SseConnection + + +def _request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://api.example.com/stream")) + + +class _LedgerBody(ResponseBody): + """Records how many times it is closed, to prove release-exactly-once.""" + + def __init__(self, chunks: list[bytes], *, error: BaseException | None = None) -> None: + self._chunks = chunks + self._error = error + self.closes = 0 + + def media_type(self) -> MediaType | None: + return MediaType.parse("text/event-stream") + + def content_length(self) -> int: + return -1 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + yield from self._chunks + if self._error is not None: + raise self._error + + def close(self) -> None: + self.closes += 1 + + +def _response(body: ResponseBody | None, *, status: Status = Status.OK) -> Response: + return Response(request=_request(), protocol=Protocol.HTTP_1_1, status=status, body=body) + + +class _Script: + """A send-callable returning scripted responses; extra calls fail loudly.""" + + def __init__(self, responses: list[Response]) -> None: + self._responses = list(responses) + self.requests: list[Request] = [] + + def __call__(self, request: Request) -> Response: + self.requests.append(request) + if not self._responses: + raise AssertionError("send called more times than scripted") + return self._responses.pop(0) + + +class _RecordingClock: + def now(self) -> float: + return 0.0 + + def monotonic(self) -> float: + return 0.0 + + def sleep(self, duration: float) -> None: + return None + + +class _LowJitter(random.Random): + def uniform(self, a: float, b: float) -> float: + return a + + +def _conn(responses: list[Response]) -> SseConnection: + return SseConnection(_Script(responses), _request(), clock=_RecordingClock(), rand=_LowJitter()) + + +# --- release-exactly-once ledger across every termination path ------------- + + +@pytest.mark.req("SSE-25") +def test_release_once_on_partial_consumption() -> None: + body = _LedgerBody([b"data: one\n\n"]) + conn = _conn([_response(body)]) + + for event in conn: + assert event.data == "one" + break # caller stops iterating early + + assert body.closes == 1 + + +@pytest.mark.req("SSE-23", "SSE-25") +def test_release_once_on_with_block_exit() -> None: + body = _LedgerBody([b"data: one\n\n"]) + + with _conn([_response(body)]) as conn: + it = iter(conn) + assert next(it).data == "one" # parked mid-stream, response still owned + # ``__exit__`` must release the still-open response exactly once. + assert body.closes == 1 + + +@pytest.mark.req("SSE-23", "SSE-28") +def test_release_once_and_idempotent_on_explicit_close() -> None: + body = _LedgerBody([b"data: one\n\n"]) + conn = _conn([_response(body)]) + + it = iter(conn) + assert next(it).data == "one" + conn.close() + conn.close() # idempotent — a second call must not double-release + assert body.closes == 1 + + +@pytest.mark.req("SSE-17", "SSE-24") +def test_release_once_per_response_on_clean_end_of_stream() -> None: + # A clean end-of-stream reconnects; the finished response must be released + # exactly once as the client rolls over to the next connection. + first = _LedgerBody([b"data: a\n\n"]) + second = _LedgerBody([b"data: b\n\n"]) + conn = _conn([_response(first), _response(second)]) + + seen: list[str] = [] + for event in conn: + seen.append(event.data) + if len(seen) == 2: + break + + assert seen == ["a", "b"] + assert first.closes == 1 + assert second.closes == 1 + + +@pytest.mark.req("SSE-23", "SSE-28") +def test_release_once_on_transient_mid_stream_failure() -> None: + dropped = _LedgerBody([b"data: a\n\n"], error=ServiceResponseError("dropped")) + resumed = _LedgerBody([b"data: b\n\n"]) + conn = _conn([_response(dropped), _response(resumed)]) + + seen: list[str] = [] + for event in conn: + seen.append(event.data) + if len(seen) == 2: + break + + assert seen == ["a", "b"] + assert dropped.closes == 1 + assert resumed.closes == 1 + + +class _PrimaryError(Exception): + """A non-transient mid-stream failure that must propagate to the caller.""" + + +class _SecondaryError(Exception): + """A failure raised while releasing; must not mask the primary failure.""" + + +@pytest.mark.req("SSE-29") +def test_non_transient_failure_releases_before_propagating() -> None: + body = _LedgerBody([], error=_PrimaryError("mid-stream boom")) + conn = _conn([_response(body)]) + + with pytest.raises(_PrimaryError): + for _ in conn: + pass + + assert body.closes == 1 # released before the exception surfaced + + +class _MaskingBody(ResponseBody): + """Fails mid-stream, then fails again on close, to test error precedence.""" + + def media_type(self) -> MediaType | None: + return MediaType.parse("text/event-stream") + + def content_length(self) -> int: + return -1 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + raise _PrimaryError("mid-stream boom") + yield b"" # pragma: no cover — marks this a generator + + def close(self) -> None: + raise _SecondaryError("teardown failed") + + +@pytest.mark.req("SSE-29") +def test_close_time_error_is_demoted_onto_context() -> None: + conn = _conn([_response(_MaskingBody())]) + + with pytest.raises(_PrimaryError) as exc_info: + for _ in conn: + pass + + # The primary mid-stream failure wins; the close-time error is preserved on + # __context__ rather than masking it. + assert isinstance(exc_info.value.__context__, _SecondaryError) + + +class _CleanEofFailCloseBody(ResponseBody): + """Ends cleanly, then fails on close — to exercise the clean-terminal path.""" + + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self.closes = 0 + + def media_type(self) -> MediaType | None: + return MediaType.parse("text/event-stream") + + def content_length(self) -> int: + return -1 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + yield from self._chunks + + def close(self) -> None: + self.closes += 1 + raise RuntimeError("release failed on clean EOF") + + +@pytest.mark.req("SSE-30") +def test_clean_eof_release_failure_is_swallowed_and_reconnect_continues() -> None: + # On the automatic clean-EOF rollover (no error in flight) a release failure + # must not be turned into a thrown result that discards already-delivered + # events: it is reported out-of-band and swallowed, and the connection rolls + # over to the next stream instead of aborting. + first = _CleanEofFailCloseBody([b"data: a\n\n"]) + second = _LedgerBody([b"data: b\n\n"]) + conn = _conn([_response(first), _response(second)]) + + seen: list[str] = [] + for event in conn: + seen.append(event.data) + if len(seen) == 2: + break + + assert seen == ["a", "b"] # the swallowed release did not abort the stream + assert first.closes >= 1 # close was attempted despite raising + + +# --- single-pass iteration ------------------------------------------------- + + +@pytest.mark.req("SSE-26") +def test_second_iterator_raises() -> None: + conn = _conn([_response(_LedgerBody([b"data: one\n\n"]))]) + it = iter(conn) + assert next(it).data == "one" + + with pytest.raises(RuntimeError, match="single iteration"): + iter(conn) + + conn.close() + + +@pytest.mark.req("SSE-27") +def test_iteration_after_close_raises() -> None: + conn = _conn([]) + conn.close() + + with pytest.raises(RuntimeError, match="closed"): + iter(conn) + + +# --- cross-thread close race ------------------------------------------------ + + +class _ParkingBody(ResponseBody): + """Yields one event, parks until ``close`` wakes it, then ends cleanly. + + ``close`` is the only thing that sets ``unblock``; counting its invocations + proves that a race of concurrent closers releases exactly once. + """ + + def __init__(self, parked: threading.Event, unblock: threading.Event) -> None: + self._parked = parked + self._unblock = unblock + self.closes = 0 + + def media_type(self) -> MediaType | None: + return MediaType.parse("text/event-stream") + + def content_length(self) -> int: + return -1 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + yield b"data: one\n\n" + self._parked.set() + assert self._unblock.wait(5.0) # park like a live stream awaiting bytes + + def close(self) -> None: + self.closes += 1 + self._unblock.set() + + +@pytest.mark.req("SSE-18", "SSE-27", "SSE-31") +def test_close_from_another_thread_during_iteration_releases_once() -> None: + # Free-threaded-CPython correctness: the close-once transition is guarded by + # an explicit ``threading.Lock`` (not the GIL), so only the closer that + # atomically takes the response slot calls ``close`` on it — even with three + # closers firing simultaneously and the reader parked in the transport. On a + # no-GIL build without the lock, the check-then-act on the slot could let + # several closers double-release; this test pins that it cannot. + parked = threading.Event() + unblock = threading.Event() + body = _ParkingBody(parked, unblock) + conn = _conn([_response(body)]) + + seen: list[str] = [] + errors: list[BaseException] = [] + + def consume() -> None: + try: + for event in conn: + seen.append(event.data) + except BaseException as exc: + errors.append(exc) + + reader = threading.Thread(target=consume) + reader.start() + assert parked.wait(5.0) # reader is now parked mid-stream + + start = threading.Barrier(3) + + def close_racing() -> None: + start.wait(5.0) + conn.close() + + closers = [threading.Thread(target=close_racing) for _ in range(3)] + for thread in closers: + thread.start() + for thread in closers: + thread.join(5.0) + reader.join(5.0) + + assert not reader.is_alive() + assert body.closes == 1 # released exactly once despite three concurrent closers + assert seen == ["one"] + assert errors == [] # the parked iteration ended cleanly, not corrupted diff --git a/packages/dexpace-sdk-core/tests/sse/test_parser.py b/packages/dexpace-sdk-core/tests/sse/test_parser.py index 4fb0666..907382c 100644 --- a/packages/dexpace-sdk-core/tests/sse/test_parser.py +++ b/packages/dexpace-sdk-core/tests/sse/test_parser.py @@ -23,6 +23,7 @@ def _chunked(data: bytes, size: int) -> list[bytes]: return [data[i : i + size] for i in range(0, len(data), size)] +@pytest.mark.req("SSE-1", "SSE-15", "SSE-21") def test_single_event() -> None: stream = b"data: hello\n\n" events = _events(stream) @@ -35,43 +36,181 @@ def test_multi_line_data_joined_with_newline() -> None: assert events == [SseEvent(data="line1\nline2")] +@pytest.mark.req("SSE-21", "SSE-3", "SSE-5") def test_event_field() -> None: stream = b"event: update\ndata: x\n\n" events = _events(stream) assert events == [SseEvent(data="x", event="update")] -def test_id_persists_across_events() -> None: +@pytest.mark.req("SSE-16") +def test_id_is_per_block_not_carried_forward() -> None: + # The parser is single-pass and MUST NOT persist a last-event-id: each event + # surfaces only the id present in its own block. The second block sent no + # id, so it is absent (None) rather than the sticky "1". stream = b"id: 1\ndata: a\n\ndata: b\n\n" events = _events(stream) assert events[0].id == "1" - assert events[1].id == "1" + assert events[1].id is None +@pytest.mark.req("SSE-11") def test_retry_parsed() -> None: stream = b"retry: 5000\ndata: x\n\n" events = _events(stream) assert events[0].retry == 5000 +@pytest.mark.req("SSE-11") def test_retry_ignored_when_non_numeric() -> None: stream = b"retry: soon\ndata: x\n\n" events = _events(stream) assert events[0].retry is None -def test_comment_lines_ignored() -> None: +def test_retry_at_signed_64bit_ceiling_is_accepted() -> None: + ceiling = (1 << 63) - 1 + stream = b"retry: " + str(ceiling).encode() + b"\ndata: x\n\n" + events = _events(stream) + assert events[0].retry == ceiling + + +@pytest.mark.req("SSE-11") +def test_retry_above_signed_64bit_ceiling_is_rejected_cleanly() -> None: + # A digits-only value beyond the 2**63-1 millisecond cap is ignored (the + # sticky retry stays unset) rather than stored as an unbounded integer. + stream = b"retry: " + str(1 << 63).encode() + b"\ndata: x\n\n" + events = _events(stream) + assert events[0].retry is None + + +def test_retry_unicode_superscript_digit_does_not_raise() -> None: + # "²".isdigit() is True but int("²") raises; the parser must treat + # non-ASCII digits as non-numeric instead of leaking ValueError out of feed. + stream = "retry: ²\ndata: x\n\n".encode() + events = _events(stream) + assert events[0].retry is None + + +@pytest.mark.req("SSE-11") +def test_retry_non_ascii_decimal_digit_is_ignored() -> None: + # Arabic-Indic four (U+0664) is a Unicode decimal digit that int() accepts, + # but the WHATWG spec restricts retry to ASCII digits, so it is ignored. + stream = "retry: ٤\ndata: x\n\n".encode() + events = _events(stream) + assert events[0].retry is None + + +@pytest.mark.req("SSE-6") +def test_comment_text_is_captured_alongside_data() -> None: + # A ':'-led line is a comment: its text (after the single-space strip) is + # captured on the event rather than discarded. stream = b": keepalive\ndata: x\n\n" events = _events(stream) - assert events == [SseEvent(data="x")] + assert events == [SseEvent(data="x", comment="keepalive")] + + +@pytest.mark.req("SSE-6", "SSE-13") +def test_comment_only_block_dispatches() -> None: + # A comment counts as a "field seen", so a comment-only keep-alive block is + # still dispatched as a (non-empty) event with empty data. + stream = b": keep-alive\n\n" + events = _events(stream) + assert events == [SseEvent(data="", comment="keep-alive")] + + +@pytest.mark.req("SSE-6") +def test_comment_is_latest_wins_within_a_block() -> None: + stream = b": first\n: second\ndata: x\n\n" + events = _events(stream) + assert events == [SseEvent(data="x", comment="second")] + + +@pytest.mark.req("SSE-10") +def test_event_name_absent_is_none_not_message() -> None: + # An omitted event: field surfaces as None, never defaulted to "message". + stream = b"data: x\n\n" + assert _events(stream) == [SseEvent(data="x", event=None)] + + +@pytest.mark.req("SSE-10") +def test_event_name_stored_raw_latest_wins() -> None: + stream = b"event: first\nevent: second\ndata: x\n\n" + assert _events(stream) == [SseEvent(data="x", event="second")] + + +@pytest.mark.req("SSE-4") +def test_present_but_empty_event_is_recorded_and_dispatches() -> None: + # An 'event:' with nothing after it is a present-but-empty field: recorded + # as "" (distinct from absent None) and it counts as a field seen, so the + # block dispatches even without data. + stream = b"event:\n\n" + assert _events(stream) == [SseEvent(data="", event="")] + + +@pytest.mark.req("SSE-4", "SSE-13") +def test_present_but_empty_id_dispatches_id_only_block() -> None: + stream = b"id:\n\n" + assert _events(stream) == [SseEvent(data="", id="")] + + +@pytest.mark.req("SSE-13") +def test_id_only_block_dispatches() -> None: + stream = b"id: 5\n\n" + assert _events(stream) == [SseEvent(data="", id="5")] + + +@pytest.mark.req("SSE-13") +def test_retry_only_block_dispatches() -> None: + stream = b"retry: 4000\n\n" + assert _events(stream) == [SseEvent(data="", retry=4000)] + + +@pytest.mark.req("SSE-13") +def test_pure_blank_block_emits_nothing() -> None: + # A block in which no tracked field was set (only blank lines) is skipped. + stream = b"\n\n\ndata: x\n\n" + assert _events(stream) == [SseEvent(data="x")] + + +@pytest.mark.req("SSE-13") +def test_unknown_field_only_block_emits_nothing() -> None: + # An unknown field is not tracked, so an unknown-field-only block dispatches + # nothing (distinct from a comment-only block, which does). + stream = b"x-custom: thing\n\ndata: y\n\n" + assert _events(stream) == [SseEvent(data="y")] + + +@pytest.mark.req("SSE-9") +def test_id_containing_nul_is_ignored_entirely() -> None: + # An id whose value contains U+0000 is ignored: it does not set the id and + # does not, by itself, count as a field seen. + stream = b"id: a\x00b\ndata: x\n\n" + assert _events(stream) == [SseEvent(data="x", id=None)] + + +@pytest.mark.req("SSE-9") +def test_nul_id_does_not_overwrite_valid_id_in_same_block() -> None: + stream = b"id: good\nid: a\x00b\ndata: x\n\n" + assert _events(stream) == [SseEvent(data="x", id="good")] + + +@pytest.mark.req("SSE-9", "SSE-13") +def test_nul_id_only_block_emits_nothing() -> None: + # A NUL id is not a field seen, so an id-with-NUL-only block dispatches + # nothing rather than an id-less event. + stream = b"id: a\x00b\n\ndata: y\n\n" + assert _events(stream) == [SseEvent(data="y")] +@pytest.mark.req("SSE-2") def test_crlf_line_terminators() -> None: stream = b"data: x\r\n\r\n" events = _events(stream) assert events == [SseEvent(data="x")] +@pytest.mark.req("SSE-3") def test_field_without_colon_treated_as_field_with_empty_value() -> None: stream = b"data\n\n" events = _events(stream) @@ -85,12 +224,14 @@ def test_chunk_boundary_split_mid_line() -> None: assert events == [SseEvent(data="hello")] +@pytest.mark.req("SSE-7") def test_unknown_field_skipped() -> None: stream = b"x-custom: thing\ndata: y\n\n" events = _events(stream) assert events == [SseEvent(data="y")] +@pytest.mark.req("SSE-14") def test_final_event_without_trailing_blank() -> None: stream = b"data: tail" events = _events(stream) @@ -130,6 +271,7 @@ def test_invalid_utf8_in_complete_line_does_not_raise_from_feed() -> None: assert events == [SseEvent(data="�")] +@pytest.mark.req("SSE-1", "SSE-15") async def test_async_parser() -> None: from dexpace.sdk.core.http.sse import parse_async_events diff --git a/packages/dexpace-sdk-core/tests/sse/test_parser_golden_corpus.py b/packages/dexpace-sdk-core/tests/sse/test_parser_golden_corpus.py new file mode 100644 index 0000000..f3d0be3 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/sse/test_parser_golden_corpus.py @@ -0,0 +1,77 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Certify the production SSE parser against the golden byte corpus. + +The ``sse`` family of the golden wire-exactness corpus pins the parsed events +for a set of raw byte streams that exercise the full WHATWG grammar across +``LF`` / ``CR`` / ``CRLF`` terminators, a leading UTF-8 BOM, multi-line data +concatenation, sticky ``id``, ``retry`` parsing, comments, the single-space +strip, UTF-8 payloads, and the trailing event with no final blank line. + +Unlike ``tests/test_golden_corpus.py`` — which validates the fixtures against +small stdlib oracles — this suite drives the real ``SseParser`` (through +``parse_events`` / ``parse_async_events``) so the shipped parser is held to the +corpus byte-for-byte. Each vector is checked three ways: + +- fed as one whole-stream chunk (sync), +- fed one byte at a time so every terminator and the BOM straddle a chunk + boundary (sync), and +- fed through the async driver (whole stream). + +All three must reproduce the corpus's expected event sequence exactly, proving +the parse is independent of transport chunking. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest + +from dexpace.sdk.core.http.sse import SseEvent, parse_async_events, parse_events +from dexpace.sdk.tck.golden_corpus import SseExpectedEvent, SseVector, load_sse + +_SSE_VECTORS: tuple[SseVector, ...] = load_sse() +_VECTOR_IDS: list[str] = [vector.id for vector in _SSE_VECTORS] + + +def _expected(events: tuple[SseExpectedEvent, ...]) -> list[SseEvent]: + """Map corpus expectations onto the parser's ``SseEvent`` shape.""" + return [ + SseEvent( + data=event.data, + event=event.event, + id=event.id, + retry=event.retry, + comment=event.comment, + ) + for event in events + ] + + +@pytest.mark.req("SSE-5") +@pytest.mark.parametrize("vector", _SSE_VECTORS, ids=_VECTOR_IDS) +def test_golden_corpus_whole_stream(vector: SseVector) -> None: + """Parsing the whole stream reproduces the corpus events byte-for-byte.""" + events = list(parse_events([vector.stream])) + assert events == _expected(vector.events) + + +@pytest.mark.parametrize("vector", _SSE_VECTORS, ids=_VECTOR_IDS) +def test_golden_corpus_byte_by_byte(vector: SseVector) -> None: + """One-byte chunks — terminators and the BOM straddle every boundary.""" + chunks = [vector.stream[i : i + 1] for i in range(len(vector.stream))] + events = list(parse_events(chunks)) + assert events == _expected(vector.events) + + +@pytest.mark.parametrize("vector", _SSE_VECTORS, ids=_VECTOR_IDS) +async def test_golden_corpus_async_stream(vector: SseVector) -> None: + """The async driver reproduces the same corpus events.""" + + async def producer() -> AsyncIterator[bytes]: + yield vector.stream + + events = [event async for event in parse_async_events(producer())] + assert events == _expected(vector.events) diff --git a/packages/dexpace-sdk-core/tests/sse/test_parser_properties.py b/packages/dexpace-sdk-core/tests/sse/test_parser_properties.py new file mode 100644 index 0000000..e1bb9e4 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/sse/test_parser_properties.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Property-based tests pinning SSE parser chunk-boundary invariance. + +Invariant covered: + +- Re-splitting the same byte stream at arbitrary chunk boundaries yields + identical parsed events. ``SseParser`` buffers across chunk splits and + handles ``LF`` / ``CR`` / ``CRLF`` terminators and the leading UTF-8 BOM, so + the emitted ``SseEvent`` sequence must not depend on where the transport cut + the stream — including cuts inside a multi-byte codepoint, inside a CRLF + pair, or inside the BOM. + +The payload alphabet is biased toward SSE structure (field names, colons, +line terminators) and includes multi-byte codepoints so re-splits exercise the +UTF-8 buffering path. Every payload is built from a ``str`` and encoded as +UTF-8, so it is always valid UTF-8 and never triggers the mid-codepoint +end-of-stream error path — that keeps the suite deterministically green while +still stressing arbitrary re-splitting. + +The example count and deadline are governed by the capped "ci" Hypothesis +profile registered in ``tests/conftest.py``. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from dexpace.sdk.core.http.sse import SseEvent, parse_events + +# Field-name fragments, structural punctuation, all three line terminators, a +# BOM, and multi-byte codepoints (2-, 3-, and 4-byte) so re-splits can land +# mid-codepoint. +_SSE_ALPHABET = "dataeventidretry: ;\t\r\n0123456789é☃😀" +_sse_text = st.text(alphabet=_SSE_ALPHABET, max_size=120) + + +@st.composite +def _payload_and_chunks(draw: st.DrawFn) -> list[bytes]: + """Encode arbitrary SSE-ish text and split it at arbitrary byte boundaries.""" + payload = draw(_sse_text).encode("utf-8") + chunks: list[bytes] = [] + index = 0 + while index < len(payload): + size = draw(st.integers(min_value=1, max_value=len(payload) - index)) + chunks.append(payload[index : index + size]) + index += size + return chunks + + +@pytest.mark.req("SSE-40") +@given(chunks=_payload_and_chunks()) +def test_events_invariant_under_chunk_resplits(chunks: list[bytes]) -> None: + """Chunk boundaries do not change the parsed SSE event sequence.""" + whole = b"".join(chunks) + baseline: list[SseEvent] = list(parse_events([whole])) + resplit: list[SseEvent] = list(parse_events(chunks)) + assert resplit == baseline diff --git a/packages/dexpace-sdk-core/tests/sse/test_sse_bom.py b/packages/dexpace-sdk-core/tests/sse/test_sse_bom.py index 24a342e..e14db22 100644 --- a/packages/dexpace-sdk-core/tests/sse/test_sse_bom.py +++ b/packages/dexpace-sdk-core/tests/sse/test_sse_bom.py @@ -24,6 +24,7 @@ def _chunked(data: bytes, size: int) -> list[bytes]: return [data[i : i + size] for i in range(0, len(data), size)] +@pytest.mark.req("SSE-12") def test_leading_bom_stripped_so_first_field_parses() -> None: stream = _BOM + b"data: hello\n\n" assert _events(stream) == [SseEvent(data="hello")] @@ -42,6 +43,7 @@ def test_bom_split_across_chunks_is_still_stripped() -> None: assert _events(stream, chunk_size=1) == [SseEvent(data="split")] +@pytest.mark.req("SSE-12", "SSE-40") def test_bom_split_two_then_rest() -> None: parser = SseParser() parser.feed(_BOM[:2]) @@ -55,6 +57,7 @@ def test_no_bom_first_field_unaffected() -> None: assert _events(stream) == [SseEvent(data="plain")] +@pytest.mark.req("SSE-12") def test_feff_not_at_start_is_not_stripped() -> None: # U+FEFF after the first event is content, never a BOM. stream = b"data: a\n\n" + "data: b".encode() + b"\n\n" diff --git a/packages/dexpace-sdk-core/tests/sse/test_sse_crlf_boundary.py b/packages/dexpace-sdk-core/tests/sse/test_sse_crlf_boundary.py index f6a659a..ff2ea1d 100644 --- a/packages/dexpace-sdk-core/tests/sse/test_sse_crlf_boundary.py +++ b/packages/dexpace-sdk-core/tests/sse/test_sse_crlf_boundary.py @@ -12,10 +12,13 @@ from collections.abc import AsyncIterator +import pytest + from dexpace.sdk.core.http.sse import SseEvent, parse_async_events, parse_events from dexpace.sdk.core.http.sse.parser import SseParser +@pytest.mark.req("SSE-2") def test_crlf_split_across_chunk_boundary_yields_single_event() -> None: # CR ends the first chunk; LF begins the second. The two halves form one # CRLF terminator, so the two data lines join into a single event. @@ -24,6 +27,7 @@ def test_crlf_split_across_chunk_boundary_yields_single_event() -> None: assert events == [SseEvent(data="a\nb")] +@pytest.mark.req("SSE-2") def test_cr_held_until_next_byte_disambiguates_lone_cr() -> None: # A lone CR (next byte is not LF) is still a terminator once the next byte # arrives, so the two data lines join into one event. @@ -32,6 +36,7 @@ def test_cr_held_until_next_byte_disambiguates_lone_cr() -> None: assert events == [SseEvent(data="a\nb")] +@pytest.mark.req("SSE-14", "SSE-2") def test_trailing_cr_at_eos_is_a_terminator_not_literal_carriage_return() -> None: # A CR that is the final byte of the whole stream terminates the line; it # must not leak into the decoded field value as a literal '\r'. diff --git a/packages/dexpace-sdk-core/tests/sse/test_typed_adapter.py b/packages/dexpace-sdk-core/tests/sse/test_typed_adapter.py new file mode 100644 index 0000000..28f88d3 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/sse/test_typed_adapter.py @@ -0,0 +1,384 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Certification for the typed SSE adapter layer. + +Covers `TypedSseConnection` / `AsyncTypedSseConnection`: + +- SKIP discards an event and keeps pulling; DONE stops the stream and releases + the wrapped source. +- Mapping is lazy and pull-based: exactly one event is drawn from the source per + unit of consumer demand (plus any skipped events), never buffering ahead. +- A mapper that raises releases the wrapped source before the error propagates. +- The adapter drives the real reconnecting facade to a clean stop via DONE, + releasing the underlying response exactly once. +""" + +from __future__ import annotations + +import random +from collections.abc import AsyncIterator, Iterator + +import pytest + +from dexpace.sdk.core.http.common import MediaType, Protocol, Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.core.http.response import Response, ResponseBody, Status +from dexpace.sdk.core.http.sse import ( + AsyncTypedSseConnection, + SseConnection, + SseSignal, + TypedSseConnection, +) +from dexpace.sdk.core.http.sse.parser import SseEvent + + +class _RecordingSource: + """A sync SSE source recording pulls and (idempotent) releases. + + Models the real `SseConnection`, whose ``close`` is idempotent: ``closes`` + counts distinct releases, so a redelegated close stays at one. + """ + + def __init__(self, events: list[SseEvent]) -> None: + self._events = events + self.pulled = 0 + self.closes = 0 + self._closed = False + + def __iter__(self) -> Iterator[SseEvent]: + for event in self._events: + self.pulled += 1 + yield event + + def close(self) -> None: + if not self._closed: + self._closed = True + self.closes += 1 + + +class _AsyncRecordingSource: + """Async twin of `_RecordingSource` (idempotent release).""" + + def __init__(self, events: list[SseEvent]) -> None: + self._events = events + self.pulled = 0 + self.closes = 0 + self._closed = False + + def __aiter__(self) -> AsyncIterator[SseEvent]: + return self._gen() + + async def _gen(self) -> AsyncIterator[SseEvent]: + for event in self._events: + self.pulled += 1 + yield event + + async def aclose(self) -> None: + if not self._closed: + self._closed = True + self.closes += 1 + + +def _ev(data: str, event: str = "message") -> SseEvent: + return SseEvent(data=data, event=event) + + +# --- SKIP / DONE ------------------------------------------------------------ + + +@pytest.mark.req("SSE-24", "SSE-33", "SSE-34") +def test_skip_discards_and_keeps_pulling() -> None: + source = _RecordingSource([_ev("keep-a"), _ev("drop"), _ev("keep-b")]) + + def mapper(name: str, data: str) -> str | SseSignal: + return SseSignal.SKIP if data == "drop" else data + + assert list(TypedSseConnection(source, mapper)) == ["keep-a", "keep-b"] + assert source.closes == 1 # released on clean exhaustion + + +@pytest.mark.req("SSE-34") +def test_done_stops_the_stream_and_releases() -> None: + source = _RecordingSource([_ev("1"), _ev("2"), _ev("stop"), _ev("3")]) + + def mapper(name: str, data: str) -> int | SseSignal: + return SseSignal.DONE if data == "stop" else int(data) + + assert list(TypedSseConnection(source, mapper)) == [1, 2] + # Stopped at "stop": the event after it was never pulled. + assert source.pulled == 3 + assert source.closes == 1 + + +# --- lazy, pull-based draining --------------------------------------------- + + +@pytest.mark.req("SSE-35", "SSE-39") +def test_mapping_is_lazy_one_pull_per_demand() -> None: + source = _RecordingSource([_ev("a"), _ev("b"), _ev("c")]) + typed = TypedSseConnection(source, lambda name, data: data) + + iterator = iter(typed) + assert next(iterator) == "a" + assert source.pulled == 1 # only the first event was drawn, nothing ahead + + assert next(iterator) == "b" + assert source.pulled == 2 + + typed.close() + + +@pytest.mark.req("SSE-35") +def test_lazy_draining_accounts_for_skipped_events() -> None: + source = _RecordingSource([_ev("skip"), _ev("value"), _ev("ahead")]) + + def mapper(name: str, data: str) -> str | SseSignal: + return SseSignal.SKIP if data == "skip" else data + + typed = TypedSseConnection(source, mapper) + iterator = iter(typed) + assert next(iterator) == "value" + # One demand drew exactly the skipped event plus the delivered one; the + # third event was not pulled ahead. + assert source.pulled == 2 + + typed.close() + + +@pytest.mark.req("SSE-33") +def test_mapper_receives_event_name_and_data() -> None: + source = _RecordingSource([_ev("payload", event="custom")]) + seen: list[tuple[str, str]] = [] + + def mapper(name: str, data: str) -> str: + seen.append((name, data)) + return data + + assert list(TypedSseConnection(source, mapper)) == ["payload"] + assert seen == [("custom", "payload")] + + +# --- mapper failure releases the resource first ---------------------------- + + +class _MapperError(Exception): + pass + + +@pytest.mark.req("SSE-36") +def test_mapper_throw_releases_resource_first() -> None: + source = _RecordingSource([_ev("boom")]) + + def mapper(name: str, data: str) -> str: + raise _MapperError(data) + + with pytest.raises(_MapperError): + list(TypedSseConnection(source, mapper)) + + assert source.closes == 1 # released before the mapper error propagated + + +# --- clean-terminal release-failure demotion (SSE-30) ---------------------- + + +class _CloseError(Exception): + """Raised by a source's close() to exercise release-failure handling.""" + + +class _FailingCloseSource: + """A sync SSE source whose close() always raises.""" + + def __init__(self, events: list[SseEvent]) -> None: + self._events = events + self.closes = 0 + + def __iter__(self) -> Iterator[SseEvent]: + yield from self._events + + def close(self) -> None: + self.closes += 1 + raise _CloseError("release failed") + + +@pytest.mark.req("SSE-30") +def test_clean_terminal_release_failure_is_swallowed() -> None: + # A DONE sentinel is an automatic clean-terminal path with no error in + # flight: a release failure must not be turned into a thrown result that + # discards already-delivered events, so it is swallowed (reported + # out-of-band), not raised. + source = _FailingCloseSource([_ev("1"), _ev("stop")]) + + def mapper(name: str, data: str) -> int | SseSignal: + return SseSignal.DONE if data == "stop" else int(data) + + assert list(TypedSseConnection(source, mapper)) == [1] + assert source.closes >= 1 # close was attempted despite raising + + +@pytest.mark.req("SSE-30") +def test_clean_exhaustion_release_failure_is_swallowed() -> None: + # Natural end-of-stream is likewise a clean terminal: the release failure is + # swallowed and the delivered values survive. + source = _FailingCloseSource([_ev("a"), _ev("b")]) + assert list(TypedSseConnection(source, lambda name, data: data)) == ["a", "b"] + + +@pytest.mark.req("SSE-30") +def test_explicit_close_propagates_release_failure() -> None: + # By contrast, a release failure during an EXPLICIT close() propagates to + # the caller rather than being swallowed. + source = _FailingCloseSource([]) + typed = TypedSseConnection(source, lambda name, data: data) + + with pytest.raises(_CloseError): + typed.close() + + +class _AsyncFailingCloseSource: + """Async twin of `_FailingCloseSource`.""" + + def __init__(self, events: list[SseEvent]) -> None: + self._events = events + self.closes = 0 + + def __aiter__(self) -> AsyncIterator[SseEvent]: + return self._gen() + + async def _gen(self) -> AsyncIterator[SseEvent]: + for event in self._events: + yield event + + async def aclose(self) -> None: + self.closes += 1 + raise _CloseError("release failed") + + +@pytest.mark.req("SSE-30") +async def test_async_clean_terminal_release_failure_is_swallowed() -> None: + source = _AsyncFailingCloseSource([_ev("1"), _ev("stop")]) + + def mapper(name: str, data: str) -> int | SseSignal: + return SseSignal.DONE if data == "stop" else int(data) + + received = [value async for value in AsyncTypedSseConnection(source, mapper)] + assert received == [1] + assert source.closes >= 1 + + +@pytest.mark.req("SSE-30") +async def test_async_explicit_close_propagates_release_failure() -> None: + source = _AsyncFailingCloseSource([]) + typed = AsyncTypedSseConnection(source, lambda name, data: data) + + with pytest.raises(_CloseError): + await typed.aclose() + + +# --- integration with the real reconnecting facade ------------------------- + + +class _Body(ResponseBody): + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self.closes = 0 + + def media_type(self) -> MediaType | None: + return MediaType.parse("text/event-stream") + + def content_length(self) -> int: + return -1 + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + yield from self._chunks + + def close(self) -> None: + self.closes += 1 + + +class _RecordingClock: + def now(self) -> float: + return 0.0 + + def monotonic(self) -> float: + return 0.0 + + def sleep(self, duration: float) -> None: + return None + + +class _LowJitter(random.Random): + def uniform(self, a: float, b: float) -> float: + return a + + +def _request() -> Request: + return Request(method=Method.GET, url=Url.parse("https://api.example.com/stream")) + + +@pytest.mark.req("SSE-37") +def test_typed_adapter_drives_real_connection_to_clean_stop() -> None: + body = _Body([b"data: 1\n\n", b"data: 2\n\ndata: [DONE]\n\n"]) + + def send(request: Request) -> Response: + return Response(request=_request(), protocol=Protocol.HTTP_1_1, status=Status.OK, body=body) + + connection = SseConnection(send, _request(), clock=_RecordingClock(), rand=_LowJitter()) + + def mapper(name: str, data: str) -> int | SseSignal: + return SseSignal.DONE if data == "[DONE]" else int(data) + + with TypedSseConnection(connection, mapper) as typed: + assert list(typed) == [1, 2] + + assert body.closes == 1 # the underlying response was released exactly once + + +# --- async twin ------------------------------------------------------------- + + +@pytest.mark.req("SSE-34") +async def test_async_skip_and_done() -> None: + source = _AsyncRecordingSource([_ev("1"), _ev("drop"), _ev("2"), _ev("stop"), _ev("3")]) + + def mapper(name: str, data: str) -> int | SseSignal: + if data == "drop": + return SseSignal.SKIP + if data == "stop": + return SseSignal.DONE + return int(data) + + received = [value async for value in AsyncTypedSseConnection(source, mapper)] + assert received == [1, 2] + assert source.pulled == 4 # "3" never pulled + assert source.closes == 1 + + +@pytest.mark.req("SSE-36") +async def test_async_mapper_throw_releases_resource_first() -> None: + source = _AsyncRecordingSource([_ev("boom")]) + + def mapper(name: str, data: str) -> str: + raise _MapperError(data) + + with pytest.raises(_MapperError): + async with AsyncTypedSseConnection(source, mapper) as typed: + async for _ in typed: + pass + + assert source.closes == 1 + + +@pytest.mark.req("SSE-35", "SSE-39") +async def test_async_mapping_is_lazy() -> None: + source = _AsyncRecordingSource([_ev("a"), _ev("b"), _ev("c")]) + typed = AsyncTypedSseConnection(source, lambda name, data: data) + + iterator = aiter(typed) + assert await anext(iterator) == "a" + assert source.pulled == 1 + + assert await anext(iterator) == "b" + assert source.pulled == 2 + + await typed.aclose() diff --git a/packages/dexpace-sdk-core/tests/test_api_surface_pins.py b/packages/dexpace-sdk-core/tests/test_api_surface_pins.py new file mode 100644 index 0000000..d72a063 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_api_surface_pins.py @@ -0,0 +1,186 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Guards for the *consumer-facing* API contract that the surface baseline alone +does not pin down. + +Two mechanisms live here: + +- **Protocol / ABC member pins.** Every seam a downstream consumer implements + (`HttpClient`, `Serde`, `PaginationStrategy`, `Policy`, …) has its required + member set frozen. Adding a member to one of these is a breaking change for + every existing implementer, so a new capability must arrive as a *new* + optional Protocol, not a widened existing one. These tests fail the moment a + member is silently added or removed, forcing that decision to be explicit. +- **A seeded-change proof.** A faithful copy of the source trees reproduces the + committed surface baseline exactly; renaming a single public method in the + copy then makes `build_surface` diverge. This proves the snapshot-diff gate + in ``test_public_surface.py`` actually catches a public-signature change + rather than passing vacuously. +""" + +from __future__ import annotations + +import importlib.util +import json +import shutil +import typing +from pathlib import Path +from types import ModuleType + +import pytest + +from dexpace.sdk.core.client import AsyncHttpClient, HttpClient +from dexpace.sdk.core.pagination import HasHeaders, PaginationStrategy +from dexpace.sdk.core.pipeline.async_policy import AsyncPolicy +from dexpace.sdk.core.pipeline.policy import Policy +from dexpace.sdk.core.serde import Deserializer, Serde, Serializer + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TOOL_PATH = _REPO_ROOT / "tools" / "surface_snapshot.py" +_BASELINE_PATH = _REPO_ROOT / "tools" / "surface_baseline.json" + +# Distribution dir -> namespace sub-path, mirrored from surface_snapshot so the +# seeded-change proof can copy each ``src`` tree faithfully. +_DISTRIBUTIONS = { + "dexpace-sdk-core": "dexpace/sdk/core", + "dexpace-sdk-http-stdlib": "dexpace/sdk/http/stdlib", + "dexpace-sdk-http-httpx": "dexpace/sdk/http/httpx", + "dexpace-sdk-http-aiohttp": "dexpace/sdk/http/aiohttp", + "dexpace-sdk-http-requests": "dexpace/sdk/http/requests", +} + +# --- Pinned required-member sets ------------------------------------------- +# +# Each entry freezes the members a downstream consumer must supply to satisfy +# the Protocol. Adding one here without a deliberate review is exactly the +# silent-widening this guard exists to reject. +_PROTOCOL_MEMBERS: dict[type, frozenset[str]] = { + HttpClient: frozenset({"execute"}), + AsyncHttpClient: frozenset({"execute"}), + # media_type added deliberately (PY-M5-04): each bundle declares its own + # wire format rather than the SPI defaulting one at the call site. + Serde: frozenset({"serializer", "deserializer", "media_type"}), + Serializer: frozenset({"serialize", "serialize_to_bytes", "serialize_to_stream"}), + Deserializer: frozenset({"deserialize", "deserialize_bytes", "deserialize_stream"}), + PaginationStrategy: frozenset({"parse"}), + HasHeaders: frozenset({"headers"}), +} + + +def _load_snapshot_tool() -> ModuleType: + """Import ``tools/surface_snapshot.py`` by file path (``tools`` is not a package).""" + spec = importlib.util.spec_from_file_location("_surface_snapshot_pins", _TOOL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load surface snapshot tool from {_TOOL_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _protocol_members(proto: type) -> frozenset[str]: + """Return the required member names of a `typing.Protocol`. + + Computed the same way `typing.get_protocol_members` does — walking the MRO + for annotated attributes and body-defined methods / properties, skipping the + ``typing`` machinery (`Protocol`, `Generic`) and ``object`` — so the pin is + reproducible on Python 3.12, which predates that public helper. + + Args: + proto: A Protocol class (subscripted generics are fine; the origin's + MRO is walked). + + Returns: + The frozen set of public member names the Protocol requires. + """ + members: set[str] = set() + for klass in proto.__mro__: + if klass is object or klass.__module__ == "typing": + continue + members.update( + name for name in getattr(klass, "__annotations__", {}) if not name.startswith("_") + ) + for name, value in vars(klass).items(): + if not name.startswith("_") and (callable(value) or isinstance(value, property)): + members.add(name) + return frozenset(members) + + +@pytest.mark.req("NFR-3") +@pytest.mark.parametrize( + ("proto", "expected"), + list(_PROTOCOL_MEMBERS.items()), + ids=lambda arg: arg.__name__ if isinstance(arg, type) else "", +) +def test_protocol_required_members_are_pinned(proto: type, expected: frozenset[str]) -> None: + assert _protocol_members(proto) == expected, ( + f"{proto.__name__}'s required member set changed. Adding a required member " + "is a breaking change for every existing implementer — ship the new " + "capability as a NEW optional Protocol instead of widening this one. If " + "the change is truly intended, update the pin in _PROTOCOL_MEMBERS." + ) + + +def test_protocol_member_helper_matches_stdlib_when_available() -> None: + # On 3.13+ the stdlib grew ``typing.get_protocol_members``; cross-check the + # hand-rolled walker against it so the 3.12-compatible fallback cannot drift. + stdlib_impl = getattr(typing, "get_protocol_members", None) + if stdlib_impl is None: + pytest.skip("typing.get_protocol_members is unavailable before Python 3.13") + for proto in _PROTOCOL_MEMBERS: + assert _protocol_members(proto) == frozenset(stdlib_impl(proto)) + + +@pytest.mark.parametrize( + ("policy", "abstract"), + [(Policy, frozenset({"send"})), (AsyncPolicy, frozenset({"send"}))], + ids=["Policy", "AsyncPolicy"], +) +def test_policy_abstract_member_set_is_pinned( + policy: type[Policy] | type[AsyncPolicy], abstract: frozenset[str] +) -> None: + # ``Policy`` / ``AsyncPolicy`` are consumer-subclassed ABCs: a new abstract + # method would break every existing subclass, so the abstract set is pinned. + assert policy.__abstractmethods__ == abstract + # The declared class-level requirements (wired by the pipeline, not the + # subclass) are likewise frozen so neither silently grows. + assert set(policy.__dict__.get("__annotations__", {})) == {"STAGE", "next"} + + +def _copy_source_trees(destination: Path) -> None: + """Copy every distribution's ``src`` tree under ``destination/packages``.""" + for dist, namespace in _DISTRIBUTIONS.items(): + src = _REPO_ROOT / "packages" / dist / "src" + if not (src / namespace).is_dir(): + continue + shutil.copytree(src, destination / "packages" / dist / "src") + + +@pytest.mark.req("NFR-4") +def test_seeded_public_signature_change_breaks_the_surface_diff(tmp_path: Path) -> None: + tool = _load_snapshot_tool() + baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8")) + _copy_source_trees(tmp_path) + + # A faithful copy must reproduce the committed baseline exactly; otherwise + # the proof below would pass for the wrong reason. + assert tool.build_surface(tmp_path) == baseline + + # Seed a public-signature change: rename the ``HttpClient.execute`` seam. + seam = ( + tmp_path + / "packages" + / "dexpace-sdk-core" + / "src" + / ("dexpace/sdk/core/client/http_client.py") + ) + source = seam.read_text(encoding="utf-8") + assert "def execute(" in source + seam.write_text(source.replace("def execute(", "def execute_renamed("), encoding="utf-8") + + mutated = tool.build_surface(tmp_path) + assert mutated != baseline, "the snapshot diff failed to detect a renamed public method" + + client_defs = mutated["dexpace-sdk-core"]["definitions"]["dexpace.sdk.core.client.http_client"] + assert "execute" not in client_defs["HttpClient"]["methods"] + assert "execute_renamed" in client_defs["HttpClient"]["methods"] diff --git a/packages/dexpace-sdk-core/tests/test_conformance_fixtures.py b/packages/dexpace-sdk-core/tests/test_conformance_fixtures.py index 44b319e..7c8aa68 100644 --- a/packages/dexpace-sdk-core/tests/test_conformance_fixtures.py +++ b/packages/dexpace-sdk-core/tests/test_conformance_fixtures.py @@ -157,10 +157,12 @@ def test_context_store_evicts_entry_on_close() -> None: request_ctx = dispatch.to_request_context( Request(method=Method.GET, url=Url.parse("https://example.com/")) ) - assert ContextStore.get(trace_id) is request_ctx + key = request_ctx.store_key + assert key is not None + assert ContextStore.get(key) is request_ctx request_ctx.close() - assert ContextStore.get(trace_id) is None + assert ContextStore.get(key) is None def test_context_manager_exit_evicts_from_store() -> None: @@ -170,9 +172,11 @@ def test_context_manager_exit_evicts_from_store() -> None: request_ctx = dispatch.to_request_context( Request(method=Method.GET, url=Url.parse("https://example.com/")) ) + key = request_ctx.store_key + assert key is not None with request_ctx: - assert ContextStore.get(trace_id) is request_ctx - assert ContextStore.get(trace_id) is None + assert ContextStore.get(key) is request_ctx + assert ContextStore.get(key) is None # ----- retry honours Retry-After ------------------------------------------ diff --git a/packages/dexpace-sdk-core/tests/test_dependency_audit.py b/packages/dexpace-sdk-core/tests/test_dependency_audit.py new file mode 100644 index 0000000..4ed06fd --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_dependency_audit.py @@ -0,0 +1,121 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Runtime-dependency budget gate. + +``tools/check_dependency_audit.py`` enforces the workspace's dependency budget: +``dexpace-sdk-core`` declares stdlib plus ``furl`` and nothing else, and every +transport adapter declares ``dexpace-sdk-core`` plus at most one third-party +library. These tests prove the live workspace is within budget and that the +audit rejects each way the budget can be broken. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TOOL_PATH = _REPO_ROOT / "tools" / "check_dependency_audit.py" + + +def _load_tool() -> ModuleType: + """Import ``tools/check_dependency_audit.py`` by file path.""" + spec = importlib.util.spec_from_file_location("_check_dependency_audit", _TOOL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load dependency-audit tool from {_TOOL_PATH}") + module = importlib.util.module_from_spec(spec) + # Register before executing so the tool's dataclasses can resolve their + # (stringised) annotations against their own module namespace. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def tool() -> ModuleType: + """Provide the loaded dependency-audit tool once per module.""" + return _load_tool() + + +def _write_workspace(root: Path, deps: dict[str, list[str]]) -> None: + """Materialise a minimal workspace of pyproject files under ``root``. + + Args: + root: Directory to build the ``packages/`` tree under. + deps: Distribution name -> its ``[project].dependencies`` list. + """ + for distribution, dependencies in deps.items(): + package_dir = root / "packages" / distribution + package_dir.mkdir(parents=True, exist_ok=True) + rendered = ", ".join(f'"{dep}"' for dep in dependencies) + (package_dir / "pyproject.toml").write_text( + f'[project]\nname = "{distribution}"\ndependencies = [{rendered}]\n', + encoding="utf-8", + ) + + +_WITHIN_BUDGET: dict[str, list[str]] = { + "dexpace-sdk-core": ["furl>=2.1.3"], + "dexpace-sdk-http-stdlib": ["dexpace-sdk-core>=0.1,<0.2"], + "dexpace-sdk-http-httpx": ["dexpace-sdk-core>=0.1,<0.2", "httpx>=0.27,<0.29"], + "dexpace-sdk-http-aiohttp": ["dexpace-sdk-core>=0.1,<0.2", "aiohttp>=3.9,<4.0"], + "dexpace-sdk-http-requests": ["dexpace-sdk-core>=0.1,<0.2", "requests>=2.32,<3.0"], +} + + +@pytest.mark.req("NFR-1", "SEAM-1", "SEAM-2") +def test_live_workspace_is_within_budget(tool: ModuleType) -> None: + violations = tool.audit_workspace(_REPO_ROOT) + assert violations == [], [f"{v.distribution}: {v.message}" for v in violations] + + +@pytest.mark.req("NFR-2") +def test_synthetic_budget_workspace_passes(tool: ModuleType, tmp_path: Path) -> None: + _write_workspace(tmp_path, _WITHIN_BUDGET) + assert tool.audit_workspace(tmp_path) == [] + + +@pytest.mark.req("NFR-1", "SEAM-1") +def test_core_with_extra_dependency_is_rejected(tool: ModuleType, tmp_path: Path) -> None: + deps = dict(_WITHIN_BUDGET) + deps["dexpace-sdk-core"] = ["furl>=2.1.3", "requests>=2.32"] + _write_workspace(tmp_path, deps) + violations = tool.audit_workspace(tmp_path) + assert any(v.distribution == "dexpace-sdk-core" and "requests" in v.message for v in violations) + + +@pytest.mark.req("NFR-1") +def test_core_without_furl_is_rejected(tool: ModuleType, tmp_path: Path) -> None: + deps = dict(_WITHIN_BUDGET) + deps["dexpace-sdk-core"] = [] + _write_workspace(tmp_path, deps) + violations = tool.audit_workspace(tmp_path) + assert any(v.distribution == "dexpace-sdk-core" for v in violations) + + +@pytest.mark.req("NFR-2") +def test_adapter_with_two_third_party_libs_is_rejected(tool: ModuleType, tmp_path: Path) -> None: + deps = dict(_WITHIN_BUDGET) + deps["dexpace-sdk-http-httpx"] = ["dexpace-sdk-core>=0.1,<0.2", "httpx>=0.27", "anyio>=4.0"] + _write_workspace(tmp_path, deps) + violations = tool.audit_workspace(tmp_path) + assert any( + v.distribution == "dexpace-sdk-http-httpx" and "limit is 1" in v.message for v in violations + ) + + +@pytest.mark.req("NFR-2") +def test_adapter_without_core_is_rejected(tool: ModuleType, tmp_path: Path) -> None: + deps = dict(_WITHIN_BUDGET) + deps["dexpace-sdk-http-requests"] = ["requests>=2.32,<3.0"] + _write_workspace(tmp_path, deps) + violations = tool.audit_workspace(tmp_path) + assert any( + v.distribution == "dexpace-sdk-http-requests" and "does not depend on" in v.message + for v in violations + ) diff --git a/packages/dexpace-sdk-core/tests/test_docs_snippets.py b/packages/dexpace-sdk-core/tests/test_docs_snippets.py new file mode 100644 index 0000000..3a9b07b --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_docs_snippets.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Keep the Markdown docs honest against the real public API. + +Every fenced ``python`` block under ``docs/`` is checked two ways: it must parse +and compile (no stale syntax), and every ``dexpace`` import it names must resolve +to a real, current symbol. A snippet that imports a symbol the SDK no longer +exports fails here, so prose and code cannot drift apart silently. + +Third-party and example-package imports (anything outside the ``dexpace`` +namespace) are left to the reader's environment — only the SDK's own surface is +held to a hard resolve. +""" + +from __future__ import annotations + +import ast +import importlib +import re +from collections.abc import Iterator +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_DOCS = _REPO_ROOT / "docs" + +#: A fenced ``python`` code block; group 1 is the block's source. +_FENCE = re.compile(r"^```python\s*$(.*?)^```\s*$", re.MULTILINE | re.DOTALL) + + +def _iter_python_blocks() -> Iterator[tuple[str, str]]: + """Yield ``(label, source)`` for every ``python`` block under ``docs/``. + + Yields: + A stable ``file.md#pyN`` label and the block's source text, in document + order across the sorted set of Markdown files. + """ + for md in sorted(_DOCS.glob("*.md")): + text = md.read_text(encoding="utf-8") + for index, match in enumerate(_FENCE.finditer(text)): + yield f"{md.name}#py{index}", match.group(1) + + +_BLOCKS = list(_iter_python_blocks()) + + +def _parse(source: str, label: str) -> ast.Module: + """Parse a snippet, failing the test with its label on a syntax error. + + Args: + source: The snippet source. + label: The ``file.md#pyN`` label used in the failure message. + + Returns: + The parsed module. + """ + try: + return ast.parse(source, filename=label) + except SyntaxError as exc: + pytest.fail(f"{label}: syntax error in doc snippet: {exc}") + + +def _assert_dexpace_imports_resolve(tree: ast.Module, label: str) -> None: + """Assert every ``dexpace.*`` import in ``tree`` resolves to a real symbol. + + Args: + tree: The parsed snippet. + label: The ``file.md#pyN`` label used in failure messages. + """ + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.level or node.module is None: + continue + module = node.module + strict = module.split(".", 1)[0] == "dexpace" + try: + imported = importlib.import_module(module) + except ImportError: + if strict: + pytest.fail(f"{label}: cannot import {module!r}") + continue + for alias in node.names: + if alias.name == "*": + continue + assert hasattr(imported, alias.name), ( + f"{label}: {module}.{alias.name} is not a real symbol" + ) + elif isinstance(node, ast.Import): + for alias in node.names: + module = alias.name + if module.split(".", 1)[0] != "dexpace": + continue + try: + importlib.import_module(module) + except ImportError: + pytest.fail(f"{label}: cannot import {module!r}") + + +def test_docs_contain_python_snippets() -> None: + """The docs carry python snippets — a guard against a broken docs path.""" + assert _BLOCKS, f"no python snippets found under {_DOCS} — check the docs path" + + +@pytest.mark.parametrize( + ("label", "source"), + _BLOCKS, + ids=[label for label, _ in _BLOCKS], +) +def test_doc_snippet_compiles_and_imports_resolve(label: str, source: str) -> None: + """Each doc snippet compiles, and every ``dexpace`` import in it resolves.""" + tree = _parse(source, label) + compile(tree, label, "exec") + _assert_dexpace_imports_resolve(tree, label) diff --git a/packages/dexpace-sdk-core/tests/test_golden_corpus.py b/packages/dexpace-sdk-core/tests/test_golden_corpus.py new file mode 100644 index 0000000..fcae79a --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_golden_corpus.py @@ -0,0 +1,345 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Self-consistency checks for the golden wire-exactness byte corpus. + +This suite proves the corpus loads and is internally coherent — it does NOT +implement the production codecs/parsers that will later consume the fixtures. +Where a vector claims a relationship (strict render / lenient parse inverse, +canonical date round-trip, ``Retry-After`` classification), the check +re-derives it with a small stdlib oracle so a corrupted or mis-authored +fixture fails the build. The oracles here are fixture validators, not the SDK's +URL codec, redirect resolver, pagination splicer, SSE parser, or retry-pacing +parser — those live elsewhere and point their own conformance at this corpus. +""" + +from __future__ import annotations + +import os +import re +from datetime import datetime +from email.utils import formatdate, parsedate_to_datetime +from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit + +import pytest + +from dexpace.sdk.tck.golden_corpus import ( + FAMILIES, + FAMILY_TAGS, + load_all, + load_pagination_splice, + load_query_codec, + load_redirect, + load_retry_pacing, + load_rfc1123_dates, + load_sse, +) + +# ----- stdlib fixture-validation oracles (NOT the production codecs) ------- + +_PRESCREEN = re.compile(r"^\s*\d+(\.\d+)?\s*$") + + +def _strict_render(params: tuple[tuple[bytes, bytes], ...]) -> str: + """Strict RFC 3986 query render: unreserved-only, space->%20, +->%2B.""" + return "&".join( + f"{quote(name.decode(), safe='')}={quote(value.decode(), safe='')}" + for name, value in params + ) + + +def _lenient_parse(query: str) -> list[tuple[str, str]]: + """Lenient RFC 3986 query parse: '+' stays literal (never form-decoded).""" + if not query: + return [] + pairs: list[tuple[str, str]] = [] + for token in query.split("&"): + name, sep, value = token.partition("=") + pairs.append((unquote(name), unquote(value) if sep else "")) + return pairs + + +def _resolve(base: str, location: str) -> str: + """Raw-component Location splice: urljoin then drop userinfo.""" + parts = urlsplit(urljoin(base, location)) + netloc = parts.netloc.rsplit("@", 1)[-1] if "@" in parts.netloc else parts.netloc + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + +def _parsedate_or_none(text: str) -> datetime | None: + try: + return parsedate_to_datetime(text) + except (TypeError, ValueError): + return None + + +def _float_ok(token: str) -> bool: + try: + float(token) + except ValueError: + return False + return True + + +# ----- corpus-wide structure ---------------------------------------------- + + +def test_family_tags_cover_exactly_the_families() -> None: + assert set(FAMILY_TAGS) == set(FAMILIES) + assert len(set(FAMILY_TAGS.values())) == len(FAMILIES) + + +def test_every_family_loads_and_is_nonempty() -> None: + corpus = load_all() + assert set(corpus) == set(FAMILIES) + for family in FAMILIES: + vectors = corpus[family] + assert vectors, f"{family} has no vectors" + for vector in vectors: + assert vector.family == family + assert vector.tag == FAMILY_TAGS[family] + assert vector.id + assert vector.description + + +def test_vector_ids_are_unique_within_each_family() -> None: + for family, vectors in load_all().items(): + ids = [vector.id for vector in vectors] + assert len(ids) == len(set(ids)), f"duplicate id in {family}" + + +def test_wire_fields_load_as_bytes_never_str() -> None: + for query in load_query_codec(): + assert isinstance(query.query, bytes) + assert all(isinstance(n, bytes) and isinstance(v, bytes) for n, v in query.params) + for redirect in load_redirect(): + assert isinstance(redirect.base, bytes) + assert isinstance(redirect.location, bytes) + assert isinstance(redirect.resolved, bytes) + for splice in load_pagination_splice(): + assert isinstance(splice.base, bytes) and isinstance(splice.spliced, bytes) + for sse in load_sse(): + assert isinstance(sse.stream, bytes) + for pacing in load_retry_pacing(): + assert isinstance(pacing.raw, bytes) + for date in load_rfc1123_dates(): + assert isinstance(date.text, bytes) + + +# ----- query codec (HTTP-29) ---------------------------------------------- + + +@pytest.mark.req("HTTP-29") +def test_query_codec_render_and_parse_match_claims() -> None: + for vector in load_query_codec(): + canonical = [(n.decode(), v.decode()) for n, v in vector.params] + query = vector.query.decode("ascii") + if vector.render_matches: + assert _strict_render(vector.params) == query, vector.id + if vector.parse_matches: + assert _lenient_parse(query) == canonical, vector.id + assert vector.round_trippable == (vector.render_matches and vector.parse_matches) + + +def test_query_codec_round_trip_is_exact_inverse() -> None: + for vector in load_query_codec(): + if not vector.round_trippable: + continue + canonical = [(n.decode(), v.decode()) for n, v in vector.params] + assert _lenient_parse(_strict_render(vector.params)) == canonical, vector.id + + +def test_query_codec_pins_named_edge_cases() -> None: + by_id = {vector.id: vector for vector in load_query_codec()} + assert by_id["space-encodes-to-pct20"].query == b"q=a%20b" + assert by_id["plus-encodes-to-pct2b"].query == b"q=a%2Bb" + assert by_id["valueless-flag-empty-string"].query == b"flag=" + # A literal wire '+' parses back to '+', not space (not round-trippable). + literal_plus = by_id["parse-literal-plus-not-space"] + assert literal_plus.query == b"q=a+b" + assert literal_plus.parse_matches and not literal_plus.render_matches + # A bare flag is a present empty string, distinct from absent. + assert by_id["parse-bare-flag-is-empty"].params == ((b"flag", b""),) + + +# ----- redirect (REDIR-12) ------------------------------------------------ + + +@pytest.mark.req("REDIR-12") +def test_redirect_resolution_matches_raw_splice() -> None: + for vector in load_redirect(): + expected = _resolve(vector.base.decode(), vector.location.decode()) + assert vector.resolved.decode() == expected, vector.id + + +def test_redirect_drops_userinfo() -> None: + for vector in load_redirect(): + authority = vector.resolved.split(b"://", 1)[1].split(b"/", 1)[0] + assert b"@" not in authority, vector.id + + +def test_redirect_keeps_ipv6_brackets_and_percent_encoding() -> None: + by_id = {vector.id: vector for vector in load_redirect()} + for vid in ("ipv6-brackets-kept-absolute", "ipv6-brackets-kept-relative"): + assert b"[" in by_id[vid].resolved and b"]" in by_id[vid].resolved + encoded = by_id["preserve-percent-encoding"].resolved + assert b"%2F" in encoded and b"%20" in encoded # never decoded/normalised + assert b"user:pass" not in by_id["userinfo-dropped"].resolved + + +# ----- pagination splice (PAGE-21) ---------------------------------------- + + +@pytest.mark.req("PAGE-21") +def test_pagination_splice_adds_param_and_preserves_untouched() -> None: + for vector in load_pagination_splice(): + assert vector.param + b"=" + vector.value in vector.spliced, vector.id + for untouched in vector.untouched: + assert untouched in vector.spliced, (vector.id, untouched) + + +def test_pagination_splice_preserves_bytes_verbatim() -> None: + by_id = {vector.id: vector for vector in load_pagination_splice()} + # A literal '+' is neither re-encoded to %2B nor decoded to space. + assert b"tag=a+b" in by_id["preserve-literal-plus-verbatim"].spliced + # A pre-encoded %20 survives byte-for-byte. + assert b"q=a%20b" in by_id["preserve-encoded-param-verbatim"].spliced + + +# ----- SSE (SSE-14) ------------------------------------------------------- + + +@pytest.mark.req("SSE-14") +def test_sse_streams_preserve_declared_terminators() -> None: + for vector in load_sse(): + assert vector.stream, vector.id + stream = vector.stream + if vector.terminator == "CRLF": + assert b"\r\n" in stream, vector.id + elif vector.terminator == "CR": + bare_cr = any( + byte == 0x0D and (i + 1 >= len(stream) or stream[i + 1] != 0x0A) + for i, byte in enumerate(stream) + ) + assert bare_cr, vector.id + elif vector.terminator == "LF": + assert b"\n" in stream and b"\r" not in stream, vector.id + else: + pytest.fail(f"unknown terminator {vector.terminator!r} in {vector.id}") + + +def test_sse_expected_events_are_well_formed() -> None: + for vector in load_sse(): + assert vector.events, vector.id + for event in vector.events: + assert isinstance(event.data, str) + # event is absent (None) when the block sent no event: field — the + # parser does not default it to "message". + assert event.event is None or isinstance(event.event, str) + assert event.id is None or isinstance(event.id, str) + assert event.retry is None or isinstance(event.retry, int) + assert event.comment is None or isinstance(event.comment, str) + + +def test_sse_binary_sensitive_fixtures_are_byte_exact() -> None: + by_id = {vector.id: vector for vector in load_sse()} + assert by_id["crlf-single-event"].stream == b"data: hello\r\n\r\n" + assert by_id["cr-single-event"].stream == b"data: hello\r\r" + assert by_id["leading-bom-stripped"].stream.startswith(b"\xef\xbb\xbf") + assert by_id["utf8-data-payload"].stream == b"data: caf\xc3\xa9\n\n" + + +# ----- retry pacing (RETRY-19) -------------------------------------------- + + +@pytest.mark.req("RETRY-19") +def test_retry_pacing_classification_is_self_consistent() -> None: + for vector in load_retry_pacing(): + token = vector.raw.decode("ascii") + assert _float_ok(token) == vector.float_accepts, vector.id + prescreen = bool(_PRESCREEN.match(token)) + if vector.kind == "reject": + assert not prescreen and _parsedate_or_none(token) is None, vector.id + assert vector.seconds is None, vector.id + elif vector.kind == "delta": + assert prescreen and vector.seconds == float(token), vector.id + if vector.clamp_max is not None: + assert vector.clamped == min(float(token), vector.clamp_max), vector.id + elif vector.kind == "http_date": + assert not prescreen, vector.id + when = _parsedate_or_none(token) + assert when is not None and vector.now is not None, vector.id + delta = max(0.0, when.timestamp() - vector.now) + assert vector.seconds == delta, vector.id + if vector.clamp_max is not None: + assert vector.clamped == min(delta, vector.clamp_max), vector.id + else: + pytest.fail(f"unknown kind {vector.kind!r} in {vector.id}") + + +def test_retry_pacing_prescreen_rejects_what_float_over_accepts() -> None: + by_id = {vector.id: vector for vector in load_retry_pacing()} + for vid in ("reject-nan-token", "reject-inf-token", "reject-underscore-grouping"): + vector = by_id[vid] + assert vector.float_accepts is True # float() would take it... + assert vector.kind == "reject" and vector.seconds is None # ...pre-screen won't + + +def test_retry_pacing_past_date_zero_is_distinct_from_malformed_none() -> None: + by_id = {vector.id: vector for vector in load_retry_pacing()} + assert by_id["http-date-past-floors-to-zero"].seconds == 0.0 + assert by_id["reject-empty"].seconds is None + + +def test_retry_pacing_365_day_clamp() -> None: + for vector in load_retry_pacing(): + if vector.clamp_max is not None: + assert vector.clamp_max == 365 * 24 * 3600.0 + assert vector.clamped == vector.clamp_max, vector.id + + +# ----- RFC 1123 dates (CFG-29) -------------------------------------------- + + +@pytest.mark.req("CFG-29") +def test_rfc1123_render_and_parse_match_oracle() -> None: + for vector in load_rfc1123_dates(): + text = vector.text.decode("ascii") + if vector.direction == "render": + assert formatdate(vector.epoch, usegmt=True) == text, vector.id + else: + assert int(parsedate_to_datetime(text).timestamp()) == vector.epoch, vector.id + assert vector.round_trippable == (formatdate(vector.epoch, usegmt=True) == text) + + +def test_rfc1123_tolerant_parse_named_cases() -> None: + by_id = {vector.id: vector for vector in load_rfc1123_dates()} + canonical = by_id["parse-canonical"].epoch + # Informational weekday and zone aliases all resolve to the same instant. + for vid in ( + "parse-informational-weekday", + "parse-zone-alias-UT", + "parse-zone-numeric-utc", + "parse-zone-offset-plus0100", + ): + assert by_id[vid].epoch == canonical, vid + assert by_id["parse-informational-weekday"].round_trippable is False + assert by_id["parse-canonical"].round_trippable is True + + +# ----- furl-upgrade canary lane (non-blocking, opt-in) -------------------- + + +@pytest.mark.skipif( + os.environ.get("GOLDEN_CORPUS_FURL_CANARY") != "1", + reason=( + "furl-upgrade canary lane (opt-in): set GOLDEN_CORPUS_FURL_CANARY=1. " + "TODO: wire into a latest-furl CI matrix so a furl upgrade that shifts " + "URL byte-exactness is caught by re-running this corpus." + ), +) +def test_furl_upgrade_canary_reloads_corpus() -> None: + # Non-blocking stub: a future latest-furl lane reloads the whole corpus so + # a furl upgrade cannot silently change URL byte-exactness unnoticed. + corpus = load_all() + assert set(corpus) == set(FAMILIES) diff --git a/packages/dexpace-sdk-core/tests/test_import_contracts.py b/packages/dexpace-sdk-core/tests/test_import_contracts.py new file mode 100644 index 0000000..8664886 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_import_contracts.py @@ -0,0 +1,104 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Import-linter contract gate. + +The checked-in ``.importlinter`` contracts encode the SDK's dependency-direction +invariants: adapters depend on core (never the reverse), ``furl`` is confined to +the single ``Url`` module, and the pipeline runners sit above the pure algorithm +layer. ``lint-imports`` enforces them in CI; these tests prove the gate is wired +correctly — it passes against the real tree, and a deliberately seeded violation +(a module importing ``furl`` outside the ``Url`` module) turns it red. + +The contracts are exercised through the real ``lint-imports`` console script, +exactly as CI runs it, so the test cannot drift from the shipped behaviour. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from importlinter.api import read_configuration + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_CONFIG_PATH = _REPO_ROOT / ".importlinter" +_EXPECTED_CONTRACTS = { + "Adapters depend on core, never the reverse", + "furl is confined to the Url module", + "Pipeline runners sit above the pure algorithm layer", + "Codegen executor shells sit above the pure planner layer and never import each other", +} + + +def _lint_imports_executable() -> str: + """Return the path to the ``lint-imports`` console script in this env.""" + candidate = Path(sys.executable).parent / "lint-imports" + if candidate.exists(): + return str(candidate) + found = shutil.which("lint-imports") + if found is not None: + return found + raise FileNotFoundError("lint-imports console script not found in this environment") + + +def _run_lint_imports() -> subprocess.CompletedProcess[str]: + """Run ``lint-imports`` from the repo root, capturing its output.""" + return subprocess.run( + [_lint_imports_executable()], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.req("NFR-7") +def test_config_declares_the_expected_contracts() -> None: + configuration = read_configuration(str(_CONFIG_PATH)) + names = {contract["name"] for contract in configuration["contracts_options"]} + assert names == _EXPECTED_CONTRACTS + + +@pytest.mark.req("NFR-1", "NFR-7", "SEAM-1", "SEAM-2") +def test_real_tree_satisfies_all_contracts() -> None: + result = _run_lint_imports() + assert result.returncode == 0, ( + "lint-imports failed against the real tree:\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + assert "Contracts: 4 kept, 0 broken." in result.stdout + + +@pytest.mark.req("NFR-7") +def test_seeded_furl_violation_breaks_the_build() -> None: + # A module importing furl anywhere but the Url implementation must break the + # furl-confinement contract. Seed one directly in the source tree, confirm + # the gate goes red, and always remove it again. + seed = _REPO_ROOT / "packages" / "dexpace-sdk-core" / "src" / "dexpace" / "sdk" / "core" + seed = seed / "_furl_confinement_seed.py" + seed.write_text( + "# Copyright (c) 2026 dexpace and Omar Aljarrah.\n" + "# Licensed under the MIT License. See LICENSE.md in the repository root for details.\n" + "from __future__ import annotations\n" + "import furl # noqa: F401\n", + encoding="utf-8", + ) + try: + result = _run_lint_imports() + finally: + seed.unlink() + assert result.returncode != 0, "seeded furl import did not break the build" + assert "furl is confined to the Url module BROKEN" in result.stdout + assert "is not allowed to import furl" in result.stdout + + +@pytest.mark.parametrize("config_key", ["root_packages"]) +def test_config_scopes_all_five_distributions(config_key: str) -> None: + configuration = read_configuration(str(_CONFIG_PATH)) + roots = configuration["session_options"][config_key] + assert "dexpace.sdk.core" in roots + assert sum(1 for name in roots if name.startswith("dexpace.sdk.http.")) == 4 diff --git a/packages/dexpace-sdk-core/tests/test_parity_check.py b/packages/dexpace-sdk-core/tests/test_parity_check.py new file mode 100644 index 0000000..940c4d2 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_parity_check.py @@ -0,0 +1,305 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the sync/async parity gate (``tools/parity_check.py``). + +The tool is not an installed package, so it is loaded by file path — the same +approach ``test_public_surface.py`` uses for its sibling tool. Coverage: + +- a clean sync/async fixture pair normalizes to identical ASTs and passes; +- a fixture pair with a single seeded semantic drift fails in blocking mode and + is reported (but does not fail) in report-only mode; +- the declared token map handles ``asyncio.sleep`` -> ``time.sleep``, + ``async for`` -> ``for``, ``async with`` -> ``with``, and protocol method + renames; +- report-only mode over the real hand-written policy twins produces a diff + artifact without failing, even for the twins that legitimately diverge. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TOOL_PATH = _REPO_ROOT / "tools" / "parity_check.py" +_POLICIES = _REPO_ROOT / "packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies" + +# A clean, properly-paired sync/async fixture: the async twin differs only by +# async syntax and declared token swaps (``Policy``/``AsyncPolicy``, +# ``Response``/``AsyncResponse``, ``Clock``/``AsyncClock``, ...). +_CLEAN_SYNC = '''\ +"""Sync reference module.""" + +from __future__ import annotations + +from clock import SYSTEM_CLOCK, Clock +from policy import Policy + + +class SamplePolicy(Policy): + """A sample sync policy.""" + + def __init__(self, *, clock: Clock = SYSTEM_CLOCK) -> None: + self._clock = clock + + def send(self, request: object, ctx: object) -> Response: + stamped = request.with_time(self._clock.now()) + for header in stamped.headers: + stamped.mark(header) + return self.next.send(stamped, ctx) +''' + +_CLEAN_ASYNC = '''\ +"""Async twin of the sample module.""" + +from __future__ import annotations + +from clock import ASYNC_SYSTEM_CLOCK, AsyncClock +from async_policy import AsyncPolicy + + +class AsyncSamplePolicy(AsyncPolicy): + """Async variant of the sample policy.""" + + def __init__(self, *, clock: AsyncClock = ASYNC_SYSTEM_CLOCK) -> None: + self._clock = clock + + async def send(self, request: object, ctx: object) -> AsyncResponse: + stamped = request.with_time(self._clock.now()) + for header in stamped.headers: + stamped.mark(header) + return await self.next.send(stamped, ctx) +''' + +# The same async twin with one seeded semantic drift: it stamps ``now() + 1`` +# instead of ``now()``. Everything else is identical. +_DRIFT_ASYNC = _CLEAN_ASYNC.replace( + "request.with_time(self._clock.now())", + "request.with_time(self._clock.now() + 1)", +) + +# Token map for the fixtures above: the declared async-to-sync swaps the pair +# relies on, including the fixture's own class-name twin. +_FIXTURE_TOKENS: dict[str, str] = { + "AsyncPolicy": "Policy", + "async_policy": "policy", + "AsyncResponse": "Response", + "AsyncClock": "Clock", + "ASYNC_SYSTEM_CLOCK": "SYSTEM_CLOCK", + "AsyncSamplePolicy": "SamplePolicy", +} + + +def _load_tool() -> ModuleType: + """Import ``tools/parity_check.py`` by file path.""" + spec = importlib.util.spec_from_file_location("_parity_check", _TOOL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load parity tool from {_TOOL_PATH}") + module = importlib.util.module_from_spec(spec) + # Register before executing so the module's ``@dataclass`` can resolve its + # own module namespace (``sys.modules[cls.__module__]``) during class build. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def tool() -> ModuleType: + """Provide the loaded parity-check tool once per module.""" + return _load_tool() + + +@pytest.fixture +def fixture_tokens() -> dict[str, str]: + """The declared token map the sync/async fixtures are paired under.""" + return dict(_FIXTURE_TOKENS) + + +def test_clean_pair_is_in_parity(tool: ModuleType, fixture_tokens: dict[str, str]) -> None: + result = tool.check_parity_sources( + _CLEAN_SYNC, _CLEAN_ASYNC, token_map=fixture_tokens, mode=tool.Mode.BLOCKING + ) + assert result.in_parity, result.diff + assert result.diff == "" + assert not result.failed + + +def test_seeded_drift_fails_in_blocking_mode( + tool: ModuleType, fixture_tokens: dict[str, str] +) -> None: + result = tool.check_parity_sources( + _CLEAN_SYNC, _DRIFT_ASYNC, token_map=fixture_tokens, mode=tool.Mode.BLOCKING + ) + assert not result.in_parity + assert result.failed + assert result.diff # a structural diff artifact is produced + + +def test_seeded_drift_is_reported_but_not_failed_in_report_only( + tool: ModuleType, fixture_tokens: dict[str, str] +) -> None: + result = tool.check_parity_sources( + _CLEAN_SYNC, _DRIFT_ASYNC, token_map=fixture_tokens, mode=tool.Mode.REPORT_ONLY + ) + assert not result.in_parity + assert not result.failed # report-only never fails the build + assert result.diff + + +def test_diff_is_structural_not_textual(tool: ModuleType, fixture_tokens: dict[str, str]) -> None: + # Reformatting and comment/docstring changes must not register as drift. + reflowed_async = _CLEAN_ASYNC.replace( + '"""Async twin of the sample module."""', + '"""Wildly different prose here."""', + ).replace(" stamped = request", " stamped = (request)") + result = tool.check_parity_sources(_CLEAN_SYNC, reflowed_async, token_map=fixture_tokens) + assert result.in_parity, result.diff + + +def test_asyncio_sleep_maps_to_sync_wait(tool: ModuleType) -> None: + sync_src = "def wait(x: float) -> None:\n time.sleep(x)\n" + async_src = "async def wait(x: float) -> None:\n await asyncio.sleep(x)\n" + result = tool.check_parity_sources(sync_src, async_src) + assert result.in_parity, result.diff + + +def test_async_for_and_with_map_to_sync(tool: ModuleType) -> None: + sync_src = ( + "def drain(src: object) -> None:\n" + " with src.open() as handle:\n" + " for chunk in handle.iter():\n" + " handle.close()\n" + ) + async_src = ( + "async def drain(src: object) -> None:\n" + " async with src.open() as handle:\n" + " async for chunk in handle.aiter():\n" + " await handle.aclose()\n" + ) + result = tool.check_parity_sources(sync_src, async_src) + assert result.in_parity, result.diff + + +def test_unmapped_rename_is_caught_as_drift(tool: ModuleType) -> None: + # An identifier the token map does not name is compared as-is: an accidental + # rename is drift, never smoothed over by fuzzy matching. + sync_src = "def f() -> None:\n handle.commit()\n" + async_src = "async def f() -> None:\n await handle.flush()\n" + result = tool.check_parity_sources(sync_src, async_src) + assert not result.in_parity + + +def test_keep_docstrings_surfaces_prose_drift(tool: ModuleType) -> None: + sync_src = 'def f() -> None:\n """One."""\n return None\n' + async_src = 'async def f() -> None:\n """Two."""\n return None\n' + stripped = tool.check_parity_sources(sync_src, async_src) + assert stripped.in_parity + kept = tool.check_parity_sources(sync_src, async_src, keep_docstrings=True) + assert not kept.in_parity + + +def test_exact_string_constant_matching_token_is_renamed(tool: ModuleType) -> None: + # ``__all__`` entries are name-as-string references: a whole-string match to + # a token key is normalized so the export lists line up. + sync_src = '__all__ = ["Response"]\n' + async_src = '__all__ = ["AsyncResponse"]\n' + assert tool.check_parity_sources(sync_src, async_src).in_parity + + +def test_substring_of_token_in_string_is_left_as_drift(tool: ModuleType) -> None: + # Only exact whole-string matches are renamed; a message that merely embeds + # a token still registers real drift. + sync_src = 'x = "Response received"\n' + async_src = 'x = "AsyncResponse received"\n' + assert not tool.check_parity_sources(sync_src, async_src).in_parity + + +def test_real_set_date_twin_is_certified_in_parity(tool: ModuleType) -> None: + # A real hand-written twin that is a faithful mechanical transliteration is + # certified clean under the default token map — even in blocking mode. + sync_path = _POLICIES / "set_date.py" + async_path = _POLICIES / "async_set_date.py" + if not (sync_path.exists() and async_path.exists()): + pytest.skip("set_date twin pair not present") + result = tool.check_parity(sync_path, async_path, mode=tool.Mode.BLOCKING) + assert result.in_parity, result.diff + + +@pytest.mark.parametrize( + ("sync_name", "async_name"), + [ + ("set_date.py", "async_set_date.py"), + ("client_identity.py", "async_client_identity.py"), + ("redirect.py", "async_redirect.py"), + ("retry.py", "async_retry.py"), + ("idempotency.py", "async_idempotency.py"), + ], +) +def test_report_only_over_real_twins_never_fails( + tool: ModuleType, sync_name: str, async_name: str +) -> None: + sync_path = _POLICIES / sync_name + async_path = _POLICIES / async_name + if not (sync_path.exists() and async_path.exists()): + pytest.skip(f"policy twin pair not present: {sync_name}/{async_name}") + result = tool.check_parity(sync_path, async_path, mode=tool.Mode.REPORT_ONLY) + # Report-only tolerates the legitimate divergence of the hand-written twins + # (delegating wrappers, async-only helpers); it must never fail the build. + assert not result.failed + assert result.mode is tool.Mode.REPORT_ONLY + + +def test_report_only_produces_a_diff_artifact(tool: ModuleType, tmp_path: Path) -> None: + # The redirect twin legitimately diverges (the async form delegates to a + # wrapped sync policy), so report-only over it yields a non-empty artifact. + sync_path = _POLICIES / "redirect.py" + async_path = _POLICIES / "async_redirect.py" + if not (sync_path.exists() and async_path.exists()): + pytest.skip("redirect twin pair not present") + out = tmp_path / "parity_diff.txt" + rc = tool.main( + [ + str(sync_path), + str(async_path), + "--mode", + "report-only", + "--report-out", + str(out), + ] + ) + assert rc == 0 # report-only never fails the build + assert out.exists() + assert out.read_text(encoding="utf-8") # a diff artifact was written + + +def _write_pair(tmp_path: Path, sync_src: str, async_src: str) -> tuple[Path, Path, Path]: + """Write a sync/async pair plus a JSON token map to ``tmp_path``.""" + sync_file = tmp_path / "sync_mod.py" + async_file = tmp_path / "async_mod.py" + token_file = tmp_path / "tokens.json" + sync_file.write_text(sync_src, encoding="utf-8") + async_file.write_text(async_src, encoding="utf-8") + token_file.write_text(json.dumps(_FIXTURE_TOKENS), encoding="utf-8") + return sync_file, async_file, token_file + + +def test_cli_blocking_mode_exits_nonzero_on_drift(tool: ModuleType, tmp_path: Path) -> None: + sync_file, async_file, token_file = _write_pair(tmp_path, _CLEAN_SYNC, _DRIFT_ASYNC) + rc = tool.main( + [str(sync_file), str(async_file), "--mode", "blocking", "--token-map", str(token_file)] + ) + assert rc == 1 + + +def test_cli_blocking_mode_passes_clean_pair(tool: ModuleType, tmp_path: Path) -> None: + sync_file, async_file, token_file = _write_pair(tmp_path, _CLEAN_SYNC, _CLEAN_ASYNC) + rc = tool.main( + [str(sync_file), str(async_file), "--mode", "blocking", "--token-map", str(token_file)] + ) + assert rc == 0 diff --git a/packages/dexpace-sdk-core/tests/test_public_surface.py b/packages/dexpace-sdk-core/tests/test_public_surface.py index 417e9c2..f27635f 100644 --- a/packages/dexpace-sdk-core/tests/test_public_surface.py +++ b/packages/dexpace-sdk-core/tests/test_public_surface.py @@ -77,6 +77,7 @@ def _load_baseline() -> dict[str, dict[str, object]]: return parsed +@pytest.mark.req("NFR-3", "NFR-4") def test_live_surface_matches_committed_baseline(snapshot_tool: ModuleType) -> None: live = snapshot_tool.build_surface(_REPO_ROOT) baseline = _load_baseline() @@ -87,6 +88,7 @@ def test_live_surface_matches_committed_baseline(snapshot_tool: ModuleType) -> N ) +@pytest.mark.req("NFR-4") def test_baseline_is_canonical_and_round_trips(snapshot_tool: ModuleType) -> None: # The committed file must match the tool's own canonical rendering exactly, # so a hand-edited or non-canonical baseline (different key order, missing @@ -113,6 +115,7 @@ def test_every_distribution_has_exports_and_definitions() -> None: assert surface["definitions"], f"{dist} baseline has no public definitions" +@pytest.mark.req("NFR-3") def test_core_init_packages_declare_all(snapshot_tool: ModuleType) -> None: # Every re-exporting subpackage of core must declare a static ``__all__``; # the snapshot tool only records packages that do, so a populated exports diff --git a/packages/dexpace-sdk-core/tests/test_serialization_snapshots.py b/packages/dexpace-sdk-core/tests/test_serialization_snapshots.py index 64c68d5..9e2ce88 100644 --- a/packages/dexpace-sdk-core/tests/test_serialization_snapshots.py +++ b/packages/dexpace-sdk-core/tests/test_serialization_snapshots.py @@ -67,9 +67,10 @@ def test_url_serialization_encodes_space_and_ampersand_in_query() -> None: path="/search", query=QueryParams({"q": "a b", "tag": ["x", "y"]}), ) - assert str(url) == "https://api.example.com/search?q=a+b&tag=x&tag=y" - # ``QueryParams.encode`` uses percent-encoding for the space (RFC 3986), - # whereas the full-URL serializer renders the form-style ``+``. + # The full-URL serializer renders the query through the same pure RFC 3986 + # codec as ``QueryParams.encode`` — a space is ``%20`` (never the form-style + # ``+``), and furl's own query normalisation never leaks into the bytes. + assert str(url) == "https://api.example.com/search?q=a%20b&tag=x&tag=y" assert url.query.encode() == "q=a%20b&tag=x&tag=y" diff --git a/packages/dexpace-sdk-core/tests/test_traceability_audit.py b/packages/dexpace-sdk-core/tests/test_traceability_audit.py new file mode 100644 index 0000000..4170896 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_traceability_audit.py @@ -0,0 +1,343 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the requirement-ID traceability audit (tools/traceability_audit.py). + +These prove the audit machinery end-to-end against its self-contained +placeholder index: an uncovered MUST in a flagged subsystem is a gap, adding a +covering marker closes it, the milestone-scoped enablement and the deviations +(N/A) seam both behave, and the matrix artifact renders deterministically. A +pair of subprocess runs verify the ``req`` marker collects warning-free under +``-W error::pytest.PytestUnknownMarkWarning`` while an unregistered marker still +escalates to an error there. +""" + +from __future__ import annotations + +import csv +import importlib.util +import io +import json +import subprocess +import sys +import tomllib +from collections.abc import Iterator +from pathlib import Path +from types import ModuleType + +import pytest + +# ``tools`` is not an installed package, so load the audit module by path — the +# same pattern the public-surface test uses. +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TOOL_PATH = _REPO_ROOT / "tools" / "traceability_audit.py" + + +def _load_audit_tool() -> ModuleType: + """Import ``tools/traceability_audit.py`` by file path.""" + spec = importlib.util.spec_from_file_location("_traceability_audit_under_test", _TOOL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load traceability audit tool from {_TOOL_PATH}") + module = importlib.util.module_from_spec(spec) + # Register before executing so the module's dataclasses can resolve their + # (stringified) annotations against their own namespace under Python 3.12+. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def tool() -> ModuleType: + """Provide the loaded audit tool module once per test module.""" + return _load_audit_tool() + + +# --- Core audit behaviour -------------------------------------------------- + + +def test_audit_fails_on_uncovered_must_then_passes_when_covered(tool: ModuleType) -> None: + index = tool.build_index([tool.Requirement("X-1", tool.Level.MUST, "demo", "desc")]) + + uncovered = tool.audit(index, {}, enabled_subsystems=["demo"]) + assert not uncovered.ok + assert [gap.requirement.id for gap in uncovered.gaps] == ["X-1"] + + covered = tool.audit( + index, {"X-1": ["pkg/tests/test_demo.py::test_it"]}, enabled_subsystems=["demo"] + ) + assert covered.ok + assert covered.gaps == () + assert covered.matrix["X-1"] == ("pkg/tests/test_demo.py::test_it",) + + +def test_placeholder_index_gap_closes_when_all_must_covered(tool: ModuleType) -> None: + index = tool.PLACEHOLDER_INDEX + must_by_subsystem: dict[str, list[str]] = {} + for req in index.requirements: + if req.level is tool.Level.MUST: + must_by_subsystem.setdefault(req.subsystem, []).append(req.id) + subsystem, must_ids = next(iter(must_by_subsystem.items())) + + gap = tool.audit(index, {}, enabled_subsystems=[subsystem]) + assert not gap.ok + assert {g.requirement.id for g in gap.gaps} == set(must_ids) + + collected = {rid: [f"pkg/tests/test_x.py::test_{rid}"] for rid in must_ids} + assert tool.audit(index, collected, enabled_subsystems=[subsystem]).ok + + +def test_empty_enabled_subsystems_never_gaps(tool: ModuleType) -> None: + # Milestone-scoped: with nothing flagged done the whole (placeholder) spec + # is uncovered yet the audit fails on nothing. + result = tool.audit(tool.PLACEHOLDER_INDEX, {}) + assert result.ok + assert result.gaps == () + + +def test_na_ids_exclude_requirements_from_gaps(tool: ModuleType) -> None: + # ``retry`` MUST ids are RETRY-19 and RETRY-07; marking both N/A leaves no + # gap even with the subsystem enabled. + result = tool.audit( + tool.PLACEHOLDER_INDEX, + {}, + enabled_subsystems=["retry"], + na_ids=["RETRY-19", "RETRY-07"], + ) + assert result.ok + assert result.na_ids == frozenset({"RETRY-19", "RETRY-07"}) + + +def test_should_and_may_requirements_never_gap(tool: ModuleType) -> None: + index = tool.build_index( + [ + tool.Requirement("S-1", tool.Level.SHOULD, "demo", "d"), + tool.Requirement("M-1", tool.Level.MAY, "demo", "d"), + ] + ) + assert tool.audit(index, {}, enabled_subsystems=["demo"]).ok + + +def test_unknown_marker_ids_are_surfaced_not_matrixed(tool: ModuleType) -> None: + result = tool.audit(tool.PLACEHOLDER_INDEX, {"NOPE-1": ["t::a"]}) + assert result.unknown_marker_ids == ("NOPE-1",) + assert "NOPE-1" not in result.matrix + + +def test_build_index_rejects_duplicate_ids(tool: ModuleType) -> None: + with pytest.raises(ValueError, match="duplicate requirement id"): + tool.build_index( + [ + tool.Requirement("D-1", tool.Level.MUST, "x", "a"), + tool.Requirement("D-1", tool.Level.MUST, "x", "b"), + ] + ) + + +# --- Vendored requirement index (real Appendix C projection) --------------- + + +def test_vendored_index_loads_and_covers_every_subsystem(tool: ModuleType) -> None: + index = tool.load_index_file(tool.default_index_path()) + # The vendored index is the full spec: 645 requirements across 19 subsystems. + assert len(index.requirements) == 645 + assert len(index.subsystems()) == 19 + must = [r for r in index.requirements if r.level is tool.Level.MUST] + assert len(must) == 532 # MUST + the single MUST NOT, which folds to MUST + + +def test_default_index_returns_the_vendored_index(tool: ModuleType) -> None: + # With the vendored file present, default_index() is the real 645-req index, + # not the 15-req placeholder. + assert tool.default_index_path().is_file() + assert len(tool.default_index().requirements) == 645 + + +def test_load_index_file_rejects_a_foreign_document(tool: ModuleType, tmp_path: Path) -> None: + bad = tmp_path / "not-an-index.json" + bad.write_text('{"schema": "something-else/v1", "requirements": []}\n', encoding="utf-8") + with pytest.raises(ValueError, match="not a requirement index"): + tool.load_index_file(bad) + + +# --- Matrix artifact rendering --------------------------------------------- + + +def test_matrix_json_covers_every_requirement(tool: ModuleType) -> None: + index = tool.PLACEHOLDER_INDEX + result = tool.audit(index, {"RETRY-19": ["pkg/tests/test_r.py::test_ra"]}) + text = tool.render_matrix_json(result, index) + + assert text.endswith("\n") + document = json.loads(text) + assert document["schema"].startswith("dexpace-traceability-matrix/") + assert len(document["requirements"]) == len(index.requirements) + row = next(r for r in document["requirements"] if r["id"] == "RETRY-19") + assert row["covered"] is True + assert row["tests"] == ["pkg/tests/test_r.py::test_ra"] + # Deterministic: re-rendering the same result is byte-for-byte identical. + assert tool.render_matrix_json(result, index) == text + + +def test_matrix_csv_has_one_row_per_requirement(tool: ModuleType) -> None: + index = tool.PLACEHOLDER_INDEX + result = tool.audit(index, {"RETRY-19": ["a::b", "c::d"]}) + rows = list(csv.reader(io.StringIO(tool.render_matrix_csv(result, index)))) + + assert rows[0][0] == "requirement_id" + assert len(rows) == len(index.requirements) + 1 + retry_row = next(r for r in rows[1:] if r[0] == "RETRY-19") + assert retry_row[3] == "True" # covered + assert retry_row[4] == "2" # test_count + assert retry_row[5] == "a::b;c::d" + + +# --- Deviations (N/A) seam ------------------------------------------------- + + +def test_na_ids_from_missing_or_none_path_is_empty(tool: ModuleType, tmp_path: Path) -> None: + assert tool.na_ids_from_file(None) == frozenset() + assert tool.na_ids_from_file(tmp_path / "does-not-exist.md") == frozenset() + + +def test_na_ids_from_file_reads_ids_and_skips_comments(tool: ModuleType, tmp_path: Path) -> None: + ledger = tmp_path / "deviations.txt" + ledger.write_text( + "# whole-line comment\nRETRY-19\n\n AUTH-02 \nRETRY-9 # deviation: met differently\n", + encoding="utf-8", + ) + # Bare ids and ids with an inline justification comment both resolve to the id. + assert tool.na_ids_from_file(ledger) == frozenset({"RETRY-19", "AUTH-02", "RETRY-9"}) + + +# --- Plugin behaviour ------------------------------------------------------ + + +class _FakeMark: + def __init__(self, args: tuple[str, ...]) -> None: + self.args = args + + +class _FakeItem: + def __init__(self, nodeid: str, req_ids: list[str]) -> None: + self.nodeid = nodeid + self._marks = [_FakeMark((rid,)) for rid in req_ids] + + def iter_markers(self, name: str) -> Iterator[_FakeMark]: + assert name == "req" + return iter(self._marks) + + +def test_plugin_collects_markers_and_writes_matrix(tool: ModuleType, tmp_path: Path) -> None: + out = tmp_path / "matrix.json" + plugin = tool.TraceabilityPlugin(output_path=out) + items = [ + _FakeItem("pkg/tests/test_a.py::test_one", ["RETRY-19"]), + _FakeItem("pkg/tests/test_a.py::test_two", ["RETRY-19", "AUTH-02"]), + ] + plugin.pytest_collection_modifyitems(None, items) + plugin.pytest_collection_finish(None) + + assert out.is_file() + document = json.loads(out.read_text(encoding="utf-8")) + retry = next(r for r in document["requirements"] if r["id"] == "RETRY-19") + assert retry["tests"] == [ + "pkg/tests/test_a.py::test_one", + "pkg/tests/test_a.py::test_two", + ] + auth = next(r for r in document["requirements"] if r["id"] == "AUTH-02") + assert auth["tests"] == ["pkg/tests/test_a.py::test_two"] + + +def test_plugin_gate_fails_on_uncovered_must(tool: ModuleType, tmp_path: Path) -> None: + out = tmp_path / "gated.json" + plugin = tool.TraceabilityPlugin(output_path=out, enabled_subsystems=["retry"], gate=True) + plugin.pytest_collection_modifyitems(None, []) + with pytest.raises(pytest.UsageError, match="uncovered MUST"): + plugin.pytest_collection_finish(None) + assert out.is_file() # artifact written even though the gate then raised + + +def test_build_plugin_from_env_honours_overrides( + tool: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # build_plugin_from_env() uses the real vendored index, so the ids here are + # real Appendix C ids (RETRY-7, not the placeholder's RETRY-07). + ledger = tmp_path / "na.txt" + ledger.write_text("RETRY-7\n", encoding="utf-8") + out = tmp_path / "env-matrix.json" + monkeypatch.setenv("DEXPACE_TRACEABILITY_MATRIX", str(out)) + monkeypatch.setenv("DEXPACE_TRACEABILITY_ENABLE", "retry") + monkeypatch.setenv("DEXPACE_TRACEABILITY_NA", str(ledger)) + monkeypatch.setenv("DEXPACE_TRACEABILITY_GATE", "yes") + + plugin = tool.build_plugin_from_env() + plugin.pytest_collection_modifyitems(None, []) + with pytest.raises(pytest.UsageError) as excinfo: + plugin.pytest_collection_finish(None) + + message = str(excinfo.value) + assert "RETRY-45" in message # an uncovered retry MUST is reported + assert "RETRY-7," not in message and not message.endswith("RETRY-7") # N/A-excluded + assert out.is_file() + + +# --- Marker registration is warning-free ----------------------------------- + + +def _registered_req_marker(tool: ModuleType) -> str: + """Return the ``req`` marker entry from the root pyproject markers list.""" + pyproject = tool.repo_root() / "pyproject.toml" + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + markers = data["tool"]["pytest"]["ini_options"]["markers"] + for entry in markers: + if entry.startswith("req("): + return entry + raise AssertionError("no 'req' marker registered in root pyproject.toml") + + +def _run_pytest(project_dir: Path) -> subprocess.CompletedProcess[str]: + """Run pytest hermetically inside ``project_dir`` and capture the result.""" + return subprocess.run( + [sys.executable, "-m", "pytest", "-p", "no:cacheprovider"], + cwd=project_dir, + capture_output=True, + text=True, + check=False, + ) + + +def test_req_marker_is_registered_in_pyproject(tool: ModuleType) -> None: + assert _registered_req_marker(tool).startswith("req(") + + +def test_registered_req_marker_collects_warning_free(tool: ModuleType, tmp_path: Path) -> None: + marker = _registered_req_marker(tool) + assert "'" not in marker # embedded as a TOML literal string below + (tmp_path / "pyproject.toml").write_text( + "[tool.pytest.ini_options]\n" + 'addopts = "-W error::pytest.PytestUnknownMarkWarning --strict-markers"\n' + f"markers = ['{marker}']\n", + encoding="utf-8", + ) + (tmp_path / "test_registered.py").write_text( + 'import pytest\n\n\n@pytest.mark.req("RETRY-19")\ndef test_ok() -> None:\n assert True\n', + encoding="utf-8", + ) + result = _run_pytest(tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_unregistered_marker_errors_under_w_error(tmp_path: Path) -> None: + # Negative control: without registration, PytestUnknownMarkWarning fires and + # ``-W error`` escalates it, proving the gate is live. + (tmp_path / "pyproject.toml").write_text( + '[tool.pytest.ini_options]\naddopts = "-W error::pytest.PytestUnknownMarkWarning"\n', + encoding="utf-8", + ) + (tmp_path / "test_unregistered.py").write_text( + "import pytest\n\n\n@pytest.mark.totally_unregistered\ndef test_x() -> None:\n" + " assert True\n", + encoding="utf-8", + ) + result = _run_pytest(tmp_path) + assert result.returncode != 0 diff --git a/packages/dexpace-sdk-core/tests/test_uniqueness_audit.py b/packages/dexpace-sdk-core/tests/test_uniqueness_audit.py new file mode 100644 index 0000000..236c580 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/test_uniqueness_audit.py @@ -0,0 +1,113 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Single-source (uniqueness) audit gate. + +``tools/check_uniqueness.py`` enforces that every algorithm registered as +single-sourced is defined in exactly one module and imported everywhere else — +the guard that stops a hand-maintained async twin from copy-pasting a formula +instead of importing it. + +These tests prove two things: the live tree passes the audit (every registered +formula is unique), and the mechanism actually catches duplication. Because the +pure algorithm modules land in later milestones, the detection test drives the +audit against a generated fixture pair — one module defining a backoff formula, +one restating its body — and confirms the restatement is flagged while a module +that imports the formula is not. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TOOL_PATH = _REPO_ROOT / "tools" / "check_uniqueness.py" + +_FORMULA_BODY = ( + "def backoff_delay(attempt: int, base: float, cap: float) -> float:\n" + " delay = base * (2**attempt)\n" + " jitter = delay * 0.5\n" + " return min(cap, delay + jitter)\n" +) +_RESTATED_BODY = ( + "def recompute_backoff(attempt: int, base: float, cap: float) -> float:\n" + " delay = base * (2**attempt)\n" + " jitter = delay * 0.5\n" + " return min(cap, delay + jitter)\n" +) +_IMPORTED_BODY = ( + "from formula_source import backoff_delay\n\n\n" + "def next_delay(attempt: int) -> float:\n" + " return backoff_delay(attempt, base=0.5, cap=30.0)\n" +) + + +def _load_tool() -> ModuleType: + """Import ``tools/check_uniqueness.py`` by file path.""" + spec = importlib.util.spec_from_file_location("_check_uniqueness", _TOOL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load uniqueness tool from {_TOOL_PATH}") + module = importlib.util.module_from_spec(spec) + # Register before executing so the tool's dataclasses can resolve their + # (stringised) annotations against their own module namespace. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def tool() -> ModuleType: + """Provide the loaded uniqueness-audit tool once per module.""" + return _load_tool() + + +def test_live_tree_has_no_restated_algorithms(tool: ModuleType) -> None: + duplications = tool.find_restated_algorithms(tool.source_roots(_REPO_ROOT), tool.SINGLE_SOURCED) + assert duplications == [], [tool.format_duplication(dup) for dup in duplications] + + +def test_restated_formula_is_flagged(tool: ModuleType, tmp_path: Path) -> None: + (tmp_path / "formula_source.py").write_text(_FORMULA_BODY, encoding="utf-8") + (tmp_path / "formula_restated.py").write_text(_RESTATED_BODY, encoding="utf-8") + registry = ( + tool.Algorithm( + module="formula_source", + function="backoff_delay", + description="Exponential backoff with jitter.", + ), + ) + duplications = tool.find_restated_algorithms([tmp_path], registry) + assert len(duplications) == 1 + assert duplications[0].restated_in.module == "formula_restated" + assert duplications[0].restated_in.name == "recompute_backoff" + + +def test_imported_formula_is_not_flagged(tool: ModuleType, tmp_path: Path) -> None: + # The accepted pattern: define once, import elsewhere. The importing module + # has no duplicate body, so the audit stays green. + (tmp_path / "formula_source.py").write_text(_FORMULA_BODY, encoding="utf-8") + (tmp_path / "formula_imported.py").write_text(_IMPORTED_BODY, encoding="utf-8") + registry = ( + tool.Algorithm( + module="formula_source", + function="backoff_delay", + description="Exponential backoff with jitter.", + ), + ) + assert tool.find_restated_algorithms([tmp_path], registry) == [] + + +def test_missing_registered_algorithm_raises(tool: ModuleType, tmp_path: Path) -> None: + # A stale registry entry (canonical function absent) must fail loudly rather + # than silently pass, so the audit can never rot into a no-op by accident. + (tmp_path / "formula_source.py").write_text(_FORMULA_BODY, encoding="utf-8") + registry = ( + tool.Algorithm(module="formula_source", function="does_not_exist", description="x"), + ) + with pytest.raises(LookupError): + tool.find_restated_algorithms([tmp_path], registry) diff --git a/packages/dexpace-sdk-core/tests/tooling/__init__.py b/packages/dexpace-sdk-core/tests/tooling/__init__.py new file mode 100644 index 0000000..a69f5b7 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/tooling/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. diff --git a/packages/dexpace-sdk-core/tests/tooling/test_packaging_checks.py b/packages/dexpace-sdk-core/tests/tooling/test_packaging_checks.py new file mode 100644 index 0000000..ac8e968 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/tooling/test_packaging_checks.py @@ -0,0 +1,172 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the workspace packaging gates under ``tools/``. + +Each checker is exercised two ways: it must stay green against the real +repository tree, and it must flag a violation seeded into a temporary +directory. The wheel smoke check is validated against the editable workspace +install (which mirrors the two-distribution namespace merge a built wheel pair +must also satisfy). +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[4] +_TOOLS_DIR = _REPO_ROOT / "tools" + +HEADER = ( + "# Copyright (c) 2026 dexpace and Omar Aljarrah.\n" + "# Licensed under the MIT License. See LICENSE.md in the repository root for details.\n" +) + +NAMESPACE_PREFIXES = ( + "dexpace/__init__.py", + "dexpace/sdk/__init__.py", + "dexpace/sdk/http/__init__.py", +) + + +def _load_tool(name: str) -> ModuleType: + """Import a ``tools/*.py`` module by file path. + + The ``tools`` directory is not an importable package, so load each script + directly instead of relying on ``sys.path``. + + Args: + name: Module basename without the ``.py`` suffix. + + Returns: + The loaded module object. + """ + spec = importlib.util.spec_from_file_location(name, _TOOLS_DIR / f"{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +namespace_check = _load_tool("check_namespace_packages") +header_check = _load_tool("check_mit_headers") +smoke_check = _load_tool("smoke_wheel_import") + + +def _write(path: Path, text: str) -> None: + """Create ``path`` (and parents) with the given text.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +# --------------------------------------------------------------------------- # +# Namespace checker +# --------------------------------------------------------------------------- # + + +def test_real_repo_has_no_namespace_violations() -> None: + assert namespace_check.find_namespace_violations(_REPO_ROOT) == [] + + +@pytest.mark.parametrize("relpath", NAMESPACE_PREFIXES) +def test_stray_init_at_each_prefix_is_flagged(tmp_path: Path, relpath: str) -> None: + offender = tmp_path / "packages" / "pkg-a" / "src" / relpath + _write(offender, "") + + violations = namespace_check.find_namespace_violations(tmp_path) + + assert violations == [offender] + + +def test_clean_namespace_tree_passes(tmp_path: Path) -> None: + # A leaf __init__.py is fine; only the three shared prefixes are forbidden. + _write(tmp_path / "packages/pkg-a/src/dexpace/sdk/http/stdlib/__init__.py", HEADER) + + assert namespace_check.find_namespace_violations(tmp_path) == [] + + +def test_namespace_main_reports_exit_codes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(namespace_check, "repo_root", lambda: tmp_path) + assert namespace_check.main() == 0 + + _write(tmp_path / "packages/pkg-a/src/dexpace/sdk/__init__.py", "") + assert namespace_check.main() == 1 + + +# --------------------------------------------------------------------------- # +# MIT header checker +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("NFR-13") +def test_real_repo_headers_present() -> None: + assert header_check.find_header_violations(_REPO_ROOT) == [] + + +def test_file_with_header_passes(tmp_path: Path) -> None: + good = tmp_path / "mod.py" + _write(good, HEADER + '\n"""Docstring."""\n') + + assert header_check.file_has_header(good) is True + assert header_check.find_header_violations(tmp_path) == [] + + +@pytest.mark.req("NFR-13") +def test_file_missing_header_is_flagged(tmp_path: Path) -> None: + bad = tmp_path / "mod.py" + _write(bad, '"""No licence header here."""\n') + + assert header_check.file_has_header(bad) is False + assert header_check.find_header_violations(tmp_path) == [bad] + + +def test_shebang_before_header_is_allowed(tmp_path: Path) -> None: + script = tmp_path / "script.py" + _write(script, "#!/usr/bin/env python3\n" + HEADER) + + assert header_check.file_has_header(script) is True + + +@pytest.mark.req("NFR-13") +def test_wrong_second_line_is_flagged(tmp_path: Path) -> None: + bad = tmp_path / "mod.py" + _write(bad, "# Copyright (c) 2026 dexpace and Omar Aljarrah.\n# Wrong second line\n") + + assert header_check.file_has_header(bad) is False + + +def test_iter_python_files_skips_caches_and_dotdirs(tmp_path: Path) -> None: + _write(tmp_path / "pkg" / "mod.py", HEADER) + _write(tmp_path / ".git" / "hook.py", HEADER) + _write(tmp_path / "__pycache__" / "cached.py", HEADER) + _write(tmp_path / "build" / "out.py", HEADER) + _write(tmp_path / "thing.egg-info" / "meta.py", HEADER) + + found = set(header_check.iter_python_files(tmp_path)) + + assert found == {tmp_path / "pkg" / "mod.py"} + + +def test_header_main_reports_exit_codes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(header_check, "repo_root", lambda: tmp_path) + _write(tmp_path / "ok.py", HEADER) + assert header_check.main() == 0 + + _write(tmp_path / "bad.py", '"""missing header."""\n') + assert header_check.main() == 1 + + +# --------------------------------------------------------------------------- # +# Wheel smoke check +# --------------------------------------------------------------------------- # + + +def test_smoke_check_passes_under_workspace_install() -> None: + # The editable workspace resolves core and the stdlib adapter to two + # distinct locations under a shared dexpace.sdk namespace -- the same + # invariant a built wheel pair must satisfy (NFR-10). + assert smoke_check.smoke_check() == [] diff --git a/packages/dexpace-sdk-core/tests/typing_conformance.py b/packages/dexpace-sdk-core/tests/typing_conformance.py new file mode 100644 index 0000000..c9aa539 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/typing_conformance.py @@ -0,0 +1,137 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Static typing-conformance corpus for the public API surface. + +Every function here is a compile-time-only assertion: it takes already-typed +values as parameters and uses `typing.assert_type` to pin the *exact* static +type the public API promises. The functions are deliberately never called at +run time — importing the module is a no-op — so the corpus exercises the type +checker, not the interpreter. + +Two checkers consume this file: + +- `mypy --strict` type-checks it as part of the normal (blocking) gate. A + regression that widens, narrows, or drops a public return type turns an + `assert_type` into a hard error here. +- `pyright` can run over the same corpus in a non-blocking lane as a + cross-checker early-warning signal (see the CI ``typecheck-divergence`` job). + +A failure means a public signature changed. Fix the source, or — if the change +is intentional — update the assertion here in the same commit so both checkers +agree on the new contract. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import BinaryIO, assert_type + +from dexpace.sdk.core.client import AsyncHttpClient, HttpClient +from dexpace.sdk.core.codegen import AsyncServiceCore, Operation, OperationInput, ServiceCore +from dexpace.sdk.core.http.common import Headers +from dexpace.sdk.core.http.request import Request, RequestBody +from dexpace.sdk.core.http.response import AsyncResponse, Response, Status +from dexpace.sdk.core.pagination import HasHeaders, Page, PaginationStrategy +from dexpace.sdk.core.serde import JSON_SERDE, Deserializer, Serde, Serializer + + +class Pet: + """Stand-in model type used to track a generic parameter through the API.""" + + name: str + + +def _response_predicates(response: Response) -> None: + """`Response` status predicates each stay strictly `bool`.""" + assert_type(response.is_success, bool) + assert_type(response.is_redirect, bool) + assert_type(response.is_client_error, bool) + assert_type(response.is_server_error, bool) + + +def _response_with_helpers(response: Response, status: Status, headers: Headers) -> None: + """The `with_*` helpers return the concrete `Response`, not a bare `Self`.""" + assert_type(response.with_status(status), Response) + assert_type(response.with_headers(headers), Response) + assert_type(response.with_header("x-trace", "abc"), Response) + assert_type(response.with_body(None), Response) + + +def _http_client_execute(client: HttpClient, request: Request) -> None: + """The sync transport seam maps one `Request` to one `Response`.""" + assert_type(client.execute(request), Response) + + +async def _async_http_client_execute(client: AsyncHttpClient, request: Request) -> None: + """The async transport seam awaits to an `AsyncResponse`.""" + assert_type(await client.execute(request), AsyncResponse) + + +def _serde_surface(serde: Serde, value: object, stream: BinaryIO) -> None: + """`Serde` exposes typed serializer / deserializer halves.""" + assert_type(serde.serializer, Serializer) + assert_type(serde.deserializer, Deserializer) + assert_type(serde.serializer.serialize(value), str) + assert_type(serde.serializer.serialize_to_bytes(value), bytes) + assert_type(serde.serializer.serialize_to_stream(value, stream), None) + + +def _json_serde_surface() -> None: + """The shipped JSON `Serde` instance honours the same static contract.""" + assert_type(JSON_SERDE.serializer.serialize({"a": 1}), str) + assert_type(JSON_SERDE.serializer.serialize_to_bytes({"a": 1}), bytes) + + +def _pagination_generic_flow( + strategy: PaginationStrategy[Pet], response: HasHeaders, request: Request +) -> None: + """A `PaginationStrategy[Pet]` threads `Pet` through to `Page[Pet]`. + + This is the generic-parametrisation check: the element type declared on the + strategy flows all the way to ``page.items`` without erasing to ``object``. + """ + page = strategy.parse(response, {}, request) + assert_type(page, Page[Pet]) + assert_type(page.items, Sequence[Pet]) + assert_type(page.has_next, bool) + assert_type(page.next_request, Request | None) + + +def _headers_lookup(headers: Headers) -> None: + """`Headers.get` is optional-valued; membership is a plain `bool`.""" + assert_type(headers.get("content-type"), str | None) + assert_type("content-type" in headers, bool) + + +def _request_body_factories() -> None: + """The `RequestBody` factories return the base `RequestBody` type.""" + assert_type(RequestBody.from_bytes(b"payload"), RequestBody) + assert_type(RequestBody.from_string("payload"), RequestBody) + + +def _service_core_execute( + core: ServiceCore, op: Operation, inp: OperationInput, request: Request +) -> None: + """`ServiceCore.execute` / `execute_request` overloads narrow on `response_type`. + + With no ``response_type`` the raw `Response` is returned; a concrete type or + a subscripted generic passed as ``response_type`` narrows the return to that + exact type. + """ + assert_type(core.execute(op, inp), Response) + assert_type(core.execute(op, inp, response_type=Pet), Pet) + assert_type(core.execute(op, inp, response_type=list[Pet]), list[Pet]) + assert_type(core.execute_request(request), Response) + assert_type(core.execute_request(request, response_type=Pet), Pet) + + +async def _async_service_core_execute( + core: AsyncServiceCore, op: Operation, inp: OperationInput, request: Request +) -> None: + """`AsyncServiceCore.execute` awaits to the same narrowed types as the sync twin.""" + assert_type(await core.execute(op, inp), AsyncResponse) + assert_type(await core.execute(op, inp, response_type=Pet), Pet) + assert_type(await core.execute(op, inp, response_type=list[Pet]), list[Pet]) + assert_type(await core.execute_request(request), AsyncResponse) + assert_type(await core.execute_request(request, response_type=Pet), Pet) diff --git a/packages/dexpace-sdk-core/tests/util/test_clock.py b/packages/dexpace-sdk-core/tests/util/test_clock.py index 8cf2fbd..7cef7bf 100644 --- a/packages/dexpace-sdk-core/tests/util/test_clock.py +++ b/packages/dexpace-sdk-core/tests/util/test_clock.py @@ -5,6 +5,7 @@ from __future__ import annotations +import threading import time from itertools import pairwise @@ -14,6 +15,7 @@ ASYNC_SYSTEM_CLOCK, SYSTEM_CLOCK, AsyncClock, + CancellationToken, Clock, ) from dexpace.sdk.core.util.clock import _AsyncSystemClock, _SystemClock @@ -21,6 +23,7 @@ from ..conftest import FakeClock +@pytest.mark.req("CFG-15") def test_system_clock_now_advances() -> None: """``now()`` returns wall-clock time that advances across a sleep.""" first = SYSTEM_CLOCK.now() @@ -29,6 +32,7 @@ def test_system_clock_now_advances() -> None: assert second > first +@pytest.mark.req("CFG-16") def test_system_clock_monotonic_non_decreasing() -> None: """``monotonic()`` never goes backwards across repeated reads.""" samples = [SYSTEM_CLOCK.monotonic() for _ in range(50)] @@ -92,6 +96,7 @@ def test_async_system_clock_now_and_monotonic() -> None: assert b >= a +@pytest.mark.req("CFG-15") def test_clock_protocol_satisfied_by_system_clock() -> None: """``SYSTEM_CLOCK`` structurally satisfies the `Clock` protocol.""" assert isinstance(SYSTEM_CLOCK, Clock) @@ -127,11 +132,13 @@ def test_fake_clock_advance(fake_clock: FakeClock) -> None: assert fake_clock.now() == pytest.approx(3.0) +@pytest.mark.req("CFG-15") def test_fake_clock_satisfies_clock_protocol(fake_clock: FakeClock) -> None: """``FakeClock`` is structurally a `Clock`.""" assert isinstance(fake_clock, Clock) +@pytest.mark.req("CFG-16") def test_fake_clock_monotonic_does_not_decrease_on_negative_advance() -> None: """``advance(-delta)`` moves wall back but monotonic is preserved.""" clock = FakeClock(start=10.0) @@ -146,3 +153,77 @@ def test_fake_clock_sleep_advances_both_wall_and_monotonic() -> None: clock.sleep(5.0) assert clock.now() == pytest.approx(5.0) assert clock.monotonic() == pytest.approx(5.0) + + +# --------------------------------------------------------------------------- +# CancellationToken — interruptible sleep + + +def test_cancellation_token_starts_uncancelled() -> None: + token = CancellationToken() + assert token.cancelled is False + + +def test_cancellation_token_cancel_sets_flag() -> None: + token = CancellationToken() + token.cancel() + assert token.cancelled is True + + +def test_cancellation_token_cancel_is_idempotent() -> None: + token = CancellationToken() + token.cancel() + token.cancel() + assert token.cancelled is True + + +def test_sleep_completes_when_not_cancelled() -> None: + """A short sleep runs to completion and reports ``True``.""" + token = CancellationToken() + start = time.monotonic() + completed = token.sleep(0.05) + elapsed = time.monotonic() - start + assert completed is True + assert elapsed >= 0.04 + + +def test_sleep_returns_immediately_when_already_cancelled() -> None: + token = CancellationToken() + token.cancel() + start = time.monotonic() + completed = token.sleep(10.0) + elapsed = time.monotonic() - start + assert completed is False + assert elapsed < 0.05 + + +@pytest.mark.req("CFG-17") +def test_sleep_wakes_early_when_cancelled_from_another_thread() -> None: + token = CancellationToken() + threading.Timer(0.02, token.cancel).start() + start = time.monotonic() + completed = token.sleep(10.0) + elapsed = time.monotonic() - start + assert completed is False + # Cancelled well before the 10s budget would have elapsed. + assert elapsed < 1.0 + + +@pytest.mark.req("CFG-17") +def test_sleep_zero_is_noop_and_completes() -> None: + token = CancellationToken() + assert token.sleep(0.0) is True + + +@pytest.mark.req("CFG-17") +def test_sleep_rejects_negative_duration() -> None: + token = CancellationToken() + with pytest.raises(ValueError): + token.sleep(-1.0) + + +@pytest.mark.req("CFG-17") +def test_sleep_rejects_nan_duration() -> None: + token = CancellationToken() + with pytest.raises(ValueError): + token.sleep(float("nan")) diff --git a/packages/dexpace-sdk-core/tests/util/test_http_date.py b/packages/dexpace-sdk-core/tests/util/test_http_date.py new file mode 100644 index 0000000..7e3a883 --- /dev/null +++ b/packages/dexpace-sdk-core/tests/util/test_http_date.py @@ -0,0 +1,72 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Tests for the shared RFC 1123 HTTP-date helpers.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta, timezone + +import pytest + +from dexpace.sdk.core.util import format_http_date, parse_http_date + +# The canonical example from RFC 9110 / RFC 1123. +_CANONICAL = "Sun, 06 Nov 1994 08:49:37 GMT" +_CANONICAL_DT = datetime(1994, 11, 6, 8, 49, 37, tzinfo=UTC) + + +@pytest.mark.req("CFG-29") +def test_format_renders_canonical_rfc1123() -> None: + assert format_http_date(_CANONICAL_DT) == _CANONICAL + + +def test_format_treats_naive_datetime_as_utc() -> None: + naive = datetime(1994, 11, 6, 8, 49, 37) + assert format_http_date(naive) == _CANONICAL + + +@pytest.mark.req("CFG-29") +def test_format_converts_aware_datetime_to_gmt() -> None: + # 09:49:37 +0100 is 08:49:37 GMT. + aware = datetime(1994, 11, 6, 9, 49, 37, tzinfo=timezone(timedelta(hours=1))) + assert format_http_date(aware) == _CANONICAL + + +@pytest.mark.req("CFG-30") +def test_parse_rfc1123() -> None: + assert parse_http_date(_CANONICAL) == _CANONICAL_DT + + +@pytest.mark.req("CFG-30") +@pytest.mark.parametrize( + "raw", + [ + "Sun, 06 Nov 1994 08:49:37 GMT", # RFC 1123 + "Sunday, 06-Nov-94 08:49:37 GMT", # RFC 850 (obsolete) + "Sun Nov 6 08:49:37 1994", # C asctime + ], +) +def test_parse_accepts_all_three_http_date_formats(raw: str) -> None: + parsed = parse_http_date(raw) + assert parsed is not None + # All three encode the same instant; compare in UTC. + assert parsed.astimezone(UTC) == _CANONICAL_DT + + +def test_parse_attaches_utc_to_naive_forms() -> None: + # The asctime form carries no zone; it must come back tz-aware (UTC). + parsed = parse_http_date("Sun Nov 6 08:49:37 1994") + assert parsed is not None + assert parsed.tzinfo is not None + assert parsed.utcoffset() == timedelta(0) + + +@pytest.mark.parametrize("raw", ["", "not-a-date", "Sun, 99 Xxx 9999", "garbage 123"]) +def test_parse_returns_none_on_unparseable(raw: str) -> None: + assert parse_http_date(raw) is None + + +def test_round_trip() -> None: + rendered = format_http_date(_CANONICAL_DT) + assert parse_http_date(rendered) == _CANONICAL_DT diff --git a/packages/dexpace-sdk-core/tests/util/test_proxy.py b/packages/dexpace-sdk-core/tests/util/test_proxy.py index 293d80c..ed379c3 100644 --- a/packages/dexpace-sdk-core/tests/util/test_proxy.py +++ b/packages/dexpace-sdk-core/tests/util/test_proxy.py @@ -5,12 +5,15 @@ from __future__ import annotations +import os + import pytest from dexpace.sdk.core.config.configuration import Configuration from dexpace.sdk.core.util import ProxyOptions, ProxyType +@pytest.mark.req("CFG-22") def test_proxy_options_basic() -> None: """A bare ``ProxyOptions`` exposes its constructor arguments verbatim.""" options = ProxyOptions(type=ProxyType.HTTP, host="proxy.corp", port=8080) @@ -47,6 +50,7 @@ def test_bypasses_proxy_exact_match() -> None: assert options.bypasses_proxy("api.internal.example.com") is True +@pytest.mark.req("CFG-23") def test_bypasses_proxy_case_insensitive() -> None: """Glob matching ignores case on both pattern and candidate host.""" options = ProxyOptions( @@ -133,6 +137,7 @@ def test_bypasses_proxy_ipv6_literal_with_port() -> None: assert options.bypasses_proxy("[::1]:443") is True +@pytest.mark.req("CFG-23") def test_bypasses_proxy_explicit_glob_still_works() -> None: """Explicit ``*`` globs keep their fnmatch semantics.""" options = ProxyOptions( @@ -169,10 +174,15 @@ def test_repr_omits_mask_when_no_creds() -> None: def test_from_configuration_https_proxy_url() -> None: - """A full HTTPS_PROXY URL parses into all ``ProxyOptions`` fields.""" + """A full HTTPS_PROXY URL parses into all ``ProxyOptions`` fields. + + Credentials survive because the proxy connection itself is ``https`` + (encrypted); see ``TestFromConfigurationCredentials`` for the plaintext + contrast where they are dropped. + """ config = ( Configuration.builder() - .put(Configuration.HTTPS_PROXY, "http://user:pw@proxy.corp:8080") + .put(Configuration.HTTPS_PROXY, "https://user:pw@proxy.corp:8080") .build() ) options = ProxyOptions.from_configuration(config) @@ -184,6 +194,7 @@ def test_from_configuration_https_proxy_url() -> None: assert options.password == "pw" +@pytest.mark.req("CFG-24") def test_from_configuration_https_wins_over_http() -> None: """When both env vars are set, HTTPS_PROXY takes precedence.""" config = ( @@ -198,6 +209,7 @@ def test_from_configuration_https_wins_over_http() -> None: assert options.port == 8443 +@pytest.mark.req("CFG-27") def test_from_configuration_no_proxy_wildcard() -> None: """``NO_PROXY=*`` short-circuits to ``None``.""" config = ( @@ -247,6 +259,62 @@ def _config(url: str) -> Configuration: return Configuration.builder().put(Configuration.HTTPS_PROXY, url).build() +def _config_with_no_proxy(no_proxy: str) -> Configuration: + """Build a configuration with a valid proxy URL and a ``NO_PROXY`` value.""" + return ( + Configuration.builder() + .put(Configuration.HTTPS_PROXY, "http://proxy.corp:8080") + .put(Configuration.NO_PROXY, no_proxy) + .build() + ) + + +class TestNoProxyParsing: + """``NO_PROXY`` splitting: escape, drop-empty, unescape, trim ordering.""" + + def _hosts(self, no_proxy: str) -> tuple[str, ...]: + options = ProxyOptions.from_configuration(_config_with_no_proxy(no_proxy)) + assert options is not None + return options.non_proxy_hosts + + @pytest.mark.req("CFG-26") + def test_backslash_escaped_separator_becomes_literal(self) -> None: + # ``a\,b`` is one entry ``a,b`` (the escaped comma is a literal), while + # the unescaped comma still ends the token before ``c``. + assert self._hosts("a\\,b,c") == ("a,b", "c") + + @pytest.mark.req("CFG-26") + def test_empty_fragments_are_dropped(self) -> None: + # Zero-length fragments (adjacent separators, leading/trailing) drop. + assert self._hosts("a,,b") == ("a", "b") + assert self._hosts(",a,b,") == ("a", "b") + + @pytest.mark.req("CFG-26") + def test_whitespace_only_fragment_is_retained_as_empty_token(self) -> None: + # Because the empty-drop runs BEFORE the trim, a whitespace-only + # fragment survives the drop and is trimmed to an empty token rather + # than being removed — the observable split/drop/unescape/trim order. + assert self._hosts("a, ,b") == ("a", "", "b") + assert self._hosts(" ") == ("",) + + @pytest.mark.req("CFG-26") + def test_tokens_are_trimmed(self) -> None: + assert self._hosts(" a , b ") == ("a", "b") + + @pytest.mark.req("CFG-26") + def test_escaped_separator_survives_surrounding_whitespace(self) -> None: + # Trim happens after unescape, so surrounding whitespace is stripped + # while the escaped inner separator is preserved as a literal. + assert self._hosts(" a\\,b ") == ("a,b",) + + @pytest.mark.req("CFG-26") + def test_none_value_yields_empty_tuple(self) -> None: + config = Configuration.builder().put(Configuration.HTTPS_PROXY, "http://p:8080").build() + options = ProxyOptions.from_configuration(config) + assert options is not None + assert options.non_proxy_hosts == () + + class TestFromConfigurationScheme: """The proxy URL scheme selects the transport flavour (L4).""" @@ -281,23 +349,33 @@ def test_unsupported_scheme_rejected_with_warning( assert any(record.levelname == "WARNING" for record in caplog.records) -class TestFromConfigurationPortDefaults: - """A missing port defaults by scheme instead of dropping the proxy (L4).""" +class TestFromConfigurationPortUnspecified: + """A missing port stays ``None`` — never a fabricated per-scheme default. + + Keeping the proxy (rather than dropping a port-less URL) is required, but + the model must not invent a port: the transport decides its own default at + connection time. The port comes from the URL or is ``None``, nothing else. + """ - def test_http_without_port_defaults_to_80(self) -> None: + def test_http_without_port_stays_none(self) -> None: options = ProxyOptions.from_configuration(_config("http://proxy.corp")) assert options is not None - assert options.port == 80 + assert options.port is None - def test_https_without_port_defaults_to_443(self) -> None: + def test_https_without_port_stays_none(self) -> None: options = ProxyOptions.from_configuration(_config("https://proxy.corp")) assert options is not None - assert options.port == 443 + assert options.port is None - def test_socks_without_port_defaults_to_1080(self) -> None: + def test_socks_without_port_stays_none(self) -> None: options = ProxyOptions.from_configuration(_config("socks5://proxy.corp")) assert options is not None - assert options.port == 1080 + assert options.port is None + + def test_explicit_in_range_port_is_kept(self) -> None: + options = ProxyOptions.from_configuration(_config("http://proxy.corp:3128")) + assert options is not None + assert options.port == 3128 class TestFromConfigurationSchemeless: @@ -310,23 +388,48 @@ def test_schemeless_host_port(self) -> None: assert options.host == "proxy.corp" assert options.port == 8080 - def test_schemeless_host_only_defaults_port(self) -> None: + def test_schemeless_host_only_leaves_port_none(self) -> None: options = ProxyOptions.from_configuration(_config("proxy.corp")) assert options is not None assert options.host == "proxy.corp" - assert options.port == 80 + assert options.port is None class TestFromConfigurationCredentials: - """Percent-encoded proxy credentials are ``unquote()``-decoded (L4).""" + """Credentials attach only over an ``https`` proxy, and are decoded.""" - def test_percent_encoded_credentials_decoded(self) -> None: - # user "u@s" and password "p:w" percent-encode their reserved chars. - options = ProxyOptions.from_configuration(_config("http://u%40s:p%3Aw@proxy.corp:8080")) + def test_percent_encoded_credentials_decoded_over_https(self) -> None: + # user "u@s" and password "p:w" percent-encode their reserved chars; + # the https proxy scheme keeps them (encrypted proxy connection). + options = ProxyOptions.from_configuration(_config("https://u%40s:p%3Aw@proxy.corp:8080")) assert options is not None assert options.username == "u@s" assert options.password == "p:w" + def test_credentials_dropped_over_http_proxy(self) -> None: + # A plaintext (http) proxy connection must never carry a credential: + # Proxy-Authorization would travel in the clear, so it is dropped. + options = ProxyOptions.from_configuration(_config("http://user:pw@proxy.corp:8080")) + assert options is not None + assert options.host == "proxy.corp" + assert options.username is None + assert options.password is None + + def test_credentials_dropped_over_schemeless_proxy(self) -> None: + # A scheme-less proxy is assumed plaintext HTTP, so credentials drop. + options = ProxyOptions.from_configuration(_config("user:pw@proxy.corp:8080")) + assert options is not None + assert options.host == "proxy.corp" + assert options.username is None + assert options.password is None + + def test_credentials_dropped_over_socks_proxy(self) -> None: + # SOCKS proxy auth is likewise unencrypted; drop the credential. + options = ProxyOptions.from_configuration(_config("socks5://user:pw@proxy.corp:1080")) + assert options is not None + assert options.username is None + assert options.password is None + class TestFromConfigurationVisibility: """A genuinely unusable proxy config is logged above DEBUG (L4).""" @@ -362,3 +465,227 @@ def test_ported_glob_does_not_over_match(self) -> None: non_proxy_hosts=("*.example.com:443",), ) assert options.bypasses_proxy("api.example.org") is False + + +_SECRET = "hunter2-super-secret" + + +class TestStringFormsMaskCredentials: + """Every string rendering of a proxy masks its credentials (never leaks).""" + + def _with_creds(self) -> ProxyOptions: + return ProxyOptions( + type=ProxyType.HTTP, + host="proxy.corp", + port=8080, + username="alice", + password=_SECRET, + ) + + def test_repr_masks_password(self) -> None: + rendered = repr(self._with_creds()) + assert "***" in rendered + assert _SECRET not in rendered + assert "alice" not in rendered + + @pytest.mark.req("CFG-22") + def test_str_masks_password(self) -> None: + # ``str`` must mask too — it is a distinct string form from ``repr``. + rendered = str(self._with_creds()) + assert "***" in rendered + assert _SECRET not in rendered + assert "alice" not in rendered + + def test_format_masks_password(self) -> None: + # f-string / ``format`` route through ``__str__``/``__format__``. + rendered = f"{self._with_creds()}" + assert _SECRET not in rendered + + def test_no_string_form_leaks_the_literal_secret(self) -> None: + # Grep every string rendering for the literal secret: zero hits. + options = self._with_creds() + for rendered in (repr(options), str(options), f"{options}", format(options)): + assert _SECRET not in rendered + + +class TestBypassAllShortCircuit: + """A single bare ``*`` bypass entry short-circuits to bypass-everything.""" + + @pytest.mark.req("CFG-22") + def test_bare_star_bypasses_every_host(self) -> None: + options = ProxyOptions( + type=ProxyType.HTTP, + host="proxy.corp", + port=8080, + non_proxy_hosts=("*",), + ) + assert options.bypasses_proxy("example.com") is True + assert options.bypasses_proxy("anything.internal") is True + assert options.bypasses_proxy("10.0.0.1") is True + assert options.bypasses_proxy("") is True + + @pytest.mark.req("CFG-23", "CFG-27") + def test_bare_star_short_circuits_without_running_matchers(self) -> None: + # "Don't process ``*`` as a literal glob needing host matching": the + # bare-``*`` entry is not compiled into a per-host matcher at all. + options = ProxyOptions( + type=ProxyType.HTTP, + host="proxy.corp", + port=8080, + non_proxy_hosts=("*",), + ) + assert options._bypass_all is True + assert options._bypass_matchers == () + + def test_star_mixed_with_other_entries_still_bypasses_all(self) -> None: + options = ProxyOptions( + type=ProxyType.HTTP, + host="proxy.corp", + port=8080, + non_proxy_hosts=("example.com", "*", "*.internal"), + ) + assert options._bypass_all is True + assert options.bypasses_proxy("totally.unrelated.host") is True + + +class TestSameLayerHostAndPort: + """Host and port always come from the SAME configuration layer, never mixed.""" + + def test_higher_layer_supplies_both_host_and_port(self) -> None: + # The env layer offers one endpoint and the (higher-priority) override + # layer another. The winning layer supplies BOTH host and port — you + # never observe the override host paired with the env port or vice + # versa, the classic mix-and-match footgun. + env = {"HTTPS_PROXY": "http://env-host:1111"} + config = ( + Configuration.builder() + .env(env.get) + .put(Configuration.HTTPS_PROXY, "http://override-host:2222") + .build() + ) + options = ProxyOptions.from_configuration(config) + assert options is not None + assert options.host == "override-host" + assert options.port == 2222 + + @pytest.mark.req("CFG-24") + def test_single_url_ties_host_and_port_together(self) -> None: + # Both parts derive from one resolved URL string, so a port-less URL + # yields the URL's host and a ``None`` port — never a port borrowed + # from some other source. + config = _config("https://only-host") + options = ProxyOptions.from_configuration(config) + assert options is not None + assert options.host == "only-host" + assert options.port is None + + +_PROXY_ENV_KEYS = frozenset({"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"}) + + +def _forbid_proxy_env_reads(monkeypatch: pytest.MonkeyPatch) -> None: + """Booby-trap ``os.environ.get`` so any proxy-variable read raises. + + Only the ``get`` accessor is shadowed (item assignment and the rest of the + mapping keep working, so the test harness is unaffected). Unrelated reads + delegate to the real environment; a read of any proxy variable raises, + proving the code under test never resolves proxy settings from + ``os.environ`` implicitly. + """ + real_get = os.environ.get + + def guarded_get(key: str, default: str | None = None) -> str | None: + if key in _PROXY_ENV_KEYS: + raise AssertionError(f"proxy env var {key!r} must not be read implicitly") + return real_get(key, default) + + monkeypatch.setattr(os.environ, "get", guarded_get) + + +class TestOptInEnvironmentResolution: + """Env resolution is opt-in and hermetic — never an implicit os.environ read.""" + + @pytest.mark.req("CFG-28") + def test_plain_construction_never_reads_proxy_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # With proxy-env reads booby-trapped, build and exercise the model + # directly. Constructing and matching must not touch proxy env vars. + _forbid_proxy_env_reads(monkeypatch) + options = ProxyOptions( + type=ProxyType.HTTP, + host="proxy.corp", + port=8080, + non_proxy_hosts=("*.internal", "example.com"), + username="u", + password="p", + ) + assert options.bypasses_proxy("api.internal") is True + assert options.bypasses_proxy("elsewhere.org") is False + + @pytest.mark.req("CFG-28") + def test_from_environment_uses_injected_seam_only( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Opt-in env resolution reads ONLY the injected seam; the real + # os.environ is booby-trapped to prove it is never consulted, and no + # real environment variable is mutated by the test. + _forbid_proxy_env_reads(monkeypatch) + fake_env = {"HTTPS_PROXY": "http://seam-host:8080", "NO_PROXY": "example.com"} + options = ProxyOptions.from_environment(env=fake_env.get) + assert options is not None + assert options.host == "seam-host" + assert options.port == 8080 + assert options.non_proxy_hosts == ("example.com",) + + def test_from_environment_returns_none_when_seam_is_empty(self) -> None: + options = ProxyOptions.from_environment(env=lambda _name: None) + assert options is None + + def test_from_environment_default_reads_process_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The default seam IS os.environ.get, so the explicit opt-in call (and + # only it) reflects the real process environment when no seam is given. + monkeypatch.setenv("HTTPS_PROXY", "http://env-default:8080") + monkeypatch.delenv("NO_PROXY", raising=False) + options = ProxyOptions.from_environment() + assert options is not None + assert options.host == "env-default" + assert options.port == 8080 + + +class TestResolutionNeverThrows: + """Malformed proxy configuration degrades to ``None`` — it never raises.""" + + @pytest.mark.req("CFG-24") + @pytest.mark.parametrize( + "value", + [ + "http://", # no host + "http://proxy.corp:99999", # port out of range + "ftp://proxy.corp:8080", # unsupported scheme + "://::::", # garbage + "http://[unterminated", # malformed IPv6 authority + " ", # whitespace only + "not a url at all", # a host may never contain whitespace + ], + ) + def test_malformed_value_degrades_to_none(self, value: str) -> None: + # No exception escapes for any malformed value; it degrades to None. + assert ProxyOptions.from_configuration(_config(value)) is None + + +class TestLoggingRedactsCredentials: + """The WARNING log path for an unusable proxy never leaks credentials.""" + + def test_invalid_port_url_redacts_userinfo(self, caplog: pytest.LogCaptureFixture) -> None: + secret_url = f"http://alice:{_SECRET}@proxy.corp:99999" + with caplog.at_level("WARNING", logger="dexpace.sdk.core.util.proxy"): + options = ProxyOptions.from_configuration(_config(secret_url)) + assert options is None + blob = "\n".join(record.getMessage() for record in caplog.records) + assert blob # a WARNING was emitted + assert _SECRET not in blob + assert "alice" not in blob + assert "***@proxy.corp" in blob diff --git a/packages/dexpace-sdk-core/tests/webhooks/test_verification.py b/packages/dexpace-sdk-core/tests/webhooks/test_verification.py index 211d577..5e0cbc4 100644 --- a/packages/dexpace-sdk-core/tests/webhooks/test_verification.py +++ b/packages/dexpace-sdk-core/tests/webhooks/test_verification.py @@ -8,7 +8,9 @@ import base64 import hashlib import hmac +import inspect import json +import re from collections.abc import Mapping import pytest @@ -18,6 +20,7 @@ InvalidWebhookSignatureError, WebhookVerifier, ) +from dexpace.sdk.core.http.webhooks import verification as _verification_module # A known whsec_ secret and its raw base64 body. The key is "supersecret" # base64-encoded so the test vectors are reproducible by hand if needed. @@ -288,3 +291,22 @@ def test_verifier_accepts_a_generic_mapping() -> None: signature = _sign(_RAW_KEY, _WEBHOOK_ID, _TIMESTAMP, _BODY) headers: Mapping[str, str] = _headers(signature) _verifier().verify(headers, _BODY) + + +def test_signature_comparison_uses_constant_time_compare_digest() -> None: + # The signature check must go through hmac.compare_digest so a timing side + # channel cannot leak the expected signature byte by byte. + source = inspect.getsource(_verification_module) + assert "hmac.compare_digest(" in source + + +def test_no_timing_unsafe_equality_digest_comparison_exists() -> None: + # Guard against a regression to a plain '==' comparison of the computed + # digest or a candidate signature, which would be timing-unsafe. + source = inspect.getsource(_verification_module) + forbidden = re.compile( + r"(?:expected|candidate|digest|signature)\w*\s*==|" + r"==\s*(?:expected|candidate|digest|signature)" + ) + offending = [line.strip() for line in source.splitlines() if forbidden.search(line)] + assert not offending, f"timing-unsafe digest comparison found: {offending}" diff --git a/packages/dexpace-sdk-http-aiohttp/pyproject.toml b/packages/dexpace-sdk-http-aiohttp/pyproject.toml index 72cbf1c..99c59b9 100644 --- a/packages/dexpace-sdk-http-aiohttp/pyproject.toml +++ b/packages/dexpace-sdk-http-aiohttp/pyproject.toml @@ -27,7 +27,13 @@ classifiers = [ ] dependencies = [ "dexpace-sdk-core>=0.1,<0.2", - "aiohttp>=3.9,<4.0", + # The adapter maps aiohttp's connect-vs-read timeout split + # (ConnectionTimeoutError / SocketTimeoutError), added in aiohttp 3.10, so + # 3.10 is the real floor. On Python 3.14 the floor rises to 3.13, the first + # release with a 3.14 wheel (3.14 rejects break/continue/return inside a + # finally block, a pattern aiohttp used until 3.13). + "aiohttp>=3.10,<4.0; python_version < '3.14'", + "aiohttp>=3.13,<4.0; python_version >= '3.14'", ] [project.urls] diff --git a/packages/dexpace-sdk-http-aiohttp/src/dexpace/sdk/http/aiohttp/client.py b/packages/dexpace-sdk-http-aiohttp/src/dexpace/sdk/http/aiohttp/client.py index c539d20..a061a8b 100644 --- a/packages/dexpace-sdk-http-aiohttp/src/dexpace/sdk/http/aiohttp/client.py +++ b/packages/dexpace-sdk-http-aiohttp/src/dexpace/sdk/http/aiohttp/client.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio +import logging from collections.abc import AsyncIterator, Iterator from types import TracebackType from typing import TYPE_CHECKING, Final, Self @@ -48,8 +49,12 @@ from dexpace.sdk.core.http.request.request import Request from dexpace.sdk.core.http.request.request_body import RequestBody +_LOGGER: Final = logging.getLogger("dexpace.sdk.http.aiohttp") _DEFAULT_TIMEOUT: Final[float] = 30.0 _UPLOAD_CHUNK: Final[int] = 8192 +#: Methods whose semantics call for an explicit zero-length body substitution +#: when the caller supplies no payload (RFC 9110 §8.6). +_ZERO_LENGTH_METHODS: Final[frozenset[str]] = frozenset({"POST", "PUT", "PATCH"}) class AiohttpHttpClient: @@ -97,7 +102,8 @@ async def execute(self, request: Request) -> AsyncResponse: else None ) data = _payload(request.body) - headers = _request_headers(_frame_length(request)) + framed_headers = _with_content_type(_frame_length(request), request.body) + headers = _request_headers(framed_headers) try: ctx = session.request( method=str(request.method), @@ -122,7 +128,16 @@ async def execute(self, request: Request) -> AsyncResponse: raise ServiceResponseError(f"Response error: {err}", error=err) from err except aiohttp.ClientError as err: raise ServiceRequestError(f"Transport error: {err}", error=err) from err - return _wrap_response(request, aio_response) + try: + return _wrap_response(request, aio_response) + except (ValueError, TypeError) as err: + # A live response was received but could not be adapted (for + # example a non-numeric status line surfaced by a nonconformant + # peer). The native response must still be released — it is never + # reachable again once this call raises, so leaving it open would + # leak the underlying connection. + aio_response.release() + raise ServiceResponseError(f"Response adaptation failed: {err}", error=err) from err async def aclose(self) -> None: if self._closed: @@ -159,17 +174,23 @@ def _frame_length(request: Request) -> Headers: and otherwise falls back to ``Transfer-Encoding: chunked`` for any async-iterable payload — including a fully buffered, known-length body. Setting the length up front keeps known-length uploads (bytes, file) - length-framed and wire-consistent with the other transports. + length-framed and wire-consistent with the other transports. A + body-bearing method with no body at all gets an explicit + ``Content-Length: 0`` per RFC 9110 §8.6, rather than leaving the request + unframed. Args: request: The outgoing request. Returns: The request headers, with ``Content-Length`` added when the body - reports a non-negative length and no such header is already set. + reports a non-negative length, the method calls for a zero-length + default, and no such header is already set. """ body = request.body if body is None: + if str(request.method) in _ZERO_LENGTH_METHODS: + return request.headers.with_set(http_header_name.CONTENT_LENGTH, "0") return request.headers length = body.content_length() if length < 0 or http_header_name.CONTENT_LENGTH in request.headers: @@ -177,6 +198,26 @@ def _frame_length(request: Request) -> Headers: return request.headers.with_set(http_header_name.CONTENT_LENGTH, str(length)) +def _with_content_type(headers: Headers, body: RequestBody | None) -> Headers: + """Keep a caller-supplied content-type; else derive one from the body. + + Args: + headers: The headers computed so far (post-framing). + body: The outgoing request body, if any. + + Returns: + ``headers`` unchanged if a ``Content-Type`` is already present or + there is no body to derive one from; otherwise ``headers`` with the + body's `MediaType` set. + """ + if headers.get(http_header_name.CONTENT_TYPE) is not None or body is None: + return headers + media = body.media_type() + if media is None: + return headers + return headers.with_set(http_header_name.CONTENT_TYPE, str(media)) + + def _request_headers(headers: Headers) -> list[tuple[str, str]]: """Flatten ``Headers`` to a list of ``(name, value)`` pairs. @@ -239,10 +280,9 @@ def _next_chunk(iterator: Iterator[bytes]) -> bytes | None: def _wrap_response(request: Request, aio_response: aiohttp.ClientResponse) -> AsyncResponse: """Build an `AsyncResponse` from an `aiohttp.ClientResponse`. - An in-range HTTP status (100..599), named or not, is preserved on a live - response so the body still reaches retry/error-map policies. Only a status - outside that range maps to `ServiceResponseError` (releasing the handle - first). + Status is total over integers (TRANSPORT-24): any HTTP status, in or out + of the registered 100..599 band, named or not, is preserved on a live + response so the body still reaches retry/error-map policies. Args: request: The originating request. @@ -250,21 +290,9 @@ def _wrap_response(request: Request, aio_response: aiohttp.ClientResponse) -> As Returns: The wrapped async response. - - Raises: - ServiceResponseError: If the status code is outside 100..599. """ - try: - status = Status(aio_response.status) - except ValueError as err: - # Genuinely invalid status (outside 100..599): release the handle - # before bailing so the connection returns to the pool rather than - # leaking (aiohttp's release() is synchronous). - aio_response.release() - raise ServiceResponseError( - f"Invalid status code: {aio_response.status}", error=err - ) from err - headers = Headers(tuple(aio_response.headers.items())) + status = Status(aio_response.status) + headers = _inbound_headers(aio_response) reason = aio_response.reason content_length = _content_length(aio_response, headers) body = AsyncResponseBody.from_async_stream( @@ -280,6 +308,29 @@ def _wrap_response(request: Request, aio_response: aiohttp.ClientResponse) -> As ) +def _inbound_headers(aio_response: aiohttp.ClientResponse) -> Headers: + """Build response headers, dropping malformed lines, preserving obs-text. + + ``aio_response.headers`` is aiohttp's own already-parsed multidict; a name + that fails the SDK's RFC 7230 token check (a stray space, for example) + must not fail the whole response, so each pair is added individually and a + rejected one is logged and skipped rather than propagated. + + Args: + aio_response: The aiohttp response carrying the raw headers. + + Returns: + The parsed, defensively-filtered response headers. + """ + headers = Headers() + for name, value in aio_response.headers.items(): + try: + headers = headers.with_added(name, value) + except ValueError: + _LOGGER.warning("dropping malformed inbound response header: %r", name) + return headers + + def _protocol(aio_response: aiohttp.ClientResponse) -> Protocol: """Map aiohttp's reported HTTP version onto core's `Protocol`. diff --git a/packages/dexpace-sdk-http-aiohttp/tests/conformance_harness.py b/packages/dexpace-sdk-http-aiohttp/tests/conformance_harness.py new file mode 100644 index 0000000..1bc0bb1 --- /dev/null +++ b/packages/dexpace-sdk-http-aiohttp/tests/conformance_harness.py @@ -0,0 +1,530 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Real-``aiohttp`` harness for the shared transport-adapter conformance battery. + +``AiohttpAsyncHarness`` implements `AsyncTransportHarness` (from the installed +``dexpace.sdk.tck`` conformance kit) against the REAL `AiohttpHttpClient` +adapter — not a fake. Every native exchange the battery programs is realised as +literally as aiohttp allows: + +- A ``RESPONSE`` outcome with a numeric status and well-formed headers is + served by a genuine ``aiohttp.web`` server bound to loopback; the request + travels over a real socket and the reply is parsed by aiohttp's own wire + parser. This is what makes ``test_adapter_does_not_follow_redirects`` + meaningful for this transport: a 302 only proves non-following if aiohttp's + own redirect machinery was actually in the loop and chose not to act. + ``READ_TIMEOUT`` is served by the same real server, simply never replying. +- ``CONNECT_ERROR`` and ``CONNECT_TIMEOUT``, a non-numeric status, and a + malformed inbound header name are all synthesised at the point the adapter + hands its request to ``aiohttp.ClientSession`` — the first two because a + genuinely cancelled or refused real socket connect leaves a raw socket for + ``aiohappyeyeballs`` to reclaim on its own schedule (a real, but + independently-owned, resource-warning race this harness should not paper + over by suppressing warnings), the latter two because no real HTTP/1.1 + exchange can carry either at all — aiohttp's own client parser rejects both + before a live response ever reaches adapter code. All four exercise the + adapter's real exception-mapping / adaptation code (`execute`, + `_wrap_response`) against a duck-typed stand-in or synthetic native + exception, without inventing a fake transport underneath it — this mirrors + the connect-timeout technique the package's own ``test_aiohttp_client.py`` + already uses (a stub session raising ``aiohttp.ConnectionTimeoutError`` + directly) rather than something novel. + +The seam is a custom `aiohttp.abc.AbstractResolver` (routes real exchanges to +the loopback server) plus an instance-level override of ``session.request`` +(captures what the adapter handed to aiohttp and raises/returns the four +synthetic facets above). ``ClientSession`` explicitly warns against being +subclassed, so both seams are wired via instance-level monkeypatching rather +than a subclass. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import re +import socket +from collections import deque +from collections.abc import AsyncIterator, Sequence +from typing import TYPE_CHECKING, Final + +import aiohttp +from aiohttp import web + +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.http.aiohttp import AiohttpHttpClient +from dexpace.sdk.tck.harness import ( + AsyncTransportHarness, + ClosableNative, + DropLog, + Outcome, + OutcomeKind, + ResponseSpec, + SentRequest, +) + +if TYPE_CHECKING: + from dexpace.sdk.core.client.async_http_client import AsyncHttpClient + +#: Default per-call timeout for harness-built clients. Short enough that the +#: connect-timeout and read-timeout outcomes (below) resolve quickly, long +#: enough that a genuine loopback round trip never spuriously trips it. +_DEFAULT_CLIENT_TIMEOUT: Final[float] = 1.0 +#: How long the real server holds a READ_TIMEOUT / PROTOCOL_ERROR / CANCELLED +#: exchange open before giving up on its own. Purely a safety net: the +#: client's own (much shorter) timeout is expected to fire first every time. +_SERVER_HOLD_SECONDS: Final[float] = 5.0 +#: RFC 7230 `token` charset, matching `dexpace.sdk.core.http.common.headers`. +#: A header name outside this set cannot survive a real HTTP/1.1 wire +#: round trip, so responses carrying one are served via the stub path. +_TOKEN_RE: Final[re.Pattern[str]] = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") + + +class _HarnessCore: + """Programmable queue and observation log for one harness instance. + + Shared by the resolver, the session-level request override, and the real + server handler — whichever of the three ends up consuming a given + exchange calls `take`. + """ + + def __init__(self) -> None: + self._queue: deque[Outcome] = deque() + self.sent: list[SentRequest] = [] + self.attempts = 0 + self.drop_log = DropLog() + self.responses: list[ClosableNative] = [] + + def program(self, outcome: Outcome) -> None: + """Queue one outcome for the next exchange.""" + self._queue.append(outcome) + + def peek_kind(self) -> OutcomeKind: + """Return the next queued outcome's kind without consuming it.""" + if not self._queue: + raise AssertionError("no transport outcome was programmed") + return self._queue[0].kind + + def peek_response(self) -> ResponseSpec: + """Return the next queued RESPONSE outcome's spec without consuming it.""" + spec = self._queue[0].response + if spec is None: + raise AssertionError("next outcome is not a RESPONSE outcome") + return spec + + def take(self) -> Outcome: + """Consume the next programmed outcome, counting the attempt.""" + self.attempts += 1 + if not self._queue: + raise AssertionError("no transport outcome was programmed") + return self._queue.popleft() + + def record_sent(self, sent: SentRequest) -> None: + """Record a request that reached the aiohttp session layer.""" + self.sent.append(sent) + + +def _needs_stub(spec: ResponseSpec) -> bool: + """Whether ``spec`` can only be realised via the synthetic stub path. + + A non-numeric status or a header name outside the HTTP token charset + cannot travel over a genuine HTTP/1.1 wire exchange — aiohttp's own + parser rejects both before a live response ever reaches adapter code. + """ + if not isinstance(spec.status, int): + return True + return any(not _TOKEN_RE.match(name) for name, _ in spec.headers) + + +async def _drain(data: object) -> bytes: + """Fully drain a request payload (``None`` or an async byte iterator). + + Args: + data: The ``data=`` argument the adapter handed to ``session.request``. + + Returns: + The concatenated payload bytes (empty when ``data`` is ``None``). + """ + if data is None: + return b"" + if isinstance(data, AsyncIterator): + chunks = [chunk async for chunk in data] + return b"".join(chunks) + raise TypeError(f"unexpected request payload type: {type(data)!r}") + + +def _resolve_result(hostname: str, host: str, port: int) -> aiohttp.abc.ResolveResult: + """Build an `aiohttp.abc.ResolveResult` pinning ``hostname`` to ``host``:``port``.""" + return { + "hostname": hostname, + "host": host, + "port": port, + "family": socket.AF_INET, + "proto": 0, + "flags": 0, + } + + +def _synthetic_connect_error() -> aiohttp.ClientConnectorError: + """Build a `ClientConnectorError` shaped like a genuine connection refusal. + + Not routed through a real socket: a real refused-port or cancelled-connect + attempt leaves ``aiohappyeyeballs`` to reclaim a raw socket on its own + schedule, independent of anything this harness or the adapter controls, + which is a distinct concern from what this facet is meant to certify + (that the adapter maps this exception type to `ServiceRequestError`). + """ + key = aiohttp.client_reqrep.ConnectionKey( + host="service.test", + port=80, + is_ssl=False, + ssl=False, + proxy=None, + proxy_auth=None, + proxy_headers_hash=None, + ) + return aiohttp.ClientConnectorError(key, OSError("connection refused (harness)")) + + +class _StubContent: + """Minimal ``aiohttp.StreamReader``-shaped stand-in returning a fixed body.""" + + def __init__(self, body: bytes) -> None: + self._body = body + self._read = False + + async def read(self, size: int = -1) -> bytes: + if self._read: + return b"" + self._read = True + return self._body + + +class _StubHeaders: + """Minimal ``CIMultiDict``-shaped stand-in. + + Exposes only the two members the adapter's adaptation path + (`_wrap_response` and its helpers) reads: ``.items()`` and ``.get()``. + """ + + def __init__(self, pairs: Sequence[tuple[str, str]]) -> None: + self._pairs = tuple(pairs) + + def items(self) -> tuple[tuple[str, str], ...]: + return self._pairs + + def get(self, name: str) -> str | None: + lowered = name.lower() + for key, value in self._pairs: + if key.lower() == lowered: + return value + return None + + +class _StubResponse: + """Duck-typed stand-in for an `aiohttp.ClientResponse`. + + Used only for the two facets a real HTTP/1.1 wire exchange cannot + reproduce (see module docstring). Everything the adapter's + ``_wrap_response`` / `_StreamReaderAdapter` touches is present: + ``status``, ``headers``, ``reason``, ``version``, ``content.read``, and a + synchronous ``release`` whose effect ``native_responses()`` can observe. + """ + + def __init__(self, spec: ResponseSpec) -> None: + self.status = spec.status + self.headers = _StubHeaders(spec.headers) + self.reason = spec.reason + self.version = aiohttp.HttpVersion(1, 1) + self.content = _StubContent(spec.body) + self._closed = False + + def release(self) -> None: + """Mark the stub released. Idempotent.""" + self._closed = True + + def close(self) -> None: + """Mark the stub closed. Idempotent.""" + self._closed = True + + @property + def closed(self) -> bool: + """Whether `release` or `close` has been called.""" + return self._closed + + +class _RealServer: + """Lazily-started ``aiohttp.web`` server backing real-network outcomes. + + One instance per harness (so per pytest-asyncio test, since a fresh + harness is built per test): started on the first real exchange, on + whichever event loop is current at that point, and left running for the + rest of that test. Tracked in `_LIVE_SERVERS` so `reap_test_resources` + can properly ``cleanup()`` it once the test is done. + """ + + def __init__(self, core: _HarnessCore) -> None: + self._core = core + self._runner: web.AppRunner | None = None + self._port: int | None = None + self._lock = asyncio.Lock() + + async def ensure_started(self) -> int: + """Start the server on first use; return its loopback port.""" + async with self._lock: + if self._runner is None: + app = web.Application() + app.router.add_route("*", "/{tail:.*}", self._handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + sockets = site._server.sockets # type: ignore[union-attr] + assert sockets, "server has no listening sockets" + self._port = int(sockets[0].getsockname()[1]) + self._runner = runner + _LIVE_SERVERS.append(runner) + assert self._port is not None + return self._port + + async def _handle(self, request: web.Request) -> web.StreamResponse: + """Serve one exchange by consuming the next programmed outcome.""" + outcome = self._core.take() + await request.read() + if outcome.kind is OutcomeKind.RESPONSE: + assert outcome.response is not None + return await _build_response(request, outcome.response) + if outcome.kind is OutcomeKind.READ_TIMEOUT: + await _hold_or_give_up() + return web.Response(status=200) + # PROTOCOL_ERROR / CANCELLED: sever the connection abruptly so the + # client observes a genuine mid-exchange transport failure. + transport = request.transport + if transport is not None: + transport.close() + await _hold_or_give_up() + return web.Response(status=200) + + +async def _hold_or_give_up() -> None: + """Wait for the client to disconnect, bounded by `_SERVER_HOLD_SECONDS`.""" + with contextlib.suppress(TimeoutError, asyncio.CancelledError): + await asyncio.wait_for(asyncio.Event().wait(), timeout=_SERVER_HOLD_SECONDS) + + +async def _build_response(request: web.Request, spec: ResponseSpec) -> web.StreamResponse: + """Serve ``spec`` as a real HTTP/1.1 response (buffered or streamed).""" + status = spec.status + assert isinstance(status, int) # non-numeric statuses use the stub path + headers = list(spec.headers) + if not spec.stream: + return web.Response(status=status, reason=spec.reason, headers=headers, body=spec.body) + response = web.StreamResponse(status=status, reason=spec.reason, headers=headers) + await response.prepare(request) + for chunk in spec.chunks: + await response.write(chunk) + if spec.block_after_chunks: + await _hold_or_give_up() + return response + await response.write_eof() + return response + + +class _HarnessResolver(aiohttp.abc.AbstractResolver): + """Routes every real connection attempt to the loopback server. + + ``CONNECT_ERROR`` / ``CONNECT_TIMEOUT`` never reach this resolver at all — + the session-level override (`_install_request_override`) intercepts and + synthesises them before a real connect is ever attempted. + """ + + def __init__(self, core: _HarnessCore, server: _RealServer) -> None: + self._core = core + self._server = server + + async def resolve( + self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET + ) -> list[aiohttp.abc.ResolveResult]: + real_port = await self._server.ensure_started() + return [_resolve_result(host, "127.0.0.1", real_port)] + + async def close(self) -> None: + """No resources of its own to release.""" + return None + + +#: Every real session and every real server this harness has built during the +#: current pytest test item, not yet reaped. See `reap_test_resources`. +_LIVE_SESSIONS: list[aiohttp.ClientSession] = [] +_LIVE_SERVERS: list[web.AppRunner] = [] + + +async def reap_test_resources() -> None: + """Properly close every session and server this harness built for the + just-finished test item. + + Every exchange this harness drives owns a throwaway `ClientSession` + + `TCPConnector` + loopback `aiohttp.web` server, all scoped to one + pytest-asyncio test (a fresh event loop per test). The shared battery's + test bodies intentionally do not call `aclose_client` in most cases — + that is not the facet under test there — so left to themselves these + resources are only reachable by ordinary garbage collection, on a + schedule this module does not control; aiohttp's ``ClientSession.__del__`` + and the *raw socket*'s own ``ResourceWarning`` (the listening/connected + sockets `asyncio` tracks, independent of any aiohttp-level bookkeeping) + both treat that as a leak, and this workspace's ``filterwarnings = + ["error"]`` gate promotes either into a test failure. + + The underlying OS socket needs its owning event loop to process one more + iteration to finish tearing down, so this must run as a real ``await`` on + that same loop before it closes — a synchronous "mark closed" flag is not + enough. `conftest.py`'s ``pytest_runtest_teardown`` hook (a *global* + pytest hook, unlike a fixture, so it fires for every test regardless of + which conftest.py defines it — including the shared battery's own test + file) drives this via ``loop.run_until_complete`` immediately after each + test item, while that item's own loop is still open but no longer + running. + """ + while _LIVE_SESSIONS: + await _LIVE_SESSIONS.pop().close() + while _LIVE_SERVERS: + await _LIVE_SERVERS.pop().cleanup() + + +def _install_request_override(session: aiohttp.ClientSession, core: _HarnessCore) -> None: + """Swap in an instance-level override of ``session.request``. + + ``ClientSession`` explicitly discourages subclassing (it warns on + ``__init_subclass__``), so the interception seam is an instance attribute + shadowing the bound method rather than a subclass override. The override + captures exactly what the adapter handed to aiohttp (`SentRequest`) and, + for the four facets described in the module docstring, raises or returns + a synthetic native result instead of touching the network at all. + """ + real_request = session.request + + async def _request( + *, + method: str, + url: str, + headers: Sequence[tuple[str, str]] | None = None, + data: object = None, + timeout: aiohttp.ClientTimeout | None = None, + allow_redirects: bool = False, + ) -> aiohttp.ClientResponse: + body = await _drain(data) + effective_timeout = timeout.sock_read if timeout is not None else None + core.record_sent( + SentRequest( + method=method, + url=url, + headers=Headers(tuple(headers or ())), + body=body, + timeout=effective_timeout, + ) + ) + kind = core.peek_kind() + if kind is OutcomeKind.CONNECT_ERROR: + core.take() + raise _synthetic_connect_error() + if kind is OutcomeKind.CONNECT_TIMEOUT: + core.take() + raise aiohttp.ConnectionTimeoutError(f"connect to {url} timed out (harness)") + if kind is OutcomeKind.RESPONSE and _needs_stub(core.peek_response()): + outcome = core.take() + assert outcome.response is not None + stub = _StubResponse(outcome.response) + core.responses.append(stub) + return stub # type: ignore[return-value] + response = await real_request( + method=method, + url=url, + headers=headers, + data=body, + timeout=timeout, + allow_redirects=allow_redirects, + ) + core.responses.append(response) + return response + + session.request = _request # type: ignore[assignment] + + +def _build_session(core: _HarnessCore, server: _RealServer) -> aiohttp.ClientSession: + """Build a real, harness-wired `aiohttp.ClientSession`.""" + resolver = _HarnessResolver(core, server) + connector = aiohttp.TCPConnector(resolver=resolver, use_dns_cache=False, force_close=True) + session = aiohttp.ClientSession(connector=connector) + _install_request_override(session, core) + _LIVE_SESSIONS.append(session) + return session + + +class AiohttpAsyncHarness(AsyncTransportHarness): + """Real-``aiohttp``-backed harness for the async conformance battery. + + Capability flags reflect genuine transport limits, not laziness: + + - `supports_native_header_rejection` is ``False``: aiohttp has no + per-header-name native rejection list for the adapter to consult + (unlike the in-memory fake, which models one deliberately), so this + facet is honestly unsupported rather than faked. + - `supports_proxy_introspection` is ``False``: the adapter has no proxy + configuration surface at all today. + - `supports_byo` and `supports_streaming` are ``True``: both are real, + exercised via a caller-supplied session and a real chunked HTTP + response respectively. + """ + + supports_byo = True + supports_native_header_rejection = False + supports_streaming = True + supports_proxy_introspection = False + + def __init__(self) -> None: + self._core = _HarnessCore() + self._server = _RealServer(self._core) + self._byo_session: aiohttp.ClientSession | None = None + + def expect(self, outcome: Outcome) -> None: + self._core.program(outcome) + + def sent_requests(self) -> list[SentRequest]: + return self._core.sent + + def attempt_count(self) -> int: + return self._core.attempts + + def drop_log(self) -> DropLog: + return self._core.drop_log + + def native_responses(self) -> list[ClosableNative]: + return self._core.responses + + def mark_native_rejected(self, *names: str) -> None: + """No-op for this transport. + + `supports_native_header_rejection` is ``False``, so the battery + never relies on this call meaningfully. + """ + return None + + def byo_underlying(self) -> ClosableNative | None: + return self._byo_session + + def new_async_client(self, **options: object) -> AsyncHttpClient: + timeout = options.get("timeout", _DEFAULT_CLIENT_TIMEOUT) + assert isinstance(timeout, int | float) + use_byo = bool(options.get("byo", False)) + session = _build_session(self._core, self._server) + if use_byo: + self._byo_session = session + return AiohttpHttpClient(timeout=float(timeout), session=session) + + async def aclose_client(self, client: AsyncHttpClient) -> None: + assert isinstance(client, AiohttpHttpClient) + await client.aclose() + + +__all__ = ["AiohttpAsyncHarness"] diff --git a/packages/dexpace-sdk-http-aiohttp/tests/conftest.py b/packages/dexpace-sdk-http-aiohttp/tests/conftest.py new file mode 100644 index 0000000..0da5ebc --- /dev/null +++ b/packages/dexpace-sdk-http-aiohttp/tests/conftest.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Registers the real-``aiohttp`` harness into the shared conformance registry. + +The shared transport-adapter conformance battery (the installed +``dexpace.sdk.tck`` package) reads ``ASYNC_HARNESS_FACTORIES`` at collection +time to parametrize its ``async_harness`` fixture. Appending this package's +harness factory here is the entire integration: no test body in that battery +changes. + +The harness ABCs resolve from the installed ``dexpace.sdk.tck`` distribution as +an ordinary import; this package's own ``conformance_harness`` module is a bare +module in this test directory, so the ``sys.path`` insertion below makes it +importable regardless of pytest's import mode (``consider_namespace_packages`` +stops pytest from adding a plain test directory to ``sys.path`` on its own). +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from conformance_harness import AiohttpAsyncHarness, reap_test_resources # noqa: E402 +from dexpace.sdk.tck.harness import ASYNC_HARNESS_FACTORIES # noqa: E402 + +if TYPE_CHECKING: + import pytest + + +def _register(registry: list[tuple[str, type]], label: str, factory: type) -> None: + """Append ``(label, factory)`` to ``registry`` unless the label is present.""" + if not any(existing == label for existing, _ in registry): + registry.append((label, factory)) + + +_register(ASYNC_HARNESS_FACTORIES, "aiohttp", AiohttpAsyncHarness) + + +def pytest_runtest_teardown(item: pytest.Item) -> None: + """Close every real session/server `AiohttpAsyncHarness` built for ``item``. + + A *global* pytest hook (unlike a fixture, which is only visible within + its own conftest.py's directory subtree) — this fires after every test in + the whole run, including the shared battery's own ``test_async_contract.py``, + which is what makes it possible to guarantee cleanup for a harness whose + test bodies live in a different package entirely and never call + `aclose_client` themselves. `AiohttpHttpClient` sessions and the loopback + server behind them are real asyncio resources bound to the event loop + pytest-asyncio just used to run ``item`` — that loop is no longer + *running* by this point (`pytest_runtest_call` has already returned) but + is not yet *closed* either, so driving the real async close here, on that + same loop, is what actually releases the underlying OS sockets instead of + leaving them for garbage collection to discover on an unpredictable + schedule. + """ + try: + loop = asyncio.get_event_loop() + except RuntimeError: + return + if not loop.is_closed(): + loop.run_until_complete(reap_test_resources()) diff --git a/packages/dexpace-sdk-http-aiohttp/tests/test_aiohttp_client.py b/packages/dexpace-sdk-http-aiohttp/tests/test_aiohttp_client.py index 451ea56..fe39ac3 100644 --- a/packages/dexpace-sdk-http-aiohttp/tests/test_aiohttp_client.py +++ b/packages/dexpace-sdk-http-aiohttp/tests/test_aiohttp_client.py @@ -14,7 +14,6 @@ from dexpace.sdk.core.errors import ( ServiceRequestError, ServiceRequestTimeoutError, - ServiceResponseError, ServiceResponseTimeoutError, ) from dexpace.sdk.core.http.common import Url @@ -273,16 +272,18 @@ async def test_apache_218_status_is_preserved() -> None: assert response.status.is_success -def test_invalid_status_releases_connection_and_raises() -> None: - """A genuinely invalid code (outside 100..599) releases then raises.""" +@pytest.mark.req("TRANSPORT-24") +async def test_out_of_range_status_surfaces_with_readable_body() -> None: + """TRANSPORT-24: a genuinely out-of-range code (outside 100..599) surfaces.""" request = Request(method=Method.GET, url=Url.parse("http://example.test/")) - fake = _FakeAioResponse(999) # out of the valid HTTP range + fake = _FakeAioResponse(999, headers={"Content-Length": "5"}, body=b"hello") - with pytest.raises(ServiceResponseError) as exc_info: - _wrap_response(request, fake) # type: ignore[arg-type] + response = _wrap_response(request, fake) # type: ignore[arg-type] - assert fake.released, "connection leaked: release() was not called" - assert "999" in str(exc_info.value) + assert not fake.released, "live response must not be discarded" + assert int(response.status) == 999 + assert response.body is not None + assert await response.body.bytes() == b"hello" # ----------------------------------------------------------------- post-close @@ -296,6 +297,7 @@ async def test_execute_after_aclose_raises() -> None: await client.execute(Request(method=Method.GET, url=Url.parse("http://example.test/"))) +@pytest.mark.req("ASYNC-15", "TRANSPORT-16") async def test_aclose_is_idempotent() -> None: """Calling aclose() twice is a no-op and stays in the closed state.""" client = AiohttpHttpClient(timeout=5.0) diff --git a/packages/dexpace-sdk-http-aiohttp/tests/test_lifecycle.py b/packages/dexpace-sdk-http-aiohttp/tests/test_lifecycle.py new file mode 100644 index 0000000..099320f --- /dev/null +++ b/packages/dexpace-sdk-http-aiohttp/tests/test_lifecycle.py @@ -0,0 +1,147 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Session/connector ownership matrix and cancellation propagation. + +``test_aiohttp_client.py`` already covers ``aclose()`` idempotency and that a +caller-supplied session survives close. This file goes one level deeper on +two facets PY-M8-04 calls out specifically: + +- The BYO case must leave the *connector* untouched too, not just the + session (a session can report ``closed is False`` while its connector was + independently torn down). +- The owned case must release the underlying connector *exactly once*, even + under a redundant `aclose()` call — proven by counting the real close + invocation rather than only inspecting the final ``closed`` state, which + cannot distinguish "closed once" from "closed twice". + +It also adds a dedicated test proving ``asyncio.CancelledError`` propagates +natively through `AiohttpHttpClient.execute` — never swallowed or converted +into an SDK error — which the shared conformance battery does not exercise +(its own cancellation test targets the response body iterator, not `execute` +itself). +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import aiohttp +import pytest +from aiohttp import web + +from dexpace.sdk.core.http.common import Url +from dexpace.sdk.core.http.request import Method, Request +from dexpace.sdk.http.aiohttp import AiohttpHttpClient + + +async def _ok(_request: web.Request) -> web.Response: + return web.Response(text="ok") + + +async def _slow(_request: web.Request) -> web.Response: + await asyncio.sleep(2.0) + return web.Response(text="too late") + + +@pytest.fixture +async def base_url() -> AsyncIterator[str]: + """Start an aiohttp.web server on an ephemeral port; yield its base URL.""" + app = web.Application() + app.router.add_get("/ok", _ok) + app.router.add_get("/slow", _slow) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + sockets = site._server.sockets # type: ignore[union-attr] + assert sockets, "server has no listening sockets" + port = int(sockets[0].getsockname()[1]) + try: + yield f"http://127.0.0.1:{port}" + finally: + await runner.cleanup() + + +# --------------------------------------------------------------------------- # +# Session/connector ownership matrix. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("ASYNC-15", "SEAM-14", "SEAM-25", "TRANSPORT-15") +async def test_byo_session_and_connector_survive_close(base_url: str) -> None: + """A caller-supplied session AND its connector survive `aclose()`.""" + connector = aiohttp.TCPConnector() + session = aiohttp.ClientSession(connector=connector) + try: + client = AiohttpHttpClient(session=session) + request = Request(method=Method.GET, url=Url.parse(f"{base_url}/ok")) + async with await client.execute(request) as response: + assert response.body is not None + await response.body.bytes() + + await client.aclose() + + assert session.closed is False + assert connector.closed is False + finally: + await session.close() + + +@pytest.mark.req("ASYNC-15", "ASYNC-16", "SEAM-14", "SEAM-25", "TRANSPORT-16") +async def test_owned_session_and_connector_released_exactly_once(base_url: str) -> None: + """An adapter-owned connector is closed exactly once. + + Even across a redundant `aclose()` call. + """ + client = AiohttpHttpClient(timeout=5.0) + request = Request(method=Method.GET, url=Url.parse(f"{base_url}/ok")) + async with await client.execute(request) as response: + assert response.body is not None + await response.body.bytes() + + session = client._session + assert session is not None + connector = session.connector + assert connector is not None + real_close = connector.close + calls = 0 + + async def _counting_close(*args: Any, **kwargs: Any) -> None: + nonlocal calls + calls += 1 + await real_close(*args, **kwargs) + + connector.close = _counting_close # type: ignore[method-assign] + + await client.aclose() + await client.aclose() # idempotent: must not release a second time + + assert calls == 1 + assert connector.closed is True + + +# --------------------------------------------------------------------------- # +# Cancellation propagation. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("ASYNC-6", "SEAM-13", "TRANSPORT-7") +async def test_cancelled_error_propagates_natively_through_execute(base_url: str) -> None: + """``asyncio.CancelledError`` is never swallowed or remapped by `execute`. + + None of `execute`'s ``except`` clauses catch `BaseException` broadly, so + cancelling the coroutine mid-flight should surface the *native* + ``asyncio.CancelledError`` unchanged — not a `ServiceRequestError` / + `ServiceResponseTimeoutError` / any other SDK-mapped type. + """ + async with AiohttpHttpClient(timeout=30.0) as client: + request = Request(method=Method.GET, url=Url.parse(f"{base_url}/slow")) + task = asyncio.create_task(client.execute(request)) + await asyncio.sleep(0.05) # let the request reach the in-flight read + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task diff --git a/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/async_.py b/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/async_.py index 4a1e85a..093945b 100644 --- a/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/async_.py +++ b/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/async_.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import logging from collections.abc import AsyncIterator, Iterator from types import TracebackType from typing import Any, Final, Self @@ -43,8 +44,13 @@ from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody from dexpace.sdk.core.http.response.status import Status +_LOGGER: Final = logging.getLogger(__name__) + _DEFAULT_TIMEOUT: Final[float] = 30.0 _DEFAULT_CHUNK_SIZE: Final[int] = 8192 +#: Methods whose semantics call for an explicit zero-length body substitution +#: when the caller supplies no payload (RFC 9110 §8.6). +_ZERO_LENGTH_METHODS: Final[frozenset[str]] = frozenset({"POST", "PUT", "PATCH"}) class AsyncHttpxHttpClient: @@ -68,6 +74,14 @@ class AsyncHttpxHttpClient: the timeout / transport kwargs entirely. Ownership stays with the caller: ``aclose`` does not close a client passed in this way, so the caller remains responsible for closing it. + + A client this adapter constructs itself never trusts the process + environment: `httpx.AsyncClient`'s own ``trust_env`` (which implicitly + reads ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``NO_PROXY``) is pinned to + ``False``, so proxy routing only ever happens through the SDK's own + opt-in ``ProxyOptions`` resolution, never silently through httpx's + defaults. A caller-supplied ``client=`` keeps whatever ``trust_env`` it + was built with — ownership of that choice stays with the caller. """ __slots__ = ("_client", "_closed", "_owns_client") @@ -92,7 +106,12 @@ def __init__( write=write_timeout, pool=pool_timeout, ) - self._client = httpx.AsyncClient(timeout=timeout, transport=transport) + self._client = httpx.AsyncClient( + timeout=timeout, + transport=transport, + follow_redirects=False, + trust_env=False, + ) self._owns_client = True self._closed = False @@ -110,7 +129,19 @@ async def execute(self, request: Request) -> AsyncResponse: raise ServiceRequestError("AsyncHttpxHttpClient is closed") httpx_request = self._build_request(request) try: - httpx_response = await self._client.send(httpx_request, stream=True) + # ``follow_redirects=False`` is pinned here explicitly rather than + # left to the client's own default: a caller-supplied (BYO) + # ``httpx.AsyncClient`` could have been constructed with + # ``follow_redirects=True``, and redirect handling is the SDK's + # own pipeline policy's job, never the transport's. + httpx_response = await self._client.send( + httpx_request, stream=True, follow_redirects=False + ) + # Subtype-first: every ``httpx.TimeoutException`` subtype (Connect / + # Read / Write / Pool) is caught by name *before* the broader + # ``httpx.ConnectError`` / ``httpx.RequestError`` clauses below, so a + # timeout is never misclassified as a generic network error by + # falling through to a wider catch first. except httpx.ConnectTimeout as err: raise ServiceRequestTimeoutError(str(err), error=err) from err except httpx.ReadTimeout as err: @@ -126,7 +157,8 @@ async def execute(self, request: Request) -> AsyncResponse: return await _build_async_response(request, httpx_response) def _build_request(self, request: Request) -> httpx.Request: - headers = _headers_to_pairs(request.headers) + sdk_headers = _resolve_content_type(request.headers, request.body) + headers = _headers_to_pairs(sdk_headers) # ``httpx.AsyncClient`` requires an async byte stream for the request # body. ``Request.body`` is a synchronous ``RequestBody``, so its # ``iter_bytes`` iterator is pumped one chunk at a time on a worker @@ -135,12 +167,21 @@ def _build_request(self, request: Request) -> httpx.Request: content: Any = None if request.body is not None: content = _sync_iter_to_async(request.body.iter_bytes(_DEFAULT_CHUNK_SIZE)) - _frame_known_length(headers, request.headers, request.body) + headers = _resolve_framing_headers(headers, request.body) + else: + headers = _strip_framing_headers(headers) + if str(request.method) in _ZERO_LENGTH_METHODS: + headers.append((http_header_name.CONTENT_LENGTH.canonical_name, "0")) + # A per-call ``Request.timeout`` overrides the client's own default + # for this exchange only; ``None`` defers to whatever the client (or + # a BYO client's own construction) already has configured. + timeout = request.timeout if request.timeout is not None else httpx.USE_CLIENT_DEFAULT return self._client.build_request( method=str(request.method), url=request.url.wire_form(), headers=headers, content=content, + timeout=timeout, ) async def aclose(self) -> None: @@ -214,18 +255,49 @@ async def close(self) -> None: await self._response.aclose() +class _AdaptationError(Exception): + """Raised internally when a native response cannot be adapted.""" + + +def _parse_status(raw: int) -> Status: + """Parse the native status; a non-numeric token fails adaptation cleanly. + + Status is total over *valid* integers (TRANSPORT-24): an out-of-range + code (outside 100..599) synthesizes an ``UNKNOWN_`` member rather + than raising, so a vendor/unusual status still surfaces with its body. + Only a genuinely unparseable value (not an int at all) fails here. + """ + try: + return Status(int(raw)) + except (TypeError, ValueError) as err: + raise _AdaptationError(f"unparseable status: {raw!r}") from err + + +def _inbound_headers(httpx_response: httpx.Response) -> Headers: + """Build response headers, dropping malformed names, preserving obs-text. + + Built one entry at a time rather than in a single ``Headers(...)`` call: + a defensively-received header whose name fails the RFC 7230 token check + (a literal space, say) is dropped and logged instead of failing the whole + response adaptation. A legal-but-unusual obs-text byte in a header + *value* is tolerated, per `Headers`'s inbound-lenient contract. + """ + headers = Headers() + for name, value in httpx_response.headers.multi_items(): + try: + headers = headers.with_added(name, value) + except ValueError: + _LOGGER.warning("dropping malformed inbound response header: %r", name) + return headers + + async def _build_async_response(request: Request, httpx_response: httpx.Response) -> AsyncResponse: try: - status = Status(int(httpx_response.status_code)) - except ValueError as err: - # Genuinely invalid status (outside 100..599). Release the underlying - # socket back to the pool; httpx async responses reject sync ``close``, - # so use ``aclose``. + status = _parse_status(httpx_response.status_code) + headers = _inbound_headers(httpx_response) + except _AdaptationError as err: await httpx_response.aclose() - raise ServiceResponseError( - f"Invalid status code: {httpx_response.status_code}", error=err - ) from err - headers = Headers(httpx_response.headers.multi_items()) + raise ServiceResponseError(str(err), error=err) from err stream = _AsyncHttpxStreamAdapter(httpx_response, _DEFAULT_CHUNK_SIZE) content_length = _content_length(httpx_response) body = AsyncResponseBody.from_async_stream(stream, content_length=content_length) @@ -270,23 +342,69 @@ def _content_length(httpx_response: httpx.Response) -> int: return -1 -def _frame_known_length(pairs: list[tuple[str, str]], headers: Headers, body: RequestBody) -> None: - """Add a ``Content-Length`` pair for a known-length body. +def _resolve_content_type(headers: Headers, body: RequestBody | None) -> Headers: + """Keep a caller-set ``Content-Type``; else derive one from the body. + + Args: + headers: The SDK request headers. + body: The outgoing request body, if any. + + Returns: + ``headers`` unchanged when the caller already set a content-type (or + there is no body / the body reports none); otherwise ``headers`` with + the body's `MediaType` stamped on as ``Content-Type``. + """ + if headers.get(http_header_name.CONTENT_TYPE) is not None or body is None: + return headers + media = body.media_type() + if media is None: + return headers + return headers.with_set(http_header_name.CONTENT_TYPE, str(media)) + + +def _resolve_framing_headers( + pairs: list[tuple[str, str]], body: RequestBody +) -> list[tuple[str, str]]: + """Recompute framing headers from the actual body; never trust the caller. httpx receives the body as a bare iterator with no declared length, so it - falls back to ``Transfer-Encoding: chunked``. Declaring the length lets it - frame the upload by length instead. Unknown-length bodies (``-1``) are left - chunked, and an explicit caller-supplied ``Content-Length`` is preserved. + falls back to ``Transfer-Encoding: chunked`` unless told otherwise. A + caller-supplied ``Content-Length`` that disagrees with the body actually + being written (or one left over from a different, unknown-length body) is + stale and would violate RFC 9112 §6.1's "not both" framing rule, so any + existing ``Content-Length`` / ``Transfer-Encoding`` is dropped first and + the correct one is derived from ``body.content_length()``. Args: - pairs: The header name/value pairs forwarded to httpx; mutated in place. - headers: The SDK request headers, consulted case-insensitively. + pairs: The header name/value pairs forwarded to httpx. body: The outgoing request body. + + Returns: + ``pairs`` with framing headers recomputed from ``body``. """ + pairs = _strip_framing_headers(pairs) length = body.content_length() - if length < 0 or http_header_name.CONTENT_LENGTH in headers: - return - pairs.append((http_header_name.CONTENT_LENGTH.canonical_name, str(length))) + if length >= 0: + pairs.append((http_header_name.CONTENT_LENGTH.canonical_name, str(length))) + return pairs + + +def _strip_framing_headers(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]: + """Drop any caller-supplied ``Content-Length`` / ``Transfer-Encoding``. + + Framing headers describe a body; when there is none, any stale + caller-supplied value would misdescribe the (absent) payload, so both are + dropped here before the bodyless-method case re-adds an explicit + ``Content-Length: 0`` (RFC 9110 §8.6). + + Args: + pairs: The header name/value pairs forwarded to httpx. + + Returns: + A new list with any ``content-length`` / ``transfer-encoding`` entries + removed. + """ + return [(n, v) for n, v in pairs if n.lower() not in ("content-length", "transfer-encoding")] _ITER_DONE: Final = object() diff --git a/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/sync.py b/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/sync.py index fb17d07..7aab84e 100644 --- a/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/sync.py +++ b/packages/dexpace-sdk-http-httpx/src/dexpace/sdk/http/httpx/sync.py @@ -20,6 +20,7 @@ from __future__ import annotations +import logging from collections.abc import Iterator from types import TracebackType from typing import Any, Final, Self @@ -40,8 +41,13 @@ from dexpace.sdk.core.http.response.response_body import ResponseBody from dexpace.sdk.core.http.response.status import Status +_LOGGER: Final = logging.getLogger(__name__) + _DEFAULT_TIMEOUT: Final[float] = 30.0 _DEFAULT_CHUNK_SIZE: Final[int] = 8192 +#: Methods whose semantics call for an explicit zero-length body substitution +#: when the caller supplies no payload (RFC 9110 §8.6). +_ZERO_LENGTH_METHODS: Final[frozenset[str]] = frozenset({"POST", "PUT", "PATCH"}) class HttpxHttpClient: @@ -69,6 +75,14 @@ class HttpxHttpClient: timeout / transport kwargs entirely. Ownership stays with the caller: ``close`` does not close a client passed in this way, so the caller remains responsible for closing it. + + A client this adapter constructs itself never trusts the process + environment: `httpx.Client`'s own ``trust_env`` (which implicitly reads + ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``NO_PROXY``) is pinned to ``False``, so + proxy routing only ever happens through the SDK's own opt-in + ``ProxyOptions`` resolution, never silently through httpx's defaults. A + caller-supplied ``client=`` keeps whatever ``trust_env`` it was built + with — ownership of that choice stays with the caller. """ __slots__ = ("_client", "_closed", "_owns_client") @@ -93,7 +107,12 @@ def __init__( write=write_timeout, pool=pool_timeout, ) - self._client = httpx.Client(timeout=timeout, transport=transport) + self._client = httpx.Client( + timeout=timeout, + transport=transport, + follow_redirects=False, + trust_env=False, + ) self._owns_client = True self._closed = False @@ -111,7 +130,17 @@ def execute(self, request: Request) -> Response: raise ServiceRequestError("HttpxHttpClient is closed") httpx_request = self._build_request(request) try: - httpx_response = self._client.send(httpx_request, stream=True) + # ``follow_redirects=False`` is pinned here explicitly rather than + # left to the client's own default: a caller-supplied (BYO) + # ``httpx.Client`` could have been constructed with + # ``follow_redirects=True``, and redirect handling is the SDK's + # own pipeline policy's job, never the transport's. + httpx_response = self._client.send(httpx_request, stream=True, follow_redirects=False) + # Subtype-first: every ``httpx.TimeoutException`` subtype (Connect / + # Read / Write / Pool) is caught by name *before* the broader + # ``httpx.ConnectError`` / ``httpx.RequestError`` clauses below, so a + # timeout is never misclassified as a generic network error by + # falling through to a wider catch first. except httpx.ConnectTimeout as err: raise ServiceRequestTimeoutError(str(err), error=err) from err except httpx.ReadTimeout as err: @@ -127,16 +156,26 @@ def execute(self, request: Request) -> Response: return _build_response(request, httpx_response) def _build_request(self, request: Request) -> httpx.Request: - headers = _headers_to_pairs(request.headers) + sdk_headers = _resolve_content_type(request.headers, request.body) + headers = _headers_to_pairs(sdk_headers) content: Iterator[bytes] | None = None if request.body is not None: content = request.body.iter_bytes(_DEFAULT_CHUNK_SIZE) - _frame_known_length(headers, request.headers, request.body) + headers = _resolve_framing_headers(headers, request.body) + else: + headers = _strip_framing_headers(headers) + if str(request.method) in _ZERO_LENGTH_METHODS: + headers.append((http_header_name.CONTENT_LENGTH.canonical_name, "0")) + # A per-call ``Request.timeout`` overrides the client's own default + # for this exchange only; ``None`` defers to whatever the client (or + # a BYO client's own construction) already has configured. + timeout = request.timeout if request.timeout is not None else httpx.USE_CLIENT_DEFAULT return self._client.build_request( method=str(request.method), url=request.url.wire_form(), headers=headers, content=content, + timeout=timeout, ) def close(self) -> None: @@ -216,17 +255,49 @@ def close(self) -> None: self._response.close() +class _AdaptationError(Exception): + """Raised internally when a native response cannot be adapted.""" + + +def _parse_status(raw: int) -> Status: + """Parse the native status; a non-numeric token fails adaptation cleanly. + + Status is total over *valid* integers (TRANSPORT-24): an out-of-range + code (outside 100..599) synthesizes an ``UNKNOWN_`` member rather + than raising, so a vendor/unusual status still surfaces with its body. + Only a genuinely unparseable value (not an int at all) fails here. + """ + try: + return Status(int(raw)) + except (TypeError, ValueError) as err: + raise _AdaptationError(f"unparseable status: {raw!r}") from err + + +def _inbound_headers(httpx_response: httpx.Response) -> Headers: + """Build response headers, dropping malformed names, preserving obs-text. + + Built one entry at a time rather than in a single ``Headers(...)`` call: + a defensively-received header whose name fails the RFC 7230 token check + (a literal space, say) is dropped and logged instead of failing the whole + response adaptation. A legal-but-unusual obs-text byte in a header + *value* is tolerated, per `Headers`'s inbound-lenient contract. + """ + headers = Headers() + for name, value in httpx_response.headers.multi_items(): + try: + headers = headers.with_added(name, value) + except ValueError: + _LOGGER.warning("dropping malformed inbound response header: %r", name) + return headers + + def _build_response(request: Request, httpx_response: httpx.Response) -> Response: try: - status = Status(int(httpx_response.status_code)) - except ValueError as err: - # Genuinely invalid status (outside 100..599); release the socket - # back to the pool before surfacing the error. + status = _parse_status(httpx_response.status_code) + headers = _inbound_headers(httpx_response) + except _AdaptationError as err: httpx_response.close() - raise ServiceResponseError( - f"Invalid status code: {httpx_response.status_code}", error=err - ) from err - headers = Headers(httpx_response.headers.multi_items()) + raise ServiceResponseError(str(err), error=err) from err stream = _HttpxStreamAdapter(httpx_response, _DEFAULT_CHUNK_SIZE) content_length = _content_length(httpx_response) body: Any = ResponseBody.from_stream(stream, content_length=content_length) # type: ignore[arg-type] @@ -271,23 +342,69 @@ def _content_length(httpx_response: httpx.Response) -> int: return -1 -def _frame_known_length(pairs: list[tuple[str, str]], headers: Headers, body: RequestBody) -> None: - """Add a ``Content-Length`` pair for a known-length body. +def _resolve_content_type(headers: Headers, body: RequestBody | None) -> Headers: + """Keep a caller-set ``Content-Type``; else derive one from the body. + + Args: + headers: The SDK request headers. + body: The outgoing request body, if any. + + Returns: + ``headers`` unchanged when the caller already set a content-type (or + there is no body / the body reports none); otherwise ``headers`` with + the body's `MediaType` stamped on as ``Content-Type``. + """ + if headers.get(http_header_name.CONTENT_TYPE) is not None or body is None: + return headers + media = body.media_type() + if media is None: + return headers + return headers.with_set(http_header_name.CONTENT_TYPE, str(media)) + + +def _resolve_framing_headers( + pairs: list[tuple[str, str]], body: RequestBody +) -> list[tuple[str, str]]: + """Recompute framing headers from the actual body; never trust the caller. httpx receives the body as a bare iterator with no declared length, so it - falls back to ``Transfer-Encoding: chunked``. Declaring the length lets it - frame the upload by length instead. Unknown-length bodies (``-1``) are left - chunked, and an explicit caller-supplied ``Content-Length`` is preserved. + falls back to ``Transfer-Encoding: chunked`` unless told otherwise. A + caller-supplied ``Content-Length`` that disagrees with the body actually + being written (or one left over from a different, unknown-length body) is + stale and would violate RFC 9112 §6.1's "not both" framing rule, so any + existing ``Content-Length`` / ``Transfer-Encoding`` is dropped first and + the correct one is derived from ``body.content_length()``. Args: - pairs: The header name/value pairs forwarded to httpx; mutated in place. - headers: The SDK request headers, consulted case-insensitively. + pairs: The header name/value pairs forwarded to httpx. body: The outgoing request body. + + Returns: + ``pairs`` with framing headers recomputed from ``body``. """ + pairs = _strip_framing_headers(pairs) length = body.content_length() - if length < 0 or http_header_name.CONTENT_LENGTH in headers: - return - pairs.append((http_header_name.CONTENT_LENGTH.canonical_name, str(length))) + if length >= 0: + pairs.append((http_header_name.CONTENT_LENGTH.canonical_name, str(length))) + return pairs + + +def _strip_framing_headers(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]: + """Drop any caller-supplied ``Content-Length`` / ``Transfer-Encoding``. + + Framing headers describe a body; when there is none, any stale + caller-supplied value would misdescribe the (absent) payload, so both are + dropped here before the bodyless-method case re-adds an explicit + ``Content-Length: 0`` (RFC 9110 §8.6). + + Args: + pairs: The header name/value pairs forwarded to httpx. + + Returns: + A new list with any ``content-length`` / ``transfer-encoding`` entries + removed. + """ + return [(n, v) for n, v in pairs if n.lower() not in ("content-length", "transfer-encoding")] def _headers_to_pairs(headers: Headers) -> list[tuple[str, str]]: diff --git a/packages/dexpace-sdk-http-httpx/tests/conftest.py b/packages/dexpace-sdk-http-httpx/tests/conftest.py new file mode 100644 index 0000000..22be4b8 --- /dev/null +++ b/packages/dexpace-sdk-http-httpx/tests/conftest.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Registers the httpx harnesses into the shared transport-conformance battery. + +The TCK's ``dexpace.sdk.tck`` conftest parametrizes the ``sync_harness`` / +``async_harness`` fixtures over ``SYNC_HARNESS_FACTORIES`` / +``ASYNC_HARNESS_FACTORIES`` at collection time. Appending this package's +factories here — rather than editing the TCK — keeps this certification's +changes confined to ``dexpace-sdk-http-httpx``, so it never collides with the +equivalent registration a sibling transport adds to its own package. + +Registration only has an effect when the TCK's battery is part of the same +pytest session (the normal case: the workspace's ``testpaths`` collects the TCK +battery and every adapter package's ``tests/`` in one run, and all +distributions share one editable-install virtualenv, so +``dexpace.sdk.tck.harness`` is the very same module object regardless of which +package's conftest imports it first). Running this package's tests in isolation +without the TCK installed never loads that module, so both the harness module +and this registration are best-effort: this package's own tests still run +standalone, just without the shared battery. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# This package's own ``transport_conformance_harness`` is a bare module in this +# test directory; adding the directory to ``sys.path`` makes it importable +# regardless of pytest's import mode (``consider_namespace_packages`` stops +# pytest from adding a plain test directory to ``sys.path`` on its own). +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +_Registry = list[tuple[str, type]] + +_registries: tuple[_Registry, _Registry] | None +try: + from dexpace.sdk.tck.harness import ( + ASYNC_HARNESS_FACTORIES, + SYNC_HARNESS_FACTORIES, + ) + from transport_conformance_harness import HttpxAsyncHarness, HttpxSyncHarness + + _registries = (SYNC_HARNESS_FACTORIES, ASYNC_HARNESS_FACTORIES) +except ModuleNotFoundError: + _registries = None + + +def _register(registry: _Registry, label: str, factory: type) -> None: + """Append ``(label, factory)`` to ``registry`` unless the label is present.""" + if not any(existing == label for existing, _ in registry): + registry.append((label, factory)) + + +if _registries is not None: + _sync_registry, _async_registry = _registries + _register(_sync_registry, "httpx", HttpxSyncHarness) + _register(_async_registry, "httpx", HttpxAsyncHarness) diff --git a/packages/dexpace-sdk-http-httpx/tests/test_async_httpx_client.py b/packages/dexpace-sdk-http-httpx/tests/test_async_httpx_client.py index 6bb3d92..1c3bb81 100644 --- a/packages/dexpace-sdk-http-httpx/tests/test_async_httpx_client.py +++ b/packages/dexpace-sdk-http-httpx/tests/test_async_httpx_client.py @@ -11,6 +11,7 @@ import pytest from dexpace.sdk.core.errors import ( + SdkError, ServiceRequestError, ServiceRequestTimeoutError, ServiceResponseTimeoutError, @@ -358,23 +359,107 @@ def handler(request: httpx.Request) -> httpx.Response: assert await response.body.bytes() == payload -async def test_async_invalid_status_closes_response_and_raises() -> None: - """A genuinely invalid status releases the response and raises.""" - from dexpace.sdk.core.errors import ServiceResponseError +@pytest.mark.req("TRANSPORT-24") +async def test_async_out_of_range_status_surfaces_with_readable_body() -> None: + """TRANSPORT-24: an out-of-range status surfaces intact with its body.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(999, content=b"hello") + + transport = httpx.MockTransport(handler) + async with AsyncHttpxHttpClient(transport=transport) as client: + request = Request(method=Method.GET, url=Url.parse("http://example.test/")) + response = await client.execute(request) + assert int(response.status) == 999 + assert response.body is not None + assert await response.body.bytes() == b"hello" + + +async def test_async_byo_client_second_close_is_a_noop() -> None: + """Closing an adapter over a BYO client twice never touches the client.""" + transport = httpx.MockTransport(_ok_handler(b"{}")) + shared = httpx.AsyncClient(transport=transport) + client = AsyncHttpxHttpClient(client=shared) + await client.aclose() + await client.aclose() # idempotent, non-blocking + assert not shared.is_closed + await shared.aclose() + + +async def test_async_byo_client_with_follow_redirects_true_is_overridden() -> None: + """A BYO client's own ``follow_redirects=True`` never leaks through. + + ``follow_redirects=False`` is pinned explicitly on every ``.send()`` + call, not just relied on as the client's construction-time default, so a + caller-supplied client built with ``follow_redirects=True`` still never + has its 3xx responses auto-followed by httpx itself — that decision + belongs solely to the SDK's own redirect policy. + """ + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(302, headers={"location": "http://example.test/moved"}) + + transport = httpx.MockTransport(handler) + shared = httpx.AsyncClient(transport=transport, follow_redirects=True) + async with AsyncHttpxHttpClient(client=shared) as client: + request = Request(method=Method.GET, url=Url.parse("http://example.test/")) + async with await client.execute(request) as response: + assert response.status == Status(302) + assert response.headers.get("location") == "http://example.test/moved" + assert calls == 1 + await shared.aclose() + + +async def test_async_owned_client_disables_trust_env() -> None: + """An adapter-constructed client never trusts the process environment. + + httpx's own ``trust_env`` implicitly reads ``HTTP_PROXY`` / + ``HTTPS_PROXY`` / ``NO_PROXY``; proxy routing must only ever happen + through the SDK's own opt-in ``ProxyOptions`` resolution, so a client + this adapter builds itself pins ``trust_env=False``. + """ + async with AsyncHttpxHttpClient() as client: + assert client._client.trust_env is False + + +# --------------------------------------------------------------------------- # +# Exception-mapping matrix: proves the check ORDER, not just the final type. +# +# ``ConnectTimeout`` / ``ReadTimeout`` / ``WriteTimeout`` / ``PoolTimeout`` are +# all ``httpx.TimeoutException`` subtypes; ``ConnectError`` / ``ReadError`` are +# ``httpx.NetworkError`` subtypes; ``RemoteProtocolError`` is a +# ``httpx.ProtocolError``. All of these are also ``httpx.TransportError``. If +# the broad ``except httpx.RequestError`` clause in ``execute`` ran BEFORE the +# specific timeout clauses, every timeout below would misclassify as +# ``ServiceRequestError`` regardless of which phase actually timed out — this +# matrix would catch that regression. +# --------------------------------------------------------------------------- # + +_ASYNC_EXCEPTION_MATRIX: tuple[tuple[type[httpx.RequestError], type[SdkError]], ...] = ( + (httpx.ConnectTimeout, ServiceRequestTimeoutError), + (httpx.ReadTimeout, ServiceResponseTimeoutError), + (httpx.WriteTimeout, ServiceRequestTimeoutError), + (httpx.PoolTimeout, ServiceRequestTimeoutError), + (httpx.ConnectError, ServiceRequestError), + (httpx.ReadError, ServiceRequestError), + (httpx.RemoteProtocolError, ServiceRequestError), +) - closed = {"yes": False} - class _TrackedResponse(httpx.Response): - async def aclose(self) -> None: - closed["yes"] = True - await super().aclose() +@pytest.mark.parametrize(("native_exc", "expected"), _ASYNC_EXCEPTION_MATRIX) +async def test_async_exception_mapping_matrix( + native_exc: type[httpx.RequestError], expected: type[SdkError] +) -> None: + """Every native transport error maps to its documented SDK type, in order.""" def handler(request: httpx.Request) -> httpx.Response: - return _TrackedResponse(999, content=b"") + raise native_exc("boom", request=request) transport = httpx.MockTransport(handler) async with AsyncHttpxHttpClient(transport=transport) as client: request = Request(method=Method.GET, url=Url.parse("http://example.test/")) - with pytest.raises(ServiceResponseError): + with pytest.raises(expected): await client.execute(request) - assert closed["yes"], "Response should be closed when status mapping fails" diff --git a/packages/dexpace-sdk-http-httpx/tests/test_httpx_client.py b/packages/dexpace-sdk-http-httpx/tests/test_httpx_client.py index 5c91651..8b0e339 100644 --- a/packages/dexpace-sdk-http-httpx/tests/test_httpx_client.py +++ b/packages/dexpace-sdk-http-httpx/tests/test_httpx_client.py @@ -11,6 +11,7 @@ import pytest from dexpace.sdk.core.errors import ( + SdkError, ServiceRequestError, ServiceRequestTimeoutError, ServiceResponseTimeoutError, @@ -312,26 +313,20 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.body.bytes() == payload -def test_invalid_status_closes_response_and_raises() -> None: - """A genuinely invalid status releases the response and raises.""" - from dexpace.sdk.core.errors import ServiceResponseError - - closed = {"yes": False} - - class _TrackedResponse(httpx.Response): - def close(self) -> None: - closed["yes"] = True - super().close() +@pytest.mark.req("TRANSPORT-24") +def test_out_of_range_status_surfaces_with_readable_body() -> None: + """TRANSPORT-24: an out-of-range status surfaces intact with its body.""" def handler(request: httpx.Request) -> httpx.Response: - return _TrackedResponse(999, content=b"") + return httpx.Response(999, content=b"hello") transport = httpx.MockTransport(handler) with HttpxHttpClient(transport=transport) as client: request = Request(method=Method.GET, url=Url.parse("http://example.test/")) - with pytest.raises(ServiceResponseError): - client.execute(request) - assert closed["yes"], "Response should be closed when status mapping fails" + response = client.execute(request) + assert int(response.status) == 999 + assert response.body is not None + assert response.body.bytes() == b"hello" def test_shared_client_is_not_closed_on_close() -> None: @@ -342,3 +337,95 @@ def test_shared_client_is_not_closed_on_close() -> None: client.close() assert not shared.is_closed shared.close() + + +@pytest.mark.req("TRANSPORT-16") +def test_byo_client_second_close_is_a_noop() -> None: + """Closing an adapter over a BYO client twice never touches the client.""" + transport = httpx.MockTransport(_ok_handler(b"{}")) + shared = httpx.Client(transport=transport) + client = HttpxHttpClient(client=shared) + client.close() + client.close() # idempotent, non-blocking + assert not shared.is_closed + shared.close() + + +@pytest.mark.req("TRANSPORT-1") +def test_byo_client_with_follow_redirects_true_is_overridden() -> None: + """A BYO client's own ``follow_redirects=True`` never leaks through. + + ``follow_redirects=False`` is pinned explicitly on every ``.send()`` + call, not just relied on as the client's construction-time default, so a + caller-supplied client built with ``follow_redirects=True`` still never + has its 3xx responses auto-followed by httpx itself — that decision + belongs solely to the SDK's own redirect policy. + """ + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(302, headers={"location": "http://example.test/moved"}) + + transport = httpx.MockTransport(handler) + shared = httpx.Client(transport=transport, follow_redirects=True) + with HttpxHttpClient(client=shared) as client: + request = Request(method=Method.GET, url=Url.parse("http://example.test/")) + with client.execute(request) as response: + assert response.status == Status(302) + assert response.headers.get("location") == "http://example.test/moved" + assert calls == 1 + shared.close() + + +def test_owned_client_disables_trust_env() -> None: + """An adapter-constructed client never trusts the process environment. + + httpx's own ``trust_env`` implicitly reads ``HTTP_PROXY`` / + ``HTTPS_PROXY`` / ``NO_PROXY``; proxy routing must only ever happen + through the SDK's own opt-in ``ProxyOptions`` resolution, so a client + this adapter builds itself pins ``trust_env=False``. + """ + with HttpxHttpClient() as client: + assert client._client.trust_env is False + + +# --------------------------------------------------------------------------- # +# Exception-mapping matrix: proves the check ORDER, not just the final type. +# +# ``ConnectTimeout`` / ``ReadTimeout`` / ``WriteTimeout`` / ``PoolTimeout`` are +# all ``httpx.TimeoutException`` subtypes; ``ConnectError`` / ``ReadError`` are +# ``httpx.NetworkError`` subtypes; ``RemoteProtocolError`` is a +# ``httpx.ProtocolError``. All of these are also ``httpx.TransportError``. If +# the broad ``except httpx.RequestError`` clause in ``execute`` ran BEFORE the +# specific timeout clauses, every timeout below would misclassify as +# ``ServiceRequestError`` regardless of which phase actually timed out — this +# matrix would catch that regression. +# --------------------------------------------------------------------------- # + +_EXCEPTION_MATRIX: tuple[tuple[type[httpx.RequestError], type[SdkError]], ...] = ( + (httpx.ConnectTimeout, ServiceRequestTimeoutError), + (httpx.ReadTimeout, ServiceResponseTimeoutError), + (httpx.WriteTimeout, ServiceRequestTimeoutError), + (httpx.PoolTimeout, ServiceRequestTimeoutError), + (httpx.ConnectError, ServiceRequestError), + (httpx.ReadError, ServiceRequestError), + (httpx.RemoteProtocolError, ServiceRequestError), +) + + +@pytest.mark.parametrize(("native_exc", "expected"), _EXCEPTION_MATRIX) +def test_exception_mapping_matrix( + native_exc: type[httpx.RequestError], expected: type[SdkError] +) -> None: + """Every native transport error maps to its documented SDK type, in order.""" + + def handler(request: httpx.Request) -> httpx.Response: + raise native_exc("boom", request=request) + + transport = httpx.MockTransport(handler) + with HttpxHttpClient(transport=transport) as client: + request = Request(method=Method.GET, url=Url.parse("http://example.test/")) + with pytest.raises(expected): + client.execute(request) diff --git a/packages/dexpace-sdk-http-httpx/tests/transport_conformance_harness.py b/packages/dexpace-sdk-http-httpx/tests/transport_conformance_harness.py new file mode 100644 index 0000000..74b346f --- /dev/null +++ b/packages/dexpace-sdk-http-httpx/tests/transport_conformance_harness.py @@ -0,0 +1,398 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Real-adapter conformance harness for the httpx transports. + +Implements the shared transport-adapter conformance battery's +``SyncTransportHarness`` / ``AsyncTransportHarness`` ABCs (from the installed +``dexpace.sdk.tck.harness`` module) against the REAL `HttpxHttpClient` / +`AsyncHttpxHttpClient` adapters — not a stand-in. + +The "native layer" faked here is a custom `httpx.BaseTransport` / +`httpx.AsyncBaseTransport` pair, plugged in through the same ``transport=`` +extension point the adapters already document as their test seam. Everything +above that boundary — request building, content-type resolution, framing, +per-call timeout, exception mapping, response adaptation, streaming, close — +runs as real `httpx` / adapter code; only the socket is faked out, exactly +where `httpx.MockTransport` would sit. + +This module intentionally lives in ``dexpace-sdk-http-httpx``'s own test tree +(not inside the TCK): the harness ABCs ship in the installed +``dexpace.sdk.tck`` package, so registering from here keeps this package's +certification self-contained — no shared file is edited, so parallel +certification work for the other transports never collides with this one. +""" + +from __future__ import annotations + +import asyncio +import sys +import threading +from collections import deque +from collections.abc import AsyncIterator, Iterator +from typing import TYPE_CHECKING, Final + +import httpx + +from dexpace.sdk.core.errors import RequestCancelledError +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.http.httpx import AsyncHttpxHttpClient, HttpxHttpClient +from dexpace.sdk.tck.harness import ( + AsyncTransportHarness, + ClosableNative, + DropLog, + Outcome, + OutcomeKind, + ResponseSpec, + SentRequest, + SyncTransportHarness, +) + +if TYPE_CHECKING: + from dexpace.sdk.core.client.async_http_client import AsyncHttpClient + from dexpace.sdk.core.client.http_client import HttpClient + +_DEFAULT_TIMEOUT: Final[float] = 30.0 + + +def _resolve_timeouts(options: dict[str, object]) -> tuple[float, float, float, float]: + """Resolve the harness's ``timeout`` option into four equal phase timeouts.""" + timeout = options.get("timeout") + if timeout is None: + return (_DEFAULT_TIMEOUT, _DEFAULT_TIMEOUT, _DEFAULT_TIMEOUT, _DEFAULT_TIMEOUT) + assert isinstance(timeout, int | float) + value = float(timeout) + return (value, value, value, value) + + +def _to_sdk_headers(httpx_headers: httpx.Headers) -> Headers: + """Convert httpx's own header set into the SDK's `Headers` value type.""" + return Headers(httpx_headers.multi_items()) + + +def _extract_timeout(request: httpx.Request) -> float | None: + """Return the effective per-call timeout httpx resolved for ``request``. + + By the time a transport's ``handle_request`` runs, ``httpx.Client.send`` + has already populated ``request.extensions["timeout"]`` — either from a + per-call override (``HttpxHttpClient`` passes ``request.timeout`` to + ``build_request``) or the client's own configured default. All four + phases are set equal by this adapter, so reading any one is + representative. + """ + extensions = request.extensions.get("timeout") + if not extensions: + return None + value = extensions.get("read") + return None if value is None else float(value) + + +def _response_header_pairs(spec: ResponseSpec) -> list[tuple[bytes, bytes]]: + """Encode response headers as wire-form (Latin-1) byte pairs. + + Real HTTP libraries decode header bytes as Latin-1 (obs-text tolerant) — + that is what lets a raw byte like ``0xE9`` survive as ``café``'s ``é``. + Passing plain ``str`` pairs to ``httpx.Headers`` instead would reject + anything outside ASCII, so headers are encoded here exactly as a real + transport would hand them to httpx off the wire. + """ + return [(name.encode("latin-1"), value.encode("latin-1")) for name, value in spec.headers] + + +def _raise_for_outcome(outcome: Outcome, request: httpx.Request) -> None: + """Raise the native failure a programmed non-``RESPONSE`` outcome models.""" + kind = outcome.kind + if kind is OutcomeKind.CONNECT_ERROR: + raise httpx.ConnectError("connect failed", request=request) + if kind is OutcomeKind.CONNECT_TIMEOUT: + raise httpx.ConnectTimeout("connect timed out", request=request) + if kind is OutcomeKind.READ_TIMEOUT: + raise httpx.ReadTimeout("read timed out", request=request) + if kind is OutcomeKind.CANCELLED: + # A synchronous httpx client has no native cancellation primitive of + # its own — cooperative cancellation is a caller-side concept. A + # custom transport (this adapter's documented BYO extension point) + # can already raise an SDK error directly and have it pass through + # unmapped (it matches none of ``execute``'s ``except httpx.*`` + # clauses), which is exactly what is simulated here. + raise RequestCancelledError("request cancelled") + if kind is OutcomeKind.PROTOCOL_ERROR: + raise httpx.RemoteProtocolError("protocol error", request=request) + + +def _build_httpx_response( + spec: ResponseSpec, + stream: httpx.SyncByteStream | httpx.AsyncByteStream, + request: httpx.Request, +) -> httpx.Response: + """Build the native httpx.Response the adapter will adapt.""" + extensions: dict[str, bytes] = {"http_version": spec.protocol.encode("ascii")} + if spec.reason is not None: + extensions["reason_phrase"] = spec.reason.encode("ascii") + return httpx.Response( + status_code=spec.status, # type: ignore[arg-type] # deliberately non-int for adaptation-failure tests + headers=_response_header_pairs(spec), + stream=stream, + request=request, + extensions=extensions, + ) + + +class _SyncFakeStream(httpx.SyncByteStream): + """Programmable native byte stream whose close the battery can observe.""" + + __slots__ = ("_spec", "closed") + + def __init__(self, spec: ResponseSpec) -> None: + self._spec = spec + self.closed = False + + def __iter__(self) -> Iterator[bytes]: + if self._spec.stream: + yield from self._spec.chunks + else: + yield self._spec.body + + def close(self) -> None: + """Release the native stream. Idempotent.""" + self.closed = True + + +class _AsyncFakeStream(httpx.AsyncByteStream): + """Async twin of `_SyncFakeStream`, with a blockable producer.""" + + __slots__ = ("_released", "_spec", "closed") + + def __init__(self, spec: ResponseSpec) -> None: + self._spec = spec + self.closed = False + self._released = asyncio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + if self._spec.stream: + for chunk in self._spec.chunks: + yield chunk + else: + yield self._spec.body + if self._spec.stream and self._spec.block_after_chunks: + # Model a producer that would block waiting for more data; only a + # close (early abandon / cancellation) unblocks it. + await self._released.wait() + + async def aclose(self) -> None: + """Release the native stream and unblock the producer. Idempotent.""" + self.closed = True + self._released.set() + + +class _HttpxFakeCore: + """Programmable state shared by a harness and its fake transport.""" + + def __init__(self) -> None: + self._queue: deque[Outcome] = deque() + self.sent: list[SentRequest] = [] + self.attempts = 0 + self.responses: list[ClosableNative] = [] + self._lock = threading.Lock() + + def program(self, outcome: Outcome) -> None: + """Queue one outcome for the next exchange.""" + with self._lock: + self._queue.append(outcome) + + def take(self) -> Outcome: + """Consume the next programmed outcome, counting the attempt.""" + with self._lock: + self.attempts += 1 + if not self._queue: + raise AssertionError("no transport outcome was programmed") + return self._queue.popleft() + + def record(self, request: httpx.Request) -> None: + """Record a request that reached the fake native transport.""" + sent = SentRequest( + method=request.method, + url=str(request.url), + headers=_to_sdk_headers(request.headers), + body=request.content, + timeout=_extract_timeout(request), + ) + with self._lock: + self.sent.append(sent) + + +class _HttpxFakeTransport(httpx.BaseTransport): + """Fake synchronous httpx transport driving the shared conformance battery. + + Sits exactly where a real ``httpx.HTTPTransport`` would: below + ``httpx.Client``'s request-building / redirect logic, above the socket. + """ + + def __init__(self, core: _HttpxFakeCore) -> None: + self._core = core + + def handle_request(self, request: httpx.Request) -> httpx.Response: + """Resolve the next programmed outcome for ``request``.""" + request.read() + self._core.record(request) + outcome = self._core.take() + if outcome.kind is not OutcomeKind.RESPONSE: + _raise_for_outcome(outcome, request) + assert outcome.response is not None + stream = _SyncFakeStream(outcome.response) + self._core.responses.append(stream) + return _build_httpx_response(outcome.response, stream, request) + + +class _HttpxFakeAsyncTransport(httpx.AsyncBaseTransport): + """Async twin of `_HttpxFakeTransport`.""" + + def __init__(self, core: _HttpxFakeCore) -> None: + self._core = core + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + """Resolve the next programmed outcome for ``request``.""" + await request.aread() + self._core.record(request) + outcome = self._core.take() + if outcome.kind is not OutcomeKind.RESPONSE: + _raise_for_outcome(outcome, request) + assert outcome.response is not None + stream = _AsyncFakeStream(outcome.response) + self._core.responses.append(stream) + return _build_httpx_response(outcome.response, stream, request) + + +class _ByoClientView: + """Exposes ``.closed`` for a BYO httpx client, matching `ClosableNative`.""" + + __slots__ = ("_client",) + + def __init__(self, client: httpx.Client | httpx.AsyncClient) -> None: + self._client = client + + @property + def closed(self) -> bool: + """Whether the underlying httpx client has been closed.""" + return self._client.is_closed + + +class _HttpxHarnessMixin: + """Shared programming / introspection for the httpx harnesses. + + Neither facet below exists in the real httpx adapters, so both + capabilities are turned off rather than faked: the adapters have no + pre-emptive outbound header-rejection mechanism (unlike the in-memory + reference fake), and no proxy-configuration surface at all yet. + """ + + supports_native_header_rejection = False + supports_proxy_introspection = False + #: Python 3.14 tightened ``email`` header-name validation, and httpx's own + #: cookie-jar extraction inside ``client.send()`` builds an + #: ``email.message.Message`` from the response headers (via + #: ``response.info()``). A malformed inbound header NAME — the exact input + #: this facet programs — therefore makes httpx raise ``ValueError`` from its + #: cookie machinery on 3.14+, BEFORE the adapter's response adaptation (and + #: its defensive per-header drop) ever runs. httpx genuinely cannot deliver a + #: malformed-name header to the adapter on 3.14+, so this facet is a real, + #: verified capability limitation of the httpx transport there — not a + #: weakened test. On 3.13 and earlier httpx tolerates the header and the + #: adapter's defensive drop is exercised in full, so the facet runs there. + supports_defensive_inbound_headers = sys.version_info < (3, 14) + min_timeout_granularity = 1e-6 + + def __init__(self) -> None: + self._core = _HttpxFakeCore() + self._byo: _ByoClientView | None = None + + def expect(self, outcome: Outcome) -> None: + """Program the next native exchange to resolve as ``outcome``.""" + self._core.program(outcome) + + def sent_requests(self) -> list[SentRequest]: + """Return, in order, every request the adapter handed to httpx.""" + return self._core.sent + + def attempt_count(self) -> int: + """Return how many native exchanges the adapter initiated.""" + return self._core.attempts + + def drop_log(self) -> DropLog: + """Return an (always-empty) drop log; this facet is unsupported.""" + return DropLog() + + def native_responses(self) -> list[ClosableNative]: + """Return the native streams produced so far (for close checks).""" + return self._core.responses + + def mark_native_rejected(self, *names: str) -> None: + """No-op: the httpx adapter has no native-rejection facet to model.""" + del names + + def byo_underlying(self) -> ClosableNative | None: + """Return the caller-supplied native client, if one was built.""" + return self._byo + + +class HttpxSyncHarness(_HttpxHarnessMixin, SyncTransportHarness): + """Synchronous harness backed by the real `HttpxHttpClient` adapter.""" + + def __init__(self) -> None: + super().__init__() + self._transport = _HttpxFakeTransport(self._core) + + def new_client(self, **options: object) -> HttpClient: + """Build an `HttpxHttpClient` over the fake transport.""" + connect, read, write, pool = _resolve_timeouts(options) + if bool(options.get("byo", False)): + httpx_client = httpx.Client( + transport=self._transport, follow_redirects=False, trust_env=False + ) + self._byo = _ByoClientView(httpx_client) + return HttpxHttpClient(client=httpx_client) + return HttpxHttpClient( + connect_timeout=connect, + read_timeout=read, + write_timeout=write, + pool_timeout=pool, + transport=self._transport, + ) + + def close_client(self, client: HttpClient) -> None: + """Close a sync adapter via its ``close`` surface.""" + assert isinstance(client, HttpxHttpClient) + client.close() + + +class HttpxAsyncHarness(_HttpxHarnessMixin, AsyncTransportHarness): + """Asynchronous harness backed by the real `AsyncHttpxHttpClient` adapter.""" + + def __init__(self) -> None: + super().__init__() + self._transport = _HttpxFakeAsyncTransport(self._core) + + def new_async_client(self, **options: object) -> AsyncHttpClient: + """Build an `AsyncHttpxHttpClient` over the fake transport.""" + connect, read, write, pool = _resolve_timeouts(options) + if bool(options.get("byo", False)): + httpx_client = httpx.AsyncClient( + transport=self._transport, follow_redirects=False, trust_env=False + ) + self._byo = _ByoClientView(httpx_client) + return AsyncHttpxHttpClient(client=httpx_client) + return AsyncHttpxHttpClient( + connect_timeout=connect, + read_timeout=read, + write_timeout=write, + pool_timeout=pool, + transport=self._transport, + ) + + async def aclose_client(self, client: AsyncHttpClient) -> None: + """Close an async adapter via its ``aclose`` surface.""" + assert isinstance(client, AsyncHttpxHttpClient) + await client.aclose() + + +__all__ = ["HttpxAsyncHarness", "HttpxSyncHarness"] diff --git a/packages/dexpace-sdk-http-requests/README.md b/packages/dexpace-sdk-http-requests/README.md index 0243df1..3b08d39 100644 --- a/packages/dexpace-sdk-http-requests/README.md +++ b/packages/dexpace-sdk-http-requests/README.md @@ -19,6 +19,17 @@ A session you pass in is borrowed: the client never closes it, so a pooled session shared with other components stays usable. Only a session the client created itself is closed on `close()`. +`requests` / `urllib3` ship two defaults that fight this SDK's own pipeline: +following redirects and retrying connect-phase failures. Every send passes +`allow_redirects=False` explicitly (the redirect policy owns following a 3xx, +not the transport), and a session this client creates for itself is mounted +with a zero-retry `HTTPAdapter` on both `http://` and `https://` (the retry +policy owns re-sending, not `urllib3`) and gets `trust_env=False`, so it never +implicitly picks up `HTTP_PROXY` / `HTTPS_PROXY` / `.netrc` from the +environment. A session you pass in keeps its own retry configuration and +`trust_env` exactly as you set them — the client only ever hardens a session +it created itself. + ## Installation ```bash @@ -49,12 +60,18 @@ with Pipeline(RequestsHttpClient(), policies=[]) as pipeline: ## Exception mapping -| `requests` exception | SDK exception | -|------------------------|--------------------------------| -| `ConnectTimeout` | `ServiceRequestTimeoutError` | -| `ReadTimeout` | `ServiceResponseTimeoutError` | -| `ConnectionError` | `ServiceRequestError` | -| `RequestException` | `ServiceRequestError` | +| Native exception | SDK exception | +|---------------------|--------------------------------| +| `ConnectTimeout` | `ServiceRequestTimeoutError` | +| `ReadTimeout` | `ServiceResponseTimeoutError` | +| `ConnectionError` | `ServiceRequestError` | +| `InterruptedError` | `RequestCancelledError` | +| `RequestException` | `ServiceRequestError` | + +`InterruptedError` is a plain Python built-in, not a `requests` type: it +surfaces when a blocking call is aborted by an external signal, and is mapped +to the SDK's terminal, non-retryable cancellation type rather than a +retryable network error. ## Thread safety diff --git a/packages/dexpace-sdk-http-requests/src/dexpace/sdk/http/requests/client.py b/packages/dexpace-sdk-http-requests/src/dexpace/sdk/http/requests/client.py index 20f42ec..f3f776f 100644 --- a/packages/dexpace-sdk-http-requests/src/dexpace/sdk/http/requests/client.py +++ b/packages/dexpace-sdk-http-requests/src/dexpace/sdk/http/requests/client.py @@ -28,28 +28,46 @@ - ``requests.ConnectTimeout`` -> `ServiceRequestTimeoutError` - ``requests.ReadTimeout`` -> `ServiceResponseTimeoutError` - ``requests.ConnectionError`` -> `ServiceRequestError` +- ``InterruptedError`` (a blocking call aborted by an external signal) -> + `RequestCancelledError` - ``requests.RequestException`` (catch-all) -> `ServiceRequestError` Failures that surface later, while the response body is being streamed, are classified as response-side errors (the request was already sent): a ``requests.Timeout`` -> `ServiceResponseTimeoutError` and any other -``requests.RequestException`` -> `ServiceResponseError`. +``requests.RequestException`` -> `ServiceResponseError`. A native response +that cannot be adapted (an unparseable status, most commonly) closes the raw +``requests`` response before raising `ServiceResponseError`, so a failed +adaptation never leaks the connection. + +Two dangerous ``requests`` / ``urllib3`` defaults are neutralised on any +session this adapter creates for itself: ``urllib3`` retries connect-phase +errors unless told otherwise, and ``requests.Session.trust_env`` implicitly +merges ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``.netrc`` from the environment. A +session the adapter creates is given a zero-retry ``HTTPAdapter`` on both +``http://`` and ``https://`` and ``trust_env=False``, matching the SDK's +opt-in-only proxy-resolution rule (`dexpace.sdk.core.util.proxy`). A +caller-supplied (BYO) session is never reconfigured this way — its own mounted +adapters and ``trust_env`` are left exactly as the caller set them. """ from __future__ import annotations +import logging from collections.abc import Iterator from types import TracebackType from typing import TYPE_CHECKING, Final, Self import requests from dexpace.sdk.core.errors import ( + RequestCancelledError, ServiceRequestError, ServiceRequestTimeoutError, ServiceResponseError, ServiceResponseTimeoutError, ) from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.media_type import MediaType from dexpace.sdk.core.http.common.protocol import Protocol from dexpace.sdk.core.http.request.request import Request from dexpace.sdk.core.http.response.response import Response @@ -59,9 +77,45 @@ if TYPE_CHECKING: from dexpace.sdk.core.http.request.request_body import RequestBody +_LOGGER: Final = logging.getLogger("dexpace.sdk.http.requests") + _DEFAULT_TIMEOUT: Final[float] = 30.0 _CHUNK_SIZE: Final[int] = 8192 + +class _AdaptationError(Exception): + """Raised internally when a native response cannot be adapted.""" + + +def _new_owned_session() -> requests.Session: + """Build a session this adapter owns, pinned to safe defaults. + + A session the adapter creates itself gets two explicit hardenings that a + caller-supplied (BYO) session is never forced into: + + - Zero transport-level retries on both schemes. ``urllib3`` retries + connect-phase failures by default; the SDK's own retry policy is the + only thing that may re-send a request, so the transport must never also + retry underneath it. + - ``trust_env=False``. ``requests`` otherwise reads ``HTTP_PROXY`` / + ``HTTPS_PROXY`` / ``.netrc`` from the environment implicitly, which + conflicts with the SDK's opt-in-only proxy-resolution rule. + + A BYO session keeps whatever the caller configured — including its own + mounted adapters and ``trust_env`` — untouched; mutating a session the + caller owns would be surprising and could break unrelated code sharing it. + + Returns: + A fresh, hardened ``requests.Session``. + """ + session = requests.Session() + session.trust_env = False + no_retry = requests.adapters.HTTPAdapter(max_retries=0) + session.mount("http://", no_retry) + session.mount("https://", no_retry) + return session + + # urllib3 reports the negotiated HTTP version as an int on the raw response # (``11`` == HTTP/1.1, ``10`` == HTTP/1.0, ``20`` == HTTP/2). Map the ones we # can name onto the core ``Protocol`` enum; anything else defaults to HTTP/1.1. @@ -104,41 +158,42 @@ def __init__( raise ValueError(f"timeout must be positive, got {timeout}") self.timeout = timeout self._owns_session = session is None - self._session = session if session is not None else requests.Session() + self._session = session if session is not None else _new_owned_session() self._closed = False def execute(self, request: Request) -> Response: """Send ``request`` and return the response. - An in-range HTTP status the server returns is preserved on the - response, even when it is not a named member of the registry; only a - status outside the valid 100-599 range is treated as a protocol error. + Any HTTP status the server returns is preserved on the response, in + or out of the registered 100-599 range and whether or not it is a + named member of the registry (TRANSPORT-24) — a vendor or unusual + status is never treated as a protocol error. Raises: ServiceRequestError: When the request cannot be dispatched (connection refused, DNS failure, generic transport error). ServiceRequestTimeoutError: On ``ConnectTimeout``. ServiceResponseTimeoutError: On ``ReadTimeout``. - ServiceResponseError: When the status code is outside the valid - HTTP range (100-599). + RequestCancelledError: On an ``InterruptedError`` (the blocking + call was aborted by an external signal). + ServiceResponseError: When a response was received but could not + be adapted (for example, an unparseable status). """ if self._closed: raise ServiceRequestError("RequestsHttpClient is closed") data = _body_payload(request.body) - headers = {name: ", ".join(values) for name, values in request.headers.items()} - if not (data is None or isinstance(data, _SizedBody)): - # Unknown-length streaming body: requests frames it with - # ``Transfer-Encoding: chunked``. Drop any caller-supplied - # ``Content-Length`` so the wire never carries both framing headers - # (RFC 9112 §6.1) over an un-chunk-framed body. - headers = {n: v for n, v in headers.items() if n.lower() != "content-length"} + headers = _prepare_headers(request, data) + # A per-call timeout on the ``Request`` overrides the client's default; + # neither is clamped further since ``requests``/``urllib3`` timeouts are + # plain floats with no meaningful minimum resolution to truncate to. + timeout = request.timeout if request.timeout is not None else self.timeout try: raw = self._session.request( method=str(request.method), url=request.url.wire_form(), headers=headers, data=data, - timeout=self.timeout, + timeout=timeout, stream=True, allow_redirects=False, ) @@ -148,9 +203,15 @@ def execute(self, request: Request) -> Response: raise ServiceResponseTimeoutError(str(err), error=err) from err except requests.ConnectionError as err: raise ServiceRequestError(str(err), error=err) from err + except InterruptedError as err: + raise RequestCancelledError(str(err), error=err) from err except requests.RequestException as err: raise ServiceRequestError(str(err), error=err) from err - return _build_response(request, raw) + try: + return _build_response(request, raw) + except _AdaptationError as err: + raw.close() + raise ServiceResponseError(str(err), error=err) from err def close(self) -> None: """Mark the client as closed; close the session only when owned.""" @@ -172,6 +233,34 @@ def __exit__( self.close() +def _prepare_headers(request: Request, data: _SizedBody | Iterator[bytes] | None) -> dict[str, str]: + """Resolve the outbound headers ``requests`` should send for ``request``. + + A caller-set ``Content-Type`` is authoritative; otherwise one is derived + from the body's own `MediaType`, so a JSON/form/multipart body gets the + right content-type without every caller having to set it by hand. An + unknown-length streaming body drops any caller-set ``Content-Length`` so + the wire never carries both framing headers (RFC 9112 §6.1) once + ``requests`` adds ``Transfer-Encoding: chunked``. + + Args: + request: The originating SDK request. + data: The payload already resolved by `_body_payload`. + + Returns: + The single-valued header mapping ``requests`` should send. + """ + resolved = request.headers + if resolved.get("content-type") is None and request.body is not None: + media = request.body.media_type() + if media is not None: + resolved = resolved.with_set("Content-Type", str(media)) + headers = {name: ", ".join(values) for name, values in resolved.items()} + if not (data is None or isinstance(data, _SizedBody)): + headers = {n: v for n, v in headers.items() if n.lower() != "content-length"} + return headers + + def _body_payload(body: RequestBody | None) -> _SizedBody | Iterator[bytes] | None: """Render ``body`` into the payload ``requests`` should send. @@ -230,17 +319,18 @@ def _build_response(request: Request, raw: requests.Response) -> Response: The constructed `Response`, with the body left unread. Raises: - ServiceResponseError: When the status code is outside the valid HTTP - range (100-599). The response handle is released first. + _AdaptationError: If the native status cannot be parsed as an integer. + The caller is responsible for closing ``raw`` on this path. """ - try: - status = Status(raw.status_code) - except ValueError as err: - raw.close() - raise ServiceResponseError(f"Invalid status code: {raw.status_code}", error=err) from err - headers = Headers(_header_pairs(raw)) + status = _parse_status(raw.status_code) + headers = _inbound_headers(raw) content_length = _content_length(headers) - body = ResponseBody.from_stream(_IterContentStream(raw), content_length=content_length) # type: ignore[arg-type] + media_type = _inbound_media_type(headers) + body = ResponseBody.from_stream( + _IterContentStream(raw), # type: ignore[arg-type] + media_type=media_type, + content_length=content_length, + ) reason: str | None = raw.reason if raw.reason else None return Response( request=request, @@ -252,6 +342,54 @@ def _build_response(request: Request, raw: requests.Response) -> Response: ) +def _parse_status(raw_status: object) -> Status: + """Parse the native status; a non-numeric token fails adaptation. + + Status is total over integers (TRANSPORT-24): an out-of-range code + (outside 100..599) synthesizes an ``UNKNOWN_`` member rather than + raising, so a vendor/unusual status still surfaces with its body. Only a + genuinely unparseable value (never produced by a real HTTP status line, + but reachable through a synthesized native response) fails adaptation. + + Args: + raw_status: The native ``status_code`` value. + + Returns: + The parsed `Status`. + + Raises: + _AdaptationError: If ``raw_status`` cannot be parsed as an integer. + """ + try: + return Status(int(raw_status)) # type: ignore[call-overload] + except (TypeError, ValueError) as err: + raise _AdaptationError(f"unparseable status: {raw_status!r}") from err + + +def _inbound_headers(raw: requests.Response) -> Headers: + """Build response headers defensively, dropping malformed lines. + + A malformed inbound header name (one the SDK's `Headers` token check + rejects) is dropped and logged rather than failing the whole response — + the native response has already been received, so one bad line should + never discard an otherwise-usable body. Legal-but-unusual obs-text bytes + are lenient here and survive unchanged. + + Args: + raw: The streamed ``requests`` response. + + Returns: + The parsed `Headers`, with any malformed lines dropped. + """ + headers = Headers() + for name, value in _header_pairs(raw): + try: + headers = headers.with_added(name, value) + except ValueError: + _LOGGER.warning("dropping malformed inbound response header: %r", name) + return headers + + def _header_pairs(raw: requests.Response) -> list[tuple[str, str]]: """Return response headers as ``(name, value)`` pairs preserving repeats. @@ -272,6 +410,24 @@ def _header_pairs(raw: requests.Response) -> list[tuple[str, str]]: return list(raw.headers.items()) +def _inbound_media_type(headers: Headers) -> MediaType | None: + """Parse the response content-type, downgrading a malformed value to ``None``. + + Args: + headers: The parsed response headers. + + Returns: + The parsed `MediaType`, or ``None`` when absent or unparseable. + """ + raw = headers.get("content-type") + if raw is None: + return None + try: + return MediaType.parse(raw) + except ValueError: + return None + + def _content_length(headers: Headers) -> int: """Return the framed body length, or ``-1`` when it is unknown or unusable. diff --git a/packages/dexpace-sdk-http-requests/tests/conftest.py b/packages/dexpace-sdk-http-requests/tests/conftest.py new file mode 100644 index 0000000..887b519 --- /dev/null +++ b/packages/dexpace-sdk-http-requests/tests/conftest.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Registers the real-``requests`` harness into the shared transport-adapter +conformance battery (the installed ``dexpace.sdk.tck`` package). + +The battery's registries (``SYNC_HARNESS_FACTORIES`` / ``ASYNC_HARNESS_FACTORIES``) +are plain module-level lists on ``dexpace.sdk.tck.harness`` — the same object +regardless of which package imports it, for the lifetime of one pytest process. +Appending this package's harness factory here is enough for every ``test_*`` +body in that shared battery to run against it too, with no change to the +battery itself. + +The harness ABCs resolve from the installed ``dexpace.sdk.tck`` distribution as +an ordinary import; this package's own ``requests_harness`` module is a bare +module in this test directory, so the ``sys.path`` insertion below makes it +importable regardless of pytest's import mode (``consider_namespace_packages`` +stops pytest from adding a plain test directory to ``sys.path`` on its own). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from dexpace.sdk.tck.harness import SYNC_HARNESS_FACTORIES # noqa: E402 +from requests_harness import RequestsSyncHarness # noqa: E402 + + +def _register(registry: list[tuple[str, type]], label: str, factory: type) -> None: + """Append ``(label, factory)`` to ``registry`` unless the label is present.""" + if not any(existing == label for existing, _ in registry): + registry.append((label, factory)) + + +_register(SYNC_HARNESS_FACTORIES, "requests", RequestsSyncHarness) diff --git a/packages/dexpace-sdk-http-requests/tests/requests_harness.py b/packages/dexpace-sdk-http-requests/tests/requests_harness.py new file mode 100644 index 0000000..964c8db --- /dev/null +++ b/packages/dexpace-sdk-http-requests/tests/requests_harness.py @@ -0,0 +1,363 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Real-transport harness certifying ``RequestsHttpClient`` against the shared +transport-adapter conformance battery (the installed ``dexpace.sdk.tck`` +package). + +Unlike the TCK's ``dexpace.sdk.tck.fake_transport`` (an in-memory reference +adapter over a fake native layer), this harness drives the REAL +``RequestsHttpClient`` over a REAL +``requests.Session``. The "native layer" the battery programs is realised by +mounting a custom ``requests.adapters.BaseAdapter`` on the session — the same, +already-established technique this package's own unit tests use +(``test_requests_client.py``'s ``_FixedStatusAdapter`` / ``_CapturingAdapter`` +/ ``_BodyFailureAdapter``) to control exactly what a ``requests.Session`` +hands back without touching a real socket. Everything ABOVE that seam — +``PreparedRequest`` construction, header merging, content-type/framing +resolution, redirect suppression, timeout plumbing, exception mapping, and +body/stream adaptation — is the real, production `RequestsHttpClient` code +path. + +Two facets genuinely have no analogue for this transport, and the harness +opts out of them honestly via capability flags rather than faking support: + +- ``supports_native_header_rejection``: ``requests`` does not reject any + well-formed outbound header at the transport layer, so there is nothing for + the adapter to defensively drop. +- ``supports_proxy_introspection``: this adapter does not (yet) expose a + ``proxy_url`` / proxy-capability surface. + +Cooperative cancellation (``OutcomeKind.CANCELLED``) has no native ``requests`` +exception type — a purely synchronous, blocking call has no in-band way to +signal "an external party aborted this." The harness realises it via a real, +importable stdlib signal for exactly that situation, ``InterruptedError`` (a +blocking syscall aborted by an external signal), which `RequestsHttpClient` +now maps to ``RequestCancelledError``. +""" + +from __future__ import annotations + +import threading +from collections import deque +from typing import TYPE_CHECKING, Final, cast + +import requests +import requests.adapters +from urllib3 import HTTPHeaderDict + +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.tck.harness import ( + ClosableNative, + DropLog, + Outcome, + OutcomeKind, + ResponseSpec, + SentRequest, + SyncTransportHarness, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Iterator + + from dexpace.sdk.core.client.http_client import HttpClient + +from dexpace.sdk.http.requests import RequestsHttpClient + +_DEFAULT_TIMEOUT: Final[float] = 30.0 + +#: Maps a ``ResponseSpec.protocol`` token onto urllib3's integer convention +#: (``raw.raw.version``), mirroring what a real urllib3 connection reports. +_PROTOCOL_VERSION: Final[dict[str, int]] = {"http/1.0": 10, "http/1.1": 11, "http/2": 20} + + +class _ClosedTracker: + """A tiny ``ClosableNative`` the harness flips on ``close()``.""" + + __slots__ = ("_closed",) + + def __init__(self) -> None: + self._closed = False + + def mark_closed(self) -> None: + """Record that the tracked native resource was closed.""" + self._closed = True + + @property + def closed(self) -> bool: + """Whether the tracked native resource has been closed.""" + return self._closed + + +class _RealNativeCore: + """Thread-safe programmable state shared by the harness and its adapter.""" + + def __init__(self) -> None: + self._queue: deque[Outcome] = deque() + self.sent: list[SentRequest] = [] + self.attempts = 0 + self.drop_log = DropLog() + self.responses: list[ClosableNative] = [] + self._lock = threading.Lock() + + def program(self, outcome: Outcome) -> None: + """Queue one outcome for the next exchange.""" + with self._lock: + self._queue.append(outcome) + + def take(self) -> Outcome: + """Consume the next programmed outcome, counting the attempt.""" + with self._lock: + self.attempts += 1 + if not self._queue: + raise AssertionError("no transport outcome was programmed") + return self._queue.popleft() + + def record_sent(self, sent: SentRequest) -> None: + """Record a request that reached the native layer.""" + with self._lock: + self.sent.append(sent) + + +def _header_value_str(value: object) -> str: + """Coerce a ``PreparedRequest.headers`` value to ``str``. + + ``requests`` occasionally stores a header value as ``bytes``; the SDK's + `Headers` model is string-only, so decode defensively. + + Args: + value: The raw header value. + + Returns: + The value as ``str``. + """ + return value.decode("latin-1") if isinstance(value, bytes) else str(value) + + +def _drain_prepared_body(body: object) -> bytes: + """Read a ``requests.PreparedRequest.body`` into bytes. + + Mirrors the pattern already proven in this package's own unit tests + (``_CapturingAdapter``): a sized body arrives as the caller's original + file-like / iterable object, not yet materialised, so the adapter reads it + itself. A failure here (a body whose production raises) propagates before + any outcome is taken or recorded, matching the production adapter's own + read-before-dispatch ordering. + + Args: + body: The prepared request body — ``None``, ``bytes``, ``str``, a + file-like object, or an iterable of ``bytes`` chunks. + + Returns: + The fully-drained payload as ``bytes``. + """ + if body is None: + return b"" + if isinstance(body, bytes): + return body + if isinstance(body, str): + return body.encode() + read = getattr(body, "read", None) + if callable(read): + data = read() + return data if isinstance(data, bytes) else str(data).encode() + return b"".join(cast("Iterable[bytes]", body)) + + +class _SyntheticRaw: + """A synthetic urllib3-shaped ``.raw`` body for a programmed ``requests.Response``. + + Exposes ``.stream()`` (preferred by ``Response.iter_content``), so a + buffered body is a single chunk and a streamed one is produced lazily, + optionally blocking after its programmed chunks until ``close()`` releases + it — proving the same close-cascade / abandoned-stream behaviour the + in-memory fake proves, but through a real ``requests.Response``. + """ + + def __init__(self, spec: ResponseSpec, on_close: Callable[[], None]) -> None: + self.headers = HTTPHeaderDict(spec.headers) + self.version = _PROTOCOL_VERSION.get(spec.protocol, 11) + source = spec.chunks if spec.stream else ((spec.body,) if spec.body else ()) + self._chunks: deque[bytes] = deque(source) + self._block_after_chunks = spec.block_after_chunks + self._released = threading.Event() + self._closed = False + self._on_close = on_close + + def stream(self, _amt: object = None, decode_content: object = None) -> Iterator[bytes]: + """Yield the programmed chunks, then optionally block until closed.""" + while self._chunks and not self._closed: + yield self._chunks.popleft() + if self._block_after_chunks and not self._closed: + self._released.wait() + + def release_conn(self) -> None: + """No-op: there is no real connection pool entry to release.""" + + def close(self) -> None: + """Release the synthetic raw body and unblock any waiting producer.""" + if self._closed: + return + self._closed = True + self._released.set() + self._on_close() + + +def _dispatch(outcome: Outcome, core: _RealNativeCore) -> requests.Response: + """Realise a programmed `Outcome` as a native ``requests`` exchange result. + + Args: + outcome: The programmed outcome for this exchange. + core: The harness's native core (native responses are tracked here). + + Returns: + A synthesized ``requests.Response`` for a ``RESPONSE`` outcome. + + Raises: + requests.exceptions.ConnectionError: For ``CONNECT_ERROR``. + requests.exceptions.ConnectTimeout: For ``CONNECT_TIMEOUT``. + requests.exceptions.ReadTimeout: For ``READ_TIMEOUT``. + InterruptedError: For ``CANCELLED`` (see module docstring). + requests.exceptions.RequestException: For ``PROTOCOL_ERROR``. + """ + if outcome.kind is OutcomeKind.CONNECT_ERROR: + raise requests.exceptions.ConnectionError("simulated connect error") + if outcome.kind is OutcomeKind.CONNECT_TIMEOUT: + raise requests.exceptions.ConnectTimeout("simulated connect timeout") + if outcome.kind is OutcomeKind.READ_TIMEOUT: + raise requests.exceptions.ReadTimeout("simulated read timeout") + if outcome.kind is OutcomeKind.CANCELLED: + raise InterruptedError("simulated cooperative cancellation") + if outcome.kind is OutcomeKind.PROTOCOL_ERROR: + raise requests.exceptions.RequestException("simulated protocol error") + assert outcome.response is not None + return _build_native_response(outcome.response, core) + + +def _build_native_response(spec: ResponseSpec, core: _RealNativeCore) -> requests.Response: + """Construct a real ``requests.Response`` carrying the programmed spec.""" + response = requests.Response() + response.status_code = spec.status # type: ignore[assignment] + response.reason = spec.reason if spec.reason is not None else "" + tracker = _ClosedTracker() + core.responses.append(tracker) + response.raw = _SyntheticRaw(spec, tracker.mark_closed) + return response + + +class _ProgrammableAdapter(requests.adapters.BaseAdapter): + """Mounted transport adapter realising the harness's programmed outcomes.""" + + def __init__(self, core: _RealNativeCore) -> None: + super().__init__() + self._core = core + + def send( # signature mirrors BaseAdapter.send + self, + request: requests.PreparedRequest, + stream: bool = False, + timeout: float | tuple[float | None, float | None] | None = None, + verify: bool | str = True, + cert: str | tuple[str, str] | None = None, + proxies: dict[str, str] | None = None, + ) -> requests.Response: + body_bytes = _drain_prepared_body(request.body) + effective_timeout = timeout if isinstance(timeout, int | float) else None + header_pairs = [(name, _header_value_str(value)) for name, value in request.headers.items()] + sent = SentRequest( + method=request.method or "", + url=request.url or "", + headers=Headers(header_pairs), + body=body_bytes, + timeout=effective_timeout, + ) + self._core.record_sent(sent) + outcome = self._core.take() + return _dispatch(outcome, self._core) + + def close(self) -> None: + """No-op: nothing real to release.""" + + +class RequestsSyncHarness(SyncTransportHarness): + """Certifies the real ``requests``-backed adapter against the sync battery.""" + + supports_byo = True + # requests does not reject any well-formed outbound header at the + # transport layer, so there is nothing for the adapter to defensively drop. + supports_native_header_rejection = False + supports_streaming = True + # This adapter does not (yet) expose a proxy_url / capability surface. + supports_proxy_introspection = False + min_timeout_granularity = 1e-6 + + def __init__(self) -> None: + self._core = _RealNativeCore() + self._byo: _ClosedTracker | None = None + + def expect(self, outcome: Outcome) -> None: + """Program the next native exchange to resolve as ``outcome``.""" + self._core.program(outcome) + + def sent_requests(self) -> list[SentRequest]: + """Return, in order, every request the adapter handed to ``requests``.""" + return self._core.sent + + def attempt_count(self) -> int: + """Return how many native exchanges the adapter initiated.""" + return self._core.attempts + + def drop_log(self) -> DropLog: + """Return the (always-empty) drop log; rejection is not simulated.""" + return self._core.drop_log + + def native_responses(self) -> list[ClosableNative]: + """Return the native response handles produced so far.""" + return self._core.responses + + def mark_native_rejected(self, *names: str) -> None: + """No-op: ``supports_native_header_rejection`` is ``False``.""" + + def byo_underlying(self) -> ClosableNative | None: + """Return the BYO session's close-tracker, if one was built.""" + return self._byo + + def new_client(self, **options: object) -> HttpClient: + """Build a real `RequestsHttpClient` over a session mounted to this harness.""" + timeout = options.get("timeout", _DEFAULT_TIMEOUT) + assert isinstance(timeout, int | float) + session = requests.Session() + adapter = _ProgrammableAdapter(self._core) + session.mount("http://", adapter) + session.mount("https://", adapter) + if bool(options.get("byo", False)): + self._byo = _wrap_close_tracking(session) + return RequestsHttpClient(timeout=float(timeout), session=session) + + def close_client(self, client: HttpClient) -> None: + """Close a sync adapter via its ``close`` surface.""" + assert isinstance(client, RequestsHttpClient) + client.close() + + +def _wrap_close_tracking(session: requests.Session) -> _ClosedTracker: + """Wrap ``session.close`` so the harness can observe whether it was closed. + + Args: + session: The BYO session to instrument. + + Returns: + A `_ClosedTracker` reflecting whether ``session.close()`` was called. + """ + tracker = _ClosedTracker() + original_close = session.close + + def _close() -> None: + tracker.mark_closed() + original_close() + + session.close = _close # type: ignore[method-assign] + return tracker + + +__all__ = ["RequestsSyncHarness"] diff --git a/packages/dexpace-sdk-http-requests/tests/test_requests_client.py b/packages/dexpace-sdk-http-requests/tests/test_requests_client.py index 7280478..1a0e283 100644 --- a/packages/dexpace-sdk-http-requests/tests/test_requests_client.py +++ b/packages/dexpace-sdk-http-requests/tests/test_requests_client.py @@ -17,11 +17,13 @@ import requests.structures from dexpace.sdk.core.errors import ( + RequestCancelledError, ServiceRequestError, ServiceResponseError, ServiceResponseTimeoutError, ) from dexpace.sdk.core.http.common import Headers, Url +from dexpace.sdk.core.http.common.media_type import MediaType from dexpace.sdk.core.http.common.protocol import Protocol from dexpace.sdk.core.http.request import Method, Request, RequestBody from dexpace.sdk.core.http.response import Status @@ -107,6 +109,9 @@ def handle(self) -> None: class _TestServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True + #: Request count, used by the redirect/flaky fixtures to prove exactly one + #: exchange reached the server; unused (stays 0) by the other fixtures. + hits: int = 0 @pytest.fixture @@ -280,16 +285,20 @@ def test_in_range_unregistered_status_is_preserved() -> None: assert response.body.bytes() == b"this is fine" -def test_invalid_status_releases_and_raises() -> None: - """An out-of-range status code is released and mapped to an error.""" +@pytest.mark.req("TRANSPORT-24") +def test_out_of_range_status_surfaces_with_readable_body() -> None: + """TRANSPORT-24: an out-of-range status code surfaces with its body.""" closed = {"yes": False} session = requests.Session() - session.mount("http://", _FixedStatusAdapter(999, b"", closed)) + session.mount("http://", _FixedStatusAdapter(999, b"hello", closed)) client = RequestsHttpClient(session=session) request = Request(method=Method.GET, url=Url.parse("http://example.test/")) - with client, pytest.raises(ServiceResponseError): - client.execute(request) - assert closed["yes"], "Response should be closed when status mapping fails" + with client: + response = client.execute(request) + # No error: the live response is preserved, not discarded. + assert int(response.status) == 999 + assert response.body is not None + assert response.body.bytes() == b"hello" class _BodyFailureAdapter(requests.adapters.BaseAdapter): @@ -415,6 +424,7 @@ def test_known_length_body_is_framed_not_chunked() -> None: assert captured["body"] == payload +@pytest.mark.req("TRANSPORT-28") def test_file_body_is_length_framed(tmp_path: pathlib.Path) -> None: """A FileRequestBody (replayable, known length) frames with Content-Length.""" path = tmp_path / "payload.bin" @@ -536,6 +546,7 @@ def chunks() -> Iterator[bytes]: assert has_length is expect_length and has_chunked is expect_chunked, f"{body!r}" +@pytest.mark.req("TRANSPORT-15") def test_shared_session_is_not_closed_on_close() -> None: """A caller-supplied Session is left open when the adapter closes.""" session = requests.Session() @@ -553,6 +564,7 @@ def _close() -> None: assert not calls["closed"], "shared session must not be closed by client.close()" +@pytest.mark.req("TRANSPORT-15") def test_owned_session_is_closed_on_close() -> None: """A session the client created is closed on the adapter's close.""" client = RequestsHttpClient() @@ -663,3 +675,262 @@ def test_content_length_propagated_without_encoding() -> None: body = response.body assert body is not None assert body.content_length() == 5 + + +def test_media_type_derived_from_content_type_header(echo_server: str) -> None: + """The response body's media type reflects a real Content-Type header.""" + client = RequestsHttpClient() + request = Request(method=Method.GET, url=Url.parse(f"{echo_server}/")) + with client: + response = client.execute(request) + body = response.body + assert body is not None + assert body.media_type() == MediaType.of("application", "json") + body.bytes() + + +# --------------------------------------------------------------------------- # +# Cooperative cancellation: InterruptedError -> RequestCancelledError. +# --------------------------------------------------------------------------- # + + +class _RaisingAdapter(requests.adapters.BaseAdapter): + """Adapter that raises a fixed exception from ``send``.""" + + def __init__(self, exc: Exception) -> None: + super().__init__() + self._exc = exc + + def send( # signature mirrors BaseAdapter.send + self, + request: requests.PreparedRequest, + stream: bool = False, + timeout: float | tuple[float | None, float | None] | None = None, + verify: bool | str = True, + cert: str | tuple[str, str] | None = None, + proxies: dict[str, str] | None = None, + ) -> requests.Response: + raise self._exc + + def close(self) -> None: # pragma: no cover - nothing to release + pass + + +@pytest.mark.req("TRANSPORT-3") +def test_interrupted_error_maps_to_request_cancelled_error() -> None: + """An ``InterruptedError`` (an aborted blocking call) is terminal, not retryable.""" + session = requests.Session() + session.mount("http://", _RaisingAdapter(InterruptedError("signal"))) + client = RequestsHttpClient(session=session) + request = Request(method=Method.GET, url=Url.parse("http://example.test/")) + with client, pytest.raises(RequestCancelledError) as excinfo: + client.execute(request) + err = excinfo.value + assert isinstance(err, OSError) + assert err.retryable is False + + +# --------------------------------------------------------------------------- # +# Per-call timeout override. +# --------------------------------------------------------------------------- # + + +class _TimeoutCapturingAdapter(requests.adapters.BaseAdapter): + """Adapter recording the effective ``timeout`` it was handed.""" + + def __init__(self, captured: dict[str, object]) -> None: + super().__init__() + self._captured = captured + + def send( # signature mirrors BaseAdapter.send + self, + request: requests.PreparedRequest, + stream: bool = False, + timeout: float | tuple[float | None, float | None] | None = None, + verify: bool | str = True, + cert: str | tuple[str, str] | None = None, + proxies: dict[str, str] | None = None, + ) -> requests.Response: + self._captured["timeout"] = timeout + response = requests.Response() + response.status_code = 200 + response.reason = "OK" + response.raw = io.BytesIO(b"") + return response + + def close(self) -> None: # pragma: no cover - nothing to release + pass + + +@pytest.mark.req("TRANSPORT-5") +def test_per_call_timeout_overrides_client_default() -> None: + """A ``Request.timeout`` overrides the client's constructor-level default.""" + captured: dict[str, object] = {} + session = requests.Session() + session.mount("http://", _TimeoutCapturingAdapter(captured)) + client = RequestsHttpClient(timeout=30.0, session=session) + request = Request(method=Method.GET, url=Url.parse("http://example.test/"), timeout=0.25) + with client: + client.execute(request) + assert captured["timeout"] == 0.25 + + +@pytest.mark.req("TRANSPORT-5") +def test_no_override_falls_back_to_client_default_timeout() -> None: + """Without a per-call override, the client's constructor-level default is used.""" + captured: dict[str, object] = {} + session = requests.Session() + session.mount("http://", _TimeoutCapturingAdapter(captured)) + client = RequestsHttpClient(timeout=7.5, session=session) + request = Request(method=Method.GET, url=Url.parse("http://example.test/")) + with client: + client.execute(request) + assert captured["timeout"] == 7.5 + + +# --------------------------------------------------------------------------- # +# trust_env / native-retry hardening matrix. +# +# ``requests`` defaults ``Session.trust_env=True`` (implicitly merging +# ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``.netrc`` from the environment) and its +# default ``HTTPAdapter`` may not be zero-retry depending on version/caller +# configuration. A session this client CREATES gets both hardened; a +# caller-supplied (BYO) session is left exactly as configured. +# --------------------------------------------------------------------------- # + + +def test_owned_session_has_trust_env_disabled() -> None: + """A session the client creates itself opts out of environment proxy trust.""" + client = RequestsHttpClient() + with client: + assert client._session.trust_env is False + + +@pytest.mark.parametrize("initial_trust_env", [True, False]) +def test_byo_session_trust_env_left_untouched(initial_trust_env: bool) -> None: + """A caller-supplied session's ``trust_env`` is never overridden.""" + session = requests.Session() + session.trust_env = initial_trust_env + client = RequestsHttpClient(session=session) + with client: + assert client._session.trust_env is initial_trust_env + + +@pytest.mark.req("TRANSPORT-2") +def test_owned_session_adapters_have_zero_retries() -> None: + """A session the client creates mounts zero-retry adapters on both schemes.""" + client = RequestsHttpClient() + with client: + for scheme in ("http://", "https://"): + adapter = client._session.get_adapter(f"{scheme}example.test/") + assert isinstance(adapter, requests.adapters.HTTPAdapter) + assert adapter.max_retries.total == 0 + + +def test_byo_session_adapters_left_untouched() -> None: + """A caller-supplied session's own mounted adapter is never replaced.""" + session = requests.Session() + dangerous = requests.adapters.HTTPAdapter(max_retries=5) + session.mount("http://", dangerous) + client = RequestsHttpClient(session=session) + with client: + adapter = client._session.get_adapter("http://example.test/") + assert adapter is dangerous + assert adapter.max_retries.total == 5 + + +# --------------------------------------------------------------------------- # +# Behavioral proof, against real servers: redirects and retries are off. +# --------------------------------------------------------------------------- # + + +class _RedirectHandler(socketserver.StreamRequestHandler): + """Handler that always replies 302 and counts how many requests it served.""" + + def handle(self) -> None: + while True: + line = self.rfile.readline() + if not line or line in (b"\r\n", b"\n"): + break + self.server.hits += 1 # type: ignore[attr-defined] + response = ( + b"HTTP/1.1 302 Found\r\n" + b"Location: http://127.0.0.1:1/should-not-be-followed\r\n" + b"Content-Length: 0\r\n" + b"Connection: close\r\n" + b"\r\n" + ) + self.wfile.write(response) + + +class _FlakyHandler(socketserver.BaseRequestHandler): + """Handler that accepts a connection, counts it, then resets it unanswered.""" + + def handle(self) -> None: + self.server.hits += 1 # type: ignore[attr-defined] + self.request.close() + + +@pytest.fixture +def redirect_server() -> Iterator[tuple[str, _TestServer]]: + """Start a real server that always 302s, tracking how many hits it served.""" + server = _TestServer(("127.0.0.1", 0), _RedirectHandler) + server.hits = 0 + port = int(server.server_address[1]) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}", server + finally: + server.shutdown() + server.server_close() + + +@pytest.fixture +def flaky_server() -> Iterator[tuple[str, _TestServer]]: + """Start a real server that resets every connection unanswered.""" + server = _TestServer(("127.0.0.1", 0), _FlakyHandler) + server.hits = 0 + port = int(server.server_address[1]) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}", server + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.req("TRANSPORT-1") +def test_native_redirects_are_disabled_behaviorally( + redirect_server: tuple[str, _TestServer], +) -> None: + """A real redirecting server proves ``allow_redirects=False`` end to end. + + If the adapter had followed the 302, it would also have tried to connect + to the unreachable ``Location`` and raised instead of returning cleanly — + and the real server would have logged a second hit for a followed + redirect that looped back to it. Neither happens. + """ + base_url, server = redirect_server + client = RequestsHttpClient(timeout=2.0) + request = Request(method=Method.GET, url=Url.parse(f"{base_url}/")) + with client: + response = client.execute(request) + assert response.status == Status(302) + assert response.headers.get("location") == "http://127.0.0.1:1/should-not-be-followed" + response.close() + assert server.hits == 1 + + +@pytest.mark.req("TRANSPORT-2") +def test_native_retries_are_disabled_behaviorally( + flaky_server: tuple[str, _TestServer], +) -> None: + """A real flaky/connection-reset server proves urllib3-level retries are off.""" + base_url, server = flaky_server + client = RequestsHttpClient(timeout=2.0) + request = Request(method=Method.GET, url=Url.parse(f"{base_url}/")) + with client, pytest.raises(ServiceRequestError): + client.execute(request) + assert server.hits == 1 diff --git a/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/asyncio_http_client.py b/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/asyncio_http_client.py index 8d24671..ae978fb 100644 --- a/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/asyncio_http_client.py +++ b/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/asyncio_http_client.py @@ -45,6 +45,7 @@ import asyncio import contextlib +import logging import ssl as _ssl from collections.abc import Mapping from types import TracebackType @@ -57,6 +58,7 @@ ServiceResponseTimeoutError, ) from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.media_type import MediaType from dexpace.sdk.core.http.common.protocol import Protocol from dexpace.sdk.core.http.common.url import Url from dexpace.sdk.core.http.request.request import Request @@ -67,6 +69,8 @@ if TYPE_CHECKING: from dexpace.sdk.core.http.request.request_body import RequestBody +_LOGGER: Final = logging.getLogger("dexpace.sdk.http.stdlib.asyncio") + _DEFAULT_TIMEOUT: Final[float] = 30.0 _CRLF: Final[bytes] = b"\r\n" _BODY_METHODS: Final[frozenset[str]] = frozenset({"POST", "PUT", "PATCH"}) @@ -108,13 +112,46 @@ def __init__( self._closed = False async def execute(self, request: Request) -> AsyncResponse: + """Send ``request`` and return the response. + + Exception-mapping matrix (native cause -> SDK type): + + - Connect-phase failure/timeout, mapped in `_open` -> `ServiceRequestError` + / `ServiceRequestTimeoutError`. The timeout check is by *type* + (``except TimeoutError``), explicit rather than an incidental + reliance on any platform aliasing. + - A ``TimeoutError`` reaching this method (connect already succeeded, + so the stall is read/write-phase) -> `ServiceResponseTimeoutError`. + - A truncated body (``IncompleteReadError``) or an over-limit header + line (``readline`` re-raising ``LimitOverrunError`` as + ``ValueError``) -> `ServiceResponseError`. + - A malformed status line, malformed header, or + ``Transfer-Encoding: chunked`` response (unsupported by this + transport) -> `ServiceResponseError`, raised directly by `_read`. + - Any other mid-exchange ``OSError`` (the request may already be on + the wire) -> `ServiceResponseError`, not the request-error bucket + the httpx/aiohttp/requests adapters use, so a non-idempotent + request already possibly received is never blindly retried. + - ``asyncio.CancelledError`` is a `BaseException`, never caught by + any clause here, and propagates natively — the connection is still + torn down via the ``finally`` clause below. + + Raises: + ServiceRequestError: When the connection cannot be established. + ServiceRequestTimeoutError: When the connect phase times out. + ServiceResponseError: When the response cannot be read or + parsed, or another mid-exchange transport error occurs. + ServiceResponseTimeoutError: When a read/write phase stalls + after the connection was established. + """ if self._closed: raise ServiceRequestError("AsyncioHttpClient is closed") + timeout = request.timeout if request.timeout is not None else self.timeout host, port, secure, path = _split_url(request.url) - reader, writer = await self._open(host, port, secure) + reader, writer = await self._open(host, port, secure, timeout) try: await self._send(writer, request, host, port, secure, path) - return await self._read(reader, request) + return await self._read(reader, request, timeout) except TimeoutError as err: # The connection was established (``_open`` already maps connect # timeouts), so a timeout here is a read/write-phase stall. @@ -164,6 +201,7 @@ async def _open( host: str, port: int, secure: bool, + timeout: float, ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: # ``http://`` must never attempt a TLS handshake, even when a caller # supplied an ``ssl_context`` — the ``secure`` guard wraps the whole @@ -172,7 +210,7 @@ async def _open( try: return await asyncio.wait_for( asyncio.open_connection(host, port, ssl=ssl_ctx), - timeout=self.timeout, + timeout=timeout, ) except TimeoutError as err: raise ServiceRequestTimeoutError( @@ -196,19 +234,26 @@ async def _send( # (file / stream reads); run it off the event loop so the loop is # not stalled while the payload is gathered. body_bytes = await asyncio.to_thread(_drain_body, request.body) - headers = Headers(request.headers.items()) + headers = _resolve_content_type(request) if "host" not in headers: # Preserve a caller-supplied Host (virtual hosting); only derive # it from the URL when the request did not set one. RFC 9112 §3.2 # requires the port for non-default ports. headers = headers.with_set("Host", _host_header(host, port, secure)) - if "content-length" not in headers: - if body_bytes: - headers = headers.with_set("Content-Length", str(len(body_bytes))) - elif str(request.method) in _BODY_METHODS: - # RFC 9110 §8.6 recommends an explicit ``Content-Length: 0`` - # for body-bearing methods sent without a payload. - headers = headers.with_set("Content-Length", "0") + # A caller-supplied Content-Length/Transfer-Encoding is never trusted: + # it is dropped and recomputed from the bytes actually drained above, + # since a stale value disagreeing with the real payload breaks HTTP + # framing (the peer reads short or hangs waiting for bytes that never + # arrive). This transport always fully buffers before send, so the + # exact length is always known — Transfer-Encoding: chunked is never + # emitted (see the module docstring's streaming-upload limitation). + headers = headers.without("Content-Length").without("Transfer-Encoding") + if body_bytes: + headers = headers.with_set("Content-Length", str(len(body_bytes))) + elif str(request.method) in _BODY_METHODS: + # RFC 9110 §8.6 recommends an explicit ``Content-Length: 0`` + # for body-bearing methods sent without a payload. + headers = headers.with_set("Content-Length", "0") headers = headers.with_set("Connection", "close") request_line = f"{request.method} {path or '/'} HTTP/1.1".encode() lines: list[bytes] = [request_line] @@ -223,8 +268,9 @@ async def _read( self, reader: asyncio.StreamReader, request: Request, + timeout: float, ) -> AsyncResponse: - status_line = await asyncio.wait_for(reader.readline(), timeout=self.timeout) + status_line = await asyncio.wait_for(reader.readline(), timeout=timeout) if not status_line: raise ServiceResponseError("Empty response from server") parts = status_line.decode("latin-1").rstrip("\r\n").split(" ", 2) @@ -233,18 +279,20 @@ async def _read( protocol = _PROTOCOL_BY_VERSION.get(parts[0], Protocol.HTTP_1_1) status = _parse_status(parts[1]) reason = (parts[2] or None) if len(parts) > 2 else None - headers = await self._read_headers(reader) - body_bytes = await self._read_body(reader, headers) + headers = await self._read_headers(reader, timeout) + body_bytes = await self._read_body(reader, headers, timeout) return AsyncResponse( request=request, protocol=protocol, status=status, headers=headers, reason=reason, - body=AsyncResponseBody.from_bytes(body_bytes), + body=AsyncResponseBody.from_bytes(body_bytes, media_type=_inbound_media_type(headers)), ) - async def _read_body(self, reader: asyncio.StreamReader, headers: Headers) -> bytes: + async def _read_body( + self, reader: asyncio.StreamReader, headers: Headers, timeout: float + ) -> bytes: """Read the response body, honouring the response's framing. ``Transfer-Encoding: chunked`` is rejected loudly (this reference @@ -255,6 +303,7 @@ async def _read_body(self, reader: asyncio.StreamReader, headers: Headers) -> by Args: reader: The connection's stream reader. headers: The parsed response headers. + timeout: The effective per-call timeout for this read. Returns: The raw response body bytes. @@ -269,24 +318,59 @@ async def _read_body(self, reader: asyncio.StreamReader, headers: Headers) -> by content_length = _content_length(headers) if content_length is None: # No Content-Length: connection-close framed. Read to EOF. - return await asyncio.wait_for(reader.read(), timeout=self.timeout) + return await asyncio.wait_for(reader.read(), timeout=timeout) return await asyncio.wait_for( reader.readexactly(content_length) if content_length > 0 else _empty(), - timeout=self.timeout, + timeout=timeout, ) - async def _read_headers(self, reader: asyncio.StreamReader) -> Headers: - entries: list[tuple[str, str]] = [] + async def _read_headers(self, reader: asyncio.StreamReader, timeout: float) -> Headers: + """Read response header lines, dropping malformed names defensively. + + A missing colon is a genuine framing failure (the line is not a + header at all) and aborts the exchange. A well-formed ``name: value`` + line whose name fails `Headers`' token validation (e.g. an embedded + space) is instead dropped and logged — a single malformed header must + never take down the whole response, and `Headers`' bulk constructor + raises on the first invalid entry rather than skipping it, so entries + are added one at a time here. + """ + headers = Headers() while True: - line = await asyncio.wait_for(reader.readline(), timeout=self.timeout) + line = await asyncio.wait_for(reader.readline(), timeout=timeout) if not line or line in (b"\r\n", b"\n"): break text = line.decode("latin-1").rstrip("\r\n") if ":" not in text: raise ServiceResponseError(f"Malformed header: {text!r}") name, _, value = text.partition(":") - entries.append((name.strip(), value.strip())) - return Headers(entries) + try: + headers = headers.with_added(name.strip(), value.strip()) + except ValueError: + _LOGGER.warning("dropping malformed inbound response header: %r", name.strip()) + return headers + + +def _resolve_content_type(request: Request) -> Headers: + """Keep a caller-set Content-Type; else derive one from the body.""" + headers = request.headers + if headers.get("Content-Type") is not None or request.body is None: + return headers + media = request.body.media_type() + if media is None: + return headers + return headers.with_set("Content-Type", str(media)) + + +def _inbound_media_type(headers: Headers) -> MediaType | None: + """Parse the response Content-Type, downgrading a malformed value to ``None``.""" + raw = headers.get("Content-Type") + if raw is None: + return None + try: + return MediaType.parse(raw) + except ValueError: + return None def _split_url(url: Url) -> tuple[str, int, bool, str]: @@ -316,12 +400,12 @@ def _host_header(host: str, port: int, secure: bool) -> str: def _parse_status(code: str) -> Status: - """Build a `Status` from the status-line code, preserving in-range codes. + """Build a `Status` from the status-line code. - ``Status`` synthesizes a member for any code in 100..599, so an - unregistered-but-valid code (for example ``218`` or ``599``) yields a - usable status carried through to the pipeline instead of being discarded. - Only a genuinely out-of-range code raises. + ``Status`` is total over integers (TRANSPORT-24): any numeric code, in or + out of the registered 100..599 band, yields a usable status carried + through to the pipeline rather than being discarded. Only a non-numeric + status-line token (a malformed status line) raises. Args: code: The numeric status token from the status line. @@ -330,12 +414,13 @@ def _parse_status(code: str) -> Status: The resolved (possibly synthesized) status. Raises: - ServiceResponseError: When ``code`` is outside the valid HTTP range. + ServiceResponseError: When ``code`` is not a valid integer token. """ try: - return Status(int(code)) + numeric = int(code) except ValueError as err: raise ServiceResponseError(f"Invalid status code: {code}", error=err) from err + return Status(numeric) def _drain_body(body: RequestBody) -> bytes: diff --git a/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/urllib_http_client.py b/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/urllib_http_client.py index c3be907..cf72037 100644 --- a/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/urllib_http_client.py +++ b/packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/urllib_http_client.py @@ -25,6 +25,24 @@ that legitimately repeat — notably ``Set-Cookie`` (not applicable on outbound) and, in proxy/forwarder scenarios, ``WWW-Authenticate``. Use a production transport if you need to emit repeated outbound headers. +- **A body with no caller- or body-derived Content-Type still gets one.** + When neither the caller nor ``RequestBody.media_type()`` supplies a + Content-Type, ``urllib.request`` itself inserts its own legacy default + (``application/x-www-form-urlencoded``, mirroring HTML form-submission + semantics) once the request reaches ``do_request_``. This adapter does not + suppress that fallback — doing so would mean bypassing + ``urllib.request.Request`` entirely. Supply a body with an explicit media + type (or set ``Content-Type`` on the request) to avoid it. +- **A malformed inbound header name can take later headers with it.** + ``http.client`` parses response headers via ``email.message.Message``, + whose own header parser treats a line that is not a valid ``name: value`` + token (e.g. a name containing a space) as the end of the header block — + every header line that follows is silently discarded along with it, before + this adapter's own (per-entry, defensive) header conversion ever sees them. + This adapter still drops the one malformed entry it *is* handed rather than + aborting the whole conversion, but it cannot recover headers ``http.client`` + itself already lost. Not reproducible by bypassing ``http.client`` without + abandoning ``urllib.request`` entirely, so it is documented here instead. Redirects are **not** followed by this transport. A custom opener disables ``urllib``'s built-in ``HTTPRedirectHandler`` so 3xx responses surface to @@ -40,8 +58,8 @@ from __future__ import annotations -import contextlib import http.client +import logging from collections.abc import Mapping from http.client import HTTPResponse from types import TracebackType @@ -63,6 +81,8 @@ from dexpace.sdk.core.http.response.response_body import ResponseBody from dexpace.sdk.core.http.response.status import Status +_LOGGER: Final = logging.getLogger("dexpace.sdk.http.stdlib.urllib") + _DEFAULT_TIMEOUT: Final[float] = 30.0 # ``http.client.HTTPResponse.version`` is an integer: ``10`` for HTTP/1.0 and @@ -140,19 +160,39 @@ def execute(self, request: Request) -> Response: transport layer); the pipeline's redirect policy decides whether to reissue. + Exception-mapping matrix (native cause -> SDK type): + + - Connection refused / DNS failure (``URLError`` wrapping a + non-timeout ``OSError``) -> `ServiceRequestError`. + - Connect-phase timeout (``URLError(reason=TimeoutError)``) -> + `ServiceRequestTimeoutError`. Discriminated from a bare + ``TimeoutError`` by *type* (``isinstance(err.reason, + TimeoutError)``), never by message text — this is deliberate and + explicit, not an incidental reliance on Python 3.10+ aliasing + ``socket.timeout`` to ``TimeoutError``. + - Read-phase timeout (a *bare* ``TimeoutError``, not wrapped in + ``URLError``) -> `ServiceResponseTimeoutError`. + - A garbled/unparseable response (``http.client.HTTPException``, + e.g. ``BadStatusLine`` — never wrapped in ``URLError`` by urllib) -> + `ServiceRequestError`. + - A read-phase failure on the response body itself (stall, ``os`` + truncation) is mapped by `_ReadMappingStream`, not here. + Raises: - ServiceRequestError: When the connection cannot be established. + ServiceRequestError: When the connection cannot be established, + or the response is too garbled to interpret at all. ServiceRequestTimeoutError: When the connection times out before the request is transmitted (connect phase). - ServiceResponseError: When reading the response fails. + ServiceResponseError: When reading the response body fails. ServiceResponseTimeoutError: When the request was transmitted but the response read times out (read phase). """ if self._closed: raise ServiceRequestError("UrllibHttpClient is closed") + timeout = request.timeout if request.timeout is not None else self.timeout raw = _build_urllib_request(request) try: - opened = _OPENER.open(raw, timeout=self.timeout) + opened = _OPENER.open(raw, timeout=timeout) except HTTPError as err: # urllib raises HTTPError for 3xx (redirects are not followed) and # 4xx/5xx; surface them via the normal Response path so policies @@ -171,6 +211,15 @@ def execute(self, request: Request) -> Response: # stalled. Classify as a *response* timeout so non-idempotent # reads are not auto-retried as if they never reached the service. raise ServiceResponseTimeoutError(str(err), error=err) from err + except http.client.HTTPException as err: + # A garbled/unparseable response (bad status line, connection + # dropped before any status line arrived) is not wrapped in + # ``URLError`` by urllib — ``http.client`` raises its own + # exception hierarchy for these directly out of ``getresponse()``. + # The exchange never yielded an interpretable response, so this + # maps to the same retryable bucket as a connection failure + # rather than escaping as a raw stdlib exception. + raise ServiceRequestError(str(err), error=err) from err return _build_response(request, opened) def close(self) -> None: @@ -190,34 +239,65 @@ def __exit__( def _build_urllib_request(request: Request) -> _UrllibRequest: - body_bytes: bytes | None = None - if request.body is not None: - body_bytes = b"".join(request.body.iter_bytes()) + headers, body_bytes = _prepare(request) # ``urllib.request.Request`` accepts a ``Mapping[str, str]`` only, so # multi-value headers are joined into a single comma-separated string # rather than dropped. Safe for most list-typed headers (``Accept``, # ``Cache-Control``); wire-incorrect for headers that legitimately # repeat (``Set-Cookie`` — not applicable on outbound; ``WWW-Authenticate`` # in proxy/forwarder scenarios). See module docstring. - headers = {name: ", ".join(values) for name, values in request.headers.items()} + flattened = {name: ", ".join(values) for name, values in headers.items()} return _UrllibRequest( url=request.url.wire_form(), data=body_bytes, - headers=headers, + headers=flattened, method=str(request.method), ) +def _prepare(request: Request) -> tuple[Headers, bytes | None]: + """Resolve Content-Type and drop any stale framing headers. + + A caller-set ``Content-Type`` is authoritative; otherwise the body's own + ``media_type()`` is used when present. Any pre-existing ``Content-Length`` + / ``Transfer-Encoding`` is dropped rather than trusted — ``http.client`` + recomputes ``Content-Length`` itself from the exact bytes handed to it + (see ``HTTPConnection._get_content_length``), including the RFC 9110 + §8.6 zero-length default for body-bearing methods sent with no payload. + Trusting a caller-supplied value that disagrees with the real payload + would break HTTP framing: the peer reads short or hangs waiting for + bytes that never arrive. + + Args: + request: The request being adapted. + + Returns: + The resolved headers and the fully-drained body bytes (``None`` when + the request carries no body). + """ + headers = _resolve_content_type(request).without("Content-Length").without("Transfer-Encoding") + if request.body is None: + return headers, None + return headers, b"".join(request.body.iter_bytes()) + + +def _resolve_content_type(request: Request) -> Headers: + """Keep a caller-set Content-Type; else derive one from the body.""" + headers = request.headers + if headers.get("Content-Type") is not None or request.body is None: + return headers + media = request.body.media_type() + if media is None: + return headers + return headers.with_set("Content-Type", str(media)) + + def _build_response(request: Request, opened: object) -> Response: status_code: int = getattr(opened, "status", 200) - try: - status = Status(status_code) - except ValueError as err: - # Only a genuinely out-of-range code (outside 100..599) reaches here: - # ``Status`` synthesizes a member for any in-range code. Release the - # underlying response first so the connection is not leaked. - _close_quietly(opened) - raise ServiceResponseError(f"Invalid status code: {status_code}", error=err) from err + # ``Status`` is total over integers (TRANSPORT-24): a code outside the + # registered 100..599 band synthesizes an ``UNKNOWN_`` member rather + # than raising, so a vendor/unusual status still surfaces with its body. + status = Status(status_code) raw_headers = getattr(opened, "headers", None) headers = _convert_headers(raw_headers) content_length = _body_content_length(headers) @@ -277,28 +357,38 @@ def _body_content_length(headers: Headers) -> int: return -1 -def _close_quietly(opened: object) -> None: - """Close ``opened`` if it exposes ``close``, swallowing any error. - - Used on the failure path before raising so a partially constructed - response does not leak its underlying connection. +def _convert_headers(raw: object) -> Headers: + """Convert urllib's parsed response headers, dropping malformed names. + + ``http.client``/``email`` header parsing already tolerates obs-text in + values, but a name `Headers` rejects (e.g. one containing a space) must + still be dropped rather than aborting the whole conversion — entries are + added one at a time so a single bad name never takes down the good ones + that follow it, mirroring the defensive inbound-header handling every + adapter in this SDK provides. (A name malformed enough to confuse + ``http.client``'s own header parser may already have been dropped, or + have taken subsequent lines with it, upstream of this function — that is + a limitation of building on ``http.client``, not of this conversion.) Args: - opened: The urllib response (or ``HTTPError``) to release. - """ - close = getattr(opened, "close", None) - if callable(close): - with contextlib.suppress(Exception): - close() + raw: The native header container (an ``email.message.Message``-like + object), or ``None``. - -def _convert_headers(raw: object) -> Headers: + Returns: + The converted, lenient `Headers`. + """ if raw is None: return Headers() items_method = getattr(raw, "items", None) if items_method is None: return Headers() - return Headers(list(items_method())) + headers = Headers() + for name, value in items_method(): + try: + headers = headers.with_added(name, value) + except ValueError: + _LOGGER.warning("dropping malformed inbound response header: %r", name) + return headers class _ReadMappingStream: diff --git a/packages/dexpace-sdk-http-stdlib/tests/conftest.py b/packages/dexpace-sdk-http-stdlib/tests/conftest.py new file mode 100644 index 0000000..1572418 --- /dev/null +++ b/packages/dexpace-sdk-http-stdlib/tests/conftest.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Registers the real-stdlib harnesses into the shared transport-adapter +conformance battery (the installed ``dexpace.sdk.tck`` package). + +The battery's registries (``SYNC_HARNESS_FACTORIES`` / ``ASYNC_HARNESS_FACTORIES``) +are plain module-level lists on ``dexpace.sdk.tck.harness`` — the same object +regardless of which package imports it, for the lifetime of one pytest process. +Appending this package's harness factories here is enough for every ``test_*`` +body in that shared battery to run against the real ``UrllibHttpClient`` / +``AsyncioHttpClient`` too, with no change to the battery itself. + +The harness ABCs live in the installed ``dexpace.sdk.tck`` distribution, so +resolving them is an ordinary install-based import. This package's own +``stdlib_conformance_harness`` module, however, is a bare module in this test +directory; the ``sys.path`` insertion below makes it importable regardless of +pytest's import mode (``consider_namespace_packages`` stops pytest from adding a +plain test directory to ``sys.path`` on its own). Keeping the stdlib harness in +this package — rather than in the TCK — is what keeps the shared battery +transport-agnostic (it depends on no adapter package), matching the httpx / +requests / aiohttp adapters. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from dexpace.sdk.tck.harness import ( # noqa: E402 + ASYNC_HARNESS_FACTORIES, + SYNC_HARNESS_FACTORIES, +) +from stdlib_conformance_harness import StdlibAsyncHarness, StdlibSyncHarness # noqa: E402 + + +def _register(registry: list[tuple[str, type]], label: str, factory: type) -> None: + """Append ``(label, factory)`` to ``registry`` unless the label is present.""" + if not any(existing == label for existing, _ in registry): + registry.append((label, factory)) + + +_register(SYNC_HARNESS_FACTORIES, "stdlib-urllib", StdlibSyncHarness) +_register(ASYNC_HARNESS_FACTORIES, "stdlib-asyncio", StdlibAsyncHarness) diff --git a/packages/dexpace-sdk-http-stdlib/tests/stdlib_conformance_harness.py b/packages/dexpace-sdk-http-stdlib/tests/stdlib_conformance_harness.py new file mode 100644 index 0000000..3ef23b0 --- /dev/null +++ b/packages/dexpace-sdk-http-stdlib/tests/stdlib_conformance_harness.py @@ -0,0 +1,655 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Real-transport harness for `UrllibHttpClient` / `AsyncioHttpClient`. + +Unlike the TCK's `dexpace.sdk.tck.fake_transport` (an in-memory fake native +layer), this harness drives the REAL `dexpace-sdk-http-stdlib` adapters over a +REAL local TCP +server (`_MockServer`) speaking raw HTTP/1.1 — both stdlib transports refuse +to follow redirects and force ``Connection: close``, so one accepted +connection always corresponds to exactly one programmed `Outcome`. + +The seam between "what the battery programs" and "what the real adapter +does" is a thin per-transport wrapper (`_ObservableUrllibClient` / +`_ObservableAsyncioClient`) that: + +- claims the next programmed `Outcome` from a shared, lock-protected queue + (atomic, so concurrent callers never race over which outcome is theirs); +- for `CONNECT_ERROR` / `CONNECT_TIMEOUT` — facets the mock server cannot + produce, since they must never complete a TCP handshake — retargets the + request at a closed port / a blackhole address instead of the mock server, + reusing the exact techniques the existing per-adapter unit tests already + rely on; +- otherwise tags the request with a unique call-id header and retargets it + at the mock server, which looks the claimed outcome up by that id once it + accepts the connection; +- lets the real adapter run entirely unmodified — the wrapper never touches + its body-draining, framing, or error-mapping logic; +- recovers `sent_requests()` from what the mock server actually received off + the wire (the most faithful proof of "what reached the native layer"), + pairing it with the per-call timeout the wrapper resolved itself (the one + fact a wire capture can never observe, since a socket timeout is a local + option, not a wire artifact). + +Capability flags are set honestly for what a minimal, standard-library-only +transport can and cannot simulate — see each flag's assignment below and its +docstring on `_BaseHarness` for the verified reasoning. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import queue +import socket +import socketserver +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final, Self + +from dexpace.sdk.core.errors import ServiceRequestError +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.url import Url +from dexpace.sdk.core.http.request.request import Request +from dexpace.sdk.http.stdlib.asyncio_http_client import AsyncioHttpClient +from dexpace.sdk.http.stdlib.urllib_http_client import UrllibHttpClient +from dexpace.sdk.tck.harness import ( + AsyncTransportHarness, + ClosableNative, + DropLog, + Outcome, + OutcomeKind, + ResponseSpec, + SentRequest, + SyncTransportHarness, +) + +if TYPE_CHECKING: + from dexpace.sdk.core.client.async_http_client import AsyncHttpClient + from dexpace.sdk.core.client.http_client import HttpClient + from dexpace.sdk.core.http.response.async_response import AsyncResponse + from dexpace.sdk.core.http.response.response import Response + +_CALL_ID_HEADER: Final = "X-Dexpace-Harness-Call-Id" +#: Nothing listens here — a real, immediate connection refusal. +_CLOSED_HOST: Final = "127.0.0.1" +_CLOSED_PORT: Final = 1 +#: Non-routable (RFC 5737-adjacent, reserved by convention in this repo's own +#: existing per-adapter tests): the SYN is silently dropped, producing a +#: genuine connect-phase timeout rather than an immediate refusal. +_BLACKHOLE_HOST: Final = "10.255.255.1" +_BLACKHOLE_PORT: Final = 81 +_DEFAULT_TIMEOUT: Final = 30.0 +#: Effective timeout forced onto the two outcomes whose whole purpose is to +#: make the client's *own* timeout fire (CONNECT_TIMEOUT / READ_TIMEOUT). +#: Those battery tests assert only the raised error's TYPE, never a timeout +#: value or `sent.timeout`, so capping the wait keeps the certification +#: identical while turning a ~30 s stall (the default the battery configures) +#: into a sub-second one. Large enough that a real loopback connect/read never +#: races it on a loaded machine; small enough to keep the suite quick. +_TIMEOUT_PROBE_BUDGET: Final = 0.5 +#: How long a native-response tracker's `closed` property waits for the mock +#: server to observe the peer closing the connection before giving up. +_CLOSE_WAIT: Final = 2.0 +#: How long a READ_TIMEOUT / stalled connection is held open server-side — +#: comfortably larger than `_TIMEOUT_PROBE_BUDGET` so the client's own read +#: deadline always fires first, never this sleep. +_LONG_STALL: Final = 10.0 +_MIN_TIMEOUT_GRANULARITY: Final = 1e-6 + + +class _ConnectionTracker: + """Tracks whether a real accepted connection has since been closed. + + `closed` is a bounded wait rather than an instant read: closing a real + socket is asynchronous from the observer's point of view (the mock + server notices the peer's close via its own blocking read), so a plain + boolean read taken immediately after `response.close()` would race the + server's own detection. + """ + + __slots__ = ("_event",) + + def __init__(self) -> None: + self._event = threading.Event() + + def mark_closed(self) -> None: + """Record that the connection has closed. Idempotent.""" + self._event.set() + + @property + def closed(self) -> bool: + """Whether the connection closed within the bounded wait budget.""" + return self._event.wait(timeout=_CLOSE_WAIT) + + +@dataclass +class _Capture: + """What the mock server actually received for one call.""" + + headers: Headers = field(default_factory=Headers) + body: bytes = b"" + + +class _RealCore: + """Shared programming / introspection state for one harness instance. + + Owns the `_MockServer` and everything the battery inspects through the + harness seam: the outcome queue, sent-request log, attempt counter, and + native-response trackers. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._pending: queue.Queue[Outcome] = queue.Queue() + self._assigned: dict[str, Outcome] = {} + self.sent: list[SentRequest] = [] + self.attempts = 0 + self.drop_log = DropLog() + self.responses: list[ClosableNative] = [] + self.server = _MockServer(self) + + def program(self, outcome: Outcome) -> None: + """Queue one outcome for the next exchange.""" + self._pending.put(outcome) + + def claim_next(self) -> Outcome: + """Atomically dequeue the next programmed outcome for this call.""" + try: + return self._pending.get_nowait() + except queue.Empty: + raise AssertionError("no transport outcome was programmed") from None + + def assign(self, call_id: str, outcome: Outcome) -> None: + """Register ``outcome`` for the mock server to look up by ``call_id``.""" + with self._lock: + self._assigned[call_id] = outcome + + def take_assigned(self, call_id: str) -> Outcome | None: + """Pop the outcome assigned to ``call_id``, if any.""" + with self._lock: + return self._assigned.pop(call_id, None) + + def record_attempt(self) -> None: + """Count one native exchange attempt.""" + with self._lock: + self.attempts += 1 + + def record_sent(self, sent: SentRequest) -> None: + """Record a request the mock server actually received.""" + with self._lock: + self.sent.append(sent) + + def track_response(self, tracker: ClosableNative) -> None: + """Register a native-response tracker for a RESPONSE-kind outcome.""" + with self._lock: + self.responses.append(tracker) + + def shutdown(self) -> None: + """Best-effort server teardown; safe to call more than once.""" + self.server.shutdown() + + +def _retarget(url: Url, host: str, port: int) -> Url: + """Return ``url`` pointed at a different plaintext host:port, path kept.""" + return Url(scheme="http", host=host, port=port, path=url.path, query=url.query) + + +def _aimed_at(request: Request, host: str, port: int, timeout: float) -> Request: + """Retarget ``request`` at ``host:port`` and pin its per-call ``timeout``. + + Pinning ``timeout`` on the request itself is how the wrapper forces the + real adapter (built with a different constructor default) to use the + effective value the harness resolved — the adapter reads + ``request.timeout`` first. + """ + return dataclasses.replace(request, url=_retarget(request.url, host, port), timeout=timeout) + + +def _route(core: _RealCore, request: Request, default_timeout: float) -> tuple[Request, float]: + """Claim the next outcome and retarget ``request`` at the right peer. + + `CONNECT_ERROR` / `CONNECT_TIMEOUT` are dispatched straight at a closed + port / a blackhole address — the mock server must never see these, since + accepting the connection at all would contradict the outcome. Every + other kind is tagged with a fresh call-id and pointed at the mock server, + which resolves the tag back to the claimed outcome once it accepts the + connection. The two timeout-probe outcomes have their effective timeout + capped to `_TIMEOUT_PROBE_BUDGET` so the client's own deadline fires + quickly (see that constant's rationale). + + Returns: + The retargeted request and the effective timeout actually applied. + """ + timeout = request.timeout if request.timeout is not None else default_timeout + outcome = core.claim_next() + if outcome.kind is OutcomeKind.CONNECT_ERROR: + core.record_attempt() + return _aimed_at(request, _CLOSED_HOST, _CLOSED_PORT, timeout), timeout + if outcome.kind is OutcomeKind.CONNECT_TIMEOUT: + core.record_attempt() + budget = min(timeout, _TIMEOUT_PROBE_BUDGET) + return _aimed_at(request, _BLACKHOLE_HOST, _BLACKHOLE_PORT, budget), budget + budget = ( + min(timeout, _TIMEOUT_PROBE_BUDGET) if outcome.kind is OutcomeKind.READ_TIMEOUT else timeout + ) + call_id = uuid.uuid4().hex + core.assign(call_id, outcome) + retargeted = _aimed_at(request, "127.0.0.1", core.server.port, budget).with_header( + _CALL_ID_HEADER, call_id + ) + return retargeted, budget + + +def _finish(core: _RealCore, request: Request, call_id: str | None, timeout: float) -> None: + """Record `SentRequest` from the mock server's capture, if one exists. + + No capture exists when the exchange never reached the mock server at all + (a `CONNECT_ERROR` / `CONNECT_TIMEOUT` dispatch, or a body that failed to + drain before any connection was attempted) — matching the neutral + contract, which likewise never asserts on `sent_requests()` for those + scenarios. + """ + if call_id is None: + return + capture = core.server.take_capture(call_id) + if capture is None: + return + core.record_sent( + SentRequest( + method=str(request.method), + url=request.url.wire_form(), + headers=capture.headers, + body=capture.body, + timeout=timeout, + ) + ) + + +class _MockServer: + """Real TCP server driving programmed outcomes for either stdlib client. + + Speaks raw HTTP/1.1 over a socket, so either `UrllibHttpClient` or + `AsyncioHttpClient` can connect to it — the wire protocol is identical + regardless of which client library drives the request. Every accepted + connection serves exactly one claimed `Outcome`, matching both + transports' one-request-per-connection (`Connection: close`) behaviour. + """ + + def __init__(self, core: _RealCore) -> None: + self._core = core + self._captures: dict[str, _Capture] = {} + self._captures_lock = threading.Lock() + handler = _make_handler(self) + self._server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), handler) + self._server.daemon_threads = True + self._server.allow_reuse_address = True + self.port: int = self._server.server_address[1] + # A small poll interval keeps `shutdown()` snappy: `serve_forever` + # only checks the stop flag once per interval, so the stock 0.5 s + # default would add ~0.5 s to every harness teardown (one per test). + self._thread = threading.Thread( + target=self._server.serve_forever, + kwargs={"poll_interval": 0.01}, + daemon=True, + ) + self._thread.start() + + def take_capture(self, call_id: str) -> _Capture | None: + """Pop the captured request for ``call_id``, if the server saw one.""" + with self._captures_lock: + return self._captures.pop(call_id, None) + + def shutdown(self) -> None: + """Stop accepting new connections. Idempotent; safe under GC.""" + with contextlib.suppress(Exception): + self._server.shutdown() + self._server.server_close() + + def handle_connection(self, rfile: object, wfile: object, connection: socket.socket) -> None: + """Serve one accepted connection end to end.""" + call_id, headers, body = _read_request(rfile) # type: ignore[arg-type] + if call_id is None: + # No harness-issued call-id: nothing we programmed could route + # here deliberately. Drop the connection without a response. + return + with self._captures_lock: + self._captures[call_id] = _Capture(headers=headers, body=body) + outcome = self._core.take_assigned(call_id) + if outcome is None: + return + self._core.record_attempt() + if outcome.kind is OutcomeKind.RESPONSE: + assert outcome.response is not None + tracker = _ConnectionTracker() + self._core.track_response(tracker) + _write_response(wfile, outcome.response) # type: ignore[arg-type] + # Half-close: signals EOF to the peer's read (so a response with + # no Content-Length still frames correctly, purely via RFC 9112 + # §6.3's close-delimited rule) while keeping our own read side + # open, so the loop below can still observe the peer's own close. + # Never an HTTP-level "Connection: close" header — the neutral + # contract asserts on the exact header set a program specifies, + # and this framing choice must stay invisible at that layer. + with contextlib.suppress(OSError): + connection.shutdown(socket.SHUT_WR) + _watch_for_close(connection, tracker) + elif outcome.kind is OutcomeKind.READ_TIMEOUT: + time.sleep(_LONG_STALL) + else: + # PROTOCOL_ERROR and CANCELLED (the latter unreachable while + # `supports_cancellation` is False) both model a peer that speaks + # garbage instead of a valid response. + with contextlib.suppress(OSError): + wfile.write(b"NOT A VALID STATUS LINE\r\n\r\n") # type: ignore[attr-defined] + + +def _make_handler(server: _MockServer) -> type[socketserver.BaseRequestHandler]: + class _Handler(socketserver.StreamRequestHandler): + def handle(self) -> None: + server.handle_connection(self.rfile, self.wfile, self.connection) + + return _Handler + + +def _read_request(rfile: socket.SocketIO) -> tuple[str | None, Headers, bytes]: + """Parse one HTTP/1.1 request off the wire; return its call-id, headers, body.""" + rfile.readline() # request line: method/path are not needed server-side + call_id: str | None = None + headers = Headers() + content_length = 0 + while True: + line = rfile.readline() + if not line or line in (b"\r\n", b"\n"): + break + text = line.decode("latin-1").rstrip("\r\n") + name, _, value = text.partition(":") + name, value = name.strip(), value.strip() + if name.lower() == _CALL_ID_HEADER.lower(): + call_id = value + continue + headers = headers.with_added(name, value) + if name.lower() == "content-length": + with contextlib.suppress(ValueError): + content_length = int(value) + body = rfile.read(content_length) if content_length else b"" + return call_id, headers, body + + +def _write_response(wfile: socket.SocketIO, spec: ResponseSpec) -> None: + """Write a programmed `ResponseSpec` as a real HTTP/1.1 response. + + Writes ``spec.headers`` verbatim — never injecting a ``Content-Length`` + or ``Connection`` header of its own — so both stdlib clients read the + exact programmed body via the TCP half-close their caller applies right + after this returns (RFC 9112 §6.3's close-delimited framing), regardless + of what a test's own (possibly deliberately malformed) ``spec.headers`` + claims. + """ + reason = spec.reason if spec.reason is not None else "programmed" + status_token = str(spec.status) + lines: list[bytes] = [f"HTTP/1.1 {status_token} {reason}".encode("latin-1", "replace")] + for name, value in spec.headers: + lines.append(f"{name}: {value}".encode("latin-1", "replace")) + body = spec.body if not spec.stream else b"".join(spec.chunks) + lines.extend((b"", b"")) + with contextlib.suppress(OSError): + wfile.write(b"\r\n".join(lines) + body) + wfile.flush() + + +def _watch_for_close(connection: socket.socket, tracker: _ConnectionTracker) -> None: + """Mark the tracker closed only on an *observed* peer close (EOF). + + Crucially, the tracker is NOT marked on the `_CLOSE_WAIT` `recv` timeout: + if the adapter regressed and never released the connection, the wait must + expire with the tracker still open so the close-cascade battery tests can + actually fail. Marking unconditionally would make those assertions + non-falsifiable — they would pass even against an adapter that leaked the + socket. ``socket.timeout`` is an ``OSError``, so the suppress swallows it + and the function returns without marking. + """ + with contextlib.suppress(OSError): + connection.settimeout(_CLOSE_WAIT) + while True: + data = connection.recv(4096) + if not data: + tracker.mark_closed() + return + + +# --------------------------------------------------------------------------- # +# Sync wrapper + harness. +# --------------------------------------------------------------------------- # + + +class _ObservableUrllibClient: + """Wraps `UrllibHttpClient`, recording what reached the mock server.""" + + def __init__(self, real: UrllibHttpClient, core: _RealCore, default_timeout: float) -> None: + self._real = real + self._core = core + self._default_timeout = default_timeout + self._closed = False + + def execute(self, request: Request) -> Response: + """Route, delegate to the real adapter, then record what was sent. + + Checked before claiming an outcome: a closed client must raise + without requiring a test to have programmed anything, matching the + neutral contract's own closed-adapter tests, which never call + ``expect_*`` at all. + """ + if self._closed: + raise ServiceRequestError("client is closed") + retargeted, timeout = _route(self._core, request, self._default_timeout) + call_id = retargeted.headers.get(_CALL_ID_HEADER) + try: + return self._real.execute(retargeted) + finally: + _finish(self._core, request, call_id, timeout) + + def close(self) -> None: + """Close the wrapped adapter. Idempotent.""" + self._closed = True + self._real.close() + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc_info: object) -> None: + self.close() + + +class StdlibSyncHarness(SyncTransportHarness): + """Certifies `UrllibHttpClient` against the shared conformance battery.""" + + # No cancellation hook exists anywhere in `HttpClient.execute(request)` — + # `Request` carries no cancellation token, and cooperative cancellation is + # a pipeline/retry-loop concept in this SDK, never a transport one. + supports_cancellation = False + # This transport fully buffers every request body before send (see the + # module docstring's "no streaming uploads" limitation): the exact length + # is always known once buffering completes, so it always frames with + # Content-Length and never emits (or needs) chunked encoding. + supports_unknown_length_chunked_framing = False + # `http.client` rejects a non-numeric/out-of-range status line itself, + # before handing back any response object — and `Status` is total over + # integers (TRANSPORT-24), so no *valid* status can fail adaptation + # either. There is no reachable state where a native response exists yet + # adaptation fails. + supports_adaptation_failure = False + # `http.client` parses response headers via `email.message.Message`, + # whose own parser treats an invalid header name as the end of the + # header block, silently discarding every header line that follows — + # upstream of anything this adapter's own (per-entry, defensive) header + # conversion controls. Verified empirically; see the module docstring + # limitation this task's certification added to `urllib_http_client.py`. + supports_defensive_inbound_headers = False + # No BYO / proxy surface exists on `UrllibHttpClient` at all. + supports_byo = False + # No mechanism exists to make the adapter drop a header of the harness's + # choosing; nothing here can honestly claim to simulate this facet. + supports_native_header_rejection = False + supports_proxy_introspection = False + min_timeout_granularity = _MIN_TIMEOUT_GRANULARITY + + def __init__(self) -> None: + self._core = _RealCore() + + def expect(self, outcome: Outcome) -> None: + self._core.program(outcome) + + def sent_requests(self) -> list[SentRequest]: + return self._core.sent + + def attempt_count(self) -> int: + return self._core.attempts + + def drop_log(self) -> DropLog: + return self._core.drop_log + + def native_responses(self) -> list[ClosableNative]: + return self._core.responses + + def mark_native_rejected(self, *names: str) -> None: + # No native-rejection mechanism exists for this transport; recorded + # only so the (skipped, per `supports_native_header_rejection`) + # battery tests have somewhere inert to write. + for name in names: + self._core.drop_log.record(name) + + def byo_underlying(self) -> ClosableNative | None: + return None + + def new_client(self, **options: object) -> HttpClient: + timeout = options.get("timeout", _DEFAULT_TIMEOUT) + assert isinstance(timeout, int | float) + real = UrllibHttpClient(timeout=float(timeout)) + return _ObservableUrllibClient(real, self._core, float(timeout)) + + def close_client(self, client: HttpClient) -> None: + assert isinstance(client, _ObservableUrllibClient) + client.close() + + def shutdown(self) -> None: + self._core.shutdown() + + +# --------------------------------------------------------------------------- # +# Async wrapper + harness. +# --------------------------------------------------------------------------- # + + +class _ObservableAsyncioClient: + """Wraps `AsyncioHttpClient`, recording what reached the mock server.""" + + def __init__(self, real: AsyncioHttpClient, core: _RealCore, default_timeout: float) -> None: + self._real = real + self._core = core + self._default_timeout = default_timeout + self._closed = False + + async def execute(self, request: Request) -> AsyncResponse: + """Route, delegate to the real adapter, then record what was sent. + + Checked before claiming an outcome — see `_ObservableUrllibClient`. + The check runs inside the coroutine body, so a closed client still + surfaces the failure through the returned awaitable rather than + synchronously at call time. + """ + if self._closed: + raise ServiceRequestError("client is closed") + retargeted, timeout = _route(self._core, request, self._default_timeout) + call_id = retargeted.headers.get(_CALL_ID_HEADER) + try: + return await self._real.execute(retargeted) + finally: + _finish(self._core, request, call_id, timeout) + + async def aclose(self) -> None: + """Close the wrapped adapter. Idempotent.""" + self._closed = True + await self._real.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.aclose() + + +class StdlibAsyncHarness(AsyncTransportHarness): + """Certifies `AsyncioHttpClient` against the shared conformance battery.""" + + # See `StdlibSyncHarness` — no cancellation hook exists on the transport + # seam at all; kept honestly False even though no async battery test + # currently exercises `expect_cancellation()`. + supports_cancellation = False + supports_unknown_length_chunked_framing = False + # Unlike urllib/http.client, this transport parses the status line itself + # and rejects a non-digit token directly — a native response can + # genuinely fail SDK-level adaptation here (verified empirically). + supports_adaptation_failure = True + # This transport parses headers line-by-line itself (no `email` parser + # in the loop) and, once its own bulk-construction bug is fixed, recovers + # a good header on either side of a malformed one (verified empirically) + # — unlike the urllib/http.client pillar. + supports_defensive_inbound_headers = True + # This transport fully materialises the response body (`_read_body` + # reads it to completion) before `execute()` ever returns — there is no + # lazy/incremental read surface, so the streaming-dependent facets + # (close-cascade timing, cancellation-during-body-read) cannot be + # exercised. `asyncio.CancelledError` propagating natively through a + # stalled `execute()` call itself IS covered — see + # `test_cancelled_task_propagates_natively_and_closes_connection` in + # this package's own `test_asyncio_http_client.py`. + supports_streaming = False + supports_byo = False + supports_native_header_rejection = False + supports_proxy_introspection = False + min_timeout_granularity = _MIN_TIMEOUT_GRANULARITY + + def __init__(self) -> None: + self._core = _RealCore() + + def expect(self, outcome: Outcome) -> None: + self._core.program(outcome) + + def sent_requests(self) -> list[SentRequest]: + return self._core.sent + + def attempt_count(self) -> int: + return self._core.attempts + + def drop_log(self) -> DropLog: + return self._core.drop_log + + def native_responses(self) -> list[ClosableNative]: + return self._core.responses + + def mark_native_rejected(self, *names: str) -> None: + for name in names: + self._core.drop_log.record(name) + + def byo_underlying(self) -> ClosableNative | None: + return None + + def new_async_client(self, **options: object) -> AsyncHttpClient: + timeout = options.get("timeout", _DEFAULT_TIMEOUT) + assert isinstance(timeout, int | float) + real = AsyncioHttpClient(timeout=float(timeout)) + return _ObservableAsyncioClient(real, self._core, float(timeout)) + + async def aclose_client(self, client: AsyncHttpClient) -> None: + assert isinstance(client, _ObservableAsyncioClient) + await client.aclose() + + def shutdown(self) -> None: + self._core.shutdown() + + +__all__ = ["StdlibAsyncHarness", "StdlibSyncHarness"] diff --git a/packages/dexpace-sdk-http-stdlib/tests/test_asyncio_http_client.py b/packages/dexpace-sdk-http-stdlib/tests/test_asyncio_http_client.py index 8534017..4bc84cb 100644 --- a/packages/dexpace-sdk-http-stdlib/tests/test_asyncio_http_client.py +++ b/packages/dexpace-sdk-http-stdlib/tests/test_asyncio_http_client.py @@ -7,17 +7,20 @@ import asyncio import ssl +import time from collections.abc import AsyncIterator, Awaitable, Callable import pytest from dexpace.sdk.core.errors import ( ServiceRequestError, + ServiceRequestTimeoutError, ServiceResponseError, ServiceResponseTimeoutError, ) from dexpace.sdk.core.http.common import Url from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.media_type import MediaType from dexpace.sdk.core.http.common.protocol import Protocol from dexpace.sdk.core.http.request import Method, Request, RequestBody from dexpace.sdk.core.http.response import Status @@ -225,6 +228,7 @@ def _header_value(head: list[str], name: str) -> str | None: return None +@pytest.mark.req("TRANSPORT-11") async def test_host_header_includes_non_default_port() -> None: # A non-default port must appear in the Host header (RFC 9112 §3.2). sink: dict[str, list[str]] = {} @@ -240,6 +244,7 @@ async def test_host_header_includes_non_default_port() -> None: assert _header_value(sink["head"], "host") == f"127.0.0.1:{port}" +@pytest.mark.req("TRANSPORT-26") async def test_empty_post_body_sends_content_length_zero() -> None: # A body-bearing method with no payload must advertise # Content-Length: 0 (RFC 9110 §8.6). @@ -255,6 +260,7 @@ async def test_empty_post_body_sends_content_length_zero() -> None: assert _header_value(sink["head"], "content-length") == "0" +@pytest.mark.req("TRANSPORT-26") async def test_empty_get_omits_content_length() -> None: # A GET with no body must not gain a spurious Content-Length: 0. sink: dict[str, list[str]] = {} @@ -427,15 +433,38 @@ async def odd_status(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) await anext(gen) -async def test_invalid_status_raises_service_response_error() -> None: - # A genuinely out-of-range status (999) is invalid HTTP and must raise. - async def bad_status(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: +@pytest.mark.req("TRANSPORT-24") +async def test_out_of_range_status_surfaces_with_readable_body() -> None: + # TRANSPORT-24: an out-of-range status (999) is total over integers now — + # Status synthesizes a member rather than raising, so the response + # surfaces intact instead of being discarded. + async def weird_status(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: await _read_request_head(reader) writer.write(b"HTTP/1.1 999 Nope\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") await writer.drain() writer.close() - gen = _serve(bad_status) + gen = _serve(weird_status) + base = await anext(gen) + try: + async with AsyncioHttpClient(timeout=5.0) as client: + response = await client.execute(Request(method=Method.GET, url=Url.parse(f"{base}/"))) + assert int(response.status) == 999 + finally: + with pytest.raises(StopAsyncIteration): + await anext(gen) + + +async def test_non_numeric_status_token_raises_service_response_error() -> None: + # A malformed status line (non-numeric token) is still a real parse + # failure, distinct from Status's totality over valid integers. + async def malformed_status(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await _read_request_head(reader) + writer.write(b"HTTP/1.1 OK Nope\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + await writer.drain() + writer.close() + + gen = _serve(malformed_status) base = await anext(gen) try: async with AsyncioHttpClient(timeout=5.0) as client: @@ -463,3 +492,180 @@ async def http10(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> finally: with pytest.raises(StopAsyncIteration): await anext(gen) + + +async def test_content_type_derived_from_body_media_type() -> None: + # A body with a declared media type but no caller-set Content-Type must + # surface that type on the wire. + sink: dict[str, list[str]] = {} + gen = _serve(await _collect_head(sink)) + base = await anext(gen) + try: + async with AsyncioHttpClient(timeout=5.0) as client: + await client.execute( + Request( + method=Method.POST, + url=Url.parse(f"{base}/"), + body=RequestBody.from_bytes(b"{}", MediaType.of("application", "json")), + ) + ) + finally: + with pytest.raises(StopAsyncIteration): + await anext(gen) + assert _header_value(sink["head"], "content-type") == "application/json" + + +async def test_explicit_content_type_is_authoritative() -> None: + sink: dict[str, list[str]] = {} + gen = _serve(await _collect_head(sink)) + base = await anext(gen) + try: + async with AsyncioHttpClient(timeout=5.0) as client: + await client.execute( + Request( + method=Method.POST, + url=Url.parse(f"{base}/"), + headers=Headers([("Content-Type", "text/plain")]), + body=RequestBody.from_bytes(b"{}", MediaType.of("application", "json")), + ) + ) + finally: + with pytest.raises(StopAsyncIteration): + await anext(gen) + assert _header_value(sink["head"], "content-type") == "text/plain" + + +async def test_stale_content_length_header_is_recomputed() -> None: + # A caller-supplied Content-Length that disagrees with the real body must + # never reach the wire: trusting it would make the peer wait for bytes + # that never arrive. + sink: dict[str, list[str]] = {} + gen = _serve(await _collect_head(sink)) + base = await anext(gen) + try: + async with AsyncioHttpClient(timeout=5.0) as client: + await client.execute( + Request( + method=Method.POST, + url=Url.parse(f"{base}/"), + headers=Headers([("Content-Length", "999")]), + body=RequestBody.from_bytes(b"hello world"), + ) + ) + finally: + with pytest.raises(StopAsyncIteration): + await anext(gen) + assert _header_value(sink["head"], "content-length") == "11" + + +@pytest.mark.req("ASYNC-19") +async def test_per_call_timeout_overrides_client_default() -> None: + # A per-request timeout must reach the connect wait_for call, not just + # the client's own constructor default. Proven by racing a *short* + # per-call timeout against a blackhole address and a *long* constructor + # default: if the override were ignored, this would hang for the full + # 30s default instead of failing in well under a second. + start = time.monotonic() + async with AsyncioHttpClient(timeout=30.0) as client: + with pytest.raises(ServiceRequestTimeoutError): + await asyncio.wait_for( + client.execute( + Request( + method=Method.GET, + url=Url.parse("http://10.255.255.1:81/"), + timeout=0.3, + ) + ), + timeout=5.0, + ) + assert time.monotonic() - start < 5.0 + + +async def test_response_content_type_is_parsed() -> None: + # The response body must expose the declared Content-Type, not always + # None. + async def json_response(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await _read_request_head(reader) + body = b"{}" + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(body)).encode() + + b"\r\nConnection: close\r\n\r\n" + + body + ) + await writer.drain() + writer.close() + + gen = _serve(json_response) + base = await anext(gen) + try: + async with AsyncioHttpClient(timeout=5.0) as client: + response = await client.execute(Request(method=Method.GET, url=Url.parse(f"{base}/"))) + body = response.body + assert body is not None + assert body.media_type() == MediaType.of("application", "json") + finally: + with pytest.raises(StopAsyncIteration): + await anext(gen) + + +@pytest.mark.req("ASYNC-7", "TRANSPORT-7") +async def test_cancelled_task_propagates_natively_and_closes_connection() -> None: + # Cancelling the task running execute() must propagate asyncio.CancelledError + # unmodified (not swallowed, not converted to an SdkError) and must still + # tear down the underlying connection. + accepted = asyncio.Event() + + async def stall(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await _read_request_head(reader) + accepted.set() + await asyncio.sleep(30.0) + writer.close() + + gen = _serve(stall) + base = await anext(gen) + try: + client = AsyncioHttpClient(timeout=30.0) + task = asyncio.create_task( + client.execute(Request(method=Method.GET, url=Url.parse(f"{base}/"))) + ) + await accepted.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + with pytest.raises(StopAsyncIteration): + await anext(gen) + + +@pytest.mark.req("TRANSPORT-14") +async def test_malformed_inbound_header_dropped_others_preserved() -> None: + # A single malformed header name (one `Headers` rejects, e.g. containing + # a space) must be dropped without losing the good headers before *or* + # after it, and without aborting the exchange. `Headers`' bulk + # constructor raises on the first invalid entry rather than skipping it, + # so this exercises the per-entry lenient construction in + # `_read_headers`. + async def malformed(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await _read_request_head(reader) + body = b"hi" + writer.write( + b"HTTP/1.1 200 OK\r\nX-Good: ok\r\nBad Name: x\r\nX-After: yes\r\nContent-Length: " + + str(len(body)).encode() + + b"\r\nConnection: close\r\n\r\n" + + body + ) + await writer.drain() + writer.close() + + gen = _serve(malformed) + base = await anext(gen) + try: + async with AsyncioHttpClient(timeout=5.0) as client: + response = await client.execute(Request(method=Method.GET, url=Url.parse(f"{base}/"))) + assert response.headers.get("x-good") == "ok" + assert response.headers.get("x-after") == "yes" + assert "bad name" not in response.headers.names() + finally: + with pytest.raises(StopAsyncIteration): + await anext(gen) diff --git a/packages/dexpace-sdk-http-stdlib/tests/test_urllib_http_client.py b/packages/dexpace-sdk-http-stdlib/tests/test_urllib_http_client.py index db195ab..341c003 100644 --- a/packages/dexpace-sdk-http-stdlib/tests/test_urllib_http_client.py +++ b/packages/dexpace-sdk-http-stdlib/tests/test_urllib_http_client.py @@ -6,6 +6,7 @@ from __future__ import annotations import contextlib +import dataclasses import http.client as http_client import socketserver import threading @@ -21,6 +22,8 @@ ServiceResponseTimeoutError, ) from dexpace.sdk.core.http.common import Url +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.media_type import MediaType from dexpace.sdk.core.http.common.protocol import Protocol from dexpace.sdk.core.http.request import Method, Request, RequestBody from dexpace.sdk.core.http.response import Status @@ -157,6 +160,7 @@ def redirect_server() -> Iterator[str]: server.server_close() +@pytest.mark.req("TRANSPORT-1") def test_redirect_is_not_followed(redirect_server: str) -> None: # A 302 must surface to the pipeline as a 302 Response, not be # transparently followed by the transport (which would also leak the @@ -296,15 +300,16 @@ def test_in_range_unregistered_status_is_preserved_not_discarded() -> None: assert body.bytes() == b"hello" -def test_invalid_status_closes_response_before_raising() -> None: - # A genuinely out-of-range status code (outside 100..599) makes - # Status(code) raise. The underlying response must be released first so - # the connection is not leaked. +@pytest.mark.req("TRANSPORT-24") +def test_out_of_range_status_surfaces_with_readable_body() -> None: + # TRANSPORT-24: a genuinely out-of-range status code (outside 100..599) + # no longer maps to an error — Status is total over integers, so the + # response surfaces intact with its vendor code and body. opened = _TrackingResponse(999) request = Request(method=Method.GET, url=Url.parse("http://127.0.0.1/")) - with pytest.raises(ServiceResponseError): - _urllib_mod._build_response(request, opened) - assert opened.closed is True + response = _urllib_mod._build_response(request, opened) + assert int(response.status) == 999 + assert opened.closed is False def test_protocol_version_is_reported_from_http_response() -> None: @@ -353,3 +358,205 @@ def read(self, size: int = -1) -> bytes: assert body is not None with pytest.raises(ServiceResponseError): body.bytes() + + +@dataclasses.dataclass +class _Captured: + """What a `_CapturingHandler` actually received on the wire.""" + + request_line: str = "" + headers: list[tuple[str, str]] = dataclasses.field(default_factory=list) + body: bytes = b"" + + +class _CapturingHandler(socketserver.StreamRequestHandler): + """Records the request line, headers, and body it actually received.""" + + captured: _Captured = _Captured() + + def handle(self) -> None: + request_line = self.rfile.readline().decode("latin-1").rstrip("\r\n") + headers: list[tuple[str, str]] = [] + content_length = 0 + while True: + line = self.rfile.readline() + if not line or line in (b"\r\n", b"\n"): + break + text = line.decode("latin-1").rstrip("\r\n") + name, _, value = text.partition(":") + headers.append((name.strip(), value.strip())) + if name.strip().lower() == "content-length": + content_length = int(value.strip()) + body = self.rfile.read(content_length) if content_length else b"" + type(self).captured = _Captured(request_line=request_line, headers=headers, body=body) + payload = b'{"ok":true}' + self.wfile.write( + b"HTTP/1.1 200 OK\r\nContent-Length: " + + str(len(payload)).encode() + + b"\r\nConnection: close\r\n\r\n" + + payload + ) + + +@pytest.fixture +def capturing_server() -> Iterator[str]: + """Start a server that records the request it receives; yield its base URL.""" + _CapturingHandler.captured = _Captured() + server = _TestServer(("127.0.0.1", 0), _CapturingHandler) + port = int(server.server_address[1]) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}" + finally: + server.shutdown() + server.server_close() + + +def _sent_header(name: str) -> str | None: + for key, value in _CapturingHandler.captured.headers: + if key.lower() == name.lower(): + return value + return None + + +def test_content_type_derived_from_body_media_type(capturing_server: str) -> None: + # A body with a declared media type but no caller-set Content-Type must + # have that media type surface on the wire, not urllib's own + # application/x-www-form-urlencoded legacy default. + body = RequestBody.from_bytes(b"{}", MediaType.of("application", "json")) + request = Request(method=Method.POST, url=Url.parse(f"{capturing_server}/"), body=body) + with UrllibHttpClient(timeout=2.0) as client: + client.execute(request).close() + assert _sent_header("Content-Type") == "application/json" + + +@pytest.mark.req("TRANSPORT-10") +def test_explicit_content_type_is_authoritative(capturing_server: str) -> None: + body = RequestBody.from_bytes(b"{}", MediaType.of("application", "json")) + request = Request( + method=Method.POST, + url=Url.parse(f"{capturing_server}/"), + headers=Headers([("Content-Type", "text/plain")]), + body=body, + ) + with UrllibHttpClient(timeout=2.0) as client: + client.execute(request).close() + assert _sent_header("Content-Type") == "text/plain" + + +@pytest.mark.req("TRANSPORT-11") +def test_stale_content_length_header_is_recomputed(capturing_server: str) -> None: + # A caller-supplied Content-Length that disagrees with the real body must + # never reach the wire: trusting it would make the peer wait for bytes + # that never arrive (previously this caused a hang / read timeout). + request = Request( + method=Method.POST, + url=Url.parse(f"{capturing_server}/"), + headers=Headers([("Content-Length", "999")]), + body=RequestBody.from_bytes(b"hello world"), + ) + with UrllibHttpClient(timeout=2.0) as client: + response = client.execute(request) + response.close() + assert _sent_header("Content-Length") == "11" + assert _CapturingHandler.captured.body == b"hello world" + + +def test_per_call_timeout_overrides_client_default() -> None: + # A per-request timeout must reach urlopen, not just the client's own + # constructor default. Proven by racing a *short* per-call timeout + # against a blackhole address with a *long* constructor default: if the + # override were ignored, this would hang for the full 30s default + # instead of failing in well under it. + start = time.monotonic() + request = Request( + method=Method.GET, + url=Url.parse("http://10.255.255.1:81/"), + timeout=0.3, + ) + with UrllibHttpClient(timeout=30.0) as client, pytest.raises(ServiceRequestTimeoutError): + client.execute(request) + assert time.monotonic() - start < 5.0 + + +class _GarbledHandler(socketserver.StreamRequestHandler): + """Drains the request, then replies with a non-HTTP status line.""" + + def handle(self) -> None: + self.rfile.readline() + while True: + line = self.rfile.readline() + if not line or line in (b"\r\n", b"\n"): + break + self.wfile.write(b"NOT A VALID STATUS LINE\r\n\r\n") + + +@pytest.fixture +def garbled_server() -> Iterator[str]: + """Start a server that replies with a garbled, non-HTTP status line.""" + server = _TestServer(("127.0.0.1", 0), _GarbledHandler) + port = int(server.server_address[1]) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}" + finally: + server.shutdown() + server.server_close() + + +class _MalformedNameHandler(socketserver.StreamRequestHandler): + """Replies with a header name `http.client` keeps but `Headers` rejects. + + A name containing a space (``Bad Name``) is dropped by ``http.client``'s + own ``email``-based parser before this adapter ever sees it. To exercise + the adapter's *own* per-entry lenient conversion, the name must be one + ``http.client`` preserves (and keeps subsequent headers after) yet + ``Headers`` rejects as a non-token — ``X@Bad`` fits (``@`` is a + separator, not a ``tchar``). The adapter must drop just that entry and + keep the good headers on both sides of it. + """ + + def handle(self) -> None: + self.rfile.readline() + while True: + line = self.rfile.readline() + if not line or line in (b"\r\n", b"\n"): + break + body = b"hi" + self.wfile.write( + b"HTTP/1.1 200 OK\r\nX-Good: ok\r\nX@Bad: x\r\nX-After: yes\r\nContent-Length: " + + str(len(body)).encode() + + b"\r\nConnection: close\r\n\r\n" + + body + ) + + +@pytest.mark.req("TRANSPORT-14") +def test_malformed_response_header_dropped_others_preserved() -> None: + server = _TestServer(("127.0.0.1", 0), _MalformedNameHandler) + port = int(server.server_address[1]) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = UrllibHttpClient(timeout=2.0) + request = Request(method=Method.GET, url=Url.parse(f"http://127.0.0.1:{port}/")) + with client, client.execute(request) as response: + assert response.headers.get("x-good") == "ok" + assert response.headers.get("x-after") == "yes" + assert "x@bad" not in response.headers.names() + finally: + server.shutdown() + server.server_close() + + +def test_malformed_response_maps_to_service_request_error(garbled_server: str) -> None: + # A garbled/non-HTTP response is not wrapped in URLError by urllib — + # http.client raises its own exception hierarchy (e.g. BadStatusLine) + # directly out of getresponse(). Previously this escaped as a raw + # http.client.HTTPException instead of an SdkError. + client = UrllibHttpClient(timeout=2.0) + request = Request(method=Method.GET, url=Url.parse(f"{garbled_server}/")) + with pytest.raises(ServiceRequestError): + client.execute(request) diff --git a/packages/dexpace-sdk-tck/LICENSE.md b/packages/dexpace-sdk-tck/LICENSE.md new file mode 100644 index 0000000..1724c32 --- /dev/null +++ b/packages/dexpace-sdk-tck/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2026 dexpace and Omar Aljarrah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/dexpace-sdk-tck/README.md b/packages/dexpace-sdk-tck/README.md new file mode 100644 index 0000000..e092cd4 --- /dev/null +++ b/packages/dexpace-sdk-tck/README.md @@ -0,0 +1,66 @@ +# dexpace-sdk-tck + +The executable **conformance kit** (TCK) for +[`dexpace-sdk-core`](https://pypi.org/project/dexpace-sdk-core/) transport +adapters. It houses the shared, transport-**agnostic** conformance battery, the +golden wire-exactness byte corpus, and the adapter-certification seam, so any +transport adapter can be certified against one shared spec. + +This is a **test-only** distribution. Its runtime dependency is +`dexpace-sdk-core`; `pytest` / `hypothesis` are declared under the `test` extra +because they are how the kit is *run*, not what its library surface depends on. + +## What ships here + +- **Harness ABCs** (`dexpace.sdk.tck.harness`) — `SyncTransportHarness` / + `AsyncTransportHarness` plus the neutral programming/observation seam + (`Outcome`, `ResponseSpec`, `SentRequest`, `DropLog`, capability flags) and + the `SYNC_HARNESS_FACTORIES` / `ASYNC_HARNESS_FACTORIES` registries. A + transport adapter is certified by implementing one harness and appending its + factory to the registry. +- **In-memory reference** (`dexpace.sdk.tck.fake_transport`) — a reference + adapter over a fake native layer that satisfies the whole battery, so the + battery is a genuine contract rather than a tautology. +- **The battery** (`test_sync_contract` / `test_async_contract`) — every test + drives a parametrized harness and never a concrete transport. +- **The golden corpus** (`dexpace.sdk.tck.golden_corpus`) — checked-in raw byte + fixtures (URL/query codec, redirect `Location` resolution, raw-query + pagination splices, SSE streams, `Retry-After` pacing, RFC 1123 dates) plus a + loader that reads them via `importlib.resources` in binary mode (zip-safe), so + CR / LF / CRLF and percent-encoded bytes are never mangled. + +The kit depends only on core's **public re-exported surface**; it imports no +underscore-prefixed (private) core module. An in-package test +(`test_import_discipline`) enforces that invariant by AST-scanning the kit's own +import graph. + +## Certifying an adapter + +Each transport-adapter package registers its own harness from its +`tests/conftest.py`: + +```python +from dexpace.sdk.tck.harness import SYNC_HARNESS_FACTORIES + +from my_harness import MyTransportSyncHarness + +SYNC_HARNESS_FACTORIES.append(("my-transport", MyTransportSyncHarness)) +``` + +When the workspace test session collects core plus every adapter package, the +battery runs against the in-memory reference and all registered adapters in one +pass. The kit can also be invoked directly against whatever adapters have +registered: + +```bash +pytest --pyargs dexpace.sdk.tck +``` + +## Installation + +```bash +pip install "dexpace-sdk-tck[test]" +``` + +This pulls in `dexpace-sdk-core` plus the `pytest` / `hypothesis` needed to run +the battery. diff --git a/packages/dexpace-sdk-tck/pyproject.toml b/packages/dexpace-sdk-tck/pyproject.toml new file mode 100644 index 0000000..984a3bc --- /dev/null +++ b/packages/dexpace-sdk-tck/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "dexpace-sdk-tck" +version = "0.1.0" +description = "Executable conformance kit (TCK) for dexpace-sdk-core transport adapters: the shared, transport-agnostic conformance battery plus the golden byte corpus." +readme = "README.md" +requires-python = ">=3.12" +license = "MIT" +license-files = ["LICENSE.md"] +authors = [{ name = "Omar Aljarrah", email = "o.mazari.om63@gmail.com" }] +keywords = ["sdk", "http", "client", "conformance", "tck", "test-kit"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: Pytest", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Testing", + "Typing :: Typed", +] +# Runtime dependency is core only. pytest/hypothesis are how the kit is *run*, +# not what its library surface (the harness ABCs + corpus loader) depends on; +# they are declared as an optional extra so a consumer that only imports the +# harness ABCs or the corpus loader need not pull them in, while +# `pip install dexpace-sdk-tck[test]` provisions everything to execute the +# battery via `pytest --pyargs dexpace.sdk.tck`. +dependencies = [ + "dexpace-sdk-core>=0.1,<0.2", +] + +[project.optional-dependencies] +test = [ + "pytest>=9.1.0", + "pytest-asyncio>=1.4.0", + "hypothesis>=6.100", +] + +[project.urls] +Homepage = "https://github.com/dexpace/python-sdk" +Documentation = "https://github.com/dexpace/python-sdk/tree/main/docs" +Repository = "https://github.com/dexpace/python-sdk" +Issues = "https://github.com/dexpace/python-sdk/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/dexpace/sdk/tck"] +sources = ["src"] +# The golden byte corpus ships as package data so `importlib.resources` can +# read it from an installed wheel (zip-safe), never as a repo-relative path. +# Hatch already vendors every file under the package directory into the wheel; +# this explicit include keeps the non-.py fixture bytes (.query/.sse/.base/ +# .jsonl/... and the corpus .gitattributes) in the artifact even if a future +# default narrows to source files only. +artifacts = [ + "src/dexpace/sdk/tck/golden_corpus/data/**", +] + +[tool.hatch.build.targets.sdist] +include = ["src", "README.md"] diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/__init__.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/__init__.py new file mode 100644 index 0000000..6ea4c45 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/__init__.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Executable conformance kit (TCK) for dexpace-sdk-core transport adapters. + +The public surface is the certification seam: the two harness ABCs +(`SyncTransportHarness` / `AsyncTransportHarness`), the neutral programming and +observation types the battery speaks (`Outcome`, `OutcomeKind`, `ResponseSpec`, +`SentRequest`, `DropLog`, `ClosableNative`), the self-registration registries +(`SYNC_HARNESS_FACTORIES` / `ASYNC_HARNESS_FACTORIES`), and the in-memory +reference harnesses (`InMemorySyncHarness` / `InMemoryAsyncHarness`). + +A transport-adapter package certifies itself by implementing a harness against +these ABCs and appending its factory to the matching registry from its own +``tests/conftest.py``; the battery (``test_sync_contract`` / ``test_async_contract`` +shipped in this package) then runs against it unchanged. Invoke the battery +directly with ``pytest --pyargs dexpace.sdk.tck``. + +The golden wire-exactness byte corpus is a sibling subpackage, +`dexpace.sdk.tck.golden_corpus`; import its loaders from there. + +This package depends only on core's public re-exported surface and imports no +underscore-prefixed (private) core module — an invariant enforced by +``test_import_discipline`` in this package. +""" + +from __future__ import annotations + +from .fake_transport import ( + FakeAsyncHttpClient, + FakeSyncHttpClient, + InMemoryAsyncHarness, + InMemorySyncHarness, +) +from .harness import ( + ASYNC_HARNESS_FACTORIES, + SYNC_HARNESS_FACTORIES, + AsyncTransportHarness, + ClosableNative, + DropLog, + Outcome, + OutcomeKind, + ResponseSpec, + SentRequest, + SyncTransportHarness, +) + +__all__ = [ + "ASYNC_HARNESS_FACTORIES", + "SYNC_HARNESS_FACTORIES", + "AsyncTransportHarness", + "ClosableNative", + "DropLog", + "FakeAsyncHttpClient", + "FakeSyncHttpClient", + "InMemoryAsyncHarness", + "InMemorySyncHarness", + "Outcome", + "OutcomeKind", + "ResponseSpec", + "SentRequest", + "SyncTransportHarness", +] diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/conftest.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/conftest.py new file mode 100644 index 0000000..6f8a3ab --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/conftest.py @@ -0,0 +1,82 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Wiring for the transport-adapter conformance battery. + +Registers the in-memory reference harness into the shared registries and +parametrizes the ``sync_harness`` / ``async_harness`` fixtures over them at +collection time via ``pytest_generate_tests``. Reading the registries at +collection time (rather than at import time) is what makes the battery +genuinely parametrizable: each real transport-adapter package appends its own +harness factory to ``SYNC_HARNESS_FACTORIES`` / ``ASYNC_HARNESS_FACTORIES`` +from ITS OWN ``tests/conftest.py`` when that package's tests are collected, and +every ``test_*`` body runs against it unchanged. This TCK conftest depends on +NO adapter package — it registers only the in-memory reference — so the battery +stays transport-agnostic: certifying any adapter needs only that adapter's own +harness registration. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from .fake_transport import InMemoryAsyncHarness, InMemorySyncHarness +from .harness import ( + ASYNC_HARNESS_FACTORIES, + SYNC_HARNESS_FACTORIES, + AsyncTransportHarness, + SyncTransportHarness, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + +def _register(registry: list[tuple[str, type]], label: str, factory: type) -> None: + """Append ``(label, factory)`` to ``registry`` unless the label is present.""" + if not any(existing == label for existing, _ in registry): + registry.append((label, factory)) + + +_register(SYNC_HARNESS_FACTORIES, "in-memory", InMemorySyncHarness) +_register(ASYNC_HARNESS_FACTORIES, "in-memory", InMemoryAsyncHarness) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Parametrize the harness fixtures over the current registries.""" + if "sync_harness" in metafunc.fixturenames: + metafunc.parametrize( + "sync_harness", + [pytest.param(factory, id=label) for label, factory in SYNC_HARNESS_FACTORIES], + indirect=True, + ) + if "async_harness" in metafunc.fixturenames: + metafunc.parametrize( + "async_harness", + [pytest.param(factory, id=label) for label, factory in ASYNC_HARNESS_FACTORIES], + indirect=True, + ) + + +@pytest.fixture +def sync_harness(request: pytest.FixtureRequest) -> Iterator[SyncTransportHarness]: + """Yield a fresh synchronous harness, shutting it down on teardown.""" + harness = request.param() + assert isinstance(harness, SyncTransportHarness) + try: + yield harness + finally: + harness.shutdown() + + +@pytest.fixture +def async_harness(request: pytest.FixtureRequest) -> Iterator[AsyncTransportHarness]: + """Yield a fresh asynchronous harness, shutting it down on teardown.""" + harness = request.param() + assert isinstance(harness, AsyncTransportHarness) + try: + yield harness + finally: + harness.shutdown() diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/fake_transport.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/fake_transport.py new file mode 100644 index 0000000..8d1cddc --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/fake_transport.py @@ -0,0 +1,768 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""In-memory reference transport that satisfies the conformance battery. + +This is a *reference adapter* over a *fake native layer*, not a shortcut: it +implements the same behaviours a real adapter (httpx / requests / aiohttp / +stdlib) must — content-type resolution, adapter-computed framing, native-rejected +header dropping, defensive inbound-header handling, native-error mapping, +ownership-aware close, single-use body consumption, lazy streaming with a +close cascade, and per-call timeout clamping — so proving the battery green +against it proves the battery is a genuine contract, not a tautology. + +The fake native layer (`_NativeCore`, `_SyncNativeResponse`, +`_AsyncNativeResponse`) never follows a 3xx and never retries on its own: it +does exactly what it is programmed to do, once. That is the whole point of the +redirect / retry contract — the SDK's own policies own those behaviours, the +adapter is inert. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from collections import deque +from types import TracebackType +from typing import TYPE_CHECKING, Final, Self + +from dexpace.sdk.core.errors import ( + RequestCancelledError, + SdkError, + ServiceRequestError, + ServiceRequestTimeoutError, + ServiceResponseError, + ServiceResponseTimeoutError, +) +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.media_type import MediaType +from dexpace.sdk.core.http.common.protocol import Protocol +from dexpace.sdk.core.http.response.async_response import AsyncResponse +from dexpace.sdk.core.http.response.async_response_body import AsyncResponseBody +from dexpace.sdk.core.http.response.response import Response +from dexpace.sdk.core.http.response.response_body import ResponseBody +from dexpace.sdk.core.http.response.status import Status + +from .harness import ( + AsyncTransportHarness, + ClosableNative, + DropLog, + Outcome, + OutcomeKind, + ResponseSpec, + SentRequest, + SyncTransportHarness, +) + +if TYPE_CHECKING: + from dexpace.sdk.core.client.async_http_client import AsyncHttpClient + from dexpace.sdk.core.client.http_client import HttpClient + from dexpace.sdk.core.http.request.request import Request + +_LOGGER: Final = logging.getLogger("dexpace.sdk.tck.fake") + +_DEFAULT_TIMEOUT: Final[float] = 30.0 +#: Methods whose semantics call for an explicit zero-length body substitution +#: when the caller supplies no payload (RFC 9110 §8.6). +_ZERO_LENGTH_METHODS: Final[frozenset[str]] = frozenset({"POST", "PUT", "PATCH"}) +#: Proxy schemes this reference adapter can actually route through; anything +#: else is discoverable as unsupported rather than silently ignored. +_SUPPORTED_PROXY_SCHEMES: Final[frozenset[str]] = frozenset({"http", "https"}) + + +# --------------------------------------------------------------------------- # +# Fake native error hierarchy. +# +# The two timeout types deliberately subclass ``TimeoutError`` (an ``OSError``) +# to reproduce the real ``socket.timeout`` ambiguity: a naive handler that +# caught ``OSError`` (or a cancellation type, also an ``OSError``) before the +# timeout type would mislabel a timeout. ``_map_native_error`` therefore checks +# the timeout types FIRST, by type, never by message text. +# --------------------------------------------------------------------------- # + + +class _FakeNativeError(Exception): + """Root of the fake native-library error hierarchy.""" + + +class _NativeConnectError(_FakeNativeError): + """No response was ever produced (connection refused / DNS / reset).""" + + +class _NativeConnectTimeoutError(_FakeNativeError, TimeoutError): + """The connect phase timed out before any byte was sent.""" + + +class _NativeReadTimeoutError(_FakeNativeError, TimeoutError): + """The read phase timed out after the request was transmitted.""" + + +class _NativeCancelledError(_FakeNativeError): + """The in-flight exchange was cooperatively cancelled.""" + + +class _NativeProtocolError(_FakeNativeError): + """A generic transport error occurred mid-exchange.""" + + +_NATIVE_ERROR_BY_KIND: Final[dict[OutcomeKind, type[_FakeNativeError]]] = { + OutcomeKind.CONNECT_ERROR: _NativeConnectError, + OutcomeKind.CONNECT_TIMEOUT: _NativeConnectTimeoutError, + OutcomeKind.READ_TIMEOUT: _NativeReadTimeoutError, + OutcomeKind.CANCELLED: _NativeCancelledError, + OutcomeKind.PROTOCOL_ERROR: _NativeProtocolError, +} + + +def _map_native_error(err: _FakeNativeError) -> SdkError: + """Map a native error onto the SDK taxonomy, timeout-before-cancellation. + + Discrimination is by exception *type*, never message text, and the timeout + types are matched before the cancellation type — both timeouts and + ``RequestCancelledError`` are ``OSError`` subtypes, so an ordering that fell + through to a broad ``OSError`` clause first would misclassify a timeout. + + Args: + err: The fake native error raised for the exchange. + + Returns: + The corresponding SDK error to raise. + """ + if isinstance(err, _NativeConnectTimeoutError): + return ServiceRequestTimeoutError("connect timed out", error=err) + if isinstance(err, _NativeReadTimeoutError): + return ServiceResponseTimeoutError("read timed out", error=err) + if isinstance(err, _NativeCancelledError): + return RequestCancelledError("request cancelled", error=err) + if isinstance(err, _NativeConnectError): + return ServiceRequestError("connect failed", error=err) + return ServiceRequestError("transport error", error=err) + + +class _AdaptationError(Exception): + """Raised internally when a native response cannot be adapted.""" + + +# --------------------------------------------------------------------------- # +# Fake native responses (sync + async), each tracking its own close. +# --------------------------------------------------------------------------- # + + +class _SyncNativeResponse: + """A programmable native response whose close the battery can observe.""" + + __slots__ = ("_chunks", "closed", "spec") + + def __init__(self, spec: ResponseSpec) -> None: + self.spec = spec + self.closed = False + source = spec.chunks if spec.stream else (spec.body,) + self._chunks: deque[bytes] = deque(source) + + def next_chunk(self) -> bytes | None: + """Pop the next body chunk, or ``None`` once exhausted/closed.""" + if self.closed or not self._chunks: + return None + return self._chunks.popleft() + + def close(self) -> None: + """Release the native response. Idempotent.""" + self.closed = True + + +class _AsyncNativeResponse: + """Async twin of `_SyncNativeResponse`, with a blockable producer.""" + + __slots__ = ("_chunks", "_released", "closed", "spec") + + def __init__(self, spec: ResponseSpec) -> None: + self.spec = spec + self.closed = False + source = spec.chunks if spec.stream else (spec.body,) + self._chunks: deque[bytes] = deque(source) + self._released = asyncio.Event() + + async def next_chunk(self) -> bytes | None: + """Pop the next chunk, blocking after exhaustion when programmed to.""" + if self.closed: + return None + if self._chunks: + return self._chunks.popleft() + if self.spec.block_after_chunks: + # Model a producer that would block waiting for more data; only a + # close (early abandon) or a task cancellation unblocks it. + await self._released.wait() + return None + + async def close(self) -> None: + """Release the native response and unblock the producer. Idempotent.""" + self.closed = True + self._released.set() + + +class _SyncNativeReader: + """``BinaryIO``-shaped, buffered reader over a `_SyncNativeResponse`.""" + + __slots__ = ("_buf", "_done", "_native") + + def __init__(self, native: _SyncNativeResponse) -> None: + self._native = native + self._buf = bytearray() + self._done = False + + def read(self, size: int = -1) -> bytes: + """Read up to ``size`` bytes (all when ``size`` < 0); ``b""`` at EOF.""" + target = None if size < 0 else size + while (target is None or len(self._buf) < target) and not self._done: + chunk = self._native.next_chunk() + if chunk is None: + self._done = True + break + self._buf.extend(chunk) + if target is None: + out = bytes(self._buf) + self._buf.clear() + return out + take = min(target, len(self._buf)) + out = bytes(self._buf[:take]) + del self._buf[:take] + return out + + def close(self) -> None: + """Cascade the close through to the native response.""" + self._native.close() + + +class _AsyncNativeReader: + """``SupportsAsyncRead`` buffered reader over an `_AsyncNativeResponse`.""" + + __slots__ = ("_buf", "_done", "_native") + + def __init__(self, native: _AsyncNativeResponse) -> None: + self._native = native + self._buf = bytearray() + self._done = False + + async def read(self, size: int = -1) -> bytes: + """Read up to ``size`` bytes (all when ``size`` < 0); ``b""`` at EOF.""" + target = None if size < 0 else size + while (target is None or len(self._buf) < target) and not self._done: + chunk = await self._native.next_chunk() + if chunk is None: + self._done = True + break + self._buf.extend(chunk) + if target is None: + out = bytes(self._buf) + self._buf.clear() + return out + take = min(target, len(self._buf)) + out = bytes(self._buf[:take]) + del self._buf[:take] + return out + + async def close(self) -> None: + """Cascade the close through to the native response.""" + await self._native.close() + + +# --------------------------------------------------------------------------- # +# Proxy config + owned/BYO native client markers. +# --------------------------------------------------------------------------- # + + +class _ByoNativeClient: + """Stand-in for a caller-supplied underlying client; tracks open/closed.""" + + __slots__ = ("_open",) + + def __init__(self) -> None: + self._open = True + + def close(self) -> None: + """Close the underlying client. Idempotent.""" + self._open = False + + @property + def is_open(self) -> bool: + """Whether the underlying client is still usable.""" + return self._open + + @property + def closed(self) -> bool: + """Whether the underlying client has been closed.""" + return not self._open + + +class _ProxyConfig: + """Proxy settings, exposing capability introspection and credential safety.""" + + __slots__ = ("_password", "_scheme", "_user", "host", "requested_features") + + def __init__( + self, + url: str, + requested_features: frozenset[str] = frozenset(), + ) -> None: + scheme, _, rest = url.partition("://") + userinfo, _, host = rest.rpartition("@") + user, _, password = userinfo.partition(":") + self._scheme = scheme + self._user = user + self._password = password + self.host = host + self.requested_features = requested_features + + @property + def redacted_url(self) -> str: + """The proxy URL with any userinfo replaced by a redaction marker.""" + if self._user: + return f"{self._scheme}://[REDACTED]@{self.host}" + return f"{self._scheme}://{self.host}" + + def unsupported_features(self) -> frozenset[str]: + """Return requested proxy features this adapter cannot honour.""" + unsupported = set(self.requested_features) + if self._scheme not in _SUPPORTED_PROXY_SCHEMES: + unsupported.add(f"scheme:{self._scheme}") + return frozenset(unsupported) + + +# --------------------------------------------------------------------------- # +# The programmable native core, shared by an adapter and its harness. +# --------------------------------------------------------------------------- # + + +class _NativeCore: + """The programmable native layer shared by an adapter and its harness.""" + + def __init__(self) -> None: + self._queue: deque[Outcome] = deque() + self.sent: list[SentRequest] = [] + self.attempts = 0 + self.drop_log = DropLog() + self.native_rejected: set[str] = set() + self.responses: list[ClosableNative] = [] + self._lock = threading.Lock() + + def program(self, outcome: Outcome) -> None: + """Queue one outcome for the next exchange.""" + with self._lock: + self._queue.append(outcome) + + def take(self) -> Outcome: + """Consume the next programmed outcome, counting the attempt.""" + with self._lock: + self.attempts += 1 + if not self._queue: + raise AssertionError("no transport outcome was programmed") + return self._queue.popleft() + + def record_sent(self, sent: SentRequest) -> None: + """Record a request that reached the native layer.""" + with self._lock: + self.sent.append(sent) + + +def _clamp_timeout(timeout: float | None, granularity: float) -> float | None: + """Clamp a positive sub-resolution timeout up to the minimum granularity. + + A tiny positive timeout (e.g. ``0.0001``) must never be truncated to zero, + which would read as "no timeout"; it is raised to ``granularity`` instead. + + Args: + timeout: The requested per-call timeout, or ``None`` for no timeout. + granularity: The smallest positive timeout the adapter can apply. + + Returns: + The effective timeout: ``None`` passes through; a positive value is at + least ``granularity``. + """ + if timeout is None: + return None + return max(timeout, granularity) + + +class _AdapterBase: + """Shared preparation / adaptation logic for the sync and async adapters.""" + + __slots__ = ("_byo", "_closed", "_core", "_granularity", "_owns", "_proxy", "_timeout") + + def __init__( + self, + core: _NativeCore, + *, + timeout: float, + granularity: float, + byo: _ByoNativeClient | None, + proxy: _ProxyConfig | None, + ) -> None: + self._core = core + self._timeout = timeout + self._granularity = granularity + self._proxy = proxy + self._byo = byo if byo is not None else _ByoNativeClient() + self._owns = byo is None + self._closed = False + + def __repr__(self) -> str: + """Render the adapter without ever echoing proxy credentials.""" + proxy = self._proxy.redacted_url if self._proxy is not None else None + return f"{type(self).__name__}(timeout={self._timeout!r}, proxy={proxy!r})" + + def unsupported_proxy_features(self) -> frozenset[str]: + """Return requested proxy features the adapter cannot honour.""" + return self._proxy.unsupported_features() if self._proxy is not None else frozenset() + + def _prepare(self, request: Request) -> tuple[Headers, bytes]: + """Resolve content-type, compute framing, drop native-rejected headers.""" + headers = self._resolve_content_type(request) + headers, body = self._frame(headers, request) + headers = self._drop_rejected(headers) + return headers, body + + @staticmethod + def _resolve_content_type(request: Request) -> Headers: + """Keep a caller content-type; else derive one from the body.""" + headers = request.headers + if headers.get("content-type") is not None or request.body is None: + return headers + media = request.body.media_type() + if media is None: + return headers + return headers.with_set("Content-Type", str(media)) + + def _frame(self, headers: Headers, request: Request) -> tuple[Headers, bytes]: + """Compute framing headers from the actual body, never trusting caller values.""" + body = request.body + if body is None: + headers = headers.without("Content-Length").without("Transfer-Encoding") + if str(request.method) in _ZERO_LENGTH_METHODS: + headers = headers.with_set("Content-Length", "0") + return headers, b"" + # Draining consumes a single-use body exactly once. + payload = b"".join(body.iter_bytes()) + if body.content_length() >= 0: + headers = headers.without("Transfer-Encoding").with_set( + "Content-Length", str(len(payload)) + ) + else: + headers = headers.without("Content-Length").with_set("Transfer-Encoding", "chunked") + return headers, payload + + def _drop_rejected(self, headers: Headers) -> Headers: + """Drop headers the native library rejects; count and log each drop.""" + for name in headers.names(): + if name in self._core.native_rejected: + headers = headers.without(name) + self._core.drop_log.record(name) + _LOGGER.warning("dropping native-rejected request header: %s", name) + return headers + + def _effective_timeout(self, request: Request) -> float | None: + """Resolve the per-call timeout, clamped up from any sub-resolution value.""" + requested = request.timeout if request.timeout is not None else self._timeout + return _clamp_timeout(requested, self._granularity) + + def _record(self, request: Request, headers: Headers, body: bytes) -> None: + """Record what actually reached the native layer for this attempt.""" + self._core.record_sent( + SentRequest( + method=str(request.method), + url=request.url.wire_form(), + headers=headers, + body=body, + timeout=self._effective_timeout(request), + ) + ) + + @staticmethod + def _dispatch_error(outcome: Outcome) -> None: + """Raise the mapped SDK error for a non-response outcome; else return.""" + native_cls = _NATIVE_ERROR_BY_KIND.get(outcome.kind) + if native_cls is None: + return + raise _map_native_error(native_cls()) + + @staticmethod + def _parse_status(raw: int | str) -> Status: + """Parse the native status; a non-numeric token fails adaptation.""" + try: + return Status(int(raw)) + except (TypeError, ValueError) as err: + raise _AdaptationError(f"unparseable status: {raw!r}") from err + + @staticmethod + def _inbound_headers(spec: ResponseSpec) -> Headers: + """Build response headers, dropping malformed lines, preserving obs-text.""" + headers = Headers() + for name, value in spec.headers: + try: + headers = headers.with_added(name, value) + except ValueError: + # A malformed inbound header is dropped, never fatal to the whole + # response. Legal-but-unusual obs-text bytes are lenient here and + # survive unchanged. + _LOGGER.warning("dropping malformed inbound response header: %r", name) + return headers + + +def _inbound_content_length(headers: Headers) -> int: + """Resolve the response body length, downgrading a malformed value to ``-1``.""" + raw = headers.get("content-length") + if raw is None: + return -1 + try: + return max(0, int(raw)) + except ValueError: + return -1 + + +def _inbound_media_type(headers: Headers) -> MediaType | None: + """Parse the response content-type, downgrading a malformed value to ``None``.""" + raw = headers.get("content-type") + if raw is None: + return None + try: + return MediaType.parse(raw) + except ValueError: + return None + + +def _protocol_of(spec: ResponseSpec) -> Protocol: + """Map the native protocol token onto the SDK enum, defaulting to HTTP/1.1.""" + try: + return Protocol.parse(spec.protocol) + except ValueError: + return Protocol.HTTP_1_1 + + +class FakeSyncHttpClient(_AdapterBase): + """In-memory synchronous transport adapter under conformance test.""" + + def execute(self, request: Request) -> Response: + """Send ``request`` once and adapt the programmed native outcome.""" + if self._closed: + raise ServiceRequestError("FakeSyncHttpClient is closed") + headers, body = self._prepare(request) + self._record(request, headers, body) + outcome = self._core.take() + self._dispatch_error(outcome) + assert outcome.response is not None + native = _SyncNativeResponse(outcome.response) + self._core.responses.append(native) + return self._adapt(request, native) + + def _adapt(self, request: Request, native: _SyncNativeResponse) -> Response: + """Turn a native response into a `Response`; close native on failure.""" + try: + spec = native.spec + headers = self._inbound_headers(spec) + status = self._parse_status(spec.status) + reader = _SyncNativeReader(native) + body = ResponseBody.from_stream( + reader, # type: ignore[arg-type] # buffered read/close adapter + media_type=_inbound_media_type(headers), + content_length=_inbound_content_length(headers), + ) + return Response( + request=request, + protocol=_protocol_of(spec), + status=status, + headers=headers, + reason=spec.reason, + body=body, + ) + except _AdaptationError as err: + native.close() + raise ServiceResponseError(str(err), error=err) from err + + def close(self) -> None: + """Close the adapter; close the underlying client only when owned.""" + if self._closed: + return + self._closed = True + if self._owns: + self._byo.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + +class FakeAsyncHttpClient(_AdapterBase): + """In-memory asynchronous transport adapter under conformance test.""" + + async def execute(self, request: Request) -> AsyncResponse: + """Send ``request`` once and adapt the programmed native outcome. + + A pre-dispatch failure (a closed client) surfaces through the returned + awaitable — this is an ``async def``, so the raise runs only when the + coroutine is awaited, never synchronously at call time. + """ + if self._closed: + raise ServiceRequestError("FakeAsyncHttpClient is closed") + headers, body = self._prepare(request) + self._record(request, headers, body) + outcome = self._core.take() + self._dispatch_error(outcome) + assert outcome.response is not None + native = _AsyncNativeResponse(outcome.response) + self._core.responses.append(native) + return await self._adapt(request, native) + + async def _adapt(self, request: Request, native: _AsyncNativeResponse) -> AsyncResponse: + """Turn a native response into an `AsyncResponse`; close native on failure.""" + try: + spec = native.spec + headers = self._inbound_headers(spec) + status = self._parse_status(spec.status) + reader = _AsyncNativeReader(native) + body = AsyncResponseBody.from_async_stream( + reader, + media_type=_inbound_media_type(headers), + content_length=_inbound_content_length(headers), + ) + return AsyncResponse( + request=request, + protocol=_protocol_of(spec), + status=status, + headers=headers, + reason=spec.reason, + body=body, + ) + except _AdaptationError as err: + await native.close() + raise ServiceResponseError(str(err), error=err) from err + + async def aclose(self) -> None: + """Close the adapter; close the underlying client only when owned.""" + if self._closed: + return + self._closed = True + if self._owns: + self._byo.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + +# --------------------------------------------------------------------------- # +# In-memory harnesses that wire the fake adapters to the neutral seam. +# --------------------------------------------------------------------------- # + + +class _InMemoryHarnessMixin: + """Shared programming / introspection for the in-memory harnesses.""" + + def __init__(self) -> None: + self._core = _NativeCore() + self._byo: _ByoNativeClient | None = None + + def expect(self, outcome: Outcome) -> None: + self._core.program(outcome) + + def sent_requests(self) -> list[SentRequest]: + return self._core.sent + + def attempt_count(self) -> int: + return self._core.attempts + + def drop_log(self) -> DropLog: + return self._core.drop_log + + def native_responses(self) -> list[ClosableNative]: + return self._core.responses + + def mark_native_rejected(self, *names: str) -> None: + for name in names: + self._core.native_rejected.add(name.lower()) + + def byo_underlying(self) -> ClosableNative | None: + return self._byo + + def unsupported_proxy_features(self, client: object) -> frozenset[str]: + assert isinstance(client, _AdapterBase) + return client.unsupported_proxy_features() + + def _make_byo(self, use_byo: bool) -> _ByoNativeClient | None: + if not use_byo: + return None + self._byo = _ByoNativeClient() + return self._byo + + @staticmethod + def _proxy(options: dict[str, object]) -> _ProxyConfig | None: + url = options.get("proxy_url") + if url is None: + return None + assert isinstance(url, str) + features = options.get("proxy_features", frozenset()) + assert isinstance(features, frozenset) + return _ProxyConfig(url, features) + + +class InMemorySyncHarness(_InMemoryHarnessMixin, SyncTransportHarness): + """Synchronous in-memory reference harness.""" + + def new_client(self, **options: object) -> HttpClient: + timeout = options.get("timeout", _DEFAULT_TIMEOUT) + assert isinstance(timeout, int | float) + byo = self._make_byo(bool(options.get("byo", False))) + return FakeSyncHttpClient( + self._core, + timeout=float(timeout), + granularity=self.min_timeout_granularity, + byo=byo, + proxy=self._proxy(options), + ) + + def close_client(self, client: HttpClient) -> None: + """Close a sync adapter via its ``close`` surface.""" + assert isinstance(client, FakeSyncHttpClient) + client.close() + + +class InMemoryAsyncHarness(_InMemoryHarnessMixin, AsyncTransportHarness): + """Asynchronous in-memory reference harness.""" + + def new_async_client(self, **options: object) -> AsyncHttpClient: + timeout = options.get("timeout", _DEFAULT_TIMEOUT) + assert isinstance(timeout, int | float) + byo = self._make_byo(bool(options.get("byo", False))) + return FakeAsyncHttpClient( + self._core, + timeout=float(timeout), + granularity=self.min_timeout_granularity, + byo=byo, + proxy=self._proxy(options), + ) + + async def aclose_client(self, client: AsyncHttpClient) -> None: + """Close an async adapter via its ``aclose`` surface.""" + assert isinstance(client, FakeAsyncHttpClient) + await client.aclose() + + +__all__ = [ + "FakeAsyncHttpClient", + "FakeSyncHttpClient", + "InMemoryAsyncHarness", + "InMemorySyncHarness", +] diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/__init__.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/__init__.py new file mode 100644 index 0000000..07484e2 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/__init__.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Golden wire-exactness byte corpus. + +Checked-in raw byte fixtures — URL/query codec, redirect ``Location`` +resolution, raw-query pagination splices, SSE streams, ``Retry-After`` pacing +vectors, and RFC 1123 dates — plus a small loader API. The corpus is the single +source of truth that keeps those behaviours byte-exact; later work points its +own conformance checks at these vectors. + +The fixtures live under ``data/`` as raw byte files organised by family, read +in binary mode via ``importlib.resources`` so newline forms and percent-encoded +bytes are never mangled. The corpus ships as package data inside the +``dexpace.sdk.tck`` distribution and loads byte-exactly from an installed wheel. +""" + +from __future__ import annotations + +from .loader import ( + FAMILIES, + FAMILY_TAGS, + GoldenVector, + PaginationSpliceVector, + QueryCodecVector, + RedirectVector, + RetryPacingVector, + Rfc1123Vector, + SseExpectedEvent, + SseVector, + load_all, + load_pagination_splice, + load_query_codec, + load_redirect, + load_retry_pacing, + load_rfc1123_dates, + load_sse, +) + +__all__ = [ + "FAMILIES", + "FAMILY_TAGS", + "GoldenVector", + "PaginationSpliceVector", + "QueryCodecVector", + "RedirectVector", + "RetryPacingVector", + "Rfc1123Vector", + "SseExpectedEvent", + "SseVector", + "load_all", + "load_pagination_splice", + "load_query_codec", + "load_redirect", + "load_retry_pacing", + "load_rfc1123_dates", + "load_sse", +] diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/.gitattributes b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/.gitattributes new file mode 100644 index 0000000..68b1855 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/.gitattributes @@ -0,0 +1,5 @@ +# Golden wire-exactness fixtures are raw bytes. Git must never apply EOL +# normalization here, or the CR / LF / CRLF SSE streams (and the exact +# byte layout of every other fixture) would be silently corrupted on +# checkin/checkout. `-text` pins verbatim bytes while keeping files diffable. +* -text diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/append-to-empty-query.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/append-to-empty-query.base new file mode 100644 index 0000000..c6ee269 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/append-to-empty-query.base @@ -0,0 +1 @@ +https://api.example.com/items \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/append-to-existing-query.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/append-to-existing-query.base new file mode 100644 index 0000000..2b191ef --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/append-to-existing-query.base @@ -0,0 +1 @@ +https://api.example.com/items?limit=50 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/manifest.jsonl b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/manifest.jsonl new file mode 100644 index 0000000..3b74349 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/manifest.jsonl @@ -0,0 +1,7 @@ +{"tag": "PAGE-21", "id": "append-to-empty-query", "description": "Splicing a cursor onto a query-less URL appends ?cursor=...", "base_file": "append-to-empty-query.base", "param": "cursor", "value": "c1", "spliced": "https://api.example.com/items?cursor=c1", "untouched": []} +{"tag": "PAGE-21", "id": "append-to-existing-query", "description": "An existing param is preserved; the cursor is appended with &.", "base_file": "append-to-existing-query.base", "param": "cursor", "value": "c1", "spliced": "https://api.example.com/items?limit=50&cursor=c1", "untouched": ["limit=50"]} +{"tag": "PAGE-21", "id": "replace-existing-cursor-in-place", "description": "An existing cursor value is replaced in place; trailing params keep position.", "base_file": "replace-existing-cursor-in-place.base", "param": "cursor", "value": "c2", "spliced": "https://api.example.com/items?cursor=c2&limit=50", "untouched": ["limit=50"]} +{"tag": "PAGE-21", "id": "preserve-encoded-param-verbatim", "description": "A pre-encoded %20 in an untouched param survives byte-for-byte.", "base_file": "preserve-encoded-param-verbatim.base", "param": "cursor", "value": "c2", "spliced": "https://api.example.com/items?q=a%20b&sort=desc&cursor=c2", "untouched": ["q=a%20b", "sort=desc"]} +{"tag": "PAGE-21", "id": "preserve-literal-plus-verbatim", "description": "A literal + in an untouched param is NOT re-encoded to %2B nor decoded to space.", "base_file": "preserve-literal-plus-verbatim.base", "param": "cursor", "value": "c2", "spliced": "https://api.example.com/items?tag=a+b&cursor=c2", "untouched": ["tag=a+b"]} +{"tag": "PAGE-21", "id": "opaque-cursor-appended-verbatim", "description": "An opaque URL-safe cursor token is spliced verbatim.", "base_file": "opaque-cursor-appended-verbatim.base", "param": "cursor", "value": "eyJvIjoxMH0", "spliced": "https://api.example.com/items?limit=2&cursor=eyJvIjoxMH0", "untouched": ["limit=2"]} +{"tag": "PAGE-21", "id": "preserve-multivalue-param", "description": "Repeated untouched params keep every value and order.", "base_file": "preserve-multivalue-param.base", "param": "cursor", "value": "c2", "spliced": "https://api.example.com/items?tag=x&tag=y&cursor=c2", "untouched": ["tag=x", "tag=y"]} diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/opaque-cursor-appended-verbatim.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/opaque-cursor-appended-verbatim.base new file mode 100644 index 0000000..c04edf1 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/opaque-cursor-appended-verbatim.base @@ -0,0 +1 @@ +https://api.example.com/items?limit=2 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-encoded-param-verbatim.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-encoded-param-verbatim.base new file mode 100644 index 0000000..9b2052d --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-encoded-param-verbatim.base @@ -0,0 +1 @@ +https://api.example.com/items?q=a%20b&sort=desc \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-literal-plus-verbatim.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-literal-plus-verbatim.base new file mode 100644 index 0000000..3ba4989 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-literal-plus-verbatim.base @@ -0,0 +1 @@ +https://api.example.com/items?tag=a+b \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-multivalue-param.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-multivalue-param.base new file mode 100644 index 0000000..c85bba4 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/preserve-multivalue-param.base @@ -0,0 +1 @@ +https://api.example.com/items?tag=x&tag=y \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/replace-existing-cursor-in-place.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/replace-existing-cursor-in-place.base new file mode 100644 index 0000000..2f077fa --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/pagination_splice/replace-existing-cursor-in-place.base @@ -0,0 +1 @@ +https://api.example.com/items?cursor=c1&limit=50 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/empty-distinct-from-absent.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/empty-distinct-from-absent.params new file mode 100644 index 0000000..e4baaf4 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/empty-distinct-from-absent.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/empty-distinct-from-absent.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/empty-distinct-from-absent.query new file mode 100644 index 0000000..14b7c18 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/empty-distinct-from-absent.query @@ -0,0 +1 @@ +a=1&flag= \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/manifest.jsonl b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/manifest.jsonl new file mode 100644 index 0000000..8dde60e --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/manifest.jsonl @@ -0,0 +1,12 @@ +{"tag": "HTTP-29", "id": "space-encodes-to-pct20", "description": "A literal space renders as %20, never + (quote_plus/urlencode form).", "params_file": "space-encodes-to-pct20.params", "query_file": "space-encodes-to-pct20.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "plus-encodes-to-pct2b", "description": "A literal + in a value renders as %2B, never left bare nor read as space.", "params_file": "plus-encodes-to-pct2b.params", "query_file": "plus-encodes-to-pct2b.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "valueless-flag-empty-string", "description": "A value-less flag is the empty string, rendered as 'flag=' (present, empty).", "params_file": "valueless-flag-empty-string.params", "query_file": "valueless-flag-empty-string.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "empty-distinct-from-absent", "description": "flag= (present empty) sits beside a=1; absent means the key is not in the list.", "params_file": "empty-distinct-from-absent.params", "query_file": "empty-distinct-from-absent.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "order-preserved-ba", "description": "Insertion order is preserved verbatim; params are not sorted.", "params_file": "order-preserved-ba.params", "query_file": "order-preserved-ba.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "order-preserved-ab", "description": "Same key set as order-preserved-ba but opposite order -> distinct query (order-sensitive).", "params_file": "order-preserved-ab.params", "query_file": "order-preserved-ab.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "multi-valued-key", "description": "A repeated key keeps every value in order.", "params_file": "multi-valued-key.params", "query_file": "multi-valued-key.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "reserved-sub-delims-encoded", "description": "Structural chars (/ ? # & =) inside a value are all percent-encoded.", "params_file": "reserved-sub-delims-encoded.params", "query_file": "reserved-sub-delims-encoded.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "utf8-value-encoded", "description": "Non-ASCII is UTF-8 then percent-encoded (cafe with acute e -> caf%C3%A9).", "params_file": "utf8-value-encoded.params", "query_file": "utf8-value-encoded.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "unreserved-preserved", "description": "RFC 3986 unreserved set (A-Za-z0-9-._~) is emitted verbatim.", "params_file": "unreserved-preserved.params", "query_file": "unreserved-preserved.query", "render_matches": true, "parse_matches": true} +{"tag": "HTTP-29", "id": "parse-literal-plus-not-space", "description": "Lenient parse keeps a wire '+' as literal '+' (RFC 3986), NOT form-decoded to space.", "params_file": "parse-literal-plus-not-space.params", "query_file": "parse-literal-plus-not-space.query", "render_matches": false, "parse_matches": true} +{"tag": "HTTP-29", "id": "parse-bare-flag-is-empty", "description": "A bare flag with no '=' parses to a present empty string, distinct from absent.", "params_file": "parse-bare-flag-is-empty.params", "query_file": "parse-bare-flag-is-empty.query", "render_matches": false, "parse_matches": true} diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/multi-valued-key.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/multi-valued-key.params new file mode 100644 index 0000000..d715e9e Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/multi-valued-key.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/multi-valued-key.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/multi-valued-key.query new file mode 100644 index 0000000..4339c9c --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/multi-valued-key.query @@ -0,0 +1 @@ +k=1&k=2 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ab.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ab.params new file mode 100644 index 0000000..2be9cb9 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ab.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ab.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ab.query new file mode 100644 index 0000000..6828797 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ab.query @@ -0,0 +1 @@ +a=1&b=2 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ba.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ba.params new file mode 100644 index 0000000..514aed9 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ba.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ba.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ba.query new file mode 100644 index 0000000..b4906af --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/order-preserved-ba.query @@ -0,0 +1 @@ +b=2&a=1 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-bare-flag-is-empty.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-bare-flag-is-empty.params new file mode 100644 index 0000000..f05b689 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-bare-flag-is-empty.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-bare-flag-is-empty.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-bare-flag-is-empty.query new file mode 100644 index 0000000..551c90f --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-bare-flag-is-empty.query @@ -0,0 +1 @@ +flag \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-literal-plus-not-space.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-literal-plus-not-space.params new file mode 100644 index 0000000..9e7a44f Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-literal-plus-not-space.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-literal-plus-not-space.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-literal-plus-not-space.query new file mode 100644 index 0000000..f1a3013 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/parse-literal-plus-not-space.query @@ -0,0 +1 @@ +q=a+b \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/plus-encodes-to-pct2b.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/plus-encodes-to-pct2b.params new file mode 100644 index 0000000..9e7a44f Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/plus-encodes-to-pct2b.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/plus-encodes-to-pct2b.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/plus-encodes-to-pct2b.query new file mode 100644 index 0000000..24eae43 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/plus-encodes-to-pct2b.query @@ -0,0 +1 @@ +q=a%2Bb \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/reserved-sub-delims-encoded.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/reserved-sub-delims-encoded.params new file mode 100644 index 0000000..e8bd0a7 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/reserved-sub-delims-encoded.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/reserved-sub-delims-encoded.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/reserved-sub-delims-encoded.query new file mode 100644 index 0000000..31c7c24 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/reserved-sub-delims-encoded.query @@ -0,0 +1 @@ +q=a%2Fb%3Fc%23d%26e%3Df \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/space-encodes-to-pct20.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/space-encodes-to-pct20.params new file mode 100644 index 0000000..3d7e7d6 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/space-encodes-to-pct20.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/space-encodes-to-pct20.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/space-encodes-to-pct20.query new file mode 100644 index 0000000..086b54c --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/space-encodes-to-pct20.query @@ -0,0 +1 @@ +q=a%20b \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/unreserved-preserved.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/unreserved-preserved.params new file mode 100644 index 0000000..f1e342a Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/unreserved-preserved.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/unreserved-preserved.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/unreserved-preserved.query new file mode 100644 index 0000000..743f2c0 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/unreserved-preserved.query @@ -0,0 +1 @@ +q=AZaz09-._~ \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/utf8-value-encoded.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/utf8-value-encoded.params new file mode 100644 index 0000000..74630a4 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/utf8-value-encoded.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/utf8-value-encoded.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/utf8-value-encoded.query new file mode 100644 index 0000000..02e55fe --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/utf8-value-encoded.query @@ -0,0 +1 @@ +name=caf%C3%A9 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/valueless-flag-empty-string.params b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/valueless-flag-empty-string.params new file mode 100644 index 0000000..f05b689 Binary files /dev/null and b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/valueless-flag-empty-string.params differ diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/valueless-flag-empty-string.query b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/valueless-flag-empty-string.query new file mode 100644 index 0000000..ee45a36 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/query_codec/valueless-flag-empty-string.query @@ -0,0 +1 @@ +flag= \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-location-replaces.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-location-replaces.base new file mode 100644 index 0000000..575c11e --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-location-replaces.base @@ -0,0 +1 @@ +https://api.example.com/v1/things \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-location-replaces.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-location-replaces.location new file mode 100644 index 0000000..1ffded4 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-location-replaces.location @@ -0,0 +1 @@ +https://cdn.example.com/blob/9 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-path-from-root.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-path-from-root.base new file mode 100644 index 0000000..575c11e --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-path-from-root.base @@ -0,0 +1 @@ +https://api.example.com/v1/things \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-path-from-root.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-path-from-root.location new file mode 100644 index 0000000..7920f61 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/absolute-path-from-root.location @@ -0,0 +1 @@ +/v2/other \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/fragment-carried.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/fragment-carried.base new file mode 100644 index 0000000..21b7181 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/fragment-carried.base @@ -0,0 +1 @@ +https://api.example.com/a \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/fragment-carried.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/fragment-carried.location new file mode 100644 index 0000000..2b16736 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/fragment-carried.location @@ -0,0 +1 @@ +/b#frag \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-absolute.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-absolute.base new file mode 100644 index 0000000..21b7181 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-absolute.base @@ -0,0 +1 @@ +https://api.example.com/a \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-absolute.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-absolute.location new file mode 100644 index 0000000..49a18cd --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-absolute.location @@ -0,0 +1 @@ +https://[2001:db8::1]:8443/x \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-relative.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-relative.base new file mode 100644 index 0000000..4461363 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-relative.base @@ -0,0 +1 @@ +https://[2001:db8::1]:8443/v1/a \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-relative.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-relative.location new file mode 100644 index 0000000..42532fe --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/ipv6-brackets-kept-relative.location @@ -0,0 +1 @@ +../b \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/manifest.jsonl b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/manifest.jsonl new file mode 100644 index 0000000..88d594a --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/manifest.jsonl @@ -0,0 +1,10 @@ +{"tag": "REDIR-12", "id": "absolute-location-replaces", "description": "An absolute Location replaces the whole URL.", "base_file": "absolute-location-replaces.base", "location_file": "absolute-location-replaces.location", "resolved": "https://cdn.example.com/blob/9"} +{"tag": "REDIR-12", "id": "relative-dotdot-path", "description": "A ../ relative Location resolves against the base path.", "base_file": "relative-dotdot-path.base", "location_file": "relative-dotdot-path.location", "resolved": "https://api.example.com/v1/else"} +{"tag": "REDIR-12", "id": "absolute-path-from-root", "description": "A root-absolute path keeps scheme+host, replaces the path.", "base_file": "absolute-path-from-root.base", "location_file": "absolute-path-from-root.location", "resolved": "https://api.example.com/v2/other"} +{"tag": "REDIR-12", "id": "query-only-location", "description": "A ?query-only Location keeps the base path, replaces the query.", "base_file": "query-only-location.base", "location_file": "query-only-location.location", "resolved": "https://api.example.com/things?page=2"} +{"tag": "REDIR-12", "id": "preserve-percent-encoding", "description": "%2F and %20 in the target are preserved wire-exact, not decoded or normalised.", "base_file": "preserve-percent-encoding.base", "location_file": "preserve-percent-encoding.location", "resolved": "https://api.example.com/p%2Fq?x=%20y"} +{"tag": "REDIR-12", "id": "userinfo-dropped", "description": "userinfo in a server Location is always discarded.", "base_file": "userinfo-dropped.base", "location_file": "userinfo-dropped.location", "resolved": "https://other.example.com/x"} +{"tag": "REDIR-12", "id": "ipv6-brackets-kept-absolute", "description": "An IPv6 literal Location keeps its [brackets] and explicit port.", "base_file": "ipv6-brackets-kept-absolute.base", "location_file": "ipv6-brackets-kept-absolute.location", "resolved": "https://[2001:db8::1]:8443/x"} +{"tag": "REDIR-12", "id": "ipv6-brackets-kept-relative", "description": "A relative Location against an IPv6 base keeps the bracketed host+port.", "base_file": "ipv6-brackets-kept-relative.base", "location_file": "ipv6-brackets-kept-relative.location", "resolved": "https://[2001:db8::1]:8443/b"} +{"tag": "REDIR-12", "id": "scheme-relative-location", "description": "A //host protocol-relative Location inherits the base scheme.", "base_file": "scheme-relative-location.base", "location_file": "scheme-relative-location.location", "resolved": "https://other.example.com/x"} +{"tag": "REDIR-12", "id": "fragment-carried", "description": "A #fragment on the Location is carried onto the resolved target.", "base_file": "fragment-carried.base", "location_file": "fragment-carried.location", "resolved": "https://api.example.com/b#frag"} diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/preserve-percent-encoding.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/preserve-percent-encoding.base new file mode 100644 index 0000000..21b7181 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/preserve-percent-encoding.base @@ -0,0 +1 @@ +https://api.example.com/a \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/preserve-percent-encoding.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/preserve-percent-encoding.location new file mode 100644 index 0000000..ab2b29c58 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/preserve-percent-encoding.location @@ -0,0 +1 @@ +/p%2Fq?x=%20y \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/query-only-location.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/query-only-location.base new file mode 100644 index 0000000..7438f34 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/query-only-location.base @@ -0,0 +1 @@ +https://api.example.com/things?page=1 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/query-only-location.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/query-only-location.location new file mode 100644 index 0000000..355d656 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/query-only-location.location @@ -0,0 +1 @@ +?page=2 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/relative-dotdot-path.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/relative-dotdot-path.base new file mode 100644 index 0000000..07dabbf --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/relative-dotdot-path.base @@ -0,0 +1 @@ +https://api.example.com/v1/things/1 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/relative-dotdot-path.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/relative-dotdot-path.location new file mode 100644 index 0000000..e702973 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/relative-dotdot-path.location @@ -0,0 +1 @@ +../else \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/scheme-relative-location.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/scheme-relative-location.base new file mode 100644 index 0000000..21b7181 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/scheme-relative-location.base @@ -0,0 +1 @@ +https://api.example.com/a \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/scheme-relative-location.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/scheme-relative-location.location new file mode 100644 index 0000000..5ac3a51 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/scheme-relative-location.location @@ -0,0 +1 @@ +//other.example.com/x \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/userinfo-dropped.base b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/userinfo-dropped.base new file mode 100644 index 0000000..21b7181 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/userinfo-dropped.base @@ -0,0 +1 @@ +https://api.example.com/a \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/userinfo-dropped.location b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/userinfo-dropped.location new file mode 100644 index 0000000..9fc8995 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/redirect/userinfo-dropped.location @@ -0,0 +1 @@ +https://user:pass@other.example.com/x \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-exceeds-365d-clamp.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-exceeds-365d-clamp.token new file mode 100644 index 0000000..d2caa99 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-exceeds-365d-clamp.token @@ -0,0 +1 @@ +999999999 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-fractional.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-fractional.token new file mode 100644 index 0000000..7290605 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-fractional.token @@ -0,0 +1 @@ +7.5 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-integer.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-integer.token new file mode 100644 index 0000000..8580e7b --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-integer.token @@ -0,0 +1 @@ +30 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-surrounding-whitespace.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-surrounding-whitespace.token new file mode 100644 index 0000000..9469d89 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-surrounding-whitespace.token @@ -0,0 +1 @@ + 30 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-zero.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-zero.token new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/delta-zero.token @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-exceeds-365d-clamp.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-exceeds-365d-clamp.token new file mode 100644 index 0000000..c808444 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-exceeds-365d-clamp.token @@ -0,0 +1 @@ +Fri, 01 Jan 2100 00:00:00 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-future.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-future.token new file mode 100644 index 0000000..e271966 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-future.token @@ -0,0 +1 @@ +Wed, 21 Oct 2015 07:28:00 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-past-floors-to-zero.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-past-floors-to-zero.token new file mode 100644 index 0000000..275c4e8 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/http-date-past-floors-to-zero.token @@ -0,0 +1 @@ +Sun, 06 Nov 1994 08:49:37 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/manifest.jsonl b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/manifest.jsonl new file mode 100644 index 0000000..8bc87be --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/manifest.jsonl @@ -0,0 +1,18 @@ +{"tag": "RETRY-19", "id": "delta-integer", "description": "A plain integer delta parses to that many seconds.", "raw_file": "delta-integer.token", "kind": "delta", "seconds": 30.0, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": ""} +{"tag": "RETRY-19", "id": "delta-zero", "description": "Zero seconds is a valid immediate retry.", "raw_file": "delta-zero.token", "kind": "delta", "seconds": 0.0, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": ""} +{"tag": "RETRY-19", "id": "delta-fractional", "description": "A single fractional part is allowed.", "raw_file": "delta-fractional.token", "kind": "delta", "seconds": 7.5, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": ""} +{"tag": "RETRY-19", "id": "delta-surrounding-whitespace", "description": "Surrounding whitespace is tolerated by the pre-screen.", "raw_file": "delta-surrounding-whitespace.token", "kind": "delta", "seconds": 30.0, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": ""} +{"tag": "RETRY-19", "id": "delta-exceeds-365d-clamp", "description": "A delta beyond 365 days is clamped to the 365-day ceiling.", "raw_file": "delta-exceeds-365d-clamp.token", "kind": "delta", "seconds": 999999999.0, "now": null, "clamp_max": 31536000.0, "clamped": 31536000.0, "float_accepts": true, "note": "raw value exceeds the 365-day sanity clamp"} +{"tag": "RETRY-19", "id": "reject-trailing-unit", "description": "A trailing unit letter is rejected.", "raw_file": "reject-trailing-unit.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": false, "note": "float() rejects too"} +{"tag": "RETRY-19", "id": "reject-hex-float", "description": "A hex-float literal is rejected.", "raw_file": "reject-hex-float.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": false, "note": "float() rejects too"} +{"tag": "RETRY-19", "id": "reject-nan-token", "description": "float() accepts 'nan' but the pre-screen must reject it.", "raw_file": "reject-nan-token.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": "float() over-accepts NaN"} +{"tag": "RETRY-19", "id": "reject-inf-token", "description": "float() accepts 'inf' but the pre-screen must reject it.", "raw_file": "reject-inf-token.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": "float() over-accepts inf"} +{"tag": "RETRY-19", "id": "reject-underscore-grouping", "description": "float() accepts '1_000' but the pre-screen must reject it.", "raw_file": "reject-underscore-grouping.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": "float() over-accepts underscore digit grouping"} +{"tag": "RETRY-19", "id": "reject-signed", "description": "A leading sign is rejected; the pre-screen has no sign.", "raw_file": "reject-signed.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": "float() over-accepts a sign"} +{"tag": "RETRY-19", "id": "reject-scientific", "description": "Scientific notation is rejected by the pre-screen.", "raw_file": "reject-scientific.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": true, "note": "float() over-accepts 1e3"} +{"tag": "RETRY-19", "id": "reject-multiple-dots", "description": "A malformed multi-dot number is rejected.", "raw_file": "reject-multiple-dots.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": false, "note": "float() rejects too"} +{"tag": "RETRY-19", "id": "reject-empty", "description": "An empty value yields None.", "raw_file": "reject-empty.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": false, "note": "float() rejects too"} +{"tag": "RETRY-19", "id": "reject-whitespace-only", "description": "A whitespace-only value yields None.", "raw_file": "reject-whitespace-only.token", "kind": "reject", "seconds": null, "now": null, "clamp_max": null, "clamped": null, "float_accepts": false, "note": "float() rejects too"} +{"tag": "RETRY-19", "id": "http-date-future", "description": "A future HTTP-date yields the positive delta to that instant.", "raw_file": "http-date-future.token", "kind": "http_date", "seconds": 3600.0, "now": 1445408880.0, "clamp_max": null, "clamped": null, "float_accepts": false, "note": "one hour in the future relative to now"} +{"tag": "RETRY-19", "id": "http-date-past-floors-to-zero", "description": "A past HTTP-date floors to 0 seconds (distinct from a malformed value -> None).", "raw_file": "http-date-past-floors-to-zero.token", "kind": "http_date", "seconds": 0.0, "now": 2000000000.0, "clamp_max": null, "clamped": null, "float_accepts": false, "note": "past date -> 0, NOT None"} +{"tag": "RETRY-19", "id": "http-date-exceeds-365d-clamp", "description": "A far-future HTTP-date delta is clamped to the 365-day ceiling.", "raw_file": "http-date-exceeds-365d-clamp.token", "kind": "http_date", "seconds": 2524608000.0, "now": 1577836800.0, "clamp_max": 31536000.0, "clamped": 31536000.0, "float_accepts": false, "note": "delta beyond 365 days is clamped"} diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-empty.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-empty.token new file mode 100644 index 0000000..e69de29 diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-hex-float.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-hex-float.token new file mode 100644 index 0000000..6a31f16 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-hex-float.token @@ -0,0 +1 @@ +0x1p4 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-inf-token.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-inf-token.token new file mode 100644 index 0000000..a28aa9a --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-inf-token.token @@ -0,0 +1 @@ +inf \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-multiple-dots.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-multiple-dots.token new file mode 100644 index 0000000..e2cac26 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-multiple-dots.token @@ -0,0 +1 @@ +1.2.3 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-nan-token.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-nan-token.token new file mode 100644 index 0000000..0982929 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-nan-token.token @@ -0,0 +1 @@ +nan \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-scientific.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-scientific.token new file mode 100644 index 0000000..671e2d1 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-scientific.token @@ -0,0 +1 @@ +1e3 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-signed.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-signed.token new file mode 100644 index 0000000..c42fe6e --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-signed.token @@ -0,0 +1 @@ ++30 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-trailing-unit.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-trailing-unit.token new file mode 100644 index 0000000..e47be59 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-trailing-unit.token @@ -0,0 +1 @@ +30d \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-underscore-grouping.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-underscore-grouping.token new file mode 100644 index 0000000..f9e73cf --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-underscore-grouping.token @@ -0,0 +1 @@ +1_000 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-whitespace-only.token b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-whitespace-only.token new file mode 100644 index 0000000..136d063 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/retry_pacing/reject-whitespace-only.token @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/manifest.jsonl b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/manifest.jsonl new file mode 100644 index 0000000..682170b --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/manifest.jsonl @@ -0,0 +1,8 @@ +{"tag": "CFG-29", "id": "render-canonical-1994", "description": "The RFC 7231 canonical example instant.", "direction": "render", "text_file": "render-canonical-1994.date", "epoch": 784111777, "round_trippable": true} +{"tag": "CFG-29", "id": "render-unix-epoch", "description": "The Unix epoch renders with a zero-padded day.", "direction": "render", "text_file": "render-unix-epoch.date", "epoch": 0, "round_trippable": true} +{"tag": "CFG-29", "id": "render-y2k", "description": "A Y2K instant renders canonically.", "direction": "render", "text_file": "render-y2k.date", "epoch": 946684800, "round_trippable": true} +{"tag": "CFG-29", "id": "parse-canonical", "description": "The canonical form round-trips exactly.", "direction": "parse", "text_file": "parse-canonical.date", "epoch": 784111777, "round_trippable": true} +{"tag": "CFG-29", "id": "parse-informational-weekday", "description": "The weekday name is informational and ignored (wrong Fri still parses to Sunday's instant).", "direction": "parse", "text_file": "parse-informational-weekday.date", "epoch": 784111777, "round_trippable": false} +{"tag": "CFG-29", "id": "parse-zone-alias-UT", "description": "The obsolete UT zone alias is treated as UTC.", "direction": "parse", "text_file": "parse-zone-alias-UT.date", "epoch": 784111777, "round_trippable": false} +{"tag": "CFG-29", "id": "parse-zone-numeric-utc", "description": "A +0000 numeric offset is UTC.", "direction": "parse", "text_file": "parse-zone-numeric-utc.date", "epoch": 784111777, "round_trippable": false} +{"tag": "CFG-29", "id": "parse-zone-offset-plus0100", "description": "A +0100 offset resolves to the same instant one hour earlier in UTC.", "direction": "parse", "text_file": "parse-zone-offset-plus0100.date", "epoch": 784111777, "round_trippable": false} diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-canonical.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-canonical.date new file mode 100644 index 0000000..275c4e8 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-canonical.date @@ -0,0 +1 @@ +Sun, 06 Nov 1994 08:49:37 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-informational-weekday.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-informational-weekday.date new file mode 100644 index 0000000..45633e9 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-informational-weekday.date @@ -0,0 +1 @@ +Fri, 06 Nov 1994 08:49:37 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-alias-UT.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-alias-UT.date new file mode 100644 index 0000000..15ae079 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-alias-UT.date @@ -0,0 +1 @@ +Sun, 06 Nov 1994 08:49:37 UT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-numeric-utc.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-numeric-utc.date new file mode 100644 index 0000000..82520b1 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-numeric-utc.date @@ -0,0 +1 @@ +Sun, 06 Nov 1994 08:49:37 +0000 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-offset-plus0100.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-offset-plus0100.date new file mode 100644 index 0000000..e7735c9 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/parse-zone-offset-plus0100.date @@ -0,0 +1 @@ +Sun, 06 Nov 1994 09:49:37 +0100 \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-canonical-1994.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-canonical-1994.date new file mode 100644 index 0000000..275c4e8 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-canonical-1994.date @@ -0,0 +1 @@ +Sun, 06 Nov 1994 08:49:37 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-unix-epoch.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-unix-epoch.date new file mode 100644 index 0000000..46e2b50 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-unix-epoch.date @@ -0,0 +1 @@ +Thu, 01 Jan 1970 00:00:00 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-y2k.date b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-y2k.date new file mode 100644 index 0000000..fdc87f3 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/rfc1123_dates/render-y2k.date @@ -0,0 +1 @@ +Sat, 01 Jan 2000 00:00:00 GMT \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/comment-line-captured.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/comment-line-captured.sse new file mode 100644 index 0000000..ce30a52 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/comment-line-captured.sse @@ -0,0 +1,3 @@ +: keep-alive +data: x + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/cr-single-event.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/cr-single-event.sse new file mode 100644 index 0000000..d247091 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/cr-single-event.sse @@ -0,0 +1 @@ +data: hello \ No newline at end of file diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/crlf-single-event.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/crlf-single-event.sse new file mode 100644 index 0000000..1f8a920 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/crlf-single-event.sse @@ -0,0 +1,2 @@ +data: hello + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/final-event-no-trailing-blank.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/final-event-no-trailing-blank.sse new file mode 100644 index 0000000..0189d26 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/final-event-no-trailing-blank.sse @@ -0,0 +1 @@ +data: last diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/id-not-sticky-across-events.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/id-not-sticky-across-events.sse new file mode 100644 index 0000000..371dd4d --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/id-not-sticky-across-events.sse @@ -0,0 +1,5 @@ +id: 42 +data: a + +data: b + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/leading-bom-stripped.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/leading-bom-stripped.sse new file mode 100644 index 0000000..89d0366 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/leading-bom-stripped.sse @@ -0,0 +1,2 @@ +data: hi + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/lf-single-event.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/lf-single-event.sse new file mode 100644 index 0000000..d56fbe3 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/lf-single-event.sse @@ -0,0 +1,2 @@ +data: hello + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/manifest.jsonl b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/manifest.jsonl new file mode 100644 index 0000000..bfaeb50 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/manifest.jsonl @@ -0,0 +1,12 @@ +{"tag": "SSE-14", "id": "lf-single-event", "description": "LF terminators, one event.", "stream_file": "lf-single-event.sse", "terminator": "LF", "events": [{"data": "hello", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "crlf-single-event", "description": "CRLF terminators must survive binary loading intact.", "stream_file": "crlf-single-event.sse", "terminator": "CRLF", "events": [{"data": "hello", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "cr-single-event", "description": "Bare CR terminators are equivalent to LF per the spec.", "stream_file": "cr-single-event.sse", "terminator": "CR", "events": [{"data": "hello", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "multiline-data-joined-lf", "description": "Multiple data: lines join with a single \\n.", "stream_file": "multiline-data-joined-lf.sse", "terminator": "LF", "events": [{"data": "a\nb", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "named-event", "description": "An event: field sets the event name.", "stream_file": "named-event.sse", "terminator": "LF", "events": [{"data": "1", "event": "ping", "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "comment-line-captured", "description": "A line beginning with ':' is a comment; its text is captured and the block still dispatches.", "stream_file": "comment-line-captured.sse", "terminator": "LF", "events": [{"data": "x", "event": null, "id": null, "retry": null, "comment": "keep-alive"}]} +{"tag": "SSE-14", "id": "id-not-sticky-across-events", "description": "id: belongs to its own block and is NOT carried onto later events.", "stream_file": "id-not-sticky-across-events.sse", "terminator": "LF", "events": [{"data": "a", "event": null, "id": "42", "retry": null}, {"data": "b", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "retry-field-parsed", "description": "retry: N sets the reconnect hint in milliseconds.", "stream_file": "retry-field-parsed.sse", "terminator": "LF", "events": [{"data": "go", "event": null, "id": null, "retry": 3000}]} +{"tag": "SSE-14", "id": "leading-bom-stripped", "description": "A single leading UTF-8 BOM is stripped once at stream start.", "stream_file": "leading-bom-stripped.sse", "terminator": "LF", "events": [{"data": "hi", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "final-event-no-trailing-blank", "description": "A trailing event with no blank line is still flushed at end-of-stream.", "stream_file": "final-event-no-trailing-blank.sse", "terminator": "LF", "events": [{"data": "last", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "utf8-data-payload", "description": "UTF-8 bytes in data survive binary loading.", "stream_file": "utf8-data-payload.sse", "terminator": "LF", "events": [{"data": "café", "event": null, "id": null, "retry": null}]} +{"tag": "SSE-14", "id": "single-space-after-colon-stripped", "description": "Exactly one space after the colon is stripped; a second space is data.", "stream_file": "single-space-after-colon-stripped.sse", "terminator": "LF", "events": [{"data": " two", "event": null, "id": null, "retry": null}]} diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/multiline-data-joined-lf.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/multiline-data-joined-lf.sse new file mode 100644 index 0000000..d28ceb2 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/multiline-data-joined-lf.sse @@ -0,0 +1,3 @@ +data: a +data: b + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/named-event.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/named-event.sse new file mode 100644 index 0000000..3545d42 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/named-event.sse @@ -0,0 +1,3 @@ +event: ping +data: 1 + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/retry-field-parsed.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/retry-field-parsed.sse new file mode 100644 index 0000000..e811cc7 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/retry-field-parsed.sse @@ -0,0 +1,3 @@ +retry: 3000 +data: go + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/single-space-after-colon-stripped.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/single-space-after-colon-stripped.sse new file mode 100644 index 0000000..17ce467 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/single-space-after-colon-stripped.sse @@ -0,0 +1,2 @@ +data: two + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/utf8-data-payload.sse b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/utf8-data-payload.sse new file mode 100644 index 0000000..d166ee4 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/data/sse/utf8-data-payload.sse @@ -0,0 +1,2 @@ +data: café + diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/loader.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/loader.py new file mode 100644 index 0000000..4b4b683 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/golden_corpus/loader.py @@ -0,0 +1,530 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Loader API for the golden wire-exactness byte corpus. + +Reads the checked-in raw byte fixtures under ``data/`` via +``importlib.resources`` — always in binary mode, never as text, so CR / LF / +CRLF SSE fixtures and percent-encoded query bytes survive untouched — and +exposes each vector as a frozen, slotted record. + +Every fixture family is tagged with a requirement-ID-style label (``HTTP-29``, +``REDIR-12``, ``PAGE-21``, ``SSE-14``, ``RETRY-19``, ``CFG-29``) so a +traceability tool can map vectors back to their spec requirements. Each +family's raw files plus its ``manifest.jsonl`` index live in a single +self-contained directory, and the whole corpus ships as package data inside the +``dexpace.sdk.tck`` distribution — read via ``importlib.resources`` so it loads +byte-exactly from an installed wheel, never from a repo-relative path. + +The loader carries no codec logic of its own: it materialises the fixture set +and the expectations the fixtures pin. The URL codec, redirect resolver, +pagination splicer, SSE parser, retry-pacing parser, and RFC 1123 date helpers +are implemented elsewhere and point their conformance checks at this corpus. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from dataclasses import dataclass +from importlib.resources import files +from typing import cast + +if __package__ is None: # pragma: no cover - always imported as a package + raise RuntimeError("golden_corpus.loader must be imported as part of its package") + +_ANCHOR: str = __package__ +_DATA_SUBDIR: str = "data" + +#: Every fixture family, in a stable order. +FAMILIES: tuple[str, ...] = ( + "query_codec", + "redirect", + "pagination_splice", + "sse", + "retry_pacing", + "rfc1123_dates", +) + +#: Family -> requirement-ID-style label, for the traceability tool. +FAMILY_TAGS: dict[str, str] = { + "query_codec": "HTTP-29", + "redirect": "REDIR-12", + "pagination_splice": "PAGE-21", + "sse": "SSE-14", + "retry_pacing": "RETRY-19", + "rfc1123_dates": "CFG-29", +} + + +# ----- record types ------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class QueryCodecVector: + """One URL query codec vector (strict RFC 3986 render / lenient parse). + + Attributes: + family: The owning family (``"query_codec"``). + tag: Requirement-ID label (``"HTTP-29"``). + id: Unique vector id within the family. + description: Human-readable statement of what the vector pins. + params: Ordered ``(name, value)`` pairs as raw bytes. + query: The wire query string bytes (no leading ``?``). + render_matches: True when strict render of ``params`` equals ``query``. + parse_matches: True when lenient parse of ``query`` equals ``params``. + """ + + family: str + tag: str + id: str + description: str + params: tuple[tuple[bytes, bytes], ...] + query: bytes + render_matches: bool + parse_matches: bool + + @property + def round_trippable(self) -> bool: + """True when the vector renders and parses as exact inverses.""" + return self.render_matches and self.parse_matches + + +@dataclass(frozen=True, slots=True) +class RedirectVector: + """One redirect ``Location`` resolution vector. + + Attributes: + family: The owning family (``"redirect"``). + tag: Requirement-ID label (``"REDIR-12"``). + id: Unique vector id within the family. + description: Human-readable statement of what the vector pins. + base: The base request URL bytes. + location: The raw ``Location`` header bytes. + resolved: The expected absolute resolved URL bytes. + """ + + family: str + tag: str + id: str + description: str + base: bytes + location: bytes + resolved: bytes + + +@dataclass(frozen=True, slots=True) +class PaginationSpliceVector: + """One raw-query pagination splice vector. + + Attributes: + family: The owning family (``"pagination_splice"``). + tag: Requirement-ID label (``"PAGE-21"``). + id: Unique vector id within the family. + description: Human-readable statement of what the vector pins. + base: The base URL bytes (may already carry a raw query). + param: The parameter name spliced in. + value: The parameter value spliced in. + spliced: The expected result URL bytes. + untouched: Raw ``name=value`` substrings that must survive verbatim. + """ + + family: str + tag: str + id: str + description: str + base: bytes + param: bytes + value: bytes + spliced: bytes + untouched: tuple[bytes, ...] + + +@dataclass(frozen=True, slots=True) +class SseExpectedEvent: + """One expected parsed Server-Sent Event. + + Attributes: + data: The (possibly multi-line) payload. + event: The raw event name, or None when the block sent no ``event:`` + field. The WHATWG-conformant parser surfaces an omitted event name + as absent rather than defaulting it to ``"message"``. + id: The id present in this block, or None. The parser is single-pass + and does not carry a last-event-id forward, so each event pins only + the id in its own block. + retry: The reconnect hint in milliseconds, or None. + comment: The captured comment text for the block, or None. + """ + + data: str + event: str | None + id: str | None + retry: int | None + comment: str | None = None + + +@dataclass(frozen=True, slots=True) +class SseVector: + """One SSE stream vector. + + Attributes: + family: The owning family (``"sse"``). + tag: Requirement-ID label (``"SSE-14"``). + id: Unique vector id within the family. + description: Human-readable statement of what the vector pins. + stream: The raw stream bytes, with literal CR / LF / CRLF terminators. + terminator: One of ``"LF"``, ``"CR"``, ``"CRLF"``. + events: The expected sequence of parsed events. + """ + + family: str + tag: str + id: str + description: str + stream: bytes + terminator: str + events: tuple[SseExpectedEvent, ...] + + +@dataclass(frozen=True, slots=True) +class RetryPacingVector: + """One ``Retry-After`` pacing vector. + + Attributes: + family: The owning family (``"retry_pacing"``). + tag: Requirement-ID label (``"RETRY-19"``). + id: Unique vector id within the family. + description: Human-readable statement of what the vector pins. + raw: The raw ``Retry-After`` token bytes. + kind: One of ``"delta"``, ``"http_date"``, ``"reject"``. + seconds: Expected parsed seconds after past-flooring but before any + clamp; None for a rejected (malformed) value. + now: Epoch reference used to convert an HTTP-date to a delta, or None. + clamp_max: The clamp ceiling in seconds when the vector exercises the + 365-day clamp, else None. + clamped: Expected seconds after applying ``clamp_max``, else None. + float_accepts: Whether Python's ``float()`` accepts the raw token — + pins the divergence for ``nan`` / ``inf`` / ``1_000``. + note: Short explanation of the reject reason or clamp behaviour. + """ + + family: str + tag: str + id: str + description: str + raw: bytes + kind: str + seconds: float | None + now: float | None + clamp_max: float | None + clamped: float | None + float_accepts: bool + note: str + + +@dataclass(frozen=True, slots=True) +class Rfc1123Vector: + """One RFC 1123 date vector (canonical render or tolerant parse). + + Attributes: + family: The owning family (``"rfc1123_dates"``). + tag: Requirement-ID label (``"CFG-29"``). + id: Unique vector id within the family. + description: Human-readable statement of what the vector pins. + direction: ``"render"`` (epoch -> canonical string) or ``"parse"`` + (tolerant string -> instant). + text: The date string bytes (render output / parse input). + epoch: The instant in whole seconds since the Unix epoch. + round_trippable: True when rendering ``epoch`` reproduces ``text``. + """ + + family: str + tag: str + id: str + description: str + direction: str + text: bytes + epoch: int + round_trippable: bool + + +type GoldenVector = ( + QueryCodecVector + | RedirectVector + | PaginationSpliceVector + | SseVector + | RetryPacingVector + | Rfc1123Vector +) + + +# ----- resource + manifest plumbing --------------------------------------- + + +def _read(family: str, name: str) -> bytes: + """Read one raw fixture file for ``family`` as bytes (never text).""" + return files(_ANCHOR).joinpath(_DATA_SUBDIR, family, name).read_bytes() + + +def _entries(family: str) -> Iterator[dict[str, object]]: + """Yield each parsed ``manifest.jsonl`` object for ``family``.""" + text = _read(family, "manifest.jsonl").decode("utf-8") + for line in text.splitlines(): + if not line.strip(): + continue + obj = json.loads(line) + if not isinstance(obj, dict): + raise ValueError(f"{family}: manifest line is not an object: {line!r}") + yield cast("dict[str, object]", obj) + + +def _s(entry: dict[str, object], key: str) -> str: + value = entry[key] + if not isinstance(value, str): + raise TypeError(f"{key!r} must be a string, got {type(value).__name__}") + return value + + +def _opt_s(entry: dict[str, object], key: str) -> str | None: + value = entry[key] + if value is None or isinstance(value, str): + return value + raise TypeError(f"{key!r} must be a string or null, got {type(value).__name__}") + + +def _opt_s_default(entry: dict[str, object], key: str) -> str | None: + """Return the string value for ``key``, or None when absent or null.""" + value = entry.get(key) + if value is None or isinstance(value, str): + return value + raise TypeError(f"{key!r} must be a string or null, got {type(value).__name__}") + + +def _bool(entry: dict[str, object], key: str) -> bool: + value = entry[key] + if not isinstance(value, bool): + raise TypeError(f"{key!r} must be a bool, got {type(value).__name__}") + return value + + +def _int(entry: dict[str, object], key: str) -> int: + value = entry[key] + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{key!r} must be an int, got {type(value).__name__}") + return value + + +def _opt_int(entry: dict[str, object], key: str) -> int | None: + value = entry[key] + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{key!r} must be an int or null, got {type(value).__name__}") + return value + + +def _opt_float(entry: dict[str, object], key: str) -> float | None: + value = entry[key] + if value is None: + return None + if isinstance(value, bool): + raise TypeError(f"{key!r} must be a number or null, got bool") + if isinstance(value, int | float): + return float(value) + raise TypeError(f"{key!r} must be a number or null, got {type(value).__name__}") + + +def _str_list(entry: dict[str, object], key: str) -> tuple[str, ...]: + value = entry[key] + if not isinstance(value, list): + raise TypeError(f"{key!r} must be a list, got {type(value).__name__}") + out: list[str] = [] + for item in value: + if not isinstance(item, str): + raise TypeError(f"{key!r} entries must be strings") + out.append(item) + return tuple(out) + + +def _parse_params(raw: bytes) -> tuple[tuple[bytes, bytes], ...]: + """Decode a NUL-delimited ``name\\0value\\0...`` params blob.""" + if not raw: + return () + fields = raw.split(b"\x00") + if len(fields) % 2 != 0: + raise ValueError("params blob must hold an even number of NUL-delimited fields") + return tuple((fields[i], fields[i + 1]) for i in range(0, len(fields), 2)) + + +# ----- per-family loaders ------------------------------------------------- + + +def load_query_codec() -> tuple[QueryCodecVector, ...]: + """Load every URL query codec vector.""" + out: list[QueryCodecVector] = [] + for e in _entries("query_codec"): + out.append( + QueryCodecVector( + family="query_codec", + tag=_s(e, "tag"), + id=_s(e, "id"), + description=_s(e, "description"), + params=_parse_params(_read("query_codec", _s(e, "params_file"))), + query=_read("query_codec", _s(e, "query_file")), + render_matches=_bool(e, "render_matches"), + parse_matches=_bool(e, "parse_matches"), + ) + ) + return tuple(out) + + +def load_redirect() -> tuple[RedirectVector, ...]: + """Load every redirect ``Location`` resolution vector.""" + out: list[RedirectVector] = [] + for e in _entries("redirect"): + out.append( + RedirectVector( + family="redirect", + tag=_s(e, "tag"), + id=_s(e, "id"), + description=_s(e, "description"), + base=_read("redirect", _s(e, "base_file")), + location=_read("redirect", _s(e, "location_file")), + resolved=_s(e, "resolved").encode("ascii"), + ) + ) + return tuple(out) + + +def load_pagination_splice() -> tuple[PaginationSpliceVector, ...]: + """Load every raw-query pagination splice vector.""" + out: list[PaginationSpliceVector] = [] + for e in _entries("pagination_splice"): + out.append( + PaginationSpliceVector( + family="pagination_splice", + tag=_s(e, "tag"), + id=_s(e, "id"), + description=_s(e, "description"), + base=_read("pagination_splice", _s(e, "base_file")), + param=_s(e, "param").encode("ascii"), + value=_s(e, "value").encode("ascii"), + spliced=_s(e, "spliced").encode("ascii"), + untouched=tuple(s.encode("ascii") for s in _str_list(e, "untouched")), + ) + ) + return tuple(out) + + +def _load_events(entry: dict[str, object]) -> tuple[SseExpectedEvent, ...]: + raw = entry["events"] + if not isinstance(raw, list): + raise TypeError("'events' must be a list") + out: list[SseExpectedEvent] = [] + for item in raw: + if not isinstance(item, dict): + raise TypeError("each SSE event must be an object") + ev = cast("dict[str, object]", item) + out.append( + SseExpectedEvent( + data=_s(ev, "data"), + event=_opt_s(ev, "event"), + id=_opt_s(ev, "id"), + retry=_opt_int(ev, "retry"), + comment=_opt_s_default(ev, "comment"), + ) + ) + return tuple(out) + + +def load_sse() -> tuple[SseVector, ...]: + """Load every SSE stream vector (raw CR / LF / CRLF bytes preserved).""" + out: list[SseVector] = [] + for e in _entries("sse"): + out.append( + SseVector( + family="sse", + tag=_s(e, "tag"), + id=_s(e, "id"), + description=_s(e, "description"), + stream=_read("sse", _s(e, "stream_file")), + terminator=_s(e, "terminator"), + events=_load_events(e), + ) + ) + return tuple(out) + + +def load_retry_pacing() -> tuple[RetryPacingVector, ...]: + """Load every ``Retry-After`` pacing vector.""" + out: list[RetryPacingVector] = [] + for e in _entries("retry_pacing"): + out.append( + RetryPacingVector( + family="retry_pacing", + tag=_s(e, "tag"), + id=_s(e, "id"), + description=_s(e, "description"), + raw=_read("retry_pacing", _s(e, "raw_file")), + kind=_s(e, "kind"), + seconds=_opt_float(e, "seconds"), + now=_opt_float(e, "now"), + clamp_max=_opt_float(e, "clamp_max"), + clamped=_opt_float(e, "clamped"), + float_accepts=_bool(e, "float_accepts"), + note=_s(e, "note"), + ) + ) + return tuple(out) + + +def load_rfc1123_dates() -> tuple[Rfc1123Vector, ...]: + """Load every RFC 1123 date vector.""" + out: list[Rfc1123Vector] = [] + for e in _entries("rfc1123_dates"): + out.append( + Rfc1123Vector( + family="rfc1123_dates", + tag=_s(e, "tag"), + id=_s(e, "id"), + description=_s(e, "description"), + direction=_s(e, "direction"), + text=_read("rfc1123_dates", _s(e, "text_file")), + epoch=_int(e, "epoch"), + round_trippable=_bool(e, "round_trippable"), + ) + ) + return tuple(out) + + +def load_all() -> dict[str, tuple[GoldenVector, ...]]: + """Load every family, keyed by family name in ``FAMILIES`` order.""" + result: dict[str, tuple[GoldenVector, ...]] = {} + result["query_codec"] = load_query_codec() + result["redirect"] = load_redirect() + result["pagination_splice"] = load_pagination_splice() + result["sse"] = load_sse() + result["retry_pacing"] = load_retry_pacing() + result["rfc1123_dates"] = load_rfc1123_dates() + return result + + +__all__ = [ + "FAMILIES", + "FAMILY_TAGS", + "GoldenVector", + "PaginationSpliceVector", + "QueryCodecVector", + "RedirectVector", + "RetryPacingVector", + "Rfc1123Vector", + "SseExpectedEvent", + "SseVector", + "load_all", + "load_pagination_splice", + "load_query_codec", + "load_redirect", + "load_retry_pacing", + "load_rfc1123_dates", + "load_sse", +] diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/harness.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/harness.py new file mode 100644 index 0000000..d2a9123 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/harness.py @@ -0,0 +1,403 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Transport-neutral seam for the transport-adapter conformance battery. + +This module is the reusable contract layer. It defines *what* a transport +adapter must be programmed to do (``Outcome`` / ``ResponseSpec``), *what* the +battery observes about an exchange (``SentRequest``, ``DropLog``), and the two +harness ABCs (`SyncTransportHarness` / `AsyncTransportHarness`) that the battery +drives. It deliberately knows nothing about any concrete transport. + +A harness is the single seam between a test body and a concrete adapter: + +- the battery programs the next exchange with ``expect_*`` / ``mark_*`` methods, +- builds an adapter with ``new_client`` (or ``new_async_client``), +- and inspects what the adapter did through ``sent_requests`` / + ``attempt_count`` / ``drop_log`` / ``native_responses`` / ``byo_*``. + +Because every test body speaks only this seam, a transport adapter is certified +by shipping its own harness implementation and appending it to the registry — no +test body changes. This module and the ``test_*`` batteries ship together inside +the installed ``dexpace.sdk.tck`` package, and each transport-adapter package +supplies its own harness and registers it from that package's own +``tests/conftest.py``. + +Capability flags let a harness declare which contract facets it can simulate; +the battery skips the facets a given transport cannot exercise rather than +forcing every harness to model every native quirk. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum, auto +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Sequence + + from dexpace.sdk.core.client.async_http_client import AsyncHttpClient + from dexpace.sdk.core.client.http_client import HttpClient + from dexpace.sdk.core.http.common.headers import Headers + + +class OutcomeKind(Enum): + """The way a programmed native exchange resolves. + + The battery expresses transport behaviour in these neutral terms; each + harness maps them onto its transport's native mechanism (a raised native + exception, a mock response, a real socket close, ...). + """ + + RESPONSE = auto() + CONNECT_ERROR = auto() + CONNECT_TIMEOUT = auto() + READ_TIMEOUT = auto() + CANCELLED = auto() + PROTOCOL_ERROR = auto() + + +@dataclass(frozen=True) +class ResponseSpec: + """A native response the harness should hand back for one exchange. + + Attributes: + status: The status code to surface. A non-numeric string models a + response the adapter cannot adapt (adaptation must fail cleanly and + close the native response). + headers: Inbound header lines, verbatim — including ones the adapter + must defensively drop (a malformed name) or preserve (obs-text). + body: The buffered response payload (ignored when ``stream`` is set). + reason: The reason phrase, if any. + protocol: The wire protocol token (``http/1.1``, ``http/2``, ...). + stream: When true, the body is produced lazily by a native producer so + the battery can prove close-cascade and early-abandon behaviour. + chunks: The lazily produced chunks (used only when ``stream`` is set). + block_after_chunks: When true, the streaming producer blocks after + yielding ``chunks`` instead of ending — used to prove that an + abandoned or cancelled subscription unblocks the producer. + """ + + status: int | str = 200 + headers: Sequence[tuple[str, str]] = () + body: bytes = b"" + reason: str | None = None + protocol: str = "http/1.1" + stream: bool = False + chunks: Sequence[bytes] = () + block_after_chunks: bool = False + + +@dataclass(frozen=True) +class Outcome: + """A programmed result for a single native exchange.""" + + kind: OutcomeKind + response: ResponseSpec | None = None + + @classmethod + def of_response(cls, spec: ResponseSpec) -> Outcome: + """Wrap a `ResponseSpec` as a RESPONSE outcome.""" + return cls(OutcomeKind.RESPONSE, spec) + + +@dataclass +class SentRequest: + """What actually reached the native layer after the adapter prepared it. + + Captures the post-adaptation view: content-type resolved, framing headers + computed, native-rejected headers dropped. The battery asserts against this + record, never against the caller's original `Request`. + + Attributes: + method: The wire method token. + url: The wire-form URL. + headers: The headers the adapter handed to the native layer. + body: The bytes the adapter wrote to the wire. + timeout: The effective per-call timeout the adapter applied. + """ + + method: str + url: str + headers: Headers + body: bytes + timeout: float | None + + @property + def content_type(self) -> str | None: + """The content-type actually sent, or ``None``.""" + return self.headers.get("content-type") + + @property + def content_length(self) -> str | None: + """The content-length actually sent, or ``None``.""" + return self.headers.get("content-length") + + @property + def transfer_encoding(self) -> str | None: + """The transfer-encoding actually sent, or ``None``.""" + return self.headers.get("transfer-encoding") + + +class DropLog: + """Bounded, case-insensitive record of headers the adapter dropped. + + Models the observability contract for a header the model layer accepts but + the native library rejects: the drop is counted (never silent), the count + is case-insensitive (``X-Bad`` and ``x-bad`` are one entry), and the number + of *distinct* dropped names is bounded so a hostile stream of unique bad + names cannot grow the log without limit. + """ + + __slots__ = ("_cap", "_counts") + + def __init__(self, cap: int = 64) -> None: + self._cap = cap + self._counts: dict[str, int] = {} + + def record(self, name: str) -> None: + """Record one drop of ``name`` (case-insensitive, bounded by capacity).""" + key = name.lower() + if key in self._counts: + self._counts[key] += 1 + elif len(self._counts) < self._cap: + self._counts[key] = 1 + + def count(self, name: str) -> int: + """Return how many times ``name`` was dropped (case-insensitive).""" + return self._counts.get(name.lower(), 0) + + @property + def total(self) -> int: + """Total drops recorded across all names.""" + return sum(self._counts.values()) + + @property + def distinct(self) -> int: + """Number of distinct dropped names currently held.""" + return len(self._counts) + + @property + def capacity(self) -> int: + """The ceiling on distinct dropped names.""" + return self._cap + + +@runtime_checkable +class ClosableNative(Protocol): + """A native response handle whose release the battery can observe.""" + + @property + def closed(self) -> bool: + """Whether the underlying native response has been closed.""" + ... + + +class _BaseHarness(ABC): + """Shared programming and introspection seam for both pillars. + + Capability flags default to ``True`` for the in-memory reference fake, which + can simulate every facet. A real-adapter harness lowers the flags it cannot + model; the battery skips the corresponding tests instead of failing them. + """ + + #: Whether the transport accepts a caller-supplied (BYO) native client. + supports_byo: bool = True + #: Whether the harness can inject a header the native library rejects. + supports_native_header_rejection: bool = True + #: Whether the harness can produce a lazily-streamed response body. + supports_streaming: bool = True + #: Whether the adapter exposes proxy capability introspection. + supports_proxy_introspection: bool = True + #: Whether the harness can program a native cooperative-cancellation + #: outcome. The SDK's `HttpClient.execute(request)` seam has no + #: cancellation hook of its own (`Request` carries no cancellation + #: token) — cooperative cancellation is a pipeline/retry-loop concept in + #: this SDK, not a transport one. A harness backed by a real, minimal + #: transport with no cancellation channel of its own sets this `False` + #: rather than forcing a misleading `True`. + supports_cancellation: bool = True + #: Whether the adapter can frame an unknown-upfront-length request body + #: as genuine wire-level ``Transfer-Encoding: chunked`` rather than fully + #: buffering it first. A transport that always buffers the whole body + #: before send (documented as a "no streaming uploads" limitation) always + #: knows the exact length once buffering completes, so it correctly uses + #: ``Content-Length`` even for a body whose declared length was unknown + #: upfront — it never emits (and should not fake) chunked framing. + supports_unknown_length_chunked_framing: bool = True + #: Whether the harness can produce a native response that is obtained + #: successfully but then fails SDK-level adaptation (the fake models this + #: with a non-numeric status token). A transport whose native library + #: validates the status line itself (rejecting a non-numeric or + #: out-of-range token before handing back any response object at all) — + #: combined with the SDK's own `Status` being total over integers + #: (TRANSPORT-24: no valid status ever fails adaptation) — can have no + #: state where a native response is obtained yet adaptation fails, and + #: sets this `False` rather than forcing a misleading `True`. + supports_adaptation_failure: bool = True + #: Whether the adapter can recover a good header that arrives *after* a + #: malformed one in the same response. Every adapter in this SDK drops a + #: single malformed inbound header without aborting the conversion, but a + #: native library whose own header parser treats an invalid name as the + #: end of the header block (silently discarding every header that + #: follows) can lose "good" headers upstream of anything the adapter's + #: own conversion logic controls. Set this `False` rather than forcing a + #: misleading `True` when that upstream loss is real and verified, not + #: assumed. + supports_defensive_inbound_headers: bool = True + #: Smallest positive timeout the adapter will apply (sub-resolution values + #: clamp up to this rather than truncating to zero). + min_timeout_granularity: float = 0.001 + + @abstractmethod + def expect(self, outcome: Outcome) -> None: + """Program the next native exchange to resolve as ``outcome``.""" + + @abstractmethod + def sent_requests(self) -> list[SentRequest]: + """Return, in order, every request the adapter handed to the native layer.""" + + @abstractmethod + def attempt_count(self) -> int: + """Return how many native exchanges the adapter initiated.""" + + @abstractmethod + def drop_log(self) -> DropLog: + """Return the log of headers the adapter dropped as native-rejected.""" + + @abstractmethod + def native_responses(self) -> list[ClosableNative]: + """Return the native response handles produced so far (for close checks).""" + + @abstractmethod + def mark_native_rejected(self, *names: str) -> None: + """Declare header names the native library rejects (adapter must drop them).""" + + @abstractmethod + def byo_underlying(self) -> ClosableNative | None: + """Return the caller-supplied native client, if the adapter was built with one.""" + + def unsupported_proxy_features(self, client: object) -> frozenset[str]: + """Return proxy features the built ``client`` cannot honour. + + Only meaningful when ``supports_proxy_introspection`` is set; the base + implementation raises so a harness that advertises the capability must + override it. + """ + raise NotImplementedError + + def shutdown(self) -> None: # noqa: B027 # intentional no-op default, not abstract + """Release any process-level resources the harness holds. Idempotent. + + The battery's fixtures call this in teardown. A harness backed by a + real server/socket (rather than the in-memory fake) overrides it to + tear that down; the default is intentionally a no-op — an in-memory + harness holds nothing to release, so this is a real (empty) default, + not an unimplemented abstract method. + """ + + # ----- Convenience programming (implemented on the neutral seam) -------- + + def expect_response( + self, + status: int | str = 200, + *, + headers: Sequence[tuple[str, str]] = (), + body: bytes = b"", + reason: str | None = None, + protocol: str = "http/1.1", + stream: bool = False, + chunks: Sequence[bytes] = (), + block_after_chunks: bool = False, + ) -> None: + """Program a native response for the next exchange.""" + self.expect( + Outcome.of_response( + ResponseSpec( + status=status, + headers=tuple(headers), + body=body, + reason=reason, + protocol=protocol, + stream=stream, + chunks=tuple(chunks), + block_after_chunks=block_after_chunks, + ) + ) + ) + + def expect_connect_error(self) -> None: + """Program a connection-level failure that never yields a response.""" + self.expect(Outcome(OutcomeKind.CONNECT_ERROR)) + + def expect_connect_timeout(self) -> None: + """Program a connect-phase timeout.""" + self.expect(Outcome(OutcomeKind.CONNECT_TIMEOUT)) + + def expect_read_timeout(self) -> None: + """Program a read-phase timeout.""" + self.expect(Outcome(OutcomeKind.READ_TIMEOUT)) + + def expect_cancellation(self) -> None: + """Program a cooperative cancellation of the in-flight exchange.""" + self.expect(Outcome(OutcomeKind.CANCELLED)) + + def expect_protocol_error(self) -> None: + """Program a generic transport error mid-exchange.""" + self.expect(Outcome(OutcomeKind.PROTOCOL_ERROR)) + + +class SyncTransportHarness(_BaseHarness): + """Builds and programs a synchronous transport adapter under test.""" + + @abstractmethod + def new_client(self, **options: object) -> HttpClient: + """Return a fresh synchronous adapter bound to this harness's program. + + Recognised options (a real-adapter harness may ignore any it cannot + honour): ``timeout`` (float), ``byo`` (bool — build over a + caller-supplied underlying client), ``proxy_url`` (str), + ``proxy_features`` (frozenset[str]). + """ + + @abstractmethod + def close_client(self, client: HttpClient) -> None: + """Close ``client`` through whatever close surface the adapter exposes.""" + + +class AsyncTransportHarness(_BaseHarness): + """Builds and programs an asynchronous transport adapter under test.""" + + @abstractmethod + def new_async_client(self, **options: object) -> AsyncHttpClient: + """Return a fresh asynchronous adapter bound to this harness's program. + + See `SyncTransportHarness.new_client` for the recognised options. + """ + + @abstractmethod + async def aclose_client(self, client: AsyncHttpClient) -> None: + """Close ``client`` through whatever async close surface the adapter exposes.""" + + +# Registries each transport-adapter package appends its harness factory to. +# Populated for the in-memory reference fake in this package's ``conftest.py``; +# each real adapter appends its factory from its own package's ``tests/conftest.py``. +SYNC_HARNESS_FACTORIES: list[tuple[str, type[SyncTransportHarness]]] = [] +ASYNC_HARNESS_FACTORIES: list[tuple[str, type[AsyncTransportHarness]]] = [] + + +__all__ = [ + "ASYNC_HARNESS_FACTORIES", + "SYNC_HARNESS_FACTORIES", + "AsyncTransportHarness", + "ClosableNative", + "DropLog", + "Outcome", + "OutcomeKind", + "ResponseSpec", + "SentRequest", + "SyncTransportHarness", +] diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/py.typed b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_async_contract.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_async_contract.py new file mode 100644 index 0000000..e647c65 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_async_contract.py @@ -0,0 +1,343 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Asynchronous transport-adapter conformance battery. + +The async twin of ``test_sync_contract``. It drives an ``async_harness`` and +covers the facets that are async-specific — pre-dispatch failure surfacing +through the returned awaitable, ``asyncio.CancelledError`` cancellation with a +shielded close cascade — alongside the shared framing / header / status +contract that both pillars must honour. +""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +from typing import TYPE_CHECKING + +import pytest + +from dexpace.sdk.core.errors import ( + NetworkError, + SdkError, + ServiceRequestError, + ServiceRequestTimeoutError, + ServiceResponseError, + ServiceResponseTimeoutError, +) +from dexpace.sdk.core.http.common import Url +from dexpace.sdk.core.http.common.media_type import MediaType +from dexpace.sdk.core.http.request import Method, Request, RequestBody +from dexpace.sdk.core.http.response import AsyncResponse, Status + +if TYPE_CHECKING: + from .harness import AsyncTransportHarness + +_URL = "http://service.test/v1" + + +def _get(url: str = _URL, *, timeout: float | None = None) -> Request: + return Request(method=Method.GET, url=Url.parse(url), timeout=timeout) + + +def _post(body: RequestBody, url: str = "http://service.test/upload") -> Request: + return Request(method=Method.POST, url=Url.parse(url), body=body) + + +async def _execute_error(harness: AsyncTransportHarness, request: Request) -> BaseException: + """Await ``request`` expecting a failure; return the raised error.""" + client = harness.new_async_client() + with pytest.raises(SdkError) as excinfo: + await client.execute(request) + return excinfo.value + + +# --------------------------------------------------------------------------- # +# Success shape + redirect/retry inertia. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("ASYNC-1", "SEAM-16", "TRANSPORT-23") +async def test_success_yields_a_response_never_none(async_harness: AsyncTransportHarness) -> None: + async_harness.expect_response(200, body=b"ok") + response = await async_harness.new_async_client().execute(_get()) + assert response is not None + assert isinstance(response, AsyncResponse) + async with response as resp: + assert resp.body is not None + assert await resp.body.bytes() == b"ok" + + +@pytest.mark.req("TRANSPORT-1") +async def test_adapter_does_not_follow_redirects(async_harness: AsyncTransportHarness) -> None: + async_harness.expect_response(302, headers=[("Location", "http://service.test/moved")]) + async with await async_harness.new_async_client().execute(_get()) as response: + assert response.status == Status(302) + assert async_harness.attempt_count() == 1 + + +@pytest.mark.req("TRANSPORT-2") +async def test_adapter_does_not_retry_on_failure(async_harness: AsyncTransportHarness) -> None: + async_harness.expect_connect_error() + await _execute_error(async_harness, _get()) + assert async_harness.attempt_count() == 1 + + +# --------------------------------------------------------------------------- # +# Error mapping + pre-dispatch surfacing. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("ASYNC-1", "TRANSPORT-20") +async def test_connection_failure_maps_to_retryable_network_error( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_connect_error() + err = await _execute_error(async_harness, _get()) + assert isinstance(err, ServiceRequestError) + assert isinstance(err, NetworkError) + assert err.retryable is True + + +async def test_connect_timeout_maps_to_retryable_timeout( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_connect_timeout() + err = await _execute_error(async_harness, _get()) + assert isinstance(err, ServiceRequestTimeoutError) + assert err.retryable is True + + +async def test_read_timeout_maps_to_response_timeout( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_read_timeout() + err = await _execute_error(async_harness, _get()) + assert isinstance(err, ServiceResponseTimeoutError) + + +@pytest.mark.req("ASYNC-2", "SEAM-17", "TRANSPORT-21", "TRANSPORT-23") +async def test_pre_dispatch_failure_surfaces_through_awaitable( + async_harness: AsyncTransportHarness, +) -> None: + # A pre-dispatch failure must not throw synchronously at call time; it rides + # out on the returned awaitable so `async` call sites see it uniformly. + client = async_harness.new_async_client() + await async_harness.aclose_client(client) + coro = client.execute(_get()) + assert inspect.iscoroutine(coro) + with pytest.raises(ServiceRequestError): + await coro + + +# --------------------------------------------------------------------------- # +# Content-Type resolution + adapter-computed framing. +# --------------------------------------------------------------------------- # + + +async def test_explicit_content_type_is_authoritative( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_response(200) + body = RequestBody.from_bytes(b"{}", MediaType.of("application", "json")) + request = _post(body).with_header("Content-Type", "text/plain") + await (await async_harness.new_async_client().execute(request)).close() + assert async_harness.sent_requests()[0].content_type == "text/plain" + + +async def test_body_derived_content_type_used_when_absent( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_response(200) + body = RequestBody.from_bytes(b"{}", MediaType.of("application", "json")) + await (await async_harness.new_async_client().execute(_post(body))).close() + assert async_harness.sent_requests()[0].content_type == "application/json" + + +async def test_empty_body_bearing_method_sends_zero_length( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_response(200) + request = Request(method=Method.POST, url=Url.parse("http://service.test/x")) + await (await async_harness.new_async_client().execute(request)).close() + assert async_harness.sent_requests()[0].content_length == "0" + + +@pytest.mark.req("TRANSPORT-17") +async def test_single_use_body_written_exactly_once( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_response(200) + body = RequestBody.from_iter(iter([b"chunk-1;", b"chunk-2"])) + await (await async_harness.new_async_client().execute(_post(body))).close() + assert async_harness.sent_requests()[0].body == b"chunk-1;chunk-2" + with pytest.raises(RuntimeError): + list(body.iter_bytes()) + + +# --------------------------------------------------------------------------- # +# Header hygiene. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-12") +async def test_native_rejected_header_dropped_counted_and_logged( + async_harness: AsyncTransportHarness, + caplog: pytest.LogCaptureFixture, +) -> None: + if not async_harness.supports_native_header_rejection: + pytest.skip("harness cannot inject a native-rejected header") + async_harness.mark_native_rejected("X-Bad") + async_harness.expect_response(200) + request = _get().with_header("X-Bad", "boom").with_header("X-Ok", "fine") + with caplog.at_level(logging.WARNING): + await (await async_harness.new_async_client().execute(request)).close() + sent = async_harness.sent_requests()[0] + assert sent.headers.get("x-bad") is None + assert sent.headers.get("x-ok") == "fine" + assert async_harness.drop_log().count("X-BAD") == 1 + assert any("native-rejected" in message for message in caplog.messages) + + +async def test_malformed_inbound_header_dropped_obs_text_preserved( + async_harness: AsyncTransportHarness, +) -> None: + if not async_harness.supports_defensive_inbound_headers: + pytest.skip("harness cannot recover a good header following a malformed one") + async_harness.expect_response( + 200, + headers=[("X-Good", "ok"), ("Bad Name", "x"), ("X-Obs", "caf\xe9")], + ) + async with await async_harness.new_async_client().execute(_get()) as response: + assert set(response.headers.names()) == {"x-good", "x-obs"} + assert response.headers.get("x-obs") == "caf\xe9" + + +# --------------------------------------------------------------------------- # +# Response lifecycle: adaptation failure, close cascade, cancellation. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("ASYNC-5", "TRANSPORT-22") +async def test_adaptation_failure_closes_native_response( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_response("BAD") + with pytest.raises(ServiceResponseError): + await async_harness.new_async_client().execute(_get()) + assert async_harness.native_responses()[-1].closed is True + + +@pytest.mark.req("TRANSPORT-25") +async def test_streamed_response_close_cascades_to_native( + async_harness: AsyncTransportHarness, +) -> None: + if not async_harness.supports_streaming: + pytest.skip("harness cannot produce a streamed response body") + async_harness.expect_response(200, stream=True, chunks=[b"x", b"y"]) + response = await async_harness.new_async_client().execute(_get()) + await response.close() + assert async_harness.native_responses()[-1].closed is True + + +@pytest.mark.req("ASYNC-6", "ASYNC-7", "SEAM-13", "SEAM-30", "TRANSPORT-7") +async def test_cancellation_releases_native_via_shielded_close( + async_harness: AsyncTransportHarness, +) -> None: + if not async_harness.supports_streaming: + pytest.skip("harness cannot produce a streamed response body") + async_harness.expect_response(200, stream=True, chunks=[b"one"], block_after_chunks=True) + response = await async_harness.new_async_client().execute(_get()) + assert response.body is not None + body = response.body + + async def consume() -> None: + async for _chunk in body.aiter_bytes(3): + pass + + task = asyncio.create_task(consume()) + await asyncio.sleep(0.02) # let the consumer pull "one" and block for more + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + # The shielded close in the body iterator releases the native resource even + # though the consuming task was cancelled mid-stream. + assert async_harness.native_responses()[-1].closed is True + + +# --------------------------------------------------------------------------- # +# Concurrency + status totality + ownership + proxy. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("ASYNC-22", "SEAM-12", "TRANSPORT-29") +async def test_concurrent_execute_is_safe(async_harness: AsyncTransportHarness) -> None: + count = 16 + for _ in range(count): + async_harness.expect_response(200, body=b"ok") + client = async_harness.new_async_client() + + async def run() -> int: + async with await client.execute(_get()) as response: + assert response.body is not None + await response.body.bytes() + return int(response.status) + + results = await asyncio.gather(*[run() for _ in range(count)]) + assert results == [200] * count + assert async_harness.attempt_count() == count + + +@pytest.mark.req("TRANSPORT-24") +async def test_out_of_range_status_surfaces_with_body( + async_harness: AsyncTransportHarness, +) -> None: + async_harness.expect_response(999, body=b"vendor code") + async with await async_harness.new_async_client().execute(_get()) as response: + assert int(response.status) == 999 + assert response.body is not None + assert await response.body.bytes() == b"vendor code" + + +@pytest.mark.req("ASYNC-15") +async def test_byo_client_survives_adapter_close(async_harness: AsyncTransportHarness) -> None: + if not async_harness.supports_byo: + pytest.skip("harness cannot build over a caller-supplied client") + async_harness.expect_response(200) + client = async_harness.new_async_client(byo=True) + await (await client.execute(_get())).close() + await async_harness.aclose_client(client) + byo = async_harness.byo_underlying() + assert byo is not None + assert byo.closed is False + await async_harness.aclose_client(client) # idempotent + + +@pytest.mark.req("ASYNC-2", "SEAM-15") +async def test_execute_after_close_raises(async_harness: AsyncTransportHarness) -> None: + client = async_harness.new_async_client() + await async_harness.aclose_client(client) + with pytest.raises(ServiceRequestError): + await client.execute(_get()) + + +async def test_proxy_credentials_never_leak(async_harness: AsyncTransportHarness) -> None: + if not async_harness.supports_proxy_introspection: + pytest.skip("harness has no proxy surface") + client = async_harness.new_async_client(proxy_url="http://user:s3cret@proxy.test:8080") + assert "s3cret" not in repr(client) + + +async def test_unsupported_proxy_feature_is_discoverable( + async_harness: AsyncTransportHarness, +) -> None: + if not async_harness.supports_proxy_introspection: + pytest.skip("harness has no proxy surface") + client = async_harness.new_async_client( + proxy_url="socks5://user:s3cret@proxy.test:1080", + proxy_features=frozenset({"socks"}), + ) + unsupported = async_harness.unsupported_proxy_features(client) + assert "scheme:socks5" in unsupported diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_corpus_packaging.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_corpus_packaging.py new file mode 100644 index 0000000..7753657 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_corpus_packaging.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Prove the golden corpus ships as package data and loads from the install. + +The corpus must load through ``importlib.resources`` from the installed +``dexpace.sdk.tck`` distribution — never from a repo-relative path — so a +consumer that ``pip install``s the wheel and runs ``pytest --pyargs +dexpace.sdk.tck`` from any working directory gets the exact fixture bytes. These +tests read the corpus as package-data resources and, as an external-invocation +smoke, load it in a subprocess whose working directory is *outside* the repo +tree. +""" + +from __future__ import annotations + +import subprocess +import sys +from importlib.resources import files +from pathlib import Path + +from dexpace.sdk.tck.golden_corpus import FAMILIES, load_all + +_CORPUS_ANCHOR = "dexpace.sdk.tck.golden_corpus" + + +def test_every_family_loads_as_package_data() -> None: + corpus = load_all() + assert set(corpus) == set(FAMILIES) + for family in FAMILIES: + assert corpus[family], f"{family} loaded empty from package data" + + +def test_binary_fixture_bytes_survive_package_data_loading() -> None: + # A CRLF-terminated SSE fixture is the sharpest proof that the resource is + # read as raw bytes (never text-mode) straight out of the packaged data. + resource = files(_CORPUS_ANCHOR).joinpath("data", "sse", "crlf-single-event.sse") + assert resource.read_bytes() == b"data: hello\r\n\r\n" + + +def test_corpus_loads_from_outside_the_repo_tree(tmp_path: Path) -> None: + # Run from a cwd outside the repository so nothing repo-relative can be in + # play: the corpus must be found purely through the installed package. + script = ( + "from dexpace.sdk.tck.golden_corpus import FAMILIES, load_all\n" + "corpus = load_all()\n" + "assert set(corpus) == set(FAMILIES), corpus.keys()\n" + "assert all(corpus[f] for f in FAMILIES)\n" + "print('OK', sum(len(corpus[f]) for f in FAMILIES))\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.startswith("OK ") diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_import_discipline.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_import_discipline.py new file mode 100644 index 0000000..e862e95 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_import_discipline.py @@ -0,0 +1,81 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Enforce that the TCK touches no underscore-prefixed (private) core module. + +The kit is contractually built against core's PUBLIC re-exported surface only: +it must never reach into a ``dexpace.sdk.core`` module (or import a name) whose +dotted path carries an underscore-prefixed component. That doubles as an +underscore-discipline check on core itself — if the battery genuinely needed a +private module, the symbol would have to be promoted to the public surface +rather than reached into from here. + +This test AST-scans every ``.py`` file shipped in the ``dexpace.sdk.tck`` +package (static parse, no execution) and asserts the import graph names no +private core module or private core name. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import dexpace.sdk.tck + +_CORE_PREFIX = "dexpace.sdk.core" + + +def _package_root() -> Path: + """Return the on-disk directory of the installed ``dexpace.sdk.tck`` package.""" + origin = dexpace.sdk.tck.__file__ + assert origin is not None + return Path(origin).resolve().parent + + +def _has_private_component(dotted: str) -> bool: + """Return whether any component of ``dotted`` is underscore-prefixed. + + Dunder components (``__init__`` and friends) are not counted — no core + module is a dunder — so only genuine single-underscore privates flag. + """ + return any(part.startswith("_") and not part.startswith("__") for part in dotted.split(".")) + + +def _private_core_imports(tree: ast.Module) -> list[str]: + """Collect every private-core import target named in one module AST. + + Flags both ``import dexpace.sdk.core.x._y`` (a private module path) and + ``from dexpace.sdk.core.x import _y`` (a private module or private name); + relative imports (which target the TCK package itself) are ignored. + """ + offenders: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + offenders.extend( + alias.name + for alias in node.names + if alias.name.startswith(_CORE_PREFIX) and _has_private_component(alias.name) + ) + elif isinstance(node, ast.ImportFrom): + module = node.module + if node.level != 0 or module is None or not module.startswith(_CORE_PREFIX): + continue + if _has_private_component(module): + offenders.append(module) + offenders.extend( + f"{module}.{alias.name}" + for alias in node.names + if alias.name.startswith("_") and not alias.name.startswith("__") + ) + return offenders + + +def test_tck_imports_no_private_core_module() -> None: + root = _package_root() + violations: dict[str, list[str]] = {} + for path in sorted(root.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + offenders = _private_core_imports(tree) + if offenders: + violations[str(path.relative_to(root))] = offenders + assert not violations, f"TCK imports private core module(s): {violations}" diff --git a/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_sync_contract.py b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_sync_contract.py new file mode 100644 index 0000000..453d683 --- /dev/null +++ b/packages/dexpace-sdk-tck/src/dexpace/sdk/tck/test_sync_contract.py @@ -0,0 +1,557 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Synchronous transport-adapter conformance battery. + +Every test drives a ``sync_harness`` — the parametrized seam — and never a +concrete transport. The suite is proven against the in-memory reference fake; +a certification task points it at a real adapter by registering that adapter's +harness (see ``conftest.py``), with no change to any test body below. +""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from dexpace.sdk.core.errors import ( + NetworkError, + RequestCancelledError, + SdkError, + ServiceRequestError, + ServiceRequestTimeoutError, + ServiceResponseError, + ServiceResponseTimeoutError, +) +from dexpace.sdk.core.http.common import Url +from dexpace.sdk.core.http.common.headers import Headers +from dexpace.sdk.core.http.common.media_type import MediaType +from dexpace.sdk.core.http.request import Method, Request, RequestBody +from dexpace.sdk.core.http.response import Response, Status + +if TYPE_CHECKING: + from collections.abc import Iterator + + from .harness import SyncTransportHarness + +_URL = "http://service.test/v1" + + +def _get(url: str = _URL, *, timeout: float | None = None) -> Request: + return Request(method=Method.GET, url=Url.parse(url), timeout=timeout) + + +def _post(body: RequestBody, url: str = "http://service.test/upload") -> Request: + return Request(method=Method.POST, url=Url.parse(url), body=body) + + +def _classify(err: BaseException) -> str: + """Discriminate an error by TYPE, timeout before cancellation. + + Both timeout types and ``RequestCancelledError`` are ``OSError`` subtypes, + so the timeout check must come first or a timeout would be misread as a + cancellation (or a generic network error). This mirrors the ordering a + retry policy must use. + """ + if isinstance(err, ServiceRequestTimeoutError | ServiceResponseTimeoutError): + return "timeout" + if isinstance(err, RequestCancelledError): + return "cancelled" + if isinstance(err, NetworkError): + return "network" + return "other" + + +def _execute_error(harness: SyncTransportHarness, request: Request) -> BaseException: + """Execute ``request`` expecting a failure; return the raised error.""" + client = harness.new_client() + with pytest.raises(SdkError) as excinfo: + client.execute(request) + return excinfo.value + + +# --------------------------------------------------------------------------- # +# Success shape + redirect/retry inertia. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-11") +def test_success_yields_a_response_never_none(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200, body=b"ok") + client = sync_harness.new_client() + response = client.execute(_get()) + assert response is not None + assert isinstance(response, Response) + with response as resp: + assert resp.body is not None + assert resp.body.bytes() == b"ok" + + +@pytest.mark.req("TRANSPORT-1") +def test_adapter_does_not_follow_redirects(sync_harness: SyncTransportHarness) -> None: + # A 3xx surfaces as an ordinary response; the SDK redirect policy — not the + # adapter — owns following it. Exactly one exchange reaches the transport. + sync_harness.expect_response(302, headers=[("Location", "http://service.test/moved")]) + client = sync_harness.new_client() + with client.execute(_get()) as response: + assert response.status == Status(302) + assert response.headers.get("location") == "http://service.test/moved" + assert sync_harness.attempt_count() == 1 + + +@pytest.mark.req("TRANSPORT-2") +def test_adapter_does_not_retry_on_failure(sync_harness: SyncTransportHarness) -> None: + # A native failure is mapped and raised once; retry is a pipeline policy, + # never adapter magic. Only one exchange is attempted. + sync_harness.expect_connect_error() + _execute_error(sync_harness, _get()) + assert sync_harness.attempt_count() == 1 + + +# --------------------------------------------------------------------------- # +# Error mapping. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-20") +def test_connection_failure_maps_to_retryable_network_error( + sync_harness: SyncTransportHarness, +) -> None: + sync_harness.expect_connect_error() + err = _execute_error(sync_harness, _get()) + assert isinstance(err, ServiceRequestError) + assert isinstance(err, NetworkError) + assert err.retryable is True + + +@pytest.mark.req("TRANSPORT-20") +def test_connect_timeout_maps_to_retryable_timeout_type( + sync_harness: SyncTransportHarness, +) -> None: + sync_harness.expect_connect_timeout() + err = _execute_error(sync_harness, _get()) + assert isinstance(err, ServiceRequestTimeoutError) + assert isinstance(err, NetworkError) + assert err.retryable is True + assert not isinstance(err, RequestCancelledError) + assert _classify(err) == "timeout" + + +def test_read_timeout_maps_to_response_timeout_type(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_read_timeout() + err = _execute_error(sync_harness, _get()) + assert isinstance(err, ServiceResponseTimeoutError) + assert not isinstance(err, RequestCancelledError) + assert _classify(err) == "timeout" + + +@pytest.mark.req("TRANSPORT-3") +def test_cancellation_maps_to_terminal_cancelled(sync_harness: SyncTransportHarness) -> None: + if not sync_harness.supports_cancellation: + pytest.skip("harness cannot program a native cooperative-cancellation outcome") + sync_harness.expect_cancellation() + err = _execute_error(sync_harness, _get()) + assert isinstance(err, RequestCancelledError) + assert isinstance(err, OSError) + assert not isinstance(err, NetworkError) + assert not isinstance(err, ServiceRequestTimeoutError | ServiceResponseTimeoutError) + assert err.retryable is False + assert _classify(err) == "cancelled" + + +@pytest.mark.req("TRANSPORT-3") +def test_timeout_is_discriminated_before_cancellation(sync_harness: SyncTransportHarness) -> None: + # Both a timeout and a cancellation are OSErrors, yet the type-ordered + # classifier keeps them apart: timeout -> retryable, cancellation -> terminal. + if not sync_harness.supports_cancellation: + pytest.skip("harness cannot program a native cooperative-cancellation outcome") + sync_harness.expect_connect_timeout() + timeout_err = _execute_error(sync_harness, _get()) + sync_harness.expect_cancellation() + cancel_err = _execute_error(sync_harness, _get()) + assert isinstance(timeout_err, OSError) and isinstance(cancel_err, OSError) + assert _classify(timeout_err) == "timeout" + assert _classify(cancel_err) == "cancelled" + + +def test_protocol_error_maps_to_request_error(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_protocol_error() + err = _execute_error(sync_harness, _get()) + assert isinstance(err, ServiceRequestError) + + +# --------------------------------------------------------------------------- # +# Content-Type resolution + adapter-computed framing. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-10") +def test_explicit_content_type_is_authoritative(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + body = RequestBody.from_bytes(b"{}", MediaType.of("application", "json")) + request = _post(body).with_header("Content-Type", "text/plain") + sync_harness.new_client().execute(request).close() + assert sync_harness.sent_requests()[0].content_type == "text/plain" + + +@pytest.mark.req("TRANSPORT-10") +def test_body_derived_content_type_used_when_absent(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + body = RequestBody.from_bytes(b"{}", MediaType.of("application", "json")) + sync_harness.new_client().execute(_post(body)).close() + assert sync_harness.sent_requests()[0].content_type == "application/json" + + +@pytest.mark.req("TRANSPORT-11") +def test_content_length_is_computed_by_adapter(sync_harness: SyncTransportHarness) -> None: + # A caller-set Content-Length that disagrees with the real body is not + # trusted: the adapter recomputes it from the bytes it actually writes. + sync_harness.expect_response(200) + request = Request( + method=Method.POST, + url=Url.parse("http://service.test/upload"), + headers=Headers([("Content-Length", "999")]), + body=RequestBody.from_bytes(b"hello world"), + ) + sync_harness.new_client().execute(request).close() + sent = sync_harness.sent_requests()[0] + assert sent.content_length == "11" + assert sent.transfer_encoding is None + + +def test_unknown_length_body_is_chunked_without_content_length( + sync_harness: SyncTransportHarness, +) -> None: + if not sync_harness.supports_unknown_length_chunked_framing: + pytest.skip("harness always buffers the body fully before send") + sync_harness.expect_response(200) + request = Request( + method=Method.POST, + url=Url.parse("http://service.test/upload"), + headers=Headers([("Content-Length", "5")]), + body=RequestBody.from_iter(iter([b"a", b"bc"])), + ) + sync_harness.new_client().execute(request).close() + sent = sync_harness.sent_requests()[0] + assert sent.content_length is None + assert sent.transfer_encoding == "chunked" + + +@pytest.mark.req("TRANSPORT-26") +def test_empty_body_bearing_method_sends_zero_length(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + request = Request(method=Method.POST, url=Url.parse("http://service.test/x")) + sync_harness.new_client().execute(request).close() + assert sync_harness.sent_requests()[0].content_length == "0" + + +@pytest.mark.req("TRANSPORT-26") +def test_bodyless_get_has_no_content_length(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + sync_harness.new_client().execute(_get()).close() + assert sync_harness.sent_requests()[0].content_length is None + + +# --------------------------------------------------------------------------- # +# Header hygiene: native-rejected outbound + defensive inbound. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-12", "TRANSPORT-13") +def test_native_rejected_header_dropped_counted_and_logged( + sync_harness: SyncTransportHarness, + caplog: pytest.LogCaptureFixture, +) -> None: + if not sync_harness.supports_native_header_rejection: + pytest.skip("harness cannot inject a native-rejected header") + sync_harness.mark_native_rejected("X-Bad") + sync_harness.expect_response(200) + request = _get().with_header("X-Bad", "boom").with_header("X-Ok", "fine") + with caplog.at_level(logging.WARNING): + sync_harness.new_client().execute(request).close() + sent = sync_harness.sent_requests()[0] + assert sent.headers.get("x-bad") is None + assert sent.headers.get("x-ok") == "fine" + # Counted case-insensitively; the drop is observable in the log. + assert sync_harness.drop_log().count("X-BAD") == 1 + assert sync_harness.drop_log().count("x-bad") == 1 + assert any("native-rejected" in message for message in caplog.messages) + + +@pytest.mark.req("TRANSPORT-13") +def test_native_rejected_drop_log_is_bounded(sync_harness: SyncTransportHarness) -> None: + if not sync_harness.supports_native_header_rejection: + pytest.skip("harness cannot inject a native-rejected header") + drop_log = sync_harness.drop_log() + names = [f"X-Bad-{i}" for i in range(drop_log.capacity + 20)] + sync_harness.mark_native_rejected(*names) + sync_harness.expect_response(200) + request = _get() + for name in names: + request = request.with_header(name, "x") + sync_harness.new_client().execute(request).close() + assert drop_log.distinct <= drop_log.capacity + + +@pytest.mark.req("TRANSPORT-14") +def test_malformed_inbound_header_dropped_obs_text_preserved( + sync_harness: SyncTransportHarness, +) -> None: + if not sync_harness.supports_defensive_inbound_headers: + pytest.skip("harness cannot recover a good header following a malformed one") + sync_harness.expect_response( + 200, + headers=[("X-Good", "ok"), ("Bad Name", "x"), ("X-Obs", "caf\xe9")], + ) + with sync_harness.new_client().execute(_get()) as response: + assert set(response.headers.names()) == {"x-good", "x-obs"} + assert response.headers.get("x-obs") == "caf\xe9" + + +@pytest.mark.req("TRANSPORT-27") +def test_malformed_inbound_content_length_downgraded(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200, headers=[("Content-Length", "not-a-number")], body=b"data") + with sync_harness.new_client().execute(_get()) as response: + assert response.body is not None + assert response.body.content_length() == -1 + assert response.body.bytes() == b"data" + + +@pytest.mark.req("TRANSPORT-27") +def test_malformed_inbound_content_type_downgraded(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200, headers=[("Content-Type", "///bad")], body=b"data") + with sync_harness.new_client().execute(_get()) as response: + assert response.body is not None + assert response.body.media_type() is None + + +# --------------------------------------------------------------------------- # +# Request body semantics. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-17") +def test_single_use_body_written_exactly_once(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + body = RequestBody.from_iter(iter([b"chunk-1;", b"chunk-2"])) + sync_harness.new_client().execute(_post(body)).close() + assert sync_harness.sent_requests()[0].body == b"chunk-1;chunk-2" + assert sync_harness.attempt_count() == 1 + with pytest.raises(RuntimeError): + list(body.iter_bytes()) + + +@pytest.mark.req("TRANSPORT-17", "TRANSPORT-18") +def test_replayable_body_replays_byte_identical(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + sync_harness.expect_response(200) + body = RequestBody.from_bytes(b"payload-bytes") + client = sync_harness.new_client() + client.execute(_post(body)).close() + client.execute(_post(body)).close() + sent = sync_harness.sent_requests() + assert sent[0].body == sent[1].body == b"payload-bytes" + + +@pytest.mark.req("TRANSPORT-28") +def test_file_backed_body_is_replayable(sync_harness: SyncTransportHarness, tmp_path: Path) -> None: + path = tmp_path / "payload.bin" + path.write_bytes(b"file-payload") + sync_harness.expect_response(200) + sync_harness.expect_response(200) + body = RequestBody.from_file(path) + client = sync_harness.new_client() + client.execute(_post(body)).close() + client.execute(_post(body)).close() + sent = sync_harness.sent_requests() + assert sent[0].body == sent[1].body == b"file-payload" + + +@pytest.mark.req("TRANSPORT-18") +def test_replayable_buffering_failure_is_clean(sync_harness: SyncTransportHarness) -> None: + # A body-producer whose buffering fails must fail loudly, not send a + # truncated payload or a silent empty body. + sync_harness.expect_response(200) + body = _FailingBody() + with pytest.raises(OSError, match="disk gone"): + sync_harness.new_client().execute(_post(body)) + assert sync_harness.attempt_count() == 0 + assert sync_harness.sent_requests() == [] + + +# --------------------------------------------------------------------------- # +# Response lifecycle: adaptation failure + streaming close cascade. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-22") +def test_adaptation_failure_closes_native_response(sync_harness: SyncTransportHarness) -> None: + # A non-numeric status models a native response the adapter cannot adapt. + if not sync_harness.supports_adaptation_failure: + pytest.skip("harness cannot obtain a native response that then fails adaptation") + sync_harness.expect_response("BAD") + with pytest.raises(ServiceResponseError): + sync_harness.new_client().execute(_get()) + assert sync_harness.native_responses()[-1].closed is True + + +@pytest.mark.req("SEAM-11", "TRANSPORT-25") +def test_streamed_response_close_cascades_to_native(sync_harness: SyncTransportHarness) -> None: + if not sync_harness.supports_streaming: + pytest.skip("harness cannot produce a streamed response body") + sync_harness.expect_response(200, stream=True, chunks=[b"a", b"b", b"c"]) + response = sync_harness.new_client().execute(_get()) + response.close() + assert sync_harness.native_responses()[-1].closed is True + + +@pytest.mark.req("TRANSPORT-25") +def test_abandoned_stream_releases_producer(sync_harness: SyncTransportHarness) -> None: + if not sync_harness.supports_streaming: + pytest.skip("harness cannot produce a streamed response body") + sync_harness.expect_response(200, stream=True, chunks=[b"one", b"two", b"three"]) + response = sync_harness.new_client().execute(_get()) + assert response.body is not None + first = next(response.body.iter_bytes(3)) + assert first # consumed one chunk, then abandon the subscription + response.close() + assert sync_harness.native_responses()[-1].closed is True + + +# --------------------------------------------------------------------------- # +# Timeout handling + concurrency. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-6") +def test_sub_resolution_timeout_clamped_up(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + sync_harness.new_client().execute(_get(timeout=0.0001)).close() + sent = sync_harness.sent_requests()[0] + assert sent.timeout is not None + assert sent.timeout >= sync_harness.min_timeout_granularity + assert sent.timeout > 0 + + +@pytest.mark.req("SEAM-12", "TRANSPORT-29", "TRANSPORT-5") +def test_per_call_timeout_isolation(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(200) + sync_harness.expect_response(200) + client = sync_harness.new_client() + + def run(timeout: float) -> None: + client.execute(_get(timeout=timeout)).close() + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(run, 0.5), pool.submit(run, 5.0)] + for future in futures: + future.result() + assert {sent.timeout for sent in sync_harness.sent_requests()} == {0.5, 5.0} + + +@pytest.mark.req("SEAM-12", "TRANSPORT-29") +def test_concurrent_execute_is_safe(sync_harness: SyncTransportHarness) -> None: + count = 16 + for _ in range(count): + sync_harness.expect_response(200, body=b"ok") + client = sync_harness.new_client() + + def run() -> int: + with client.execute(_get()) as response: + assert response.body is not None + response.body.bytes() + return int(response.status) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: run(), range(count))) + assert results == [200] * count + assert sync_harness.attempt_count() == count + + +# --------------------------------------------------------------------------- # +# Status totality (already-shipped TRANSPORT-24 behaviour). +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-24") +def test_out_of_range_status_surfaces_with_body(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(999, body=b"vendor code") + with sync_harness.new_client().execute(_get()) as response: + assert int(response.status) == 999 + assert response.body is not None + assert response.body.bytes() == b"vendor code" + + +def test_in_range_unregistered_status_surfaces(sync_harness: SyncTransportHarness) -> None: + sync_harness.expect_response(218, body=b"this is fine") + with sync_harness.new_client().execute(_get()) as response: + assert int(response.status) == 218 + assert response.body is not None + assert response.body.bytes() == b"this is fine" + + +# --------------------------------------------------------------------------- # +# Ownership-aware close. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("SEAM-14", "TRANSPORT-15") +def test_byo_client_survives_adapter_close(sync_harness: SyncTransportHarness) -> None: + if not sync_harness.supports_byo: + pytest.skip("harness cannot build over a caller-supplied client") + sync_harness.expect_response(200) + client = sync_harness.new_client(byo=True) + client.execute(_get()).close() + sync_harness.close_client(client) + byo = sync_harness.byo_underlying() + assert byo is not None + assert byo.closed is False + sync_harness.close_client(client) # idempotent, non-blocking + + +@pytest.mark.req("SEAM-15") +def test_execute_after_close_raises(sync_harness: SyncTransportHarness) -> None: + client = sync_harness.new_client() + sync_harness.close_client(client) + with pytest.raises(ServiceRequestError): + client.execute(_get()) + + +# --------------------------------------------------------------------------- # +# Proxy discoverability + credential safety. +# --------------------------------------------------------------------------- # + + +@pytest.mark.req("TRANSPORT-30") +def test_proxy_credentials_never_leak(sync_harness: SyncTransportHarness) -> None: + if not sync_harness.supports_proxy_introspection: + pytest.skip("harness has no proxy surface") + client = sync_harness.new_client(proxy_url="http://user:s3cret@proxy.test:8080") + assert "s3cret" not in repr(client) + + +@pytest.mark.req("TRANSPORT-30") +def test_unsupported_proxy_feature_is_discoverable(sync_harness: SyncTransportHarness) -> None: + if not sync_harness.supports_proxy_introspection: + pytest.skip("harness has no proxy surface") + client = sync_harness.new_client( + proxy_url="socks5://user:s3cret@proxy.test:1080", + proxy_features=frozenset({"socks"}), + ) + unsupported = sync_harness.unsupported_proxy_features(client) + assert "scheme:socks5" in unsupported + + +class _FailingBody(RequestBody): + """A request body whose buffering fails partway, to prove clean failure.""" + + def media_type(self) -> MediaType | None: + return None + + def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + yield b"partial" + raise OSError("disk gone") diff --git a/pyproject.toml b/pyproject.toml index 9850cd1..4c2248f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "dexpace-sdk-http-aiohttp", "dexpace-sdk-http-httpx", "dexpace-sdk-http-requests", + "dexpace-sdk-tck", ] [tool.uv.workspace] @@ -20,31 +21,74 @@ dexpace-sdk-http-stdlib = { workspace = true } dexpace-sdk-http-aiohttp = { workspace = true } dexpace-sdk-http-httpx = { workspace = true } dexpace-sdk-http-requests = { workspace = true } +dexpace-sdk-tck = { workspace = true } [dependency-groups] dev = [ "pytest>=9.1.0", "pytest-cov>=4.1", "pytest-asyncio>=1.4.0", + "hypothesis>=6.100", "ruff>=0.15.17", "mypy>=1.10", + "import-linter>=2.0", ] [tool.pytest.ini_options] testpaths = [ + "packages/dexpace-sdk-tck/src/dexpace/sdk/tck", "packages/dexpace-sdk-core/tests", "packages/dexpace-sdk-http-stdlib/tests", "packages/dexpace-sdk-http-aiohttp/tests", "packages/dexpace-sdk-http-httpx/tests", "packages/dexpace-sdk-http-requests/tests", + "examples/petstore/tests", ] +# The TCK battery + its self-tests ship inside the installed ``dexpace.sdk.tck`` +# namespace package (so ``pytest --pyargs dexpace.sdk.tck`` certifies an adapter +# externally). ``consider_namespace_packages`` makes pytest import those modules +# under their full ``dexpace.sdk.tck.*`` names — the same module objects the +# adapter conftests import ``SYNC_HARNESS_FACTORIES`` / ``ASYNC_HARNESS_FACTORIES`` +# from — so registration and the battery share one registry, not two. +consider_namespace_packages = true +# The petstore canary lives outside the packaged workspace members, so its +# generated SDK is importable in tests without an editable install. +pythonpath = ["examples/petstore/src"] addopts = "-ra --strict-markers" asyncio_mode = "auto" +markers = [ + "req(requirement_id): mark a test as covering a spec requirement id (e.g. req(\"RETRY-19\")); consumed by tools/traceability_audit.py", +] filterwarnings = [ + # First-party suites run warnings-as-errors. The bare "error" entry already + # promotes every category (DeprecationWarning included); the explicit + # "error::DeprecationWarning" line pins that NFR contract literally, so the + # gate keeps catching deprecations even if "error" is ever narrowed. "error", + "error::DeprecationWarning", + # Scoped waiver: pytest-asyncio raises its own DeprecationWarnings from + # inside the plugin. Ignore only those (listed last so it wins over the + # error filters above) — first-party deprecations still fail the run. "ignore::DeprecationWarning:pytest_asyncio.*", ] +[tool.coverage.run] +branch = true +source = ["dexpace.sdk"] + +[tool.coverage.report] +# Aggregate line+branch coverage gate, enforced as blocking in CI (the +# `coverage` job in .github/workflows/ci.yml). The tree currently measures +# ~94%; 80% is the contractual floor that holds the line against regressions, +# not a target to code down to. +fail_under = 80 +show_missing = true +skip_covered = true +exclude_also = [ + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] + [tool.ruff] line-length = 100 target-version = "py312" @@ -52,7 +96,9 @@ src = ["packages/dexpace-sdk-core/src", "packages/dexpace-sdk-core/tests", "packages/dexpace-sdk-http-stdlib/src", "packages/dexpace-sdk-http-stdlib/tests", "packages/dexpace-sdk-http-aiohttp/src", "packages/dexpace-sdk-http-aiohttp/tests", "packages/dexpace-sdk-http-httpx/src", "packages/dexpace-sdk-http-httpx/tests", - "packages/dexpace-sdk-http-requests/src", "packages/dexpace-sdk-http-requests/tests"] + "packages/dexpace-sdk-http-requests/src", "packages/dexpace-sdk-http-requests/tests", + "packages/dexpace-sdk-tck/src", + "examples/petstore/src", "examples/petstore/tests"] [tool.ruff.lint] select = [ @@ -88,12 +134,25 @@ disallow_untyped_defs = true disallow_any_generics = true namespace_packages = true explicit_package_bases = true +# Each transport-adapter package ships a thin tests/conftest.py that registers +# its harness into the shared transport-conformance battery. Those shims all +# resolve to the bare module name "conftest" and collide in mypy's flat module +# namespace (core/tests avoids this by being a package via tests/__init__.py; +# the adapter test dirs deliberately are not, to keep pytest's rootdir-relative +# collection). The substantive, typed harness code lives in each package's +# uniquely-named *_harness.py / *_conformance_harness.py module, which mypy +# still type-checks in full — only the registration shims are excluded here. +exclude = [ + "packages/dexpace-sdk-http-[^/]+/tests/conftest\\.py$", +] mypy_path = [ "packages/dexpace-sdk-core/src", "packages/dexpace-sdk-http-stdlib/src", "packages/dexpace-sdk-http-aiohttp/src", "packages/dexpace-sdk-http-httpx/src", "packages/dexpace-sdk-http-requests/src", + "packages/dexpace-sdk-tck/src", + "examples/petstore/src", ] files = [ "packages/dexpace-sdk-core/src", @@ -106,6 +165,9 @@ files = [ "packages/dexpace-sdk-http-httpx/tests", "packages/dexpace-sdk-http-requests/src", "packages/dexpace-sdk-http-requests/tests", + "packages/dexpace-sdk-tck/src", + "examples/petstore/src", + "examples/petstore/tests", ] [[tool.mypy.overrides]] @@ -115,6 +177,25 @@ disallow_untyped_decorators = false disallow_untyped_calls = false warn_return_any = false +# The TCK's own pytest bodies (the battery + its packaging/import self-tests) +# and its conftest are test code, not library surface, so they get the same +# relaxations as every other `tests.*` module. The TCK's *library* modules +# (`harness`, `fake_transport`, `golden_corpus.*`) are deliberately excluded +# from this list and stay under full `--strict`, since they are the public +# certification seam adapters compile against. +[[tool.mypy.overrides]] +module = [ + "dexpace.sdk.tck.conftest", + "dexpace.sdk.tck.test_async_contract", + "dexpace.sdk.tck.test_corpus_packaging", + "dexpace.sdk.tck.test_import_discipline", + "dexpace.sdk.tck.test_sync_contract", +] +disallow_untyped_defs = false +disallow_untyped_decorators = false +disallow_untyped_calls = false +warn_return_any = false + [[tool.mypy.overrides]] module = "furl.*" ignore_missing_imports = true diff --git a/tools/build_requirement_index.py b/tools/build_requirement_index.py new file mode 100644 index 0000000..e65d56c --- /dev/null +++ b/tools/build_requirement_index.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Generate the vendored requirement index from the product spec's Appendix C. + +The normative spec — ``java-sdk/docs/product-spec.md`` — lives outside this +repository, but the traceability audit (``tools/traceability_audit.py``) needs +the full requirement index available in-repo and in CI, where the spec is not +checked out. This script parses Appendix C ("Consolidated Normative Requirement +Index") into ``tools/requirement_index.json``, a committed, machine-readable +projection that the audit loads as its real index. + +Regenerate the vendored file whenever the spec's Appendix C changes: + + python tools/build_requirement_index.py path/to/product-spec.md + +The output is deterministic (rows in Appendix C order, sorted keys), so a +regeneration with an unchanged spec is a no-op diff. Each requirement carries a +stable subsystem *slug* (see ``_SUBSYSTEM_SLUGS``) rather than the spec's long +prose subsystem name, so the audit's milestone-scoped enablement can name +subsystems in an environment variable without quoting spaces and parentheses. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +#: Map each Appendix C subsystem prose name to a short, stable slug. The audit +#: gates on these slugs (``DEXPACE_TRACEABILITY_ENABLE=http,retry,...``). Keyed +#: by the exact subsystem string as it appears in the Appendix C table. +_SUBSYSTEM_SLUGS: dict[str, str] = { + "Product vision, pluggable seams and extension model": "seams", + "Core HTTP domain model": "http", + "Request/response body and body-logging lifecycle": "bodies", + "I/O streaming contracts": "io", + "Execution context model (context promotion chain + ContextStore backstop)": "context", + "Stage-based execution pipeline runtime (http.pipeline)": "pipeline", + "Recovery-chain pipeline primitives": "recovery", + "Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline)": "redirect", + "Retry and resilience": "retry", + "Authentication": "auth", + "Cross-cutting invariants and policies": "xcut", + "Serialization (Serde)": "serde", + "Pagination": "pagination", + "Server-Sent Events and streaming": "sse", + "Instrumentation and observability": "observability", + "Configuration and utilities": "config", + "Transport adapter conformance contract": "transport", + "Asynchronous runtime adapter contract": "async", + "Non-functional requirements and quality bar": "nfr", +} + +#: Row shape: leading ``|`` then an uppercase requirement id in the first cell. +_ROW = re.compile(r"^\|\s*([A-Z][A-Z0-9-]*)\s*\|") + +#: Canonical schema tag for the vendored artifact. +_SCHEMA = "dexpace-requirement-index/v1" + +_LEVELS = {"MUST": "MUST", "MUSTNOT": "MUST", "SHOULD": "SHOULD", "MAY": "MAY"} + + +def _normalize_level(raw: str) -> str: + """Map a spec level token to the audit's ``Level`` vocabulary. + + ``MUST NOT`` is a prohibition — normatively MUST-strength — so it folds to + ``MUST`` for gating. ``SHOULD``/``MAY`` pass through. + """ + key = raw.upper().replace(" ", "") + try: + return _LEVELS[key] + except KeyError: + raise SystemExit(f"unknown requirement level: {raw!r}") from None + + +def parse_appendix_c(spec_text: str) -> list[dict[str, str]]: + """Parse the Appendix C requirement table into a list of requirement dicts. + + Args: + spec_text: The full product-spec markdown. + + Returns: + Requirements in Appendix C order, each with ``id``, ``level``, + ``subsystem`` (slug), and ``description``. + + Raises: + SystemExit: If the appendix is missing, a row is malformed, or a + subsystem name has no slug mapping. + """ + lines = spec_text.splitlines() + try: + start = next(i for i, ln in enumerate(lines) if ln.startswith("## Appendix C")) + except StopIteration: + raise SystemExit("Appendix C heading not found in spec") from None + + requirements: list[dict[str, str]] = [] + for line in lines[start:]: + if not _ROW.match(line): + continue + # Only a description cell can contain a table pipe, escaped as ``\|``; + # protect it before splitting so real cell separators stay unambiguous. + cells = [c.strip() for c in line.replace(r"\|", "\x00").strip().strip("|").split("|")] + if len(cells) < 4: + raise SystemExit(f"malformed Appendix C row: {line[:80]!r}") + rid, level, subsystem = cells[0], cells[1], cells[2] + if rid == "ID" and level == "Level": + continue # header row + slug = _SUBSYSTEM_SLUGS.get(subsystem) + if slug is None: + raise SystemExit(f"no slug for subsystem: {subsystem!r}") + requirements.append( + { + "id": rid, + "level": _normalize_level(level), + "subsystem": slug, + "description": "|".join(cells[3:]).replace("\x00", "|"), + } + ) + return requirements + + +def render(requirements: list[dict[str, str]], *, source: str) -> str: + """Render the vendored index JSON document (deterministic, newline-terminated).""" + ids = [r["id"] for r in requirements] + duplicates = sorted({i for i in ids if ids.count(i) > 1}) + if duplicates: + raise SystemExit(f"duplicate requirement ids in Appendix C: {duplicates}") + payload = { + "schema": _SCHEMA, + "source": source, + "count": len(requirements), + "requirements": requirements, + } + return json.dumps(payload, indent=2, ensure_ascii=False) + "\n" + + +def default_output_path() -> Path: + """Return the committed vendored-index path (``tools/requirement_index.json``).""" + return Path(__file__).resolve().parent / "requirement_index.json" + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: parse a spec file and write the vendored index.""" + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("spec", type=Path, help="path to product-spec.md") + parser.add_argument("-o", "--output", type=Path, default=default_output_path()) + args = parser.parse_args(argv) + text = args.spec.read_text(encoding="utf-8") + requirements = parse_appendix_c(text) + # Provenance records the appendix, not the machine-specific spec path. + document = render(requirements, source="java-sdk/docs/product-spec.md#appendix-c") + args.output.write_text(document, encoding="utf-8") + sys.stdout.write(f"wrote {len(requirements)} requirements to {args.output}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_dependency_audit.py b/tools/check_dependency_audit.py new file mode 100644 index 0000000..c906807 --- /dev/null +++ b/tools/check_dependency_audit.py @@ -0,0 +1,182 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Runtime-dependency audit for the SDK workspace. + +The dependency budget is a hard architectural constraint, not a preference: + +- ``dexpace-sdk-core`` ships against the standard library plus ``furl`` and + nothing else. ``furl`` (with its own transitive ``orderedmultidict`` / ``six``) + is the one sanctioned third-party runtime dependency; it powers ``Url`` and is + confined to that module by the import-linter contract. +- Every transport adapter depends on ``dexpace-sdk-core`` plus at most one + third-party HTTP library (``aiohttp`` / ``httpx`` / ``requests``); the stdlib + transport adds none. + +This module reads each distribution's declared ``[project].dependencies`` from +its ``pyproject.toml`` with the standard-library ``tomllib`` parser and reports +any package that exceeds its budget. It inspects *declared* dependencies rather +than a resolved environment so the check is deterministic and needs no network. + +Run as a script to audit the workspace: + + python tools/check_dependency_audit.py +""" + +from __future__ import annotations + +import argparse +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +# The core distribution and its single permitted third-party runtime dependency. +_CORE = "dexpace-sdk-core" +_CORE_ALLOWED: frozenset[str] = frozenset({"furl"}) + +# Adapter distributions and the sibling packages that count as "internal" (free) +# dependencies rather than third-party ones. +_ADAPTERS: tuple[str, ...] = ( + "dexpace-sdk-http-stdlib", + "dexpace-sdk-http-httpx", + "dexpace-sdk-http-aiohttp", + "dexpace-sdk-http-requests", +) +_INTERNAL_PREFIX = "dexpace-sdk-" +_MAX_ADAPTER_THIRD_PARTY = 1 + + +@dataclass(frozen=True) +class Violation: + """A distribution whose declared runtime dependencies break the budget.""" + + distribution: str + message: str + + +def repo_root() -> Path: + """Return the workspace root (the directory that holds ``packages/``).""" + return Path(__file__).resolve().parent.parent + + +def _normalise(requirement: str) -> str: + """Return the bare, canonical distribution name from a requirement string. + + Strips version specifiers, extras, environment markers and surrounding + whitespace, then lower-cases and normalises separators per PEP 503, so + ``furl>=2.1.3`` and ``Furl`` both reduce to ``furl``. + + Args: + requirement: A single ``[project].dependencies`` entry. + + Returns: + The canonical (PEP 503) distribution name. + """ + name = requirement.strip() + for separator in (";", "[", "(", "@"): + name = name.split(separator, 1)[0] + for operator in ("===", "==", ">=", "<=", "~=", "!=", ">", "<"): + name = name.split(operator, 1)[0] + return name.strip().lower().replace("_", "-").replace(".", "-") + + +def declared_dependencies(pyproject: Path) -> list[str]: + """Return the canonical names of a distribution's runtime dependencies. + + Args: + pyproject: Path to the distribution's ``pyproject.toml``. + + Returns: + The canonicalised ``[project].dependencies`` names, in declared order. + """ + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + project = data.get("project", {}) + raw = project.get("dependencies", []) + return [_normalise(entry) for entry in raw] + + +def _third_party(names: list[str]) -> set[str]: + """Return declared names that are neither internal nor the workspace root.""" + return {name for name in names if not name.startswith(_INTERNAL_PREFIX)} + + +def _audit_core(names: list[str]) -> list[Violation]: + """Audit the core distribution: stdlib + furl exactly, nothing more.""" + third_party = _third_party(names) + if third_party == _CORE_ALLOWED: + return [] + extra = sorted(third_party - _CORE_ALLOWED) + missing = sorted(_CORE_ALLOWED - third_party) + detail = [] + if extra: + detail.append(f"unexpected third-party deps {extra}") + if missing: + detail.append(f"missing required deps {missing}") + return [Violation(_CORE, "; ".join(detail))] + + +def _audit_adapter(distribution: str, names: list[str]) -> list[Violation]: + """Audit one adapter: core plus at most one third-party library.""" + violations: list[Violation] = [] + if _CORE not in names: + violations.append(Violation(distribution, f"does not depend on {_CORE}")) + third_party = sorted(_third_party(names)) + if len(third_party) > _MAX_ADAPTER_THIRD_PARTY: + violations.append( + Violation( + distribution, + f"declares {len(third_party)} third-party deps {third_party}, " + f"limit is {_MAX_ADAPTER_THIRD_PARTY}", + ) + ) + return violations + + +def audit_workspace(root: Path | None = None) -> list[Violation]: + """Audit every distribution's declared runtime dependency budget. + + Args: + root: Workspace root to resolve against; defaults to the repository root. + + Returns: + Every budget violation found; empty when the workspace is within budget. + + Raises: + FileNotFoundError: If a distribution's ``pyproject.toml`` is missing. + """ + base = (root or repo_root()).resolve() + violations: list[Violation] = [] + core_pyproject = base / "packages" / _CORE / "pyproject.toml" + violations.extend(_audit_core(declared_dependencies(core_pyproject))) + for adapter in _ADAPTERS: + pyproject = base / "packages" / adapter / "pyproject.toml" + violations.extend(_audit_adapter(adapter, declared_dependencies(pyproject))) + return violations + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: audit the workspace and report any budget violations. + + Args: + argv: Argument vector (defaults to ``sys.argv[1:]``). + + Returns: + ``0`` when every distribution is within budget, ``1`` otherwise. + """ + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.parse_args(argv) + violations = audit_workspace() + if not violations: + print("dependency audit: OK (core = stdlib + furl; adapters = core + <= 1 lib)") + return 0 + print("dependency audit: FAILED", file=sys.stderr) + for violation in violations: + print(f" - {violation.distribution}: {violation.message}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_mit_headers.py b/tools/check_mit_headers.py new file mode 100644 index 0000000..bb73bf9 --- /dev/null +++ b/tools/check_mit_headers.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Enforce the MIT licence header on every Python source file. + +The project is MIT-licensed and every ``.py`` file -- source, tests, and +tooling alike -- must open with the two-line header before anything else +(an optional shebang may precede it). This module is the blocking gate that +scans the tree and fails when a file is missing or mangles the header. + +Run it directly to check the current tree: + + uv run python tools/check_mit_headers.py + +Exit code is ``0`` when every scanned file carries the header and ``1`` when +any file is missing it. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterator +from pathlib import Path + +__all__ = [ + "EXPECTED_HEADER", + "SKIP_DIR_NAMES", + "file_has_header", + "find_header_violations", + "iter_python_files", + "main", + "repo_root", +] + +# The exact two lines every .py file must begin with. +EXPECTED_HEADER: tuple[str, str] = ( + "# Copyright (c) 2026 dexpace and Omar Aljarrah.", + "# Licensed under the MIT License. See LICENSE.md in the repository root for details.", +) + +# Directory names pruned during the walk: build/cache artefacts, any +# dot-directory (``.git``, ``.venv``, ``.mypy_cache``, session scratch, ...), +# and gitignored local reference checkouts that live inside the repo root but +# carry no project source (see ``.gitignore``). Excluding these keeps the +# scan to real, tracked source. +SKIP_DIR_NAMES: frozenset[str] = frozenset( + { + "__pycache__", + "azure-sdk-for-python", + "build", + "dist", + "node_modules", + "venv", + } +) + + +def repo_root() -> Path: + """Return the repository root (the parent of this ``tools`` directory).""" + return Path(__file__).resolve().parent.parent + + +def _is_skipped_dir(name: str) -> bool: + """Return whether a directory name should be pruned from the walk.""" + return name.startswith(".") or name in SKIP_DIR_NAMES or name.endswith(".egg-info") + + +def iter_python_files(root: Path) -> Iterator[Path]: + """Yield every ``.py`` file under a repo tree, skipping non-source dirs. + + Args: + root: Directory to walk. + + Yields: + Absolute paths to ``.py`` files, in sorted order per directory. + """ + for dirpath, dirnames, filenames in root.walk(): + dirnames[:] = sorted(d for d in dirnames if not _is_skipped_dir(d)) + for filename in sorted(filenames): + if filename.endswith(".py"): + yield dirpath / filename + + +def file_has_header(path: Path) -> bool: + """Return whether a file opens with the expected MIT header. + + A single leading shebang line (``#!...``) is tolerated before the header; + the two header lines must then appear back to back and match exactly. + + Args: + path: Path to the ``.py`` file to inspect. + + Returns: + ``True`` if the header is present and exact, ``False`` otherwise. + """ + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return False + start = 1 if lines and lines[0].startswith("#!") else 0 + return tuple(lines[start : start + 2]) == EXPECTED_HEADER + + +def find_header_violations(root: Path) -> list[Path]: + """Find ``.py`` files under a repo tree missing the MIT header. + + Args: + root: Repository root to scan. + + Returns: + Sorted list of offending paths. Empty when every file is compliant. + """ + return [path for path in iter_python_files(root) if not file_has_header(path)] + + +def main() -> int: + """Report header violations and return a process exit code. + + Returns: + ``0`` when every scanned file carries the header, ``1`` otherwise. + """ + root = repo_root() + violations = find_header_violations(root) + if not violations: + print("MIT header check: OK (every .py file carries the licence header)") + return 0 + print("MIT header check: FAILED -- missing or malformed MIT header:") + for path in violations: + print(f" {path.relative_to(root)}") + print( + "\nEvery .py file (src and tests) must start with these two lines:\n" + f" {EXPECTED_HEADER[0]}\n" + f" {EXPECTED_HEADER[1]}" + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_namespace_packages.py b/tools/check_namespace_packages.py new file mode 100644 index 0000000..b36789f --- /dev/null +++ b/tools/check_namespace_packages.py @@ -0,0 +1,93 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Guard the PEP 420 namespace layout shared by every SDK distribution. + +The five workspace packages share the ``dexpace.sdk.*`` prefix through implicit +namespace packages. A single stray ``__init__.py`` at ``src/dexpace/``, +``src/dexpace/sdk/``, or ``src/dexpace/sdk/http/`` in *any* package turns that +directory into a regular package and silently breaks namespace merging for the +other distributions -- and only under certain install orders, which makes the +failure maddening to reproduce. This module is the mechanical gate that keeps +the invariant honest instead of relying on reviewer memory. + +Run it directly to check the current tree: + + uv run python tools/check_namespace_packages.py + +Exit code is ``0`` when the layout is clean and ``1`` when any forbidden +``__init__.py`` is found. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +__all__ = [ + "FORBIDDEN_NAMESPACE_INITS", + "find_namespace_violations", + "main", + "repo_root", +] + +# Relative to a package's ``src`` directory, these are the shared namespace +# prefixes that must remain implicit (no ``__init__.py``). +FORBIDDEN_NAMESPACE_INITS: tuple[str, ...] = ( + "dexpace/__init__.py", + "dexpace/sdk/__init__.py", + "dexpace/sdk/http/__init__.py", +) + + +def repo_root() -> Path: + """Return the repository root (the parent of this ``tools`` directory).""" + return Path(__file__).resolve().parent.parent + + +def find_namespace_violations(root: Path) -> list[Path]: + """Find forbidden namespace ``__init__.py`` files under a repo tree. + + Scans every ``packages/*/src`` directory for an ``__init__.py`` at any of + the shared namespace prefixes. + + Args: + root: Repository root to scan. Must contain a ``packages`` directory. + + Returns: + Sorted list of offending ``__init__.py`` paths. Empty when the layout + is clean. + """ + violations: list[Path] = [] + for src_dir in sorted(root.glob("packages/*/src")): + for relpath in FORBIDDEN_NAMESPACE_INITS: + candidate = src_dir / relpath + if candidate.is_file(): + violations.append(candidate) + return violations + + +def main() -> int: + """Report namespace violations and return a process exit code. + + Returns: + ``0`` when the namespace layout is clean, ``1`` otherwise. + """ + root = repo_root() + violations = find_namespace_violations(root) + if not violations: + print("namespace check: OK (no __init__.py at shared prefixes)") + return 0 + print("namespace check: FAILED -- forbidden __init__.py breaks PEP 420 merging:") + for path in violations: + print(f" {path.relative_to(root)}") + print( + "\nRemove these files. The dexpace/, dexpace/sdk/, and dexpace/sdk/http/\n" + "directories must stay implicit namespace packages so every distribution\n" + "can share the dexpace.sdk.* prefix." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_uniqueness.py b/tools/check_uniqueness.py new file mode 100644 index 0000000..aeedb56 --- /dev/null +++ b/tools/check_uniqueness.py @@ -0,0 +1,298 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Single-source audit for algorithm formulas. + +The project single-sources every non-trivial algorithm — the retry classifier, +the backoff calculator, the pacing parser, the redirect-resolution rules, and +so on. Each such formula is *defined in exactly one module* and *imported* +everywhere else. This matters most for the hand-maintained async policy twins, +which must call the shared formula rather than restate it: a copy-pasted body +drifts silently the first time the sync side is tuned and the async side is not. + +This module provides the mechanism that catches that drift. It parses the +target trees with the standard-library ``ast`` parser (no imports, no +execution) and fingerprints every function body. For each algorithm registered +as single-sourced, it verifies that the canonical body appears in no other +module. Registration is opt-in: an algorithm joins ``SINGLE_SOURCED`` the moment +its module is written, and from then on any restatement of its body fails the +audit. + +A whole-tree "flag every duplicated body" heuristic was deliberately rejected as +the gate: the sync/async twins legitimately share dozens of tiny identical +bodies (``__init__`` field stores, ``__hash__``, builder splices), so an +unregistered denylist would be noise. The registry keeps the audit precise — +zero false positives, and every entry documents an invariant on purpose. + +The registry covers the shared resilience formulas (retry classifier, backoff +calculator, pacing parser); further pure modules land in later milestones. The +companion pytest also exercises the mechanism against a fixture pair (one file +defining a formula, one restating it) so the machinery stays proven independent +of what happens to be registered. + +Run as a script to audit the live tree: + + python tools/check_uniqueness.py +""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Algorithm: + """A formula that must be defined in exactly one module. + + Attributes: + module: Dotted module path of the canonical definition, relative to a + scanned root (e.g. ``dexpace.sdk.core.pipeline._backoff``). + function: Name of the canonical top-level function. + description: Human-readable note on what the formula computes, shown in + audit failures so the invariant is self-explanatory. + """ + + module: str + function: str + description: str + + +# Registry of single-sourced algorithms. +# +# Each entry names a formula that is defined once in a pure module and imported +# everywhere else; the audit then forbids any other module (notably a +# hand-maintained async twin) from restating its body instead of importing it. +# Further pure modules (query codec, SSE grammar, redirect resolution) land in +# later milestones — register each here when it is written. +_PURE = "dexpace.sdk.core.pipeline._pure" +SINGLE_SOURCED: tuple[Algorithm, ...] = ( + Algorithm( + module=f"{_PURE}.classifier", + function="status_is_retryable", + description="Retryability status-code table lookup against a configurable set.", + ), + Algorithm( + module=f"{_PURE}.classifier", + function="is_retryable", + description="Cycle-safe retryability walk over an exception cause chain.", + ), + Algorithm( + module=f"{_PURE}.backoff", + function="compute_backoff", + description="Deterministic exponential/fixed backoff schedule (pre-jitter).", + ), + Algorithm( + module=f"{_PURE}.backoff", + function="apply_jitter", + description="Full/symmetric jitter applied to a computed backoff delay.", + ), + Algorithm( + module=f"{_PURE}.pacing", + function="parse_retry_after", + description="Retry-After header parser (delta-seconds or HTTP-date).", + ), + Algorithm( + module=f"{_PURE}.pacing", + function="parse_rate_limit_reset", + description="X-RateLimit-Reset epoch header parser.", + ), +) + +# Distribution directory name -> its ``src`` sub-path. The audit scans source +# trees only; test trees (which hold the audit's own fixtures) are excluded. +_DISTRIBUTIONS: tuple[str, ...] = ( + "dexpace-sdk-core", + "dexpace-sdk-http-stdlib", + "dexpace-sdk-http-httpx", + "dexpace-sdk-http-aiohttp", + "dexpace-sdk-http-requests", +) + + +@dataclass(frozen=True) +class FunctionRecord: + """One function definition located in a scanned tree. + + Attributes: + module: Dotted module path relative to the scan root. + name: The (possibly nested) function name. + fingerprint: A hash of the normalised function body. + statements: Number of body statements after stripping any docstring. + lineno: 1-based line of the ``def``. + """ + + module: str + name: str + fingerprint: str + statements: int + lineno: int + + +@dataclass(frozen=True) +class Duplication: + """A registered formula found restated in a second module.""" + + algorithm: Algorithm + canonical: FunctionRecord + restated_in: FunctionRecord + + +def repo_root() -> Path: + """Return the workspace root (the directory that holds ``packages/``).""" + return Path(__file__).resolve().parent.parent + + +def source_roots(root: Path | None = None) -> list[Path]: + """Return the ``src`` directory of every distribution that exists. + + Args: + root: Workspace root to resolve against; defaults to the repository root. + + Returns: + The existing per-distribution ``src`` roots, in registry order. + """ + base = (root or repo_root()).resolve() + roots = [base / "packages" / dist / "src" for dist in _DISTRIBUTIONS] + return [path for path in roots if path.is_dir()] + + +def _strip_docstring(body: list[ast.stmt]) -> list[ast.stmt]: + """Return ``body`` without a leading string-literal docstring statement.""" + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + return body[1:] + return body + + +def body_fingerprint(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[str, int]: + """Fingerprint a function body, ignoring its name, signature and docstring. + + Two functions share a fingerprint exactly when their bodies are structurally + identical, so a copy-pasted formula is caught even if the copy is renamed. + + Args: + node: The (async) function definition to fingerprint. + + Returns: + A ``(hex_digest, statement_count)`` pair for the normalised body. + """ + statements = _strip_docstring(list(node.body)) + dumped = "\n".join(ast.dump(stmt, include_attributes=False) for stmt in statements) + digest = hashlib.sha256(dumped.encode("utf-8")).hexdigest() + return digest, len(statements) + + +def _dotted_module(src_root: Path, module_file: Path) -> str: + """Return the dotted module path for a ``.py`` file under ``src_root``.""" + rel = module_file.relative_to(src_root).with_suffix("") + return ".".join(rel.parts) + + +def collect_functions(roots: list[Path]) -> list[FunctionRecord]: + """Collect every function definition under the given roots. + + Nested functions and methods are included so a formula restated inside a + method body is still caught. + + Args: + roots: Directories to walk for ``.py`` files. + + Returns: + One ``FunctionRecord`` per function/method definition found. + """ + records: list[FunctionRecord] = [] + for src_root in roots: + for module_file in sorted(src_root.rglob("*.py")): + module = _dotted_module(src_root, module_file) + tree = ast.parse(module_file.read_text(encoding="utf-8"), filename=str(module_file)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + digest, count = body_fingerprint(node) + records.append(FunctionRecord(module, node.name, digest, count, node.lineno)) + return records + + +def find_restated_algorithms( + roots: list[Path], registry: tuple[Algorithm, ...] +) -> list[Duplication]: + """Find registered formulas whose body is restated in another module. + + Args: + roots: Source roots to scan. + registry: Algorithms declared single-sourced. + + Returns: + A ``Duplication`` for every module that restates a registered formula's + canonical body. Empty when every formula is single-sourced. + + Raises: + LookupError: If a registered algorithm's canonical function is not found + in the scanned roots (a stale registry entry that must be fixed). + """ + records = collect_functions(roots) + duplications: list[Duplication] = [] + for algorithm in registry: + canonical = _canonical_record(records, algorithm) + for record in records: + if record.module == canonical.module or record.fingerprint != canonical.fingerprint: + continue + duplications.append(Duplication(algorithm, canonical, record)) + return duplications + + +def _canonical_record(records: list[FunctionRecord], algorithm: Algorithm) -> FunctionRecord: + """Return the canonical record for ``algorithm`` or raise if it is missing.""" + for record in records: + if record.module == algorithm.module and record.name == algorithm.function: + return record + raise LookupError( + f"single-sourced algorithm {algorithm.module}.{algorithm.function} " + "was not found; update the registry in tools/check_uniqueness.py" + ) + + +def format_duplication(duplication: Duplication) -> str: + """Render a duplication as a one-line, human-readable audit failure.""" + algo = duplication.algorithm + restated = duplication.restated_in + return ( + f"{algo.module}.{algo.function} ({algo.description}) is restated at " + f"{restated.module}.{restated.name} (line {restated.lineno}); import the " + "canonical definition instead of copying its body" + ) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: audit the live tree and report any restated formulas. + + Args: + argv: Argument vector (defaults to ``sys.argv[1:]``). + + Returns: + ``0`` when every single-sourced algorithm is unique, ``1`` otherwise. + """ + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.parse_args(argv) + duplications = find_restated_algorithms(source_roots(), SINGLE_SOURCED) + if not duplications: + print(f"uniqueness audit: OK ({len(SINGLE_SOURCED)} single-sourced algorithm(s))") + return 0 + print("uniqueness audit: FAILED", file=sys.stderr) + for duplication in duplications: + print(f" - {format_duplication(duplication)}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_version_train.py b/tools/check_version_train.py new file mode 100644 index 0000000..f84b7c2 --- /dev/null +++ b/tools/check_version_train.py @@ -0,0 +1,248 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Guard the single version train shared by every published SDK distribution. + +The workspace ships six distributions (`dexpace-sdk-core` plus four transport +adapters and the conformance kit). They release together on one version train: +every published package carries the same version, and that version is the one +source of truth recorded in each package's ``pyproject.toml`` and mirrored in +``uv.lock``. The workspace root (``dexpace-sdk-workspace``) is intentionally not +published and keeps the ``0.0.0`` sentinel, so it is excluded from the train. + +This gate makes two invariants mechanical instead of relying on release-time +discipline: + +* every published package sits at one identical, real version (a bump touches + all of them or the gate fails), and that version is mirrored in ``uv.lock``; + and +* a built wheel's metadata carries that real version -- never a build-time + placeholder such as ``0.0.0`` or ``unknown`` -- so a shipped artifact can + never advertise an anonymous build. + +Run it against the working tree: + + uv run --no-project python tools/check_version_train.py + +Add ``--wheel-dir dist`` to additionally assert that every built wheel in a +directory carries the train version: + + uv run --no-project python tools/check_version_train.py --wheel-dir dist + +Exit code is ``0`` when the train is consistent and ``1`` on the first breach. +Only the standard library is used so the script runs in a minimal environment. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +import zipfile +from pathlib import Path + +__all__ = [ + "ANCHOR_PACKAGE", + "PLACEHOLDER_VERSIONS", + "PUBLISHED_PACKAGES", + "check_train", + "check_wheels", + "lock_versions", + "main", + "package_versions", + "repo_root", + "wheel_version", +] + +#: The distributions that release together on the shared version train. The +#: workspace root is deliberately absent -- it is never published. +PUBLISHED_PACKAGES: tuple[str, ...] = ( + "dexpace-sdk-core", + "dexpace-sdk-http-stdlib", + "dexpace-sdk-http-httpx", + "dexpace-sdk-http-aiohttp", + "dexpace-sdk-http-requests", + "dexpace-sdk-tck", +) + +#: The package whose version anchors the train; every other package must match. +ANCHOR_PACKAGE = "dexpace-sdk-core" + +#: Versions that signal an unstamped / placeholder build rather than a release. +PLACEHOLDER_VERSIONS: frozenset[str] = frozenset({"", "0", "0.0", "0.0.0", "0.0.0.dev0", "unknown"}) + +# A pragmatic PEP 440 release matcher (release segment plus optional pre/post/ +# dev/local suffixes). Stdlib-only: the ``packaging`` module is not guaranteed +# to be importable under ``uv run --no-project``. +_PEP440 = re.compile( + r"^\d+(\.\d+)*" + r"((a|b|rc)\d+)?" + r"(\.post\d+)?" + r"(\.dev\d+)?" + r"(\+[a-z0-9]+(\.[a-z0-9]+)*)?$", + re.IGNORECASE, +) + + +def repo_root() -> Path: + """Return the repository root (the parent of this ``tools`` directory).""" + return Path(__file__).resolve().parent.parent + + +def _project_version(pyproject: Path) -> str: + """Read ``[project].version`` from a ``pyproject.toml``.""" + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + return str(data["project"]["version"]) + + +def package_versions(root: Path) -> dict[str, str]: + """Return each published package's declared version, keyed by name. + + Args: + root: Repository root containing the ``packages`` directory. + + Returns: + A mapping from distribution name to the version declared in its + ``pyproject.toml``. + """ + return { + name: _project_version(root / "packages" / name / "pyproject.toml") + for name in PUBLISHED_PACKAGES + } + + +def lock_versions(root: Path) -> dict[str, str]: + """Return the version ``uv.lock`` records for each published package. + + Args: + root: Repository root containing ``uv.lock``. + + Returns: + A mapping from distribution name to its locked version, restricted to + the published packages. + """ + data = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")) + published = set(PUBLISHED_PACKAGES) + return { + str(pkg["name"]): str(pkg["version"]) + for pkg in data.get("package", []) + if str(pkg.get("name")) in published + } + + +def check_train(pyproject: dict[str, str], lock: dict[str, str]) -> list[str]: + """Check that every published package sits at one real, matching version. + + Args: + pyproject: Versions declared in each package's ``pyproject.toml``. + lock: Versions recorded for each package in ``uv.lock``. + + Returns: + A list of human-readable failure messages; empty when the train is + consistent. + """ + failures: list[str] = [] + train = pyproject[ANCHOR_PACKAGE] + if train in PLACEHOLDER_VERSIONS or not _PEP440.match(train): + failures.append(f"train anchor {ANCHOR_PACKAGE} has a non-release version {train!r}") + for name in PUBLISHED_PACKAGES: + if pyproject[name] != train: + failures.append(f"{name} pyproject version {pyproject[name]!r} != train {train!r}") + locked = lock.get(name) + if locked is None: + failures.append(f"{name} is absent from uv.lock") + elif locked != pyproject[name]: + failures.append(f"{name} uv.lock version {locked!r} != pyproject {pyproject[name]!r}") + return failures + + +def wheel_version(wheel: Path) -> str: + """Return the ``Version`` field from a built wheel's metadata. + + Args: + wheel: Path to a ``.whl`` file. + + Returns: + The version string recorded in the wheel's ``*.dist-info/METADATA``. + + Raises: + RuntimeError: If the wheel has no metadata or no ``Version`` field. + """ + with zipfile.ZipFile(wheel) as archive: + meta_names = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")] + if not meta_names: + raise RuntimeError(f"{wheel.name}: no .dist-info/METADATA in wheel") + text = archive.read(meta_names[0]).decode("utf-8") + for line in text.splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + raise RuntimeError(f"{wheel.name}: METADATA has no Version field") + + +def check_wheels(wheel_dir: Path, expected: str) -> list[str]: + """Check that every wheel in a directory carries the expected real version. + + Args: + wheel_dir: Directory holding built ``.whl`` files. + expected: The train version each wheel must advertise. + + Returns: + A list of failure messages; empty when every wheel matches and none + carries a placeholder version. + """ + wheels = sorted(wheel_dir.glob("*.whl")) + if not wheels: + return [f"no wheels found in {wheel_dir}"] + failures: list[str] = [] + for wheel in wheels: + version = wheel_version(wheel) + if version in PLACEHOLDER_VERSIONS: + failures.append(f"{wheel.name} carries placeholder version {version!r}") + elif version != expected: + failures.append(f"{wheel.name} version {version!r} != train {expected!r}") + return failures + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + """Parse the command line.""" + parser = argparse.ArgumentParser(description="Verify the single SDK version train.") + parser.add_argument( + "--wheel-dir", + type=Path, + default=None, + help="Directory of built wheels whose metadata version must match the train.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Verify the version train and return a process exit code. + + Args: + argv: Optional argument vector (defaults to ``sys.argv``). + + Returns: + ``0`` when the train is consistent, ``1`` otherwise. + """ + args = _parse_args(argv) + root = repo_root() + pyproject = package_versions(root) + failures = check_train(pyproject, lock_versions(root)) + if not failures and args.wheel_dir is not None: + failures += check_wheels(args.wheel_dir, pyproject[ANCHOR_PACKAGE]) + if failures: + print("version train check: FAILED") + for message in failures: + print(f" {message}") + return 1 + train = pyproject[ANCHOR_PACKAGE] + scope = "pyproject + uv.lock" + if args.wheel_dir is not None: + scope += " + wheel metadata" + print(f"version train check: OK (all published packages at {train}; {scope})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/parity_check.py b/tools/parity_check.py new file mode 100644 index 0000000..92c46cc --- /dev/null +++ b/tools/parity_check.py @@ -0,0 +1,548 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Sync/async parity gate — AST-normalized diff of a sync/async source pair. + +An async twin of a sync module is meant to be a mechanical transliteration: +the same control flow and calls, with ``async``/``await`` added and a small, +fixed set of identifiers swapped (``AsyncResponse`` for ``Response``, +``AsyncPolicy`` for ``Policy``, and so on). This tool verifies that guarantee. +It normalizes both files' abstract syntax trees through a *closed, declared* +token map — stripping ``await``, mapping ``async def`` / ``async for`` / +``async with`` to their sync node types, and renaming a declared set of +identifiers — then compares the resulting trees structurally. Any residual +difference after normalization is semantic drift. + +The comparison is on AST structure, never source text: ``ast.unparse`` discards +comments and reformats, so a textual diff would report cosmetic noise as drift +and miss real drift hidden behind formatting. Normalization is a fixed token +table, never fuzzy matching — an identifier the table does not name is compared +as-is, so an accidental rename is caught rather than smoothed over. + +Two modes: + +- **Blocking** (`Mode.BLOCKING`): intended for *generated* sync/async pairs, + where AST identity after normalization is the correctness contract. A + non-empty normalized diff is a failure (non-zero exit). +- **Report-only** (`Mode.REPORT_ONLY`): intended for the existing hand-written + policy twins, whose correctness is guaranteed by a different mechanism + (import-linter contracts), not textual identity. It emits the diff artifact + but never fails, so those legitimately-divergent twins (delegating wrappers, + extra async-only helpers) do not break the build. + +Library API: `check_parity` / `check_parity_sources` / `normalize_source`. +CLI: + + python tools/parity_check.py SYNC.py ASYNC.py --mode blocking + python tools/parity_check.py SYNC.py ASYNC.py --mode report-only --report-out diff.txt +""" + +from __future__ import annotations + +import argparse +import ast +import difflib +import json +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +#: Default identifier/token map from the async tree to the sync tree. Keys that +#: contain a ``.`` are dotted attribute chains (``asyncio.sleep`` matched as a +#: whole); keys without a ``.`` are single identifiers matched wherever they +#: appear (names, attributes, class/function/parameter names, import segments). +DEFAULT_TOKEN_MAP: Mapping[str, str] = { + # Pipeline base types and their modules. + "AsyncPolicy": "Policy", + "async_policy": "policy", + # Response model and its module. + "AsyncResponse": "Response", + "async_response": "response", + # Clock seam. + "AsyncClock": "Clock", + "ASYNC_SYSTEM_CLOCK": "SYSTEM_CLOCK", + # Shipped policy class twins. + "AsyncSetDatePolicy": "SetDatePolicy", + "AsyncClientIdentityPolicy": "ClientIdentityPolicy", + "AsyncRedirectPolicy": "RedirectPolicy", + "AsyncRetryPolicy": "RetryPolicy", + "AsyncIdempotencyPolicy": "IdempotencyPolicy", + "AsyncTracingPolicy": "TracingPolicy", + # Generic async protocol method / builtin names. + "aiter": "iter", + "aiter_bytes": "iter_bytes", + "aclose": "close", + "anext": "next", + "__aenter__": "__enter__", + "__aexit__": "__exit__", + # Awaitable sleep to its synchronous wait equivalent. + "asyncio.sleep": "time.sleep", +} + + +class Mode(Enum): + """How a parity check reacts to a non-empty normalized diff. + + Attributes: + BLOCKING: Drift is a failure; the CLI exits non-zero. + REPORT_ONLY: Drift is recorded in the artifact but never fails. + """ + + BLOCKING = "blocking" + REPORT_ONLY = "report-only" + + +@dataclass(frozen=True, slots=True) +class ParityResult: + """Outcome of comparing one normalized sync/async source pair. + + Attributes: + sync_label: Human-readable label (path) for the sync source. + async_label: Human-readable label (path) for the async source. + in_parity: ``True`` when the normalized ASTs are identical. + diff: Unified diff of the two normalized AST dumps; empty in parity. + mode: The mode the check ran under. + """ + + sync_label: str + async_label: str + in_parity: bool + diff: str + mode: Mode + + @property + def failed(self) -> bool: + """Return whether this result should fail the build. + + Returns: + ``True`` only when drift was found *and* the mode is blocking. + """ + return not self.in_parity and self.mode is Mode.BLOCKING + + +def _split_token_map(token_map: Mapping[str, str]) -> tuple[dict[str, str], dict[str, str]]: + """Partition a token map into single-identifier and dotted-chain entries. + + Args: + token_map: The declared async-to-sync token table. + + Returns: + A ``(simple, dotted)`` pair of mappings; ``dotted`` holds every key that + contains a ``.`` (matched as a whole attribute chain), ``simple`` the + rest (matched per identifier). + """ + simple: dict[str, str] = {} + dotted: dict[str, str] = {} + for key, value in token_map.items(): + (dotted if "." in key else simple)[key] = value + return simple, dotted + + +def _dotted_path(node: ast.expr) -> str | None: + """Return the dotted name of a pure ``Name``/``Attribute`` chain, else None. + + Args: + node: An expression node. + + Returns: + The reconstructed dotted string (``a.b.c``) when ``node`` is a chain of + attribute accesses bottoming out in a bare name, otherwise ``None`` + (e.g. when the base is a call or subscript). + """ + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + base = _dotted_path(node.value) + return None if base is None else f"{base}.{node.attr}" + return None + + +class _StructuralNormalizer(ast.NodeTransformer): + """Collapse async syntax onto its synchronous node shapes. + + Rewrites ``async def`` to ``def``, ``async for``/``async with`` to + ``for``/``with``, drops each ``await`` wrapper, and clears the ``is_async`` + flag on comprehension clauses. Identifier renaming is a separate pass. + """ + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST: + sync = ast.FunctionDef( + name=node.name, + args=node.args, + body=node.body, + decorator_list=node.decorator_list, + returns=node.returns, + type_comment=node.type_comment, + type_params=node.type_params, + ) + return self.generic_visit(sync) + + def visit_AsyncFor(self, node: ast.AsyncFor) -> ast.AST: + sync = ast.For( + target=node.target, + iter=node.iter, + body=node.body, + orelse=node.orelse, + type_comment=node.type_comment, + ) + return self.generic_visit(sync) + + def visit_AsyncWith(self, node: ast.AsyncWith) -> ast.AST: + sync = ast.With(items=node.items, body=node.body, type_comment=node.type_comment) + return self.generic_visit(sync) + + def visit_Await(self, node: ast.Await) -> ast.AST: + # Drop the ``await`` wrapper, keeping (and recursing into) its operand. + replacement = self.visit(node.value) + assert isinstance(replacement, ast.AST) + return replacement + + def visit_comprehension(self, node: ast.comprehension) -> ast.AST: + node.is_async = 0 + return self.generic_visit(node) + + +class _DocstringStripper(ast.NodeTransformer): + """Drop the leading string-literal docstring from every module/class/def. + + Docstrings are prose, not behaviour; an async twin legitimately documents + itself as an "async twin of ...". Removing them keeps the comparison on + semantics. General string literals (log messages, header values) are left + intact so a changed message still registers as drift. + """ + + @staticmethod + def _strip(body: list[ast.stmt]) -> list[ast.stmt]: + if body and isinstance(body[0], ast.Expr): + value = body[0].value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return body[1:] + return body + + def visit_Module(self, node: ast.Module) -> ast.AST: + node.body = self._strip(node.body) + return self.generic_visit(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST: + node.body = self._strip(node.body) + return self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST: + node.body = self._strip(node.body) + return self.generic_visit(node) + + +class _DottedRenamer(ast.NodeTransformer): + """Rewrite whole dotted attribute chains named in the dotted token map.""" + + def __init__(self, dotted: Mapping[str, str]) -> None: + self._dotted = dotted + + def visit_Attribute(self, node: ast.Attribute) -> ast.AST: + dotted = _dotted_path(node) + if dotted is not None and dotted in self._dotted: + replacement = ast.parse(self._dotted[dotted], mode="eval").body + return replacement + return self.generic_visit(node) + + +class _IdentifierRenamer(ast.NodeTransformer): + """Rename single identifiers wherever they appear, per the simple map. + + Covers names, attribute labels, class/function/parameter names, keyword + argument labels, and import segments. Import module paths and dotted import + aliases are renamed segment by segment, so ``async_response`` inside + ``...http.response.async_response`` maps without a dedicated dotted entry. + A string constant that *exactly* equals a token key is also renamed (whole + string, never a substring), so ``__all__`` entries and other name-as-string + references line up; a string that merely contains a token is left untouched + so real message drift still registers. + """ + + def __init__(self, simple: Mapping[str, str]) -> None: + self._simple = simple + + def _rename(self, name: str) -> str: + return self._simple.get(name, name) + + def _rename_dotted(self, dotted: str) -> str: + return ".".join(self._rename(part) for part in dotted.split(".")) + + def visit_Name(self, node: ast.Name) -> ast.AST: + node.id = self._rename(node.id) + return node + + def visit_Constant(self, node: ast.Constant) -> ast.AST: + if isinstance(node.value, str) and node.value in self._simple: + node.value = self._simple[node.value] + return node + + def visit_Attribute(self, node: ast.Attribute) -> ast.AST: + node.attr = self._rename(node.attr) + return self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST: + node.name = self._rename(node.name) + return self.generic_visit(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST: + node.name = self._rename(node.name) + return self.generic_visit(node) + + def visit_arg(self, node: ast.arg) -> ast.AST: + node.arg = self._rename(node.arg) + return self.generic_visit(node) + + def visit_keyword(self, node: ast.keyword) -> ast.AST: + if node.arg is not None: + node.arg = self._rename(node.arg) + return self.generic_visit(node) + + def visit_alias(self, node: ast.alias) -> ast.AST: + node.name = self._rename_dotted(node.name) + if node.asname is not None: + node.asname = self._rename(node.asname) + return node + + def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.AST: + if node.module is not None: + node.module = self._rename_dotted(node.module) + return self.generic_visit(node) + + def visit_Global(self, node: ast.Global) -> ast.AST: + node.names = [self._rename(name) for name in node.names] + return node + + def visit_Nonlocal(self, node: ast.Nonlocal) -> ast.AST: + node.names = [self._rename(name) for name in node.names] + return node + + +def normalize_source( + source: str, + token_map: Mapping[str, str] | None = None, + *, + keep_docstrings: bool = False, +) -> ast.Module: + """Parse ``source`` and normalize its AST through the token map. + + The pipeline is fixed and declared: collapse async syntax, optionally drop + docstrings, rewrite dotted chains, then rename single identifiers. + + Args: + source: Python source text. + token_map: Async-to-sync token table. Defaults to `DEFAULT_TOKEN_MAP`. + keep_docstrings: When ``True``, leave docstrings in place instead of + stripping them before the comparison. + + Returns: + The normalized module AST, ready to dump or compare. + """ + tokens = DEFAULT_TOKEN_MAP if token_map is None else token_map + simple, dotted = _split_token_map(tokens) + tree: ast.AST = ast.parse(source) + tree = _StructuralNormalizer().visit(tree) + if not keep_docstrings: + tree = _DocstringStripper().visit(tree) + tree = _DottedRenamer(dotted).visit(tree) + tree = _IdentifierRenamer(simple).visit(tree) + ast.fix_missing_locations(tree) + assert isinstance(tree, ast.Module) + return tree + + +def normalized_dump( + source: str, + token_map: Mapping[str, str] | None = None, + *, + keep_docstrings: bool = False, +) -> str: + """Return the canonical, position-free AST dump of normalized ``source``. + + Args: + source: Python source text. + token_map: Async-to-sync token table. Defaults to `DEFAULT_TOKEN_MAP`. + keep_docstrings: Forwarded to `normalize_source`. + + Returns: + An indented ``ast.dump`` string; equal dumps mean AST parity. + """ + tree = normalize_source(source, token_map, keep_docstrings=keep_docstrings) + return ast.dump(tree, indent=2) + + +def check_parity_sources( + sync_source: str, + async_source: str, + *, + sync_label: str = "sync", + async_label: str = "async", + token_map: Mapping[str, str] | None = None, + mode: Mode = Mode.BLOCKING, + keep_docstrings: bool = False, +) -> ParityResult: + """Compare two source strings after normalization. + + Both sources are normalized to the sync shape and their AST dumps compared. + When they differ, a unified diff of the dumps is produced (structural, not + textual — it diffs AST node trees, not source lines). + + Args: + sync_source: The sync (reference) source text. + async_source: The async twin source text. + sync_label: Label for the sync side in diagnostics and the diff header. + async_label: Label for the async side. + token_map: Async-to-sync token table. Defaults to `DEFAULT_TOKEN_MAP`. + mode: Blocking or report-only. + keep_docstrings: Forwarded to `normalize_source`. + + Returns: + A `ParityResult` describing whether the pair is in parity. + """ + sync_dump = normalized_dump(sync_source, token_map, keep_docstrings=keep_docstrings) + async_dump = normalized_dump(async_source, token_map, keep_docstrings=keep_docstrings) + in_parity = sync_dump == async_dump + diff = "" if in_parity else _render_diff(sync_dump, async_dump, sync_label, async_label) + return ParityResult(sync_label, async_label, in_parity, diff, mode) + + +def _render_diff(sync_dump: str, async_dump: str, sync_label: str, async_label: str) -> str: + """Render a unified diff of two normalized AST dumps.""" + lines = difflib.unified_diff( + sync_dump.splitlines(keepends=True), + async_dump.splitlines(keepends=True), + fromfile=f"{sync_label} (normalized AST)", + tofile=f"{async_label} (normalized AST)", + ) + return "".join(lines) + + +def check_parity( + sync_path: str | Path, + async_path: str | Path, + *, + token_map: Mapping[str, str] | None = None, + mode: Mode = Mode.BLOCKING, + keep_docstrings: bool = False, +) -> ParityResult: + """Compare a sync/async file pair after normalization. + + Args: + sync_path: Path to the sync (reference) module. + async_path: Path to the async twin module. + token_map: Async-to-sync token table. Defaults to `DEFAULT_TOKEN_MAP`. + mode: Blocking or report-only. + keep_docstrings: Forwarded to `normalize_source`. + + Returns: + A `ParityResult` describing whether the pair is in parity. + """ + sync_p, async_p = Path(sync_path), Path(async_path) + return check_parity_sources( + sync_p.read_text(encoding="utf-8"), + async_p.read_text(encoding="utf-8"), + sync_label=str(sync_p), + async_label=str(async_p), + token_map=token_map, + mode=mode, + keep_docstrings=keep_docstrings, + ) + + +def _load_token_map(path: Path | None) -> Mapping[str, str]: + """Load a token map from a JSON file, or return the default. + + Args: + path: Path to a JSON object of string-to-string entries, or ``None``. + + Returns: + The parsed token map, or `DEFAULT_TOKEN_MAP` when ``path`` is ``None``. + + Raises: + ValueError: If the JSON is not an object of string-to-string entries. + """ + if path is None: + return DEFAULT_TOKEN_MAP + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in raw.items() + ): + raise ValueError(f"token map at {path} must be a JSON object of string -> string") + return raw + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + """Build and parse the command-line arguments.""" + parser = argparse.ArgumentParser( + description="AST-normalized sync/async parity check.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("sync", type=Path, help="path to the sync (reference) module") + parser.add_argument("async_", metavar="async", type=Path, help="path to the async twin module") + parser.add_argument( + "--mode", + type=Mode, + default=Mode.BLOCKING, + choices=list(Mode), + help="blocking (drift fails) or report-only (drift recorded, never fails)", + ) + parser.add_argument( + "--token-map", type=Path, default=None, help="JSON file overriding the default token map" + ) + parser.add_argument( + "--keep-docstrings", + action="store_true", + help="compare docstrings instead of stripping them", + ) + parser.add_argument( + "--report-out", type=Path, default=None, help="write the diff artifact to this path" + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point for a single sync/async pair. + + Args: + argv: Argument vector (defaults to ``sys.argv[1:]``). + + Returns: + ``0`` when in parity or in report-only mode; ``1`` on drift in blocking + mode. + """ + args = _parse_args(argv) + result = check_parity( + args.sync, + args.async_, + token_map=_load_token_map(args.token_map), + mode=args.mode, + keep_docstrings=args.keep_docstrings, + ) + if args.report_out is not None: + artifact = result.diff or "# in parity: normalized ASTs are identical\n" + args.report_out.write_text(artifact, encoding="utf-8") + if result.in_parity: + print(f"parity OK: {args.sync} == {args.async_} (normalized)") + return 0 + sys.stdout.write(result.diff) + verdict = "FAIL" if result.failed else "drift (report-only)" + print(f"parity {verdict}: {args.sync} vs {args.async_}", file=sys.stderr) + return 1 if result.failed else 0 + + +__all__ = [ + "DEFAULT_TOKEN_MAP", + "Mode", + "ParityResult", + "check_parity", + "check_parity_sources", + "main", + "normalize_source", + "normalized_dump", +] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/requirement_index.json b/tools/requirement_index.json new file mode 100644 index 0000000..3fb2213 --- /dev/null +++ b/tools/requirement_index.json @@ -0,0 +1,3877 @@ +{ + "schema": "dexpace-requirement-index/v1", + "source": "java-sdk/docs/product-spec.md#appendix-c", + "count": 645, + "requirements": [ + { + "id": "SEAM-1", + "level": "MUST", + "subsystem": "seams", + "description": "The core library MUST NOT embed a concrete HTTP transport, a concrete byte-stream I/O implementation, or a concrete wire codec. It provides only the abstractions (models, pipeline, seams) and depends at runtime on nothing beyond its language's standard library plus a logging facade. Every concrete capability that touches the outside world is supplied by a separate plug-in." + }, + { + "id": "SEAM-2", + "level": "MUST", + "subsystem": "seams", + "description": "Each external concern that has a core-owned contract MUST be exposed as exactly one narrow interface seam that the core depends on but never implements, and the core MUST NOT reference any concrete implementation of that seam by name. The enumerated core interface seams are: byte-stream provider, synchronous transport, asynchronous transport, wire codec (serde), and operation-input->request projection. The async-runtime concern is NOT a core interface seam: it is handled through the canonical async pivot (the async transport's future return type) plus out-of-core adapter modules (see SEAM-17)." + }, + { + "id": "SEAM-3", + "level": "MUST", + "subsystem": "seams", + "description": "The byte-stream provider seam MUST expose factory operations that create: a new empty in-memory buffer, a buffered reader over a raw input stream, a buffered reader over a byte array, a buffered writer over a raw output stream, and wrappers that add the buffered surface to a primitive source/sink. A reader/writer created over a caller's raw input/output stream TAKES OWNERSHIP of that stream — closing the reader/writer closes the underlying stream." + }, + { + "id": "SEAM-4", + "level": "MUST", + "subsystem": "seams", + "description": "Byte-stream provider factory operations MUST be safe to invoke concurrently from many threads. The buffer/reader/writer instances they return are NOT required to be thread-safe; each such instance is expected to be confined to a single logical operation." + }, + { + "id": "SEAM-5", + "level": "MUST", + "subsystem": "seams", + "description": "Provider resolution MUST follow a fixed precedence: an explicitly installed provider always wins; otherwise the runtime auto-discovers providers registered on the classpath/plugin registry. Resolution MUST throw a descriptive error when ZERO providers are discoverable (message telling the caller to install one) and when MORE THAN ONE distinct provider is discoverable (message listing all candidates so the ambiguity is obvious). Exactly one discoverable provider is selected silently." + }, + { + "id": "SEAM-6", + "level": "MUST", + "subsystem": "seams", + "description": "Explicit installation of the byte-stream provider MUST be idempotent for the same instance (re-installing the identical provider is a no-op) and MUST reject installing a DIFFERENT provider when one is already installed, by throwing with a message naming both the incumbent and the rejected provider. A separate unchecked/internal swap seam MAY exist for test-scoped overrides." + }, + { + "id": "SEAM-7", + "level": "MUST", + "subsystem": "seams", + "description": "Once auto-discovery has resolved exactly one provider, that result MUST be cached process-wide so the discovery scan does not repeat on every access. An UNRESOLVED state (zero or multiple candidates, which throws) MUST remain re-evaluable on the next access, so a provider registered later (or an explicit install) can still take effect." + }, + { + "id": "SEAM-8", + "level": "SHOULD", + "subsystem": "seams", + "description": "When an explicit provider install replaces a DIFFERENT provider that had already been auto-resolved AND handed out to a caller, the runtime SHOULD emit a warning (not fail): objects may already have been created against the previously-resolved provider, risking mixed-provider state. No warning is warranted when nothing was resolved, when the resolved provider was never actually returned, or when the incoming provider is effectively the same implementation." + }, + { + "id": "SEAM-9", + "level": "MUST", + "subsystem": "seams", + "description": "Provider resolution/install/swap state MUST be concurrency-safe: reads observe the latest install without blocking, and writes are serialized so two concurrent installs cannot both pass the conflict check, and a concurrent first-access cannot run the discovery scan twice. A port must preserve this guarantee (memory-safe publication of the installed provider to non-blocking readers, serialized writes, single-scan resolution) rather than any particular concurrency mechanism." + }, + { + "id": "SEAM-10", + "level": "SHOULD", + "subsystem": "seams", + "description": "The registration mechanism SHOULD tolerate a single logical provider being seen through more than one registry/loader without misreporting it as multiple distinct providers. Candidates SHOULD be de-duplicated by concrete implementation identity, and a provider that is a thin shim delegating to a canonical instance SHOULD be recognizable AS that canonical instance (via an 'underlying' identity) so a shim + its target do not read as two providers or trip the mixed-provider warning." + }, + { + "id": "SEAM-11", + "level": "MUST", + "subsystem": "seams", + "description": "The synchronous transport seam MUST be a single-operation contract: given one request, produce one response. The response body MUST NOT be pre-buffered by the transport — the caller owns reading and closing it. The transport MAY additionally accept per-call options; a transport that ignores options MUST behave identically to the no-options call (options default to being dropped)." + }, + { + "id": "SEAM-12", + "level": "MUST", + "subsystem": "seams", + "description": "Transport implementations (sync and async) MUST be safe for concurrent calls from multiple threads, with all per-request state confined to locals or to the returned response graph (sync) / the returned future's completion graph (async)." + }, + { + "id": "SEAM-13", + "level": "SHOULD", + "subsystem": "seams", + "description": "Blocking transport implementations SHOULD honor cooperative cancellation (thread interruption on the JVM) during blocking I/O and propagate it per the SDK cancellation contract; async transports SHOULD treat cancelling the returned future as a best-effort request to abort the in-flight exchange and release transport resources (sockets, descriptors)." + }, + { + "id": "SEAM-14", + "level": "MUST", + "subsystem": "seams", + "description": "Both transport seams MUST be closeable, and the close contract MUST be: (1) idempotent — repeated close is safe; (2) ownership-aware — only resources the transport itself created are released, and BYO dependencies supplied by the caller (a pre-built native client, a caller-supplied executor) are NEVER touched; (3) interrupt-safe — a close path that blocks on shutdown honors cancellation. A lightweight transport MAY have a no-op close by default." + }, + { + "id": "SEAM-15", + "level": "MAY", + "subsystem": "seams", + "description": "After a transport's close() returns, further send calls MAY have undefined behavior — the SDK does not mandate a specific post-close failure mode (throw vs error response). A port MAY choose a mode but SHOULD document it." + }, + { + "id": "SEAM-16", + "level": "MUST", + "subsystem": "seams", + "description": "The asynchronous transport seam's returned future MUST complete either with a non-null response (caller owns closing it) or exceptionally with the transport failure. It MUST NOT complete successfully with a null/absent value — an implementation with no response MUST complete exceptionally instead. Cancelling an already-completed success future does NOT close the delivered response body; the caller MUST still close it even when discarding the value." + }, + { + "id": "SEAM-17", + "level": "SHOULD", + "subsystem": "seams", + "description": "The async transport contract SHOULD be expressed in terms of one canonical, dependency-free async primitive (a future that completes with a value or exceptionally) that serves as the interop pivot; ecosystem-specific facades (coroutines, reactive streams, event-loop futures, virtual threads) SHOULD be provided as separate adapter modules that bridge to and from that pivot, aiming to preserve cancellation and error semantics with per-adapter caveats documented (e.g. an executor-bridged blocking client can only interrupt on an interrupting cancel)." + }, + { + "id": "SEAM-18", + "level": "MUST", + "subsystem": "seams", + "description": "The provided sync<->async bridges MUST preserve semantics across the boundary: wrapping a blocking transport as async REQUIRES the caller to supply an executor (there is intentionally no default, and a shared global fork/join-style pool is explicitly not acceptable because a blocking call would starve it); wrapping an async transport as blocking MUST unwrap the async wrapper exception so callers observe the original failure, and the blocking wait MUST honor interruption (restore the interrupt flag, cancel the in-flight future, surface an interrupted-I/O error). Per-call options MUST be threaded through the bridge, not dropped." + }, + { + "id": "SEAM-19", + "level": "MUST", + "subsystem": "seams", + "description": "The wire-codec (serde) seam MUST be a bundle exposing a serializer, a deserializer, and the media type its serializer produces. The media type MUST NOT be defaulted — each codec declares its own (e.g. JSON vs XML) so a non-JSON codec cannot silently stamp the wrong content type. Concrete codecs live outside the core." + }, + { + "id": "SEAM-20", + "level": "MUST", + "subsystem": "seams", + "description": "Serializer operations MUST offer the common allocation profiles (produce a fresh string, produce a fresh byte array, stream into a caller-owned output, encode into a caller-owned scratch buffer at an offset), and streaming/buffer variants MUST NOT close the caller's target. Encode failures MUST surface as a stable SDK serialization-failure type (subtype of the serde failure type), chaining the underlying codec's error as cause; a genuine downstream stream-write I/O error propagates unwrapped, and a fixed-buffer overflow raises an index/bounds error." + }, + { + "id": "SEAM-21", + "level": "MUST", + "subsystem": "seams", + "description": "Deserializer operations MUST require an explicit runtime type token for the target type rather than relying on an erased/inferred generic, so a language with type erasure recovers the intended type instead of silently producing a generic map/list that fails at first field access. For parametric targets a full generic type capture MUST be accepted; a format-agnostic (no-codec) deserializer MUST fail with the stable serde failure type when asked to resolve a genuinely parametric type it cannot handle. Decode failures MUST surface as a stable SDK deserialization-failure type chaining the underlying cause; a genuine stream-read I/O error propagates unwrapped, and streaming decode reads to EOF but does NOT close the caller's stream." + }, + { + "id": "SEAM-22", + "level": "MUST", + "subsystem": "seams", + "description": "A full generic type capture used for deserialization MUST be created with a concrete type argument at the call site (on the JVM: an anonymous subclass). Construction MUST reject a capture whose argument is an unresolved type variable (i.e. captured inside a generic function/subclass where the argument is erased to its bound), because decoding it would resolve to the wrong type. The capture exposes both the full generic type and its erased raw class for a fast non-parametric path." + }, + { + "id": "SEAM-23", + "level": "MUST", + "subsystem": "seams", + "description": "The serde seam's failure types MUST form a stable, SDK-owned hierarchy (a base serde failure with encode/decode subtypes) that adapters throw INSTEAD of leaking their backing library's exception type, always chaining the original as the cause so no diagnostic is lost. These failures are unchecked (callers are not forced to wrap every round-trip), and the base type is open for codegen/adapters to add more specific subtypes." + }, + { + "id": "SEAM-24", + "level": "SHOULD", + "subsystem": "seams", + "description": "Async-runtime adapter modules that hand work to another thread SHOULD propagate the ambient logging/diagnostic context (e.g. MDC) across the thread handoff so log/trace events emitted on the worker carry the originating context. Each adapter's cancellation bridge SHOULD map cancellation in both directions per that ecosystem's idiom (e.g. cancelling a downstream subscription/future cancels the pivot future, and vice versa)." + }, + { + "id": "SEAM-25", + "level": "MUST", + "subsystem": "seams", + "description": "An async-runtime adapter that OWNS an executor/thread-pool resource MUST implement close() as an idempotent, ownership-aware release: only the first close shuts the owned executor down and emits the lifecycle event, later closes are true no-ops. Closing such an adapter MUST NOT be required to cancel in-flight requests (a graceful shutdown that waits for running tasks is acceptable). An adapter/bridge built over a caller-supplied executor MUST NOT shut that executor down on close." + }, + { + "id": "SEAM-26", + "level": "MUST", + "subsystem": "seams", + "description": "The operation-input projection seam MUST let generated/operation code declare, per operation, an HTTP method, a path template with named placeholders, and typed projections of inputs onto path / query / header / body — so operation arguments (cursors, page numbers, ids) flow through typed projections rather than being spliced into a URL by string surgery. Only method and path template are required; the four projections default to empty for a parameterless operation. The body is carried, not encoded, by this seam." + }, + { + "id": "SEAM-27", + "level": "MUST", + "subsystem": "seams", + "description": "When the operation projection is assembled against a base URL, path-parameter values MUST be percent-encoded as single path segments (so a value cannot inject extra '/' segments), every placeholder in the template MUST have a supplied value (a missing one is an error), the query MUST be RFC-3986 rendered, and base-URL composition MUST follow fixed rules: a trailing slash is normalized to exactly one separator, an empty path leaves the base untouched, an existing base query is preserved with the operation's query appended after it (its dangling separator dropped), and a base URL carrying a fragment or resolving to a malformed URL is rejected with a context-bearing error." + }, + { + "id": "SEAM-28", + "level": "MAY", + "subsystem": "seams", + "description": "The operation projection MAY carry a stable operation identifier for instrumentation/tracing; when present it is attached to the request's context chain but MUST NOT affect the assembled request's URL, headers, or body." + }, + { + "id": "SEAM-29", + "level": "MUST", + "subsystem": "seams", + "description": "Public model construction MUST go through the immutable-value + Builder pattern: models are immutable, constructed only via a builder whose build() materializes a new instance, and a shared generic Builder contract (build() producing the target type) MUST exist so generic composition/folding helpers can accept any builder. Required-field validation MUST be uniform: a missing required field fails at build() with a consistent, field-named error message of the form ' is required'." + }, + { + "id": "SEAM-30", + "level": "MUST", + "subsystem": "seams", + "description": "On any path where a send produces a response value that the returned future will NOT hand to a caller — because the future was already cancelled or completed exceptionally (the completion race) — the producer MUST close that orphaned response so its underlying connection/descriptor is not leaked. This closes the gap in the caller-closes-the-response rule (SEAM-16), which cannot apply to a value no caller receives, and applies to native async transports, the sync->async bridge, and every ecosystem adapter that can drop a produced value." + }, + { + "id": "HTTP-1", + "level": "MUST", + "subsystem": "http", + "description": "All core domain-model types (Request, Response, Headers, MediaType, QueryParams, RequestOptions, RequestConditions, Status, ETag, HttpRange, the typed header name, Protocol, Method) MUST have an immutable value/metadata surface after construction, safe to share across threads without external synchronization; any change MUST produce a new instance rather than mutating an existing one. The one carve-out: a body attached to a Request/Response may carry single-use stream state whose consumption is NOT thread-safe (see the body requirements), and per-call tag values are opaque so their own mutability is the caller's concern." + }, + { + "id": "HTTP-2", + "level": "MUST", + "subsystem": "http", + "description": "Model instances MUST be constructible only through their builder or dedicated factory, never by exposing a public field-wise constructor or an unchecked copy operation that bypasses builder/factory validation." + }, + { + "id": "HTTP-3", + "level": "MUST", + "subsystem": "http", + "description": "Each builder-based model (Request, Response, Headers, QueryParams, RequestOptions, RequestConditions, and the multipart body) MUST expose a newBuilder()-style derivation returning a builder pre-populated with the instance's current fields, so a caller can derive a modified copy without restating unchanged fields; the pre-filled builder MUST NOT alias the original's internal collections (each value list is copied). Value-based types that have no builder (MediaType, Status, the typed header name, ETag, HttpRange, Method, Protocol) are instead derived by re-constructing through their factories and intentionally expose no builder." + }, + { + "id": "HTTP-4", + "level": "MUST", + "subsystem": "http", + "description": "A builder's build() MUST validate that all required fields are present and fail with a clear, field-named error when one is missing (Request requires url; Response requires request, protocol, status). Missing-field failures MUST NOT silently substitute defaults except where a default is explicitly specified." + }, + { + "id": "HTTP-5", + "level": "MUST", + "subsystem": "http", + "description": "Accessors returning collections of header/query names, values, or entries MUST NOT let a caller mutate the model through the returned value, and MUST NOT surface later mutations of a live builder. Name-set and entry-set accessors return a fresh per-call snapshot; per-name value-list accessors return the instance's own list. Isolation from a builder is guaranteed by a build-time deep copy of every value list; isolation from mutation-through-the-collection is guaranteed by returning read-only-typed collections. A port in a language without read-only collection views MUST reproduce this guarantee with unmodifiable wrappers or per-call defensive copies, or a consumer could downcast a returned value list and mutate the shared model." + }, + { + "id": "HTTP-6", + "level": "MUST", + "subsystem": "http", + "description": "A Request MUST carry exactly method, target URL, headers, and an optional body; headers MUST always be a non-null (possibly empty) collection. A Response MUST carry the originating request, negotiated protocol, status, an optional reason phrase, headers (non-null, possibly empty), and an optional body." + }, + { + "id": "HTTP-7", + "level": "MUST", + "subsystem": "http", + "description": "The builder MUST reject a non-null request body on any method whose classification forbids a body (GET, HEAD, TRACE, CONNECT), failing at construction rather than deferring to the transport. To downgrade a body-carrying request to such a method, the caller clears the body first." + }, + { + "id": "HTTP-8", + "level": "SHOULD", + "subsystem": "http", + "description": "When no method is set, build() SHOULD default to GET only if no body is present; if a body is present but no method was set, build() SHOULD fail reporting a missing method (a forgotten verb) rather than defaulting to GET and then tripping the no-body-on-GET rule with a misleading message." + }, + { + "id": "HTTP-9", + "level": "MUST", + "subsystem": "http", + "description": "The model MUST define an idempotency classification used by retry logic — the idempotent method set is {GET, HEAD, OPTIONS, PUT, DELETE} — and this set MUST be the single source both the configurable retry allow-list and the inherent replay-safety gate derive from. (There is no separate safe-method classification, and the idempotent set is an internal constant rather than a public accessor on the method type.) A method's canonical wire token MUST equal its uppercase name and be usable directly in a request line without translation." + }, + { + "id": "HTTP-10", + "level": "MUST", + "subsystem": "http", + "description": "Status MUST be a total function of the integer code: mapping any code MUST return a Status (never throw), returning a canonical named instance for recognized codes and an instance carrying the raw code with a null/absent name for unrecognized ones. A separate lookup MUST allow callers to distinguish recognized codes (returns null/absent for unknown)." + }, + { + "id": "HTTP-11", + "level": "MUST", + "subsystem": "http", + "description": "Status MUST classify by numeric range: informational 100-199, success 200-299, redirect 300-399, client-error 400-499, server-error 500-599, and error 400-599. Response MUST expose these same classifications derived from its status." + }, + { + "id": "HTTP-12", + "level": "MUST", + "subsystem": "http", + "description": "Two Status values MUST be equal iff their numeric codes are equal (name does not participate), so a code-derived Status compares equal to the corresponding canonical constant." + }, + { + "id": "HTTP-13", + "level": "MUST", + "subsystem": "http", + "description": "Header names MUST be treated case-insensitively for storage, lookup, containment, mutation, removal, equality, and hashing, by folding to lower case using an ASCII/invariant rule (never a locale-sensitive fold such as Turkish i). A header added under one casing MUST be visible under any other casing." + }, + { + "id": "HTTP-14", + "level": "MUST", + "subsystem": "http", + "description": "The header model MUST support multiple values per name: an add operation appends to the name's value list; a set operation replaces the entire list for that name. Per-name value order MUST be preserved in insertion order." + }, + { + "id": "HTTP-15", + "level": "MUST", + "subsystem": "http", + "description": "Setting a header value to null MUST remove the header entirely (equivalent to remove), rather than storing a null or empty value." + }, + { + "id": "HTTP-16", + "level": "SHOULD", + "subsystem": "http", + "description": "The header model SHOULD preserve the insertion order of distinct names so serialization and iteration are deterministic and reproducible." + }, + { + "id": "HTTP-17", + "level": "MUST", + "subsystem": "http", + "description": "Outbound (caller-set) header names MUST be validated at construction: reject a blank name, and reject any name containing a control character (C0 range 0x00-0x1F or DEL 0x7F, which includes CR, LF, NUL) or any non-ASCII byte (>= 0x80). Surrounding whitespace MUST be trimmed before validation, and the trimmed name stored." + }, + { + "id": "HTTP-18", + "level": "MUST", + "subsystem": "http", + "description": "Outbound (caller-set) header values MUST be validated: reject any control character (C0 0x00-0x1F and DEL 0x7F) EXCEPT horizontal tab 0x09, and reject any non-ASCII byte (>= 0x80). The accepted set is HTAB plus printable ASCII 0x20-0x7E." + }, + { + "id": "HTTP-19", + "level": "MUST", + "subsystem": "http", + "description": "The model MUST provide a distinct lenient path for inbound (already-received / response) header values that RELAXES the non-ASCII rule (obs-text 0x80+ is permitted) while STILL rejecting control characters (C0 except HTAB, plus DEL). Header names on the inbound path remain strictly validated." + }, + { + "id": "HTTP-20", + "level": "MUST", + "subsystem": "http", + "description": "Validation error messages MUST NOT echo the offending header value verbatim, and MUST escape any control characters in an echoed header name (e.g. as \\uXXXX), so a rejected CR/LF/NUL or a secret/oversized value never lands raw in a log line." + }, + { + "id": "HTTP-21", + "level": "MUST", + "subsystem": "http", + "description": "A typed header-name abstraction MUST compare equal and hash by its case-folded form while preserving the original casing for wire emission/logging; it MUST be interchangeable with the string-keyed header API (a name added via one form is visible via the other) and MUST enforce the same name validation as HTTP-17." + }, + { + "id": "HTTP-22", + "level": "MAY", + "subsystem": "http", + "description": "The typed header-name abstraction MAY intern instances process-wide so repeated lookups of the same name share one instance, with the first caller's casing winning. This is an optimization; the observable contract is value equality by case-folded name (HTTP-21), not reference identity." + }, + { + "id": "HTTP-23", + "level": "MUST", + "subsystem": "http", + "description": "MediaType construction MUST lower-case the type, subtype, and every parameter KEY, but MUST preserve the case of every parameter VALUE. Equality MUST therefore be case-insensitive on type/subtype/param-keys and case-sensitive on param-values." + }, + { + "id": "HTTP-24", + "level": "MUST", + "subsystem": "http", + "description": "MediaType MUST resolve its charset parameter case-insensitively and MUST return null (not throw) when the parameter is absent or names an unknown/unsupported charset, so callers can fall back to a default without exception handling." + }, + { + "id": "HTTP-25", + "level": "MUST", + "subsystem": "http", + "description": "MediaType parsing MUST split parameters while respecting quoted-strings (a ';' or '=' inside double quotes is not a separator), MUST split each parameter on its FIRST '=' only, and MUST strip surrounding quotes and unescape quoted-pairs (\\\" → \", \\\\ → \\). Rendering MUST emit a parameter value bare when it is a valid token and as an escaped quoted-string otherwise, so parse(render(x)) == x for every constructible MediaType (including boundaries containing ';' or '=')." + }, + { + "id": "HTTP-26", + "level": "MUST", + "subsystem": "http", + "description": "MediaType construction MUST reject a control character (C0 except HTAB, plus DEL) or a non-ASCII byte in the type, subtype, or any parameter key/value, using the same predicate as outbound header-value validation, so a media type can always be emitted as a Content-Type header without a late rejection or a CR/LF injection." + }, + { + "id": "HTTP-27", + "level": "SHOULD", + "subsystem": "http", + "description": "MediaType SHOULD support wildcard matching where a wildcard type is permitted only when the subtype is also a wildcard (bare */*), and an includes()/matches operation treats a wildcard in either position as matching any value with parameters ignored (a wildcard-subtype receiver matches concrete subtypes but not the reverse)." + }, + { + "id": "HTTP-28", + "level": "MUST", + "subsystem": "http", + "description": "QueryParams MUST treat parameter names as case-sensitive (no folding), preserve insertion order, support multiple values per name, and model a value-less parameter (?flag) as a single empty-string value distinct from an absent name." + }, + { + "id": "HTTP-29", + "level": "MUST", + "subsystem": "http", + "description": "QueryParams.encode() MUST render each name and value with RFC 3986 percent-encoding (space → %20, literal '+' → %2B, '/' → %2F, '*' → %2A), percent-encoding everything except the unreserved set A-Z a-z 0-9 - . _ ~, MUST preserve insertion order, MUST emit a repeated name once per value (a=1&a=2), MUST omit the leading '?', and MUST return an empty string when empty. This is NOT application/x-www-form-urlencoded (which uses '+' for space)." + }, + { + "id": "HTTP-30", + "level": "MUST", + "subsystem": "http", + "description": "QueryParams equality MUST be order-sensitive: two instances are equal iff they carry the same names and values in the same order — i.e. iff they encode() identically. A name whose value list is empty MUST be dropped at build time (it renders nothing) so it cannot leave a phantom contains()==true entry invisible to encode()." + }, + { + "id": "HTTP-31", + "level": "MUST", + "subsystem": "http", + "description": "QueryParams.parse() MUST invert encode() for names/values/order and MUST be lenient on input: a null or blank query yields empty; a leading '?' is tolerated and stripped; a segment with no '=' and a segment with a trailing '=' both yield an empty-string value; empty segments (stray '&') are skipped; and malformed percent-encoding falls back to the raw text rather than throwing." + }, + { + "id": "HTTP-32", + "level": "SHOULD", + "subsystem": "http", + "description": "Percent-encoding of a single component SHOULD encode using RFC 3986 unreserved-set rules independent of any underlying library quirks: a space becomes %20 (never '+'), a literal '+' becomes %2B, and decoding MUST leave a literal '+' as '+' (never a space). '~' MUST remain unencoded and '*' MUST be encoded." + }, + { + "id": "HTTP-33", + "level": "MUST", + "subsystem": "http", + "description": "Protocol MUST expose a canonical lower-case wire form (e.g. http/1.1, http/2) and a case-insensitive parse that accepts the canonical forms plus the aliases HTTP/2 and HTTP/2.0 for HTTP/2, throwing on an unrecognized identifier. Case folding MUST be locale-invariant." + }, + { + "id": "HTTP-34", + "level": "MUST", + "subsystem": "http", + "description": "RequestOptions MUST model per-call operational overrides that are NOT part of the wire form (at minimum: per-call timeout, per-call max-retries, and opaque string-keyed tags), with every field defaulting to a null/empty 'use the transport/pipeline default' sentinel, and MUST provide a canonical EMPTY 'override nothing' instance used when a caller supplies no options. Tags MUST be defensively copied at build." + }, + { + "id": "HTTP-35", + "level": "MUST", + "subsystem": "http", + "description": "RequestOptions builder MUST reject a non-null timeout that is zero or negative and MUST reject a negative max-retries, because those values carry inconsistent or opposite meanings downstream (zero-timeout means 'no timeout' in one transport but is an error in another; a negative retry count would be silently reinterpreted as 'use default'). A max-retries of 0 MUST be accepted and MUST mean 'disable retries for this call'." + }, + { + "id": "HTTP-36", + "level": "MUST", + "subsystem": "http", + "description": "A request body MUST produce its bytes on demand via a single write-to-sink operation, MUST report its media type (nullable) and content length (with a negative sentinel, -1, meaning 'unknown'), and MUST expose whether it is replayable — i.e. whether writing it more than once yields identical bytes." + }, + { + "id": "HTTP-37", + "level": "MUST", + "subsystem": "http", + "description": "A single-use (non-replayable) body MUST fail loudly on a second write attempt (throwing, not silently emitting zero bytes), and the second-write guard MUST be race-safe so that under concurrent writes at most one write proceeds and the loser observes a clear error. toReplayable() on a single-use body MUST drain it once into memory, MUST return a replayable equivalent, and MUST leave the original consumed (the caller continues with the returned value)." + }, + { + "id": "HTTP-38", + "level": "MUST", + "subsystem": "http", + "description": "Body factories MUST classify replayability by source: byte-array, string, in-memory buffer, file, and serialized-object bodies are replayable; a body backed by a one-shot source/stream is single-use UNLESS the stream supports mark/reset AND the declared length fits the mark limit, in which case it MAY be replayable via reset. A form-urlencoded body MUST be replayable and MUST use x-www-form-urlencoded encoding ('+' for space) distinct from RFC 3986 query encoding." + }, + { + "id": "HTTP-39", + "level": "MUST", + "subsystem": "http", + "description": "A body that copies a fixed number of bytes from a source MUST write exactly that many bytes and MUST raise an end-of-input error if the source ends early (it MUST NOT silently send a short body or spin on a zero-length read)." + }, + { + "id": "HTTP-40", + "level": "MUST", + "subsystem": "http", + "description": "A file-backed body MUST validate fail-fast at construction: the file must exist, must be a regular file (not a directory/special file), the start offset must be non-negative and within the file size, and offset+count must not exceed the file size captured at construction. It MUST be replayable, opening a fresh file handle on each write, and MUST expose the exact byte count it will upload." + }, + { + "id": "HTTP-41", + "level": "MUST", + "subsystem": "http", + "description": "A response body MUST be single-use (its byte source can be read only once; repeated accessors return the same underlying source) and MUST require an explicit, idempotent close that releases the underlying transport connection even when the body was never read. Convenience readers that materialize the whole body (as string or byte array) MUST close the body in a finally block whether or not the read succeeds." + }, + { + "id": "HTTP-42", + "level": "MUST", + "subsystem": "http", + "description": "Reading a response body as text MUST default its charset to the charset declared in the body's media type, falling back to UTF-8 when none is declared or the declared charset is unknown." + }, + { + "id": "HTTP-43", + "level": "MUST", + "subsystem": "http", + "description": "Response MUST be closeable and its close MUST be idempotent and forward to the body, so a response can be released in a scoped/using block whether or not its body was consumed. (Idempotency is delegated to the body's idempotent close per HTTP-41; a bodyless response close is a no-op.)" + }, + { + "id": "HTTP-44", + "level": "MUST", + "subsystem": "http", + "description": "A lazy typed-response wrapper MUST expose raw status/headers/protocol/reason/request WITHOUT consuming the body, and MUST parse the typed value at most once on first access, memoizing the outcome so every later access returns the same value or re-throws the same failure without re-running the handler or re-reading the single-use body. Both a null success and a thrown failure MUST be memoized." + }, + { + "id": "HTTP-45", + "level": "MUST", + "subsystem": "http", + "description": "Concurrent first accesses to the lazy typed value MUST be serialized so the handler runs exactly once. The serializing primitive MUST NOT pin/park the underlying OS carrier thread for the duration of the parse — portable intent: use a lock that cooperates with lightweight/virtual-thread schedulers rather than an intrinsic monitor that holds and pins the carrier across the parse." + }, + { + "id": "HTTP-46", + "level": "MUST", + "subsystem": "http", + "description": "Request URL equality/hashing MUST NOT perform blocking work or name resolution (DNS): URLs MUST be compared by their textual external form. Request equality MUST otherwise compare method, headers, and body by value." + }, + { + "id": "HTTP-47", + "level": "SHOULD", + "subsystem": "http", + "description": "Building a Request from a malformed URL string or a non-absolute URI SHOULD fail with an argument error carrying the offending input, rather than surfacing a lower-level or transport-specific error later." + }, + { + "id": "HTTP-48", + "level": "SHOULD", + "subsystem": "http", + "description": "An ETag helper SHOULD model the three RFC 7232 forms — strong (\"opaque\"), weak (W/\"opaque\"), and the any singleton (*) — validate that a supplied opaque value contains only permitted etagc characters (rejecting a literal double-quote, control characters, and DEL, permitting obs-text), reject an empty strong opaque, permit an empty weak opaque, and round-trip its raw header form through parse/render preserving the exact text. parse MUST reject unterminated forms and return absent for blank input." + }, + { + "id": "HTTP-49", + "level": "SHOULD", + "subsystem": "http", + "description": "An HTTP Range helper SHOULD provide validated factories for a bounded byte range (offset+length, rejecting negative offset / non-positive length and detecting overflow), a suffix range (last N bytes), and an open-ended range (offset to end), SHOULD support only the 'bytes' unit and only a single range (rejecting multi-range commas), and SHOULD store a parsed value verbatim so its casing round-trips." + }, + { + "id": "HTTP-50", + "level": "SHOULD", + "subsystem": "http", + "description": "A conditional-requests aggregator SHOULD emit If-Match/If-None-Match as a single comma-separated header per list, emit If-Modified-Since/If-Unmodified-Since as RFC 1123 dates, be idempotent when applied to a header set (using set, not add), and enforce that the any-tag (*) is mutually exclusive with concrete entity-tags in the same list (collapsing repeated * to one)." + }, + { + "id": "HTTP-51", + "level": "SHOULD", + "subsystem": "http", + "description": "A multipart body SHOULD derive its declared content length and its written bytes from a single shared framing routine (so the declared length can never drift from the bytes written), SHOULD report replayable only when every part body is replayable and length-known only when every part body has a known length, SHOULD generate a spec-valid random boundary and reject a caller-supplied boundary that violates the RFC 2046 grammar (1-70 chars from bcharsnospace), and MUST quote/escape part header parameter values so CR/LF or a quote cannot break the framing." + }, + { + "id": "HTTP-52", + "level": "MUST", + "subsystem": "http", + "description": "An error-mapping path that must retain a response body after the transport connection is released MUST buffer the body into memory up to a fixed cap (1 MiB), dropping bytes beyond the cap, and MUST expose the buffered copy as a replayable body readable multiple times. Buffering MUST occur inside the original body's close scope so a provider-resolution failure still releases the connection." + }, + { + "id": "HTTP-53", + "level": "MUST", + "subsystem": "http", + "description": "Parsing a media-type string MUST reject blank input and MUST require a well-formed type/subtype: a '/' must be present with a non-empty type before it and a non-empty subtype after it (a bare word, a leading-slash, and a trailing-slash form are all rejected). Each parameter segment MUST contain a '=', with a non-empty key and a non-empty raw value, or parsing fails." + }, + { + "id": "IO-1", + "level": "MUST", + "subsystem": "io", + "description": "Source.read(dest, byteCount) MUST append the bytes it reads to the TAIL of the caller-provided destination buffer (never overwrite existing content), and MUST return the number of bytes transferred: at least 1 when byteCount>0 and the source is not exhausted, exactly 0 when byteCount==0, -1 when the source is exhausted before any byte is read, and never more than byteCount." + }, + { + "id": "IO-2", + "level": "MUST", + "subsystem": "io", + "description": "A read of byteCount==0 MUST return 0 and MUST NOT report end-of-stream (-1), even when the source is already exhausted." + }, + { + "id": "IO-3", + "level": "MUST", + "subsystem": "io", + "description": "A negative byteCount passed to a size-taking read/write/copy operation MUST be rejected as an argument-validation (programming) error before any I/O occurs, rather than silently clamped. Note the reference raises this as an IllegalArgumentException for Source.read / Sink.write and as an IndexOutOfBoundsException (a bounds error) for Buffer.copyTo; a port MAY use whichever argument-error type is idiomatic, so long as it fails fast." + }, + { + "id": "IO-4", + "level": "MUST", + "subsystem": "io", + "description": "Sink.write(src, byteCount) MUST remove exactly byteCount bytes from the HEAD of the source buffer and push them downstream; if the source holds fewer than byteCount bytes this MUST fail with an I/O error rather than write a short/partial amount." + }, + { + "id": "IO-5", + "level": "MUST", + "subsystem": "io", + "description": "Sink MUST expose flush() that pushes currently-buffered bytes toward their final destination, and both Source and Sink MUST be closeable." + }, + { + "id": "IO-6", + "level": "MUST", + "subsystem": "io", + "description": "When a provider wraps a caller-supplied underlying stream (readable stream -> buffered source, writable stream -> buffered sink), the returned wrapper MUST take ownership of that stream: closing the wrapper closes the underlying stream. The same holds for the stream bridges obtained from a buffered source/sink (closing the bridge closes the owning source/sink). Wrapping a plain byte array owns no external resource." + }, + { + "id": "IO-7", + "level": "MUST", + "subsystem": "io", + "description": "A Buffer MUST behave as a FIFO byte queue that is simultaneously a source and a sink: bytes written through its sink surface MUST be read back through its source surface in the exact order written, and its size MUST reflect the number of bytes currently held." + }, + { + "id": "IO-8", + "level": "MUST", + "subsystem": "io", + "description": "Buffer.snapshot() MUST return a fresh, independent byte-array copy of the buffer's current contents without consuming or otherwise mutating the buffer, such that later mutations of the buffer do not affect a previously returned snapshot and vice versa." + }, + { + "id": "IO-9", + "level": "SHOULD", + "subsystem": "io", + "description": "Materializing an entire buffer as one contiguous byte array via snapshot() SHOULD refuse sizes that exceed the host's maximum single-array allocation, failing loudly with an actionable message that points callers at streaming alternatives (stream bridge / copyTo) rather than crashing with a low-level allocation error. Length-bounded slice reads apply the same guarded, message-bearing cap. Plain (non-slice) exact-count buffered reads instead inherit whatever bounds check the underlying stream library performs, which may raise a less specific error." + }, + { + "id": "IO-10", + "level": "MUST", + "subsystem": "io", + "description": "Buffer.clear() MUST discard every byte (leaving size==0), and Buffer.copyTo(out, offset, byteCount) MUST copy the specified window into another buffer WITHOUT consuming or mutating the source buffer, defaulting to 'from offset through end' when byteCount is omitted, and rejecting out-of-range windows (negative offset/byteCount or offset+byteCount>size)." + }, + { + "id": "IO-11", + "level": "MUST", + "subsystem": "io", + "description": "exhausted() MUST return true exactly when no more bytes are available (and may block while it waits on an upstream source to decide), readByte() MUST return the next byte or fail with an EOF error when none remain, and readByteArray() (no count) MUST return all remaining bytes (empty array when already exhausted)." + }, + { + "id": "IO-12", + "level": "MUST", + "subsystem": "io", + "description": "An exact-count read (readByteArray(n) / readUtf8(n)) MUST return exactly n bytes/characters or fail with an EOF error if fewer than n remain; it MUST NOT return a short result." + }, + { + "id": "IO-13", + "level": "MUST", + "subsystem": "io", + "description": "UTF-8 and explicit-charset reads MUST decode the requested bytes with the specified encoding: readUtf8()/readString(charset) drain and decode all remaining bytes, and the write side MUST provide the symmetric encodings (writeUtf8, writeUtf8(substring range), writeString(charset))." + }, + { + "id": "IO-14", + "level": "MUST", + "subsystem": "io", + "description": "readUtf8Line() MUST read up to and consume the next line terminator and return the preceding bytes decoded as UTF-8, treating both '\\n' and '\\r\\n' as terminators; it MUST return null when the source is exhausted before any byte is read; a final line with no terminator MUST be returned as-is; and a lone '\\r' not followed by '\\n' MUST be kept as part of the line's content." + }, + { + "id": "IO-15", + "level": "MUST", + "subsystem": "io", + "description": "skip(byteCount) MUST advance past exactly byteCount bytes, failing with an EOF error if fewer remain (skip(0) MUST be a no-op even at/after EOF)." + }, + { + "id": "IO-16", + "level": "SHOULD", + "subsystem": "io", + "description": "A BufferedSource SHOULD provide a read-only host-native byte-stream bridge whose single-byte read returns the next byte as an unsigned value 0..255 or -1 at end, and whose bulk read returns the count read or -1 at end; closing the bridge MUST close (or invalidate) the owning source. Symmetrically a BufferedSink SHOULD provide a writable-stream bridge whose close closes the sink." + }, + { + "id": "IO-17", + "level": "MUST", + "subsystem": "io", + "description": "writeAll(source) MUST pump the source to exhaustion into the sink and return the total number of bytes transferred; it MUST terminate only on a -1 (EOF) read. When pumping a foreign (non-adapter-native) source, a read that returns 0 for a non-zero requested count MUST be treated as a source contract violation and raised as an I/O error (not tolerated as EOF and not spun on forever)." + }, + { + "id": "IO-18", + "level": "SHOULD", + "subsystem": "io", + "description": "The sink surface SHOULD distinguish emit() from flush(): emit pushes buffered bytes one level toward their destination (a cheap hand-off, e.g. staging buffer -> underlying stream) without forcing a system-level flush, while flush forces bytes all the way out. On a pure in-memory buffer both MAY be no-ops that simply return self." + }, + { + "id": "IO-19", + "level": "MUST", + "subsystem": "io", + "description": "peek() MUST return a non-consuming view over the whole remaining source such that reads from the peek view do not advance the original source's cursor." + }, + { + "id": "IO-20", + "level": "MUST", + "subsystem": "io", + "description": "slice(offset, byteCount) MUST return a non-consuming, length-bounded view exposing at most byteCount bytes starting offset bytes ahead of the current cursor; reads from the slice MUST NOT advance the parent source, and reading past the window MUST behave as end-of-window (short read / EOF as appropriate to the read form)." + }, + { + "id": "IO-21", + "level": "MUST", + "subsystem": "io", + "description": "Slice offset overflow MUST be detected LAZILY: constructing a slice whose offset exceeds the source size MUST succeed, and the overflow MUST surface only on first read as an empty/EOF result (empty array or empty string for drain-style reads, EOF error for exact/byte reads, null for line reads). Negative offset or negative byteCount MUST be rejected eagerly at construction." + }, + { + "id": "IO-22", + "level": "MUST", + "subsystem": "io", + "description": "Closing a slice MUST NOT close its parent source and MUST NOT advance the parent's cursor; conversely, closing the parent source MUST invalidate every outstanding slice derived from it so that subsequent reads on those slices fail loudly (a state/IO error), never returning stale or arbitrary bytes." + }, + { + "id": "IO-23", + "level": "MUST", + "subsystem": "io", + "description": "Multiple slices (and peeks) of the same source MUST be mutually independent (each has its own cursor and byte budget), and a slice-of-a-slice MUST compose offsets additively and cap its window at the outer slice's remaining bytes." + }, + { + "id": "IO-24", + "level": "MUST", + "subsystem": "io", + "description": "Reading from a slice AFTER it has been explicitly closed MUST fail loudly (a state error) for every read form, distinct from normal EOF." + }, + { + "id": "IO-25", + "level": "MUST", + "subsystem": "io", + "description": "A TeeSink MUST mirror the bytes written through it into an in-memory tap buffer AND forward the full, untruncated payload to its primary sink; the bytes delivered to the primary (the wire body) MUST never be reduced or altered by the presence of the tap." + }, + { + "id": "IO-26", + "level": "MUST", + "subsystem": "io", + "description": "A TeeSink MUST support a tap capacity limit that bounds how many bytes are mirrored into the tap; once the limit is reached, further writes MUST stop copying into the tap while still forwarding the FULL payload to the primary. The default limit MUST be effectively unbounded, and a limit of 0 MUST mirror nothing while still forwarding everything." + }, + { + "id": "IO-27", + "level": "MUST", + "subsystem": "io", + "description": "A TeeSink MUST mirror the attempted bytes into the tap BEFORE forwarding them to the primary sink, so that if the primary write fails mid-stream the attempted bytes are still captured in the tap; and its staging buffer MUST be cleared even on a failed primary write so a later write does not prepend stale bytes." + }, + { + "id": "IO-28", + "level": "MUST", + "subsystem": "io", + "description": "A TeeSink MUST NOT expose direct access to a backing buffer (attempting it MUST fail with a clear error directing callers to the typed write methods), because direct buffer writes would reach only the tap or only the primary and silently corrupt the wire body." + }, + { + "id": "IO-29", + "level": "MUST", + "subsystem": "io", + "description": "A TeeSink's own flush(), close(), and emit() MUST forward to the PRIMARY sink only (not the tap), so lifecycle/flush semantics of the real destination are preserved and the in-memory tap is left intact for later snapshotting. (The separate writable-stream bridge it exposes may additionally flush/close its own in-memory tap stream, which is harmless.)" + }, + { + "id": "IO-30", + "level": "MUST", + "subsystem": "io", + "description": "The provider seam MUST offer factory operations to (a) create a new empty in-memory Buffer, (b) wrap a caller-supplied readable stream as a BufferedSource and a byte array as a BufferedSource, (c) wrap a caller-supplied writable stream as a BufferedSink, and (d) wrap a foreign primitive Source/Sink with the typed buffered surface. Each Buffer produced MUST be a fresh, independent, empty instance, and the byte-array-wrapping source MUST be an independent copy of the input (later mutation of the input array does not change the source, and vice versa)." + }, + { + "id": "IO-31", + "level": "MUST", + "subsystem": "io", + "description": "The provider registry MUST resolve the active implementation with this precedence: (1) an explicitly installed provider always wins; (2) otherwise a single implementation is auto-discovered from the runtime's plugin/registration mechanism; (3) if auto-discovery finds zero implementations OR more than one, resolution MUST fail loudly with an actionable message (the multiple-candidate message naming all candidates)." + }, + { + "id": "IO-32", + "level": "MUST", + "subsystem": "io", + "description": "Installing a provider MUST be idempotent for the SAME instance (re-installing the already-installed provider is a no-op) and MUST reject installing a DIFFERENT provider while one is already installed, failing loudly (naming both and pointing to a scoped-override mechanism) rather than silently overwriting." + }, + { + "id": "IO-33", + "level": "MUST", + "subsystem": "io", + "description": "An explicit install MUST win even after auto-discovery has already resolved (and possibly handed out) a provider; installing the same underlying implementation that was auto-resolved MUST NOT be treated as a conflict." + }, + { + "id": "IO-34", + "level": "SHOULD", + "subsystem": "io", + "description": "A successful auto-resolution SHOULD be cached process-wide so the discovery scan does not repeat; an UNRESOLVED state (zero or multiple candidates, which fails) SHOULD be re-evaluated on the next access so that later classpath/registration changes or a subsequent explicit install can succeed." + }, + { + "id": "IO-35", + "level": "SHOULD", + "subsystem": "io", + "description": "When an explicit install replaces a DIFFERENT provider that had already been auto-resolved AND handed out to a caller, the registry SHOULD surface a warning (not a failure) that objects created earlier may reference the previously-resolved provider, since late replacement risks mixed-provider state. No warning should fire on a first install, when nothing was handed out, or when the same underlying implementation is installed." + }, + { + "id": "IO-36", + "level": "MAY", + "subsystem": "io", + "description": "On runtimes with hierarchical plugin/classloader scopes, auto-discovery MAY consult the thread-context (child) scope before the defining (parent) scope and MUST de-duplicate candidates by concrete implementation identity so a provider visible through both scopes is counted once (not misreported as an ambiguous multi-provider setup). Ports on runtimes without such scoping MAY ignore this ordering while still honoring IO-31." + }, + { + "id": "IO-37", + "level": "MUST", + "subsystem": "io", + "description": "All streaming instances (Source, Sink, BufferedSource, BufferedSink, Buffer, TeeSink) are single-threaded contracts: they are NOT required to be safe for concurrent use, and callers MUST serialize external access when sharing one across threads. Independent views (slices, peeks) MAY be used from different threads, but each individual view remains single-threaded." + }, + { + "id": "IO-38", + "level": "MUST", + "subsystem": "io", + "description": "Even though individual instances are single-threaded, the CLOSE state of a source/buffer MUST be observable across threads to the slices derived from it, so that a close on one thread reliably invalidates a slice being read on another (no torn or stale reads)." + }, + { + "id": "IO-39", + "level": "SHOULD", + "subsystem": "io", + "description": "The provider REGISTRY (the global holder) SHOULD support lock-free reads of the active provider (so hot-path callers never block) while serializing installs/swaps under a non-blocking lock that does not pin/park carrier threads under lightweight-thread (Loom-style) schedulers." + }, + { + "id": "IO-40", + "level": "MUST", + "subsystem": "io", + "description": "These streaming contracts MUST NOT impose their own read/write timeout or deadline; the adapter wraps foreign streams with a no-op timeout, delegating all deadline enforcement to the transport. A mirroring/wrapping sink MUST NOT swallow OR duplicate the wrapped stream's cancellation/interrupt handling — it forwards operations to the primary and relies on the primary to honor the runtime's interrupt signal. The prompt-cancellation-of-blocked-I/O guarantee itself belongs to the wrapped transport, not to these contracts (the in-memory buffers never block)." + }, + { + "id": "IO-41", + "level": "MUST", + "subsystem": "io", + "description": "Closing a source, sink, or buffer MUST be idempotent: a second (or later) close() MUST NOT throw, and the underlying resource MUST be closed at most once." + }, + { + "id": "IO-42", + "level": "MUST", + "subsystem": "io", + "description": "A buffered source/sink that wraps an external stream MUST reject read/write/flush/emit attempts made after close() with an I/O error, so use-after-close of a real resource fails loudly. A purely in-memory buffer is exempt on its OWN read/write surface (an in-memory close frees nothing and may remain readable so snapshot-after-close logging still works), but its close() MUST still invalidate every slice derived from it (see IO-22/IO-38)." + }, + { + "id": "BODY-1", + "level": "MUST", + "subsystem": "bodies", + "description": "A request body MUST expose a boolean replayability property. It defaults to false (single-use). It MUST be true only when the body's write operation can be invoked more than once and produces byte-for-byte identical output on every invocation." + }, + { + "id": "BODY-2", + "level": "MUST", + "subsystem": "bodies", + "description": "A composite/aggregate body (e.g. multipart) MUST report itself replayable if and only if every constituent part is replayable, and its declared content length MUST collapse to 'unknown' if any part's length is unknown." + }, + { + "id": "BODY-3", + "level": "MUST", + "subsystem": "bodies", + "description": "A materialize-once operation (toReplayable) MUST return the same body unchanged when it is already replayable, and otherwise MUST drain the body's write output exactly once into an in-memory buffer and return a replayable buffer-backed body. After it is invoked on a single-use body, the original body MUST be treated as consumed and MUST NOT be written again." + }, + { + "id": "BODY-4", + "level": "MUST", + "subsystem": "bodies", + "description": "Retry, redirect, and authentication-challenge replay MUST all consult the body's replayability before re-sending a body-bearing request: such a request is eligible for re-send only when its body reports replayable, and a consumed single-use body MUST NOT be re-sent on any of these paths. The three paths query the same property but differ in how they decline a non-replayable body, and a port need not unify the decline behavior: the retry path stops retrying and surfaces the last outcome, the auth path returns the original challenge response unchanged (and does NOT close it), and the redirect path fails loudly by raising an error." + }, + { + "id": "BODY-5", + "level": "MUST", + "subsystem": "bodies", + "description": "On the retry path, a body-less request's re-send eligibility MUST be gated on method idempotency rather than replayability: only idempotent methods may be retried when there is no body. This gate is specific to retries; the redirect path re-sends body-less requests per redirect semantics and does not consult idempotency." + }, + { + "id": "BODY-6", + "level": "MUST", + "subsystem": "bodies", + "description": "A single-use request body MUST fail loudly (raise a clear error) on a second write attempt rather than silently emitting zero bytes. This applies to source-backed and one-shot stream-backed bodies once their underlying source is exhausted/closed." + }, + { + "id": "BODY-7", + "level": "MUST", + "subsystem": "bodies", + "description": "The consume-once guard of a single-use body MUST be race-safe: under concurrent writes at most one writer may pass the guard; every other concurrent writer MUST observe the already-consumed failure. (JVM incidental: an atomic compare-and-set; a port uses its platform's equivalent atomic/once primitive.)" + }, + { + "id": "BODY-8", + "level": "MUST", + "subsystem": "bodies", + "description": "A single-use request body that owns a closeable source MUST release (close) that source as part of its single write, so that skipping materialization does not leak it. In the reference implementation this holds for the buffered-source-backed body, whose write drains-and-closes the source. Note the raw byte-stream-backed bodies do NOT close their stream during the write: the rewindable variant must keep it open to replay, and the one-shot variant leaves the caller-supplied stream unclosed per its documented ownership. A port MUST decide its stream-ownership/close rule deliberately rather than assume every single-use body closes its input." + }, + { + "id": "BODY-9", + "level": "SHOULD", + "subsystem": "bodies", + "description": "A stream-backed request body of known length SHOULD be treated as replayable when (and only when) the stream supports mark/reset and the length fits the platform's maximum single-array bound; otherwise it MUST be single-use. When replayable via mark/reset, each write after the first MUST rewind (reset) before reading, and the rewind MUST be race-safe (at most one reset between any two writes)." + }, + { + "id": "BODY-10", + "level": "MUST", + "subsystem": "bodies", + "description": "An exact-length copy from a stream into the sink MUST write precisely the declared number of bytes; a premature end of stream MUST raise an end-of-file error naming how many bytes of how many were delivered, and a zero-length read for a positive request MUST be treated as a stream-contract violation (error), never as an infinite spin or as end-of-stream. A declared length of zero MUST be a legitimate empty write." + }, + { + "id": "BODY-11", + "level": "MUST", + "subsystem": "bodies", + "description": "A file-backed request body MUST be replayable, MUST open a fresh file handle for each write (so concurrent/repeated sends are safe at the body level), and MUST validate fail-fast at construction: the path must exist and be a regular file, offset must be non-negative, count must be non-negative or the 'rest of file' sentinel, and offset+count must not exceed the file size captured at construction." + }, + { + "id": "BODY-12", + "level": "SHOULD", + "subsystem": "bodies", + "description": "A file-backed request body SHOULD stream its bytes using the platform's most efficient file-to-sink transfer (avoiding an unnecessary user-space copy), and the transport layer SHOULD be able to recognize a file-backed body by type to dispatch a true zero-copy kernel transfer (sendfile-style) where available. The portable intent: uploading a large file must not require buffering it in the heap and should minimize copies." + }, + { + "id": "BODY-13", + "level": "MUST", + "subsystem": "bodies", + "description": "A file-backed transfer MUST detect a short write (fewer bytes transferred than the declared count) and raise an error naming transferred-of-total, rather than completing silently with a truncated body." + }, + { + "id": "BODY-14", + "level": "MUST", + "subsystem": "bodies", + "description": "A response body MUST be single-use: its read handle (source) is obtained once and, once consumed, the bytes are gone; requesting the handle repeatedly MUST return the same underlying handle, not a fresh replay. Repeatable/non-destructive access MUST require an explicit buffering wrapper." + }, + { + "id": "BODY-15", + "level": "MUST", + "subsystem": "bodies", + "description": "Closing a response body MUST release the underlying transport resource, MUST be idempotent (second and later closes are no-ops), and MUST NOT assume the body was read — a caller that skips the body entirely still relies on close to release the connection." + }, + { + "id": "BODY-16", + "level": "MUST", + "subsystem": "bodies", + "description": "Convenience readers that consume a response body fully (read-as-string, read-as-bytes) MUST close the body in a finally-style guarantee whether or not the read succeeds." + }, + { + "id": "BODY-17", + "level": "MUST", + "subsystem": "bodies", + "description": "The request-logging wrapper MUST mirror the exact bytes produced by the wrapped body's single write into an internal tap while forwarding those same bytes to the transport sink, consuming the upstream exactly once (never twice). The full payload MUST always reach the transport sink regardless of any tap cap; logging MUST NOT alter or truncate the bytes on the wire." + }, + { + "id": "BODY-18", + "level": "MUST", + "subsystem": "bodies", + "description": "The request-logging tap MUST be cleared at the start of every write so that a post-write snapshot reflects only the most recent write attempt; retries against a replayable delegate MUST NOT accumulate bytes from earlier attempts." + }, + { + "id": "BODY-19", + "level": "MUST", + "subsystem": "bodies", + "description": "The request-logging tap MUST be bounded by a configurable cap: once the cap's worth of bytes has been mirrored, further bytes MUST stop being copied into the tap while the full payload continues to the transport. A multi-GB upload MUST mirror only a bounded preview into memory. (The unbounded default cap exists for direct wrapper use; the instrumentation layer always supplies a finite cap.)" + }, + { + "id": "BODY-20", + "level": "SHOULD", + "subsystem": "bodies", + "description": "If the wrapped body's write fails partway, the request-logging snapshot SHOULD return the bytes mirrored up to the failure point, to aid diagnosis of a failed request. The tap MUST mirror the attempted bytes before (or at least not after) forwarding to the primary, so a primary-side failure still leaves the failing chunk captured." + }, + { + "id": "BODY-21", + "level": "MUST", + "subsystem": "bodies", + "description": "The request-logging wrapper MUST expose the delegate's replayability verbatim, and its materialize-once MUST return a wrapper around the delegate's replayable form (preserving the configured tap cap), so retry loops keep capturing bytes for every attempt." + }, + { + "id": "BODY-22", + "level": "MUST", + "subsystem": "bodies", + "description": "The response-logging wrapper MUST drain the delegate at most once, lazily, on the first access (read/snapshot/exception query), buffering up to a configurable byte cap. Concurrent first accesses MUST be serialized so the upstream is read exactly once. (JVM incidental: double-checked locking with a volatile field guarded by a reentrant mutex chosen so it does not pin the underlying carrier thread; a port uses an equivalent once-latch/mutex that does not pin threads.)" + }, + { + "id": "BODY-23", + "level": "MUST", + "subsystem": "bodies", + "description": "When the whole response body fits within the cap (end-of-stream reached before the cap), the wrapper MUST capture it entirely, close the delegate, and thereafter serve every read as a fresh non-consuming view of the captured bytes — fully repeatable. Repeated reads in this regime MUST each succeed independently." + }, + { + "id": "BODY-24", + "level": "MUST", + "subsystem": "bodies", + "description": "When the response body exceeds the cap (cap hit with bytes still pending), the wrapper MUST buffer only the prefix, MUST leave the delegate open, and MUST serve the next read as a single-use stream that first replays the captured prefix and then continues from the still-live tail so the consumer receives the complete body. A second read in this regime MUST fail (the tail is single-consumer)." + }, + { + "id": "BODY-25", + "level": "MUST", + "subsystem": "bodies", + "description": "A read from the response delegate that returns zero bytes for a positive requested count MUST be treated as a stream-contract violation (error), never as end-of-stream. End-of-stream MUST be signaled only by the explicit end-of-stream sentinel. This prevents silent truncation of the captured body." + }, + { + "id": "BODY-26", + "level": "MUST", + "subsystem": "bodies", + "description": "A failure during the response drain MUST NOT silently truncate. The wrapper MUST retain the bytes read before the failure and cache the error such that: reads (source) re-throw the cached error on every call; snapshot returns the partial bytes without throwing; and an exception-query accessor surfaces the cached error (or null) without triggering a drain." + }, + { + "id": "BODY-27", + "level": "MUST", + "subsystem": "bodies", + "description": "The response-logging wrapper MUST close the delegate (and, by ownership, its source) at most once across all close paths — the wrapper's own close and the one-shot tail stream's close MUST route through a single shared close-once guard — because some transport streams throw on a double-close. If the delegate's close throws, it MUST still be marked closed (the failing close propagates once, but no second close is attempted)." + }, + { + "id": "BODY-28", + "level": "MUST", + "subsystem": "bodies", + "description": "On the fits-cap path, closing the delegate as part of a successful capture is best-effort: a close failure after a successful full capture MUST NOT be reported as a drain error and MUST NOT prevent the captured body from being served. The captured in-memory buffer MUST survive the wrapper's close (it holds only memory, no transport resource) so post-mortem snapshot logging still works after close." + }, + { + "id": "BODY-29", + "level": "SHOULD", + "subsystem": "bodies", + "description": "The response-logging wrapper's reported content length SHOULD be the captured size only when the body was fully captured within the cap; otherwise it SHOULD report the delegate's declared length (the true length), since the in-memory capture is only a bounded prefix." + }, + { + "id": "BODY-30", + "level": "MUST", + "subsystem": "bodies", + "description": "Turning an error response into an exception MUST buffer at most a fixed cap of the error body (1 MiB) into memory and re-serve it as a replayable body, dropping bytes beyond the cap. The buffered copy MUST be readable independently and repeatably (decode it, then snapshot it) after the original transport connection is released. Buffering MUST occur inside the original body's close-guaranteeing scope so a provider/buffer-allocation failure still closes the original body rather than leaking the connection. A response with no body MUST be returned unchanged." + }, + { + "id": "BODY-31", + "level": "MUST", + "subsystem": "bodies", + "description": "Error-to-exception mapping MUST apply only to error statuses (4xx/5xx). A non-error, non-success response (e.g. 304 Not Modified, or a 3xx that no redirect step followed) MUST be returned with its body intact rather than consumed and raised." + }, + { + "id": "BODY-32", + "level": "MUST", + "subsystem": "bodies", + "description": "Byte-capped snapshot/preview operations MUST reject a negative cap, MUST silently clamp the cap to the platform's maximum single-array size, and MUST return whatever bytes are available up to the (clamped) cap without requiring that exactly that many bytes exist. A snapshot operation with no explicit cap MUST fail loudly when the captured size exceeds the platform's maximum single-array size (rather than allocate an impossible array)." + }, + { + "id": "BODY-33", + "level": "SHOULD", + "subsystem": "bodies", + "description": "An exception-side error-body preview SHOULD be non-consuming: it reads from a fresh peek view of the body's source and MUST NOT advance the primary read path, returning null when there is no body and an empty result when the source is exhausted." + }, + { + "id": "BODY-34", + "level": "MUST", + "subsystem": "bodies", + "description": "Body logging (the tee-capture on the request side and the drain-on-first-access on the response side) MUST be engaged only when body-level logging is enabled, and the in-memory capture on both sides MUST be bounded by one shared preview-size configuration. The consumer MUST still receive every byte of an over-preview body; only the logged preview and the logged size fields are bounded by the cap." + }, + { + "id": "BODY-35", + "level": "MUST", + "subsystem": "bodies", + "description": "A body's declared content length is an advisory contract: it MUST report the exact number of bytes the write will produce when known, and MUST report the 'unknown' sentinel (-1) otherwise. Ports MUST NOT assume a known content length is always present." + }, + { + "id": "BODY-36", + "level": "MAY", + "subsystem": "bodies", + "description": "A file-backed request body MAY expose a read-only memory-mapped view of its byte range for local computation (e.g. hashing/signing) without copying bytes onto the heap, but MUST reject mapping a range larger than a single addressable buffer and MUST document that the mapping's lifetime and any OS-level file locking are tied to the mapped view, not to a channel." + }, + { + "id": "BODY-37", + "level": "MUST", + "subsystem": "bodies", + "description": "A tee/mirror sink used for request-body logging MUST NOT expose a direct writable buffer handle that bypasses the primary sink; any such accessor MUST fail loudly. Bytes written straight into the tap buffer would never reach the transport, silently truncating the wire body, so every write MUST flow through a path that reaches both the primary sink and the tap." + }, + { + "id": "CTX-1", + "level": "MUST", + "subsystem": "context", + "description": "The model MUST provide three context flavors forming a one-way promotion chain whose stages mirror the call lifecycle: a dispatch stage (before any request is built), a request stage (an outgoing request has been assembled), and an exchange stage (a response has arrived). Promotion advances dispatch -> request -> exchange only; there is no reverse promotion and the exchange stage is terminal (exposes no promotion API)." + }, + { + "id": "CTX-2", + "level": "MUST", + "subsystem": "context", + "description": "Each promotion MUST be additive and non-mutating: it produces a NEW context instance and never modifies the source. It carries forward, unchanged, the fields the source already has — the same InstrumentationContext reference and the same callKey (and, for the request->exchange promotion, additionally the same request and operationName) — and adds exactly the one new artifact for that stage: the outgoing request when promoting dispatch->request, the response when promoting request->exchange. operationName is introduced at the request stage as an argument to the dispatch->request promotion; the dispatch context has no operationName field, so it is not 'carried forward' from the dispatch source." + }, + { + "id": "CTX-3", + "level": "MUST", + "subsystem": "context", + "description": "The whole chain MUST share ONE call key: a promotion carries the source's callKey forward verbatim, so dispatch, request, and exchange contexts of the same call register under the identical store slot." + }, + { + "id": "CTX-4", + "level": "MUST", + "subsystem": "context", + "description": "Each call's store key MUST be unique per call and MUST NOT be derived from the trace identifier — or the trace+span pair — alone, so that two concurrent calls sharing a trace id (and even a span id) receive distinct keys and never overwrite or evict each other's live entry. The reference implementation derives the default key by appending a process-wide, monotonically increasing counter to a 'traceId:spanId' rendering (yielding 'traceId:spanId:n'); the exact format and the counter mechanism are a reference choice, and a port MAY key differently as long as the per-call-uniqueness invariant holds." + }, + { + "id": "CTX-5", + "level": "MUST", + "subsystem": "context", + "description": "A context constructed directly (off-chain, not via promotion) without an explicit key MUST receive a fresh call-unique key using the same uniqueness guarantee as CTX-4. A consequence, given that the key participates in value-equality, is that two directly-constructed contexts with otherwise identical fields are NOT equal (their generated keys differ); callers who require value-equality MUST be able to pin an explicit shared key at construction." + }, + { + "id": "CTX-6", + "level": "MUST", + "subsystem": "context", + "description": "Default (unspecified-key) construction MUST mint globally distinct keys across the whole process and across all three context flavors, so that two off-chain constructions — even of different flavors that share identical trace/span ids — never collide on the same key. The reference implementation achieves this with a single process-wide monotone counter shared by all three flavors' default-key generation; a port MUST guarantee equivalent global distinctness however it generates keys." + }, + { + "id": "CTX-7", + "level": "MUST", + "subsystem": "context", + "description": "Contexts MUST be immutable and safe to share across threads without external synchronization. The ContextStore MUST be thread-safe such that contexts with distinct call keys can be registered, overwritten, and removed concurrently without external locking." + }, + { + "id": "CTX-8", + "level": "MUST", + "subsystem": "context", + "description": "The store MUST support unconditional overwrite ('set': install-or-replace, never throwing) used by promotion, and MUST support a reject-on-duplicate insert ('put': install only if absent, failing the loser). Concurrent inserts of the same key via the reject-on-duplicate path MUST deterministically admit exactly one winner and fail all others with an error whose message identifies the key. (Promotion uses only 'set'; 'put' is a separate strict-register affordance.)" + }, + { + "id": "CTX-9", + "level": "MUST", + "subsystem": "context", + "description": "Closing a context MUST evict the chain's store entry CONDITIONALLY ON REFERENCE IDENTITY: it removes the slot only when the currently registered occupant IS the closing context (same reference). It MUST NOT match by value equality. Removing a non-existent or already-replaced slot MUST be a well-defined no-op, not an error." + }, + { + "id": "CTX-10", + "level": "MUST", + "subsystem": "context", + "description": "Only the context that currently occupies the shared slot (the terminal / furthest-reached link) evicts the chain's entry on close. Closing an intermediate link (a dispatch that was promoted, or a request that was promoted) MUST be a no-op with respect to the store, because the slot now points at its successor." + }, + { + "id": "CTX-11", + "level": "MUST", + "subsystem": "context", + "description": "The ContextStore MUST be bounded: it MUST enforce a maximum number of tracked entries and, after each insert, drain back down to at or below that cap. This bound is a backstop so that a caller who fails to close a context on an exception path leaks at most the cap's worth of entries rather than growing without limit." + }, + { + "id": "CTX-12", + "level": "SHOULD", + "subsystem": "context", + "description": "The cap-draining strategy SHOULD be implemented as a post-insert drain LOOP (drain until at or under the cap) rather than a single check-then-evict-then-insert. Under concurrent insert bursts the loop converges the map back to the bound instead of overshooting and sitting above the cap." + }, + { + "id": "CTX-13", + "level": "MAY", + "subsystem": "context", + "description": "Eviction victim selection under cap pressure is arbitrary: the store provides NO ordering (no insertion-order / FIFO / LRU) and NO guarantee that any particular entry survives an insert that trips the cap — INCLUDING the entry that was just inserted, which can itself be the drain's victim. The only retention guarantee is that after inserts quiesce the live set is at or below the cap (and, in isolation, that up to the cap of recently-inserted entries remain). A port MAY choose a smarter (e.g. oldest-first) victim policy, but a port MUST NOT rely on any specific entry — the most-recently inserted included — surviving." + }, + { + "id": "CTX-14", + "level": "MUST", + "subsystem": "context", + "description": "Each context MUST carry a correlation/instrumentation metadata bundle exposing at minimum: a trace id, a span id, trace flags, trace state, a trace-id encoding flavor, validity and remoteness flags, an active span, and a per-operation tracer factory. This bundle is what lets logs, metrics, and spans across the dispatch/request/exchange phases correlate into one logical trace." + }, + { + "id": "CTX-15", + "level": "MUST", + "subsystem": "context", + "description": "A disabled-tracing / no-op instrumentation context MUST be available as the default and MUST expose reserved 'invalid' sentinel identifiers (an all-zero trace id, all-zero span id, zero trace flags, empty trace state) with isValid=false and isRemote=false, and a no-op span and no-op tracer factory. Because such a context shares constant identifiers across every untraced call, the call-key derivation (CTX-4) MUST remain correct (call-unique) even when every field of this bundle is identical across calls." + }, + { + "id": "CTX-16", + "level": "SHOULD", + "subsystem": "context", + "description": "A context SHOULD carry an optional operation name (a schema-defined operation id such as 'GetUser', or absent for a raw request with no operation). When present it MUST be carried forward unchanged across every promotion. The operation name MUST be advisory only — it is exposed to the tracing seam to label the operation and MUST NOT influence the request, the dispatch decision, or the store key." + }, + { + "id": "CTX-17", + "level": "MUST", + "subsystem": "context", + "description": "Registration in the store MUST happen at promotion time, not at head-context construction: constructing the initial dispatch context MUST NOT auto-register it. The first store entry for a chain is installed by the first promotion; consequently a dispatch context that is never promoted leaves no store entry and its close is a harmless no-op." + }, + { + "id": "CTX-18", + "level": "MUST", + "subsystem": "context", + "description": "Looking up an unknown key MUST return an explicit 'absent' result (null/None/empty), never throw. Removing an unknown or already-removed key MUST be a no-op, never throw. These make double-close and cleanup-path closes well-defined." + }, + { + "id": "CTX-19", + "level": "MUST", + "subsystem": "context", + "description": "A registered context MUST keep its full Request+Response object graph reachable for as long as it stays in the store. Under this store's design the registry is treated as the sole strong reference on the live path, so reimplementations MUST NOT hold contexts by weak/soft references (that would let an in-flight context be collected mid-call) and MUST treat the bounded cap (CTX-11) — not garbage collection — as the leak backstop." + }, + { + "id": "CTX-20", + "level": "SHOULD", + "subsystem": "context", + "description": "The per-operation tracer factory carried on the instrumentation bundle SHOULD default to a no-op that emits nothing, so untraced call sites pay zero tracing cost. Its factory method MUST be safe to invoke concurrently from multiple threads, because operation starts are not serialized." + }, + { + "id": "PIPE-1", + "level": "MUST", + "subsystem": "pipeline", + "description": "Steps MUST execute in a single fixed total order derived from their stage assignment: a step in a lower-ordered stage runs before (wraps) a step in a higher-ordered stage on the inbound path, and correspondingly observes the response later on the outbound path. This CROSS-stage order is deterministic and independent of the order in which steps were added to the builder; the order of multiple steps WITHIN one non-pillar stage instead follows insertion (see PIPE-7)." + }, + { + "id": "PIPE-2", + "level": "MUST", + "subsystem": "pipeline", + "description": "The runtime MUST preserve the pillar precedence chain REDIRECT then RETRY then AUTH then LOGGING then SERDE (outer to inner), plus an outermost pre-redirect slot that runs OUTSIDE both the redirect and retry loops and a terminal SEND transport hop that runs innermost. A step's placement relative to these boundaries determines whether it sees intermediate (per-hop / per-attempt) responses or only the single terminal response. (SERDE is a reserved slot in the ordering with no shipped behavior yet.)" + }, + { + "id": "PIPE-3", + "level": "SHOULD", + "subsystem": "pipeline", + "description": "The stage list SHOULD interleave user-extensible (non-pillar) slots around each pillar (a 'pre' slot before and a 'post' slot after) so callers can position steps at a precise point relative to a pillar without editing pillar internals. Numeric order keys SHOULD be sparse to allow inserting new stages later without renumbering existing ones." + }, + { + "id": "PIPE-4", + "level": "MUST", + "subsystem": "pipeline", + "description": "A pillar stage MUST admit at most one step. The configurable pillars are REDIRECT, RETRY, AUTH, LOGGING, and SERDE. (The terminal SEND slot is also flagged as a singleton but is the transport hop itself, not a configurable step slot — see PIPE-8.)" + }, + { + "id": "PIPE-5", + "level": "MUST", + "subsystem": "pipeline", + "description": "Installing a DISTINCT second step onto an already-occupied pillar — via append, prepend, insert-after/insert-before, or seeding/flattening from an existing pipeline (bulk reload) — MUST fail fast with an error rather than silently overwriting or dropping the existing step. The error SHOULD name both step types and point the caller at the replace-in-place path. (Replace itself cannot trigger this collision: it swaps a single occupant within its own stage 1:1; a cross-stage replace fails with a distinct cross-stage error instead.)" + }, + { + "id": "PIPE-6", + "level": "MUST", + "subsystem": "pipeline", + "description": "Re-installing the SAME step onto its pillar MUST be idempotent (no error, no duplication). Reference identity, not value equality, distinguishes 'same' from 'distinct'." + }, + { + "id": "PIPE-7", + "level": "MUST", + "subsystem": "pipeline", + "description": "Non-pillar stages MUST hold an ordered sequence of steps: append adds to the tail (runs after steps already there), prepend adds to the head (runs before). The relative order of steps within a stage MUST be preserved through build and through any re-bucketing edit." + }, + { + "id": "PIPE-8", + "level": "MUST", + "subsystem": "pipeline", + "description": "The terminal SEND stage MUST be reserved for the transport hop and MUST NOT hold a user step; flattening the pipeline MUST skip it, and any attempt to install a step at SEND MUST be rejected." + }, + { + "id": "PIPE-9", + "level": "MUST", + "subsystem": "pipeline", + "description": "An empty pipeline (no steps) MUST dispatch the request directly to the terminal transport, threading the caller's per-call options, and return/complete with the transport's result. It SHOULD do so without allocating per-call cursor state." + }, + { + "id": "PIPE-10", + "level": "MUST", + "subsystem": "pipeline", + "description": "The built runtime MUST be immutable after construction (fixed ordered step collection + fixed transport reference), and each send/sendAsync MUST allocate its own per-call cursor so concurrent calls share no mutable pipeline state." + }, + { + "id": "PIPE-11", + "level": "MUST", + "subsystem": "pipeline", + "description": "Steps MUST be safe to invoke from multiple threads concurrently, because a single step is shared across all calls. Per-request mutable state MUST live in the per-call cursor (carried and forked by next), never on the step." + }, + { + "id": "PIPE-12", + "level": "MUST", + "subsystem": "pipeline", + "description": "Each step MUST be bidirectional: it receives the inbound request, MAY call next to invoke the rest of the chain, and MAY inspect or substitute the outbound response before returning it. A step MAY short-circuit by returning a synthetic response WITHOUT calling next." + }, + { + "id": "PIPE-13", + "level": "MUST", + "subsystem": "pipeline", + "description": "Invoking next MUST advance a monotonic cursor to the next step and invoke it; when the cursor is exhausted, next MUST dispatch the current in-flight request to the terminal transport, threading the caller's per-call options into the transport's per-call execute overload. The cursor MUST only move forward within a single (un-forked) drive." + }, + { + "id": "PIPE-14", + "level": "MUST", + "subsystem": "pipeline", + "description": "When a step advances the chain with a substituted request, that substitution MUST propagate to every downstream step and to the terminal transport dispatch (it 'sticks'), replacing the original request for the remainder of that drive." + }, + { + "id": "PIPE-15", + "level": "MUST", + "subsystem": "pipeline", + "description": "A step that drives the downstream chain MORE THAN ONCE (retry re-attempting, redirect following a hop, auth retrying after a challenge) MUST fork a fresh cursor for each re-drive (the copy() operation) rather than reusing the same next handle. Reusing the same handle resumes past the already-visited steps and MUST be treated as a defect. A port MUST provide an equivalent fork primitive and its wrapping pillar steps MUST use it." + }, + { + "id": "PIPE-16", + "level": "MUST", + "subsystem": "pipeline", + "description": "A forked cursor MUST resume from the SAME position as its parent (re-running the entire downstream tail from that point), carry the current in-flight request, and share the immutable per-call options; forks MUST advance independently of one another." + }, + { + "id": "PIPE-17", + "level": "MUST", + "subsystem": "pipeline", + "description": "The caller's per-call options MUST be carried unchanged for the entire call, including across every re-drive fork, MUST be readable by any step, and MUST be threaded into the terminal transport dispatch. Options MUST be immutable/shared, not copied-and-diverged per fork." + }, + { + "id": "PIPE-18", + "level": "MUST", + "subsystem": "pipeline", + "description": "The surgical insert-after / insert-before edit MUST place a step immediately after/before the FIRST existing step that is an instance of a given anchor type. The inserted step MUST declare the same stage as the matched anchor; a cross-stage insert MUST be rejected with an error rather than silently relocating the step to wherever its own stage falls." + }, + { + "id": "PIPE-19", + "level": "MUST", + "subsystem": "pipeline", + "description": "The surgical replace edit MUST swap the FIRST existing instance of the anchor type with the supplied step, which MUST declare the same stage as the replaced step; a cross-stage replacement MUST be rejected." + }, + { + "id": "PIPE-20", + "level": "MUST", + "subsystem": "pipeline", + "description": "The surgical remove edit MUST delete EVERY step that is an instance of the given type while preserving the relative order of the remaining steps, and MUST be a no-op when no instance exists." + }, + { + "id": "PIPE-21", + "level": "MUST", + "subsystem": "pipeline", + "description": "An insert-relative or replace edit whose anchor type has no instance in the pipeline MUST fail with an error identifying the missing type, rather than silently no-op'ing." + }, + { + "id": "PIPE-22", + "level": "MUST", + "subsystem": "pipeline", + "description": "Every mutation that re-buckets the whole step collection by stage (insert, replace, remove, reload) MUST re-derive the flattened order deterministically from stage assignment, so the observable ordering after an edit is identical to building the same set of steps from scratch." + }, + { + "id": "PIPE-23", + "level": "MUST", + "subsystem": "pipeline", + "description": "A bulk reload/rebuild of the step collection MUST be all-or-nothing: if it encounters a pillar collision (a distinct second step for an occupied pillar), it MUST leave the existing collection completely unchanged rather than committing a partially-rebuilt state." + }, + { + "id": "PIPE-24", + "level": "MUST", + "subsystem": "pipeline", + "description": "The standard-resilience preset MUST install its pillar steps into EMPTY slots only, validating up front that none of the target pillars is already occupied and rejecting the whole call (installing nothing) if any is. It MUST NOT overlay onto or overwrite already-configured pillars." + }, + { + "id": "PIPE-25", + "level": "MUST", + "subsystem": "pipeline", + "description": "build() MUST produce the ordered step sequence by flattening stages in declaration order (skipping SEND) into an immutable runtime, and the runtime MUST expose a read-only, ordered view of its steps for inspection." + }, + { + "id": "PIPE-26", + "level": "MUST", + "subsystem": "pipeline", + "description": "The pipeline runtime MUST itself implement the transport SPI, delegating its execute/executeAsync to its own send/sendAsync (with and without per-call options), so a fully configured pipeline can be used anywhere a transport is expected (e.g. backing a paginator) and options survive that indirection." + }, + { + "id": "PIPE-27", + "level": "MUST", + "subsystem": "pipeline", + "description": "Closing the pipeline MUST be a no-op with respect to the underlying transport: the pipeline never owns its transport and MUST NOT close it. A port MUST NOT let wrapping a pipeline in a resource-management scope create the impression that transport resources were released." + }, + { + "id": "PIPE-28", + "level": "MUST", + "subsystem": "pipeline", + "description": "The async runtime MUST reuse the identical stage identities and staging policy as the sync runtime (same ordered stage list, same pillar exclusivity, same surgical-edit semantics), so a given concern occupies the same ordered slot in both runtimes. The two runtimes MUST NOT each re-derive ordering independently." + }, + { + "id": "PIPE-29", + "level": "MUST", + "subsystem": "pipeline", + "description": "An async step MUST NOT throw synchronously to signal a transport or async failure — it MUST return a future that completes exceptionally. It MAY throw synchronously only for caller-bug argument-validation errors. The runtime MUST present a uniform async error model regardless." + }, + { + "id": "PIPE-30", + "level": "MUST", + "subsystem": "pipeline", + "description": "The async runtime MUST defensively normalize ANY synchronous exception thrown by a step's async entry point (or by the empty-pipeline transport dispatch) into an exceptionally-completed future, so one step's mistake cannot break the pipeline's async contract. Fatal/unrecoverable errors (e.g. out-of-memory / stack overflow) MUST propagate synchronously and MUST NOT be swallowed." + }, + { + "id": "PIPE-31", + "level": "MUST", + "subsystem": "pipeline", + "description": "The async terminal response-mapping operator MUST, on success, apply the handler and then close the response (idempotent double-close tolerated); on failure it MUST unwrap async wrapper exceptions to the original cause before failing the returned future, and MUST close any response that accompanies a failure to avoid leaking the body." + }, + { + "id": "PIPE-32", + "level": "MUST", + "subsystem": "pipeline", + "description": "The async standard pipeline MUST NOT follow HTTP redirects at the pipeline layer (there is no async redirect pillar step); a 3xx MUST surface to the caller verbatim unless redirect following is enabled on the transport itself. A port MUST document this asymmetry with the sync standard pipeline (which does install a redirect step)." + }, + { + "id": "PIPE-33", + "level": "MUST", + "subsystem": "pipeline", + "description": "The sync-to-async bridge MUST require a caller-supplied executor (no default), MUST run the wrapped synchronous pipeline as a single opaque unit on that executor (the whole sync pipeline executes end-to-end as one blocking call, so its own steps stay synchronous on the worker/dispatch thread and do NOT gain per-step concurrency), and MUST thread the caller's per-call options into the wrapped synchronous send. Cancelling the returned future with interruption MUST interrupt the worker running the in-flight send; cancelling without interruption MUST complete as cancelled without interrupting the worker." + }, + { + "id": "PIPE-34", + "level": "MUST", + "subsystem": "pipeline", + "description": "The async-to-sync bridge MUST block the calling thread on the async result for each call while preserving per-call options, and MUST honour thread interruption: on interrupt it restores the interrupt flag, cancels the in-flight future, and surfaces an interrupted-I/O error." + }, + { + "id": "PIPE-35", + "level": "SHOULD", + "subsystem": "pipeline", + "description": "The builder SHOULD provide two unambiguous ways to seed from an existing pipeline: FLATTEN (copy the existing pipeline's steps and transport into the new builder so they run in the same loops) versus NEST (treat the existing pipeline as an opaque transport so the new builder's steps run once, OUTSIDE the nested pipeline's redirect/retry/auth loops). A port MUST make the flatten-vs-nest choice explicit rather than accidental." + }, + { + "id": "PIPE-36", + "level": "SHOULD", + "subsystem": "pipeline", + "description": "The shipped pillar step families (redirect, retry, auth, instrumentation) SHOULD lock their stage assignment so a subclass/extension cannot relocate itself out of its pillar. Custom steps at a pillar stage that re-drive the chain MUST still honour the fork-per-re-drive contract (PIPE-15)." + }, + { + "id": "PIPE-37", + "level": "MUST", + "subsystem": "pipeline", + "description": "A step whose correctness depends on observing only the SINGLE terminal response (e.g. mapping a non-successful status to a typed error) MUST be placed at the outermost pre-redirect slot so it runs outside both the redirect and retry loops, and on a non-error status it MUST return the response untouched (body not read, consumed, or closed)." + }, + { + "id": "PIPE-38", + "level": "MUST", + "subsystem": "pipeline", + "description": "When adding a batch of steps, append-all MUST preserve the batch's iteration order within each stage, while prepend-all (each element prepended individually) MUST result in the REVERSED batch order within each stage. A port MUST document this asymmetry so callers get predictable ordering." + }, + { + "id": "PIPE-39", + "level": "SHOULD", + "subsystem": "pipeline", + "description": "The runtime SHOULD offer convenience constructors for the two most common shapes: a step-less pipeline that forwards directly to a transport, and a standard pipeline that installs the default resilience pillars over a transport (sync: redirect+retry+instrumentation; async: retry+instrumentation with a caller-supplied scheduler for non-blocking backoff)." + }, + { + "id": "PIPE-40", + "level": "MUST", + "subsystem": "pipeline", + "description": "A wrapping step that re-drives the downstream chain (redirect following a hop, retry re-attempting, auth replaying after a challenge) MUST release each superseded intermediate response — closing its body before issuing the next drive — and MUST NOT close the response it ultimately hands back to the caller (close-responsibility passes outward to the caller or the next outer step). On paths that abandon a re-drive (redirect cycle detected, non-replayable body, hop/attempt budget exhausted) the in-flight response MUST be returned unclosed." + }, + { + "id": "RECOV-1", + "level": "MUST", + "subsystem": "recovery", + "description": "The response-side outcome MUST be a closed sum type with exactly two variants: a success variant carrying an HTTP response, and a failure variant carrying a throwable/error. The two variants are mutually exclusive and jointly exhaustive; an outcome is always exactly one of them. Standard accessors (is-success / is-failure, response-or-null, error-or-null) and a fold that applies exactly one of two branch functions MUST be derivable from this shape, and the fold MUST invoke the selected branch at most once per call." + }, + { + "id": "RECOV-2", + "level": "MUST", + "subsystem": "recovery", + "description": "The unified orchestrator MUST catch EVERY throwable raised by (a) any request-chain step and (b) the transport invocation, convert it into a Failure outcome, and thread it through the response recovery chain. No throwable from the pre-request phase or the transport may bypass the recovery hooks. This is the defining invariant of the subsystem: a before-request throw MUST NOT skip after-error handling." + }, + { + "id": "RECOV-3", + "level": "MUST", + "subsystem": "recovery", + "description": "The request recovery chain MUST apply its ordered steps as a sequential left-to-right fold: the output request of step N is the input of step N+1. An empty chain MUST return the input request unchanged (identity/no-op). If a step throws, the chain MUST NOT invoke the remaining steps and MUST propagate the throwable to its caller (which the orchestrator then converts per RECOV-2)." + }, + { + "id": "RECOV-4", + "level": "MUST", + "subsystem": "recovery", + "description": "In the response recovery chain, response steps (response→response) MUST run ONLY when the current outcome is a Success. When the current outcome is a Failure, the entire response-step phase MUST be skipped (the failure passes through untouched to the recovery phase)." + }, + { + "id": "RECOV-5", + "level": "MUST", + "subsystem": "recovery", + "description": "Recovery steps MUST be applied to EVERY outcome — both successes and failures — sequentially, and MUST always run regardless of how many response steps executed. Recovery steps MUST observe the terminal outcome, including a failure that a response step just produced by throwing." + }, + { + "id": "RECOV-6", + "level": "MUST", + "subsystem": "recovery", + "description": "The fold order MUST be: all response steps first (on the success path), then all recovery steps, in declared order within each group. A recovery step MUST NOT run before the response-step phase has produced the outcome it will observe." + }, + { + "id": "RECOV-7", + "level": "MUST", + "subsystem": "recovery", + "description": "If a response step throws, its throwable MUST be converted into a Failure outcome and fed to the subsequent recovery steps — never propagated out of the response chain. Failures originating inside the response phase MUST participate in the same recovery pathway as transport failures." + }, + { + "id": "RECOV-8", + "level": "MUST", + "subsystem": "recovery", + "description": "If a recovery step itself throws, its throwable MUST be wrapped into a Failure and fed to the NEXT recovery step. A throwing recovery step MUST NOT bypass or abort the remaining recovery steps, and the response recovery chain's apply operation MUST NOT throw under any input." + }, + { + "id": "RECOV-9", + "level": "SHOULD", + "subsystem": "recovery", + "description": "Recovery steps SHOULD NOT throw; they SHOULD surface errors explicitly by returning a Failure outcome. Throwing is a supported-but-discouraged fallback (see RECOV-8) that cedes control of the wrapped error to the chain's defensive catch." + }, + { + "id": "RECOV-10", + "level": "MUST", + "subsystem": "recovery", + "description": "The unified orchestrator's dispatch operation MUST unwrap the final outcome as follows: on Success it returns the contained response; on Failure it rethrows the contained throwable UNCHANGED (no wrapping, no substitution). Any typed-exception surfacing must be performed by a recovery step constructing the error and returning a Failure." + }, + { + "id": "RECOV-11", + "level": "MUST", + "subsystem": "recovery", + "description": "When a throwable that represents thread interruption/cancellation is wrapped into a Failure, the wrapping helper MUST first re-assert the interrupt/cancellation signal on the current execution context before returning the Failure, so that code later blocked on the surfaced outcome still observes the cancellation. Portable intent: converting a cancellation exception into a data value must not swallow the cancellation signal. In the reference implementation the shared wrapper restores the JVM interrupt flag specifically for InterruptedException; a port preserves whatever its cancellation primitive is." + }, + { + "id": "RECOV-12", + "level": "MUST", + "subsystem": "recovery", + "description": "When a response step or recovery step THROWS while a Success response is in hand, the pipeline MUST close/release that in-hand response (its open transport connection and body stream) before wrapping the throwable into a Failure, and MUST attach any error raised while closing as a suppressed/secondary error so it never masks the primary throwable. The response MUST be released exactly once on this throw path. (Applies to any runtime with explicit resource release for streamed bodies; a GC-only body model must still ensure single-release where connections are pooled.)" + }, + { + "id": "RECOV-13", + "level": "MUST", + "subsystem": "recovery", + "description": "When a step is handed a Success and deliberately RETURNS a different outcome (a Success→Failure transform such as status→typed-exception mapping, or a substitute Success), the pipeline MUST NOT auto-close the discarded original response. The step that performs that transform OWNS releasing the response it drops and MUST release it before returning the replacement. (The failure→failure 'Replace' path carries no response, so nothing needs releasing there.)" + }, + { + "id": "RECOV-14", + "level": "MUST", + "subsystem": "recovery", + "description": "A chain's step lists MUST behave as immutable after construction. The response recovery chain MUST defensively copy BOTH its response-step list and its recovery-step list at construction, so later mutation of a caller-supplied list cannot alter chain behavior. (Reference-implementation asymmetry a porter must not assume away: the request recovery chain does NOT copy — it retains the caller-supplied read-only list reference directly, so its immutability depends on the caller not mutating that list; a port SHOULD copy there too.) Steps MUST be safe to invoke concurrently from multiple execution contexts; per-request state MUST live in the passed context or the value being transformed, never on the step instance." + }, + { + "id": "RECOV-15", + "level": "MUST", + "subsystem": "recovery", + "description": "The status→typed-exception mapping response step MUST treat only HTTP status codes in the range 400..599 as errors: such a status MUST be mapped to the matching typed exception (which the pipeline then turns into a Failure per RECOV-7), and all other statuses (1xx/2xx/3xx) MUST be returned unchanged so the success chain continues. Because a transport returns a response for every completed exchange (including 4xx/5xx), an error status is a response until this step maps it." + }, + { + "id": "RECOV-16", + "level": "MUST", + "subsystem": "recovery", + "description": "Before mapping an error-status response to a typed exception (both in the error-mapping response step and when a retry re-classifies a re-sent error response), the error body MUST be buffered into a bounded, replayable in-memory copy capped at a fixed maximum (1 MiB / MAX_BUFFERED_ERROR_BODY_BYTES) so that (a) the live transport connection is released promptly and (b) the error body remains readable on the resulting Failure. The cap MUST be a hard truncation: bytes beyond the cap are simply not read and are discarded with no marker, so a downstream consumer deserializing the buffered body must tolerate a structurally incomplete payload. The same bound MUST be shared across all error-body-buffering paths." + }, + { + "id": "RECOV-17", + "level": "MUST", + "subsystem": "recovery", + "description": "Retry eligibility MUST be classified off a retryability CAPABILITY, not concrete exception types. Specifically: for an HTTP-response-bearing failure, the configured retryable-status allow-list is authoritative (it is consulted alone — the exception's own retryable flag is not AND-ed in — so it both widens and narrows), retry iff the failure's status is in that set; for a non-HTTP failure, retry iff it advertises retryability via the capability flag; a network-level failure (no response ever received) is always retryable via that flag; a Success is always a pass-through." + }, + { + "id": "RECOV-18", + "level": "MUST", + "subsystem": "recovery", + "description": "Independently of classification, a request MUST pass a re-sendability gate before being retried: a body-less request is retry-safe only when its method is in the configured idempotent-method set; a body-bearing request is retry-safe only when its body is replayable. A body-less request whose method is non-idempotent (e.g. a bare POST) MUST NOT be retried even though there is no payload to resend; a non-replayable body MUST NOT be resent (resending would trip its consume-once guard and mask the original failure)." + }, + { + "id": "RECOV-19", + "level": "MUST", + "subsystem": "recovery", + "description": "Because a transport returns an error-status response rather than throwing, each RE-SENT attempt's response MUST be re-classified: if its status is an error status present in the retryable-status set, it MUST be re-mapped into a Failure (with its body buffered per RECOV-16) so the loop keeps evaluating the budget and can reach an eventual success (e.g. a 503,503,200 sequence must terminate on the 200). All other re-sent responses — including a non-retryable error status — pass through as Success." + }, + { + "id": "RECOV-20", + "level": "MUST", + "subsystem": "recovery", + "description": "Retrying MUST be bounded by BOTH a maximum-attempts cap (counting the initial send as attempt 1) AND a total-timeout budget spanning the retry involvement (attempts and inter-attempt delays). A scheduled delay that would push cumulative elapsed time past the total-timeout MUST be suppressed and the last failure surfaced unchanged. A total-timeout of zero MUST mean 'unbounded' (deadline disabled). When retries are exhausted or disallowed, the terminal failure's throwable MUST be surfaced. (The total-timeout deadline is the recovery layer's addition; the stage-based retry step intentionally omits it.)" + }, + { + "id": "RECOV-21", + "level": "MUST", + "subsystem": "recovery", + "description": "The backoff delay for retry n MUST be computed as initialDelay * multiplier^(n-1), capped at maxDelay, then perturbed by symmetric jitter drawn uniformly from [delay*(1-jitter/2), delay*(1+jitter/2)] (jitter=0 → no perturbation; midpoint stays at delay), then clamped so that elapsed+delay does not exceed the total-timeout. The attempt index passed to backoff is 1-based where 1 is the delay before the first retry (i.e. after the initial send failed) and MUST be rejected if < 1." + }, + { + "id": "RECOV-22", + "level": "MUST", + "subsystem": "recovery", + "description": "A parsed server pacing hint (Retry-After / X-RateLimit-Reset family) MUST take precedence over the computed exponential schedule for that single retry decision — it REPLACES, not augments, the exponential value — while STILL being clamped by the remaining total-timeout budget. The hint MUST NOT have additional symmetric jitter applied on top (Retry-After is respected as given; the X-RateLimit-Reset positive jitter is already applied inside the parser per RECOV-25)." + }, + { + "id": "RECOV-23", + "level": "MUST", + "subsystem": "recovery", + "description": "The pacing-header parser MUST be TOTAL — it MUST never throw for any header value. Malformed, negative, or out-of-range values MUST map to 'no hint' (null / absent), NOT to a zero delay, so the orchestrator falls back to its exponential schedule rather than retrying instantaneously against a server that asked it to slow down. A validly-parsed but past/elapsed absolute time (HTTP-date or epoch) MUST map to a zero delay (retry immediately)." + }, + { + "id": "RECOV-24", + "level": "MUST", + "subsystem": "recovery", + "description": "The pacing-header parser MUST recognize these forms and, when parsing a full header set, apply this precedence (first usable wins): Retry-After as numeric delta-seconds (integer and fractional, honored to sub-second resolution) tried before Retry-After as an HTTP-date; then a millisecond-delta variant (retry-after-ms); then the Microsoft/Azure millisecond-delta variant (x-ms-retry-after-ms); then X-RateLimit-Reset as a Unix-epoch-seconds absolute time. The numeric delta-seconds form MUST be screened by a strict decimal grammar BEFORE any float parse, so values like '30d' or hex-float forms are rejected (fall through to backoff) rather than mis-parsed." + }, + { + "id": "RECOV-25", + "level": "SHOULD", + "subsystem": "recovery", + "description": "When honoring an X-RateLimit-Reset absolute reset time, the parser SHOULD add positive jitter (bounded to roughly 100%..120% of the computed delta) so that many clients released at the same reset instant do not stampede the server simultaneously." + }, + { + "id": "RECOV-26", + "level": "MUST", + "subsystem": "recovery", + "description": "All duration arithmetic in backoff and pacing computation MUST be overflow-safe: server-supplied hints and computed deltas MUST be clamped to a sane ceiling (365 days) before any nanosecond conversion, and nanosecond conversion MUST saturate rather than throw on out-of-range durations, so a hostile or buggy server value can never surface an arithmetic overflow mid-retry." + }, + { + "id": "RECOV-27", + "level": "MUST", + "subsystem": "recovery", + "description": "The inter-attempt wait MUST be cancellable and MUST NOT permanently block/pin a scheduling worker while idle. On cancellation/interruption during the wait, the implementation MUST (a) re-assert the cancellation signal, (b) cancel the pending scheduled timer, and (c) surface an interrupted-I/O failure through the outcome that ABORTS the retry loop rather than continuing. Portable intent: retries must honor cooperative cancellation and must not busy-wait or hold a thread hostage during the delay (the reference implementation uses a scheduler + interruptible wait, not a plain sleep; a port uses its runtime's cancellable timer/sleep with the same abort semantics)." + }, + { + "id": "RECOV-28", + "level": "MUST", + "subsystem": "recovery", + "description": "A retry engine instance MUST be stateless across calls with respect to attempt bookkeeping: the per-call attempt count and start instant MUST be allocated fresh for each top-level invocation and threaded as local state, so concurrent invocations cannot clobber each other's budget and a reused/re-invoked instance always starts from a clean attempt budget." + }, + { + "id": "RECOV-29", + "level": "MUST", + "subsystem": "recovery", + "description": "A malformed or unparsable server pacing header MUST NEVER mask or replace the real upstream failure: if extracting a pacing hint fails, the engine MUST fall back to its exponential schedule while preserving the original upstream throwable as the error to surface if retries are ultimately exhausted." + }, + { + "id": "RECOV-30", + "level": "SHOULD", + "subsystem": "recovery", + "description": "The recovery-aware retry stack and the separate stage-based dispatch retry step SHOULD share ONE backoff calculator and ONE pacing-header parser and the SAME default schedule (same base/max delay, multiplier, jitter, retryable-status policy, and total send budget) so the two stacks cannot drift apart. A port that offers more than one retry entry point SHOULD single-source the retry math likewise. The recovery-aware stack MAY additionally enforce a total-timeout deadline that the stage-based step omits." + }, + { + "id": "RECOV-31", + "level": "MAY", + "subsystem": "recovery", + "description": "The retry engine MAY stamp a per-attempt request header carrying the 1-based attempt ordinal on each dispatch it performs. When enabled, the header MUST be written on a fresh per-attempt COPY of the request (never mutating the captured template) and MUST preserve any idempotency key or other headers unchanged. Ordinal 1 (the original send) is stamped only when the engine itself performs the initial send via its direct-dispatch entry point; when the engine runs as a recovery hook in a chain the initial send was performed upstream and carries no attempt header, so only the retries the engine dispatches (ordinal 2, 3, ...) are stamped. When disabled (the default), the engine MUST take a zero-allocation no-op path that returns the original request unchanged." + }, + { + "id": "RECOV-32", + "level": "MUST", + "subsystem": "recovery", + "description": "The idempotency-key injection step MUST add its configured key header only for requests whose method is in the configured method set (default: the non-idempotent write methods POST/PUT/PATCH) and MUST pass other methods through untouched. When configured to respect existing headers (the default), a request that already carries the header MUST be left unchanged and the key strategy MUST NOT be invoked; otherwise the strategy result overwrites any existing value. The key strategy MUST be invoked at most once per applicable request." + }, + { + "id": "RECOV-33", + "level": "MUST", + "subsystem": "recovery", + "description": "The client-identity step MUST compose its configured tokens into a single space-separated header value with mode-specific reconciliation: in Append mode (default) it appends its token line after the FIRST existing header value (preserving all other pre-existing values) or sets it as the sole value if the header is absent; in Replace mode it overwrites all existing values. If the token list is empty or joins to a blank/whitespace-only line, the step MUST be a no-op and MUST NOT emit a blank or whitespace-only header. (In Append mode, an empty first existing value is treated as absent so no leading space is emitted.)" + }, + { + "id": "RECOV-34", + "level": "MUST", + "subsystem": "recovery", + "description": "Retry configuration MUST validate its inputs at construction and reject invalid values: durations must be non-negative and representable in nanoseconds (~292-year ceiling); the delay multiplier must be >= 1.0; maximum attempts must be >= 1 (1 disables retries); and the jitter fraction must lie in [0.0, 1.0]. Collection-valued settings (retryable statuses, retryable methods) MUST be defensively copied at build time so later caller mutation cannot alter behavior." + }, + { + "id": "RETRY-1", + "level": "MUST", + "subsystem": "retry", + "description": "The retryable-status classifier MUST be single-sourced (one definition the SDK consults) and MUST treat exactly these codes as retryable: 408 (Request Timeout), 429 (Too Many Requests), and all of 500–599 EXCEPT 501 (Not Implemented) and 505 (HTTP Version Not Supported). This classifier is the single definition the response-carrying exception flag and the stage stack's default predicate derive from; the recovery stack layers its own configurable status allow-list on top (see RETRY-37) rather than a second copy of the classifier." + }, + { + "id": "RETRY-2", + "level": "MUST", + "subsystem": "retry", + "description": "The set of retryable throwables MUST be defined in exactly one place: any throwable that IS, or has anywhere in its cause chain, an I/O error or a timeout error. The cause-chain walk MUST be iterative and MUST terminate on a self-referential/cyclic chain (track visited nodes by identity)." + }, + { + "id": "RETRY-3", + "level": "MUST", + "subsystem": "retry", + "description": "An exception that carries a received HTTP response MUST derive its own retryable flag from the single status classifier (RETRY-1) at construction time, not from a hardcoded per-subclass constant." + }, + { + "id": "RETRY-4", + "level": "MUST", + "subsystem": "retry", + "description": "A transport-level failure that occurred before a complete response was received (connection refused, TLS/DNS failure, socket read timeout, peer reset) MUST be classified as a retryable condition unconditionally at the condition level (safety is still gated separately by re-sendability)." + }, + { + "id": "RETRY-5", + "level": "MUST", + "subsystem": "retry", + "description": "A request is re-sendable if and only if: it has no body AND its method is idempotent, OR it has a body AND that body is replayable. Both retry stacks MUST apply this identical rule." + }, + { + "id": "RETRY-6", + "level": "MUST", + "subsystem": "retry", + "description": "The idempotent-method set MUST be single-sourced and equal to {GET, HEAD, OPTIONS, PUT, DELETE}. POST/PATCH are NOT idempotent and are re-sendable only via the replayable-body path." + }, + { + "id": "RETRY-7", + "level": "MUST", + "subsystem": "retry", + "description": "When a request is not re-sendable, the retry logic MUST perform exactly one attempt and MUST NOT retry — even when the failure condition is retryable and even when there is no body to physically re-send (e.g. a bare non-idempotent POST)." + }, + { + "id": "RETRY-8", + "level": "MUST", + "subsystem": "retry", + "description": "Retry eligibility MUST require BOTH a retryable condition AND a re-sendable request; the two gates are independent and neither implies the other." + }, + { + "id": "RETRY-9", + "level": "MUST", + "subsystem": "retry", + "description": "The unjittered exponential delay MUST be initialDelay × multiplier^(attempt−1), where attempt is 1-indexed (attempt 1 = the wait before the first retry), clamped to a maximum delay cap." + }, + { + "id": "RETRY-10", + "level": "MUST", + "subsystem": "retry", + "description": "Symmetric jitter MUST draw the effective delay uniformly from [d×(1−j/2), d×(1+j/2)] with the midpoint at d. j=0 MUST return d unchanged; j MUST be constrained to [0,1] (1 ⇒ range [0, 2d]). A degenerate sub-nanosecond range MUST return the base delay rather than error, and a negative sample MUST be floored to zero." + }, + { + "id": "RETRY-11", + "level": "MUST", + "subsystem": "retry", + "description": "Delay computation MUST be overflow-safe: pathological attempt counts, multipliers, or configured magnitudes MUST saturate to the cap rather than throw. The calculator MUST reject attempt < 1." + }, + { + "id": "RETRY-12", + "level": "SHOULD", + "subsystem": "retry", + "description": "The default tuning constants SHOULD be: initial/base delay 200ms, multiplier 2.0, max delay cap 8s, jitter fraction 0.2, and a total budget of 3 sends (initial + 2 retries)." + }, + { + "id": "RETRY-13", + "level": "MUST", + "subsystem": "retry", + "description": "Both retry stacks MUST compute their backoff via the one shared calculator using the one shared set of constants (multiplier, jitter, base, cap); the stacks MUST NOT carry independent backoff formulas or duplicated constants." + }, + { + "id": "RETRY-14", + "level": "MUST", + "subsystem": "retry", + "description": "The two stacks' attempt budgets MUST denote the same number of total wire sends under equivalent defaults: the recovery stack's maxAttempts (total, default 3) MUST equal the stage stack's maxRetries (default 2) + 1 initial send." + }, + { + "id": "RETRY-15", + "level": "MUST", + "subsystem": "retry", + "description": "The pacing-header parser MUST recognize: Retry-After as delta-seconds (integer AND fractional, honored to nanosecond resolution); Retry-After as an RFC 1123 HTTP-date (tolerant of an informational weekday and single-digit day-of-month); retry-after-ms and x-ms-retry-after-ms as integer milliseconds; and X-RateLimit-Reset as a Unix epoch in seconds whose delta is positively jittered to [100%,120%]." + }, + { + "id": "RETRY-16", + "level": "MUST", + "subsystem": "retry", + "description": "The pacing-header parser MUST be total (never throw for any input). Malformed, negative, or out-of-range values MUST map to 'no hint' (null), NOT to a zero delay, so the caller falls back to its exponential schedule rather than hammering a server that asked it to slow down." + }, + { + "id": "RETRY-17", + "level": "MUST", + "subsystem": "retry", + "description": "An HTTP-date or Unix-epoch pacing value whose moment has already passed MUST yield a zero delay (retry immediately), distinct from an unparseable value (which yields no hint)." + }, + { + "id": "RETRY-18", + "level": "MUST", + "subsystem": "retry", + "description": "Any computed pacing delta MUST be clamped to a finite sane ceiling (365 days) before nanosecond conversion so a far-future date/epoch cannot overflow the duration arithmetic." + }, + { + "id": "RETRY-19", + "level": "MUST", + "subsystem": "retry", + "description": "Numeric Retry-After parsing MUST be screened by a strict decimal grammar (digits with an optional single fractional part) BEFORE any float parse, rejecting type-suffixed, hex-float, NaN, and Infinity forms (e.g. '30d', '0x1p4')." + }, + { + "id": "RETRY-20", + "level": "MUST", + "subsystem": "retry", + "description": "When a server pacing hint is present it MUST override (replace, not augment) the exponential schedule for that single decision. A literal Retry-After hint MUST NOT receive additional symmetric jitter; where a total-timeout deadline applies the hint MUST still be clamped against it (the recovery stack routes the hint through the calculator's deadline clamp; the stage stack, which has no deadline, returns the parsed hint verbatim)." + }, + { + "id": "RETRY-21", + "level": "MUST", + "subsystem": "retry", + "description": "Pacing-header resolution MUST honor a defined precedence and return the first parseable value. The recovery stack scans the whole header map with fixed precedence (Retry-After [numeric then date] → retry-after-ms → x-ms-retry-after-ms → X-RateLimit-Reset); the stage stack walks a caller-configurable ordered header list, parsing each by its name's semantics." + }, + { + "id": "RETRY-22", + "level": "MUST", + "subsystem": "retry", + "description": "A failure while parsing a pacing header MUST NOT mask the real upstream failure: the loop falls back to its exponential schedule and the original upstream throwable remains the surfaced error. The recovery stack defensively swallows any RuntimeException from hint parsing; the stage stack relies on the parser being total (RETRY-16) so no parse error arises." + }, + { + "id": "RETRY-23", + "level": "MUST", + "subsystem": "retry", + "description": "Thread interruption / cancellation MUST never be treated as a retryable failure. On interrupt during a blocking backoff wait the implementation MUST restore the interrupt flag, cancel any externally-scheduled wake, abort the retry loop, and surface an interrupted-I/O error (with the original interrupt attached). In the async stack, a downstream interrupt surfaced as an interrupted-I/O error is treated as terminal cancellation (interrupt flag restored) rather than retried." + }, + { + "id": "RETRY-24", + "level": "MUST", + "subsystem": "retry", + "description": "A read-timeout that is represented as a subtype of the interrupted-I/O error MUST NOT be mistaken for cancellation: it remains a retryable condition and flows through normal classification." + }, + { + "id": "RETRY-25", + "level": "MUST", + "subsystem": "retry", + "description": "Non-recoverable JVM errors (out-of-memory, stack overflow) MUST NOT be retried, classified as a retryable condition, or logged; they MUST be surfaced unchanged with no suppressed-trail attachment. In the stage-based sync and async stacks the error propagates immediately at the throw site; the shared predicate/delay invocation rethrows an error rather than wrapping it. (The recovery stack captures a transport-thrown error into its failure channel but still never classifies it retryable, never retries it, and surfaces it terminally — a mechanism a port unifying the stacks should note.)" + }, + { + "id": "RETRY-26", + "level": "MUST", + "subsystem": "retry", + "description": "The inter-attempt wait MUST be cancellable/interruptible and MUST NOT pin an execution carrier for its duration. Implementations differ by stack: the recovery stack schedules the wake on a shared scheduler and blocks on the resulting future (interruptible, Loom-friendly); the stage-based sync stack performs an interruptible sleep that unmounts a virtual-thread carrier; async implementations schedule the delay without blocking any thread. A naive uninterruptible sleep that cannot be cancelled is non-conforming." + }, + { + "id": "RETRY-27", + "level": "MUST", + "subsystem": "retry", + "description": "The recovery stack MUST enforce an optional total-timeout budget with per-attempt deadline shrinking: before scheduling each attempt it aborts if the attempt cap is reached, if elapsed >= budget, or if elapsed + next-delay would exceed the budget; the computed delay is additionally clamped so it cannot overshoot the budget. A zero budget disables the deadline (unbounded)." + }, + { + "id": "RETRY-28", + "level": "MUST", + "subsystem": "retry", + "description": "The stage-based stack MUST NOT impose a total-timeout budget (its backoff view sets the budget to zero); a port that unifies the stacks MUST make the total-timeout budget an explicitly opt-in feature rather than an always-on one." + }, + { + "id": "RETRY-29", + "level": "MAY", + "subsystem": "retry", + "description": "An opt-in server-driven override MAY let a response header force or suppress the retry classification: a truthy value forces a retry (even for a normally non-retryable status), a falsy value suppresses one (even for a normally retryable status); absent/unrecognized values, and the exception path, defer to the underlying classifier. The override MUST flip only classification and MUST remain subject to the attempt cap and the re-send-safety gate." + }, + { + "id": "RETRY-30", + "level": "MUST", + "subsystem": "retry", + "description": "The asynchronous retry loop MUST be driven by an iterative trampoline: N retries MUST NOT build an N-deep chain of future continuations or N-deep stack frames. A completion that warrants another attempt hands control back to a single active pump via a re-arm flag rather than recursing." + }, + { + "id": "RETRY-31", + "level": "MUST", + "subsystem": "retry", + "description": "Async backoff delays MUST be scheduled non-blockingly (no blocking sleep on the dispatching thread). A zero-length delay MUST complete inline and re-arm the active pump without adding a stack frame; a positive delay fires later on a scheduler and starts a fresh shallow pump." + }, + { + "id": "RETRY-32", + "level": "MUST", + "subsystem": "retry", + "description": "If the caller has already completed or cancelled the returned async result, the driver MUST launch no further network attempts, and any response that arrives from an already-in-flight attempt MUST be closed rather than leaked." + }, + { + "id": "RETRY-33", + "level": "MUST", + "subsystem": "retry", + "description": "Every terminal path of the async loop MUST complete the returned future (never leave it hanging). A throwing should-retry predicate, a throwing delay computation, a throwing log call, or a synchronous scheduler rejection MUST each complete the future exceptionally; any open retryable response MUST be closed before surfacing." + }, + { + "id": "RETRY-34", + "level": "MUST", + "subsystem": "retry", + "description": "On terminal failure, every prior failed attempt's exception MUST be attached to the surfaced exception as suppressed, and the surfaced instance itself MUST be skipped so a transport that reuses one exception instance across attempts cannot trip a self-suppression error. On eventual success after retries, the prior-attempt trail MUST be discarded (not surfaced). NOTE: only the async stack currently implements the skip-self guard; the sync stack attaches the prior trail unconditionally and would raise IllegalArgumentException if the terminal instance also appears in the trail — a port MUST apply the guard to both." + }, + { + "id": "RETRY-35", + "level": "MUST", + "subsystem": "retry", + "description": "A retryable response's body/connection MUST be released before the backoff wait so the socket/buffer is not pinned across the delay. The pacing delay MUST be computed from the still-open response first; if the retry decision or delay computation throws, the response MUST still be closed before the throwable propagates. (This is the stage stacks' mechanism; the recovery stack releases the connection by buffering the error body — RETRY-36.)" + }, + { + "id": "RETRY-36", + "level": "MUST", + "subsystem": "retry", + "description": "In the recovery stack, a re-sent response whose error status is in the configured retryable-status set MUST be re-mapped into a typed failure so the loop keeps evaluating the budget (a 503,503,200 sequence MUST reach the 200). The re-sent error body MUST be buffered into a bounded, replayable in-memory copy so the transport connection is released before the next attempt." + }, + { + "id": "RETRY-37", + "level": "MUST", + "subsystem": "retry", + "description": "In the recovery stack, for a failure carrying a received response the configured retryable-status set MUST be authoritative — it can both widen (retry a status the built-in classifier would not) and narrow. For a no-response transport failure, classification MUST fall back to its always-retryable flag." + }, + { + "id": "RETRY-38", + "level": "SHOULD", + "subsystem": "retry", + "description": "An optional per-attempt request header MAY stamp the 1-based attempt ordinal (1 for the original send, 2 for the first retry, …) on a fresh per-attempt copy of the request. When enabled it MUST NOT mutate the captured template and MUST preserve any existing idempotency key; when disabled (the default) it MUST allocate nothing and send the request unchanged." + }, + { + "id": "RETRY-39", + "level": "MUST", + "subsystem": "retry", + "description": "The stage stack's delay resolution MUST follow the precedence: caller delay-override provider → server pacing headers (response path only) → fixed delay → exponential backoff. The exception path skips the header step (there is no response)." + }, + { + "id": "RETRY-40", + "level": "SHOULD", + "subsystem": "retry", + "description": "A misbehaving user delay-override that throws SHOULD be non-fatal: log and fall back to default delay resolution. A misbehaving should-retry predicate that throws SHOULD abort the call (surface as a well-typed illegal-state error), with JVM-fatal errors rethrown unchanged in both cases." + }, + { + "id": "RETRY-41", + "level": "MUST", + "subsystem": "retry", + "description": "The stage stack MUST resolve the effective retry count per call as: a present per-call override wins (validated non-negative), otherwise the configured value; a negative configured value MUST be clamped to the default (and the clamp logged); zero is a valid value meaning 'no retries'." + }, + { + "id": "RETRY-42", + "level": "MUST", + "subsystem": "retry", + "description": "All retry policy/configuration components MUST be immutable and stateless after construction and safe to invoke concurrently; every piece of per-call mutable state (attempt count, start instant, suppressed trail, trampoline flags) MUST live on the per-call stack/driver, never on the shared instance." + }, + { + "id": "RETRY-43", + "level": "MAY", + "subsystem": "retry", + "description": "A fixed-delay configuration MAY force a flat delay between every retry, disabling exponential growth and jitter; the implementation MUST make the backoff path unreachable in that mode (e.g. by zeroing the base and cap so only the fixed delay applies)." + }, + { + "id": "RETRY-44", + "level": "MUST", + "subsystem": "retry", + "description": "Each retry attempt MUST re-execute the downstream chain with fresh per-attempt continuation state rather than reusing the prior attempt's in-flight chain, and steps upstream of the retry point MUST NOT mutate the shared in-flight request between attempts (a mutation would leak into the retried send)." + }, + { + "id": "RETRY-45", + "level": "MUST", + "subsystem": "retry", + "description": "The retry engine MUST NOT shut down or close a caller-supplied scheduler — the caller owns its lifecycle. When no scheduler is supplied, the recovery stack uses a process-wide daemon-thread scheduler that is created at most once VM-wide and is likewise never shut down by the SDK; the async stage stack requires the caller to supply a scheduler explicitly." + }, + { + "id": "REDIR-1", + "level": "MUST", + "subsystem": "redirect", + "description": "A redirect is attempted only for response status codes 301, 302, 303, 307, and 308. Any other status (including 2xx, 4xx, 5xx, and non-redirect 3xx) is returned to the caller verbatim without consulting redirect logic." + }, + { + "id": "REDIR-2", + "level": "MUST", + "subsystem": "redirect", + "description": "Status 300 (Multiple Choices), 304 (Not Modified), and 305 (Use Proxy) MUST NOT be auto-followed even when a Location header is present. 305 in particular is deprecated and must never redirect a request to a server-chosen proxy." + }, + { + "id": "REDIR-3", + "level": "MUST", + "subsystem": "redirect", + "description": "For 301 and 302, a redirect is followed only if the ORIGINAL request method is in the configured allowed-method set (default {GET, HEAD}). When followed, the original method AND body are preserved — there is deliberately NO automatic POST→GET rewrite for 301/302. If the method is not allowed, the 3xx response is returned verbatim." + }, + { + "id": "REDIR-4", + "level": "MUST", + "subsystem": "redirect", + "description": "For 307 and 308, the redirect preserves the original request method and body, and is followed only if that method is in the allowed-method set. With the default {GET, HEAD} set, a 307/308 on a non-GET/HEAD method is not followed." + }, + { + "id": "REDIR-5", + "level": "MUST", + "subsystem": "redirect", + "description": "303 (See Other) is NOT followed by default. When explicitly opted in (follow303=true), a 303 is re-issued as a GET with the body dropped and every Content-* request header (case-insensitively, e.g. Content-Type, Content-Length, Content-Encoding) removed. The original request method is irrelevant to whether a 303 is followed (a POST→303 with follow303=true IS followed, as a GET)." + }, + { + "id": "REDIR-6", + "level": "MUST", + "subsystem": "redirect", + "description": "Any method-preserving redirect (301/302/307/308 that is actually followed) re-sends the original request body, so that body MUST be replayable. If the body is present and not replayable, the operation MUST fail with a clear error (the reference raises an exception whose message names replayability) rather than silently corrupting or truncating the re-send. The redirect is not attempted in that case. 303 is exempt because it drops the body." + }, + { + "id": "REDIR-7", + "level": "MUST", + "subsystem": "redirect", + "description": "The Authorization request header MUST be stripped before EVERY redirect re-issue — including a same-origin redirect and the 303 GET rebuild. Re-attaching a credential for a known origin is the responsibility of the auth layer, not the redirect layer." + }, + { + "id": "REDIR-8", + "level": "MUST", + "subsystem": "redirect", + "description": "A redirect is classified cross-origin iff the resolved target differs from the ORIGINAL (seed) request origin in scheme, host, or effective port (the RFC 6454 origin tuple). Host comparison MUST be case-insensitive and the port MUST be normalized to the scheme's default when omitted. The comparison MUST be against the seed origin, not the immediately preceding hop, so that a same-origin sub-redirect on a foreign host does not re-expose the credential." + }, + { + "id": "REDIR-9", + "level": "MUST", + "subsystem": "redirect", + "description": "On a cross-origin redirect (method-preserving or the 303 GET rebuild), the origin-scoped Cookie and Proxy-Authorization request headers MUST also be stripped, matching browser / OkHttp / JDK behavior." + }, + { + "id": "REDIR-10", + "level": "SHOULD", + "subsystem": "redirect", + "description": "On a SAME-origin redirect, the Cookie header SHOULD be retained (it is legitimately origin-scoped to the same origin). Only Authorization is stripped same-origin." + }, + { + "id": "REDIR-11", + "level": "MUST", + "subsystem": "redirect", + "description": "Because the auth layer runs INSIDE the redirect loop and would otherwise re-stamp the caller credential onto the redirect target, a cross-origin re-issue MUST carry an out-of-band signal instructing the auth layer to skip credential stamping. This signal MUST: (a) be impossible for a server-supplied Location to forge into a credential leak — the redirect layer clears any inbound/caller copy of the signal on EVERY re-issue (same-origin included) before conditionally setting its own on a cross-origin hop; (b) only SUPPRESS stamping, never CAUSE a credential to be sent; (c) be removed by the credential-attaching layer before dispatch so it does not reach the wire whenever that layer runs. A same-origin re-issue is NOT signaled and is re-stamped normally. Porter caveat: in the reference the redirect layer sets the marker unconditionally on a cross-origin hop while ONLY the auth step strips it, so a pipeline assembled with no auth step (including the sync standard-resilience preset, which installs no auth step) forwards the internal marker to the transport; a robust port should strip the signal independently of whether a credential layer runs." + }, + { + "id": "REDIR-12", + "level": "MUST", + "subsystem": "redirect", + "description": "If the Location target carries userinfo (e.g. https://user:pass@host/…), the userinfo MUST be dropped before re-issue. Server-supplied credentials embedded in a redirect target must never be used." + }, + { + "id": "REDIR-13", + "level": "MUST", + "subsystem": "redirect", + "description": "Stripping userinfo (and resolving the Location generally) MUST preserve the wire-exact, already-percent-encoded path, query, and fragment, and MUST preserve bracketed IPv6 literal hosts and explicit ports. In particular, re-encoding that would decode %2F→'/' or %26→'&' is forbidden, as it would silently change path/query structure." + }, + { + "id": "REDIR-14", + "level": "MUST", + "subsystem": "redirect", + "description": "A relative Location value MUST be resolved against the CURRENT hop's request URL per RFC 3986 reference resolution (e.g. '/v2/x' relative to 'https://h/v1/x' → 'https://h/v2/x'). Absolute Location values are used as-is (after userinfo stripping)." + }, + { + "id": "REDIR-15", + "level": "MUST", + "subsystem": "redirect", + "description": "An HTTPS→HTTP scheme downgrade across a single redirect hop MUST be rejected by default (fail with a clear error), preventing TLS/credential guarantees from silently disappearing. A configuration opt-in (allowSchemeDowngrade) permits the downgrade but MUST surface it observably (e.g. a warning log). Credential stripping (REDIR-7/9) applies regardless of this flag. The downgrade check is evaluated on each individual hop transition (from the hop's request URL to its resolved target)." + }, + { + "id": "REDIR-16", + "level": "MUST", + "subsystem": "redirect", + "description": "The step MUST detect redirect loops by recording every visited absolute URI (seeded with the original request URI) and, when a redirect would revisit an already-seen URI, MUST stop and return the CURRENT redirect response to the caller WITHOUT throwing — leaving that response's body open so the caller owns and can inspect it." + }, + { + "id": "REDIR-17", + "level": "MUST", + "subsystem": "redirect", + "description": "The number of followed redirects MUST be capped by maxHops (default 3). On reaching the cap, the last response is returned as-is even if it is itself a redirect — the step MUST NOT throw on exhaustion. maxHops=0 MUST disable redirect following entirely (the first response is returned untouched)." + }, + { + "id": "REDIR-18", + "level": "MUST", + "subsystem": "redirect", + "description": "A malformed or unresolvable Location (syntactically invalid URI, illegal characters, or an unsupported/unknown scheme) MUST NOT throw. The step logs the condition (e.g. at warning) and returns the current redirect response to the caller unfollowed." + }, + { + "id": "REDIR-19", + "level": "MUST", + "subsystem": "redirect", + "description": "A redirect response with a missing or empty Location header MUST be returned to the caller unfollowed." + }, + { + "id": "REDIR-20", + "level": "MUST", + "subsystem": "redirect", + "description": "When a custom redirect predicate is configured, it fully overrides the built-in follow decision and is given a READ-ONLY condition snapshot: the current response, the count of redirects already followed (0 on the first decision), and an insertion-ordered set of visited URIs that includes the current request's URI. That snapshot MUST be a defensive copy so the predicate cannot mutate the live cycle-detection state." + }, + { + "id": "REDIR-21", + "level": "SHOULD", + "subsystem": "redirect", + "description": "On the non-redirect fast path — a status that is NOT one of the recognized redirect codes (301/302/303/307/308) — the implementation SHOULD short-circuit before allocating a condition snapshot and MUST NOT consult a configured predicate. NOTE for porters: a response that IS a recognized 3xx always allocates the condition snapshot and consults the configured predicate, EVEN when it carries no usable Location — the Location and method checks live inside the default decision logic, not in a pre-predicate fast path. Only a non-redirect status is short-circuited." + }, + { + "id": "REDIR-22", + "level": "MUST", + "subsystem": "redirect", + "description": "The step MUST manage response-body lifecycle deterministically across the loop: (a) before issuing a follow-up request, the prior redirect response's body MUST be closed; (b) if building the follow-up request throws (e.g. non-replayable body, downgrade rejection), the current response MUST be closed before the error propagates; (c) on any 'return current' outcome (not-a-redirect, opted-out, malformed/missing Location, loop detected, max hops), the returned response is left OPEN for the caller to own and close." + }, + { + "id": "REDIR-23", + "level": "SHOULD", + "subsystem": "redirect", + "description": "Redirect following SHOULD be implemented as an iterative loop (not unbounded recursion) so it is stack-safe regardless of maxHops." + }, + { + "id": "REDIR-24", + "level": "MUST", + "subsystem": "redirect", + "description": "The redirect follower MUST wrap the credential-attaching (auth) layer — i.e. the redirect loop is OUTER and the auth stamping runs INSIDE it, per redirect hop. This ordering is what necessitates the unconditional Authorization strip (REDIR-7) plus the cross-origin suppression signal (REDIR-11): without it, the auth layer would re-stamp the caller credential onto every redirect target, including foreign hosts." + }, + { + "id": "REDIR-25", + "level": "MUST", + "subsystem": "redirect", + "description": "The asynchronous pipeline MUST NOT follow HTTP redirects: the SDK ships no async redirect step and the async standard-resilience preset installs none, so a 3xx surfaces to the async caller verbatim. A porter's async path MUST either (a) leave redirect following to the transport (both reference transports default to followRedirects=false), (b) delegate to the synchronous pipeline, or (c) let callers install their own async redirect step. This asymmetry with the synchronous pipeline (which does install a default redirect step in its resilience preset) MUST be preserved or explicitly documented if changed." + }, + { + "id": "REDIR-26", + "level": "MUST", + "subsystem": "redirect", + "description": "The configured allowed-method set MUST be stored as an immutable defensive copy decoupled from the caller-supplied collection, so mutating the caller's collection after construction cannot change the effective redirect policy." + }, + { + "id": "REDIR-27", + "level": "MAY", + "subsystem": "redirect", + "description": "The response header from which the redirect target is read MAY be configurable (default 'Location') to accommodate service-specific redirect headers." + }, + { + "id": "REDIR-28", + "level": "SHOULD", + "subsystem": "redirect", + "description": "Each followed hop, loop detection, and scheme-downgrade event SHOULD be emitted as structured observability records with URLs passed through a redactor before logging, and redaction failures MUST NOT crash logging (a redaction error is swallowed to a placeholder). The malformed-Location event is an exception: it logs the raw Location string as received (it failed to parse into a URL, so it cannot be redacted). A porter that may receive credential-bearing malformed Location values should account for this raw-logging behavior." + }, + { + "id": "AUTH-1", + "level": "MUST", + "subsystem": "auth", + "description": "The set of auth schemes the descriptor/resolver layer recognizes MUST be exactly {OAUTH2, API_KEY, BASIC, DIGEST, NO_AUTH}, where NO_AUTH is a distinct sentinel meaning 'this operation may run anonymously / skip credential stamping' rather than a real wire scheme." + }, + { + "id": "AUTH-2", + "level": "MUST", + "subsystem": "auth", + "description": "An AuthRequirement MUST bind exactly one scheme to its own OAuth scopes and OAuth params, and those OAuth collections MUST be treated as meaningful only for the OAUTH2 scheme (never inspected by resolution for any scheme, but still preserved for caller inspection). The requirement MUST be immutable: retained input collections mutated by the caller after construction MUST NOT affect the stored value. It has value-based equality over scheme + scopes + params." + }, + { + "id": "AUTH-3", + "level": "MUST", + "subsystem": "auth", + "description": "An AuthDescriptor MUST be a non-empty ordered list of requirements in caller preference order, MUST reject an empty requirement list at construction with an argument/validation error, MUST be immutable (defensive copy in and read-only view out), and MUST report allowsAnonymous() true iff any requirement's scheme is NO_AUTH." + }, + { + "id": "AUTH-4", + "level": "MUST", + "subsystem": "auth", + "description": "Tier resolution MUST select the single most-specific descriptor that is present, in the strict order per-call > operation > client, and MUST resolve only against that descriptor. A higher tier that is present but cannot be satisfied MUST NOT fall through to a lower tier — it fails, because the caller asked for that override explicitly. A lower tier is consulted only when every higher tier is absent." + }, + { + "id": "AUTH-5", + "level": "MUST", + "subsystem": "auth", + "description": "Within the selected descriptor, resolution MUST return the first requirement, in declared order, whose scheme is satisfiable. A scheme is satisfiable iff it is NO_AUTH (always satisfiable) or it is a member of the supplied set of available schemes. The resolver MUST NOT inspect any concrete credential to decide satisfiability." + }, + { + "id": "AUTH-6", + "level": "MUST", + "subsystem": "auth", + "description": "Resolution MUST fail with an argument/validation error when all of per-call, operation, and client descriptors are absent, and MUST fail with a distinct auth-resolution error (carrying both the required schemes in preference order and the available schemes) when the selected descriptor lists no satisfiable scheme." + }, + { + "id": "AUTH-7", + "level": "MUST", + "subsystem": "auth", + "description": "The resolver MUST be stateless and safe for concurrent use from multiple threads, and a single shared instance MUST be a valid entry point. Resolution MUST be a deterministic pure function of its inputs." + }, + { + "id": "AUTH-8", + "level": "MUST", + "subsystem": "auth", + "description": "Every credential type MUST redact its secret material (API key, name-key secret, bearer token) in any string/diagnostic representation, and redaction MUST NOT be achieved by mutating, nulling, or otherwise corrupting the real fields. Non-secret fields (header name, prefix, key name, expiry) MAY remain visible for diagnostics. Equality is variant-specific and MUST be preserved as-is: the bearer token has value-based equality/hashing computed over its real token+expiry fields (unaffected by the redacted string form), while the API-key and name-key credentials use reference identity — two instances with identical fields are NOT equal." + }, + { + "id": "AUTH-9", + "level": "MUST", + "subsystem": "auth", + "description": "Credential construction MUST validate secret and identity fields as non-blank and reject blanks with a validation error: BearerToken.token, KeyCredential.apiKey, and NamedKeyCredential.name and key MUST be non-blank." + }, + { + "id": "AUTH-10", + "level": "MUST", + "subsystem": "auth", + "description": "BearerToken expiry MUST be optional: a null expiry means the token never locally expires. Expiry evaluation MUST be additive with a grace margin: the token is considered expired at reference time 'now' with margin M iff expiry is non-null and (now + M) is strictly after expiry. A non-expiring token is never expired regardless of margin." + }, + { + "id": "AUTH-11", + "level": "MUST", + "subsystem": "auth", + "description": "A bearer token provider's fetch errors MUST propagate to the caller and MUST NOT be cached, so a subsequent request retries the fetch (the not-caching is enforced by the consuming step — see AUTH-35). Async callers MUST observe a provider error through the asynchronous result channel (a failed future/promise), never as a synchronous throw: the default async fetch mirrors the blocking fetch's outcome into an already-failed future, and the async bearer step additionally normalizes a synchronous throw from a misbehaving async override into a failed future. Providers MAY block on the synchronous fetch and SHOULD cache/refresh internally." + }, + { + "id": "AUTH-12", + "level": "MUST", + "subsystem": "auth", + "description": "The challenge parser MUST parse RFC 7235 WWW-Authenticate/Proxy-Authenticate values into an ordered list of challenges, honoring: multiple comma-separated challenges at the top level; quoted-string values that may contain commas and '=' without being treated as delimiters; backslash escape sequences unescaped and surrounding quotes stripped; scheme names and parameter names normalized to lower case; parameter values stored verbatim after unquoting; a bare scheme with no params emitted as a valid challenge with an empty parameter map; and a token68 value recorded under the synthetic parameter key 'token68'." + }, + { + "id": "AUTH-13", + "level": "MUST", + "subsystem": "auth", + "description": "The challenge parser MUST be lenient and MUST NOT throw on malformed input: blank input yields an empty list, a malformed challenge is skipped by recovering to the next top-level comma, and an unterminated quoted string terminates the current value at end-of-input. Parameters parsed before a malformed tail MUST be preserved on the emitted challenge." + }, + { + "id": "AUTH-14", + "level": "MUST", + "subsystem": "auth", + "description": "Basic-scheme stamping MUST produce the header value 'Basic ' + base64(UTF-8 bytes of 'username:password'), computed once and reused. The handler MUST accept a Basic challenge case-insensitively and MUST emit Authorization (or Proxy-Authorization for a proxy challenge). Its credentials MUST be validated as non-empty (permitting whitespace-only values, per RFC 7617), which is intentionally laxer than the non-blank rule used elsewhere." + }, + { + "id": "AUTH-15", + "level": "MUST", + "subsystem": "auth", + "description": "Digest stamping MUST support exactly the algorithm set {MD5, MD5-sess, SHA-256, SHA-256-sess} with qop 'auth' (or absent, i.e. legacy RFC 2069 no-qop). It MUST decline (report cannot-handle for) challenges that offer only qop=auth-int, that use SHA-512-256 or any other unsupported algorithm, and MUST NOT attempt mutual-auth (rspauth) verification of the response." + }, + { + "id": "AUTH-16", + "level": "MUST", + "subsystem": "auth", + "description": "Digest challenge selection MUST consider a challenge satisfiable iff its scheme is 'Digest' (case-insensitive), it carries both realm and nonce, its qop contains the 'auth' token or is absent, and its algorithm is supported or absent (an absent algorithm MUST default to MD5). Among satisfiable challenges, selection MUST prefer the algorithm appearing earliest in the configured preference list, independent of the order challenges arrived in." + }, + { + "id": "AUTH-17", + "level": "MUST", + "subsystem": "auth", + "description": "Digest response computation MUST follow RFC 7616/2069: HA1 = H(username:realm:password) for non-session variants and H(H(username:realm:password):nonce:cnonce) for -sess variants; HA2 = H(method:digest-uri); the response = H(HA1:nonce:nc:cnonce:qop:HA2) when qop is negotiated, or the legacy H(HA1:nonce:HA2) when qop is absent. All hashes MUST be lower-case hex of the selected digest algorithm." + }, + { + "id": "AUTH-18", + "level": "MUST", + "subsystem": "auth", + "description": "The Digest nonce count (nc) MUST be tracked per server nonce, starting at 1 (rendered as 00000001) for the first request against a given nonce and incrementing only on reuse of that same nonce. It MUST be rendered as exactly 8 lower-case hex digits, using the low 32 bits when the counter would exceed 32 bits." + }, + { + "id": "AUTH-19", + "level": "SHOULD", + "subsystem": "auth", + "description": "The per-nonce request-counter store SHOULD be bounded (default cap 1024 distinct nonces) and drained back under the cap after admitting a nonce, so memory stays bounded against a server that rotates nonces aggressively. Evicting a still-live nonce is acceptable because its nc simply restarts at 1 on next reuse (spec-legal for a fresh nonce)." + }, + { + "id": "AUTH-20", + "level": "MUST", + "subsystem": "auth", + "description": "The Digest client nonce (cnonce) MUST be drawn from a cryptographically strong random source with at least 128 bits of entropy (16 random bytes) and hex-encoded, so it is unpredictable and cannot be precomputed by an attacker." + }, + { + "id": "AUTH-21", + "level": "MUST", + "subsystem": "auth", + "description": "The byte encoding used to materialize Digest hash inputs MUST be UTF-8 when the challenge advertises charset=UTF-8 (case-insensitive) and ISO-8859-1 (Latin-1) otherwise." + }, + { + "id": "AUTH-22", + "level": "MUST", + "subsystem": "auth", + "description": "The assembled Digest Authorization value MUST quote username, realm, nonce, uri, response, cnonce, and opaque (with backslash-escaping of embedded quote and backslash) and MUST leave qop, nc, and algorithm unquoted, using the full RFC spelling of the algorithm name (e.g. 'SHA-256-sess'). The digest-uri MUST be the request-target form: raw path (defaulting to '/') optionally followed by '?' and the raw query. cnonce/nc/qop MUST be emitted only when qop is negotiated." + }, + { + "id": "AUTH-23", + "level": "MUST", + "subsystem": "auth", + "description": "Composing multiple challenge handlers MUST delegate to the first handler, in declaration order, whose can-handle check passes, and MUST take a defensive copy of the handler list so later caller mutation cannot reorder it. Callers MUST order stronger schemes first (e.g. Digest before Basic) since a server offering both is answered by the first matching handler." + }, + { + "id": "AUTH-24", + "level": "MUST", + "subsystem": "auth", + "description": "Challenge handlers MUST be safe for concurrent invocation across requests, and any per-handler mutable counters (e.g. Digest nc) MUST use thread-safe primitives such that concurrent reuse of one nonce still yields correct, non-duplicated counts." + }, + { + "id": "AUTH-25", + "level": "MUST", + "subsystem": "auth", + "description": "A challenge handler MUST emit the Authorization header name for WWW-Authenticate challenges and the Proxy-Authorization header name for Proxy-Authenticate challenges, selected by an explicit proxy flag, and MUST return no header (null/none) when it cannot satisfy any offered challenge." + }, + { + "id": "AUTH-26", + "level": "MUST", + "subsystem": "auth", + "description": "Static key-credential stamping MUST write the key value into the credential's configured header (defaulting to Authorization) and, when a prefix is configured, MUST prepend it followed by a single space (e.g. 'SharedAccessKey '). The stamping step MUST be stateless after construction." + }, + { + "id": "AUTH-27", + "level": "MUST", + "subsystem": "auth", + "description": "There MUST be exactly one auth step occupying the single AUTH pillar stage, and that stage MUST run nested inside both the redirect loop and the retry loop (i.e. AUTH executes per redirect hop and per retry attempt, not once outside them). Ordering: redirect wraps retry wraps auth." + }, + { + "id": "AUTH-28", + "level": "MUST", + "subsystem": "auth", + "description": "On any request path where a credential will be attached, the auth step MUST reject a non-HTTPS request URL (scheme comparison case-insensitive) BEFORE performing any token fetch or header stamping, failing with an error that names the concrete step and the offending scheme. Credentials MUST NOT be stamped over plaintext." + }, + { + "id": "AUTH-29", + "level": "MUST", + "subsystem": "auth", + "description": "When a request is a cross-origin redirect re-issue (differing scheme, host, or effective port under the RFC 6454 origin tuple, marked by the redirect step), the auth step MUST NOT stamp the caller's credential onto it, MUST strip the internal cross-origin marker so it never reaches the wire, and (because no credential is attached on this path) MUST skip the HTTPS guard so a deliberately-allowed downgrade hop is forwarded credential-free rather than hard-failing. A same-origin redirect re-issue MUST be re-stamped normally and remains subject to the HTTPS guard. The suppression mechanism MUST only be able to suppress stamping, never to force a credential to be sent (a caller-supplied marker is cleared by the redirect step before it can be forged)." + }, + { + "id": "AUTH-30", + "level": "MUST", + "subsystem": "auth", + "description": "On a 401 (Unauthorized) response that carries a WWW-Authenticate header, the auth step MUST consult its challenge hook; if the hook yields a non-null replacement request, the step MUST close the original 401 response and drive the replacement through a fresh copy of the downstream chain exactly once, with no further challenge handling on that replacement (a single re-challenge attempt only). The default hook MUST yield no replacement (no retry). Non-401 responses, and 401s handled to completion, MUST pass through with their original behavior." + }, + { + "id": "AUTH-31", + "level": "MUST", + "subsystem": "auth", + "description": "On the 401 re-challenge replay path the replay MUST be gated on request-body replayability: if the replacement request carries a body that is not replayable, the step MUST skip the replay and surface the original 401 unchanged, and MUST NOT close that original response (the caller owns and consumes it). NOTE: the reference implementation enforces this gate on the SYNCHRONOUS auth step only; the async auth step does not currently apply a replayability gate and closes the original 401 before re-driving the replacement unconditionally. A faithful port SHOULD apply the same gate on both the sync and async paths." + }, + { + "id": "AUTH-32", + "level": "MUST", + "subsystem": "auth", + "description": "If the challenge hook itself throws (or its async future completes exceptionally, or the async hook throws synchronously), the auth step MUST close the open 401 response body before propagating the error, so a failing hook never leaks the response." + }, + { + "id": "AUTH-33", + "level": "MUST", + "subsystem": "auth", + "description": "A 401 response that does not carry a WWW-Authenticate header MUST be returned unchanged without consulting the challenge hook." + }, + { + "id": "AUTH-34", + "level": "MUST", + "subsystem": "auth", + "description": "The bearer auth step MUST stamp 'Authorization: Bearer ' using a token cached until a configurable refresh margin before its expiry (default 30 seconds), and MUST ensure that concurrent requests racing on a missing/expiring token result in at most one provider fetch (double-checked / single-flight coordination), with the hot-path read of a valid cached token being non-blocking." + }, + { + "id": "AUTH-35", + "level": "MUST", + "subsystem": "auth", + "description": "The bearer auth step MUST reject a misbehaving provider result: a null token MUST surface as an error, and a token already expired at fetch time (evaluated with NO refresh margin) MUST surface as an error. A provider that throws MUST propagate and MUST NOT be cached, so a later request retries." + }, + { + "id": "AUTH-36", + "level": "MUST", + "subsystem": "auth", + "description": "On a 401 whose WWW-Authenticate advertises a Bearer challenge, the bearer auth step MUST evict only the exact cached token that produced the 401 (matched by comparing the stamped header value) and re-stamp the single retry with a freshly fetched token; a token another request already refreshed MUST be preserved and reused. It MUST surface the 401 unchanged (no eviction, no retry) when the rejected request carried no Authorization header (cross-origin suppression) or when the response advertises no Bearer challenge. The eviction-driven retry MUST fire regardless of HTTP method (not gated by idempotency)." + }, + { + "id": "AUTH-37", + "level": "MUST", + "subsystem": "auth", + "description": "The asynchronous bearer auth step MUST implement a three-zone expiry policy without blocking the dispatching thread: (fresh) stamp the cached token with no refresh; (expiring but still valid) stamp the still-valid cached token immediately AND kick off an off-thread background refresh; (expired/missing) await a fresh single-flight fetch before stamping. Concurrent requests observing an expiring/missing token MUST coalesce onto one in-flight fetch, a failed fetch MUST NOT be cached, and a failed/unusable BACKGROUND refresh MUST NOT fail the in-flight request (log-and-continue) since a valid token was already stamped. The post-eviction challenge path MUST await a genuinely fresh fetch so the retry never re-sends the rejected token." + }, + { + "id": "AUTH-38", + "level": "SHOULD", + "subsystem": "auth", + "description": "In the asynchronous auth path, the HTTPS-only guard failure (AUTH-28) and any hook error SHOULD be delivered through the asynchronous result channel (a failed future/promise) rather than by synchronously throwing, so the whole flow remains non-blocking and presents a uniform error model." + }, + { + "id": "PAGE-1", + "level": "MUST", + "subsystem": "pagination", + "description": "The engine MUST expose two consumption views over the same walk: an item-level view that flattens each page's items into a single ordered sequence, and a page-level view that yields whole pages (each exposing raw per-page status, headers, originating request, and the live response). Items MUST be delivered in server-defined order, across page boundaries." + }, + { + "id": "PAGE-2", + "level": "MUST", + "subsystem": "pagination", + "description": "A Page MUST partition its state by lifetime: its materialized item list and its derived status code, headers, and originating request MUST remain readable after the page is closed; only the raw response body/connection becomes invalid at close. Items MUST never be null (may be empty)." + }, + { + "id": "PAGE-3", + "level": "MUST", + "subsystem": "pagination", + "description": "A Page MUST be a closeable resource that owns exactly one underlying response; whoever pulls a page owns closing it, and closing the page MUST release that response's body/connection. A component that hands a caller a live page (e.g. a first-page fetcher) MUST NOT itself close the response — ownership transfers to the page." + }, + { + "id": "PAGE-4", + "level": "MUST", + "subsystem": "pagination", + "description": "A strategy's parse output MUST carry the page's items plus a next-request value, where a null/absent next-request is the single, exclusive end-of-stream signal the iteration engine recognizes. parse MUST always return a well-formed result (never null) and MUST signal termination ONLY by returning a null next-request — never by throwing and never via a side channel (an empty items list paired with a non-null next-request is a valid non-terminal page, not a terminator). Whatever end-of-stream heuristic a concrete strategy applies internally (empty page, empty cursor, missing link — see PAGE-16/17/18) it MUST express by producing that null next-request." + }, + { + "id": "PAGE-5", + "level": "MUST", + "subsystem": "pagination", + "description": "A strategy MUST read everything it needs from the response synchronously inside parse (the body is single-use), MUST NOT retain the response or its body beyond the call, and MUST NOT close or mutate the response — lifecycle ownership belongs to the engine. Strategies MUST be immutable and safe to share concurrently across paginators and threads." + }, + { + "id": "PAGE-6", + "level": "MUST", + "subsystem": "pagination", + "description": "Iteration MUST be page-lazy: exactly one HTTP exchange MUST occur per page actually yielded to the consumer. For the blocking engine, constructing the paginator, obtaining the iterable/stream, and even obtaining the item iterator MUST trigger zero exchanges; the first exchange happens only when the consumer first probes for data. The non-blocking engine has no separate lazy 'obtain' step — invoking a walk method is itself the consumption trigger and begins fetching immediately — but the same one-exchange-per-page-consumed guarantee still holds." + }, + { + "id": "PAGE-7", + "level": "MUST", + "subsystem": "pagination", + "description": "Fetching MUST advance only forward and only on demand: after a page whose strategy returned a null next-request, the engine MUST NOT perform any further exchange, and repeated end-of-stream probes MUST be idempotent (no re-fetch). Empty pages that still carry a non-null next-request DO count as a consumed page (they cost one exchange), but advancing past the terminal page MUST NOT fetch." + }, + { + "id": "PAGE-8", + "level": "MUST", + "subsystem": "pagination", + "description": "Each independent iteration MUST restart pagination from the initial request with its own fresh state; the engine itself MUST hold only immutable configuration and be safe to share. Concurrent use of a single returned iterator/stream/walk MAY be unsafe (single-consumer), but two separate iterations from the same engine MUST each drive their own full fetch sequence and yield identical results." + }, + { + "id": "PAGE-9", + "level": "MUST", + "subsystem": "pagination", + "description": "The engine MUST accept a page cap (maximum number of exchanges) that bounds a misbehaving server which never advances its cursor. The cap MUST count exchanges/pages fetched, not items; once the cap is reached the engine MUST stop fetching even if the strategy still reports a non-null next-request. The cap MUST be validated as strictly positive at construction time, failing fast (not lazily on first walk)." + }, + { + "id": "PAGE-10", + "level": "SHOULD", + "subsystem": "pagination", + "description": "The default page cap SHOULD be effectively unbounded (matching plain lazy-sequence semantics), and documentation SHOULD direct production callers to set a finite cap." + }, + { + "id": "PAGE-11", + "level": "MUST", + "subsystem": "pagination", + "description": "The item-level view MUST eager-close each page BEFORE yielding any of that page's items (after copying the materialized items), so that abandoning item iteration mid-page — taking one item and stopping, breaking early — never strands the page's response/connection." + }, + { + "id": "PAGE-12", + "level": "MUST", + "subsystem": "pagination", + "description": "The page-level view MUST be auto-closing with a close-on-abandon guarantee: it MUST close the previous page as the consumer advances to the next, close the last page when the walk is exhausted, and — because probing for the next page eagerly runs that page's exchange — buffer the fetched-but-not-yet-delivered page in storage it owns so that an emptiness probe or early break followed by an explicit close still releases it. Explicit close MUST release BOTH the page currently held open and any such buffered page. Consumers MUST be told to wrap the view in a scoped/auto-close construct so an early break still releases the held page." + }, + { + "id": "PAGE-13", + "level": "MUST", + "subsystem": "pagination", + "description": "If a strategy's parse throws, the engine MUST close that response inline on the exceptional path (the page is never constructed, so nothing else would ever close it) and then propagate the failure. A failure while closing MUST NOT mask the original parse failure — it MUST be attached as a suppressed/secondary error, with the parse error remaining the primary." + }, + { + "id": "PAGE-14", + "level": "MUST", + "subsystem": "pagination", + "description": "The page-level view MUST be single-use: its iterator/stream may be obtained at most once, and re-iteration MUST fail rather than silently restart or double-consume; a caller restarts pagination by requesting a fresh view from the engine." + }, + { + "id": "PAGE-15", + "level": "MUST", + "subsystem": "pagination", + "description": "A close error raised while releasing a page-level view's held page(s) MUST be surfaced to the caller, not swallowed. When exposed through a stream whose terminal cannot declare the underlying I/O error type, it MUST be re-thrown wrapped so the caller can still catch it by type at the close site (rather than being sneak-thrown or dropped). When both held pages fail to close, the first failure MUST propagate with the second attached as suppressed so neither connection leaks silently." + }, + { + "id": "PAGE-16", + "level": "MUST", + "subsystem": "pagination", + "description": "The cursor strategy MUST read a page's items and its next cursor from a single read of the response body (both live in the same payload), MUST treat a null OR empty next cursor as end-of-stream, and when a non-empty cursor is present MUST derive the next request by setting a configurable cursor query parameter (default name 'cursor') on the original request template to the new cursor value." + }, + { + "id": "PAGE-17", + "level": "MUST", + "subsystem": "pagination", + "description": "The page-number strategy MUST treat an empty items list as end-of-stream (defensive against servers that return an empty page instead of 404 once paged past the end). Otherwise it MUST infer the current page from the ORIGINATING (executed) request's page query parameter, defaulting to a configurable start page (default 1) when that parameter is absent, empty, or non-numeric, and set the next request's page parameter to current+1. The page parameter name (default 'page') and start page (default 1, allowing 0-based servers) MUST be configurable." + }, + { + "id": "PAGE-18", + "level": "MUST", + "subsystem": "pagination", + "description": "The Link-header strategy MUST select the next-page URL from the response's Link header(s) using RFC 5988/8288 semantics — the first link-value whose rel parameter contains the token 'next' (case-insensitively; rel may be quoted or unquoted and may list multiple space/tab-separated relation types). It MUST parse link-values so that commas inside angle-bracketed URLs or quoted parameter values do NOT split link-values, and support quoted-pair escapes inside quoted strings. Absence of a Link header or of a rel=next segment MUST mean end-of-stream. The header name MUST be configurable (default 'Link')." + }, + { + "id": "PAGE-19", + "level": "MUST", + "subsystem": "pagination", + "description": "A rel=next target MUST be resolved as an RFC 3986 reference against the originating page's response URL: absolute targets are used as-is; relative targets are resolved against the base. A query-only reference (starting with '?') MUST preserve the base URL's FULL path and replace only the query — it MUST NOT drop the base's last path segment (the older RFC 2396 relative-resolution behavior that would point the next page at the wrong resource). A target that cannot be resolved into a valid URL MUST be treated as end-of-stream rather than aborting iteration with an error." + }, + { + "id": "PAGE-20", + "level": "SHOULD", + "subsystem": "pagination", + "description": "When a server splits pagination links across multiple separate Link header instances instead of one comma-separated header, the strategy SHOULD normalize them (e.g. by concatenation) so a single parser handles either wire shape, and an empty header set SHOULD map to no next link." + }, + { + "id": "PAGE-21", + "level": "MUST", + "subsystem": "pagination", + "description": "When rewriting the next-page request's query string, the rebuilder MUST splice the raw query verbatim: every parameter the strategy did not target MUST be copied byte-for-byte (value-less flags stay value-less, reserved characters are not rewritten, ordering is preserved), and ONLY the targeted parameter's name/value is decoded/encoded. It MUST NOT re-render or canonicalize the whole query." + }, + { + "id": "PAGE-22", + "level": "MUST", + "subsystem": "pagination", + "description": "When the rebuilder encodes a newly set parameter's name/value it MUST use RFC 3986 component encoding (space → %20, a literal '+' preserved/encoded as data — not treated as a space), and reading a parameter MUST decode with the same RFC 3986 semantics (a literal '+' reads back as '+', %20 reads back as a space, a value-less flag reads back as empty string, first match wins)." + }, + { + "id": "PAGE-23", + "level": "MUST", + "subsystem": "pagination", + "description": "Setting a query parameter MUST replace the first existing occurrence in place (dropping any further duplicates of that name — single-value convention for paging params), append the parameter at the end if it was absent, and remove it entirely when the new value is null/absent; parameter order MUST otherwise be preserved. Following an absolute/whole next URL MUST instead swap only the request's URL while preserving the template's method, headers, and body unchanged." + }, + { + "id": "PAGE-24", + "level": "MUST", + "subsystem": "pagination", + "description": "URL rewriting MUST preserve all non-query components of the URL exactly: scheme/protocol, userInfo, host, port, path, and fragment. Only the query string may change." + }, + { + "id": "PAGE-25", + "level": "MUST", + "subsystem": "pagination", + "description": "The async engine MUST drive the page-to-page walk without any thread blocking on a page: fetch, parse, deliver-to-consumer, and re-arm the next fetch MUST happen inside the async completion graph. Cancelling/completing the walk's result future from outside MUST halt the walk (no further pages fetched) and best-effort abort the in-flight exchange by cancelling its transport future, which propagates cancellation into the underlying client per the transport contract." + }, + { + "id": "PAGE-26", + "level": "MUST", + "subsystem": "pagination", + "description": "Async cancellation MUST take effect at page granularity: if the result is settled while a page is mid-drain, the items already being delivered from that page still reach the consumer; the driver stops at the next page boundary rather than interrupting an in-progress drain. A page that was fetched but not yet drained when the result settles MUST be dropped undrained AND closed so its response is not leaked; any close error on that already-settled path MUST be swallowed (the cancellation/failure remains the outcome)." + }, + { + "id": "PAGE-27", + "level": "MUST", + "subsystem": "pagination", + "description": "The async engine MUST close each page's response exactly once, on whichever path consumes it: after the page is drained to the consumer (item- or page-level), or when a fetched-but-undrained page is dropped due to external settlement or a rejected re-dispatch, or inline on a parse failure (page never built). A response MUST NOT be closed twice nor left unclosed on any of these paths." + }, + { + "id": "PAGE-28", + "level": "MUST", + "subsystem": "pagination", + "description": "In the async engine, a consumer that throws, a transport/connection failure, a parse failure, or a null success completion from the transport MUST terminate the walk and complete the result future exceptionally, surfacing the ORIGINAL underlying cause (unwrapping any future-composition wrapper) rather than a wrapper type. A transport that eagerly throws instead of returning a failed future MUST be handled as a failed walk, not allowed to escape." + }, + { + "id": "PAGE-29", + "level": "MUST", + "subsystem": "pagination", + "description": "For a single async walk the consumer MUST NOT be invoked concurrently, and items MUST be delivered one at a time in server order; the consumer MUST NOT assume any particular thread. By default the consumer runs inline on whichever thread completes each page future (typically a transport callback thread). The engine MUST also offer a mode that runs the page-draining driver — and therefore every consumer invocation — on a caller-supplied executor, so a blocking/expensive consumer does not tie up transport callback threads." + }, + { + "id": "PAGE-30", + "level": "MUST", + "subsystem": "pagination", + "description": "If the async engine is given an executor and that executor rejects a (re-)dispatch (shut down or saturated queue), the walk MUST terminate with the result future completed exceptionally carrying the rejection, and any page already staged at that point MUST be closed so its response is not leaked. It MUST NOT hang the result future or leak the rejection into the completion machinery." + }, + { + "id": "PAGE-31", + "level": "SHOULD", + "subsystem": "pagination", + "description": "The async driver SHOULD process synchronously-completed page futures (in-memory/fake transport, cache hit) iteratively rather than via recursive future composition, so an arbitrarily long run of already-complete pages does not overflow the call stack. A port on a runtime without deep-recursion risk MAY satisfy the intent with its native loop/concurrency model, but MUST NOT recurse per page." + }, + { + "id": "PAGE-32", + "level": "MUST", + "subsystem": "pagination", + "description": "In the async drain path, releasing a page's response MUST happen whether the consumer succeeds or throws, and a throwing close MUST NOT escape the driver: on the success path a throwing close MUST be reported through the result future (so the walk terminates cleanly instead of hanging with the future never settled); if the consumer already failed, that cause is kept as primary and the close error is swallowed." + }, + { + "id": "PAGE-33", + "level": "MUST", + "subsystem": "pagination", + "description": "There is one inherent async cancellation race a port MUST document rather than pretend to close: if an external cancel settles the transport future BEFORE the transport delivers its Response, that Response never reaches the paginator's close path — releasing it is the transport's responsibility, since cancelling the walk's future cannot reach into an already-built response. Conversely, a page request already dispatched MAY still complete after the abort; if it completes successfully the paginator MUST close and discard that response." + }, + { + "id": "PAGE-34", + "level": "MUST", + "subsystem": "pagination", + "description": "The fetcher-based front-end MUST call the first-page fetcher exactly once to start, then drive subsequent pages by keying the next-page fetcher off the previous page's next link, falling back to its continuation token when no next link is present (next link wins). An empty/blank next link (with no fallback token), or a null page returned by either fetcher, MUST end the stream (a null first page yields an empty stream). Each fetcher MUST build a page that owns its response and MUST NOT close that response itself; a fetcher that throws before building the page remains responsible for that response." + }, + { + "id": "PAGE-35", + "level": "SHOULD", + "subsystem": "pagination", + "description": "If a mutable paging-options object is offered to fetchers, the SAME instance SHOULD be threaded through every fetcher call so a custom retriever can stash the latest cursor/state between pages, and this cross-call mutation visibility SHOULD be documented; such an options object is single-consumer and need not be thread-safe." + }, + { + "id": "PAGE-36", + "level": "MUST", + "subsystem": "pagination", + "description": "Per-call request overrides (timeout, retry budget, tags, etc.) supplied to the strategy-based engine MUST be applied to EVERY page exchange, not just the first, so a caller's per-operation options reach all of the paginator's own requests. The default is no overrides." + }, + { + "id": "SSE-1", + "level": "MUST", + "subsystem": "sse", + "description": "The stream MUST be parsed line by line, with a blank (empty) line acting as the event-dispatch boundary: at a blank line the fields accumulated since the previous boundary collapse into exactly one event, and a fresh set of per-event accumulators (id, event, data, comment, retry, and the 'any field seen' flag) governs the next block." + }, + { + "id": "SSE-2", + "level": "MUST", + "subsystem": "sse", + "description": "Line termination MUST recognize all three SSE-legal terminators — LF ('\\n'), CR ('\\r'), and CRLF ('\\r\\n') — treating CRLF as a single terminator, with terminators stripped from line content; a CR not followed by LF terminates the line by itself." + }, + { + "id": "SSE-3", + "level": "MUST", + "subsystem": "sse", + "description": "A non-comment line MUST be split at its first colon into a field name and a value. If the line contains no colon, the entire line is the field name and the value is the empty string; if the colon is the final character, the value is likewise the empty string." + }, + { + "id": "SSE-4", + "level": "MUST", + "subsystem": "sse", + "description": "A field that is present with an empty value (a colon-less 'data', a trailing-colon 'data:', or 'event:'/'id:' with nothing after the single-space strip) MUST be recorded with the empty string as its value AND MUST count as a 'field seen'; this is a distinct state from the field being absent (surfaced as null/absent). A port MUST NOT collapse a present-but-empty field into an absent one." + }, + { + "id": "SSE-5", + "level": "MUST", + "subsystem": "sse", + "description": "When extracting a field value (and comment text), exactly one leading U+0020 SPACE immediately after the colon MUST be stripped if present; any further leading spaces are preserved." + }, + { + "id": "SSE-6", + "level": "MUST", + "subsystem": "sse", + "description": "A line whose first character is ':' MUST be treated as a comment: its text (after the single-space strip) is captured with latest-wins semantics within a block, and a comment counts as a 'field seen' so a comment-only block is still dispatched as a (non-empty) event." + }, + { + "id": "SSE-7", + "level": "MUST", + "subsystem": "sse", + "description": "Only the field names 'id', 'event', 'data', and 'retry' MUST be interpreted; any other field name MUST be silently discarded (it sets no state and does not by itself cause a dispatch)." + }, + { + "id": "SSE-8", + "level": "MUST", + "subsystem": "sse", + "description": "Consecutive 'data' fields within a block MUST accumulate, in wire order, into an ordered list of the raw per-line values; the parser MUST NOT join them at the parse layer." + }, + { + "id": "SSE-9", + "level": "MUST", + "subsystem": "sse", + "description": "An 'id' field whose value contains a U+0000 NUL character MUST be ignored entirely: it does not set the id, does not count as a 'field seen', and does not overwrite a valid id already seen earlier in the same block. A valid id is otherwise stored verbatim, latest-wins within a block." + }, + { + "id": "SSE-10", + "level": "MUST", + "subsystem": "sse", + "description": "The 'event' field MUST be stored as the raw value with latest-wins semantics within a block, and MUST be surfaced as absent/null when no 'event' field was sent — it MUST NOT be defaulted to 'message'." + }, + { + "id": "SSE-11", + "level": "MUST", + "subsystem": "sse", + "description": "The 'retry' field value MUST be accepted only if it consists solely of ASCII digits '0'-'9'; a leading sign, an embedded non-digit, an empty value, or a value exceeding the maximum representable millisecond magnitude MUST cause the field to be ignored (leaving retry unset and not marking the block dispatchable on its own account). An accepted value is surfaced as a non-negative millisecond duration, latest-wins within a block." + }, + { + "id": "SSE-12", + "level": "MUST", + "subsystem": "sse", + "description": "A single leading UTF-8 BOM (bytes EF BB BF / U+FEFF) at the very start of the stream MUST be consumed and discarded exactly once, using non-consuming lookahead so that a non-BOM prefix is left intact; any BOM occurring later in the stream MUST be preserved as ordinary data." + }, + { + "id": "SSE-13", + "level": "MUST", + "subsystem": "sse", + "description": "Dispatch MUST be permissive relative to WHATWG: an event is emitted whenever ANY of the five tracked fields (id, event, data, comment, retry) was set in the block — so id-only, retry-only, and comment-only events are visible — while a block in which no field was set (pure blank lines) MUST be skipped and MUST NOT emit an event." + }, + { + "id": "SSE-14", + "level": "MUST", + "subsystem": "sse", + "description": "At end-of-stream, if fields have accumulated but no terminating blank line was seen, the parser MUST dispatch the pending event (any field seen) rather than discarding it; if no field was accumulated it MUST signal end (no event). A final line without a terminator at EOF is returned as content." + }, + { + "id": "SSE-15", + "level": "MUST", + "subsystem": "sse", + "description": "next() MUST return an end-of-stream sentinel (null/None/absent) exactly when the source is exhausted with no pending dispatchable fields, and MUST continue to report end on subsequent calls (no spurious events after end)." + }, + { + "id": "SSE-16", + "level": "MUST", + "subsystem": "sse", + "description": "The reader MUST be single-pass and stateful: only the 'BOM already consumed' flag persists across next() calls; the last-event-id is NOT carried forward between events — each event surfaces only the id present in its own block. A reimplementation MUST NOT maintain a WHATWG-style persistent last-event-id buffer inside the parser." + }, + { + "id": "SSE-17", + "level": "MUST", + "subsystem": "sse", + "description": "The reader MUST NOT own or close the underlying byte source; source lifecycle is the caller's responsibility. (Resource ownership is introduced only by the SseStream facade — see SSE-23.)" + }, + { + "id": "SSE-18", + "level": "MUST", + "subsystem": "sse", + "description": "A single reader instance MUST be driven from one thread at a time; the parser is not internally synchronized and offers no thread-safety for concurrent next() calls. A port MAY leave the parser non-thread-safe." + }, + { + "id": "SSE-19", + "level": "MAY", + "subsystem": "sse", + "description": "The parser MAY accept arbitrarily long lines / data values with no built-in size cap; the reference implementation imposes no maximum line or event size (a growable byte accumulator expands by doubling)." + }, + { + "id": "SSE-20", + "level": "MUST", + "subsystem": "sse", + "description": "The parsed event value MUST be immutable and hold a defensively-copied, read-only data list so that neither the caller's originally-supplied list nor later mutations can reach inside a constructed event, and any copy-with-changes operation MUST likewise copy the data list." + }, + { + "id": "SSE-21", + "level": "SHOULD", + "subsystem": "sse", + "description": "The event value SHOULD provide structural value semantics — equality and hash derived from all five fields (id, event, data, comment, retry), and a stable string form — so events can be compared and used in collections." + }, + { + "id": "SSE-22", + "level": "SHOULD", + "subsystem": "sse", + "description": "The event value SHOULD expose an 'is-empty' predicate that is true only when all five fields are unset/empty; because a comment counts as content, a comment-only (keep-alive) event SHOULD report non-empty." + }, + { + "id": "SSE-23", + "level": "MUST", + "subsystem": "sse", + "description": "The streaming facade MUST own exactly one closeable resource (typically the HTTP response/body) and MUST close it exactly once across the stream's whole life, regardless of how the stream terminates (clean end, explicit close, use-block / try-with-resources exit, partial consume, or mid-stream failure)." + }, + { + "id": "SSE-24", + "level": "MUST", + "subsystem": "sse", + "description": "When the underlying reader signals end-of-stream during iteration, the facade MUST both terminate the iterator cleanly AND release the owned resource, so a fully-consumed stream needs no explicit close." + }, + { + "id": "SSE-25", + "level": "MUST", + "subsystem": "sse", + "description": "A partial consume MUST NOT strand the resource: closing the stream (explicitly, via a use/try-with-resources block, or via cancellation) after reading only some events MUST release the owned resource." + }, + { + "id": "SSE-26", + "level": "MUST", + "subsystem": "sse", + "description": "The facade MUST be single-pass: obtaining an iterator succeeds at most once; a second attempt MUST fail loudly (e.g. an illegal-state error), because the backing reader is single-pass and stateful." + }, + { + "id": "SSE-27", + "level": "MUST", + "subsystem": "sse", + "description": "After the stream is closed, requesting an iterator (and any further pulls from an already-in-flight iterator) MUST NOT read from the torn-down resource: a fresh iterator request MUST fail loudly, and an in-flight iterator MUST observe the closed state and end cleanly on its next pull." + }, + { + "id": "SSE-28", + "level": "MUST", + "subsystem": "sse", + "description": "close() MUST be idempotent — only the first call propagates to the owned resource; subsequent calls are no-ops — and this idempotence MUST hold even after an automatic release already occurred on a terminal/failure path." + }, + { + "id": "SSE-29", + "level": "MUST", + "subsystem": "sse", + "description": "A mid-stream reader failure MUST release the owned resource BEFORE the error propagates to the consumer, so a consumer iterating without a use-block never strands the connection; if releasing the resource itself fails while an error is in flight, that release failure MUST be attached to the primary error as a suppressed/secondary throwable (not swallowed, not replacing the primary)." + }, + { + "id": "SSE-30", + "level": "MUST", + "subsystem": "sse", + "description": "A failure to release the resource on an AUTOMATIC clean-terminal path (natural end-of-stream or a done-sentinel, with no error in flight) MUST NOT be turned into a thrown result that discards already-delivered events; it MUST instead be reported out-of-band and swallowed. By contrast, a release failure during an EXPLICIT close() MUST propagate to the caller." + }, + { + "id": "SSE-31", + "level": "MUST", + "subsystem": "sse", + "description": "close() MUST be safe to invoke from a different thread than the one iterating (to cancel a long-lived stream): the closed state is guarded atomically. A close observed BETWEEN pulls ends iteration cleanly; a close that tears the resource down while a read is blocked IN-FLIGHT surfaces to the iterating thread as a read failure (an I/O error), not a clean end. Either way the resource is released exactly once." + }, + { + "id": "SSE-32", + "level": "MUST", + "subsystem": "sse", + "description": "The convenience that opens a stream over an HTTP response MUST bind the stream's lifecycle to that response's body (closing the stream closes the response and releases its connection) and MUST fail loudly if the response has no body." + }, + { + "id": "SSE-33", + "level": "MUST", + "subsystem": "sse", + "description": "The typed adapter MUST invoke the caller's mapper with (event-name, joined-data) where event-name is the event's raw 'event' field (absent/null if omitted) and joined-data is the event's data lines joined with a single '\\n' separator (empty string when the event had no data field), and MUST yield the mapper's decoded value to the consumer." + }, + { + "id": "SSE-34", + "level": "MUST", + "subsystem": "sse", + "description": "The typed adapter MUST honor the mapper's three outcomes: a value is yielded to the consumer; a Skip result silently drops the event and advances to the next (never surfacing to the consumer); a Done result ends iteration cleanly and closes the underlying stream/resource without yielding a model for the sentinel event itself." + }, + { + "id": "SSE-35", + "level": "MUST", + "subsystem": "sse", + "description": "Typed decoding MUST be lazy and per-element: the mapper (and any serialization/deserialization it performs) runs only when the consumer pulls the next element, so a partial consume decodes only the events actually taken." + }, + { + "id": "SSE-36", + "level": "MUST", + "subsystem": "sse", + "description": "A mapper that throws (decode failure or a mapped error-envelope) MUST propagate the exception to the consumer's pull, but MUST first release the underlying stream/resource; a resulting release failure MUST be attached to the mapper error as a suppressed/secondary throwable." + }, + { + "id": "SSE-37", + "level": "MUST", + "subsystem": "sse", + "description": "Core parsing and streaming MUST remain format- and API-agnostic: they MUST hold no built-in done-sentinel, no error-envelope recognition, and no serialization dependency — all such conventions MUST live only in the caller-supplied mapper seam." + }, + { + "id": "SSE-38", + "level": "MUST", + "subsystem": "sse", + "description": "Reconnection and last-event-id continuity MUST remain the caller's responsibility: the subsystem surfaces the server's retry hint and each event's raw id but MUST NOT itself auto-reconnect, MUST NOT persist a last-event-id across events (see SSE-16), and MUST NOT set any reconnect request header. A callback listener contract MAY be offered whose retry/close/error hooks default to no-ops and are not auto-driven by core." + }, + { + "id": "SSE-39", + "level": "MUST", + "subsystem": "sse", + "description": "Event delivery MUST be pull-based (demand-driven) with no eager read-ahead: the parser advances the byte source only when the consumer requests the next event, so a blocking source read is the backpressure mechanism and no unbounded internal event buffer accumulates. A reactive/async adapter MUST preserve this — polling the source at most once per unit of downstream demand." + }, + { + "id": "SSE-40", + "level": "SHOULD", + "subsystem": "sse", + "description": "Sequence/iterable convenience views over a raw source SHOULD be lazy, single-pass, and propagate read exceptions to the consumer at the offending pull; they SHOULD reuse one reader instance so per-stream state (e.g. BOM consumption) is preserved, and MUST NOT be invoked twice on the same source (a second view would resume mid-stream)." + }, + { + "id": "SSE-41", + "level": "MAY", + "subsystem": "sse", + "description": "A reactive adapter MAY choose to catch only recoverable exceptions and let unrecoverable errors (the runtime's fatal/VM error family) escape rather than routing them through the stream's error channel, and MAY leave source lifecycle to the caller (not closing the source on terminal/cancel signals)." + }, + { + "id": "SERDE-1", + "level": "MUST", + "subsystem": "serde", + "description": "A Serde MUST be a single bundle that exposes exactly one encoder (serializer) and one decoder (deserializer) for one wire format, so consumers acquire both through one reference rather than wiring separate strategies." + }, + { + "id": "SERDE-2", + "level": "MUST", + "subsystem": "serde", + "description": "A Serde MUST declare the wire media type it produces, and that media type MUST be used as the default Content-Type when a request body is created from a value plus a Serde. The media type MUST NOT be defaulted to a format-agnostic constant at the SPI level." + }, + { + "id": "SERDE-3", + "level": "MUST", + "subsystem": "serde", + "description": "When encoding into a caller-supplied stream, or decoding from a caller-supplied stream, the serializer/deserializer MUST read/write the payload fully (to EOF on the read side) but MUST NOT close or take ownership of the caller's stream; the caller retains ownership and responsibility for closing. The encode-into-buffer profile likewise touches only the target region and never assumes ownership." + }, + { + "id": "SERDE-4", + "level": "MUST", + "subsystem": "serde", + "description": "The encode-into-buffer profile MUST return the number of bytes written, MUST honor a start offset, and MUST throw a range/overflow error (distinct from the serde exception type and not chaining one) when the offset is out of range or the encoded payload does not fit in the remaining space. Bytes before the offset MUST be left untouched." + }, + { + "id": "SERDE-5", + "level": "MUST", + "subsystem": "serde", + "description": "Every decode operation MUST take an explicit runtime type witness for the target type. A decoder MUST NOT rely on erased compile-time generics to recover the target, because on an erasure-based runtime that silently yields an untyped map/list which detonates as a cast error on first field access (heap pollution)." + }, + { + "id": "SERDE-6", + "level": "MUST", + "subsystem": "serde", + "description": "Parametric decode targets (e.g. List, Map) MUST be expressible through a full-generic type carrier that preserves element types across erasure. A format-agnostic decoder that cannot resolve type arguments MUST fail loudly (serde exception) for a genuinely parametric carrier rather than silently decoding into the wrong (raw) type; a carrier wrapping a plain class MUST still decode via the raw path; only a codec that resolves generics may satisfy genuinely parametric targets." + }, + { + "id": "SERDE-7", + "level": "MUST", + "subsystem": "serde", + "description": "An ergonomic reified/inline decode helper (where the host language offers one) MUST capture the full generic type of the target and route through the generic type carrier, not forward only the raw class. A concrete target still decodes identically, but a parametric target must reify its full type." + }, + { + "id": "SERDE-8", + "level": "MUST", + "subsystem": "serde", + "description": "The generic type carrier MUST capture a concrete, fully-resolved type at construction and MUST reject construction that provides no type argument or an unresolved type variable (e.g. created inside a generic function/subclass where the argument erases to its bound), failing fast with an actionable message." + }, + { + "id": "SERDE-9", + "level": "MUST", + "subsystem": "serde", + "description": "Encode/decode failures MUST be surfaced as the SDK's stable, format-agnostic serde exception type (or a subtype). Adapters MUST catch the backing codec's own processing failures and rethrow as the serde type, MUST chain the original failure as the cause, and MUST NOT allow a backing-library exception type to escape the SPI." + }, + { + "id": "SERDE-10", + "level": "MUST", + "subsystem": "serde", + "description": "Failures on the write path MUST be reported as a serialization-specific subtype and failures on the read path as a deserialization-specific subtype, both of the common serde exception root, so callers can distinguish direction while still catching one stable base type." + }, + { + "id": "SERDE-11", + "level": "SHOULD", + "subsystem": "serde", + "description": "Serde failures SHOULD be unchecked (runtime) rather than a checked/declared exception, so callers are not forced to wrap every round-trip in try/catch." + }, + { + "id": "SERDE-12", + "level": "MUST", + "subsystem": "serde", + "description": "A genuine stream I/O error (e.g. connection reset, disk write failure) raised while reading from or writing to a caller-owned stream MUST propagate unwrapped as an I/O error, and MUST NOT be re-wrapped as a serde (codec) exception. Only malformed-input / shape-mismatch / unencodable-value failures are wrapped." + }, + { + "id": "SERDE-13", + "level": "MUST", + "subsystem": "serde", + "description": "Decoding a wire null literal into a non-null target type MUST fail with a deserialization exception naming the target type, across every decode overload (raw class, generic carrier, and any codec-native type-reference path the adapter exposes). It MUST NOT return a null that flows through the non-null result and detonates later." + }, + { + "id": "SERDE-14", + "level": "MUST", + "subsystem": "serde", + "description": "The PATCH tri-state type MUST model exactly three states — Absent (key missing), Null (explicit null), Present (carries a value) — and MUST make the illegal fourth state (a present value that is itself null) unrepresentable through the public API by bounding Present to non-null values. The type SHOULD be covariant in its value type so Absent/Null satisfy any target parameterization." + }, + { + "id": "SERDE-15", + "level": "MUST", + "subsystem": "serde", + "description": "When serializing a PATCH tri-state field within an object: Absent MUST omit the key entirely from the output; Null MUST emit the key with a wire null value; Present MUST emit the key with the encoded inner value. This is the central interop invariant of the subsystem." + }, + { + "id": "SERDE-16", + "level": "MUST", + "subsystem": "serde", + "description": "When deserializing a PATCH tri-state field: a missing key MUST decode to Absent, a present explicit-null MUST decode to Null, and a present value MUST decode to Present(value) with the inner value's declared element type preserved." + }, + { + "id": "SERDE-17", + "level": "MUST", + "subsystem": "serde", + "description": "A tri-state field with no key on the wire MUST resolve to Absent. In practice this requires the field's declared default to be Absent (and the decoder's empty-value fallback to yield Absent), because a missing key is short-circuited by the codec before the decoder's null hook runs." + }, + { + "id": "SERDE-18", + "level": "SHOULD", + "subsystem": "serde", + "description": "The tri-state type SHOULD provide construction and consumption helpers: factories for absent, explicit-null, present(non-null), and a nullable-to-(present|null) mapper that can never yield Absent; plus a three-way fold, a value-or-null accessor, and is-absent/is-null/is-present predicates." + }, + { + "id": "SERDE-19", + "level": "MUST", + "subsystem": "serde", + "description": "The default codec configuration MUST wire the tri-state PATCH semantics (omit-on-absent, null-on-null). An adapter that builds a serde around a caller-supplied codec MUST register that wiring by default, and MAY allow opting out only for a caller that has already installed equivalent wiring. Absent this wiring, Absent and Null become indistinguishable on the wire and PATCH payloads are silently corrupted." + }, + { + "id": "SERDE-20", + "level": "SHOULD", + "subsystem": "serde", + "description": "When a tri-state value is serialized with no enclosing object able to omit a key (a top-level value, or an element inside an array), the implementation SHOULD degrade gracefully: emit a wire null for both Absent and Null (there is no key to omit at top level), and for an array element emit null for Absent rather than throwing. This degradation is a documented compromise, not the primary contract." + }, + { + "id": "SERDE-21", + "level": "MUST", + "subsystem": "serde", + "description": "The default decoder configuration MUST reject cross-shape scalar coercions instead of silently reshaping them: string->integer, string->floating-point, string->boolean, empty-string->integer/floating-point/boolean, floating-point->integer (lossy narrowing), boolean->integer, integer->boolean, boolean->floating-point, and any non-string scalar->string. A rejected coercion MUST surface as a deserialization failure." + }, + { + "id": "SERDE-22", + "level": "MUST", + "subsystem": "serde", + "description": "The strict-coercion policy MUST still permit representation-preserving conversions: numeric widening of an integer wire value into a floating-point target, an empty string into a textual target, and any genuinely well-typed value binding to its matching target." + }, + { + "id": "SERDE-23", + "level": "SHOULD", + "subsystem": "serde", + "description": "The default decoder configuration SHOULD ignore unknown/unexpected fields in an incoming document rather than failing, so a server can add backward-compatible fields ahead of a client model update without breaking existing clients." + }, + { + "id": "SERDE-24", + "level": "SHOULD", + "subsystem": "serde", + "description": "The default encoder configuration SHOULD emit date/time values as ISO-8601 strings, not as numeric epoch timestamps, matching prevailing public REST API conventions; whichever form is chosen, the encoding MUST round-trip back to the same instant." + }, + { + "id": "SERDE-25", + "level": "SHOULD", + "subsystem": "serde", + "description": "A factory that builds the default codec configuration SHOULD return a fresh, independent instance on each call rather than a shared mutable singleton, because codec instances carry mutable caches that interact poorly with post-construction reconfiguration." + }, + { + "id": "SERDE-26", + "level": "MUST", + "subsystem": "serde", + "description": "When a serde is built around a caller-supplied codec instance, the SDK MUST NOT mutate the caller's instance during normal construction; it MUST operate on a private copy of the codec engine (e.g. to register the tri-state wiring). If the codec cannot be copied, the implementation MAY fall back to using the supplied instance directly, but this fallback (which does mutate) MUST be documented behavior, not silent." + }, + { + "id": "SERDE-27", + "level": "MUST", + "subsystem": "serde", + "description": "A response-decoding handler MUST stream the HTTP response body directly through the deserializer into the target value (without first materializing the whole body as a string/byte array), MUST consume and close the response on every path (success and failure), MUST surface a missing body (e.g. 204 No Content) as a serde exception whose message names the target type, and MUST surface a codec/parse failure as a serde exception with the original codec failure chained as cause while letting a genuine mid-stream I/O error propagate unwrapped." + }, + { + "id": "SERDE-28", + "level": "MUST", + "subsystem": "serde", + "description": "A status-aware decoding handler MUST decode the body only on a 2xx status. On a 4xx/5xx status it MUST throw the mapped HTTP-error exception carrying a bounded, buffered in-memory copy of the error body (so the error body remains readable after the live response is closed) instead of attempting to decode the error payload as the success type. On any other non-2xx status (1xx, or an unfollowed 3xx such as 304) it MUST close the response and raise a serde exception whose message leads with the status code and preserves conditional/redirect context (e.g. ETag / Location)." + }, + { + "id": "SERDE-29", + "level": "SHOULD", + "subsystem": "serde", + "description": "A configured serde SHOULD be safe to share across concurrent threads/tasks once its configuration is complete and no longer mutated; any per-type sub-serializer/sub-deserializer caches the implementation keeps SHOULD use non-blocking, publication-safe updates rather than coarse locks." + }, + { + "id": "SERDE-30", + "level": "MAY", + "subsystem": "serde", + "description": "The absent and explicit-null sentinels MAY provide a stable, identity-free textual representation (e.g. \"Absent\", \"Null\") so logs and assertions do not leak an identity hash." + }, + { + "id": "OBS-1", + "level": "MUST", + "subsystem": "observability", + "description": "When the requested log level is disabled on the underlying logger, obtaining a log event and calling its builder methods and terminal emit MUST allocate nothing and produce no output. The facade MUST decide enabled/disabled once, at event-creation time, and return a shared inert event for the disabled case." + }, + { + "id": "OBS-2", + "level": "MUST", + "subsystem": "observability", + "description": "The facade MUST expose exactly four severity levels — ERROR, WARNING, INFO, VERBOSE — and map them onto the backend's ERROR, WARN, INFO, and most-verbose/DEBUG levels respectively." + }, + { + "id": "OBS-3", + "level": "MUST", + "subsystem": "observability", + "description": "A field key MUST be rejected (error to the caller) when empty. A null field value MUST NOT be dropped; it MUST be emitted as the literal string 'null'." + }, + { + "id": "OBS-4", + "level": "MUST", + "subsystem": "observability", + "description": "event(name) MUST set an authoritative categorisation tag under the reserved key 'event'. An empty name MUST clear the tag rather than emit 'event='. When a non-empty tag is set, any 'event' key arriving from the global context, folded diagnostic context, or a per-event field MUST be suppressed so the emitted event carries the 'event' key exactly once." + }, + { + "id": "OBS-5", + "level": "MUST", + "subsystem": "observability", + "description": "When the same field key would be contributed by more than one source, precedence MUST be: per-event field wins over global context, and both win over folded diagnostic context. A given key MUST appear at most once in the emitted event." + }, + { + "id": "OBS-6", + "level": "MUST", + "subsystem": "observability", + "description": "Field-value rendering MUST be total (never throw) and MUST special-case: throwables to 'SimpleClassName: message'; arrays/collections/maps to a bracketed textual form; and pass numeric/boolean/char primitives through type-preserving. If a value's own string conversion throws, the facade MUST substitute a diagnostic placeholder instead of propagating." + }, + { + "id": "OBS-7", + "level": "SHOULD", + "subsystem": "observability", + "description": "A rendered field value SHOULD be truncated to a bounded maximum length (reference: 8 KiB) with a truncation marker suffix, to cap log volume from oversized values. Primitives are exempt." + }, + { + "id": "OBS-8", + "level": "MUST", + "subsystem": "observability", + "description": "A single log event MUST be emitted at most once. A second terminal emit on the same event instance MUST be a no-op, and this guard MUST be correct under concurrent invocation. Field/tag/cause accumulation is not required to be thread-safe (single-thread build), but the terminal emit MUST be safe to call from any thread." + }, + { + "id": "OBS-9", + "level": "MUST", + "subsystem": "observability", + "description": "A global key/value context configured on the logger MUST be attached to every event emitted through that logger (subject to the duplicate-key precedence in OBS-5). For hot-path efficiency the implementation SHOULD reference the caller-supplied context rather than deep-copying it per event; the context is therefore expected to be effectively immutable." + }, + { + "id": "OBS-10", + "level": "MUST", + "subsystem": "observability", + "description": "When folding thread-local diagnostic context into an event, only keys in the configured allow-list MUST be folded. The default allow-list MUST be exactly {trace.id, span.id}. A null (absent) allow-list MUST fold every present diagnostic-context key (opt-in unfiltered mode). Keys with null values MUST be skipped." + }, + { + "id": "OBS-11", + "level": "MUST", + "subsystem": "observability", + "description": "URL userinfo (the 'user:password@' component) MUST always be redacted to a fixed placeholder ('***:***@'), unconditionally and independent of any allow-list." + }, + { + "id": "OBS-12", + "level": "MUST", + "subsystem": "observability", + "description": "URL query-parameter values MUST be redacted to '***' unless the parameter name (decoded, compared case-insensitively) is in the allow-list. The default query allow-list MUST be exactly {api-version}. An empty allow-list MUST redact every value. Multi-value keys MUST be treated atomically (all values for a name kept or all redacted). Parameter names and the '=' separator MUST be preserved." + }, + { + "id": "OBS-13", + "level": "MUST", + "subsystem": "observability", + "description": "A URL fragment MUST be scrubbed under the same allow-list as query parameters: 'key=value' tokens in the fragment MUST be redacted like query values, while a plain fragment with no '=' MUST be preserved verbatim." + }, + { + "id": "OBS-14", + "level": "MUST", + "subsystem": "observability", + "description": "URL redaction MUST NOT alter scheme, host, port, or path, and MUST preserve a present-but-empty query (a trailing '?'). A '?' that appears only inside the fragment MUST NOT be treated as a query delimiter (no spurious separator inserted). A trailing '&' (empty final pair) in query or fragment MAY be dropped." + }, + { + "id": "OBS-15", + "level": "MUST", + "subsystem": "observability", + "description": "URL redaction MUST be total: on any parse/rebuild failure it MUST return a fixed sentinel ('[malformed url]') rather than throwing. Logging must never break the caller." + }, + { + "id": "OBS-16", + "level": "MUST", + "subsystem": "observability", + "description": "A URL that arrives as a header value MUST be redacted. A parseable absolute value is redacted exactly like a request URL; when the value is relative or otherwise unparseable, the redactor MUST keep the path and drop everything after it (both query and fragment), appending a fixed '?***' marker whenever the value carried a query OR a fragment — since those can carry an OAuth code, a pre-signed signature, or an implicit-flow token. A value with neither MUST be returned verbatim." + }, + { + "id": "OBS-17", + "level": "MUST", + "subsystem": "observability", + "description": "When logging header values, values of URL-valued response headers (at minimum Location and Content-Location) MUST be redacted through the URL-value redactor; other header values pass through unchanged. The redaction policy MUST be shared by the sync and async logging paths so it cannot drift." + }, + { + "id": "OBS-18", + "level": "MUST", + "subsystem": "observability", + "description": "Header logging MUST gate which header NAMES are logged by an allow-list. A header whose name is not allow-listed MUST NOT have its value logged; depending on a boolean policy it is either emitted with a fixed redaction marker ('REDACTED') or omitted entirely. The default header-name allow-list MUST contain only diagnostic, non-credential headers." + }, + { + "id": "OBS-19", + "level": "SHOULD", + "subsystem": "observability", + "description": "A transport that drops a caller-set request header it cannot encode SHOULD surface the drop with a configurable verbosity policy offering at least: log every occurrence at WARN; log the first drop per header name at WARN and subsequent drops of that name at verbose; or log only at verbose. The default SHOULD be the once-per-header-name policy to avoid flooding a hot path." + }, + { + "id": "OBS-20", + "level": "MUST", + "subsystem": "observability", + "description": "Emitting structured log events around a request MUST NOT be able to fail the request: every log-emission site (request event, response event, failure event, and the body-drain that feeds them) MUST catch any exception and re-surface it as a best-effort 'http.instrumentation.*' diagnostic, and a secondary failure while emitting that diagnostic MUST be swallowed. The runtime does NOT defensively wrap tracer (span start, scope activation, end) or metrics (counter/histogram) calls; their non-failure guarantee rests instead on the SPI contract that those callbacks never throw (OBS-30), so a throwing tracer or meter WILL propagate and can fail the request." + }, + { + "id": "OBS-21", + "level": "MUST", + "subsystem": "observability", + "description": "A Span MUST expose a recording flag; when non-recording, all mutators (attribute/error) MUST be inert and drop their data, and end() MUST be a no-op. end() (both success and error variants) MUST be idempotent — a second call, or a call on a non-recording span, MUST be a documented no-op." + }, + { + "id": "OBS-22", + "level": "MUST", + "subsystem": "observability", + "description": "Activating a span as the current span MUST return a scope handle that, when closed, restores the previously-active span. The scope MUST be closeable from a try/using construct and MUST restore even when the guarded code throws." + }, + { + "id": "OBS-23", + "level": "MUST", + "subsystem": "observability", + "description": "Activating a span for log correlation MUST push the trace id and span id onto the thread-local diagnostic context (under keys 'trace.id' and 'span.id') for the scope's lifetime, and MUST restore each key to its prior value (or remove it if previously unset) on close. For a non-recording span the push MUST be skipped and activation MUST delegate to plain current-span activation." + }, + { + "id": "OBS-24", + "level": "MUST", + "subsystem": "observability", + "description": "Thread-local diagnostic context MUST be bridgeable across async thread boundaries via an immutable snapshot: capture on the originating thread, then reinstall on the executing thread for the duration of a block and restore that thread's prior context afterward (including on exception). The snapshot itself MUST be immutable/shareable, and mutations MUST affect only the calling thread." + }, + { + "id": "OBS-25", + "level": "MUST", + "subsystem": "observability", + "description": "The tracing abstractions MUST provide allocation-free no-op defaults used whenever tracing is disabled: a no-op Tracer returning a shared no-op Span, a no-op Span whose current-scope is a cached singleton, a no-op instrumentation context whose identifiers are all invalid sentinels, and a no-op HTTP-tracer / tracer-factory. Selecting a no-op path MUST NOT allocate per call." + }, + { + "id": "OBS-26", + "level": "MUST", + "subsystem": "observability", + "description": "An instrumentation/trace context MUST expose W3C-Trace-Context-compliant identifiers: a trace id, a span id (16 lowercase hex chars), trace flags (a two-hex-char byte, e.g. the sampled bit), and a vendor trace-state list. It MUST expose validity and remoteness flags. The reserved invalid sentinels MUST be: trace id of 32 hex zeros, span id of 16 hex zeros, trace flags '00', and empty trace-state; an all-zero trace/span id MUST be treated as invalid/no-trace." + }, + { + "id": "OBS-27", + "level": "MUST", + "subsystem": "observability", + "description": "Trace-id generation MUST support at least the W3C flavour (128-bit value rendered as 32 lowercase hex chars) and a Datadog flavour (64-bit unsigned integer rendered as a decimal string), plus a no-op flavour that always yields the invalid sentinel. Generation MUST NOT produce the reserved all-zero id — a zero draw MUST be coerced to a non-zero value." + }, + { + "id": "OBS-28", + "level": "SHOULD", + "subsystem": "observability", + "description": "The SDK SHOULD provide an HTTP-shaped tracer event vocabulary richer than start/end: operation started/succeeded/failed; per-attempt started/failed(with next-delay)/retries-exhausted; and transport milestones (request URL resolved, connection acquired with host+port, request sent with byte count, response headers received with status+headers, response received with byte count). Every event method SHOULD default to a no-op so adding a new event is a non-breaking change and implementers override only what they need." + }, + { + "id": "OBS-29", + "level": "MUST", + "subsystem": "observability", + "description": "HTTP-tracer lifecycle ordering MUST hold: operationStarted fires once at the start; operationSucceeded and operationFailed are mutually exclusive and each fires exactly once at the end; attempt events may fire multiple times; retries-exhausted (when it fires) is immediately followed by operationFailed with the same throwable. One tracer instance corresponds 1:1 to a single logical operation lifecycle (created by the factory per operation). This is a documented emission contract; pipeline/transport wiring to emit it is a follow-up, so it is not yet runtime-enforced." + }, + { + "id": "OBS-30", + "level": "MUST", + "subsystem": "observability", + "description": "Tracer and HTTP-tracer callbacks MUST be safe to invoke concurrently and from transport threads other than the caller's, and implementations MUST NOT throw from any callback. The metrics instruments MUST likewise be safe to call concurrently from request threads and MUST NOT throw. The instrumentation runtime does not defensively catch these callbacks (see OBS-20), so violating must-not-throw breaks the caller's request." + }, + { + "id": "OBS-31", + "level": "MUST", + "subsystem": "observability", + "description": "The metrics SPI MUST expose a Meter that manufactures at least a monotonic integer counter and a floating-point histogram, each accepting per-measurement key/value attributes. The default Meter MUST be a no-op that discards every measurement and returns shared instrument singletons, and the core MUST NOT pull a metrics runtime into its dependencies." + }, + { + "id": "OBS-32", + "level": "SHOULD", + "subsystem": "observability", + "description": "Metric instrument names, descriptions, and units SHOULD follow OpenTelemetry semantic conventions and UCUM unit symbols. The default HTTP instrumentation SHOULD emit a request counter named 'http.client.request.count' (unit '{request}') and a latency histogram named 'http.client.request.duration' (unit 'ms'), tagged with method and either status code (success) or error type (failure)." + }, + { + "id": "OBS-33", + "level": "MUST", + "subsystem": "observability", + "description": "A monotonic counter MUST document that only non-negative increments are valid (negative deltas are undefined behaviour, the caller's responsibility), and the core instrument MUST NOT validate this on the hot path. A histogram MUST tolerate any input without throwing (the no-op discards it); handling of non-finite values (NaN/±Infinity) is delegated to concrete adapters." + }, + { + "id": "OBS-34", + "level": "MUST", + "subsystem": "observability", + "description": "HTTP logging granularity MUST be selectable across at least three levels — none, headers-only, and headers-plus-body — defaulting to none (logging off unless explicitly opted in). At none, request/response log events MUST NOT be emitted; body capture MUST occur only at the body level. Span lifecycle AND metric recording (request counter + latency histogram) run on every request independent of the log level, so 'none' silences log events without disabling tracing or metrics." + }, + { + "id": "OBS-35", + "level": "SHOULD", + "subsystem": "observability", + "description": "A log-level value SHOULD be resolvable from layered configuration (explicit override -> environment variable -> normalized system property -> default) with tolerant parsing: case-insensitive and whitespace-trimmed matching against the level names, falling back to a caller-supplied default (itself defaulting to 'none') when the key is absent, empty, or unrecognised. The SDK MUST NOT bake in a default config key name." + }, + { + "id": "OBS-36", + "level": "MUST", + "subsystem": "observability", + "description": "Under body logging, body capture MUST be bounded to a configurable preview size (reference default 8 KiB) and MUST NOT buffer the whole body: a body larger than the cap MUST still stream in full to the caller (replay captured prefix then continue from the live tail) while only the preview occupies memory. Logged body-size/preview fields therefore describe the captured preview, not necessarily the full body." + }, + { + "id": "OBS-37", + "level": "SHOULD", + "subsystem": "observability", + "description": "For unknown-length (streaming/chunked) response bodies, the async logging path SHOULD skip body capture entirely and stream the body unwrapped, so a slow or idle producer cannot block the completion thread. Reimplementations SHOULD prefer headers-only logging for large downloads, SSE, and unknown-size encodings." + }, + { + "id": "OBS-38", + "level": "SHOULD", + "subsystem": "observability", + "description": "A captured body preview SHOULD be rendered charset-aware for text (decoding with the media type's declared charset, falling back to UTF-8 when absent/unknown) and binary-safe for non-text payloads (emitting a size-only marker such as '[binary N bytes captured]' instead of decoding). Decoding MUST NOT throw — malformed/truncated input yields replacement characters rather than an error. Empty input yields an empty preview." + }, + { + "id": "OBS-39", + "level": "MUST", + "subsystem": "observability", + "description": "The set of emitted structured event names and field keys MUST be stable and predictable: at minimum request/response events named 'http.request' and 'http.response', carrying keys 'http.request.method', 'url.full' (redacted), 'http.response.status_code', 'http.response.duration_ms', and content-length/header fields; a failure emits an 'http.response' event with 'error.type' and the throwable cause attached. The logged 'url.full' MUST always be the redacted URL." + }, + { + "id": "OBS-40", + "level": "SHOULD", + "subsystem": "observability", + "description": "A once-per-logger diagnostic SHOULD warn when a caller sets a per-event field colliding with the reserved 'event' key (whose value is then dropped in favour of the event tag), throttled to at most one emission per logger and gated on the verbose level being enabled so it never costs anything on the hot path when disabled. Ambient 'event' keys from global context or diagnostic context defer silently and MUST NOT be warned about." + }, + { + "id": "CFG-1", + "level": "MUST", + "subsystem": "config", + "description": "A configuration value lookup for a key MUST resolve in strict precedence order: (1) an explicit override registered for that exact key, (2) the environment-variable source queried by the exact key name, (3) the system-property source queried by the NORMALIZED key name, (4) the caller-supplied default (which MAY be null/absent)." + }, + { + "id": "CFG-2", + "level": "MUST", + "subsystem": "config", + "description": "An environment-variable value that is present but empty MUST be treated as absent, so the lookup falls through to the system-property layer (and ultimately the default) rather than resolving to the empty string." + }, + { + "id": "CFG-3", + "level": "MUST", + "subsystem": "config", + "description": "The system-property layer MUST be queried under a normalized key derived from the lookup name by lowercasing it and replacing every underscore with a dot (e.g. MAX_RETRY_ATTEMPTS -> max.retry.attempts). The override and environment layers MUST use the original (un-normalized) name." + }, + { + "id": "CFG-4", + "level": "MUST", + "subsystem": "config", + "description": "A separate raw property accessor MUST look up the system-property source by the EXACT name given, WITHOUT the env->property normalization, so keys that live only in camelCase system-property form (e.g. https.proxyHost, http.nonProxyHosts) resolve with their casing preserved." + }, + { + "id": "CFG-5", + "level": "MUST", + "subsystem": "config", + "description": "Typed accessors MUST NOT throw on missing or unparseable values: the integer accessor returns the caller default when the resolved string is absent or not a valid integer; the duration accessor returns the default on any parse failure. Negative integers are valid values and MUST be returned as-is." + }, + { + "id": "CFG-6", + "level": "MUST", + "subsystem": "config", + "description": "The boolean accessor MUST be strict: only the case-insensitive tokens \"true\" and \"false\" are recognized. Any other value — including \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\" — MUST fall through to the caller default." + }, + { + "id": "CFG-7", + "level": "MUST", + "subsystem": "config", + "description": "The duration accessor MUST accept: ISO-8601 durations (leading 'P'/'p', e.g. PT5S, P1D); shorthand with units ms, s, m, h, d (case-insensitive); and a bare number, which MUST be interpreted as MILLISECONDS. A negative duration (ISO or shorthand) MUST be rejected (return the default), and an unknown unit MUST return the default." + }, + { + "id": "CFG-8", + "level": "MUST", + "subsystem": "config", + "description": "A built configuration MUST be immutable and safe to share across threads without external synchronization: the override map MUST be defensively copied at build time so that later builder mutation cannot alter an already-built instance." + }, + { + "id": "CFG-9", + "level": "MUST", + "subsystem": "config", + "description": "Deriving a reconfigured configuration MUST be copy-on-write: it produces a NEW instance with the mutator applied while leaving the receiver completely unchanged. The override map MUST be copied before the mutator runs (added/replaced/removed overrides never leak back to the receiver), while the environment and system-property source seams MUST be inherited by reference (shared, not copied) unless the mutator explicitly replaces them." + }, + { + "id": "CFG-10", + "level": "MUST", + "subsystem": "config", + "description": "Removing an override for a key MUST drop only the override layer: a subsequent lookup for that key MUST fall through to the environment, system-property, and default layers exactly as if the override had never existed. Removal MUST NOT force the key to resolve to null, and removing a key that has no override MUST be a harmless no-op." + }, + { + "id": "CFG-11", + "level": "MUST", + "subsystem": "config", + "description": "The environment source and the system-property source MUST be substitutable seams (injectable functions from key name to optional string), so conformance tests and applications can supply hermetic lookups without touching the real process environment. The production defaults MUST delegate to the platform environment and platform system properties respectively." + }, + { + "id": "CFG-12", + "level": "SHOULD", + "subsystem": "config", + "description": "Configuration builders SHOULD be usable single-threaded only (no internal synchronization required); the immutability guarantee applies to the built configuration, not to an in-progress builder." + }, + { + "id": "CFG-13", + "level": "SHOULD", + "subsystem": "config", + "description": "A process-wide global configuration slot SHOULD be provided with last-write-wins replacement and safe publication (a reader always observes a fully-constructed, most-recently-set instance). It MUST default to an empty configuration (no overrides, platform-backed seams)." + }, + { + "id": "CFG-14", + "level": "SHOULD", + "subsystem": "config", + "description": "The subsystem SHOULD expose stable well-known key constants for the retry-attempt cap, SDK log level, and standard proxy variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), so consumers reference canonical names rather than string literals." + }, + { + "id": "CFG-15", + "level": "MUST", + "subsystem": "config", + "description": "The time abstraction MUST be an injectable seam exposing three operations: current wall-clock instant, a monotonic elapsed-time counter, and a blocking interruptible sleep. A shared default backed by the platform clock MUST be provided. Time-dependent logic (retry backoff, credential expiry, redirect timing) SHOULD route through this seam rather than calling the platform clock directly, so tests can drive time deterministically with a fake." + }, + { + "id": "CFG-16", + "level": "MUST", + "subsystem": "config", + "description": "The monotonic counter MUST be non-decreasing and used only for measuring elapsed durations between two of its own readings; its absolute value is not meaningful. The wall-clock reading MAY move backwards (e.g. on OS clock adjustment) and MUST NOT be used for elapsed-time measurement." + }, + { + "id": "CFG-17", + "level": "MUST", + "subsystem": "config", + "description": "sleep MUST reject a negative duration with an argument error, MUST allow a zero duration (returning promptly, possibly yielding), and MUST honor cooperative cancellation/interruption: when interrupted mid-sleep it MUST re-assert the thread's interrupt/cancellation status before propagating the interruption so downstream handlers observe the cancelled state. Implementations SHOULD preserve sub-millisecond precision where the platform allows." + }, + { + "id": "CFG-18", + "level": "SHOULD", + "subsystem": "config", + "description": "The async layer SHOULD provide a scheduled non-blocking delay: an operation that yields a future completing (with an empty/void value) after a given non-negative duration elapses on a provided scheduler, WITHOUT blocking a thread. A zero delay MUST complete the future immediately; a negative delay MUST be rejected; cancelling the returned future MUST cancel the underlying scheduled task so the scheduler thread is not held." + }, + { + "id": "CFG-19", + "level": "SHOULD", + "subsystem": "config", + "description": "When surfacing the cause of a failed asynchronous operation, the subsystem SHOULD unwrap the platform's async-completion wrapper exceptions (the equivalents of a completion/execution wrapper) to expose the original underlying throwable, terminating on the first non-wrapper, a null cause, or a detected cycle. A non-wrapper throwable MUST be returned unchanged." + }, + { + "id": "CFG-20", + "level": "SHOULD", + "subsystem": "config", + "description": "The subsystem SHOULD provide an interruptible-task future: running a task on an executor such that cancelling with the 'interrupt' flag interrupts the worker thread currently running the task, while cancelling without it cancels without interrupting. A task still queued (not yet started) or already finished MUST NOT be interrupted, and the worker's interrupt/cancel state MUST be cleared before it returns to its pool so a pooled thread is never poisoned by a stale interrupt aimed at a completed call. Rejected submission (saturated/shut-down executor) MUST be delivered through the returned future, never thrown synchronously." + }, + { + "id": "CFG-21", + "level": "MUST", + "subsystem": "config", + "description": "When an interruptible-task future has already been cancelled and the task nonetheless produced a result value that is a closeable resource, that result MUST be closed on the discard path (best-effort, swallowing close failures) rather than leaked. The best-effort close helper MUST be null-safe." + }, + { + "id": "CFG-22", + "level": "MUST", + "subsystem": "config", + "description": "The proxy model MUST be immutable and carry: the proxy protocol type (HTTP, SOCKS4, SOCKS5), the socket address, an ordered list of non-proxy host glob patterns, optional username/password credentials, an optional challenge-handler slot, and an explicit bypass-all flag. Its string rendering MUST mask credentials (never emit username/password in cleartext)." + }, + { + "id": "CFG-23", + "level": "MUST", + "subsystem": "config", + "description": "The host-bypass decision MUST short-circuit to true when the bypass-all flag is set; otherwise it MUST return true iff the host matches any configured non-proxy glob pattern. Glob-to-pattern conversion MUST treat '*' as 'match any run of characters', '?' as 'match one character', escape all regex metacharacters, require a FULL-string match, and match case-insensitively (host names are case-insensitive). Patterns SHOULD be compiled once at construction, not per lookup." + }, + { + "id": "CFG-24", + "level": "MUST", + "subsystem": "config", + "description": "Resolving proxy options from configuration MUST follow this source precedence and MUST NOT throw on any malformed input (proxies are optional; invalid config yields null and a warning log): (1) the system-property layer first — host is https.proxyHost preferred over http.proxyHost, and the port MUST be taken from the SAME layer as the chosen host (https host -> https.proxyPort, http host -> http.proxyPort); credentials are read ONLY from https.proxyUser/https.proxyPassword (there is no http.* credential fallback); (2) if no system-property host is set, the environment URL HTTPS_PROXY preferred over HTTP_PROXY, parsed as scheme://user:pass@host:port. If no proxy is configured, or configuration is invalid, resolution MUST return null." + }, + { + "id": "CFG-25", + "level": "MUST", + "subsystem": "config", + "description": "The proxy port MUST be explicit and within 0..65535; a missing, non-numeric, or out-of-range port MUST cause resolution to yield null (with a warning) rather than guessing a default port. When parsing a proxy URL, an absent port MUST be treated as invalid (no defaulting to 80/443), because the request's target scheme is unrelated to the proxy's listen port." + }, + { + "id": "CFG-26", + "level": "MUST", + "subsystem": "config", + "description": "The effective non-proxy host list MUST be resolved with the system property (pipe-separated) winning over the environment variable (comma-separated). Both forms MUST honor a backslash escape preceding the literal separator (so an escaped separator becomes a literal in the emitted token) and MUST trim tokens. Fragments that are empty are dropped; the observable order is split -> drop empty (before unescape/trim) -> unescape -> trim, so a whitespace-only fragment is retained as an empty token rather than removed." + }, + { + "id": "CFG-27", + "level": "MUST", + "subsystem": "config", + "description": "When the resolved non-proxy configuration is exactly a single bare '*' wildcard, it MUST be interpreted as bypass-all: proxy resolution returns null so the caller routes directly. This bypass-all semantics MUST be represented by the explicit bypass-all flag/return, NOT by placing '*' as a literal entry in the non-proxy host list (a '*' inside a multi-entry list is a normal any-host glob, not the bypass-all sentinel)." + }, + { + "id": "CFG-28", + "level": "MAY", + "subsystem": "config", + "description": "A convenience resolver MAY read proxy options from the process-wide global configuration, but the environment MUST be consulted only when a resolver is explicitly invoked — nothing may read proxy configuration implicitly at construction/startup." + }, + { + "id": "CFG-29", + "level": "MUST", + "subsystem": "config", + "description": "RFC 1123 date FORMATTING MUST emit the canonical HTTP-date form with a zero-padded two-digit day-of-month and a literal 'GMT' zone, rendering the instant in UTC (e.g. 'Sun, 06 Nov 1994 08:49:37 GMT'). A single-digit day MUST be padded (06, not 6)." + }, + { + "id": "CFG-30", + "level": "MUST", + "subsystem": "config", + "description": "RFC 1123 date PARSING MUST be tolerant in these specific ways: month names are case-insensitive; the zone token accepts 'GMT', 'UTC', '+0000', and '+00:00' (all normalized to the zero offset); and the leading weekday token is informational only and MUST NOT be validated against the actual date (a weekday inconsistent with the date is accepted; it is stripped, not parsed). Parsing MUST return an instant." + }, + { + "id": "CFG-31", + "level": "MUST", + "subsystem": "config", + "description": "RFC 1123 parsing MUST be strict on the day-of-month-onward grammar: blank/empty input MUST fail with a parse error, and a malformed header missing the comma after the weekday MUST fail (the weekday strip only applies to the well-formed 'Xxx, ' three-letter-plus-comma prefix)." + }, + { + "id": "CFG-32", + "level": "MUST", + "subsystem": "config", + "description": "The non-blocking UUID generator MUST produce type-4 (random) UUIDs with the correct RFC 4122 layout: the version field set to 4 and the variant set to the IETF variant (high bits '10'). It MUST be usable concurrently from multiple threads without shared mutable state. It MUST use a non-blocking randomness source (per-thread PRNG), and callers MUST treat the output as NON-cryptographic (suitable for request/trace IDs, not secrets)." + }, + { + "id": "CFG-33", + "level": "MUST", + "subsystem": "config", + "description": "Deep value-equality helpers MUST compare values by content: two arrays of the same kind are compared element-by-element (object arrays recurse so nested and multi-dimensional arrays compare structurally; primitive arrays compare by element value), while non-array values fall back to ordinary equality. Both helpers MUST be null-safe (two nulls are equal; null hashes to zero), and equals and hashCode MUST be mutually consistent (content-equal values hash equal)." + }, + { + "id": "CFG-34", + "level": "MUST", + "subsystem": "config", + "description": "Deep value equality MUST follow floating-point array semantics where NaN compares EQUAL to NaN and +0.0 compares UNEQUAL to -0.0 (for both primitive and boxed float/double arrays), and hashing MUST match that equality. An object array and a primitive array with the same numeric values MUST NOT be considered equal (distinct array kinds)." + }, + { + "id": "CFG-35", + "level": "SHOULD", + "subsystem": "config", + "description": "A shared retryability classifier SHOULD exist and treat these HTTP status codes as retryable: 408, 429, and all 5xx EXCEPT 501 and 505; and SHOULD treat a throwable as retryable iff it or any throwable in its cause chain is an IO/timeout error. Cause-chain traversal MUST be cycle-safe (terminate on a self-referential chain). Where the classifier is implemented, this exact status-code set is a hard contract so exception construction and the retry policy agree." + }, + { + "id": "CFG-36", + "level": "SHOULD", + "subsystem": "config", + "description": "A static build/runtime descriptor SHOULD expose the SDK version and host runtime identity (runtime version, vendor, OS name) resolved once at load time, each falling back to a non-blank 'unknown' when unavailable, and SHOULD provide a default ordered identity-token list (SDK token then runtime token) for User-Agent-style composition. Every token MUST be non-blank so joined identity strings are never malformed." + }, + { + "id": "CFG-37", + "level": "MUST", + "subsystem": "config", + "description": "Passing a null/absent required argument to a mutating configuration operation (override key or value, source function, derive mutator, or the global-config setter) MUST fail fast with an error rather than storing a null. Optional slots that are documented as nullable (proxy credentials, challenge handler, lookup default) MAY be null." + }, + { + "id": "CFG-38", + "level": "MUST", + "subsystem": "config", + "description": "The typed accessors (integer, boolean, duration) MUST resolve the raw value through the SAME layered lookup as the string accessor (override -> environment -> normalized system property) before parsing; they MUST NOT read only the override map or skip the env/property layers. Only when the layered lookup yields no value does the caller-supplied typed default apply." + }, + { + "id": "TRANSPORT-1", + "level": "MUST", + "subsystem": "transport", + "description": "An SDK-managed (builder-constructed) transport MUST disable the native client's automatic redirect following, so the SDK pipeline's redirect step is the single redirect authority. The default of the follow-redirects knob MUST be off." + }, + { + "id": "TRANSPORT-2", + "level": "MUST", + "subsystem": "transport", + "description": "Where the native client has a built-in connection-failure/automatic retry feature, an SDK-managed transport MUST disable it, leaving the SDK pipeline as the single retry authority. (java.net.http has no such feature to disable.)" + }, + { + "id": "TRANSPORT-3", + "level": "MUST", + "subsystem": "transport", + "description": "On the synchronous send path a genuine caller-initiated cancellation MUST surface as a terminal, non-retryable interrupt-shaped IOException with the runtime's cancellation signal preserved for the caller; it MUST NOT be repackaged as the retryable transport-failure exception. Discrimination MUST be done out-of-band (the runtime's cancellation state, e.g. the JVM thread interrupt flag), not by matching exception messages." + }, + { + "id": "TRANSPORT-4", + "level": "MUST", + "subsystem": "transport", + "description": "A read/response timeout MUST be classified as a RETRYABLE transport failure (the canonical NetworkException), and MUST NOT set the caller's cancellation/interrupt flag, even when the underlying runtime represents a timeout with the same exception family as an interrupt." + }, + { + "id": "TRANSPORT-5", + "level": "MUST", + "subsystem": "transport", + "description": "When a per-call timeout override is supplied it MUST be applied to that single call, overriding the transport's configured default for that call only and leaving the shared native client's configuration untouched; a null override MUST leave the configured default in force." + }, + { + "id": "TRANSPORT-6", + "level": "SHOULD", + "subsystem": "transport", + "description": "A transport SHOULD NOT let a positive per-call timeout be silently reduced to zero (or 'no timeout') by unit truncation. Where the native timeout API expresses deadlines in a coarser unit than the requested duration AND treats zero as 'no timeout' (unbounded), a positive sub-resolution duration MUST be clamped up to the smallest finite deadline the native client can represent rather than truncated to zero, so a tight budget does not silently flip to unbounded." + }, + { + "id": "TRANSPORT-7", + "level": "MUST", + "subsystem": "transport", + "description": "Cancelling the async response future MUST propagate cancellation into the in-flight native exchange so its connection/resources are released promptly instead of running to completion unobserved." + }, + { + "id": "TRANSPORT-8", + "level": "MUST", + "subsystem": "transport", + "description": "Where the native client can surface a cancellation that originates inside it (e.g. an internal cancel-all or an interceptor-driven cancel) while the SDK future is still live, that cancellation MUST complete the future with a terminal, non-retryable cancellation-shaped exception, NOT the retryable transport-failure exception; a genuine timeout on the same path MUST still complete with the retryable type. (The reference OkHttp transport implements this discrimination; the java.net.http transport has no internal-cancel path to distinguish.)" + }, + { + "id": "TRANSPORT-9", + "level": "MUST", + "subsystem": "transport", + "description": "If a native response is delivered after the SDK future has already been completed or cancelled (the adaptation race), the adapted response MUST be closed so its connection is returned to the pool and not leaked." + }, + { + "id": "TRANSPORT-10", + "level": "MUST", + "subsystem": "transport", + "description": "The caller's explicit request Content-Type header MUST remain authoritative on the wire and MUST NOT be overwritten by any body-derived / auto-stamped media type. A body-derived Content-Type MUST be emitted only when the caller set none (matched case-insensitively)." + }, + { + "id": "TRANSPORT-11", + "level": "MUST", + "subsystem": "transport", + "description": "Headers the native client computes from the body/connection (at minimum Content-Length, Host, Transfer-Encoding; plus any the native client rejects outright, e.g. Connection/Expect/Upgrade on java.net.http) MUST be dropped from the outbound request before dispatch, because the transport owns these framing headers and a caller-set value would be rejected or produce a wire-format mismatch. The transport SHOULD additionally log each such drop at a verbose level naming the header. (The exact drop set is transport-specific: OkHttp deliberately does NOT drop Connection, since it honours a Connection: close hint.)" + }, + { + "id": "TRANSPORT-12", + "level": "MUST", + "subsystem": "transport", + "description": "A header that is valid at the SDK model layer but rejected by the native client's stricter wire grammar MUST be dropped for that header only; the resulting native exception MUST NOT escape the send contract. The rest of the headers and the body MUST still be dispatched, on both sync and async paths." + }, + { + "id": "TRANSPORT-13", + "level": "SHOULD", + "subsystem": "transport", + "description": "A transport SHOULD expose a configurable policy for how model-valid-but-transport-rejected header drops are logged, with at least three modes: log every drop loudly; log the first drop per header name loudly and the rest quietly (default); log all quietly. Where the per-name dedup mode is implemented it MUST be case-insensitive and MUST be bounded so an attacker synthesising unbounded distinct names cannot grow it without limit." + }, + { + "id": "TRANSPORT-14", + "level": "MUST", + "subsystem": "transport", + "description": "Inbound (response) headers MUST be copied leniently enough that a single malformed header does not fail the whole response: a control byte in a field VALUE, or a control/non-ASCII byte in a field NAME, MUST cause only that offending header to be dropped (logged at verbose) while the body and the remaining headers are still delivered. A transport SHOULD preserve a non-ASCII / obs-text byte in a field VALUE rather than stripping it (RFC 7230 permits obs-text in values, e.g. a Latin-1 Content-Disposition filename); the reference transports preserve it." + }, + { + "id": "TRANSPORT-15", + "level": "MUST", + "subsystem": "transport", + "description": "close() MUST be ownership-aware: it releases ONLY resources the transport itself created (native client, its dispatcher/executor, connection pool, cache, any SDK-created executor). A caller-supplied ('bring your own') native client and its resources MUST NOT be shut down or mutated by the transport, so the caller may keep using it after the transport is closed." + }, + { + "id": "TRANSPORT-16", + "level": "MUST", + "subsystem": "transport", + "description": "close() MUST be idempotent (repeat calls are safe no-ops after the first) and MUST NOT block on native shutdown in a way that discards the caller's cancellation/interrupt state; it uses non-blocking shutdown semantics (no unbounded await)." + }, + { + "id": "TRANSPORT-17", + "level": "MUST", + "subsystem": "transport", + "description": "A non-replayable (single-use) request body MUST be written to the wire exactly once; the transport MUST prevent the native client from re-writing it (e.g. by reporting the body as one-shot) and MUST NOT itself trigger a second write. A replayable body MAY be re-written." + }, + { + "id": "TRANSPORT-18", + "level": "MUST", + "subsystem": "transport", + "description": "When the native body API drives writes through a re-subscribable producer (so a native internal resend — proxy-auth 407, protocol GOAWAY replay — re-reads the body), the transport MUST make each subscription produce identical bytes. It MUST require or ensure a replayable source: a non-replayable body is buffered once into a replayable copy, and if that buffering fails mid-write the send MUST fail with the transport-failure (IOException) type rather than shipping a truncated/empty body." + }, + { + "id": "TRANSPORT-19", + "level": "SHOULD", + "subsystem": "transport", + "description": "When a streaming-body subscription is acquired but then abandoned (connect failure, cancellation before the body is sent), the transport SHOULD cancel/unblock its producer so no writer thread or file handle is stranded; the teardown MUST be idempotent (the native client may close the stream more than once)." + }, + { + "id": "TRANSPORT-20", + "level": "MUST", + "subsystem": "transport", + "description": "Any transport failure that produced no HTTP response — connection refused, DNS failure, TLS handshake failure, peer reset, connect/read timeout — MUST surface as the SDK's canonical retryable transport-failure exception, which MUST be a subtype of the platform IOException so existing catch(IOException) sites keep matching, and MUST report itself as retryable." + }, + { + "id": "TRANSPORT-21", + "level": "MUST", + "subsystem": "transport", + "description": "On the async path, a failure that occurs on the caller's thread before dispatch (request adaptation rejecting a request, a synchronous dispatch rejection, or an unexpected adapter bug) MUST be delivered through the returned future (completed exceptionally), NOT thrown synchronously to the caller. Only truly fatal runtime errors (e.g. OOM) may propagate synchronously." + }, + { + "id": "TRANSPORT-22", + "level": "MUST", + "subsystem": "transport", + "description": "If adapting a live native response into the SDK response throws at any point after the native response (and its socket) are live, the transport MUST close the native response before the throwable propagates, so the connection is not leaked. This guard MUST cover both the sync and async response paths." + }, + { + "id": "TRANSPORT-23", + "level": "MUST", + "subsystem": "transport", + "description": "The async send MUST NOT complete its future with a null response on success; a transport that has no response MUST complete the future exceptionally instead." + }, + { + "id": "TRANSPORT-24", + "level": "MUST", + "subsystem": "transport", + "description": "The response status code MUST be mapped totally: any code the server returns, including vendor/non-standard codes (e.g. nginx 499, Cloudflare 520-526/530), MUST be surfaced faithfully rather than rejected, and such a response (and its body) MUST remain readable and closeable." + }, + { + "id": "TRANSPORT-25", + "level": "MUST", + "subsystem": "transport", + "description": "The response body MUST be exposed as a lazily-read stream, not pre-buffered by the transport, and closing the SDK response MUST cascade to close the native body and release the underlying connection back to the pool. The caller owns closing the response." + }, + { + "id": "TRANSPORT-26", + "level": "MUST", + "subsystem": "transport", + "description": "A body-less request MUST be valid for any HTTP method the model permits. Where the native client rejects a null body for a method it treats as body-requiring (e.g. POST/PUT/PATCH), the transport MUST substitute a zero-length body so the request dispatches (with Content-Length: 0) instead of failing; for body-forbidden methods (GET/HEAD/TRACE/CONNECT) it MUST NOT attach a body." + }, + { + "id": "TRANSPORT-27", + "level": "SHOULD", + "subsystem": "transport", + "description": "An unparseable or absent inbound Content-Type SHOULD be downgraded to 'no media type' rather than failing the response; an absent/invalid Content-Length SHOULD map to the SDK's unknown-length sentinel (-1)." + }, + { + "id": "TRANSPORT-28", + "level": "SHOULD", + "subsystem": "transport", + "description": "A transport SHOULD stream a file-backed request body directly from the file (honoring any start position and byte count) on a zero-copy path where the native I/O stack supports it, and MUST treat a file body as replayable (re-openable) so it can be re-sent on a native/pipeline resend." + }, + { + "id": "TRANSPORT-29", + "level": "MUST", + "subsystem": "transport", + "description": "A transport instance MUST be safe for concurrent send calls from multiple threads and MUST be effectively immutable after construction; all per-request state MUST be confined to local scope or the returned response graph (no shared mutable per-call state)." + }, + { + "id": "TRANSPORT-30", + "level": "SHOULD", + "subsystem": "transport", + "description": "When the SDK's proxy configuration carries a feature the native client cannot honor, the transport SHOULD make the limitation discoverable rather than silently misbehaving, and MUST NOT leak credentials. Specifically: a custom (non-Basic) proxy challenge handler SHOULD be surfaced with a WARN and proxy auth SHOULD fall back to Basic from username/password. Proxy credentials MUST NOT be logged, and MUST NOT be answered to an origin-server (401) challenge — only to a matching proxy (407) challenge." + }, + { + "id": "ASYNC-1", + "level": "MUST", + "subsystem": "async", + "description": "The async transport contract is a single-value completion future that yields exactly one Response on success or completes with exactly one failure. On the success path the future MUST deliver a non-null Response value; an implementation that has no response MUST complete via the failure channel rather than deliver a null/empty/absent value." + }, + { + "id": "ASYNC-2", + "level": "MUST", + "subsystem": "async", + "description": "Every failure an adapter can detect while constructing the async operation MUST be delivered through the future's failure channel, never thrown synchronously from the method that promised a future. This includes request-adaptation errors and worker-pool rejection (a saturated/shut-down executor)." + }, + { + "id": "ASYNC-3", + "level": "MUST", + "subsystem": "async", + "description": "When an async operation is backed by a blocking task on a worker thread, cancellation MUST distinguish two modes: cancel-with-interrupt interrupts the worker currently executing the in-flight task, while cancel-without-interrupt cancels the logical operation without interrupting the worker (a blocking call that ignores interrupts then runs to completion in the background). A task still queued (not yet started) or already finished MUST NOT be interrupted." + }, + { + "id": "ASYNC-4", + "level": "MUST", + "subsystem": "async", + "description": "Interrupt delivery MUST be ordered so a stale interrupt cannot poison a pooled thread: the cancel path publishes an 'interrupt in flight' marker BEFORE reading/targeting the worker thread, the worker's return-to-pool step blocks until that marker clears, and after the task ends the worker clears its own interrupt flag before it is reused. A worker that has already returned to its pool MUST NOT receive an interrupt aimed at a completed call." + }, + { + "id": "ASYNC-5", + "level": "MUST", + "subsystem": "async", + "description": "If a worker computes a closeable result (e.g. a Response holding an open body or pooled connection) but the future was already terminated (cancelled or otherwise completed) so the value can never be delivered to a caller, the adapter MUST close that orphaned value exactly once. Whoever loses the produce/terminate race performs the close." + }, + { + "id": "ASYNC-6", + "level": "MUST", + "subsystem": "async", + "description": "Cancellation MUST propagate bidirectionally across each ecosystem adapter: cancelling the runtime-native primitive (reactive subscription, event-loop promise, coroutine/job) MUST cancel the underlying canonical future, and cancelling the canonical future (via the sync-bridge or transport hook) MUST reach the runtime primitive / native transport call." + }, + { + "id": "ASYNC-7", + "level": "SHOULD", + "subsystem": "async", + "description": "Each adapter chooses whether its native cancellation maps to interrupt-mode or non-interrupt-mode cancellation, and that choice determines whether an in-flight blocking call is actually aborted. A port SHOULD preserve, and document per adapter, whether cancelling through a given runtime aborts a blocking transport or lets it run to completion, so cross-runtime cancellation behavior is predictable." + }, + { + "id": "ASYNC-8", + "level": "SHOULD", + "subsystem": "async", + "description": "Adapters that move work or callbacks onto a thread other than the caller's SHOULD propagate the caller's diagnostic logging context (trace/span identifiers) across the hop — capturing it on the boundary thread and reinstating it on the executing/callback thread — so log events emitted after the hop retain correlation with the originating call. This is an observability guarantee, not a functional one: an adapter that omits it still executes exchanges correctly." + }, + { + "id": "ASYNC-9", + "level": "MUST", + "subsystem": "async", + "description": "When an adapter reinstates a captured logging context on an executing or callback thread, it MUST first save that thread's prior context, install the captured context only for the duration of the work, and restore the prior context afterward — including when the work throws — so a reused/pooled thread's own logging context is never clobbered." + }, + { + "id": "ASYNC-10", + "level": "MUST", + "subsystem": "async", + "description": "When an adapter propagates logging context, capture MUST occur at the point that identifies the logical caller of the execution — per-subscription for cold/reusable stream or promise objects, and per-task-submission for executor decorators — not at object-construction/assembly time. A reused async object MUST pick up the live context of each use rather than a stale snapshot from when it was assembled." + }, + { + "id": "ASYNC-11", + "level": "MUST", + "subsystem": "async", + "description": "When an adapter propagates logging context, capture and restore MUST be safe when no logging-context backend is installed: an absent context captures as empty, and reinstating an empty context clears the target thread's context rather than raising an error." + }, + { + "id": "ASYNC-12", + "level": "MUST", + "subsystem": "async", + "description": "On runtimes where a newly created worker does not inherit the spawning thread's logging context (e.g. lightweight threads or plain thread-local contexts), an adapter that propagates logging context MUST explicitly transfer it at the thread-creation boundary. This obligation is distinct from, and additional to, any guarantee the runtime provides for preserving a thread's own context across carrier hops." + }, + { + "id": "ASYNC-13", + "level": "MUST", + "subsystem": "async", + "description": "When surfacing a failure to callers, adapters MUST unwrap the async framework's wrapper exceptions (the wrappers a completion/execution framework interposes around the real cause) down to the original cause, so typed failure handlers match the real exception rather than a wrapper. Unwrapping MUST terminate on the first non-wrapper cause, a null cause, or a detected cycle." + }, + { + "id": "ASYNC-14", + "level": "MUST", + "subsystem": "async", + "description": "An async→sync blocking bridge MUST honor thread interruption while awaiting: on interruption it MUST restore the interrupt flag, cancel the in-flight future, and throw an interrupted-I/O failure; and it MUST unwrap execution-wrapper exceptions so blocking callers see the original failure. A future cancelled independently MUST surface its cancellation as-is (not remapped to an I/O error)." + }, + { + "id": "ASYNC-15", + "level": "MUST", + "subsystem": "async", + "description": "An adapter that owns an executor or background threads MUST expose a close/dispose operation that is (a) idempotent — repeated calls are safe and only the first performs shutdown/side-effects; (b) ownership-aware — it releases only SDK-owned resources and never shuts down a caller-supplied executor/client; and (c) interrupt-safe — it honors thread interruption on any blocking shutdown step." + }, + { + "id": "ASYNC-16", + "level": "SHOULD", + "subsystem": "async", + "description": "An adapter that owns an executor SHOULD shut it down gracefully on close — stop accepting new work and wait for in-flight tasks to finish rather than interrupting them — escalating to a forceful shutdown only if the closing thread is itself interrupted. Callers needing eager abort of an in-flight blocking call use the interrupt / structured-cancellation path instead." + }, + { + "id": "ASYNC-17", + "level": "SHOULD", + "subsystem": "async", + "description": "The async transport SPI SHOULD provide a no-op default close so lightweight/functional (single-abstract-method) implementations need not implement lifecycle management, while any implementation that owns resources overrides it to follow the idempotent/ownership-aware/interrupt-safe contract (ASYNC-15). Behavior of executeAsync after close is undefined." + }, + { + "id": "ASYNC-18", + "level": "MUST", + "subsystem": "async", + "description": "The non-blocking scheduled-delay primitive used to insert async delays into a future chain MUST complete after the requested delay without blocking a thread, MUST complete immediately for a zero delay, MUST reject a negative delay, and cancelling the returned future MUST cancel the underlying scheduled task so no scheduler thread is held." + }, + { + "id": "ASYNC-19", + "level": "MUST", + "subsystem": "async", + "description": "Every bridge and facade overload that accepts per-call request options MUST thread those options into the wrapped send so per-request overrides (timeout, retry budget, tags) survive the async boundary, rather than being dropped by the SPI's options-ignoring default overload." + }, + { + "id": "ASYNC-20", + "level": "MUST", + "subsystem": "async", + "description": "Once a Response has been delivered to the caller through the future, cancelling that future MUST NOT close the Response body. The caller owns closing the Response even when discarding it. (This is distinct from ASYNC-5: the orphan-close obligation applies only to a value the future never delivered.)" + }, + { + "id": "ASYNC-21", + "level": "MUST", + "subsystem": "async", + "description": "An adapter exposing a streaming source (SSE) as a reactive stream MUST honor downstream backpressure by polling the source at most once per unit of demand (never eagerly), MUST complete the stream on end-of-source, MUST propagate a source exception as an error signal while NOT swallowing fatal errors, MUST NOT close the caller-owned source on any termination (complete/error/cancel), and MUST treat the underlying source as single-subscriber (a fresh source per subscription)." + }, + { + "id": "ASYNC-22", + "level": "MUST", + "subsystem": "async", + "description": "Async transport implementations MUST be safe for concurrent calls from multiple threads, with all per-call mutable state confined to the returned future's own completion graph (no shared mutable per-call state on the client)." + }, + { + "id": "XCUT-1", + "level": "MUST", + "subsystem": "xcut", + "description": "A thread/task cancellation MUST be surfaced as a distinct, terminal, NON-retryable signal, kept separate from a timeout. On the reference (JVM) runtime the pattern is: catch the interrupt, re-assert the runtime's interrupt/cancellation state, then throw the I/O-family cancellation type (InterruptedIOException). A port MUST preserve the portable intent: propagate cancellation without losing the ambient cancellation flag, and never let a cancelled operation be automatically retried." + }, + { + "id": "XCUT-2", + "level": "MUST", + "subsystem": "xcut", + "description": "A read/response/connect TIMEOUT (a deadline elapsing) MUST be classified as a RETRYABLE transport failure, and MUST NOT set the cancellation flag. Timeout and cancellation MUST be told apart by the ambient cancellation state, not by matching an error message string, even when the underlying runtime represents both with the same exception type (and even when the timeout type is a SUBTYPE of the cancellation type — the timeout branch must be checked first to stay reachable)." + }, + { + "id": "XCUT-3", + "level": "MUST", + "subsystem": "xcut", + "description": "Inter-attempt waits (retry backoff, timed sleeps) MUST be promptly cancellable: a pending wait MUST abort near-immediately when the operation is cancelled/interrupted, surface the cancellation signal (not a spurious timeout), and cancel any timer/future it armed. The reference implements the wait as a scheduled timer completing an awaitable future rather than a raw blocking sleep, so a virtual-thread carrier can unmount and no shared pool thread is monopolized for the delay; a port SHOULD preserve that non-pinning property where its runtime has an equivalent concern, but the normative requirement is prompt cancellation, not the specific mechanism." + }, + { + "id": "XCUT-4", + "level": "MUST", + "subsystem": "xcut", + "description": "The error taxonomy MUST have exactly two top-level branches: (a) protocol errors that carry a fully-received response (status, headers, body) and are raised as an unchecked/runtime error so callers are not forced to declare I/O failure for protocol outcomes; and (b) transport errors that carry NO response and belong to the runtime's I/O-error family so existing I/O catch sites keep matching. A transport error MUST report itself as always-retryable at the error level (the SDK treats 'no response reached the server' as transient)." + }, + { + "id": "XCUT-5", + "level": "MUST", + "subsystem": "xcut", + "description": "The baked retryability flag of a protocol (status-carrying) error MUST be computed ONCE at construction from a SINGLE shared status classifier, never hardcoded per status subclass. That classifier MUST treat 408, 429, and all 5xx EXCEPT 501 and 505 as retryable, and every other status as not retryable. The stored flag MUST always agree with the live classifier for that code. NOTE: this baked flag is a queryable property of the error; the retry step's actual eligibility gate for a protocol error is the configured retryable-status set (see XCUT-7), which by default is a subset of this classifier." + }, + { + "id": "XCUT-6", + "level": "MUST", + "subsystem": "xcut", + "description": "A transport-family or custom error type that declares itself retryable via the retryability capability MUST be able to participate in retry decisions without editing the retry classifier — for such errors the classifier queries the capability (is-Retryable and the flag), not a concrete-type match. This open-capability path governs non-protocol errors; a protocol (status-carrying) error is handled by the separate rule in XCUT-7 and its baked flag is NOT what the retry step consults." + }, + { + "id": "XCUT-7", + "level": "MUST", + "subsystem": "xcut", + "description": "For a protocol (status-carrying) error, retry eligibility MUST be decided by a CONFIGURABLE retryable-status set, which is authoritative: the retry step consults this set (not the error's baked retryability flag), and the set MAY widen the built-in classification (retry a status the built-in classifier does not mark retryable) or narrow it. The default set is {408, 429, 500, 502, 503, 504}. The same set MUST also govern whether a freshly re-sent error-status response is re-classified as a failure for the next attempt." + }, + { + "id": "XCUT-8", + "level": "MUST", + "subsystem": "xcut", + "description": "The status-to-exception mapping factory MUST reject being asked to map a non-error status (1xx/2xx/3xx) — it MUST raise an argument error rather than fabricate a 'successful exception'. A convenience form MAY instead return an absent/null value for non-error statuses." + }, + { + "id": "XCUT-9", + "level": "MUST", + "subsystem": "xcut", + "description": "Any classification that walks an error's cause chain MUST be cycle-safe: it MUST track visited causes by reference identity and terminate on a self-referential or cyclic chain instead of looping forever." + }, + { + "id": "XCUT-10", + "level": "MUST", + "subsystem": "xcut", + "description": "Retry-SAFETY MUST be decided at the retry step, independently of retryability, and applied UNIFORMLY to both protocol and transport failures: (a) a request WITHOUT a body is retry-safe only if its method is in the configured idempotent-method set — a body-less non-idempotent request such as a bare POST MUST NOT be retried, EVEN when the failure is a transport error that never reached the server; (b) a request WITH a body is retry-safe only if that body is replayable — a single-use/streaming body MUST NOT be re-sent. The safety gate MUST NOT special-case transport errors: the method-idempotency and body-replayability checks apply to every retry candidate." + }, + { + "id": "XCUT-11", + "level": "MUST", + "subsystem": "xcut", + "description": "Components documented as shared/reusable across concurrent requests (pipeline steps, auth handlers, redactors, factories) MUST be safe for concurrent invocation. Per-call mutable state (attempt counters, deadlines, seen-URI sets) MUST live on the call's stack/local state, not on the shared instance, and any shared mutable state MUST be synchronized." + }, + { + "id": "XCUT-12", + "level": "SHOULD", + "subsystem": "xcut", + "description": "Hot-path reads of a credential/token cache SHOULD be wait-free (e.g. a volatile-published read that takes no lock while the cached value is still valid). Refresh SHOULD be single-flight — only one concurrent caller fetches an expiring token while the others reuse the result. Any lock guarding the refresh MUST be scoped to that cache (per-credential/per-step) so it never serializes UNRELATED in-flight requests or the global scheduler; holding that scoped lock across the (possibly blocking) token fetch to enforce single-flight is acceptable and intended. The concurrency primitive is non-normative (the reference uses a reentrant lock rather than an intrinsic monitor to avoid pinning carrier threads under virtual threads)." + }, + { + "id": "XCUT-13", + "level": "MUST", + "subsystem": "xcut", + "description": "close()/shutdown() MUST be idempotent (latched so repeat calls are no-ops) and MUST NOT block on interrupt-sensitive waits — it uses non-blocking shutdown semantics and preserves the ambient interrupt/cancel flag as-is." + }, + { + "id": "XCUT-14", + "level": "MUST", + "subsystem": "xcut", + "description": "Every process/instance-lived map whose key space is influenced by callers or remote servers (context registries, per-nonce counters, and any similar cache) MUST be bounded by a hard cap and MUST drain back under the cap after each insert using a loop (not a single pre-insert check-then-evict), so a concurrent insert burst converges to the bound instead of overshooting permanently. Arbitrary-victim eviction is acceptable; the cap is a memory backstop and MUST NOT be relied on as the primary cleanup mechanism." + }, + { + "id": "XCUT-15", + "level": "MUST", + "subsystem": "xcut", + "description": "Public wire models (request, response, headers, media type, status, etc.) MUST be immutable after construction and safe to share across threads. Mutation MUST be expressed as producing a new instance (copy/builder/newBuilder). A model MUST NOT retain an alias to externally-mutable state that a post-construction mutation could use to alter it — either defensively copy an ingested mutable collection or hold an immutable collection type." + }, + { + "id": "XCUT-16", + "level": "MUST", + "subsystem": "xcut", + "description": "A credential MUST NOT be stamped onto a request over a non-secure (non-HTTPS) transport. The auth layer MUST reject (fail loudly) before any token fetch or header write when it is about to attach a credential and the scheme is not https (case-insensitive). The guard applies ONLY on the credential-attaching path — a deliberately credential-free re-issue (e.g. a marker-suppressed cross-origin redirect) MAY proceed over any scheme." + }, + { + "id": "XCUT-17", + "level": "MUST", + "subsystem": "xcut", + "description": "Redirect handling MUST enforce credential hygiene: (a) strip the Authorization header before EVERY redirect re-issue (even same-origin — re-stamping a known origin is the auth layer's job); (b) on a CROSS-ORIGIN redirect (judged against the ORIGINAL seed origin, not the previous hop) additionally strip origin-scoped credentials (Cookie, Proxy-Authorization) and ensure the caller's credential is NOT re-applied to the foreign host; (c) drop any userinfo (user:pass@) carried in the Location before re-issue; (d) reject an HTTPS-to-HTTP scheme downgrade by default, permitting it only via explicit opt-in (and logging the deviation)." + }, + { + "id": "XCUT-18", + "level": "MUST", + "subsystem": "xcut", + "description": "Header names and outbound header values MUST be validated at the transport-agnostic model layer BEFORE reaching any transport. Names MUST reject all C0 control bytes (0x00-0x1F, including CR, LF, NUL, and HTAB) and DEL (0x7F). Outbound values MUST reject the same set EXCEPT horizontal tab (0x09), which is permitted. Both names and outbound values MUST reject non-ASCII bytes (>= 0x80). Inbound (response) header values MAY be validated leniently — non-ASCII/obs-text permitted — but MUST still reject control bytes (except HTAB) as defense-in-depth." + }, + { + "id": "XCUT-19", + "level": "MUST", + "subsystem": "xcut", + "description": "Logging/telemetry MUST redact secrets by default: (a) URL userinfo MUST ALWAYS be redacted (never allow-listed); (b) URL query-parameter values and key=value fragment tokens MUST be redacted unless the parameter name (case-insensitive) is in an explicit allow-list; (c) header logging MUST be default-deny — only an explicit allow-list of non-credential headers is emitted verbatim, all others are emitted as a redaction placeholder or omitted; (d) credential objects MUST NOT reveal their secret in string/serialized form; (e) full request/response BODY logging MUST be OFF by default." + }, + { + "id": "XCUT-20", + "level": "MUST", + "subsystem": "xcut", + "description": "Observability code paths (redaction, structured event emission, span/metric recording) MUST NEVER throw into the caller's request path. A failure to redact/render/emit MUST degrade gracefully (e.g. substitute a safe placeholder such as a malformed-URL marker, or emit a self-describing instrumentation-error event) and let the request proceed." + }, + { + "id": "XCUT-21", + "level": "MUST", + "subsystem": "xcut", + "description": "Any security-relevant random value (auth client nonces / cnonce, and similar unpredictability-dependent tokens) MUST be drawn from a cryptographically-strong PRNG with sufficient entropy (the reference uses >= 128 bits for the Digest cnonce), never a non-cryptographic RNG." + }, + { + "id": "XCUT-22", + "level": "MUST", + "subsystem": "xcut", + "description": "The SDK MUST close only resources it created. A caller-supplied (bring-your-own) transport client, executor, or connection pool MUST NOT be closed/shut down by the SDK; the caller owns its lifecycle and may keep using it after the SDK component is closed. Only SDK-constructed resources are released on close." + }, + { + "id": "XCUT-23", + "level": "MUST", + "subsystem": "xcut", + "description": "A pluggable single-implementation seam (the I/O provider, and any similar SPI) MUST resolve deterministically: an explicit install ALWAYS wins; otherwise the implementation is auto-discovered from the environment/classpath; and the presence of ZERO or MULTIPLE candidates with no explicit selection MUST fail loudly with an actionable error rather than silently pick one or no-op. Reads of the resolved provider MUST be safe under concurrency." + }, + { + "id": "XCUT-24", + "level": "SHOULD", + "subsystem": "xcut", + "description": "Diagnostic/preview reads of caller- or server-controlled payloads (error-body snapshots, request/response body log previews) MUST be byte-capped and SHOULD be non-consuming — a preview MUST NOT materialize an unbounded payload into memory and MUST NOT disturb the primary read path the consumer will use (e.g. by peeking rather than draining, or by capping the tap while the full body streams through)." + }, + { + "id": "NFR-1", + "level": "MUST", + "subsystem": "nfr", + "description": "The core module MUST depend only on the language standard library, the language runtime, and a compile-time-only logging facade abstraction. It MUST NOT carry a runtime dependency on any concrete HTTP transport, serialization library, I/O implementation, or async framework. All such concrete capabilities are supplied by separate adapter units that depend on the core, never the reverse (dependency inversion)." + }, + { + "id": "NFR-2", + "level": "SHOULD", + "subsystem": "nfr", + "description": "Each optional capability (each transport, serialization format, I/O backend, and async-runtime bridge) SHOULD be a separately installable unit that depends on the core plus at most one third-party library, so a consumer composes only the units it uses and incurs no cost for unused ones." + }, + { + "id": "NFR-3", + "level": "SHOULD", + "subsystem": "nfr", + "description": "The public API surface SHOULD be explicit and minimal: every exported declaration is deliberately marked public with a declared type, implementation details are kept non-exported, and each adapter unit keeps its public surface as small as its capability allows — a single entry point where the capability permits (the I/O, coroutine, and Netty adapters each export exactly one public type), and a small, cohesive cluster of public types where it genuinely needs more (the serde adapter exports its Serde implementation plus an object-mapper factory, response handlers, and a Tristate module). Nothing internal leaks into the public surface by default." + }, + { + "id": "NFR-4", + "level": "SHOULD", + "subsystem": "nfr", + "description": "The public API of every published unit SHOULD be captured in a checked-in, machine-comparable snapshot, and the build SHOULD fail on any drift between the current surface and that snapshot. An intentional API change is landed by regenerating and committing the snapshot in the same change; the regeneration tool MUST NOT be used to silence an unintentional break. Unpublished/sample/test-only units are excluded from snapshotting." + }, + { + "id": "NFR-5", + "level": "SHOULD", + "subsystem": "nfr", + "description": "The build SHOULD enforce a minimum aggregate line-coverage floor (currently 80%) computed across the library units, and this verification SHOULD be wired into the default build lifecycle so an ordinary build enforces it. Sample/runnable-example code, test-only build guards, and test fixtures are excluded from the aggregate so they neither inflate nor deflate the measured floor." + }, + { + "id": "NFR-6", + "level": "SHOULD", + "subsystem": "nfr", + "description": "Compiler warnings SHOULD be treated as errors across every unit, including deprecation warnings, so that no warning can accumulate silently." + }, + { + "id": "NFR-7", + "level": "SHOULD", + "subsystem": "nfr", + "description": "The build SHOULD run automated style/lint and static-analysis checks with findings treated as fatal (a nonzero issue budget fails the build). Where a specific analyzer cannot run on a given unit's toolchain, disabling it SHOULD be a narrowly-scoped, documented exception carrying explicit re-enable conditions, not a silent global relaxation." + }, + { + "id": "NFR-8", + "level": "MUST", + "subsystem": "nfr", + "description": "In target ecosystems that support whole-program dead-code elimination / tree-shaking / minification, the SDK MUST ship the keep/retain configuration a downstream shrinker needs so its reflectively-reached and runtime-wired surface survives shrinking. That configuration MUST cover the runtime-wired SPI seams (I/O provider, transport clients, serde) and the immutable models and reflectively-bound types (request/response models, the Tristate type, and the reflective metadata that serializers read). In ecosystems without such a build step this requirement does not apply." + }, + { + "id": "NFR-9", + "level": "SHOULD", + "subsystem": "nfr", + "description": "The shipped shrinker keep-configuration SHOULD be guarded by an automated regression check, wired into the default build, that shrinks a real consumer of the SDK using only the shipped rules and runs it end-to-end against a live round-trip, failing the build if any runtime-wired or reflectively-reached surface is stripped. The guard SHOULD also assert that every shipped rule file is present so a dropped rule fails loudly rather than silently reducing protection." + }, + { + "id": "NFR-10", + "level": "MUST", + "subsystem": "nfr", + "description": "The SDK MUST declare a lowest-supported-runtime floor and target it for all general-purpose units. A capability that genuinely requires a newer runtime MUST be isolated into its own unit that declares the higher floor explicitly; that unit MUST NOT be a hard dependency of the general-purpose core. No produced artifact may reference runtime/stdlib APIs that are absent on the floor it declares — the emitted-artifact target and the visible-API level must agree, not just the compiler toolchain used." + }, + { + "id": "NFR-11", + "level": "SHOULD", + "subsystem": "nfr", + "description": "The core SHOULD be concurrency-model agnostic: it exposes plain blocking operations that are correct on any scheduler and leaks no async-framework types (coroutines, reactive publishers) into the core public surface. Shared mutable state is guarded for safe concurrent access, and the synchronization primitives used SHOULD avoid pinning or blocking lightweight scheduler threads (e.g. virtual/green threads, cooperative async runtimes) where the target runtime distinguishes them." + }, + { + "id": "NFR-12", + "level": "SHOULD", + "subsystem": "nfr", + "description": "Build artifacts SHOULD be reproducible: identical source inputs SHOULD yield byte-for-byte identical output artifacts (e.g. normalized/stripped embedded timestamps and deterministic entry ordering)." + }, + { + "id": "NFR-13", + "level": "SHOULD", + "subsystem": "nfr", + "description": "Every source file SHOULD carry the project's license/SPDX header block." + }, + { + "id": "NFR-14", + "level": "SHOULD", + "subsystem": "nfr", + "description": "Dependency versions, plugin/tool versions, and project coordinates SHOULD live in a single source of truth rather than being restated per unit, so a version, tool, or coordinate bump is ideally a one-line edit that applies uniformly." + }, + { + "id": "NFR-15", + "level": "SHOULD", + "subsystem": "nfr", + "description": "Published artifacts SHOULD embed self-identifying version metadata that the SDK can resolve at runtime, so runtime-emitted identifiers (e.g. a User-Agent) report the real version rather than an 'unknown' placeholder fallback." + }, + { + "id": "NFR-16", + "level": "SHOULD", + "subsystem": "nfr", + "description": "Published artifacts SHOULD be cryptographically signed for provenance, with signing enforced on the release/CI path and made gracefully optional in local builds that lack signing keys, so consumers can verify authenticity without blocking day-to-day development." + }, + { + "id": "NFR-17", + "level": "MUST", + "subsystem": "nfr", + "description": "The quality gates that back the requirements above (compatibility snapshot, coverage floor, warnings-as-errors, lint/static-analysis, shrink-survival where applicable, and runtime-floor checks) MUST be enforced automatically and be blocking — failing the standard build/CI rather than being advisory reports a human can ignore." + } + ] +} diff --git a/tools/smoke_wheel_import.py b/tools/smoke_wheel_import.py new file mode 100644 index 0000000..2388dd0 --- /dev/null +++ b/tools/smoke_wheel_import.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Smoke-test built wheels for namespace merging and ``py.typed`` shipping. + +Editable installs (``uv sync``) and built wheels resolve PEP 420 namespace +packages through different machinery, so a layout that works in the workspace +venv can still break once the wheels are installed side by side. This script +is meant to run inside a *fresh* venv that has only the built ``dexpace-sdk-core`` +and one adapter wheel installed -- never the editable workspace venv. + +It asserts that: + +* ``dexpace.sdk.core`` and the adapter import together in one interpreter, + proving the shared ``dexpace.sdk`` namespace merges across two wheels + (NFR-10); +* the two distributions resolve to *different* installed locations (a real + namespace merge, not one wheel shadowing the other); and +* each package ships its ``py.typed`` marker (PEP 561). + +Run it, after installing the wheels, as: + + python tools/smoke_wheel_import.py + +Exit code is ``0`` on success and ``1`` on the first failed assertion. Only the +standard library is imported here so the script runs in a minimal venv. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType + +__all__ = ["ADAPTER_MODULE", "CORE_MODULE", "main", "smoke_check"] + +CORE_MODULE = "dexpace.sdk.core" +ADAPTER_MODULE = "dexpace.sdk.http.stdlib" + + +def _package_dir(module: ModuleType) -> Path: + """Return the on-disk directory backing an imported package. + + Args: + module: An imported package module. + + Returns: + The directory that contains the package's ``__init__.py``. + + Raises: + RuntimeError: If the module has no locatable file on disk. + """ + origin = getattr(module, "__file__", None) + if origin is None: + raise RuntimeError(f"{module.__name__} has no __file__; cannot locate it on disk") + return Path(origin).resolve().parent + + +def smoke_check() -> list[str]: + """Import core plus the adapter and verify packaging invariants. + + Returns: + A list of human-readable failure messages. Empty when every check + passes. + """ + failures: list[str] = [] + + core = importlib.import_module(CORE_MODULE) + adapter = importlib.import_module(ADAPTER_MODULE) + + # Importing a real public symbol from each proves the modules load, not + # just that an empty namespace directory happens to resolve. + importlib.import_module(f"{ADAPTER_MODULE}.urllib_http_client") + + core_dir = _package_dir(core) + adapter_dir = _package_dir(adapter) + + if core_dir == adapter_dir: + failures.append( + f"expected core and adapter in different locations, both resolved to {core_dir}" + ) + + for label, module, package_dir in ( + ("core", core, core_dir), + ("adapter", adapter, adapter_dir), + ): + marker = package_dir / "py.typed" + if not marker.is_file(): + failures.append(f"{label} ({module.__name__}) is missing py.typed at {marker}") + + return failures + + +def main() -> int: + """Run the wheel smoke check and return a process exit code. + + Returns: + ``0`` when all checks pass, ``1`` otherwise. + """ + failures = smoke_check() + if not failures: + print(f"wheel smoke check: OK ({CORE_MODULE} + {ADAPTER_MODULE} import cleanly)") + return 0 + print("wheel smoke check: FAILED") + for message in failures: + print(f" {message}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/surface_baseline.json b/tools/surface_baseline.json index 64f4e5a..ece52ed 100644 --- a/tools/surface_baseline.json +++ b/tools/surface_baseline.json @@ -1,6 +1,27 @@ { "dexpace-sdk-core": { "definitions": { + "dexpace.sdk.core.client._async_to_sync": { + "AsyncToSyncHttpClient": { + "bases": [ + "HttpClient" + ], + "methods": { + "close": "close(self) -> None", + "execute": "execute(self, request: Request) -> Response" + } + } + }, + "dexpace.sdk.core.client._sync_to_async": { + "SyncToAsyncHttpClient": { + "bases": [ + "AsyncHttpClient" + ], + "methods": { + "execute": "async execute(self, request: Request) -> AsyncResponse" + } + } + }, "dexpace.sdk.core.client.async_http_client": { "AsyncHttpClient": { "bases": [ @@ -22,6 +43,156 @@ } } }, + "dexpace.sdk.core.codegen._api_version_policy": { + "ApiVersionPolicy": { + "bases": [ + "Policy" + ], + "methods": { + "send": "send(self, request: Request, ctx: PipelineContext) -> Response" + } + } + }, + "dexpace.sdk.core.codegen._async_api_version_policy": { + "AsyncApiVersionPolicy": { + "bases": [ + "AsyncPolicy" + ], + "methods": { + "send": "async send(self, request: Request, ctx: PipelineContext) -> AsyncResponse" + } + } + }, + "dexpace.sdk.core.codegen._pure.options": { + "plan_options": "plan_options(auth: object | None, call_options: Mapping[str, Any]) -> Mapping[str, Any]" + }, + "dexpace.sdk.core.codegen._pure.requests": { + "plan_request": "plan_request(op: Operation, input: OperationInput, base_url: str | Url, serde: Serde | None) -> Request" + }, + "dexpace.sdk.core.codegen._pure.results": { + "plan_result": "plan_result(handler: H | None, default_handler: H, response_type: _Witness | None, default_witness: _Witness) -> tuple[H, _Witness] | None" + }, + "dexpace.sdk.core.codegen.assembly": { + "assemble": "assemble(op: Operation, input: OperationInput, base_url: str | Url, *, serde: Serde | None) -> Request" + }, + "dexpace.sdk.core.codegen.async_merge_config": { + "merge_async_config": "merge_async_config(defaults: ServiceDefaults, user: ServiceDefaults | None, *, transport: AsyncHttpClient | None, pipeline: AsyncPipeline | None, redirect: AsyncRedirectPolicy | None, idempotency: AsyncIdempotencyPolicy | None, retry: AsyncRetryPolicy | None, set_date: AsyncSetDatePolicy | None, auth: AsyncPolicy | None, logging: AsyncLoggingPolicy | None, tracing: AsyncTracingPolicy | None) -> dict[str, Any]" + }, + "dexpace.sdk.core.codegen.async_response_handler": { + "AsyncDecodeSuccess": { + "bases": [], + "methods": {} + }, + "AsyncResponseHandler": { + "bases": [ + "Protocol" + ], + "methods": {} + } + }, + "dexpace.sdk.core.codegen.async_service_core": { + "AsyncServiceCore": { + "bases": [], + "methods": { + "aclose": "async aclose(self) -> None", + "events": "events(self, op: Operation, input: OperationInput, mapper: SseMapper[T], *, auth: AuthDescriptor | AsyncPolicy | None, **call_options: Any) -> AsyncTypedSseConnection[T]", + "execute": "async execute(self, op: Operation, input: OperationInput, *, response_type: _Witness[Any] | None, handler: AsyncResponseHandler | None, auth: AuthDescriptor | AsyncPolicy | None, **call_options: Any) -> Any", + "execute_request": "async execute_request(self, request: Request, *, response_type: _Witness[Any] | None, handler: AsyncResponseHandler | None, auth: AuthDescriptor | AsyncPolicy | None, **call_options: Any) -> Any", + "paginate": "paginate(self, op: Operation, input: OperationInput, strategy: PaginationStrategy[T], *, max_pages: int | None, auth: AuthDescriptor | AsyncPolicy | None, **call_options: Any) -> AsyncPaginator[T]" + } + } + }, + "dexpace.sdk.core.codegen.auth": { + "AuthDescriptor": { + "bases": [], + "methods": { + "of": "of(cls, *requirements: AuthRequirement) -> AuthDescriptor" + } + }, + "AuthRegistry": { + "bases": [], + "methods": { + "scheme_for": "scheme_for(self, requirement: AuthRequirement) -> AuthScheme[P] | None" + } + }, + "AuthRequirement": { + "bases": [], + "methods": {} + }, + "AuthResolutionError": { + "bases": [ + "SdkError" + ], + "methods": {} + }, + "AuthScheme": { + "bases": [], + "methods": { + "satisfies": "satisfies(self, requirement: AuthRequirement) -> bool" + } + }, + "AuthTier": { + "bases": [ + "Enum" + ], + "methods": {} + }, + "ResolvedAuth": { + "bases": [], + "methods": {} + }, + "resolve_auth": "resolve_auth(*, per_call: AuthDescriptor | None, operation: AuthDescriptor | None, client: AuthDescriptor | None, registry: AuthRegistry[P]) -> ResolvedAuth[P] | None", + "select_tier": "select_tier(*, per_call: AuthDescriptor | None, operation: AuthDescriptor | None, client: AuthDescriptor | None) -> tuple[AuthTier, AuthDescriptor] | None" + }, + "dexpace.sdk.core.codegen.merge_config": { + "merge_config": "merge_config(defaults: ServiceDefaults, user: ServiceDefaults | None, *, transport: HttpClient | None, pipeline: Pipeline | None, redirect: RedirectPolicy | None, idempotency: IdempotencyPolicy | None, retry: RetryPolicy | None, set_date: SetDatePolicy | None, auth: Policy | None, logging: LoggingPolicy | None, tracing: TracingPolicy | None) -> dict[str, Any]" + }, + "dexpace.sdk.core.codegen.operation": { + "Operation": { + "bases": [], + "methods": {} + }, + "OperationInput": { + "bases": [], + "methods": {} + } + }, + "dexpace.sdk.core.codegen.service_core": { + "ServiceCore": { + "bases": [], + "methods": { + "close": "close(self) -> None", + "events": "events(self, op: Operation, input: OperationInput, mapper: SseMapper[T], *, auth: AuthDescriptor | Policy | None, **call_options: Any) -> TypedSseConnection[T]", + "execute": "execute(self, op: Operation, input: OperationInput, *, response_type: _Witness[Any] | None, handler: ResponseHandler | None, auth: AuthDescriptor | Policy | None, **call_options: Any) -> Any", + "execute_request": "execute_request(self, request: Request, *, response_type: _Witness[Any] | None, handler: ResponseHandler | None, auth: AuthDescriptor | Policy | None, **call_options: Any) -> Any", + "paginate": "paginate(self, op: Operation, input: OperationInput, strategy: PaginationStrategy[T], *, max_pages: int | None, auth: AuthDescriptor | Policy | None, **call_options: Any) -> Paginator[T]" + } + } + }, + "dexpace.sdk.core.codegen.service_defaults": { + "ServiceDefaults": { + "bases": [], + "methods": {} + }, + "merge_service_defaults": "merge_service_defaults(defaults: ServiceDefaults, user: ServiceDefaults | None) -> ServiceDefaults" + }, + "dexpace.sdk.core.codegen.status_error_map": { + "StatusErrorMap": { + "bases": [], + "methods": { + "for_status": "for_status(self, status: int) -> type[HttpResponseError] | None", + "raise_for": "raise_for(self, response: Response) -> NoReturn" + } + } + }, + "dexpace.sdk.core.config.build_descriptor": { + "BuildDescriptor": { + "bases": [], + "methods": { + "resolve": "resolve(cls, distribution: str) -> Self" + } + } + }, "dexpace.sdk.core.config.configuration": { "Configuration": { "bases": [], @@ -30,7 +201,10 @@ "get": "get(self, name: str, default: str | None) -> str | None", "get_bool": "get_bool(self, name: str, default: bool) -> bool", "get_duration": "get_duration(self, name: str, default_seconds: float) -> float", - "get_int": "get_int(self, name: str, default: int) -> int" + "get_int": "get_int(self, name: str, default: int) -> int", + "get_raw": "get_raw(self, name: str, default: str | None) -> str | None", + "with_override": "with_override(self, name: str, value: str) -> Configuration", + "without_override": "without_override(self, name: str) -> Configuration" } }, "ConfigurationBuilder": { @@ -38,11 +212,28 @@ "methods": { "build": "build(self) -> Configuration", "env": "env(self, source: EnvSource) -> Self", + "props": "props(self, source: PropertySource) -> Self", "put": "put(self, name: str, value: str) -> Self" } - } + }, + "default_configuration": "default_configuration() -> Configuration", + "set_default_configuration": "set_default_configuration(config: Configuration) -> None" }, "dexpace.sdk.core.errors.base": { + "NetworkError": { + "bases": [ + "OSError", + "SdkError" + ], + "methods": {} + }, + "RequestCancelledError": { + "bases": [ + "OSError", + "SdkError" + ], + "methods": {} + }, "SdkError": { "bases": [ "Exception" @@ -51,7 +242,7 @@ }, "ServiceRequestError": { "bases": [ - "SdkError" + "NetworkError" ], "methods": {} }, @@ -96,7 +287,8 @@ "SdkError" ], "methods": { - "body_snapshot": "body_snapshot(self, max_bytes: int | None) -> bytes" + "body_snapshot": "body_snapshot(self, max_bytes: int | None) -> bytes", + "read_body": "read_body(self, max_bytes: int) -> bytes" } }, "ResourceExistsError": { @@ -134,6 +326,12 @@ }, "dexpace.sdk.core.errors.serialization": { "DeserializationError": { + "bases": [ + "SerdeError" + ], + "methods": {} + }, + "SerdeError": { "bases": [ "SdkError", "ValueError" @@ -142,8 +340,7 @@ }, "SerializationError": { "bases": [ - "SdkError", - "ValueError" + "SerdeError" ], "methods": {} } @@ -174,6 +371,16 @@ "methods": {} } }, + "dexpace.sdk.core.http.auth._refresh": { + "RefreshZone": { + "bases": [ + "Enum" + ], + "methods": {} + }, + "classify": "classify(token: AccessTokenInfo | None, *, now: float, margin: float) -> RefreshZone", + "validate_fetched": "validate_fetched(token: AccessTokenInfo | None, *, now: float) -> AccessTokenInfo" + }, "dexpace.sdk.core.http.auth.access_token": { "AccessTokenInfo": { "bases": [], @@ -194,7 +401,7 @@ "bases": [], "methods": {} }, - "parse_challenges": "parse_challenges(header_value: str) -> list[AuthenticateChallenge]" + "parse_challenges": "parse_challenges(header_value: str | Iterable[str]) -> list[AuthenticateChallenge]" }, "dexpace.sdk.core.http.auth.challenge_handler": { "BasicChallengeHandler": { @@ -327,6 +534,12 @@ } } }, + "dexpace.sdk.core.http.common._query_codec": { + "parse_query": "parse_query(raw: str) -> list[tuple[str, str]]", + "remove_query_param": "remove_query_param(raw_query: str, name: str) -> str", + "render_query": "render_query(pairs: Iterable[tuple[str, str]]) -> str", + "splice_query": "splice_query(raw_query: str, name: str, value: str) -> str" + }, "dexpace.sdk.core.http.common.etag": { "ETag": { "bases": [], @@ -345,6 +558,7 @@ "get": "get(self, name: _Name, default: str | None) -> str | None", "items": "items(self) -> tuple[tuple[str, tuple[str, ...]], ...]", "names": "names(self) -> tuple[str, ...]", + "outbound": "outbound(cls, entries: _Entries | None) -> Self", "values": "values(self, name: _Name) -> tuple[str, ...]", "with_added": "with_added(self, name: _Name, value: str) -> Self", "with_merged": "with_merged(self, other: Headers) -> Self", @@ -506,6 +720,16 @@ } } }, + "dexpace.sdk.core.http.request._multipart_framing": { + "closing_delimiter": "closing_delimiter(boundary: str) -> bytes", + "content_length": "content_length(fields: Sequence[MultipartField], boundary: str) -> int", + "escape_quoted": "escape_quoted(value: str) -> str", + "iter_frames": "iter_frames(fields: Sequence[MultipartField], boundary: str, chunk_size: int) -> Iterator[bytes]", + "iter_value_bytes": "iter_value_bytes(value: bytes | str | RequestBody, chunk_size: int) -> Iterator[bytes]", + "part_header_block": "part_header_block(part: MultipartField, boundary: str) -> bytes", + "render_frames": "render_frames(fields: Sequence[MultipartField], boundary: str, *, max_bytes: int | None, chunk_size: int) -> bytes", + "value_length": "value_length(value: bytes | str | RequestBody) -> int" + }, "dexpace.sdk.core.http.request.async_request_body": { "AsyncRequestBody": { "bases": [ @@ -589,7 +813,8 @@ "MultipartField": { "bases": [], "methods": { - "with_utf8_filename": "with_utf8_filename(cls, *, name: str, value: bytes | str, filename: str, media_type: MediaType | None, headers: Sequence[tuple[str, str]], ascii_fallback: str) -> Self" + "is_replayable": "is_replayable(self) -> bool", + "with_utf8_filename": "with_utf8_filename(cls, *, name: str, value: bytes | str | RequestBody, filename: str, media_type: MediaType | None, headers: Sequence[tuple[str, str]], ascii_fallback: str) -> Self" } }, "MultipartRequestBody": { @@ -599,6 +824,7 @@ "methods": { "boundary": "boundary(self) -> str", "content_length": "content_length(self) -> int", + "fields": "fields(self) -> tuple[MultipartField, ...]", "is_replayable": "is_replayable(self) -> bool", "iter_bytes": "iter_bytes(self, chunk_size: int) -> Iterator[bytes]", "media_type": "media_type(self) -> MediaType | None", @@ -642,6 +868,22 @@ } } }, + "dexpace.sdk.core.http.response.async_loggable_response_body": { + "AsyncLoggableResponseBody": { + "bases": [ + "AsyncResponseBody" + ], + "methods": { + "aiter_bytes": "aiter_bytes(self, chunk_size: int) -> AsyncIterator[bytes]", + "captured_size": "captured_size(self) -> int", + "close": "async close(self) -> None", + "content_length": "content_length(self) -> int", + "error": "async error(self) -> BaseException | None", + "media_type": "media_type(self) -> MediaType | None", + "snapshot": "async snapshot(self, max_bytes: int | None) -> bytes" + } + } + }, "dexpace.sdk.core.http.response.async_response": { "AsyncResponse": { "bases": [], @@ -684,6 +926,7 @@ "captured_size": "captured_size(self) -> int", "close": "close(self) -> None", "content_length": "content_length(self) -> int", + "error": "error(self) -> BaseException | None", "iter_bytes": "iter_bytes(self, chunk_size: int) -> Iterator[bytes]", "media_type": "media_type(self) -> MediaType | None", "snapshot": "snapshot(self, max_bytes: int | None) -> bytes" @@ -699,6 +942,7 @@ "is_redirect": "is_redirect(self) -> bool", "is_server_error": "is_server_error(self) -> bool", "is_success": "is_success(self) -> bool", + "parse": "parse(self, target: type[T], *, serde: Serde | None) -> T", "with_body": "with_body(self, body: ResponseBody | None) -> Self", "with_header": "with_header(self, name: _Name, value: str) -> Self", "with_headers": "with_headers(self, headers: Headers) -> Self", @@ -776,6 +1020,26 @@ "parse_async_events": "parse_async_events(chunks: AsyncIterable[bytes]) -> AsyncSseStream", "parse_events": "parse_events(chunks: Iterable[bytes]) -> Iterator[SseEvent]" }, + "dexpace.sdk.core.http.sse.typed": { + "AsyncTypedSseConnection": { + "bases": [], + "methods": { + "aclose": "async aclose(self) -> None" + } + }, + "SseSignal": { + "bases": [ + "Enum" + ], + "methods": {} + }, + "TypedSseConnection": { + "bases": [], + "methods": { + "close": "close(self) -> None" + } + } + }, "dexpace.sdk.core.http.webhooks.verification": { "InvalidWebhookSignatureError": { "bases": [ @@ -795,8 +1059,14 @@ "ClientLogger": { "bases": [], "methods": { + "at_error": "at_error(self) -> LogEvent", + "at_info": "at_info(self) -> LogEvent", + "at_level": "at_level(self, level: LogLevel) -> LogEvent", + "at_verbose": "at_verbose(self) -> LogEvent", + "at_warning": "at_warning(self) -> LogEvent", "error": "error(self, message: str, **fields: Any) -> None", "info": "info(self, message: str, **fields: Any) -> None", + "is_enabled": "is_enabled(self, level: LogLevel) -> bool", "log": "log(self, level: LogLevel, message: str, **fields: Any) -> None", "verbose": "verbose(self, message: str, **fields: Any) -> None", "warning": "warning(self, message: str, **fields: Any) -> None" @@ -809,15 +1079,38 @@ "methods": { "filter": "filter(self, record: logging.LogRecord) -> bool" } + }, + "LogEvent": { + "bases": [ + "ABC" + ], + "methods": { + "add": "add(self, key: str, value: Any) -> Self", + "event": "event(self, name: str) -> Self", + "log": "log(self, message: str) -> None" + } } }, "dexpace.sdk.core.instrumentation.correlation": { + "activate_span": "activate_span(span: Span) -> TracingScope", "bind_correlation": "bind_correlation(*, trace_id: str | None, span_id: str | None) -> Iterator[None]", + "bind_to_context": "bind_to_context(func: Callable[P, T]) -> Callable[P, T]", + "capture_context": "capture_context() -> Context", + "correlation_scope": "correlation_scope(span: Span) -> Iterator[None]", + "get_current_span": "get_current_span() -> Span", "get_span_id": "get_span_id() -> str | None", "get_trace_id": "get_trace_id() -> str | None", "set_span_id": "set_span_id(value: str | None) -> Token[str | None]", "set_trace_id": "set_trace_id(value: str | None) -> Token[str | None]" }, + "dexpace.sdk.core.instrumentation.header_redactor": { + "HeaderRedactor": { + "bases": [], + "methods": { + "redact": "redact(self, headers: Headers) -> dict[str, str]" + } + } + }, "dexpace.sdk.core.instrumentation.http_tracer": { "HttpTracer": { "bases": [ @@ -849,7 +1142,10 @@ "dexpace.sdk.core.instrumentation.identifiers": { "SpanId": { "bases": [], - "methods": {} + "methods": { + "generate": "generate(cls) -> Self", + "is_valid": "is_valid(self) -> bool" + } }, "TraceFlags": { "bases": [], @@ -857,7 +1153,10 @@ }, "TraceId": { "bases": [], - "methods": {} + "methods": { + "generate": "generate(cls, id_type: TraceIdType | None) -> Self", + "is_valid": "is_valid(self) -> bool" + } }, "TraceIdType": { "bases": [ @@ -961,7 +1260,8 @@ "UrlRedactor": { "bases": [], "methods": { - "redact": "redact(self, url: str | Url) -> str" + "redact": "redact(self, url: str | Url) -> str", + "redact_header_value": "redact_header_value(self, value: str) -> str" } } }, @@ -983,13 +1283,13 @@ "AsyncPaginator": { "bases": [], "methods": { - "by_page": "async by_page(self) -> AsyncIterator[Page[T]]" + "by_page": "by_page(self) -> _AsyncPageView[T]" } }, "Paginator": { "bases": [], "methods": { - "by_page": "by_page(self) -> Iterator[Page[T]]" + "by_page": "by_page(self) -> _PageView[T]" } } }, @@ -1029,6 +1329,49 @@ } } }, + "dexpace.sdk.core.pipeline._async_recovery": { + "afrom_status": "async afrom_status(response: AsyncResponse, *, error_map: Mapping[int, type[HttpResponseError]] | None, max_body_bytes: int) -> Outcome[AsyncResponse]", + "amap_success": "async amap_success(outcome: Outcome[AsyncResponse], step: Callable[[AsyncResponse], Awaitable[AsyncResponse]]) -> Outcome[AsyncResponse]", + "aproduce": "async aproduce(source: Callable[[], Awaitable[AsyncResponse]]) -> Outcome[AsyncResponse]", + "arecover": "async arecover(outcome: Outcome[AsyncResponse], cleanup: Callable[[Outcome[AsyncResponse]], Awaitable[Outcome[AsyncResponse]]]) -> Outcome[AsyncResponse]" + }, + "dexpace.sdk.core.pipeline._outcome": { + "Failure": { + "bases": [], + "methods": {} + }, + "Success": { + "bases": [], + "methods": {} + }, + "unwrap": "unwrap(outcome: Outcome[R]) -> R" + }, + "dexpace.sdk.core.pipeline._pure.backoff": { + "apply_jitter": "apply_jitter(delay: float, rng: random.Random, *, full_jitter: bool, symmetric: float) -> float", + "compute_backoff": "compute_backoff(attempt: int, *, base: float, cap: float, multiplier: float, fixed: bool) -> float" + }, + "dexpace.sdk.core.pipeline._pure.classifier": { + "Retryable": { + "bases": [ + "Protocol" + ], + "methods": {} + }, + "default_status_is_retryable": "default_status_is_retryable(status: int | None) -> bool", + "is_retryable": "is_retryable(error: BaseException) -> bool", + "status_is_retryable": "status_is_retryable(status: int | None, retryable_statuses: Collection[int]) -> bool" + }, + "dexpace.sdk.core.pipeline._pure.pacing": { + "parse_rate_limit_reset": "parse_rate_limit_reset(value: str | None, now: float, *, max_delay: float) -> float | None", + "parse_retry_after": "parse_retry_after(value: str | None, now: float, *, max_delay: float) -> float | None", + "parse_retry_after_ms": "parse_retry_after_ms(value: str | None, *, max_delay: float) -> float | None" + }, + "dexpace.sdk.core.pipeline._recovery": { + "from_status": "from_status(response: Response, *, error_map: Mapping[int, type[HttpResponseError]] | None, max_body_bytes: int) -> Outcome[Response]", + "map_success": "map_success(outcome: Outcome[Response], step: Callable[[Response], Response]) -> Outcome[Response]", + "produce": "produce(source: Callable[[], Response]) -> Outcome[Response]", + "recover": "recover(outcome: Outcome[Response], cleanup: Callable[[Outcome[Response]], Outcome[Response]]) -> Outcome[Response]" + }, "dexpace.sdk.core.pipeline.async_pipeline": { "AsyncPipeline": { "bases": [], @@ -1052,11 +1395,14 @@ "bases": [], "methods": { "append": "append(self, policy: AsyncPolicy, *, force: bool) -> Self", + "append_all": "append_all(self, policies: Iterable[AsyncPolicy], *, force: bool) -> Self", + "batch": "batch(self) -> Iterator[Self]", "build": "build(self) -> AsyncPipeline", "from_pipeline": "from_pipeline(cls, pipeline: AsyncPipeline) -> Self", "insert_after": "insert_after(self, target: type[AsyncPolicy], new: AsyncPolicy) -> Self", "insert_before": "insert_before(self, target: type[AsyncPolicy], new: AsyncPolicy) -> Self", "prepend": "prepend(self, policy: AsyncPolicy, *, force: bool) -> Self", + "prepend_all": "prepend_all(self, policies: Iterable[AsyncPolicy], *, force: bool) -> Self", "remove": "remove(self, target: type[AsyncPolicy]) -> Self", "replace": "replace(self, target: type[AsyncPolicy], new: AsyncPolicy) -> Self" } @@ -1069,7 +1415,7 @@ } }, "dexpace.sdk.core.pipeline.defaults": { - "default_async_pipeline": "default_async_pipeline(client: AsyncHttpClient, *, redirect: AsyncRedirectPolicy | None, idempotency: AsyncIdempotencyPolicy | None, retry: AsyncRetryPolicy | None, set_date: AsyncSetDatePolicy | None, client_identity: AsyncClientIdentityPolicy | None, auth: AsyncPolicy | None) -> AsyncStagedPipelineBuilder", + "default_async_pipeline": "default_async_pipeline(client: AsyncHttpClient, *, redirect: AsyncRedirectPolicy | None, idempotency: AsyncIdempotencyPolicy | None, retry: AsyncRetryPolicy | None, set_date: AsyncSetDatePolicy | None, client_identity: AsyncClientIdentityPolicy | None, auth: AsyncPolicy | None, logging: AsyncLoggingPolicy | None, tracing: AsyncTracingPolicy | None) -> AsyncStagedPipelineBuilder", "default_pipeline": "default_pipeline(client: HttpClient, *, redirect: RedirectPolicy | None, idempotency: IdempotencyPolicy | None, retry: RetryPolicy | None, set_date: SetDatePolicy | None, client_identity: ClientIdentityPolicy | None, auth: Policy | None, logging: LoggingPolicy | None, tracing: TracingPolicy | None) -> StagedPipelineBuilder" }, "dexpace.sdk.core.pipeline.dispatch": { @@ -1124,6 +1470,16 @@ } } }, + "dexpace.sdk.core.pipeline.policies.async_logging_policy": { + "AsyncLoggingPolicy": { + "bases": [ + "AsyncPolicy" + ], + "methods": { + "send": "async send(self, request: Request, ctx: PipelineContext) -> AsyncResponse" + } + } + }, "dexpace.sdk.core.pipeline.policies.async_redirect": { "AsyncRedirectPolicy": { "bases": [ @@ -1163,6 +1519,14 @@ "methods": { "send": "async send(self, request: Request, ctx: PipelineContext) -> AsyncResponse" } + }, + "AsyncTracingPolicy": { + "bases": [ + "AsyncPolicy" + ], + "methods": { + "send": "async send(self, request: Request, ctx: PipelineContext) -> AsyncResponse" + } } }, "dexpace.sdk.core.pipeline.policies.client_identity": { @@ -1187,6 +1551,15 @@ } }, "dexpace.sdk.core.pipeline.policies.logging_policy": { + "HttpLogDetailLevel": { + "bases": [ + "Enum" + ], + "methods": { + "logs_body": "logs_body(self) -> bool", + "logs_events": "logs_events(self) -> bool" + } + }, "LoggingPolicy": { "bases": [ "Policy" @@ -1197,6 +1570,10 @@ } }, "dexpace.sdk.core.pipeline.policies.redirect": { + "RedirectCondition": { + "bases": [], + "methods": {} + }, "RedirectPolicy": { "bases": [ "Policy" @@ -1205,6 +1582,15 @@ "send": "send(self, request: Request, ctx: PipelineContext) -> Response" } }, + "RedirectResponse": { + "bases": [ + "Protocol" + ], + "methods": { + "headers": "headers(self) -> Headers", + "status": "status(self) -> Status" + } + }, "resolve_http_tracer": "resolve_http_tracer(ctx: PipelineContext) -> HttpTracer" }, "dexpace.sdk.core.pipeline.policies.retry": { @@ -1277,11 +1663,14 @@ "bases": [], "methods": { "append": "append(self, policy: Policy, *, force: bool) -> Self", + "append_all": "append_all(self, policies: Iterable[Policy], *, force: bool) -> Self", + "batch": "batch(self) -> Iterator[Self]", "build": "build(self) -> Pipeline", "from_pipeline": "from_pipeline(cls, pipeline: Pipeline) -> Self", "insert_after": "insert_after(self, target: type[Policy], new: Policy) -> Self", "insert_before": "insert_before(self, target: type[Policy], new: Policy) -> Self", "prepend": "prepend(self, policy: Policy, *, force: bool) -> Self", + "prepend_all": "prepend_all(self, policies: Iterable[Policy], *, force: bool) -> Self", "remove": "remove(self, target: type[Policy]) -> Self", "replace": "replace(self, target: type[Policy], new: Policy) -> Self" } @@ -1305,7 +1694,7 @@ "Codec": { "bases": [], "methods": { - "decode": "decode(self, data: object, target: type[T]) -> T", + "decode": "decode(self, data: object, target: type[T] | TypeRef[T]) -> T", "encode": "encode(self, value: object) -> object" } }, @@ -1332,6 +1721,7 @@ "bases": [], "methods": { "deserializer": "deserializer(self) -> JsonDeserializer", + "media_type": "media_type(self) -> MediaType", "serializer": "serializer(self) -> JsonSerializer" } }, @@ -1339,11 +1729,35 @@ "bases": [], "methods": { "serialize": "serialize(self, value: Any) -> str", + "serialize_into": "serialize_into(self, value: Any, buffer: bytearray | memoryview, offset: int) -> int", "serialize_to_bytes": "serialize_to_bytes(self, value: Any) -> bytes", "serialize_to_stream": "serialize_to_stream(self, value: Any, stream: BinaryIO) -> None" } } }, + "dexpace.sdk.core.serde.response_handler": { + "DecodeBody": { + "bases": [], + "methods": {} + }, + "DecodeSuccess": { + "bases": [], + "methods": {} + }, + "ResponseDisposition": { + "bases": [ + "enum.Enum" + ], + "methods": {} + }, + "ResponseHandler": { + "bases": [ + "Protocol" + ], + "methods": {} + }, + "classify_status": "classify_status(status: Status) -> ResponseDisposition" + }, "dexpace.sdk.core.serde.serde": { "Deserializer": { "bases": [ @@ -1361,6 +1775,7 @@ ], "methods": { "deserializer": "deserializer(self) -> Deserializer", + "media_type": "media_type(self) -> MediaType", "serializer": "serializer(self) -> Serializer" } }, @@ -1387,6 +1802,15 @@ "of_optional": "of_optional(value: T | None) -> _Null | Present[T]", "present": "present(value: T) -> Present[T]" }, + "dexpace.sdk.core.serde.type_ref": { + "TypeRef": { + "bases": [], + "methods": { + "args": "args(self) -> tuple[object, ...]", + "origin": "origin(self) -> object | None" + } + } + }, "dexpace.sdk.core.util.clock": { "AsyncClock": { "bases": [ @@ -1398,6 +1822,14 @@ "sleep": "async sleep(self, duration: float) -> None" } }, + "CancellationToken": { + "bases": [], + "methods": { + "cancel": "cancel(self) -> None", + "cancelled": "cancelled(self) -> bool", + "sleep": "sleep(self, duration: float) -> bool" + } + }, "Clock": { "bases": [ "Protocol" @@ -1409,12 +1841,17 @@ } } }, + "dexpace.sdk.core.util.http_date": { + "format_http_date": "format_http_date(value: datetime) -> str", + "parse_http_date": "parse_http_date(value: str) -> datetime | None" + }, "dexpace.sdk.core.util.proxy": { "ProxyOptions": { "bases": [], "methods": { "bypasses_proxy": "bypasses_proxy(self, host: str) -> bool", - "from_configuration": "from_configuration(cls, config: Configuration) -> Self | None" + "from_configuration": "from_configuration(cls, config: Configuration) -> Self | None", + "from_environment": "from_environment(cls, env: EnvSource) -> Self | None" } }, "ProxyType": { @@ -1428,25 +1865,52 @@ "exports": { "dexpace.sdk.core.client": [ "AsyncHttpClient", + "AsyncToSyncHttpClient", "HttpClient", + "SyncToAsyncHttpClient", "asyncio_sleep" ], + "dexpace.sdk.core.codegen": [ + "AsyncDecodeSuccess", + "AsyncResponseHandler", + "AsyncServiceCore", + "AuthDescriptor", + "AuthRegistry", + "AuthRequirement", + "AuthResolutionError", + "AuthScheme", + "Operation", + "OperationInput", + "ServiceCore", + "ServiceDefaults", + "StatusErrorMap", + "assemble", + "merge_async_config", + "merge_config" + ], "dexpace.sdk.core.config": [ + "BuildDescriptor", "Configuration", - "ConfigurationBuilder" + "ConfigurationBuilder", + "default_configuration", + "set_default_configuration" ], "dexpace.sdk.core.errors": [ "ClientAuthenticationError", "DecodeError", "DeserializationError", + "ERROR_BODY_MAX_BYTES", "HttpResponseError", + "NetworkError", "PipelineAbortedError", + "RequestCancelledError", "ResourceExistsError", "ResourceModifiedError", "ResourceNotFoundError", "ResourceNotModifiedError", "ResponseNotReadError", "SdkError", + "SerdeError", "SerializationError", "ServiceRequestError", "ServiceRequestTimeoutError", @@ -1518,6 +1982,7 @@ "RequestBody" ], "dexpace.sdk.core.http.response": [ + "AsyncLoggableResponseBody", "AsyncResponse", "AsyncResponseBody", "LoggableResponseBody", @@ -1528,9 +1993,13 @@ "dexpace.sdk.core.http.sse": [ "AsyncSseConnection", "AsyncSseStream", + "AsyncTypedSseConnection", "SseConnection", "SseEvent", + "SseMapper", "SseParser", + "SseSignal", + "TypedSseConnection", "parse_async_events", "parse_events" ], @@ -1543,11 +2012,15 @@ "ClientLogger", "CorrelationFilter", "Counter", + "DEFAULT_LOGGED_HEADER_ALLOWLIST", "DEFAULT_QUERY_ALLOWLIST", + "DEFAULT_URL_VALUED_HEADERS", + "HeaderRedactor", "Histogram", "HttpTracer", "HttpTracerFactory", "InstrumentationContext", + "LogEvent", "LogLevel", "MetricsContext", "NOOP_COUNTER", @@ -1569,7 +2042,12 @@ "TracingScope", "UpDownCounter", "UrlRedactor", + "activate_span", "bind_correlation", + "bind_to_context", + "capture_context", + "correlation_scope", + "get_current_span", "get_span_id", "get_trace_id", "set_span_id", @@ -1611,11 +2089,15 @@ "dexpace.sdk.core.pipeline.policies": [ "AsyncClientIdentityPolicy", "AsyncIdempotencyPolicy", + "AsyncLoggingPolicy", "AsyncOperationTracingPolicy", "AsyncRedirectPolicy", "AsyncRetryPolicy", "AsyncSetDatePolicy", + "AsyncTracingPolicy", "ClientIdentityPolicy", + "DEFAULT_PREVIEW_BYTES", + "HttpLogDetailLevel", "IdempotencyPolicy", "LoggingPolicy", "OperationTracingPolicy", @@ -1638,6 +2120,8 @@ "Codec", "CodecError", "DISCRIMINATOR_KEY", + "DecodeBody", + "DecodeSuccess", "Deserializer", "JSON_SERDE", "JsonDeserializer", @@ -1646,9 +2130,13 @@ "NULL", "Present", "REGISTRY_KEY", + "ResponseDisposition", + "ResponseHandler", "Serde", "Serializer", "Tristate", + "TypeRef", + "classify_status", "discriminated", "field_alias", "fold", @@ -1662,10 +2150,13 @@ "dexpace.sdk.core.util": [ "ASYNC_SYSTEM_CLOCK", "AsyncClock", + "CancellationToken", "Clock", "ProxyOptions", "ProxyType", - "SYSTEM_CLOCK" + "SYSTEM_CLOCK", + "format_http_date", + "parse_http_date" ] } }, diff --git a/tools/traceability_audit.py b/tools/traceability_audit.py new file mode 100644 index 0000000..7aec93b --- /dev/null +++ b/tools/traceability_audit.py @@ -0,0 +1,635 @@ +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE.md in the repository root for details. + +"""Requirement-ID traceability audit for the dexpace SDK test suite. + +This module is two things layered on top of each other: + +- A small, pytest-independent core (``Requirement`` / ``RequirementIndex`` / + ``audit`` / matrix renderers) that cross-references a *requirement index* + against the set of requirement IDs claimed by the test suite via + ``@pytest.mark.req("SOME-ID")`` markers. It answers two questions per run: + which requirement is covered by which test, and which MUST-level requirement + in a *flagged-done* subsystem has no covering test at all. +- A thin ``TraceabilityPlugin`` that collects those markers at pytest + collection time, writes a matrix artifact (requirement ID -> covering tests) + and — only when explicitly gated — fails the run on an uncovered MUST. + +Milestone-scoped gating. The audit never fails on the whole (still-being-built) +requirement spec at once: a MUST requirement is only a gap once its +``subsystem`` is listed in ``enabled_subsystems``. Empty enabled set (the +default) means the audit reports coverage but fails nothing, so this plumbing +can land and be exercised long before the full spec index exists. + +Deviations seam. A future deviations ledger (``docs/deviations.md``, produced +by a separate task) will list requirement IDs that are intentionally not +implemented (N/A). The audit accepts that set via ``na_ids`` so those IDs never +count as gaps. ``na_ids_from_file`` is the placeholder reader; it returns an +empty set when the ledger file does not yet exist. + +Placeholder index. ``PLACEHOLDER_INDEX`` is a deliberately small, representative +sample (see its own comment) — *not* the real spec. It exists to prove the +machinery end-to-end. Swap it for the real index once that index exists; the +audit and plugin are written against the generic ``RequirementIndex`` type and +need no change. + +Environment overrides consumed by ``build_plugin_from_env``: + +- ``DEXPACE_TRACEABILITY_MATRIX``: output path for the matrix artifact. +- ``DEXPACE_TRACEABILITY_ENABLE``: comma-separated subsystems to gate on. +- ``DEXPACE_TRACEABILITY_NA``: path to a newline-delimited N/A id file. +- ``DEXPACE_TRACEABILITY_GATE``: ``1``/``true``/``yes`` to fail on gaps. + +Run as a script to dump the placeholder matrix (no coverage data outside a +pytest run): + + python tools/traceability_audit.py --format json +""" + +from __future__ import annotations + +import argparse +import csv +import io +import json +import os +import sys +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest import Config, Item, Session + +__all__ = [ + "MARKER_NAME", + "PLACEHOLDER_INDEX", + "AuditResult", + "Gap", + "Level", + "Requirement", + "RequirementIndex", + "TraceabilityPlugin", + "audit", + "build_index", + "build_plugin_from_env", + "default_index", + "default_index_path", + "default_matrix_path", + "load_index_file", + "na_ids_from_file", + "render_matrix_csv", + "render_matrix_json", + "repo_root", +] + +#: The custom pytest marker name. Registered centrally in the root +#: ``pyproject.toml`` ``[tool.pytest.ini_options].markers`` list so it collects +#: warning-free under ``-W error::pytest.PytestUnknownMarkWarning``. +MARKER_NAME = "req" + +#: Canonical schema tag written into the JSON matrix artifact, bumped only on a +#: breaking change to the artifact shape. +_SCHEMA = "dexpace-traceability-matrix/v1" + +#: Environment variable names consumed by ``build_plugin_from_env``. +ENV_MATRIX = "DEXPACE_TRACEABILITY_MATRIX" +ENV_ENABLE = "DEXPACE_TRACEABILITY_ENABLE" +ENV_NA = "DEXPACE_TRACEABILITY_NA" +ENV_GATE = "DEXPACE_TRACEABILITY_GATE" + + +class Level(Enum): + """Normative strength of a requirement, per RFC 2119 keywords. + + Only ``MUST`` requirements gate the audit; ``SHOULD`` / ``MAY`` are recorded + in the matrix for visibility but never counted as a coverage gap. + """ + + MUST = "MUST" + SHOULD = "SHOULD" + MAY = "MAY" + + +@dataclass(frozen=True, slots=True) +class Requirement: + """A single normative requirement drawn from the product spec. + + Attributes: + id: The stable requirement identifier (e.g. ``RETRY-19``). This is the + value a test passes to ``@pytest.mark.req(...)``. + level: The RFC 2119 strength of the requirement. + subsystem: The area the requirement belongs to (e.g. ``retry``). Used + for milestone-scoped gating. + description: A one-line human summary of the requirement. + """ + + id: str + level: Level + subsystem: str + description: str + + +@dataclass(frozen=True, slots=True) +class RequirementIndex: + """An immutable collection of requirements keyed by id. + + Build via ``build_index`` so duplicate ids are rejected up front. + """ + + requirements: tuple[Requirement, ...] + + def by_id(self) -> dict[str, Requirement]: + """Return a mapping of requirement id -> requirement.""" + return {req.id: req for req in self.requirements} + + def ids(self) -> frozenset[str]: + """Return the set of all requirement ids in the index.""" + return frozenset(req.id for req in self.requirements) + + def subsystems(self) -> frozenset[str]: + """Return the set of distinct subsystem names in the index.""" + return frozenset(req.subsystem for req in self.requirements) + + +@dataclass(frozen=True, slots=True) +class Gap: + """A MUST-level requirement that has no covering test marker. + + Attributes: + requirement: The uncovered requirement. + reason: A short human explanation (always "no covering test marker" + today; kept as a field so future audit rules can distinguish + gap kinds). + """ + + requirement: Requirement + reason: str + + +@dataclass(frozen=True, slots=True) +class AuditResult: + """The outcome of one audit pass. + + Attributes: + matrix: Every indexed requirement id -> the sorted tuple of test node + ids that claim it (empty tuple when uncovered). + gaps: The MUST-level gaps in the currently enabled subsystems, sorted + by requirement id. + unknown_marker_ids: Sorted ids referenced by a marker but absent from + the index (likely a typo or a not-yet-indexed requirement). + enabled_subsystems: The subsystems that were gated this pass. + na_ids: The requirement ids treated as not-applicable (never gaps). + """ + + matrix: Mapping[str, tuple[str, ...]] + gaps: tuple[Gap, ...] + unknown_marker_ids: tuple[str, ...] + enabled_subsystems: frozenset[str] + na_ids: frozenset[str] + + @property + def ok(self) -> bool: + """Return whether the audit found no gaps.""" + return not self.gaps + + +def build_index(requirements: Iterable[Requirement]) -> RequirementIndex: + """Build a ``RequirementIndex``, rejecting duplicate ids. + + Args: + requirements: The requirements to index. + + Returns: + The immutable index. + + Raises: + ValueError: If two requirements share an id. + """ + reqs = tuple(requirements) + seen: set[str] = set() + for req in reqs: + if req.id in seen: + raise ValueError(f"duplicate requirement id: {req.id}") + seen.add(req.id) + return RequirementIndex(reqs) + + +def _build_full_matrix( + index: RequirementIndex, collected: Mapping[str, Sequence[str]] +) -> dict[str, tuple[str, ...]]: + """Map every indexed requirement id to its sorted, de-duplicated test ids. + + Requirements with no collected marker map to an empty tuple, so the matrix + is a complete view of the index rather than only the covered subset. + """ + return { + req.id: tuple(sorted(set(collected.get(req.id, ())))) + for req in sorted(index.requirements, key=lambda r: r.id) + } + + +def audit( + index: RequirementIndex, + collected: Mapping[str, Sequence[str]], + *, + enabled_subsystems: Iterable[str] = (), + na_ids: Iterable[str] = (), +) -> AuditResult: + """Cross-reference collected markers against the requirement index. + + Args: + index: The requirement index to audit against. + collected: Requirement id -> the test node ids that marked it. + enabled_subsystems: Subsystems that are "done" and therefore gated; a + MUST requirement outside these is never a gap. Empty means gate + nothing (the default milestone posture). + na_ids: Requirement ids intentionally not implemented; never gaps. + + Returns: + The ``AuditResult`` for this pass. + """ + enabled = frozenset(enabled_subsystems) + not_applicable = frozenset(na_ids) + matrix = _build_full_matrix(index, collected) + by_id = index.by_id() + gaps = tuple( + Gap(req, "no covering test marker") + for req in sorted(index.requirements, key=lambda r: r.id) + if req.level is Level.MUST + and req.subsystem in enabled + and req.id not in not_applicable + and not matrix[req.id] + ) + unknown = tuple(sorted(rid for rid in collected if rid not in by_id)) + return AuditResult( + matrix=matrix, + gaps=gaps, + unknown_marker_ids=unknown, + enabled_subsystems=enabled, + na_ids=not_applicable, + ) + + +def _summary(result: AuditResult, index: RequirementIndex) -> dict[str, object]: + """Build the summary block embedded in the JSON matrix artifact.""" + reqs = index.requirements + must = [req for req in reqs if req.level is Level.MUST] + covered = sum(1 for tests in result.matrix.values() if tests) + must_covered = sum(1 for req in must if result.matrix[req.id]) + return { + "total_requirements": len(reqs), + "covered": covered, + "uncovered": len(reqs) - covered, + "must_total": len(must), + "must_covered": must_covered, + "gap_count": len(result.gaps), + "gaps": [gap.requirement.id for gap in result.gaps], + "enabled_subsystems": sorted(result.enabled_subsystems), + "na_ids": sorted(result.na_ids), + "unknown_marker_ids": list(result.unknown_marker_ids), + # Clearly-labeled "pending" bucket: requirement ids that tests claim via + # a marker but that the current index does not define yet — behaviours + # exercised and traceable, awaiting an id in the real spec index (e.g. + # transport, observability, jsonl/set-date/client-identity policies). + # A positively-named alias of ``unknown_marker_ids`` so the artifact + # surfaces these without implying a typo, and never as a gap/failure. + "pending_ids": list(result.unknown_marker_ids), + } + + +def render_matrix_json(result: AuditResult, index: RequirementIndex) -> str: + """Render the audit result as canonical, newline-terminated JSON text. + + Args: + result: The audit result to render. + index: The index the result was produced against (for requirement + metadata). + + Returns: + A deterministic JSON document (sorted keys, requirements sorted by id) + suitable for committing or diffing. + """ + by_id = index.by_id() + rows: list[dict[str, object]] = [] + for rid in sorted(result.matrix): + req = by_id[rid] + tests = list(result.matrix[rid]) + rows.append( + { + "id": rid, + "level": req.level.value, + "subsystem": req.subsystem, + "description": req.description, + "covered": bool(tests), + "tests": tests, + } + ) + payload = {"schema": _SCHEMA, "summary": _summary(result, index), "requirements": rows} + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def render_matrix_csv(result: AuditResult, index: RequirementIndex) -> str: + """Render the audit result as CSV text, one row per requirement. + + Args: + result: The audit result to render. + index: The index the result was produced against. + + Returns: + CSV text with a header row followed by one row per requirement id. + """ + by_id = index.by_id() + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow( + ["requirement_id", "level", "subsystem", "covered", "test_count", "tests", "description"] + ) + for rid in sorted(result.matrix): + req = by_id[rid] + tests = result.matrix[rid] + writer.writerow( + [ + rid, + req.level.value, + req.subsystem, + str(bool(tests)), + str(len(tests)), + ";".join(tests), + req.description, + ] + ) + return buffer.getvalue() + + +def na_ids_from_file(path: Path | str | None) -> frozenset[str]: + """Read intentionally-not-applicable requirement ids from a ledger file. + + This is the seam the deviations ledger plugs into. The accepted format is + one requirement id per line, optionally followed by an inline ``#`` comment + giving the justification (``RETRY-9 # deviation: ...``); blank lines and + whole-line ``#`` comments are ignored. A ``None`` path or a missing file + yields an empty set, so the audit works before the ledger exists. + + Args: + path: Path to the N/A id file, or ``None``. + + Returns: + The frozenset of not-applicable requirement ids. + """ + if path is None: + return frozenset() + resolved = Path(path) + if not resolved.is_file(): + return frozenset() + ids: set[str] = set() + for line in resolved.read_text(encoding="utf-8").splitlines(): + # Drop any inline comment, then take the bare id token. + token = line.split("#", 1)[0].strip() + if token: + ids.add(token) + return frozenset(ids) + + +def repo_root() -> Path: + """Return the workspace root (this file sits at ``tools/`` under it).""" + return Path(__file__).resolve().parent.parent + + +def default_matrix_path() -> Path: + """Return the default matrix artifact path (under gitignored ``build/``).""" + return repo_root() / "build" / "traceability-matrix.json" + + +def default_index_path() -> Path: + """Return the vendored requirement-index path (``tools/requirement_index.json``).""" + return repo_root() / "tools" / "requirement_index.json" + + +def load_index_file(path: Path | str) -> RequirementIndex: + """Load a requirement index from a vendored JSON projection of Appendix C. + + The file is produced by ``tools/build_requirement_index.py`` and carries a + ``requirements`` array of ``{id, level, subsystem, description}`` objects + (levels already normalized to the ``Level`` vocabulary). + + Args: + path: Path to the vendored index JSON. + + Returns: + The immutable requirement index. + + Raises: + ValueError: If the document has an unexpected schema or a duplicate id. + """ + document = json.loads(Path(path).read_text(encoding="utf-8")) + schema = document.get("schema", "") + if not schema.startswith("dexpace-requirement-index/"): + raise ValueError(f"not a requirement index document: schema={schema!r}") + return build_index( + Requirement( + id=row["id"], + level=Level(row["level"]), + subsystem=row["subsystem"], + description=row["description"], + ) + for row in document["requirements"] + ) + + +def default_index() -> RequirementIndex: + """Return the real vendored index, or the placeholder if it is absent. + + CI and local runs use the committed ``requirement_index.json``; the + placeholder fallback keeps the audit importable in a stripped checkout that + lacks the vendored file (the machinery unit tests exercise the placeholder + directly and do not depend on this fallback). + """ + path = default_index_path() + return load_index_file(path) if path.is_file() else PLACEHOLDER_INDEX + + +# --- Placeholder requirement index ----------------------------------------- +# +# NOT the real spec. A handful of representative requirements whose subsystems +# and behaviours are real (drawn from the retry / redirect / auth / bodies / +# http / pagination / sse / webhooks / tracing areas that already exist in the +# codebase), used purely to prove the audit machinery end-to-end. Replace this +# whole constant with the real index once it exists — nothing else changes. +PLACEHOLDER_INDEX = build_index( + [ + Requirement("RETRY-19", Level.MUST, "retry", "Honor a server-supplied Retry-After header."), + Requirement( + "RETRY-07", Level.MUST, "retry", "Do not retry a non-idempotent method by default." + ), + Requirement("RETRY-31", Level.SHOULD, "retry", "Apply jitter to computed backoff delays."), + Requirement( + "REDIRECT-03", Level.MUST, "redirect", "Follow 301/302/303/307/308 up to the maximum." + ), + Requirement( + "REDIRECT-11", + Level.MUST, + "redirect", + "Strip the Authorization header on a cross-origin redirect.", + ), + Requirement( + "IDEMPOTENCY-05", + Level.MUST, + "idempotency", + "Attach an idempotency key to retried unsafe requests.", + ), + Requirement( + "AUTH-02", Level.MUST, "auth", "Send the bearer token in the Authorization header." + ), + Requirement( + "AUTH-14", Level.SHOULD, "auth", "Cache tokens until the refresh-ahead window." + ), + Requirement( + "BODY-08", Level.MUST, "bodies", "Raise on second consumption of a single-use body." + ), + Requirement( + "BODY-21", Level.MAY, "bodies", "Expose a replayable body via to_replayable()." + ), + Requirement("HEADER-04", Level.MUST, "http", "Compare header names case-insensitively."), + Requirement( + "PAGINATION-06", Level.SHOULD, "pagination", "Follow RFC 8288 Link rel=next headers." + ), + Requirement("SSE-09", Level.MUST, "sse", "Join multi-line data fields with newlines."), + Requirement( + "WEBHOOK-12", Level.MUST, "webhooks", "Reject signatures failing constant-time compare." + ), + Requirement( + "TRACING-01", Level.SHOULD, "tracing", "Propagate a correlation id across stages." + ), + ] +) + + +class TraceabilityPlugin: + """A pytest plugin that collects ``req`` markers and emits the matrix. + + Registered from the root ``conftest.py`` via ``build_plugin_from_env``. On + every run it writes the matrix artifact; it only *fails* the run when + ``gate`` is set and an enabled subsystem has an uncovered MUST requirement. + """ + + def __init__( + self, + index: RequirementIndex | None = None, + *, + output_path: Path | str | None = None, + enabled_subsystems: Iterable[str] = (), + na_ids: Iterable[str] = (), + gate: bool = False, + ) -> None: + """Configure the plugin. + + Args: + index: The requirement index (defaults to ``PLACEHOLDER_INDEX``). + output_path: Where to write the JSON matrix (defaults to + ``default_matrix_path()``). + enabled_subsystems: Subsystems to gate on. + na_ids: Requirement ids to treat as not-applicable. + gate: Whether to fail the run on a gap. + """ + self._index = index if index is not None else PLACEHOLDER_INDEX + self._output_path = Path(output_path) if output_path else default_matrix_path() + self._enabled = frozenset(enabled_subsystems) + self._na = frozenset(na_ids) + self._gate = gate + self._collected: dict[str, list[str]] = {} + + def pytest_collection_modifyitems(self, config: Config, items: list[Item]) -> None: + """Record every ``@pytest.mark.req(id)`` claim against its test node.""" + for item in items: + for mark in item.iter_markers(name=MARKER_NAME): + for rid in mark.args: + self._collected.setdefault(str(rid), []).append(item.nodeid) + + def pytest_collection_finish(self, session: Session) -> None: + """Write the matrix artifact and, when gated, fail on any MUST gap.""" + import pytest + + result = audit( + self._index, + self._collected, + enabled_subsystems=self._enabled, + na_ids=self._na, + ) + self._write(result) + if self._gate and result.gaps: + ids = ", ".join(gap.requirement.id for gap in result.gaps) + raise pytest.UsageError( + f"traceability audit failed: uncovered MUST requirement(s): {ids}" + ) + + def _write(self, result: AuditResult) -> None: + """Serialise the matrix to the configured output path.""" + self._output_path.parent.mkdir(parents=True, exist_ok=True) + self._output_path.write_text(render_matrix_json(result, self._index), encoding="utf-8") + + +def _split_env(value: str | None) -> frozenset[str]: + """Split a comma-separated env value into a set of non-empty tokens.""" + if not value: + return frozenset() + return frozenset(token.strip() for token in value.split(",") if token.strip()) + + +def build_plugin_from_env() -> TraceabilityPlugin: + """Build a ``TraceabilityPlugin`` configured from environment variables. + + Returns: + A plugin honouring ``DEXPACE_TRACEABILITY_*`` overrides; with none set + it emits the matrix to the default path and gates nothing. + """ + na_path = os.environ.get(ENV_NA) + gate = os.environ.get(ENV_GATE, "").strip().lower() in {"1", "true", "yes"} + return TraceabilityPlugin( + default_index(), + output_path=os.environ.get(ENV_MATRIX), + enabled_subsystems=_split_env(os.environ.get(ENV_ENABLE)), + na_ids=na_ids_from_file(na_path) if na_path else (), + gate=gate, + ) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point: dump the placeholder matrix (no coverage data). + + Args: + argv: Argument vector (defaults to ``sys.argv[1:]``). + + Returns: + A process exit code (always ``0`` on success). + """ + parser = argparse.ArgumentParser( + description="Render the placeholder traceability matrix.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--format", choices=["json", "csv"], default="json") + parser.add_argument( + "--write", action="store_true", help="write to the default matrix path instead of stdout" + ) + args = parser.parse_args(argv) + # Dump the real vendored index (no coverage data outside a pytest run). + index = default_index() + result = audit(index, {}) + if args.format == "csv": + text = render_matrix_csv(result, index) + else: + text = render_matrix_json(result, index) + if args.write: + path = default_matrix_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + print(f"wrote matrix to {path}") + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index ebc431b..fee2f56 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", ] [manifest] @@ -13,16 +14,17 @@ members = [ "dexpace-sdk-http-httpx", "dexpace-sdk-http-requests", "dexpace-sdk-http-stdlib", + "dexpace-sdk-tck", "dexpace-sdk-workspace", ] [[package]] name = "aiohappyeyeballs" -version = "2.6.1" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] @@ -140,53 +142,56 @@ wheels = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] name = "ast-serialize" -version = "0.3.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, - { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, - { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, - { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, - { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, - { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, - { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, - { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, - { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, - { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, - { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, - { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, - { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] @@ -200,84 +205,84 @@ wheels = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -291,86 +296,71 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.0" +version = "7.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/83/6051c2a2feab48ae5bd27c84ef047191d2d4a3172f689e38eaa48ed17db1/coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67", size = 927640, upload-time = "2026-07-12T20:58:19.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/32c1826309beaf4604c54accef108fdd611e5e5e93f2f5192f050cd5f6bd/coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80", size = 221497, upload-time = "2026-07-12T20:56:37.628Z" }, + { url = "https://files.pythonhosted.org/packages/db/5c/b88ce0d68fa550c7f3b58617fbf363bce64df5bf8295a01b627e4696e022/coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189", size = 221854, upload-time = "2026-07-12T20:56:39.033Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/8509fd2a66fc4e0a829f76a0f0b1dc3cc163368352435b5f243168658077/coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615", size = 253359, upload-time = "2026-07-12T20:56:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/e5/81/c7009ed7ea9765adb2b9d095054d748266fae5f07ac6c5f925f33715fcde/coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3", size = 256096, upload-time = "2026-07-12T20:56:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/21/52/dc8ee03968a5ba86e2da5aa48ddc9e3747bd65d63825fdce2d96acb9c5ff/coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304", size = 257211, upload-time = "2026-07-12T20:56:43.513Z" }, + { url = "https://files.pythonhosted.org/packages/b8/27/95d7623908da8937deb53d48efcdbf423907a47540e63c62fa21372c652b/coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c", size = 259473, upload-time = "2026-07-12T20:56:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3b/730d761928de97d585465680b568ae69622fb40716babadeabffe75cb51b/coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b", size = 253759, upload-time = "2026-07-12T20:56:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fc/6b9277acff1f9484b6c12857af5774689d1a6a95e13265f7405329d2f5da/coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61", size = 255131, upload-time = "2026-07-12T20:56:48.073Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/c704f86129594ba34e25a64695d2068c71d51c2b98907184d716c94f4aec/coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e", size = 253275, upload-time = "2026-07-12T20:56:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/29/80fee8af47de4a6dce71ccf2938491f444687a756af258a56d8469b8f1b0/coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5", size = 257345, upload-time = "2026-07-12T20:56:51.038Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/a1e7d7ed1b48a8adf8fd5154d9e83fcc5ad8e6ff20ae00e44865057dce8d/coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d", size = 252844, upload-time = "2026-07-12T20:56:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/a4bc26e6ee207d412f3678f04d74be1550e83140563ca0e4997510579712/coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2", size = 254716, upload-time = "2026-07-12T20:56:53.968Z" }, + { url = "https://files.pythonhosted.org/packages/11/9d/8ad0266ecfada6353cf6627a1a02294cf55a907521b6ee0bd7b770cfd659/coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76", size = 223554, upload-time = "2026-07-12T20:56:55.583Z" }, + { url = "https://files.pythonhosted.org/packages/81/6d/24224929e06c6e05a93f738bc5f9e8e6ab658f8f1d9b823e7b85430e28b8/coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374", size = 224087, upload-time = "2026-07-12T20:56:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/35/23/f81441dd01de88e53c97842e706907b307d9078918c3f4998b11e9ac7250/coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a", size = 223472, upload-time = "2026-07-12T20:56:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/6fa289d7993a2a39f1b283ddb58c4bfec80f7800be654b8ba8a9f6a07c63/coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a", size = 221519, upload-time = "2026-07-12T20:57:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/0db4902a0588234a70ab0218073c0b20fbc5c740aa35f91d360160a2ebc9/coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b", size = 221895, upload-time = "2026-07-12T20:57:01.867Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/3719783865092dac5e08df842730305ee9ab1973ae7ddb6fbdf27d401f30/coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e", size = 252882, upload-time = "2026-07-12T20:57:03.459Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5e/caf3abbdbb22629626160ffc9c017eb995b7cb11c0be46b974834cef1792/coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a", size = 255479, upload-time = "2026-07-12T20:57:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f1/d60f375bfe095fef944f0f19427aefdbf9bdd5a9571c41a4bf6e2f5fdb81/coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c", size = 256715, upload-time = "2026-07-12T20:57:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/d7/17/8b0cbc90d02dc5adad4d9034c1824ec3fa567771b4c39d9c1e3f9b1431b8/coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90", size = 258845, upload-time = "2026-07-12T20:57:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/c5e69f5fb75c322e9a3e4ef64d02eebfc3d66efceccc8514ff80a3c13a56/coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f", size = 253098, upload-time = "2026-07-12T20:57:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/21144252fdd0c01d707d48fbcea13a80b0b7c42ced3f299f885ab8978c3a/coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8", size = 254844, upload-time = "2026-07-12T20:57:11.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/2a/499a28a322b0ce6768328e6c5bb2e2ad00ac068a7c7adb2ecd8533c8c5d9/coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a", size = 252807, upload-time = "2026-07-12T20:57:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/928a95da5da8b60f2b00e1482c7787b3316188e6d2d227fb8e124ada43a1/coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31", size = 256965, upload-time = "2026-07-12T20:57:14.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/10/889adbc1b8c9f866ed51e18a98bcafc0259fb9d29b81f50a719407c64ea8/coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad", size = 252628, upload-time = "2026-07-12T20:57:15.892Z" }, + { url = "https://files.pythonhosted.org/packages/1a/30/a5e1871e5d93416511f8e359d1ccebfe0cbb050a1bbf7dd20228533ec0cf/coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a", size = 254399, upload-time = "2026-07-12T20:57:17.703Z" }, + { url = "https://files.pythonhosted.org/packages/2d/26/c36fbffd549dadbdd1a75827528fb00a4c46aa3187b007b750b1e2cebbf2/coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e", size = 223564, upload-time = "2026-07-12T20:57:19.253Z" }, + { url = "https://files.pythonhosted.org/packages/16/fc/becbb9d2c4206d242b9b1e1e8e24a42f7926c0200dd3c788b9fab4bb96d5/coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7", size = 224106, upload-time = "2026-07-12T20:57:21.108Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/1cfc641461369b6858799fca61c0a8b5edc490c519bf7c636ffa6bbf556f/coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49", size = 223497, upload-time = "2026-07-12T20:57:22.734Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/81961952e7aebfb38ad0ae4264e8954cc607a7af9e7ac111f9fa986595cc/coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8", size = 221560, upload-time = "2026-07-12T20:57:24.282Z" }, + { url = "https://files.pythonhosted.org/packages/13/d2/ee14d715889f216baf47301d9f469e08fff6995552aaf67e897b282865f6/coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4", size = 221894, upload-time = "2026-07-12T20:57:25.87Z" }, + { url = "https://files.pythonhosted.org/packages/f3/38/f830bc6e6c2c5f23f43847125e6c650d378872f7eeba8d49f1d42193e8a9/coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81", size = 252938, upload-time = "2026-07-12T20:57:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/0d3dd963631259d794c898735d5436e68d6a8d40749c419a07ff7c171469/coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca", size = 255445, upload-time = "2026-07-12T20:57:29.234Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/aabed228557565c958259251b89bab8c5669b31291fa63b3e2154ebb017a/coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74", size = 256790, upload-time = "2026-07-12T20:57:30.826Z" }, + { url = "https://files.pythonhosted.org/packages/bc/aa/1cc888e5d3623e603c4e5399653cb25728bb2b40d7519188a3e293d24620/coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047", size = 259104, upload-time = "2026-07-12T20:57:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/fc16d5f5e53098dae41efa21e8ccc611a9b4fe922750dd03dc56db552182/coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9", size = 252956, upload-time = "2026-07-12T20:57:34.316Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f3/52384668c3de4519ca770bf1975a89e4d6eb5aa2faf0da0577a14008cba4/coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652", size = 254797, upload-time = "2026-07-12T20:57:35.947Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/54b807e7c1868178e902fd8360b5d4e559394462f97285c50edf1c4608db/coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4", size = 252762, upload-time = "2026-07-12T20:57:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/dde8adf0338e3ace738757dccf1ce817e5fdcadfae77e1b48a77e5a3b265/coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c", size = 257037, upload-time = "2026-07-12T20:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/179dd88cf60a0aeeee16a970ffe250dccea8b80ed4beab4c5d3f6c41ad4b/coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633", size = 252577, upload-time = "2026-07-12T20:57:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/2c/37/8a593d69ab521beb6a105a2017cac4ba94425ee0a8349e29c3c0b522d24f/coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41", size = 254235, upload-time = "2026-07-12T20:57:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/bc9b3bced66f2cdad4bf5e57ae51c54ea226e8aaaebfc9370a9a11877bf3/coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b", size = 223771, upload-time = "2026-07-12T20:57:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/4d20337bed61915d14349e62b88d5e4144d5a9872b64adbe90e9906db6db/coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a", size = 224257, upload-time = "2026-07-12T20:57:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/7b/df/bbfeae4948f3ded516f92b32f2d57952427fc5ecfc0924487bb6ee6a5f38/coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74", size = 223683, upload-time = "2026-07-12T20:57:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/35/65/0b431856064e387d1f5cf474625e4a0465e907024d42f35de6af19ced0be/coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b", size = 222298, upload-time = "2026-07-12T20:57:49.882Z" }, + { url = "https://files.pythonhosted.org/packages/a6/96/50eac9bd49df8a3df5f3d38746d1bf332299dffb554486c94ebd55c9dc49/coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49", size = 222561, upload-time = "2026-07-12T20:57:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5b/6ba1c4a27e10b8816fd2622b98162c83d3bdf1185097360373611bf96364/coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864", size = 263923, upload-time = "2026-07-12T20:57:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/fe03ade97a3ca2d890e98c572cf48a99fda9adba85757c34b823f41efe1e/coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c", size = 266043, upload-time = "2026-07-12T20:57:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/e0/55c4b1217a572a43e13b39e1eb78d0da29fb23679003bd0cdf22c50b1978/coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07", size = 268465, upload-time = "2026-07-12T20:57:57.017Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/ee47944f76afc03909119b036fe9e0da8cbd274a5141287de79791a0fb6d/coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e", size = 269584, upload-time = "2026-07-12T20:57:58.958Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8a/6b4d9779c7b2e21c3d12c3425e3261aa7411399319e27aa402dfec4db5d0/coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e", size = 263019, upload-time = "2026-07-12T20:58:00.979Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1e/db5c7fa0c8ba5ece390a1e1a3f30db71d440240a80589df28e66a7503c40/coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf", size = 265916, upload-time = "2026-07-12T20:58:03.005Z" }, + { url = "https://files.pythonhosted.org/packages/83/53/fe5176682b00709b13fab36addd26883139d0dea430816fea412e69255e2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82", size = 263520, upload-time = "2026-07-12T20:58:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/16f15127be93fbc70c667df5ec5dce934fc76c9b0888d84969a5d5341e2c/coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82", size = 267254, upload-time = "2026-07-12T20:58:06.824Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/e5119111f6f065376395a525f7ce6e9174d83f3db6d217ea0211a61cca4d/coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7", size = 262366, upload-time = "2026-07-12T20:58:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9c/6d0a81182df18a73b081e7a8630f0e2a52b12dfd7898c6ab839551a454d2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594", size = 264680, upload-time = "2026-07-12T20:58:10.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a2/bac0cbd4450638f1be2041a464b1766c8cc94abf705a2df6f1c8d4be870d/coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070", size = 224077, upload-time = "2026-07-12T20:58:12.065Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b2/d83c5403155172a43ba47c08641bad3f89822d8405102423a41339d2c857/coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f", size = 224908, upload-time = "2026-07-12T20:58:13.956Z" }, + { url = "https://files.pythonhosted.org/packages/cc/41/442b74cad832cc77712080585455482e7cc4f4a9a13192f65731dcd18231/coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b", size = 224219, upload-time = "2026-07-12T20:58:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/34/98/07a67cf1a26e795d617ed5c540c042b0ac87b72f810c30c07f076cf334f3/coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c", size = 213284, upload-time = "2026-07-12T20:58:18.079Z" }, ] [[package]] @@ -395,7 +385,8 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.9,<4.0" }, + { name = "aiohttp", marker = "python_full_version < '3.14'", specifier = ">=3.10,<4.0" }, + { name = "aiohttp", marker = "python_full_version >= '3.14'", specifier = ">=3.13,<4.0" }, { name = "dexpace-sdk-core", editable = "packages/dexpace-sdk-core" }, ] @@ -440,6 +431,30 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "dexpace-sdk-core", editable = "packages/dexpace-sdk-core" }] +[[package]] +name = "dexpace-sdk-tck" +version = "0.1.0" +source = { editable = "packages/dexpace-sdk-tck" } +dependencies = [ + { name = "dexpace-sdk-core" }, +] + +[package.optional-dependencies] +test = [ + { name = "hypothesis" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "dexpace-sdk-core", editable = "packages/dexpace-sdk-core" }, + { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.100" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.1.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.4.0" }, +] +provides-extras = ["test"] + [[package]] name = "dexpace-sdk-workspace" version = "0.0.0" @@ -450,10 +465,13 @@ dependencies = [ { name = "dexpace-sdk-http-httpx" }, { name = "dexpace-sdk-http-requests" }, { name = "dexpace-sdk-http-stdlib" }, + { name = "dexpace-sdk-tck" }, ] [package.dev-dependencies] dev = [ + { name = "hypothesis" }, + { name = "import-linter" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -468,10 +486,13 @@ requires-dist = [ { name = "dexpace-sdk-http-httpx", editable = "packages/dexpace-sdk-http-httpx" }, { name = "dexpace-sdk-http-requests", editable = "packages/dexpace-sdk-http-requests" }, { name = "dexpace-sdk-http-stdlib", editable = "packages/dexpace-sdk-http-stdlib" }, + { name = "dexpace-sdk-tck", editable = "packages/dexpace-sdk-tck" }, ] [package.metadata.requires-dev] dev = [ + { name = "hypothesis", specifier = ">=6.100" }, + { name = "import-linter", specifier = ">=2.0" }, { name = "mypy", specifier = ">=1.10" }, { name = "pytest", specifier = ">=9.1.0" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, @@ -581,6 +602,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/8c/dce3b1b7593858eba995b2dfdb833f872c7f863e3da92aab7128a6b11af4/furl-2.1.4-py2.py3-none-any.whl", hash = "sha256:da34d0b34e53ffe2d2e6851a7085a05d96922b5b578620a37377ff1dbeeb11c8", size = 27550, upload-time = "2025-03-09T05:36:19.928Z" }, ] +[[package]] +name = "grimp" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734, upload-time = "2026-07-03T12:09:36.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/66/621abde26d8ece0d34ba611ca94bc62bf8c9c4389760d8909b0d96964878/grimp-3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:915ea140cf55107fd6825c3e9eae2c4fda18aa19b87e8eee05d510b4d44ab928", size = 2143914, upload-time = "2026-07-03T12:08:50.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/76/a27fff8de84dbf46db9d6da937fe772f04a0e21e057a4863fd30e6fcaa55/grimp-3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ae2d4d958d871792a9686ad51845e9e1e0886e9db13ecc47a9475899f4b27db", size = 2089296, upload-time = "2026-07-03T12:08:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3d/fe38a0881ce7e00ef8590745853bccff5d337a943dc0d1d0735b0eb605f9/grimp-3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19a1039d50ed6a9b221f44b32c7b07cb43dda70971e892fe8149995c0e9c1840", size = 2254829, upload-time = "2026-07-03T12:07:34.365Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a2/ef18989048e8f0c92171eabb15dffe9cd72de6404b86e3a37553f7d16dd6/grimp-3.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b7b649f2a34278897237670b6650073c9ab0fc1abf56821301bda28cb5c4256", size = 2193553, upload-time = "2026-07-03T12:07:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/82303539d21068021cc28c526be5e1b1cc0b7a61704c1663909497dd9b8a/grimp-3.15-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020a8875c0cb67f407eb019b7be65b574d49071653f155c243f801aa87a1fd4d", size = 2345941, upload-time = "2026-07-03T12:08:18.082Z" }, + { url = "https://files.pythonhosted.org/packages/bd/20/3c66e7c814ba2b6b0cd230ca2445825c605f28242f4f3a658e5bb9adda73/grimp-3.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f0a0705cf9a10648c4aea71edde8fe3dfbf1cf05bd434ef38c813ad7c544886", size = 2604369, upload-time = "2026-07-03T12:07:55.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/2f642969950e096a67a43909ba66f33cb4750974e30c2c771e293aeb787c/grimp-3.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36373cd0a2d4c9b53fabefbaa9edcc4c511ad2d335fb1296484f6ca550e4f82", size = 2326619, upload-time = "2026-07-03T12:08:05.931Z" }, + { url = "https://files.pythonhosted.org/packages/a1/99/98d39545e54e239a52a54d8e96752780778b11b5ebc78096dfa090f9d2ac/grimp-3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:477ac0abcd12c697a0bd01b40875605f7f0db97332df6148e4d4057ee3ad199d", size = 2272955, upload-time = "2026-07-03T12:08:30.488Z" }, + { url = "https://files.pythonhosted.org/packages/d6/02/fabd5ae2b12276530f4bae038ffcf3a556ac2c9b9fa271f83fbeb4036a08/grimp-3.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:017e493fee9962d50db6f7a8b5d49ad2ae508484a06078d700adbef30f1ff1f0", size = 2431271, upload-time = "2026-07-03T12:08:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c8/fa3ec84df9c3ccc2b08177be41a48b76178e9e5773f4471f1caff4fc5c46/grimp-3.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e77982dd5b0977945034fa328f65cfb62ff589cb0da97a0f6042de78525c5c73", size = 2466937, upload-time = "2026-07-03T12:09:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/52d3acd2bbd6cebdf2ee6546c334f50f6358c25ae58624ae63d2ec3ad30b/grimp-3.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d129a8c57b7a19a44e8da94caf38a02753f1c9953aaba8c1a4633747f097164f", size = 2504734, upload-time = "2026-07-03T12:09:17.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/53/ad27750eb8b4a0c3ecb5ca7d78c7230f0f5e814515ed6f8986be527117ff/grimp-3.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba25002e92b1792f13391295a1f805d2e09781b846d92ec1a791ff9ed89298b6", size = 2514448, upload-time = "2026-07-03T12:09:28.401Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c0/8cc474a24198c1c2936269c5854be92af41787bd76d3190af584a9cebca7/grimp-3.15-cp312-cp312-win32.whl", hash = "sha256:232bf7a4c7536f62a99478eeb01de63c455261add35ee00c6ec9f04f280f853e", size = 1855806, upload-time = "2026-07-03T12:09:48.396Z" }, + { url = "https://files.pythonhosted.org/packages/91/11/e46139fd43dd5714fae93f09f1c858fb2dc83a575d5f8cb1daf8a15a261b/grimp-3.15-cp312-cp312-win_amd64.whl", hash = "sha256:13be2285e358a7687c0f3b798ac9d4819f275976ad8d651297966e5a75bfafb9", size = 1985556, upload-time = "2026-07-03T12:09:40.786Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a", size = 2143197, upload-time = "2026-07-03T12:08:51.685Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde", size = 2088342, upload-time = "2026-07-03T12:08:44.227Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4", size = 2254509, upload-time = "2026-07-03T12:07:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22", size = 2193831, upload-time = "2026-07-03T12:07:46.309Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522", size = 2345289, upload-time = "2026-07-03T12:08:19.458Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2", size = 2604407, upload-time = "2026-07-03T12:07:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d", size = 2326769, upload-time = "2026-07-03T12:08:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082", size = 2272498, upload-time = "2026-07-03T12:08:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3", size = 2431075, upload-time = "2026-07-03T12:08:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc", size = 2467361, upload-time = "2026-07-03T12:09:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc", size = 2503500, upload-time = "2026-07-03T12:09:19.585Z" }, + { url = "https://files.pythonhosted.org/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c", size = 2513834, upload-time = "2026-07-03T12:09:29.758Z" }, + { url = "https://files.pythonhosted.org/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2", size = 1855743, upload-time = "2026-07-03T12:09:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507", size = 1984546, upload-time = "2026-07-03T12:09:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4f/311bd40c02d61eee0182cb8c9b6ded37d42bed9a334e5ba4dacbe1c4c997/grimp-3.15-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6db0b30683612be4571b6b6208e1b39b77faa54566c5ca4f086d64cc783e4864", size = 2144799, upload-time = "2026-07-03T12:08:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/86e941cc26bd3419b5e2abb8b48fa35b850e5508f14e033f3ce28bfce608/grimp-3.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e731a1f8a192a802e04d4018d6cce9f4a12ce48b6b73f43014bd4e0a5d04a8e9", size = 2090802, upload-time = "2026-07-03T12:08:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/6c/09/bcb4e380596e533ef9ebb3f164ad3cc113fde62318ca5af71a27886b1612/grimp-3.15-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb607ee3e16440fc59ec2b49eef1b6dbbe701af9e99990b6491e6f120bd60b5", size = 2255870, upload-time = "2026-07-03T12:07:37.207Z" }, + { url = "https://files.pythonhosted.org/packages/c3/84/361cebbd7b14ce15d93bfb65e2b0ff30287252ffa6ab0b7fe08e029eae6c/grimp-3.15-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0492b9f1120176146d81e51aa0691aa6c004cdf5192ab309a221f9b223dedfc", size = 2193359, upload-time = "2026-07-03T12:07:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d6/c4dd785e149c3c66494822c8b46f0a36cbdf5a5400eeb2172e0a8b029d0f/grimp-3.15-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bb3dbb0471179e732400fdf31c8c8d0299c88d13dd3a3bbe77ce54fb3f1b545", size = 2346350, upload-time = "2026-07-03T12:08:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/4b/470922cb9d0a4b0436bb298801f770c98c6953374ff84e545dd7a196aa66/grimp-3.15-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b578512dbaee7eba2900d8078eaf1f7f78aa7616c609b0849b8403012ffddda", size = 2604959, upload-time = "2026-07-03T12:07:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/061dd80f5f0abb3ac53b29ade25ffac3e464e6853e8ce31dc6c6a33a06a9/grimp-3.15-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a550a14cd2f8123fb06814b705f3af093fa1728b244f21ebcccc8d0da0f851", size = 2327185, upload-time = "2026-07-03T12:08:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/32281e7b5fcd5622f4666b36003b9931ca0e112f2955f09458f198a30f65/grimp-3.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db5ebb94ec356eaa5242145c993bc84c4b52d0d2c6980dfcd12b22d51c5b2c46", size = 2273241, upload-time = "2026-07-03T12:08:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/b4f6ae15484541decbe4f12cf39a4a769352cb06332e428cc8e64aed4a32/grimp-3.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e03331418d7fee746e2447ecf5296986e6f094ef15fd25125efad2328844edf7", size = 2433114, upload-time = "2026-07-03T12:09:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/a2/7bdc628435a1e908aa53b351ce031a6134efd18245c4a5a4c28e1e6d19aa/grimp-3.15-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4cc91cb69e73e7899feb6ce73747f4dc092c2bfe2653f6cba69a398cd1dfc6ea", size = 2467235, upload-time = "2026-07-03T12:09:10.677Z" }, + { url = "https://files.pythonhosted.org/packages/5d/18/77853f5693d607d5f49c7e3cd58e482ef8362ea9cf86d0fcb108e4168195/grimp-3.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a848d5b4b656c1e3a28cce0d399f2790ecbeb2eeb78bb49896018614c87219f3", size = 2506552, upload-time = "2026-07-03T12:09:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/a094d7dd7115e8764e8069d5d3a44045c333b41d98a5746e99ec712b4b18/grimp-3.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:208ef56397cabfab52864b3d8a461fb5015a056fede1be4587e4453b13aa8c2f", size = 2514255, upload-time = "2026-07-03T12:09:31.383Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/013c640df50968b5d25802a5f01b157514d4e0ccd48c37c63947610a0e05/grimp-3.15-cp314-cp314-win32.whl", hash = "sha256:74d32fae3d222888f6b61579998043579dd810ed948785896e552906cbfdfcb7", size = 1856182, upload-time = "2026-07-03T12:09:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/648a1d77dfc5c2fd24fbdca0a45ee1c24669d981d12652424f5c34e3352c/grimp-3.15-cp314-cp314-win_amd64.whl", hash = "sha256:6b36e179d485c797e7fa234803620af752039fa27a8c7e3182333d7c96041f70", size = 1985451, upload-time = "2026-07-03T12:09:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/fd88ea799d181c22ab47c6cb328e7be3e196c8395444af89fd8da401cf6b/grimp-3.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9107d1037ee6e250ab7a15e1b758e0af76c841c19838b3cc688885a0b3817b5", size = 2253182, upload-time = "2026-07-03T12:07:39.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/d9/25377ba259772edfa97e35e265d89eb89b966a82652967c8479902741067/grimp-3.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b88975b2882edc55e8946640f7b36dfb83cce1303cff5f8528c3f4819d7fbb07", size = 2191822, upload-time = "2026-07-03T12:07:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/cae8ca43e05aa5217ca2e17ffbda77e86cb215c1a9a03592c110c0162f27/grimp-3.15-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fe01a5393e415e3d99b22c7dc6c6a9cc1b633714707d1c6b56ecbaccc2132f5", size = 2344413, upload-time = "2026-07-03T12:08:21.976Z" }, + { url = "https://files.pythonhosted.org/packages/39/32/feea8fec71e62394013865314854ccb3d7bb54f226564fd267e6662f509a/grimp-3.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd4d70a0de010a9b452b59326a00574f7488dd1333d003df624114e5e00e877e", size = 2602856, upload-time = "2026-07-03T12:08:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/46ccdeb698398264d774273a9b8d9b013c8d23127d05371489b22dcf3ea5/grimp-3.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:204beed673ff43ab4ebe52ff4efd29b80025ec777136a62109afb69792d7fae0", size = 2326095, upload-time = "2026-07-03T12:08:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/69/ae/f98c2c964a850ab80341d02576ef8681e75178f893ba2c73dc03e6563925/grimp-3.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1602be339f8a4d368d71758814e5505200cf665818eb0f9a2cc74d71ecc8cf1c", size = 2274375, upload-time = "2026-07-03T12:08:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/bb/38/9355cdb28fab458fb8defca6406de303d78af617aa0d8c9ae5253b4e5a8b/grimp-3.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:077c27a1e6ff3baf23a8caaa1d74cbbac27024b069956dd6ca5fc3acd43ddb4b", size = 2430307, upload-time = "2026-07-03T12:09:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/edfa7f8064c1a3502fe4aba56c07bd122e94e329188939d52c02bd7e85b6/grimp-3.15-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5516fc38899f4396eff68e6b1997310b4de2d0155e3fad9f545e666c993985b3", size = 2464014, upload-time = "2026-07-03T12:09:12.185Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c2/153b5cc3106814504b4195c7d4c178d85732d35b8895185a02112b8f9a03/grimp-3.15-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dbe8e14cc61af4eea8e7ed6f2108828101f57fe86273d5b61cff044b69e6c6b5", size = 2502010, upload-time = "2026-07-03T12:09:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3f/ae4ba51cf484030e3d15b3304eb7ab9039fe91fb35ff5e90d60fde135bff/grimp-3.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1763b7b48ed6c9e6de838d0d64f19510a392235266cf2240c9be9cc25ca7c54b", size = 2514787, upload-time = "2026-07-03T12:09:33Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5f/0fb2d2bc9fe6bce899947802c94531c24e581c715db19216b941c1b2422f/grimp-3.15-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:116c3a6dba61bd302919b82cb5d603f5977e321d80d7de3c7a1265357ab9dd66", size = 2345635, upload-time = "2026-07-03T12:08:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/00/ac/05d859a62ed9282f43a0f0962da230c46a2eb3d3ecb5b39117b3b4888405/grimp-3.15-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc2e5065d35047729e41a55c9c3eb99810cf322fe3b3e89fa126f306ce6b3b5", size = 2273647, upload-time = "2026-07-03T12:08:36.139Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dd/f726316f54e29da4f24d545d6299e1ea0accfbbf52729077fd4a619de055/grimp-3.15-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b38327673df49203cff0ebcbbf078999a80f0b11bb654760059f6f00f56f64d6", size = 2344080, upload-time = "2026-07-03T12:08:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/523a8f969f0a0b0a8ce43fbe8bc7a04e90f52724efc85f3305f1e07ae626/grimp-3.15-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965b37dad2f73164a103381c9fd1255bb3b2f6021ca2f38ffe70dd00fdc0fc55", size = 2275041, upload-time = "2026-07-03T12:08:37.582Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -618,13 +703,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypothesis" +version = "6.156.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/83/8dbe89bdb8c6f25a7a52e7898af6d82fe35dfef08e5c702f6e33231ce6c6/hypothesis-6.156.6.tar.gz", hash = "sha256:96de02faefa3ce079873541da96f42595583bb001e8e4219294ed7d4501cc4cc", size = 476304, upload-time = "2026-07-10T20:56:49.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/dc/0c2a851f06c91d5ac9ef0f3b9615efc1ed650411d2eee23b6334f491c85e/hypothesis-6.156.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:caf6a93d011c10972da111c38ceb34ced20feaa8581e2b350c0655b022e27875", size = 747998, upload-time = "2026-07-10T20:56:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f8/59203ca978ab51595d12d6bc7e7a63300d7373431ab42ca3f1742e45db68/hypothesis-6.156.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:07f2bc9df1aeba80e12029c1618e2ee54abc440068c305d7075ffd6b85251843", size = 743073, upload-time = "2026-07-10T20:55:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/68/d8/86a0023740434098d1b187a62bd5f99b198f098fb43e7fc58342283a8270/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baca17f4803ad4aa151732326f3990baf54c3127df44aa872ac5bdf8a98a9a6", size = 1070169, upload-time = "2026-07-10T20:55:49.47Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/673453915fd0c67673f35a4876ba88f48c621335f293f3537d77b27d4286/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8083806645f84243aade727f4978185caaa0b7190af4318673999ee15fdbf424", size = 1121760, upload-time = "2026-07-10T20:55:53.502Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/3a5557f52912f2fecc6ed59642dcf80dd8e89d0d9664502b68e23d66bf3d/hypothesis-6.156.6-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a922eedcd8618f9c2e17b79fa7b3f3f0b2df34e201958611cc3f0f46cca33c10", size = 1111440, upload-time = "2026-07-10T20:55:43.054Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/ae636d4ca7f996a1ccb4b3d5997d949f1718fba52b01559b3ab53b237b3f/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5291bd33c4704d274d7c214d5c200e77f372a06644f5cbbe96dcbe53cb2fbf10", size = 1244944, upload-time = "2026-07-10T20:55:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/1e/79/c425d22d734be0268ca60d120c6296299e4220a1783cb1a4cc76232807bb/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55f3ec50161b4a95bae63bff2b5166e45935b493013d3be30ede279bf6192318", size = 1288808, upload-time = "2026-07-10T20:56:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/cc9f479d22cbdd36ddfc55a978378eddadd183b09339ebdb81be33bb18e7/hypothesis-6.156.6-cp310-abi3-win32.whl", hash = "sha256:e96570ca5cdd9a5f2ff9e80a6fb2fd5420ebf33b833d7de5b09b6ebb26a3eb6c", size = 634868, upload-time = "2026-07-10T20:55:37.959Z" }, + { url = "https://files.pythonhosted.org/packages/d6/89/2008d287289841a936456cb13443ca89d88da6e4527d611d482e9544164d/hypothesis-6.156.6-cp310-abi3-win_amd64.whl", hash = "sha256:32710718c22fe8c5571464e898bb87d282837b02617d6ad68130abf7cb4843cb", size = 640382, upload-time = "2026-07-10T20:55:30.634Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9f009005b9c796f4a40424484ac7e70847bc088456fd940a937f96bb4b6d/hypothesis-6.156.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a2a728b514fceb81e3f0464508911d5220fd74dadc3270f859427a686b60c4cf", size = 748844, upload-time = "2026-07-10T20:56:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/4d852bb8a9c73a68b18eca9b5b085285282122166e158f4d2a477639bfee/hypothesis-6.156.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7489b9a8f9df8227edd6c7cd8b9ccfab2483bab24da6a474c175973ca2294f58", size = 741936, upload-time = "2026-07-10T20:55:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/b9968070ae042f9bf3149bb6ba6399d5f28f452e0fb7f638cafc69ff0b9a/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42760873d6db1069d6edbaa355a61b9078a9950259efcfc72fc695741d7db7cd", size = 1069749, upload-time = "2026-07-10T20:56:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/00/a9/753806f5292b40aeab1d269e408e3a7e85be3c0d88828fb78ab4a34d6626/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e66aaa7385538a5d617174d47c198ee807f06de99e282a67c6cb724c69340d", size = 1120983, upload-time = "2026-07-10T20:56:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/85/88/8386d064d680be27e936eba94f1448bc93ef6fa05473ee5034139f1c4284/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:08796b674c0b31a5dd4119b2173823390055921588d13eb77324e861b00fd7f8", size = 1243911, upload-time = "2026-07-10T20:55:54.799Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8c/7524c1e5279e7728eb47c99f2357cbc5f08ae92e9bce49bf50118b53f9c9/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4ca8cc26ea2d31d22cf7710e92951cfaa921f0f8aa1b6db33a5176335f583a4f", size = 1287806, upload-time = "2026-07-10T20:56:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/c347ad913e1c5f2988956fe17826c0400b4ce470b973e6c248e97b6a0acf/hypothesis-6.156.6-cp312-cp312-win_amd64.whl", hash = "sha256:c3363d3fb8015594636689572510bb6090602d8e8e838a5693c2d52d3b5b09d8", size = 637679, upload-time = "2026-07-10T20:55:39.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/9583fe153573523dac27226c89e041a86ad4aeeae08c868160cbb93d39d2/hypothesis-6.156.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:59a8def90d9a5a9b67e1ac529e903a2363ceb6cf873c209da6b4284c5daab671", size = 749264, upload-time = "2026-07-10T20:56:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/86/35/e4113d06769b544f0fb77ffea9195b598b4c56a298905c21fd47c4eed388/hypothesis-6.156.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c574c3224563d730848bc5d1ef1683c4f83993400c0167899fe328f4bfcd4725", size = 742095, upload-time = "2026-07-10T20:56:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/a47666ede10384e8978722cade7ab96a42df71d2ab577317092d0fed7c8a/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01bb8270c46b3ef53b0c2d23ff613ea506d609d06f936d823ea57c58b66b05f7", size = 1069917, upload-time = "2026-07-10T20:56:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/93/75f6057dadd9dc0134f37c08d5d14d04d3cd7374debbcb0cc4569c6712f1/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ea6559c13606e13b645927f2e0906e52b5ac5d99b40d3abaaeb2e8c7ceeb75", size = 1121204, upload-time = "2026-07-10T20:55:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/62/87/308efef08bc60d1e673d035e8ca8e9663f4b6b3ba519c3cdebf6583c2b76/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2d47054d0230f0dd9b6868fc030126c7a6c25527144272ff376cc4e9c39f7540", size = 1244168, upload-time = "2026-07-10T20:55:40.288Z" }, + { url = "https://files.pythonhosted.org/packages/3b/66/de8fff5bd9a40a4056dafbe7f904887ef12632282bbbac90f1977c30dd3b/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:050c8c0815f88d47dd0875a92698d20d61639b7b721ee043a6d687c7f14ff7d8", size = 1288127, upload-time = "2026-07-10T20:56:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/794fb26e1fd3ff004978f8f18b7aa7e1c2270ba72e1f977b987a812064f8/hypothesis-6.156.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0d73edab7b8a0051b3634f2d04d62b7e7282f8f274963b11188ee4957d672ef", size = 637954, upload-time = "2026-07-10T20:56:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/5b4b27984cb43c60e95f570b069660335dad34cb67f7d226017c5d35d31e/hypothesis-6.156.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:34a70a7b8226e34d658072d8fb81d03f97f0a75ceb536329a321b94ce2232fd6", size = 749312, upload-time = "2026-07-10T20:55:46.902Z" }, + { url = "https://files.pythonhosted.org/packages/31/11/709cceffc28666c9d4cb75ffc6df5ce30db8c7dd5cc2c8b38a2fd837427f/hypothesis-6.156.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f1969646beead7d8cf6a2537d2765af89d73056e2cb218e7fae92b83802250a3", size = 742332, upload-time = "2026-07-10T20:56:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/cfc79b13d8dd3cd6de6b9df921c557efe8528a9c90a3a7cd93b37188d57e/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbc2ec7b7d905e6b6ec1635f6340bfa52aaab718101c59f052bc012a6b486cd8", size = 1070109, upload-time = "2026-07-10T20:55:48.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/1da4def1f006b5ad01187ff96379e24c37439d659ec10c3e944c03436c0f/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9367ae25dfa6dc1af37904785e43c4f8fe1c4118cafdc2f06514154fbdd90992", size = 1121528, upload-time = "2026-07-10T20:56:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/744e4f5e3d635dea20dbedf3fa486e2a6fa5210e0a52a0d5c4da56babd84/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:455f09107ec07c78f2a83cb8fc19e23879c9d51cdc831de6f9cb6ec4059cb9af", size = 1244690, upload-time = "2026-07-10T20:56:31.854Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/42252fcd5e521d140dac532f29c2a13ca8f22908cb545ffdd64b5e225680/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c76634c45a3ceee4c4fdfed39aebd08b8b822ec8b0c556877ef82846fd777730", size = 1288519, upload-time = "2026-07-10T20:56:03.429Z" }, + { url = "https://files.pythonhosted.org/packages/44/e7/176df9e47cf583d2b8d234b78c0aac3a47075ad5d147e60b2c21a1338bb1/hypothesis-6.156.6-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:eb7e9f8343bc6b948937e6ec12e6879ed25a17b53ceccbd2b84adadd3d511698", size = 586452, upload-time = "2026-07-10T20:56:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/2c8a0411bbe76429f3ae738ef9a00107201bf6146d9534350014ce369d98/hypothesis-6.156.6-cp314-cp314-win_amd64.whl", hash = "sha256:f9631cd604ae6032c3edf99160dc1b9e33873f2e52762246b24f07fb758652ae", size = 637774, upload-time = "2026-07-10T20:56:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/2a/22/8115005e9aa72c8d63d90e9db5e0b8425fd8950fbc5d6e332805d4d32c9e/hypothesis-6.156.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1f81163d36d3763b09ffaef7c3a71e88174ca3e6816201fca9d1d159f448fdb5", size = 747428, upload-time = "2026-07-10T20:56:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c2/66bfe9337f4a4b1f7754ee6d01d950280152a81d0d797e6c1d9eb0909750/hypothesis-6.156.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:556b905767e36147918634a64356aa05d8c956576f00aee01eb351678f193908", size = 740889, upload-time = "2026-07-10T20:55:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/95/3b/69f45af2d4f0950b7d1af3cdbdd800b88a6c2370331481eda79d6171fbe3/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f2604b28d16d696aaaf4954d20f907b27e54034df98e64746a20c74c319f03", size = 1069270, upload-time = "2026-07-10T20:56:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/f8/43/6b2549885da08f5e50ba34fb8e0d0a60b2f190ffd516fac220f8db5b5869/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe012ad66dbe7b8e8ddef6f6992ab1b36719ea64430c2bf1ff7135521052a15", size = 1120409, upload-time = "2026-07-10T20:55:34.551Z" }, + { url = "https://files.pythonhosted.org/packages/70/97/745c778c3eb29befa2367b1ded8437eecfbbe6932359d0f831275bc32170/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5bfa3c7b758f7278081c6bfec5f89b43c4eb075c0c9eb095323f7a9eb019b513", size = 1243111, upload-time = "2026-07-10T20:56:17.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/c5ec6a442dc9b822f47064bda4b6d3e739dccdd1c5bf44c9a57fb6136830/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0db1f4573800c618773622f03cb6533bb3377430ef938c9476ba10c39d22591", size = 1287262, upload-time = "2026-07-10T20:56:23.749Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/c134d61710e14b68b010215dcf6bd57d2ec05cd169dff8bfab8fefc2d410/hypothesis-6.156.6-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd0c4a7b9f809f1e23a4d15adfa9c5d99869b9afc327350a5e563350b78e48", size = 637862, upload-time = "2026-07-10T20:56:13.347Z" }, +] + [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "import-linter" +version = "2.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761, upload-time = "2026-07-03T14:00:31.285Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185, upload-time = "2026-07-03T14:00:29.676Z" }, ] [[package]] @@ -638,62 +787,85 @@ wheels = [ [[package]] name = "librt" -version = "0.11.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, - { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, - { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, - { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, - { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, - { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, - { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, - { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, - { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, - { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, - { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, - { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, - { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, - { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, - { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, - { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, - { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -797,7 +969,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -806,37 +978,38 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, - { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, - { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, - { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, - { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, - { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, - { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, - { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, - { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, - { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, - { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, - { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, - { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, - { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, ] [[package]] @@ -992,7 +1165,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.1.0" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1001,9 +1174,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -1048,29 +1221,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" -version = "0.15.17" +version = "0.15.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, - { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, - { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, - { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, - { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] [[package]] @@ -1082,13 +1268,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -1102,104 +1297,82 @@ wheels = [ [[package]] name = "yarl" -version = "1.23.0" +version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ]