diff --git a/.aider.conf.yml b/.aider.conf.yml deleted file mode 100644 index 686ae81..0000000 --- a/.aider.conf.yml +++ /dev/null @@ -1,2 +0,0 @@ -# See https://aider.chat/docs/usage/conventions.html -read: CONVENTIONS.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a30d23e..421917a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,27 +2,101 @@ name: CI on: push: - branches: [ master, main ] + branches: [main] pull_request: - branches: [ master, main ] + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + tests: + runs-on: ubuntu-latest + # 3.15 is still beta and ``granian``'s PyO3 0.27 doesn't support it yet; + # the entry is kept as an early-signal channel until PyO3 catches up. + continue-on-error: ${{ matrix.experimental || false }} + strategy: + fail-fast: false + matrix: + include: + - python: "3.12" + - python: "3.13" + - python: "3.14" + - python: "3.15" + experimental: true + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - run: uv python install ${{ matrix.python }} + - run: uv sync --python ${{ matrix.python }} --all-groups --all-extras + - name: pytest (unit only) + timeout-minutes: 5 + run: uv run --python ${{ matrix.python }} pytest -m "not integration" -v + + tests-ft: + runs-on: ubuntu-latest + # 3.15t is still beta (uv ships ``cpython-3.15.0b1+freethreaded``). + continue-on-error: ${{ matrix.experimental || false }} + strategy: + fail-fast: false + matrix: + include: + - python: "3.14t" + - python: "3.15t" + experimental: true + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install Python ${{ matrix.python }} + run: uv python install ${{ matrix.python }} + - name: Install dependencies (FT-clean subset) + # --no-default-groups + narrow opt-in: avoid C extensions that + # re-enable the GIL on free-threaded builds (httptools 0.7.x, + # grpcio, granian, setproctitle). Covers the pure-Python core + # that the FT marking actually claims. ``dev-http`` (httpx, + # flask), ``dev-sentry`` (sentry-sdk), and the ``click`` extra + # are pure Python — pulled in so tests/http and + # tests/hosting/services collect cleanly. + run: > + uv sync --python ${{ matrix.python }} --no-default-groups + --group tests --group tests-unit + --group dev-http --group dev-sentry + --extra http --extra scheduler --extra cron --extra click + - name: pytest + timeout-minutes: 5 + env: + PYTHON_GIL: "0" # fail loudly if any imported C ext re-enables the GIL + # Skip ``tests/openapi`` (msgspec C ext) and ``tests/hosting/rsgi.py`` + # (exercises Granian hooks; granian C ext not in the FT subset). + run: > + uv run --python ${{ matrix.python }} pytest -v -m "not integration" + --ignore=tests/openapi + --ignore=tests/hosting/rsgi.py + sonarcloud: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - # Disable shallow clone (required for SonarQube to work properly) - fetch-depth: 0 - - uses: pdm-project/setup-pdm@v4 + fetch-depth: 0 # Sonar needs full history + - uses: astral-sh/setup-uv@v5 with: + enable-cache: true python-version-file: ".python-version" - cache: true - - name: pdm install - run: pdm install --frozen-lockfile --no-editable - - name: pytest + - run: uv sync --all-groups --all-extras + - name: pytest with coverage timeout-minutes: 5 - run: pdm run pytest --cov-report=term --cov-report=xml --cov-branch --cov -v + run: > + uv run pytest -m "not integration" -v + --cov-report=term --cov-report=xml --cov - uses: SonarSource/sonarqube-scan-action@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..4d8f486 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,42 @@ +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Sync docs deps + run: uv sync --group docs + - name: Build site + run: uv run zensical build --clean + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v4 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index 68b57c9..a9a9ccb 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -4,23 +4,31 @@ on: release: types: [ published ] +permissions: + contents: read + jobs: - # https://pdm-project.org/latest/usage/publish/#publish-with-trusted-publishers publish: runs-on: ubuntu-latest permissions: - # Required for PyPI trusted publishing + contents: read + # Required for PyPI trusted publishing + Sigstore-signed PEP 740 attestations id-token: write + attestations: write steps: - uses: actions/checkout@v4 with: - # Just "fetch-tags: true" does not work, probably because of - # fetch-depth: 0 - - uses: pdm-project/setup-pdm@v4 + - uses: astral-sh/setup-uv@v5 with: + enable-cache: true python-version-file: ".python-version" - cache: true - - run: pdm install --no-lock --no-editable - - run: pdm build - - run: pdm publish --no-build + - run: uv build + # uv publish (as of 0.11) only uploads pre-existing attestations; it does + # not generate them. Use the official PyPA action, which generates PEP 740 + # PyPI Publish Attestations via Sigstore and uploads them alongside the + # distributions. Track astral-sh/uv#15618 — once landed we can swap back + # to a single `uv publish` step. + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true diff --git a/.gitignore b/.gitignore index 15201ac..d324fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ +.venv-* + +# --- GitHub template, see https://github.com/github/gitignore/blob/main/Python.gitignore --- + # Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] +*.py[codz] *$py.class # C extensions @@ -27,8 +31,8 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -46,7 +50,8 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover +*.py.cover +*.lcov .hypothesis/ .pytest_cache/ cover/ @@ -92,43 +97,65 @@ ipython_config.py # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. -#Pipfile.lock +# Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. -#uv.lock +# uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +# poetry.lock +# poetry.toml # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml .pdm-python .pdm-build/ +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff -celerybeat-schedule +celerybeat-schedule* celerybeat.pid +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + # SageMath parsed files *.sage.py # Environments .env +.envrc .venv env/ venv/ @@ -161,11 +188,37 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ # PyPI configuration file .pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5eb2385..5d83eef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,13 @@ +minimum_pre_commit_version: "3.5.0" + ci: autoupdate_schedule: monthly repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.15.12 hooks: - - id: ruff + - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - id: ruff-format @@ -13,18 +15,36 @@ repos: rev: 1.7.0 hooks: - id: interrogate - args: [tests] + # Floor tracks current coverage; ratchet up as docstrings land. + args: [--fail-under=24, localpost] - - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + - repo: https://github.com/crate-ci/typos + rev: v1.46.1 hooks: - - id: codespell - args: [-L, alog, -L, abl] + - id: typos - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-toml - id: check-yaml + - id: check-merge-conflict + - id: check-case-conflict + - id: check-added-large-files + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.25 + hooks: + - id: validate-pyproject + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.13 + hooks: + - id: uv-lock + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint diff --git a/.python-version b/.python-version index c8cfe39..3a8498a 100644 --- a/.python-version +++ b/.python-version @@ -1 +1,2 @@ -3.10 +# Because pyspy doesn't support Python 3.14 yet +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index 672a247..41d64f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,232 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.6.0] - 2026-05-08 -### Fixed +**Effectively a rewrite from 0.4.0.** The project's focus — long-running +async Python processes built on AnyIO — is unchanged, but most internals +and a number of public APIs have changed. The core pillars (`hosting`, +`scheduler`, `http`, `di`) are the stable public surface, verified with +`ty` and `basedpyright --verifytypes`. A new `localpost.openapi` module +ships alongside them — a type-driven HTTP framework with OpenAPI 3.2 +generation built in. Several exploratory modules from the 0.4 line +(`flow`, `consumers`, `experimental`) are gone; they may return as a +separate package once the design settles. + +0.5.0 was drafted in `CHANGELOG.md` but never released. Its still-relevant +items are folded into this entry; the consumer/SQS/Kafka rework is dropped +along with the rest of `experimental`. + +Python 3.12+ is now required (was 3.10+). ### Added -### Changed +- **`localpost.http`** — small h11-based HTTP/1.1 server with a single + selector thread and pluggable parser backend (`h11` by default, + `httptools` via the `[http-fast]` extra). Driven by + `start_http_server(config, handler)`. Includes: + - `HttpApp` — decorator-driven framework on top of the lean `Router` + (`@app.get`, `@app.post`, …): parameter injection, response + conversion (str / bytes / dict / list / `Response` / `None`), + worker-pool dispatch, app-level + per-route middleware. + - `Router` — minimal RFC 6570 Level-1 URI template dispatcher; attaches + `RouteMatch` to `ctx.attrs["route_match"]`, exposed via + `route_match(ctx)`. + - WSGI bridge (`wrap_wsgi`, `to_wsgi`), ASGI bridge (`to_asgi`), RSGI + bridge (`to_rsgi` + `HostRSGIApp` for Granian deployments). + - `read_body` / `aread_body` body helpers, `static_handler`, + `compress_handler` (gzip stdlib; brotli via the `[http-compress]` + extra). + - `thread_pool_handler(handler, executor)` / + `streaming_pool_handler(handler, executor)` — opt-in worker-pool + offload of handlers and streaming uploads. The executor is + caller-owned; the wrapper holds an internal `TaskGroup` for drain + semantics but does not own the executor lifecycle. + - `HttpApp.service(executor=...)` and `openapi.HttpApp.service(executor=...)` + accept an open `Executor`. When omitted, the service opens an + `AsyncWorkerExecutor` on the hosting portal so handlers get + `from_thread.check_cancelled` support automatically. + - `HTTPReqCtx.attrs` — mutable per-request state for cross-cutting + concerns (auth, tracing, rate-limit, body cache). + - **Free-threaded CPython 3.14t support** — verified end-to-end. ~3x + RPS jump at `selectors=1` (httptools plaintext: 12,563 → 36,208 RPS) + just from removing the GIL. The pure-Python `[http]` (h11) backend + is no-GIL-clean today; `[http-fast]` (httptools) needs 0.8+ to avoid + auto-re-enabling the GIL on import. + - **Multi-selector single-process** via `ServerConfig.selectors > 1` + on Linux (`SO_REUSEPORT`). macOS does not load-balance accepts and + is a no-op there pending an accept-dispatch design. + - Neutral wire types (`Request`, `Response`, `InformationalResponse`, + `BodyTooLarge`) — the public API does not leak `h11` or `httptools`. +- **Async HTTP context surface** — `AsyncHTTPReqCtx` Protocol + + `AsyncRequestHandler` type. `to_asgi` / `to_rsgi` adapters expose the + same handler shape over async transports, so the same `HttpApp` can run + under Granian or Uvicorn or the in-tree sync server. +- **`localpost.threadtools`** — primitives for thread-bridging code, built + on plain locks (no AnyIO loop required for the sync pieces): + - `Channel` — typed, thread-safe queue with separate `SendChannel` / + `ReceiveChannel` halves, `timeout=` on `put` / `get`, `get_nowait`, + and broadcast-on-close so cloned receivers all observe `EndOfStream` + / `ClosedResourceError`. Capacity modes: unbounded (`None`), + rendezvous (`0`), bounded (`N>0`). + - `Executor` protocol — single `submit(fn, *args, **kwargs) -> Future` + contract. Three implementations: + - `WorkerExecutor` — sync `with`, channel-backed pool of plain + `threading.Thread` workers, lazy spawn; workers live until the + executor closes (see ADR-0005). No event loop needed. + - `AsyncWorkerExecutor` — `async with`, same channel/lazy-spawn + shape but workers run via `anyio.to_thread.run_sync(..., + abandon_on_cancel=False)` so user code can call + `anyio.from_thread.check_cancelled`. Cancel granularity is + *per-worker* (one worker handles many tasks). + - `AsyncExecutor` — `async with`, fresh AnyIO task per submit gated + by an always-on `CapacityLimiter` (`math.inf` = no cap). Cancel + granularity is *per-task* (`Future.cancel()` propagates). + The async variants take a caller-owned `BlockingPortal` and hold an + internal `anyio.TaskGroup`; `stop()` cancels every in-flight task. + - `TaskGroup` — Trio-style structured concurrency over an `Executor`. + Tracks `Future`s, drains in `__exit__`, surfaces failures as a + `BaseExceptionGroup` (body + task exceptions merged, deduplicated + by identity). + - All three executors snapshot `contextvars.Context` per submit, + matching `asyncio.to_thread` / Trio / AnyIO spawn semantics. +- **`localpost.di`** — `.NET`-style scoped IoC container + (`ServiceRegistry`, `ServiceProvider`, `AppContext`) with a Flask + integration that scopes services per request. +- **Hosting middleware** — `shutdown_on_signal()` and `start_timeout(...)`, + composable around any `ServiceF`. New `+` and `>>` operators for + combining and wrapping services. +- **`ServiceLifetimeView.portal`** exposes the hosting layer's per-app + `BlockingPortal`. Lets services compose `AsyncWorkerExecutor` / + `AsyncExecutor` against the same loop without opening a redundant + portal — the pattern HTTP / openapi `HttpApp.service(...)` use to + default their internal pool. +- **`HostRSGIApp`** — host an RSGI app under `localpost.hosting` so it + participates in the same lifecycle / signals as everything else. +- **`localpost.debug`** — context manager to attach AnyIO-aware debug + hooks during development. +- **`hosting.services`** adapters — `uvicorn`, `hypercorn`, `grpc`, and a + generic `_asgi`. Each runs the underlying server as a hosted service + with proper start / stop semantics. +- **Scheduler trigger composition** — operator-based combinators + (`every("1m") // delay((0, 10))`), `take_first(n)`, `cron(...)` (via + the `[cron]` extra). Sync handlers are auto-offloaded to threads via + `anyio.to_thread`. Trigger middleware is now async-generator based + (`trigger_factory_middleware`). +- **`localpost.openapi`** (`[openapi]` extra) — type-driven HTTP framework + with OpenAPI 3.2 generation, on top of `localpost.http`. FastAPI-style + decorator API where the spec and runtime handling are derived from the + *same* type annotations, including union return types for response + shapes (`Book | NotFound[str]`). msgspec for encoding / decoding / + schema generation by default; pydantic and `attrs` recognised + automatically when present (`[openapi-attrs]` adds `attrs` + `cattrs`). + Sync (`HttpApp`) and async (`HttpAsyncApp`) flavours; OpenAPI-aware + middleware can contribute security schemes and extra responses. +- **Documentation site** — Zensical-based site built from per-module + READMEs (`mkdocs.yml`); seed ADRs and design notes under `docs/`. + +### Changed (BREAKING) + +- **Python 3.12+** required; 3.10 / 3.11 classifiers dropped. +- **Hosting fully rewritten.** `Host` and `AppHost` are gone. The new + surface is the `@service` decorator → `ServiceF`, top-level + `serve` / `run` / `run_app` entry points, structured `ServiceState` + (`Starting → Running → ShuttingDown → Stopped`), and `+` / `>>` + operators for service composition. See `localpost/hosting/README.md`. +- **`Router` is a lean dispatcher**, not a self-contained framework. + The old `RequestCtx` / `Response` / `(RequestCtx) -> Response` shape is + gone from `localpost.http.router`; `Router.as_handler()` returns a + plain `RequestHandler` that attaches a `RouteMatch` and delegates. + Pythonic helpers (decorators, response conversion, param injection) + live on the new `HttpApp`. Pair with `wrap_wsgi` if you need WSGI + output. +- **Scheduler internals reworked.** `ScheduledTask` / + `ScheduledTaskTemplate` / `Task` / `Scheduler`, declarative triggers + (`every`, `after`, `after_all`, `cron`); the sync/async-handler duality + is preserved. +- **`localpost.threadtools` reshaped.** The module is now a package + (`localpost/threadtools/`); `ThreadTaskGroup` was renamed to + `TaskGroup`. The rest of the surface diverges from any previous + drafts: + - `Channel.create(...)` no longer takes `check_cancelled`; instead + `put` / `get` take an explicit `timeout=`. `close()` broadcasts to + every cloned waiter. + - `CancellableLock`, `cancellable_condition`, `cancellable_semaphore` + are removed — cancellation moves up a layer (executor / task group + / caller polls timeouts). + - `TaskGroup` no longer relies on an ambient `thread_pool()` + contextvar. It takes an `Executor` explicitly: + `TaskGroup(executor)`. Spawning runs through `executor.submit`; + drain and error-folding semantics are unchanged. + - The old `ThreadPool` / `thread_pool()` async context manager is + gone, replaced by the three explicit `Executor` implementations + (see the Added section). +- **`localpost.http.thread_pool_handler` / `streaming_pool_handler` + signature.** Both now require an `executor` argument: + `thread_pool_handler(handler, executor)`. Caller owns the executor + lifetime. The previous version auto-opened an ambient pool via the + removed `thread_pool()` contextvar. +- **HTTP server** does not leak parser types into the public API. + `HTTPReqCtx.request` is `localpost.http.Request` (was `h11.Request`); + `HTTPReqCtx.start_response` and `complete` accept + `localpost.http.Response` / `InformationalResponse`. Field shapes match + h11's, so the migration is mechanical (`from localpost.http import + Response` and replace). +- **HTTP backend selection** lives on `ServerConfig.backend: Literal[ + "h11", "httptools"] = "h11"`. There is one entry point — + `start_http_server` — and one hosted-service wrapper — `http_server`. +- **`http_server` no longer owns a worker pool.** The `max_concurrency` + kwarg is gone from `http_server`, `flask_server`, and `wsgi_server`; + wrap your handler with `thread_pool_handler` to opt back into a pool + (typical for blocking WSGI / Flask handlers). +- **HTTP/1.1 pipelining is no longer supported.** Pipelined clients are + served sequentially. +- **Internal typing modernised** to PEP 695 across `_utils`, `scheduler`, + and `hosting`. No public-API change. ### Removed -## [0.4.0] - 2025-04-03 +- **`localpost.flow`** — too complicated; the data-flow surface lives on + through the scheduler's trigger composition. +- **`localpost.experimental`** — both `experimental.consumers` (channel / + stream / queue / Pub/Sub) and `experimental.openapi` are removed. + Corresponding extras (`[sqs]`, `[kafka]`, `[nats]`, `[http-openapi]`) + and `examples/consumers/`, `examples/openapi/`, `tests/experimental/` + are gone too. May return as a separate package once the design settles. +- **`scheduler.serve()` / `scheduler.aserve()`** — use `hosting.run` / + `run_app` instead. +- **`localpost.flow_ops`** (had been merged into `flow` for 0.4) — gone + along with `flow`. +- Internal helpers that were unused everywhere: `_utils.NO_OP_TS`, + `AsyncContextManagerAdapter`, `Switch`, `MemorySendStream`'s + `send_or_drop_from_thread` / `send_or_drop` (so `MemoryStream.create()` + now returns the bare AnyIO stream pair), and + `hosting._serve_and_observe`. + +### Fixed + +- **Scheduler keeps its loop alive across handler exceptions** — a single + failing run no longer terminates the schedule. +- **`Task.subscribe` after the task has finished** raises a clear error + instead of silently hanging; graceful mid-iteration shutdown is + covered. +- **`TaskGroup.__exit__`** deduplicates exceptions that propagate via + both the body and a child task by identity, so each appears at most + once in the resulting `ExceptionGroup`. +- **HTTP send path** — non-blocking `send` with a blocking-with-timeout + fallback on `BlockingIOError`; per-request `settimeout` calls dropped + from the borrow boundary (saves two `fcntl` per request, +21–32% RPS + on the bench's hot path). +- **`UvicornService`** no longer crashes the whole app if the embedded + server fails to start (carried over from the unreleased 0.5 draft). + +### Performance + +- HTTP `Router` restructured into a lean dispatcher (+35% RPS on the + bench's hot path). +- See `benchmarks/macro/http/PERF_FINDINGS.md` for per-phase notes and numbers. + +## [0.4.0] - 2025-06-23 ### Fixed @@ -24,28 +239,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `HostedService` class, to represent a named hosted service -- Hosted service middlewares: start_timeout, stop_timeout, and lifespan +- Hosted service middlewares: start_timeout, shutdown_timeout, and lifespan - Ability to combine multiple hosted services (`+` operator) - Ability to wrap a hosted service (or a set of services) by another one (`>>` operator) - `Host.state` (similar to `ServiceLifetime.state`) +- More tests ### Changed -- `EventView.__bool__()` instead of `EventView.is_set()` -- `app_host.AppService` instead of `app_host.HostedService` -- `localpost.flow_ops` has been merged into `localpost.flow` +- `EventView.__bool__()` in addition to `EventView.is_set()` +- `AppHost` reworked (simplified) +- `localpost.flow_ops` merged into `localpost.flow` ### Removed - `localpost.scheduler.serve()` & `localpost.scheduler.aserve()` (just use `Host` instead) -## [0.3.1] - 2025-03-13 - -### Added - -- More tests - -## [0.3.0] - 2025-03-11 +## [0.3.0] - 2025-03-12 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ea7af8a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. + +## Project overview + +LocalPost is an async Python framework (Python 3.12+) for building long-running +processes. Four pillars: + +- **hosting** — service lifecycle + orchestration (start/stop/signals). +- **scheduler** — in-process, composable task scheduler. +- **http** — lightweight sync HTTP/1.1 server. +- **di** — small `.NET`-style IoC container with scopes. + +Built on AnyIO (runs on asyncio or Trio). + +**Stability note:** all four modules have settled public APIs — avoid +breaking changes unless explicitly asked. + +## Development commands + +From `justfile`: + +```bash +just deps # uv sync --all-groups --all-extras +just deps-upgrade # uv lock --upgrade && sync +just format # ruff check --fix + ruff format on localpost/ +just format-all # same, also on examples/ and tests/ +just types # ty check localpost +just types-strict # ty check localpost (strict mode) +just types-all # ty over localpost + examples + tests +just type-coverage # basedpyright --verifytypes on the public API +just tests # pytest with coverage (all tests) +just unit-tests # pytest -m "not integration" +just integration-tests # pytest -m "integration" -n auto (testcontainers) +just check FILE # ruff check --fix + ty check for one file +just why PACKAGE # inverse dep tree for a package +``` + +Single test: + +```bash +pytest tests/path/to/test.py::test_function -v +``` + +## Architecture + +``` +localpost/ +├── __init__.py # Re-exports: Result, debug, __version__ +├── _utils.py # Result, Event, MemoryStream, delay helpers +├── _debug.py +├── _otel_utils.py +├── threadtools.py # Sync primitives (CancellableLock, Channel, cancellable_semaphore) +│ +├── hosting/ # Service lifecycle + orchestration +│ ├── _host.py # ServiceLifetime, serve, run, @service, current_service +│ ├── middleware.py # shutdown_on_signal, start_timeout +│ └── services/ # Adapters: _asgi, uvicorn, hypercorn, grpc +│ +├── scheduler/ # Composable task scheduler +│ ├── _scheduler.py # ScheduledTask, ScheduledTaskTemplate, scheduled_task, run +│ ├── _cond.py # Every, After, AfterAll; every/after/after_all; delay, take_first +│ ├── _trigger.py # Trigger decorators (WIP) +│ └── cond/cron.py # cron(...) trigger — needs [cron] extra +│ +├── di/ # IoC container +│ ├── _services.py # ServiceRegistry, ServiceProvider, AppContext +│ ├── flask.py # Flask integration (RequestContext per request) +│ └── quart.py # Quart integration (stub) +│ +└── http/ # Lightweight HTTP/1.1 server (h11-based, sync I/O loop) + ├── server.py # start_http_server, HTTPReqCtx, RequestHandler + ├── router.py # URITemplate (RFC 6570 L1), Router, RequestCtx + ├── wsgi.py # wrap_wsgi() + ├── config.py # ServerConfig + └── _service.py # @hosting.service wrappers (http_server, wsgi_server) +``` + +Files prefixed with `_` are internal; public API is re-exported from each +module's `__init__.py`. + +## Conventions + +- **AnyIO everywhere.** Structured concurrency; no raw `asyncio`. Works on both + asyncio and Trio backends. +- **Public API is fully typed** — verified with `just types` (using `ty`) and + `just type-coverage` (using `basedpyright --verifytypes`). +- **Internal code** uses types where they aid readability; not strictly required. +- **Errors as values** — `Result[T]` (Ok / failure) flows through scheduler tasks + and arg resolvers. +- **Ruff** with `line-length = 120`, `pyupgrade.keep-runtime-typing = true`. +- **Sync + async duality** — the scheduler accepts both; sync callables are + offloaded to threads via `anyio.to_thread`. +- Docstrings: Google convention (per ruff config). + +## Testing + +- Unit tests: default `pytest` invocation, marker `not integration`. +- Integration tests: marked `@pytest.mark.integration`, run in parallel via + `pytest-xdist` (`-n auto`). +- `anyio_mode = "auto"` in `pyproject.toml` — async tests use `anyio[test]`. + +## Workflow rules + +- After editing any file under `localpost/`, run `just check ` (ruff + ty). + This catches lint and type regressions early. +- Never skip hooks on commit. Prefer a new commit over `--amend`. +- Treat `_`-prefixed modules as internal; change them freely, but don't import + from `_*` outside the module they live in. + +## Module deep-dives + +For more detail on each module, read its README on demand: + +- `localpost/hosting/README.md` +- `localpost/scheduler/README.md` +- `localpost/di/README.md` +- `localpost/http/README.md` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ec252bd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,237 @@ +# Contributing to localpost + +Thanks for your interest in contributing! This document covers everything you need to file a good +issue, send a pull request, or cut a release. + +## Reporting issues + +- **Bugs** — open a [GitHub issue](https://github.com/alexeyshockov/localpost.py/issues/new) with a + minimal reproducer, the Python and `localpost` version, and the AnyIO backend (`asyncio` or + `trio`). +- **Feature requests / design discussion** — open an issue first so we can agree on scope before + you write code. +- **Questions** — open a [GitHub + Discussion](https://github.com/alexeyshockov/localpost.py/discussions) or an issue tagged + `question`. + +## Development setup + +Requirements: Python 3.12+ and [`uv`](https://docs.astral.sh/uv/) (which manages Python itself, +the virtualenv, and dependencies). [`just`](https://github.com/casey/just) is used to wrap the +common workflows. + +The project keeps **tools at user level** (Homebrew + `uv tool install`), separate from the project +venv. `just doctor` installs everything you need: + +```bash +git clone https://github.com/alexeyshockov/localpost.py.git +cd localpost.py +just doctor # user-level toolchain: uv, ty, basedpyright, ruff, griffe, pre-commit, ... +just deps # sync the project venv (uv sync --all-groups --all-extras) +pre-commit install # wire the repo-local commit hooks (one-time per clone) +just unit-tests # confirm the toolchain is healthy +``` + +The full list of recipes is in `justfile` (run `just --list`). The most common ones: + +| Command | What it does | +| --- | --- | +| `just deps` | Sync the venv with every group + extra. | +| `just deps-upgrade` | `uv lock --upgrade` and re-sync. | +| `just format` | `ruff check --fix` + `ruff format` on `localpost/`. | +| `just types` | `ty check localpost`. | +| `just type-coverage` | `basedpyright --verifytypes` on the public API. | +| `just unit-tests` | `pytest -m "not integration"` with coverage. | +| `just integration-tests` | `pytest -m "integration" -n auto` (testcontainers). | +| `just tests` | Both suites. | +| `just check FILE` | Ruff + ty on a single file — handy in tight edit loops. | +| `just docs-serve` | Live-reloading docs site (Zensical). | + +After editing any file under `localpost/`, run `just check ` to catch lint and type +regressions early. + +## Project structure + +``` +localpost/ +├── hosting/ # Service lifecycle + orchestration (start/stop/signals) +├── scheduler/ # In-process composable task scheduler +├── http/ # Lightweight sync HTTP/1.1 server (h11 / httptools) +├── di/ # .NET-style scoped IoC container +├── openapi/ # Type-driven HTTP framework with OpenAPI 3.2 generation +└── threadtools/ # Sync primitives (TaskGroup, Channel, ...) +``` + +Files prefixed with `_` are internal; the public API is re-exported from each module's +`__init__.py`. Don't import from a `_`-module outside the module it lives in. + +Each module has a README (`localpost//README.md`) — read those first when working in a +specific area. Cross-cutting design notes live in `docs/`. + +## Code style and conventions + +- **AnyIO everywhere** — structured concurrency; no raw `asyncio`. Tested on both asyncio and Trio + backends (`anyio_mode = "auto"` in `pyproject.toml`). +- **Public API is fully typed** and verified with `ty check` and `basedpyright --verifytypes`. +- **Internal code** uses types where they aid readability — not strictly required. +- **Errors as values** — `Result[T]` (Ok / failure) flows through scheduler tasks and arg + resolvers. +- **Sync + async duality** — the scheduler accepts both; sync callables are offloaded to threads + via `anyio.to_thread`. +- **Ruff** with `line-length = 120` and `pyupgrade.keep-runtime-typing = true`. Wrap comments and + docstrings to 120 chars too. +- **Docstrings**: Google convention. + +Run `just format` before committing. + +## Commits + +We use [Conventional Commits](https://www.conventionalcommits.org/) with a module scope. Looking +at recent history is the fastest way to internalise the pattern: + +``` +feat(scheduler): add cron trigger +fix(threadtools): dedup body and task exceptions in TaskGroup.__exit__ +refactor(http): hoist async ctx Protocol + ASGI bridge to localpost.http +docs(scheduler): clarify Task.subscribe buffer semantics +test(http): add wsgi roundtrip cases +chore(deps): bump anyio to 4.12 +``` + +Common types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`. Use `!` after the +scope (e.g. `feat(http)!: ...`) for breaking changes — and add a CHANGELOG entry under +**Changed (BREAKING)**. + +Other rules: + +- **Never skip hooks on commit** (`--no-verify`). If a hook fails, fix the underlying issue. +- **Prefer a new commit over `--amend`** unless you're correcting the very last commit you just + made and haven't pushed. + +## Pull requests + +Before opening: + +- [ ] `just format` is clean (pre-commit hooks enforce this too) +- [ ] `just types` is clean +- [ ] `just type-coverage` reviewed — no regressions on public API you touched + (basedpyright reports an overall score; treat it as informational) +- [ ] `just unit-tests` passes +- [ ] You added or updated tests for the change +- [ ] You added a CHANGELOG entry under `## [Unreleased]` (Added / Changed / Fixed / Removed, + with breaking changes called out) +- [ ] Module README and any relevant `docs/` notes are updated + +The `main` branch is the only stable branch and will never be force-pushed. Any other branch, +including release branches, may be rebased or force-pushed at any time. + +## Tests + +- **Unit tests** (`just unit-tests`) — fast, hermetic, default suite. Marker: `not integration`. +- **Integration tests** (`just integration-tests`) — `@pytest.mark.integration`, run in parallel + via `pytest-xdist` (`-n auto`), use testcontainers. + +Run a single test with: + +```bash +pytest tests/path/to/test.py::test_function -v +``` + +Async tests pick up `anyio_mode = "auto"` from `pyproject.toml` — no fixtures needed. + +## Documentation + +- Module READMEs (`localpost//README.md`) are the canonical end-user docs and are surfaced + on the docs site verbatim. +- Cross-cutting design notes live under `docs/design/`. +- Architectural decisions live under `docs/adr/`. +- The site is built with [Zensical](https://zensical.com/); preview locally with `just docs-serve`. + +## Release process (maintainers) + +The release pipeline is automated via two `just` recipes plus a GitHub Actions workflow. + +### One-time setup + +- `gh` CLI authenticated against this repo (`gh auth status`). +- Push access to `main`. +- The PyPI project is configured for [trusted + publishing](https://docs.pypi.org/trusted-publishers/) from + `.github/workflows/pypi-publish.yaml` (no API tokens stored anywhere). + +### Cutting a release + +1. **Pre-flight: review type coverage and public-API breakages** since the previous stable + release: + + ```bash + just release-check + ``` + + This prints (a) the current `basedpyright --verifytypes` score and (b) `griffe check`'s + breaking-change report against the previous stable tag (latest `vX.Y.Z` with no + `b1`/`rc1`/`.devN` suffix). Use it to sanity-check the CHANGELOG and the version bump: + any breaking change must be called out in the CHANGELOG and should bump the minor (pre-1.0) + or major (post-1.0). The report is informational — exit code is always 0. + +2. **Land the release notes.** On a feature branch, replace `## [Unreleased]` in `CHANGELOG.md` + with a `## [X.Y.Z] - YYYY-MM-DD` heading and finalise the entries. Open a PR, get it merged + to `main`. + +3. **Run the release recipe** from a clean `main`: + + ```bash + just release 0.6.0 + ``` + + This: + - bumps `pyproject.toml` (`uv version 0.6.0`), + - commits `release: 0.6.0`, + - pushes `main`, + - creates a **draft** GitHub release `v0.6.0` pre-filled with the `## [0.6.0]` CHANGELOG + section as its body. + + Guards: aborts if the working tree is dirty, you're not on `main`, or the matching CHANGELOG + section is missing. + +4. **Review and publish the release** on GitHub. Edit notes if needed and click **Publish + release**. This: + - creates the `v0.6.0` git tag at the release commit, + - fires the `release: published` event, which triggers + [`pypi-publish.yaml`](.github/workflows/pypi-publish.yaml), + - the workflow runs `uv build` then `uv publish` with PyPI trusted publishing. + +5. **Open the next dev cycle:** + + ```bash + just release-post 0.7.0 + ``` + + This bumps `pyproject.toml` to `0.7.0.dev0` and commits/pushes `chore: bump version to + 0.7.0.dev0`. Use the next planned target — `0.6.1` for a patch line, `0.7.0` for the next + minor, etc. + +### Versioning convention + +[PEP 440](https://peps.python.org/pep-0440/) with [SemVer](https://semver.org/) on top: + +- Released versions: `X.Y.Z`. +- In-development on `main`: `X.Y.Z.dev0` where `X.Y.Z` is the **target** of the next release. + PEP 440 sorts `0.6.0.dev0 < 0.6.0 < 0.7.0.dev0`, so the suffix marks "heading toward this + version, not there yet." +- Breaking changes bump the minor pre-1.0 (per SemVer's pre-release allowance) and the major + post-1.0. +- Patch releases (`X.Y.Z+1`) are reserved for bug fixes that don't change the public API. + +### Hotfix releases + +Same flow with the patch component bumped — e.g. `just release 0.6.1` from `main` once the fix is +merged, then `just release-post 0.6.2` (or `0.7.0` if the next planned release is a minor). + +### Yanking a release + +If a release is broken, yank it on PyPI (don't delete — yanking preserves the version number while +discouraging installs). PyPI does not currently expose yank from the command line; do it through +the project's [release management +page](https://pypi.org/manage/project/localpost/releases/) and include a short reason +("broken: see #NNN"). Then cut a follow-up release with the fix as soon as possible. diff --git a/CONVENTIONS.md b/CONVENTIONS.md deleted file mode 100644 index 7aa7f5a..0000000 --- a/CONVENTIONS.md +++ /dev/null @@ -1,3 +0,0 @@ -- Use AnyIO for async stuff, to follow structured concurrency patterns -- For public API, use types everywhere (and validate it using Pyright, see `just check-type-coverage`) -- For internals API, use types only where it makes sense (to make the code more readable) diff --git a/README.md b/README.md index 373580e..b89ce30 100644 --- a/README.md +++ b/README.md @@ -2,80 +2,113 @@ [![PyPI package version](https://img.shields.io/pypi/v/localpost)](https://pypi.org/project/localpost/) ![Python versions](https://img.shields.io/pypi/pyversions/localpost) -
[![Code coverage](https://img.shields.io/sonar/coverage/alexeyshockov_localpost.py?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/project/overview?id=alexeyshockov_localpost.py) -Simple in-process task scheduler & consumers framework for different message brokers. +A small async Python framework for long-running processes: service hosting, +in-process task scheduling, and a lightweight HTTP server — all built on +[AnyIO](https://anyio.readthedocs.io/) (runs on asyncio **and** Trio). -## Scheduler +LocalPost is not a monolith. Each module is usable on its own; pick what you +need. -TBD +## Features -### Tasks +- **Service hosting** with a structured lifecycle (`Starting → Running → + ShuttingDown → Stopped`), signal handling, and composable middleware. +- **Scheduler** with declarative triggers (`every`, `after`, `cron`) and + operator-based composition (`every("1m") // delay((0, 10))`). +- **HTTP server** — sync, h11-based, ~400 LOC; wrap any WSGI app. +- **IoC container** — `.NET`-style, scoped, with Flask integration. -TBD, including: -- can accept 0 or 1 argument (trigger's value) -- return values will be available in a stream +## Install -#### Concurrency +```bash +pip install localpost +``` -Solely depends on the trigger. +Optional extras: -### Triggers & decorators +| Extra | Adds | +| ----------------- | ----------------------------------------------------------- | +| `[cron]` | `croniter` — cron-expression trigger | +| `[scheduler]` | `humanize`, `pytimeparse2` — string durations | +| `[http]` | `h11` — the HTTP server | +| `[http-fast]` | `httptools` — alternative C-based parser | -TBD +## Quick start — scheduler -### Built-in triggers & decorators +```python +import random -TBD, including: -- every() -- delay() -- skip_first() +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first -### Custom triggers & decorators -TBD +@scheduled_task(every("3s") // delay((0, 1))) +async def task1(): + return random.randint(1, 22) -## Consumers -TBD, including basic Kafka & SQS examples. +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): + print(f"task1 emitted: {task1_result}") -## Flow & flow ops -TBD +if __name__ == "__main__": + run_app(task1, task2) +``` -### Handlers & handler managers +`run_app` wires signal handling (SIGINT / SIGTERM), starts every service in +parallel, exits cleanly when they all stop, and raises ``SystemExit`` with +the resulting status code — no ``sys.exit(...)`` wrapper needed. -TBD +## Modules -### Decorators (middlewares & wrappers) +| Module | Purpose | +| ----------------------------------- | ---------------------------------------------------------- | +| [`hosting`](localpost/hosting/) | Service lifecycle, signals, middleware, ASGI/gRPC adapters | +| [`scheduler`](localpost/scheduler/) | Composable in-process task scheduler | +| [`di`](localpost/di/) | Scoped IoC container | +| [`http`](localpost/http/) | Small h11-based HTTP/1.1 server | -TBD +All four modules have stable public APIs and are not expected to break in +patch or minor releases. -## Hosting +Each subdirectory has its own README with a quickstart, key concepts, and +extension points. -TBD +## Why localpost? -### Hosted services +- **Type-safe** — public API is checked with `ty` and `basedpyright + --verifytypes`. +- **FastAPI-style ergonomics** — decorators for tasks, services, and HTTP + operations; declarative middleware. +- **Async-first** — built on AnyIO, so structured concurrency is the default, + and you get Trio support for free. +- **Handle-both** — the scheduler accepts sync or async callables; sync ones + are offloaded to a thread pool. +- **Small** — each module is focused and independently usable; take just the + scheduler, just the hosting, or the full stack. -TBD +## Status -### Running multiple services +Beta — actively developed. Python 3.12+ required. See +[CHANGELOG.md](CHANGELOG.md) for history. -TBD, including: -- combining multiple services, using host's `+` operator -- wrapping a service (or a set of services) with another one, using host's `>>` operator +Examples for every module live under [`examples/`](examples/). -### AppHost +## License -TBD +MIT — see [LICENSE](LICENSE). -## Motivation +## Contributing -TBD, including: -- type safety -- FastAPI-like - - decorators to create scheduled tasks & hosted services - - middlewares -- Async first - - AnyIO backed (mainly for structured concurrency, compatibility with Trio as a bonus) +See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, code style, commit conventions, the +pull-request checklist, and (for maintainers) the release process. + +Quick start: + +```bash +just deps # uv sync --all-groups --all-extras +just tests +``` diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 0000000..1ff75ca --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,3 @@ +# Benchmark output — regenerated by `just bench-http`, not authoritative. +results/ +results*/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..29908b1 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,95 @@ +# LocalPost benchmarks + +Two top-level groups: + +- **[`micro/`](micro/)** — `pytest-benchmark` micro-benchmarks for + `URITemplate` and `Router`. Catches perf regressions in the + deterministic core. +- **[`macro/`](macro/)** — load benchmarks driven by + [`oha`](https://github.com/hatoo/oha) against a spawned subprocess. + Two suites: + - **[`macro/http/`](macro/http/README.md)** — compares LocalPost's + HTTP server against peer servers (Gunicorn, Cheroot, Granian, + Uvicorn) on a fixed Flask / Starlette workload. Measures **HTTP + server** overhead. + - **[`macro/openapi/`](macro/openapi/README.md)** — compares + `localpost.openapi` against peer typed/OpenAPI frameworks (FastAPI, + flask-openapi v5) on a shared workload — typed handlers, schema + validation, response serialization. Measures **framework** overhead. + +The two macro suites share the runner, types, filter language, and +report writers via [`macro/_core/`](macro/_core/). Each defines its own +scenarios, stacks, and `apps/`. + +All three are kept out of the default test run (`just tests`). + +## Quick start + +```bash +brew install oha # one-time prereq for macro suites +just bench-deps # provision .venv-bench// for every interpreter + +# Macro HTTP server bench +just bench-http +just bench-http --duration 5 --stacks localpost_h11,flask_gunicorn + +# Macro OpenAPI framework bench +just bench-openapi +just bench-openapi --duration 5 --stacks fastapi,localpost_openapi + +# Micro (pytest-benchmark) +just bench-micro +``` + +Per-suite docs: [`macro/http/README.md`](macro/http/README.md), +[`macro/openapi/README.md`](macro/openapi/README.md). + +## Shared CLI surface + +Both macro runners share these flags via +[`benchmarks/macro/_core/cli.py`](macro/_core/cli.py): + +- `--duration N` — seconds per cell (default 20). +- `--stacks a,b` — verbatim stack name list (escape hatch). +- `--group ` — named preset (e.g. `quick`, `localpost`). +- `--filter key=val` — dim filter (repeatable, AND together). + Supports glob (`backend=lp-*`), comma (`app=flask,starlette`), + negation (`app!=starlette`). +- `--scenarios a,b` — restrict to specific scenarios. +- `--pythons name=path,...` — override the bench Python matrix + (default: every entry in + [`benchmarks.macro._core.pythons.PYTHONS`](macro/_core/pythons.py)). + +## Micro suite + +`pytest-benchmark`-driven, runs in-process: + +- `bench_uri_template.py` — `URITemplate.parse`, `.match` (hit / miss / + multi-var). +- `bench_router.py` — `Routes.build`, `Router.wsgi` dispatch (literal + hit, parameterised hit, 404, 405). + +```bash +# Save a baseline before changing code +just bench-micro --benchmark-autosave + +# Then later, compare +just bench-micro --benchmark-compare --benchmark-compare-fail=mean:10% +``` + +Micro-benchmarks are **not** wired to a CI check. If you want CI-stable +regression gates later, swap `pytest-benchmark` for +[`pytest-codspeed`](https://docs.codspeed.io/) — it runs the same +fixtures under deterministic instrumentation on Codspeed's infra. No +code changes beyond the dependency. + +## Caveats (macro) + +- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel + scheduling, other processes. Re-run if a cell looks anomalous. The + relative ordering on the *same* run is what matters; absolute RPS + will shift run-to-run. +- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP + throughput regression gates. Run macro benchmarks locally. +- **Single process by design.** Real deployments multiply by N + workers; the relative ordering still holds. diff --git a/examples/app_host/__init__.py b/benchmarks/__init__.py similarity index 100% rename from examples/app_host/__init__.py rename to benchmarks/__init__.py diff --git a/benchmarks/macro/__init__.py b/benchmarks/macro/__init__.py new file mode 100644 index 0000000..b3731b3 --- /dev/null +++ b/benchmarks/macro/__init__.py @@ -0,0 +1,7 @@ +"""Macro benchmark suites. + +Each suite (``http``, ``openapi``) drives a real HTTP load (oha) against a +spawned subprocess running one of its ``apps/`` modules. Shared +infrastructure — runner, filter language, types, CLI — lives in ``_core``. +``_setup.py`` syncs the shared bench venvs. +""" diff --git a/benchmarks/macro/_core/__init__.py b/benchmarks/macro/_core/__init__.py new file mode 100644 index 0000000..859e9a6 --- /dev/null +++ b/benchmarks/macro/_core/__init__.py @@ -0,0 +1,6 @@ +"""Shared infrastructure for the macro benchmark suites. + +Each suite (``benchmarks/macro/http/``, ``benchmarks/macro/openapi/``) defines its own +scenarios, stacks, and ``apps/`` modules but reuses this package for the +runner, types, CLI, and report rendering. +""" diff --git a/benchmarks/macro/_core/cli.py b/benchmarks/macro/_core/cli.py new file mode 100644 index 0000000..6754817 --- /dev/null +++ b/benchmarks/macro/_core/cli.py @@ -0,0 +1,260 @@ +"""Shared argparse + main loop for macro bench runners. + +Each suite's ``runner.py`` is a thin entry point that calls :func:`run` +with its own scenarios, stacks, groups, and apps package. + +Run from the repo root:: + + just bench-http # full matrix + just bench-http --duration 5 # quick sanity + just bench-http --group quick # ~4 stacks, fast PR check + just bench-http --filter app=flask # all Flask servers + just bench-http --filter 'backend=lp-*' --filter selectors=1 + just bench-http --stacks localpost_h11,flask_gunicorn # exact list (escape hatch) + just bench-http --pythons 3.13=.venv-bench/3.13/bin/python +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +from collections.abc import Callable, Iterable +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path + +from benchmarks.macro._core.filters import select_stacks +from benchmarks.macro._core.pythons import PYTHONS +from benchmarks.macro._core.render import render_html, render_markdown +from benchmarks.macro._core.runner import pick_port, probe_python, run_cell +from benchmarks.macro._core.types import Cell, RunReport, Scenario, Stack + + +@dataclass(frozen=True, slots=True) +class PythonInterp: + name: str + bin: str + + +def _write_results(report: RunReport, target_dir: Path) -> None: + target_dir.mkdir(parents=True, exist_ok=True) + payload = asdict(report) + (target_dir / "results.json").write_text(json.dumps(payload, indent=2) + "\n") + (target_dir / "RESULTS.md").write_text(render_markdown(report)) + (target_dir / "RESULTS.html").write_text(render_html(payload)) + + +def _parse_pythons(arg: str) -> list[PythonInterp]: + """Parse ``name=path,name=path`` (or fall back to the bench matrix).""" + if not arg: + return [PythonInterp(name=p.name, bin=p.bin) for p in PYTHONS] + pythons: list[PythonInterp] = [] + for spec in arg.split(","): + spec = spec.strip() + if not spec: + continue + if "=" not in spec: + raise ValueError(f"--pythons entry must be name=path, got: {spec!r}") + name, _, path = spec.partition("=") + pythons.append(PythonInterp(name=name.strip(), bin=path.strip())) + return pythons + + +def _timestamp_slug() -> str: + return datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S") + + +def _describe_selection(args: argparse.Namespace) -> str: + parts: list[str] = [] + if args.stacks: + parts.append(f"stacks={args.stacks}") + if args.group: + parts.append(f"group={args.group}") + if args.filter: + parts.append("filter=" + ", ".join(args.filter)) + return "; ".join(parts) if parts else "all" + + +def run( + *, + scenarios: tuple[Scenario, ...], + stacks: tuple[Stack, ...], + groups: dict[str, Callable[[Stack], bool]], + apps_pkg: str, + results_dir: Path, + title: str, + dim_keys: Iterable[str], + repo_root: Path, + argv: list[str] | None = None, + description: str | None = None, +) -> int: + """Drive a full matrix run for one suite. + + Args: + scenarios: Suite's scenario list. + stacks: Suite's stack list. + groups: Named stack-selection presets. + apps_pkg: Python package prefix used to spawn each stack as + ``python -m .``. + results_dir: Where to write per-run output directories. + title: Used in markdown / HTML headers. + dim_keys: Ordered dim names — drives HTML column order and filters. + Cells dynamically carry their own dim values; this controls + display ordering only. + repo_root: Working directory for spawned subprocesses. + argv: Override sys.argv[1:] (for testing). + description: argparse description. + """ + p = argparse.ArgumentParser(description=description, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--duration", type=int, default=20, help="seconds per cell (default: 20)") + p.add_argument( + "--stacks", + default="", + help="comma-separated stack name(s) — verbatim escape hatch (bypasses --group/--filter)", + ) + p.add_argument( + "--group", + default="", + help=f"named preset; one of: {', '.join(sorted(groups))}" if groups else "(no groups defined)", + ) + p.add_argument( + "--filter", + action="append", + default=[], + metavar="KEY=VAL", + help="dimension filter, e.g. 'app=flask', 'backend=lp-*', 'selectors=1', 'app!=starlette'. " + "Repeatable (filters AND together). Comma-separated values inside one key are OR.", + ) + p.add_argument("--scenarios", default="", help="comma-separated scenario filter (default: all)") + p.add_argument( + "--pythons", + default="", + help="comma-separated name=path pairs to override the bench matrix, e.g. " + "'3.13=.venv-bench/3.13/bin/python' (default: every entry in benchmarks.macro._core.pythons.PYTHONS)", + ) + p.add_argument("--port-base", type=int, default=18800) + args = p.parse_args(argv) + + if shutil.which("oha") is None: + print("error: 'oha' not found on PATH. Install via 'brew install oha'.", file=sys.stderr) + return 2 + + try: + selected_stacks = select_stacks( + stacks, + groups, + names=tuple(s for s in args.stacks.split(",") if s) if args.stacks else None, + group=args.group or None, + filters=args.filter, + ) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + if not selected_stacks: + print("error: no stacks selected by the given group/filter.", file=sys.stderr) + return 2 + + selected_scenarios = tuple(s for s in scenarios if not args.scenarios or s.name in args.scenarios.split(",")) + if not selected_scenarios: + print("error: no scenarios selected.", file=sys.stderr) + return 2 + + try: + pythons = _parse_pythons(args.pythons) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + for py in pythons: + if not Path(py.bin).exists(): + print(f"error: interpreter not found: {py.bin}", file=sys.stderr) + return 2 + + run_dir = results_dir / _timestamp_slug() + started_at = datetime.now(UTC).isoformat(timespec="seconds") + host = f"{platform.system()} {platform.release()} {platform.machine()}" + selection = _describe_selection(args) + dim_keys_list = list(dim_keys) + + print( + f"Running {len(pythons)} python(s) x {len(selected_stacks)} stack(s) " + f"x {len(selected_scenarios)} scenario(s) @ {args.duration}s each." + ) + print(f"Selection: {selection}") + print(f"Results dir: {run_dir}") + + any_cells = False + for py in pythons: + try: + _, full_version = probe_python(py.bin) + except (subprocess.CalledProcessError, OSError) as e: + print(f"error: cannot probe {py.bin}: {e}", file=sys.stderr) + continue + print(f"\n=== python={py.name} ({full_version}) ===") + cells: list[Cell] = [] + for stack_idx, stack in enumerate(selected_stacks): + for scen_idx, scenario in enumerate(selected_scenarios): + # NB: offset uses len(scenarios), not len(selected_scenarios), so partial + # --scenarios runs still pick distinct ports per cell. + port = pick_port(args.port_base, stack_idx * len(scenarios) + scen_idx) + print( + f" [{py.name}/{stack.name}/{scenario.name}] port={port} c={scenario.concurrency} ...", + flush=True, + ) + cell = run_cell(apps_pkg, stack, scenario, port, args.duration, py.bin, repo_root) + if cell is not None: + print( + f" rps={cell.rps:,.0f} p50={cell.p50_ms:.2f}ms p99={cell.p99_ms:.2f}ms " + f"({cell.status_expected} expected / {cell.status_other} other)" + ) + cells.append(cell) + + report = RunReport( + started_at=started_at, + duration_s=args.duration, + host=host, + python=py.name, + python_version=full_version, + cells=cells, + selection=selection, + title=title, + dim_keys=dim_keys_list, + ) + target_dir = run_dir / py.name + _write_results(report, target_dir) + print(f" wrote {target_dir / 'RESULTS.md'}") + if cells: + any_cells = True + + return 0 if any_cells else 1 + + +def entrypoint( + *, + scenarios: tuple[Scenario, ...], + stacks: tuple[Stack, ...], + groups: dict[str, Callable[[Stack], bool]], + apps_pkg: str, + results_dir: Path, + title: str, + dim_keys: Iterable[str], + repo_root: Path, + description: str | None = None, +) -> int: + """``__main__`` wrapper that sets ``PYTHONUNBUFFERED`` and forwards to :func:`run`.""" + os.environ.setdefault("PYTHONUNBUFFERED", "1") + return run( + scenarios=scenarios, + stacks=stacks, + groups=groups, + apps_pkg=apps_pkg, + results_dir=results_dir, + title=title, + dim_keys=dim_keys, + repo_root=repo_root, + description=description, + ) diff --git a/benchmarks/macro/_core/filters.py b/benchmarks/macro/_core/filters.py new file mode 100644 index 0000000..c5cb273 --- /dev/null +++ b/benchmarks/macro/_core/filters.py @@ -0,0 +1,121 @@ +"""Filter language + stack selection for macro benchmarks. + +Filter language (parsed by :func:`parse_filters`): + +* ``key=value`` — exact match. Multiple ``--filter`` flags AND together. +* ``key=a,b`` — comma list inside a key is OR. +* ``key=lp-*`` — glob via ``*`` / ``?`` (fnmatch). +* ``key!=value`` — negation (combines with comma + glob). +* Special keys: ``name`` (stack name), ``tags`` (multi-valued). + Other keys are looked up in ``Stack.dims``. + +Each suite passes its own ``STACKS`` tuple and ``GROUPS`` mapping into +:func:`select_stacks` — the language and resolution logic are shared. +""" + +from __future__ import annotations + +import fnmatch +from collections.abc import Callable, Iterable +from dataclasses import dataclass + +from benchmarks.macro._core.types import Stack + + +@dataclass(frozen=True, slots=True) +class _Filter: + key: str + values: tuple[str, ...] + """Each entry is an fnmatch pattern.""" + negate: bool + + +def parse_filters(specs: Iterable[str], valid_keys: frozenset[str]) -> tuple[_Filter, ...]: + """Parse ``key=v[,v]`` / ``key!=v[,v]`` filter strings. + + Raises ``ValueError`` with a helpful message on bad input. + """ + out: list[_Filter] = [] + for raw in specs: + spec = raw.strip() + if not spec: + continue + negate = False + if "!=" in spec: + key, _, values = spec.partition("!=") + negate = True + elif "=" in spec: + key, _, values = spec.partition("=") + else: + raise ValueError(f"--filter must be 'key=value' or 'key!=value', got: {spec!r}") + key = key.strip() + if key not in valid_keys: + raise ValueError(f"unknown filter key {key!r}; valid keys: {sorted(valid_keys)}") + items = tuple(v.strip() for v in values.split(",") if v.strip()) + if not items: + raise ValueError(f"filter {spec!r} has no values") + out.append(_Filter(key=key, values=items, negate=negate)) + return tuple(out) + + +def _stack_field(stack: Stack, key: str) -> tuple[str, ...]: + if key == "name": + return (stack.name,) + if key == "tags": + return tuple(sorted(stack.tags)) + if key in stack.dims: + return (stack.dims[key],) + return () + + +def _match_filter(stack: Stack, f: _Filter) -> bool: + haystack = _stack_field(stack, f.key) + matched = any(fnmatch.fnmatchcase(h, pat) for h in haystack for pat in f.values) + return not matched if f.negate else matched + + +def collect_dim_keys(stacks: Iterable[Stack]) -> frozenset[str]: + keys: set[str] = set() + for s in stacks: + keys.update(s.dims) + return frozenset(keys) + + +def select_stacks( + all_stacks: tuple[Stack, ...], + groups: dict[str, Callable[[Stack], bool]], + *, + names: Iterable[str] | None = None, + group: str | None = None, + filters: Iterable[str] = (), +) -> tuple[Stack, ...]: + """Resolve which stacks to run. + + Resolution order: + + 1. If ``names`` is given, return those stacks verbatim (escape hatch; + ``group`` and ``filters`` are ignored). + 2. Start from ``groups[group]`` if set, else all of ``all_stacks``. + 3. AND every parsed filter on top. + + Raises ``ValueError`` for unknown names / groups / filter keys. + """ + by_name = {s.name: s for s in all_stacks} + if names: + unknown = [n for n in names if n not in by_name] + if unknown: + raise ValueError(f"unknown stack name(s): {unknown}. Known: {sorted(by_name)}") + return tuple(by_name[n] for n in names) + + pool: tuple[Stack, ...] = all_stacks + if group is not None: + if group not in groups: + raise ValueError(f"unknown group {group!r}; known: {sorted(groups)}") + pred = groups[group] + pool = tuple(s for s in pool if pred(s)) + + valid_keys = frozenset({"name", "tags"} | collect_dim_keys(all_stacks)) + parsed = parse_filters(filters, valid_keys) + if parsed: + pool = tuple(s for s in pool if all(_match_filter(s, f) for f in parsed)) + return pool diff --git a/benchmarks/macro/_core/pythons.py b/benchmarks/macro/_core/pythons.py new file mode 100644 index 0000000..583892f --- /dev/null +++ b/benchmarks/macro/_core/pythons.py @@ -0,0 +1,30 @@ +"""Bench-matrix Python interpreters. + +Single source of truth for which Python interpreters macro benchmarks run +against. Read by suite ``_setup.py`` modules (to provision each venv) and +by the shared CLI (as the default value for ``--pythons``). + +Bench venvs live in ``.venv-bench//`` — fully separate from the +project's primary ``.venv``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class BenchPython: + name: str + venv: str + uv_python: str + + @property + def bin(self) -> str: + return f"{self.venv}/bin/python" + + +PYTHONS: tuple[BenchPython, ...] = ( + BenchPython(name="3.13", venv=".venv-bench/3.13", uv_python="3.13"), + BenchPython(name="3.14t", venv=".venv-bench/3.14t", uv_python="3.14t"), +) diff --git a/benchmarks/macro/_core/render.py b/benchmarks/macro/_core/render.py new file mode 100644 index 0000000..ad4e948 --- /dev/null +++ b/benchmarks/macro/_core/render.py @@ -0,0 +1,252 @@ +"""Markdown + HTML report renderers, dim-driven. + +The HTML page generates filter dropdowns and result columns from +``report.dim_keys`` — every suite gets its own dimensions for free. The +raw report payload is embedded as a JSON `` + + + + +""" diff --git a/benchmarks/macro/_core/runner.py b/benchmarks/macro/_core/runner.py new file mode 100644 index 0000000..423d04f --- /dev/null +++ b/benchmarks/macro/_core/runner.py @@ -0,0 +1,159 @@ +"""Cell-level orchestration: spawn → readiness probe → oha → parse → kill. + +Suite-agnostic. The CLI passes in the suite's apps package prefix (e.g. +``benchmarks.macro.http.apps``) and the runner spawns ``python -m .`` +for each cell. +""" + +from __future__ import annotations + +import json +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path + +from benchmarks.macro._core.types import Cell, Scenario, Stack + + +def pick_port(base: int, offset: int) -> int: + # Naive: a fixed offset per stack invocation. Conflicts are rare in practice + # and surface clearly via the readiness probe. + return base + offset + + +def wait_ready(port: int, deadline_s: float = 10.0) -> bool: + end = time.monotonic() + deadline_s + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + +def spawn_stack(apps_pkg: str, stack: str, port: int, python_bin: str, cwd: Path) -> subprocess.Popen: + return subprocess.Popen( # noqa: S603 + [python_bin, "-m", f"{apps_pkg}.{stack}", "--port", str(port)], + cwd=cwd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + +def kill(proc: subprocess.Popen) -> None: + if proc.poll() is not None: + return + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +def run_oha(scenario: Scenario, port: int, duration_s: int) -> dict: + base = f"http://127.0.0.1:{port}" + cmd = [ + "oha", + "--no-tui", + "--output-format", + "json", + "-z", + f"{duration_s}s", + "-c", + str(scenario.concurrency), + "-m", + scenario.method, + ] + if scenario.body is not None: + cmd += ["-d", scenario.body.decode("utf-8")] + if scenario.content_type is not None: + cmd += ["-T", scenario.content_type] + cmd.append(scenario.url(base)) + + result = subprocess.run( # noqa: S603 + cmd, + check=False, + capture_output=True, + text=True, + timeout=duration_s + 30, + ) + if result.returncode != 0: + raise RuntimeError(f"oha exited {result.returncode}: {result.stderr.strip()}") + return json.loads(result.stdout) + + +def _as_float(v: float | int | str | None, default: float = 0.0) -> float: + # oha emits explicit JSON null for percentiles when no responses fit the + # bucket (e.g. flooded server, near-zero successful requests). dict.get's + # default kicks in only for missing keys, not present-but-null, so coerce. + return default if v is None else float(v) + + +def parse_oha(raw: dict, expected_status: int) -> dict: + summary = raw.get("summary", {}) + pct = raw.get("latencyPercentiles", {}) + status = raw.get("statusCodeDistribution", {}) + expected_key = str(expected_status) + s_expected = sum(v for k, v in status.items() if k == expected_key) + s_other = sum(v for k, v in status.items() if k != expected_key) + total = s_expected + s_other + return { + "rps": _as_float(summary.get("requestsPerSec")), + "p50_ms": _as_float(pct.get("p50")) * 1000, + "p90_ms": _as_float(pct.get("p90")) * 1000, + "p99_ms": _as_float(pct.get("p99")) * 1000, + "total_requests": total, + "success_rate": _as_float(summary.get("successRate")), + "status_expected": s_expected, + "status_other": s_other, + } + + +def run_cell( + apps_pkg: str, + stack: Stack, + scenario: Scenario, + port: int, + duration_s: int, + python_bin: str, + cwd: Path, +) -> Cell | None: + proc = spawn_stack(apps_pkg, stack.name, port, python_bin, cwd) + try: + if not wait_ready(port): + stderr = proc.stderr.read().decode() if proc.stderr else "" + print(f" [{stack.name}/{scenario.name}] FAILED: not ready. stderr={stderr[-400:]!r}", file=sys.stderr) + return None + raw = run_oha(scenario, port, duration_s) + parsed = parse_oha(raw, scenario.expected_status) + return Cell( + stack=stack.name, + scenario=scenario.name, + dims=dict(stack.dims), + tags=sorted(stack.tags), + **parsed, + ) + except Exception as e: # noqa: BLE001 + print(f" [{stack.name}/{scenario.name}] ERROR: {e}", file=sys.stderr) + return None + finally: + kill(proc) + + +_PROBE_CODE = ( + "import sys;" + "gil=getattr(sys,'_is_gil_enabled',lambda:True)();" + 'print(f\'{sys.version_info.major}.{sys.version_info.minor}{"" if gil else "t"}\');' + "print(sys.version.split()[0])" +) + + +def probe_python(python_bin: str) -> tuple[str, str]: + """Return (short label like ``3.14t``, full ``X.Y.Z`` version).""" + out = subprocess.check_output([python_bin, "-c", _PROBE_CODE], text=True).strip().splitlines() # noqa: S603 + return out[0], out[1] diff --git a/benchmarks/macro/_core/types.py b/benchmarks/macro/_core/types.py new file mode 100644 index 0000000..d5a30e3 --- /dev/null +++ b/benchmarks/macro/_core/types.py @@ -0,0 +1,91 @@ +"""Shared dataclasses for macro bench runners. + +Each suite (http, openapi) builds its own ``SCENARIOS`` and ``STACKS`` from +these types. The runner, filter language, and report writers operate on the +:class:`Stack`'s ``dims`` mapping uniformly — what dim keys each suite +chooses (``app``/``backend`` for http, ``framework``/``server`` for openapi) +is up to the suite. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class Scenario: + """One wire contract every stack must implement.""" + + name: str + method: str + path: str + body: bytes | None + content_type: str | None + expected_status: int + concurrency: int + """oha -c value.""" + + def url(self, base: str) -> str: + return f"{base}{self.path}" + + +@dataclass(frozen=True, slots=True) +class Stack: + """One row of the bench matrix. + + ``name`` doubles as the module name under the suite's ``apps/`` package + — the runner spawns it as ``python -m .``. + + ``dims`` carries suite-specific dimensions used by ``--filter`` and the + HTML reporter. Values are stored as strings so the filter language and + HTML dropdowns can share one code path. + + ``tags`` is a free-form multi-valued set with its own filter semantics + (matched element-wise against ``--filter tags=...``). + """ + + name: str + dims: dict[str, str] = field(default_factory=dict) + tags: frozenset[str] = field(default_factory=frozenset) + + +@dataclass(slots=True) +class Cell: + """One (stack, scenario) result.""" + + stack: str + scenario: str + rps: float + p50_ms: float + p90_ms: float + p99_ms: float + total_requests: int + success_rate: float + status_expected: int + """Responses matching :attr:`Scenario.expected_status` exactly.""" + + status_other: int + """Everything else (wrong status code, no response, etc.).""" + + dims: dict[str, str] = field(default_factory=dict) + tags: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class RunReport: + """One run's worth of cells, plus metadata for the report writers.""" + + started_at: str + duration_s: int + host: str + python: str + python_version: str + cells: list[Cell] + selection: str = "" + """Human-readable description of the stack selection (group/filter/stacks).""" + + title: str = "Benchmark" + """Report title (e.g. 'HTTP benchmark', 'OpenAPI benchmark').""" + + dim_keys: list[str] = field(default_factory=list) + """Ordered dim names — drives HTML filter dropdowns and column ordering.""" diff --git a/benchmarks/macro/_setup.py b/benchmarks/macro/_setup.py new file mode 100644 index 0000000..13a850f --- /dev/null +++ b/benchmarks/macro/_setup.py @@ -0,0 +1,68 @@ +"""Sync every venv in the bench matrix. + +Iterates ``benchmarks.macro._core.pythons.PYTHONS`` and runs ``uv sync`` for each, +redirected to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. Continues on +failure; exits non-zero if any sync failed. + +The two macro bench suites (``benchmarks/macro/http/``, ``benchmarks/macro/openapi/``) +share the same venvs, so this installs the union of their groups and +extras in one pass — ``uv sync`` would otherwise wipe groups not named on +the current invocation. + +Only the groups + extras the bench stacks actually import are installed — +``--all-groups --all-extras`` drags in native packages (``grpcio``, +``opentelemetry-*``, …) that have spotty wheel coverage on free-threaded +Python (e.g. 3.14t). + +Invoked by ``just bench-deps`` and ``just bench-deps-upgrade``. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +from benchmarks.macro._core.pythons import PYTHONS + +# Union across all bench suites. See per-suite ``apps/`` for the actual +# imports. +GROUPS: tuple[str, ...] = ( + "bench", # starlette, uvicorn, a2wsgi, cheroot, gunicorn, granian, pytest-benchmark + "bench-openapi", # fastapi, flask-openapi3, pydantic + "dev-http", # flask, httpx +) +EXTRAS: tuple[str, ...] = ( + "http", # h11 + "http-fast", # httptools + "openapi", # msgspec (used by localpost.openapi) +) + + +def _sync_one(venv: str, uv_python: str) -> int: + env = {**os.environ, "UV_PROJECT_ENVIRONMENT": venv} + cmd = ["uv", "sync", "--python", uv_python, "--no-default-groups"] + for g in GROUPS: + cmd += ["--group", g] + for e in EXTRAS: + cmd += ["--extra", e] + return subprocess.run(cmd, env=env, check=False).returncode # noqa: S603 + + +def main() -> int: + failures: list[str] = [] + for py in PYTHONS: + print(f"\n=== {py.name} ({py.venv}, --python {py.uv_python}) ===", flush=True) + if _sync_one(py.venv, py.uv_python) != 0: + failures.append(py.name) + + print() + if failures: + print(f"Failed: {', '.join(failures)}", file=sys.stderr) + return 1 + print(f"OK ({len(PYTHONS)} venv(s) synced)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/PERF_FINDINGS.md b/benchmarks/macro/http/PERF_FINDINGS.md new file mode 100644 index 0000000..b1fdacc --- /dev/null +++ b/benchmarks/macro/http/PERF_FINDINGS.md @@ -0,0 +1,1226 @@ +# HTTP server performance findings + +Initial diagnosis from the 2026-04-27 bench run (3s per cell, before Phase 1). +Phase 1 results in [Phase 1 results](#phase-1-results-2026-04-27) below. + +## Optimisation boundaries + +Every optimisation in this document operates within these hard constraints: + +1. **In-process only.** No multi-processing, no `fork` / `spawn`. Multi-core + fanout is the user's deployment problem (systemd, k8s, external + supervisors). Multi-*selector* inside one process is in scope. +2. **No async Python.** Sync handlers + threads only on the server side. + `asyncio` / `uvloop` / ASGI are out of scope. The async-ASGI comparators + (`starlette_uvicorn`, `starlette_granian`) stay in the bench matrix as + reference points only — they're not goals. +3. **GIL or free-threaded.** Standard CPython 3.12+ is the baseline. + Free-threaded builds (3.13t / 3.14t) are an accepted target — the + architecture should hold up there with care, and `selectors=N` is where + no-GIL scaling actually pays off. + +These constraints exist because LocalPost is a *library*, not a deployment +platform. Process supervision, multi-worker orchestration, and hot reloads +belong upstream. + +### Workload assumptions (the JSON-API common case) + +These guide which paths get optimised first; we cut corners on shapes +that don't fit: + +- **Reject before body.** No-route / wrong-method / auth failures + complete inline on the selector, with no body recv. +- **Body is buffered, not streamed.** When a handler needs the body, it + needs the whole thing (e.g. to deserialise JSON). The selector + buffers the full body into ``ctx.body`` and then invokes a + :data:`BodyHandler` continuation. There's still a ``ctx.receive`` + streaming API but it isn't the optimised path. +- **Response is one chunk or SSE.** Most responses are one + status-line + headers + body block; SSE generators emit the same + block plus subsequent data chunks. The response writer + auto-buffers headers and flushes them with the first body chunk in a + single ``sendall``. +- **No HTTP/1.1 pipelining.** Pipelined clients are served sequentially + (correct, just no parallelism). Dropping support simplified the + per-conn state machine. + +## Headline numbers + +| Stack | RPS | p50 (ms) | concurrency | +| ----------------- | -----: | -------: | ----------: | +| flask_cheroot | 7,433 | 2.41 | 32 threads | +| localpost_native | 5,450 | 11.71 | 32 | +| localpost_flask | 5,117 | 12.46 | 32 | +| localpost_wsgi | 4,919 | 13.03 | 32 | + +Cheroot has only ~37% more RPS but **~5× lower p50**. h11's parser overhead would +show up as flat per-request CPU, not as a 12 ms vs 2.4 ms gap. **h11 is not the +bottleneck — the dispatch architecture is.** + +## Diagnosis + +`localpost_native`, `localpost_flask`, `localpost_wsgi` all cluster within 10% of +each other in every scenario — the bottleneck is upstream of the framework layer +(h11 + dispatch path), not WSGI/Flask conversion. + +Latency math: with p50 ≈ 12 ms and 64 concurrent clients, 64 / 0.012 ≈ 5,300 RPS, +matching measured RPS almost exactly. That's **queueing-latency-bound**, not CPU. + +### Per-request hot path (`localpost/http/_service.py:58-67`) + +``` +selector thread reads bytes + → ctx.borrow() # acquires Server._lock, unregisters fd + → from_thread.run_sync(start_soon …) # SYNCHRONOUS cross-thread call (blocks selector) +event loop schedules task + → to_thread.run_sync(handler, …) # another hop into a worker +worker runs handler + → ctx.complete() → finish_response() + → _maybe_give_back() → server.track() # acquires Server._lock, re-registers fd +``` + +3 thread transitions, 2 lock acquisitions, 1 synchronous portal call **per +request**. The selector thread blocks on `from_thread.run_sync` waiting for the +event loop to accept the dispatch — that wait is the queueing latency. + +Cheroot, by contrast, has each worker thread `accept()` and run the request +end-to-end. No event loop, no hops. + +### Secondary costs (real, but second-order) + +- `Server._cleanup_stale()` runs every iteration of `Server.run()` and walks + `selector.get_map().values()` under `_lock` (`server.py:174-184`). O(N) per + loop tick. +- `_maybe_inject_keep_alive` allocates a fresh `h11.Response` per request and + scans headers byte-by-byte (`server.py:509-530`). +- `_build_environ` walks request headers twice and does + `.decode().upper().replace()` per header on the second pass + (`wsgi.py:135-172`). +- h11 is pure-Python — ~20–30% gap to a C parser, but shows up as CPU, not p50. + +## Plan + +### Phase 1 — Worker thread pool with HTTP-native cancellation + +Replace the per-request AnyIO dispatch in `localpost/http/_service.py` with a +worker thread pool that runs handlers directly. `start_http_server` and +`Server` are unchanged — only the hosted-service dispatcher above them. + +**Dispatch path (target):** + +``` +selector thread (in lt.tg, via to_thread.run_sync once) + reads bytes, parses, ctx.borrow() + → channel_tx.put((ctx, stack)) # threadtools.Channel, bounded + +worker thread N-of-N (also in lt.tg, via to_thread.run_sync once) + for ctx, stack in channel_rx: + with request_cancel_scope(conn) as token: + handler(ctx) # runs inline, no further hops + stack.close() # re-tracks the conn for keep-alive +``` + +No portal calls, no `from_thread.run_sync` per request, no `to_thread.run_sync` +per request. Two thread crossings (selector → worker, worker → selector for +re-tracking) instead of five. + +**Why `threadtools.Channel`:** consistent with the rest of the codebase, gives +us cancellation-aware `put`/`get` for free. Workers and selector are both +AnyIO-aware threads (spawned via `to_thread.run_sync` once at service start), +so the channel's existing AnyIO-driven cancellation fires correctly on +service shutdown — workers exit cleanly when `lt.tg` is cancelled or +`channel_tx.close()` is called. + +**Bounded capacity** = `max_concurrency`. When full, the selector's +`channel_tx.put()` blocks → back-pressure on accept. + +### Phase 1b — HTTP-native request cancellation (distinct from AnyIO) + +A separate, HTTP-only cancellation layer for *request-level* signals +(client disconnect, future per-request timeout). **Not mixed with AnyIO** — +the AnyIO layer handles service-level cancellation of *workers*; this layer +handles *requests*. + +New surface in `localpost.http`: + +- `localpost.http.RequestCancelled` — exception, distinct from + `anyio.get_cancelled_exc_class()`. Inherits from `Exception`, not + `BaseException` (so handlers can catch broad `except Exception` without + surprises — different choice from AnyIO on purpose, since these are + request-scoped, not task-scoped). +- `localpost.http.check_cancelled()` — reads a `ContextVar[RequestCancel]`, + raises `RequestCancelled` if the per-request token is set. **Not** an alias + of `threadtools.check_cancelled`. Documented as "call this in long-running + handlers; raises if the client went away or the server is shutting down". +- `RequestCancel` — internal token: a `threading.Event` per in-flight request, + plus a registry on the hosted service so shutdown can flip them all. + +Cancellation triggers (Phase 1b): + +1. **Client disconnect** (the genuinely-useful trigger). While the handler is + running, the borrowed conn is re-registered in the selector with a + "watchdog" data tag. On `EVENT_READ`, selector does + `recv(1, MSG_PEEK | MSG_DONTWAIT)`; `b""` → EOF → flip the request's + cancel token. Safe vs the worker thread's `send()` (PEEK doesn't consume, + send is independent of recv at the kernel level). The watchdog is only + armed *after* the request body is fully read, to avoid racing with + handler-driven body `recv()` calls. +2. **Service shutdown.** When `lt.shutting_down` fires, walk the in-flight + registry and flip every cancel token. In-flight handlers calling + `check_cancelled()` see it. + +What we *don't* try to do in Phase 1b: per-request timeouts, async-handler +cancellation, mid-body-upload disconnect detection. Each is a clean follow-up +on the same primitive. + +### Phase 2 — h11 / WSGI micro-optimisations (after Phase 1 ships) + +1. Pre-bake the keep-alive header. Skip the rebuild in + `_maybe_inject_keep_alive` (`server.py:509-530`) when no `Connection` + header is present and the response has no keep-alive yet — append a + precomputed tuple instead of allocating a fresh `h11.Response`. +2. One-pass `_build_environ` (`wsgi.py:135-172`) — fold the two header walks + into one, cache `bytes(name)`. +3. Avoid `bytes(name).lower()` allocations in `_content_length` and the + keep-alive scan in `server.py`. + +### Phase 3 — Selector self-pipe + lock-free op queue (deferred) + +The item already on the http README roadmap. Reconsider after Phase 1+2 +benchmarks; only worth doing if a measurable gap to Cheroot remains. + +## Validation plan + +- A/B Phase 1 against current path in `benchmarks/macro/http/runner.py`. Expect + p50 to collapse from ~12 ms toward ~2-3 ms; RPS to rise correspondingly. +- New tests for Phase 1b: + - Client closes mid-request → handler's `check_cancelled()` raises + `RequestCancelled`. + - Service shutdown with in-flight handler → same. + - `check_cancelled()` outside a request raises a clear error (not + `RuntimeError` from contextvar lookup). +- Run existing `tests/http/` to ensure no regressions. +- `just check localpost/http/_service.py` after each substantive change. +- Phase 2 should mostly improve RPS, not p50. +- `just bench-micro` to confirm no router regressions. + +## Phase 1 results (2026-04-27) + +Bench: 5 s per cell, `max_concurrency=32`, 64 concurrent clients. + +| Stack | RPS (before → after) | p50 (before → after) | +| ----------------- | --------------------: | -------------------: | +| `localpost_native`| 5,450 → **9,381** | 11.71 → **6.29 ms** | +| `localpost_flask` | 5,117 → **8,157** | 12.46 → **7.44 ms** | +| `localpost_wsgi` | 4,919 → **7,743** | 13.03 → **7.87 ms** | +| `flask_cheroot` | 7,433 → 8,218 | 2.41 → 2.20 ms | + +LocalPost native now **out-throughputs Cheroot** (9,381 vs 8,218 RPS) on +plaintext. Same on `json_post`: 7,985 vs 7,698 RPS. Cheroot still wins on +p50 (~2 ms vs ~6 ms) — that's the thread-per-connection model paying off +for tail-latency. We're queue-bound at ~9 k RPS now (`64 / 0.006 ≈ 10,600` +matches measured RPS); raising `max_concurrency` past 32 should push p50 +down and RPS up further. + +All `tests/http/` (129 tests) green; 10/10 stress runs at 800/800 success. + +### What shipped + +- Worker thread pool fed by `threadtools.Channel` (`localpost/http/_service.py`). + No more per-request portal call or `to_thread.run_sync` hop. +- HTTP-native cancellation: `localpost.http.check_cancelled` / + `RequestCancelled` (`localpost/http/_cancel.py`), a per-request token + registry, and service-shutdown propagation. **Not** mixed with AnyIO. +- `Server.to_watchdog` for client-disconnect detection while a handler runs. + Cooperates with the worker via a mode-recheck under `_lock`. +- `HTTPConn.tracked` → `HTTPConn.mode: ConnMode` (UNTRACKED / NORMAL / WATCHDOG). + `tracked` kept as a backwards-compat property. +- `finish_response` now drains h11's pending request-body events before + `_maybe_give_back`. Without this the next keep-alive request hits + `PAUSED` from `parser.next_event`. + +### Bug we hit and fixed + +Once the worker pool was wired up, ~6% of requests under contention failed +with `Connection reset by peer`. Root cause: the worker's *outer* `track()` +call (in the `finally` block) ran `sock.settimeout(0)` on the conn, switching +the shared socket back to non-blocking — racing with the **next** request's +worker, which was already mid-response in blocking I/O mode and would then +hit a spurious `BlockingIOError`. The dispatcher now relies entirely on +`finish_response`'s `_maybe_give_back` to re-track; the outer block only +closes the conn when the inner path didn't (cancel / disconnect / handler +returned without completing). + +## Phase 2 results (2026-04-27) + +Three micro-optimisations on the worker hot path: + +- Pre-bake the `Keep-Alive: timeout=N` header tuple on `Server`; drop the + per-request f-string + encode in `_maybe_inject_keep_alive`. +- Drop `bytes(name).lower()` allocations in `_content_length` and + `_maybe_inject_keep_alive` — h11 normalizes header names to lowercase + bytes on both request and response sides. +- Fold the two header walks in `wsgi._build_environ` into one; cache + `bytes(name)` and avoid double-decoding. + +**Bench (5 s/cell, `max_concurrency=32`):** numbers are within +run-to-run noise (~±3 %) compared to Phase 1. All 100 % success. + +**Why no measurable RPS gain:** at the bench's load (64 clients, +`max_concurrency=32`), we are **selector-bound**, not worker-CPU-bound. +The selector thread does accept + h11 parse + dispatch serially; that +sets the ceiling regardless of how cheap each request is on the workers. +Bumping `max_concurrency` to 128 (verified with a one-shot run) yields +the same ~8.5 k RPS — confirming it's selector-thread CPU, not queueing. + +The Phase 2 changes are still net positive (less per-request CPU on +workers, smaller GC pressure) and they make the WSGI environ build +~2× cheaper, which matters under different workload mixes — but they +don't move the bench needle until Phase 3 unblocks the selector. + +## Phase 3 attempt: lock-free op queue (reverted) + +We prototyped the self-pipe + op-queue design: `Server._lock` removed, workers +enqueue typed `_OpTrack` / `_OpClose` ops on a `collections.deque`, write a +wakeup byte to `os.pipe()` registered in the selector, selector drains at the +top of each iteration. Single-writer-to-selector invariant held, but the +optimistic mode-flip semantics across three threads (selector, watchdog event +handler, worker) created races on the `ConnMode` field that I couldn't fully +close — stress dropped to 50-75% with EBADF mid-`send`. Reverted. + +## Phase 3 (shipped): simplify the conn model, pull-based disconnect detection + +The core insight from the reverted attempt: **the `WATCHDOG` mode is the +source of most of the complexity** (third state, separate selector data tag, +mode-recheck races, level-triggered busy-loop avoidance). Replacing it with a +much simpler design: + +- **Two states only:** `HTTPConn.tracked: bool`. The conn is either in the + selector for normal HTTP processing or borrowed by a worker. No third + WATCHDOG state, no `_WatchdogToken`, no `Server.to_watchdog`, no + `Server._handle_watchdog_event`. +- **Pull-based disconnect detection.** `RequestCancel.is_cancelled` does a + non-blocking `recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. + `b""` means peer FIN; `BlockingIOError` means no signal; any other + `OSError` treats the connection as broken. The result is cached on + `_event` so subsequent calls don't re-issue the syscall. +- **Single shared shutdown event.** Replaces the per-request `in_flight` + registry — every cancel token OR-s in a single `threading.Event` set by + the shutdown watcher. +- **Worker outer-finally simplified.** Only closes when `cancel.fired` + (cheap event-only check) — never reads `ctx._conn.tracked`, which is + shared with the next request's dispatcher and would race. + +### Code delta + +- `localpost/http/server.py`: dropped `ConnMode` enum, `_WatchdogToken` + dataclass, `Server.to_watchdog`, `Server._handle_watchdog_event`. Replaced + `HTTPConn.mode: ConnMode` with `tracked: bool`. Simplified `Server.run`'s + for-event branch. +- `localpost/http/_cancel.py`: `RequestCancel` gains `_sock` + `_shutdown_event`. + `is_cancelled` does the PEEK; `fired` is the event-only check. +- `localpost/http/_service.py`: dropped `_request_has_body`, the in-flight + registry, and the watchdog branch in `dispatch`. Always `stop_tracking`. + +Net: ~150 lines deleted, ~30 simplified. The selector loop has only two +event types (accept / HTTPConn) and one state field (`tracked: bool`). + +### Trade-offs + +- **Detection is cooperative now.** Disconnect surfaces only when the + handler calls `check_cancelled()` — same contract as service-shutdown + cancellation. A pure-compute handler that ignores cancellation won't + notice mid-flight disconnects (just like it doesn't notice service + shutdown). Most real handlers either poll cancellation or perform I/O, + which surfaces disconnects via `EPIPE`/`ECONNRESET` naturally. +- **One extra syscall per `check_cancelled()` call** (the PEEK). Negligible + vs the rest of the request path. + +### Bench (5 s/cell, `max_concurrency=32`, 64 clients) + +| Stack | Phase 2 RPS / p50 | Phase 3-simplified RPS / p50 | +| ----------------- | ----------------: | ---------------------------: | +| `localpost_native`| 9,381 / 6.29 | 8,961 / 7.09 ms | +| `localpost_flask` | 8,157 / 7.44 | 8,053 / 7.88 ms | +| `localpost_wsgi` | 7,743 / 7.87 | 7,630 / 8.29 ms | + +Within run-to-run noise. We're still selector-thread CPU bound at ~9 k RPS +— this refactor was about **maintainability and correctness**, not raising +the ceiling. 10/10 stress runs at 800/800. + +### What's left for future perf work + +The selector ceiling is real. Options to revisit: + +- **Move accept + parse + dispatch to multiple selector threads.** Multiple + selectors each owning a slice of conns. More CPU, more lock-free. +- **Replace pure-Python h11 parsing with a C parser** (e.g. `httptools`). + Likely the easiest win at this point. + +## Phase 5 (shipped): profile-guided micro-opts + +Profiled the server under bench load with `yappi` (CPU clock, multi-threaded). +The profile confirmed h11 dominates the per-request hot path (`next_event`, +`send`, `normalize_and_validate`, `_extract_next_receive_event` together +account for ~60-70% of selector CPU). But it surfaced two avoidable wastes +*outside* h11: + +### Surprise #1: `socket.__repr__` per request + +`Server._apply_track` was using `try selector.modify; except KeyError: +selector.register`. After a worker re-tracks a freshly-unregistered conn, +`modify` always misses — and `selectors.modify` builds the `KeyError` +message as ``f"{fileobj!r}"`` *before* throwing. `socket.__repr__` is +~25 µs/call. At 9 k req/sec that's ~225 ms/sec of CPU spent on an error +message we immediately discard. + +Fix: probe ``selector.get_map()`` (an O(1) ``fd in dict`` check) and +choose ``modify`` vs ``register`` directly — never enter the exception +path. Same fix applied to ``HTTPConn.close`` and the ``_OpClose`` handler +in ``_drain_ops``. + +### Surprise #2: `_maybe_inject_keep_alive` rebuilds the response + +Every response went through ``_maybe_inject_keep_alive``, which +constructed a *new* ``h11.Response`` to append the ``Keep-Alive: timeout=N`` +header. ``h11.Response.__init__`` runs ``normalize_and_validate`` over the +full headers list — heavy h11 work, every response. + +Fix: drop the explicit ``Keep-Alive: timeout=N`` header. HTTP/1.1 +defaults to keep-alive; the timeout header is informational and most +clients (httpx, requests, browsers) maintain their own pool timeout. +The 3 tests that asserted on the explicit header are removed. + +### Bench + +| Stack | Phase 4 RPS / p50 | Phase 5 RPS / p50 | Δ | +| ----------------- | ----------------: | ----------------: | -----: | +| `localpost_native` plaintext | 8,824 / 7.16 | **9,617 / 6.59** | +9.0% | +| `localpost_native` path_param | 8,805 / 7.20 | **9,461 / 6.71** | +7.4% | +| `localpost_native` json_post | 7,676 / 3.93 | **8,589 / 3.63** | +11.9% | +| `localpost_flask` plaintext | 7,909 / 8.01 | **8,385 / 7.56** | +6.0% | + +10/10 stress at 800/800. All remaining 126 tests pass. + +This is the realistic ceiling for the current architecture without +replacing h11 or going multi-process. The remaining selector-thread +CPU is now overwhelmingly inside h11 — verified by the same profile. + +## Phase 4 (shipped): lazy `_cleanup_stale` + +Targeted audit of the selector hot path identified one piece of clearly +wasted CPU: ``Server._cleanup_stale`` walking ``selector.get_map().values()`` +on every iteration to discover "nothing to clean" — the common case under +load (default ``rw_timeout=1.0 s``, ``keep_alive_timeout=15 s``). + +Fix: cache ``_last_cleanup_at``, skip the O(N) walk when ``now - +_last_cleanup_at < _cleanup_interval``. ``_cleanup_interval`` defaults +to ``min(rw_timeout, keep_alive_timeout) / 2`` (=0.5 s with defaults), +floored at 100 ms. + +Trade-off: stale-detection latency goes from ~``select_timeout`` (1 s) to +~``timeout + 0.5 s`` (1.5 s for ``rw_timeout``, ~15.5 s for +``keep_alive_timeout``). Both are detection latencies for non-fatal +conditions (slow clients, dead keep-alive connections), well outside +any user-visible budget. + +### Bench (5 s/cell, ``max_concurrency=32``, 64 clients) + +| Stack | Phase 3-B RPS / p50 | Phase 4 RPS / p50 | +| ----------------- | ------------------: | ----------------: | +| `localpost_native`| 8,843 / 7.15 | 8,824 / 7.16 | +| `localpost_flask` | 7,884 / 7.92 | 7,909 / 8.01 | +| `localpost_wsgi` | 7,505 / 8.45 | ~7,500 / ~8 ms | + +Within run-to-run noise — as predicted. At 64 keep-alive conns, the +O(N) walk savings are ~64 ops per iter × ~9k iter/sec = ~580 k ops/sec +saved. A few percent of selector CPU, swallowed by bench noise. The +optimization scales with conn count: real-world deployments with +hundreds–thousands of idle keep-alive conns would see proportionally +larger gains. 10/10 stress at 800/800. + +The bench needle is now firmly stuck at the h11 parsing ceiling. +Future RPS gains require either a C parser (`httptools`) or +multi-selector / SO_REUSEPORT — both meaningful architectural changes. + +## Phase 3-B (shipped): lock-free op queue + self-pipe wakeup + +With the conn model down to two states, the original Phase 3 design fits +cleanly. Workers no longer touch the selector directly: + +- ``Server._lock`` deleted. +- ``Server._ops: collections.deque`` — atomic ``append`` / ``popleft``. +- ``os.pipe()`` registered in the selector for wakeup. +- ``Server.track`` from a worker thread enqueues ``_OpTrack(conn)`` and + writes a wakeup byte. ``HTTPConn.close`` from a worker enqueues + ``_OpClose(fd)`` after closing the kernel socket synchronously (the + ``_OpClose`` handler just cleans ``selector._fd_to_key``). +- Selector drains ``_ops`` at the top of every iteration and on + wakeup-sentinel events; ``stop_tracking`` and ``_cleanup_stale`` run + inline (already on selector thread). +- ``HTTPConn.fd`` captured at construction so cleanup can use the integer + fd via ``selector.unregister(fd_int)`` even after ``sock.close()`` (where + ``sock.fileno()`` returns -1). + +### Bench (5 s/cell, ``max_concurrency=32``, 64 clients) + +| Stack | Phase 3-A (lock) RPS / p50 | Phase 3-B (op queue) RPS / p50 | +| ----------------- | -------------------------: | -----------------------------: | +| `localpost_native`| 8,961 / 7.09 | 8,843 / 7.15 | +| `localpost_flask` | 8,053 / 7.88 | 7,884 / 7.92 | +| `localpost_wsgi` | 7,630 / 8.29 | 7,505 / 8.45 | + +Within run-to-run noise — confirming the prediction that we're selector +CPU-bound (h11 parsing), not lock-bound. 10/10 stress at 800/800. + +The win isn't in the bench: it's in **the threading model is now strictly +single-writer to the selector**, and the lock is gone. Future work that +needs to add cross-thread selector mutations (e.g. scheduled FD timers) +just enqueues an op — no lock to contend with, no consistency invariants +to re-prove. + +## Phase 6 (shipped): optional httptools backend (2026-04-29) + +The Phase 5 closing line — "remaining selector-thread CPU is now +overwhelmingly inside h11" — was the explicit handoff to this phase. +Cashed it in. + +### What shipped + +A second HTTP/1.1 server backed by ``httptools`` (llhttp) ships as a +**peer** of the existing h11 server, opt-in via the ``[http-fast]`` extra. +Not a parser plugin under one server — two implementations sharing only +the parser-agnostic infrastructure: + +- ``localpost/http/_base.py``: ``BaseServer`` (selector loop, accept, + op queue + wakeup pipe, stale sweep, shutdown) lifted from the old + ``Server``, parameterised on a ``conn_factory``. ``BaseHTTPConn`` ABC + with a small surface (``__call__``, ``close``, ``sock``, ``fd``, + ``tracked``, ``close_at``, ``idle``, ``emit_stale_408``) — no parser + methods leak through. +- ``localpost/http/server_h11.py``: existing logic moved here; translates + at the boundary (``h11.Request`` ⇄ neutral ``Request``, neutral + ``Response`` → ``h11.Response`` before ``parser.send``). +- ``localpost/http/server_httptools.py``: native push-callback driven; + ``_ReadyRequest`` queue handles pipelining naturally; hand-written + response serializer. Each accepted conn gets its own + ``HttpRequestParser`` with the conn instance as the protocol target. +- ``localpost/http/_types.py``: neutral ``Request`` / ``NativeResponse`` + / ``InformationalResponse`` so handlers no longer import ``h11`` or + ``httptools`` directly. + +Hosted-service form: ``httptools_server`` (peer of ``http_server``, +lazy-imports the backend so the extra stays optional). + +Why two implementations rather than a parser Protocol: h11 is +pull-events + parse-AND-serialize, httptools is push-callbacks + +parse-only. Forcing one shape over both restricts the faster backend +without buying anything (the dispatch path / selector / pool are +already shared by ``BaseServer``). Each backend uses its parser's +natural idioms. + +### Bench (8 s/cell, Python 3.13 on Darwin arm64, 64 / 32 clients) + +| Scenario | h11 RPS / p50 | httptools RPS / p50 | Δ RPS | +| ----------------- | -----------------: | ---------------------: | -------: | +| `plaintext` | 9,494 / 6.67 ms | **12,845 / 4.96 ms** | **+35%** | +| `path_param` | 9,500 / 6.67 ms | **12,775 / 4.98 ms** | **+34%** | +| `json_post` | 8,616 / 3.66 ms | **12,504 / 2.54 ms** | **+45%** | +| `profile_update` | 5,841 / 5.43 ms | 5,885 / 5.25 ms | +1% | + +Lands almost exactly on the 30–50% projection from the original Phase 1 +diagnosis. ``profile_update`` doesn't move because the synthetic handler +holds three ``time.sleep`` totalling ~4 ms — at that point parser +overhead is a rounding error; the gain is in the noise. + +p50 latency improves ~25–31% on parser-bound scenarios. We're still +~3–5× behind ``starlette_uvicorn`` / ``starlette_granian`` on absolute +RPS — that's the async-ASGI + uvloop ceiling, well outside what a +sync-handler + thread-pool design gives. + +All 142 http tests pass (130 existing + 12 new backend-parity tests +covering GET / POST-with-body / oversize → 413 / malformed → 400 / +keep-alive×2 / ``Expect: 100-continue`` across both backends). + +### Two bugs the parity tests didn't catch + +Both surfaced only under bench load (``oha`` with persistent HTTP/1.1 +connections), not in the parity suite — pinpointed and fixed in the +same session. + +1. **``_ready`` not popped on borrow.** Under ``thread_pool_handler``, + ``h(req_ctx)`` returns immediately with ``borrowed=True``; my loop + checked ``req_ctx.borrowed`` *before* ``popleft``, so the same + request got re-dispatched on every conn re-track. Symptom: RPS = the + concurrency limit, every request hitting the 1 s ``rw_timeout``. + Fix: ``popleft`` *before* dispatch — ownership transfers to the + ``HTTPReqCtx`` regardless of whether the handler runs synchronously + or hands the conn off. +2. **No body framing for responses without Content-Length.** h11 + silently inserts ``Transfer-Encoding: chunked`` when neither + ``Content-Length`` nor ``Transfer-Encoding`` is set; my hand-written + serializer wrote raw bytes and HTTP/1.1 clients waited for FIN + before considering the response complete. The CHANGELOG/README + "Content-Length only" claim was wrong: ``Router.as_handler`` doesn't + compute lengths from ``Iterable[bytes]`` bodies, and many WSGI apps + don't set the header either. Fix: in the httptools backend's + ``start_response``, when the response lacks both headers, append + ``Transfer-Encoding: chunked`` and frame chunks + (``\r\n\r\n``) plus the ``0\r\n\r\n`` terminator on + ``finish_response``. **One** ``sendall`` per chunk — the first + version did three, and the syscall overhead alone (~75 µs/req at + small bodies) was eating the entire C-parser win, leaving the bench + at parity with h11. + +### Trade-offs + +- **Two parallel implementations, not one.** ~340 lines for the h11 + backend (mostly verbatim from the old ``server.py``) + ~430 lines for + the httptools backend (callback bookkeeping + response serializer + + chunked framing). The shared ``BaseServer`` is ~370 lines, lifted + unchanged. The duplication is intentional: each backend reads as a + straight translation of its parser's natural idioms. +- **Auto-chunked is now in scope.** The CHANGELOG / README originally + said "Content-Length only initially". That stance was based on the + assumption that ``Router`` and ``wrap_wsgi`` always set + Content-Length. They don't. The httptools backend now matches h11's + effective behaviour (silent chunked) for un-framed responses; chunked + remains absent from the trailer / Transfer-Encoding-other-than-chunked + paths, which is fine. +- **Public API took a one-time break.** ``HTTPReqCtx.request`` is now a + neutral ``Request`` (was ``h11.Request``); ``start_response`` / + ``complete`` accept neutral ``NativeResponse`` / + ``InformationalResponse``. Field shapes match h11's — migration is a + mechanical import swap, not a semantic change. Documented in + CHANGELOG. + +## Phase 7 (shipped, but flat): multi-selector single-process (2026-04-29) + +Added the `selectors: int = 1` knob to `http_server` / `httptools_server` / +`wsgi_server`. With `selectors > 1`, N independent `BaseServer` threads +each bind their own listening socket on the same address via +`SO_REUSEPORT` (already enabled in `_base.py`); the kernel hashes +incoming SYNs across them. Shared handler / shared `thread_pool_handler` +worker pool — only the selector layer fans out. Port 0 is resolved once +up-front so all selectors agree on the actual ephemeral. + +### Bench (httptools backend, 8s/cell, Python 3.13 on Darwin arm64) + +| Scenario | s1 RPS / p50 | s2 RPS / p50 | s4 RPS / p50 | +| ----------------- | -------------------: | -------------------: | -------------------: | +| `plaintext` | 12,563 / 5.05 ms | 12,902 / 4.93 ms | 12,784 / 4.98 ms | +| `path_param` | 12,606 / 5.04 ms | 12,691 / 5.01 ms | 12,761 / 5.00 ms | +| `json_post` | 12,359 / 2.57 ms | 12,369 / 2.57 ms | 12,301 / 2.58 ms | +| `profile_update` | 5,978 / 5.21 ms | 6,007 / 5.20 ms | 6,020 / 5.20 ms | + +All deltas (≤+2.7% on s2, ≤+1.8% on s4) are within run-to-run noise. +**The projected 1.5-2x gain on standard CPython did not materialise.** + +### Why the projection was wrong + +Two compounding factors: + +1. **The GIL-held fraction of selector wall time is higher than I + estimated.** I assumed `recv` + the bulk of `httptools.feed_data` + release the GIL, leaving roughly 50% of wall time for parallelism. + In reality, the parser callbacks (`on_url`, `on_header`, + `on_headers_complete`, `on_body`, `on_message_complete`), the + dispatch decision, `Channel.put`, op-queue enqueue, and the + wakeup-pipe `os.write` all hold the GIL — together they're more + like 80-90% of per-request work. Two selector threads serialise on + that, leaving little to overlap. +2. **macOS `SO_REUSEPORT` is not Linux's `SO_REUSEPORT`.** macOS + permits multiple binds to the same address, but the BPF-style + round-robin / 4-tuple-hash distribution that Linux 3.9+ ships is + not part of the macOS contract. Distribution behaviour is + unspecified — connections can funnel to one selector. We didn't + instrument per-selector accept counts in this bench, but the flat + result is consistent with either "GIL pinch" or "no kernel + distribution" or both. + +The implementation itself is correct (137/137 http tests pass, including +parity tests for `selectors ∈ {2, 3}`). The architectural lever just +doesn't pay on this platform / interpreter. + +### What this means for next steps + +- **Free-threaded Python (Phase 7b) is now the more interesting test.** + Removing the GIL is the only thing that lifts factor #1. macOS + `SO_REUSEPORT` semantics still apply — if distribution is also weak + there, we'd need an explicit accept-dispatch fallback (one acceptor + thread + N selector threads handing off via the op queue). +- **Tier 2 micro-opts deliver more reliably on standard CPython.** + Single-`sendall` per response, `socket.sendmsg` for chunked, and the + Tier 3 allocation diet are all `selectors`-independent and cut + per-request CPU on the path that's actually hot. + +`selectors=N` stays in the public API. It's free for users on Linux who +already get kernel-level load balancing, and it's the right shape for +the eventual no-GIL world. We just don't claim it as a perf win on +standard-CPython / macOS. + +## Phase 7b (shipped): free-threaded Python (3.14t) validation (2026-04-29) + +Tested LocalPost under CPython 3.14.4 free-threaded (no-GIL) on Darwin +arm64. Setup: separate venv (``.venv-ft``), `httptools` built from +git main (declares `Py_mod_gil = Py_MOD_GIL_NOT_USED` since 0.8.0; +the released 0.7.1 wheel auto-re-enables the GIL on import). + +### Headline: free-threading alone is the big win + +**Same code, same hardware, same bench harness — switching from standard +CPython 3.13 to free-threaded 3.14t at `selectors=1`:** + +| Backend / scenario | 3.13 RPS / p50 | 3.14t RPS / p50 | Δ RPS | +| --------------------------- | ------------------: | -------------------: | -------: | +| `localpost_httptools` plaintext | 12,563 / 5.05 ms | **36,208 / 1.76 ms** | **+188%** | +| `localpost_httptools` path_param | 12,606 / 5.04 ms | **35,491 / 1.78 ms** | **+182%** | +| `localpost_httptools` json_post | 12,359 / 2.57 ms | **34,220 / 0.93 ms** | **+177%** | +| `localpost_native` plaintext | ~9,500 / 6.7 ms | **23,688 / 2.69 ms** | **+150%** | + +p50 collapses ~3x on every parser-bound scenario. The reason is the +existing single-selector + worker-pool architecture is itself a +multi-threaded design (1 selector thread + 32 workers); under standard +CPython all those threads serialise on the GIL during the +parser-callbacks / dispatch / handler / response-write critical +sections. No-GIL lets the selector and workers actually overlap. The +selector loop is no longer the throughput ceiling — the workers are. + +### Multi-selector under 3.14t: still flat on macOS + +| Scenario | s1 RPS / p50 | s2 RPS / p50 | s4 RPS / p50 | +| ----------------- | -------------------: | -------------------: | -------------------: | +| `plaintext` | 36,208 / 1.76 ms | 36,089 / 1.75 ms | 36,112 / 1.75 ms | +| `path_param` | 35,491 / 1.78 ms | 35,965 / 1.77 ms | 36,638 / 1.74 ms | +| `json_post` | 34,220 / 0.93 ms | 33,740 / 0.94 ms | 34,538 / 0.92 ms | + +Same pattern at higher concurrency (`oha -c 256`) and in inline mode +(no thread pool — handlers run directly on the selector thread, +isolating selector-level throughput): all configurations converge on +the same number. + +### Why multi-selector is flat: macOS `SO_REUSEPORT` does not distribute + +Confirmed empirically with a diagnostic build +(`benchmarks/macro/http/apps/localpost_httptools_diag.py`) that counts requests +per selector thread. At `selectors=4`, `oha -c 512 -z 8s`: + +``` +=== selector distribution (total=402,871) === + tid=6168244224: 402,871 reqs (100.0%) +``` + +**100% of accepts went to one selector thread.** The other three +selectors — each with its own listening socket bound to the same port +via `SO_REUSEPORT` — got zero connections. This is the documented +divergence between Linux's `SO_REUSEPORT` (BPF-style hash distribution +since 3.9) and macOS's, which permits the bind but does not load-balance. + +The implication: on macOS, the `selectors > 1` knob is a no-op. +On Linux (kernel-level distribution) we expect it to scale, but that's +unverified pending a Linux bench. + +### What this means for next steps + +- **Free-threaded Python is now the default for serious perf.** A 3x + jump just by switching interpreters dwarfs every micro-optimisation + we'd land in pure code. We should treat 3.14t as the supported fast + path and keep the codebase free-threaded-clean (no GIL-dependent + patterns; verify deps' `Py_mod_gil` declarations). +- **Accept-dispatch fallback is the next architectural step** — and now + has a clear motivation. One acceptor thread doing `accept()` on a + shared listening socket, dispatching new conns to N selector threads + via the existing op queue. Platform-portable; works on macOS where + `SO_REUSEPORT` doesn't help. The selector loop already accommodates + cross-thread `_OpTrack` enqueues from Phase 3-B — the wiring is + small. +- **Linux-side multi-selector is still worth verifying** — the existing + `selectors > 1` path should scale there. A free CI cell (Linux x86_64, + 3.14t) would settle it. + +The implementation that shipped (`selectors: int = 1`) is correct and +keeps its place in the public API; under the current macOS bench it +just doesn't pay. The right framing for users: "use `> 1` only if your +kernel distributes (Linux 3.9+) or paired with the upcoming +accept-dispatch design." + +## Phase 8 (shipped): continuation handler + auto-buffered response (2026-04-29) + +Restructured the request lifecycle around the JSON-API common case: + +1. **Two-phase handler contract.** + `RequestHandler = Callable[[HTTPReqCtx], BodyHandler | None]`. The + pre-body handler runs on the selector when headers are parsed. It + returns either: + - `None` — handler completed inline (e.g. a 404 / 405 / auth fail + reject) or borrowed the conn for a worker. **No body recv. No + worker hop.** + - `BodyHandler` — the selector buffers the full body into + `ctx.body` and then invokes the continuation. +2. **HTTP/1.1 pipelining dropped.** The httptools backend's + `_ready` deque + per-request cur-state machinery is gone; one + in-flight request per connection. Pipelined clients are served + sequentially. ~50 lines deleted. +3. **Auto-buffered response writes.** `start_response` no longer + flushes — it stashes the serialised headers and the first + `send(...)` (or `finish_response()` for empty bodies) emits + headers + body in a single `sendall`. **Common-case `complete()` + path is now 1 syscall, down from 2.** +4. **`thread_pool_handler` adapts.** The pool no longer wraps the + pre-body invocation. It pass-through if `inner` returns `None` + (inline 404s never enter the channel) and wraps the returned + `BodyHandler` into a worker-dispatch continuation otherwise. + +### Bench (8 s/cell, 64/32 clients) + +**Standard CPython 3.13 / Darwin arm64** — primary perf target (≈ +real-world Linux deployment): + +| Scenario | Phase 7 RPS / p50 | Phase 8 RPS / p50 | Δ RPS | +| ----------------- | -----------------: | ---------------------: | -------: | +| `httptools` plaintext | 12,563 / 5.05 ms | **15,197 / 4.13 ms** | **+21%** | +| `httptools` path_param | 12,606 / 5.04 ms | **15,312 / 4.13 ms** | **+21%** | +| `httptools` json_post | 12,359 / 2.57 ms | **15,051 / 2.11 ms** | **+22%** | +| `h11` plaintext | 9,494 / 6.67 ms | **10,012 / 6.28 ms** | +5% | + +Plus +21% RPS on the path real users will see. p50 collapses with +the single-sendall flush. h11 gains are smaller (still parser-bound), +but the simplification is consistent across both backends. + +**Free-threaded CPython 3.14t / Darwin arm64**: pooled httptools +plaintext sits at 34,456 RPS (vs Phase 7's 36,208 — within ±5% +noise; the bench is client-saturated at `c=64` once p50 drops below +2 ms). Inline (no-pool) httptools plaintext: 52,015 RPS. The +restructure is neutral here, as expected — most of the savings are +already absorbed by the no-GIL win. + +### Why this delivers more than syscall coalescing alone + +Pure micro-opts (combine sendalls, header scan) saved a syscall but +left the architectural shape unchanged. The continuation pattern +adds a bigger lever: **fast handlers (no body) and rejections never +wait for the body to arrive at all.** For the common JSON-API mix +(404 / 405 / GET / POST-with-JSON), this: + +- removes a pool-channel round-trip from every body-free request + (the old `thread_pool_handler` always borrowed) +- removes the worker-thread body recv from POSTs (selector buffers + it; worker just runs the JSON parse + response build) + +The single-sendall change rides on top. + +### One-time API break + +`RequestHandler`'s return type changed from `None` to `BodyHandler | +None`. Old-style `(ctx) -> None` handlers are forward-compatible — +returning `None` implicitly is the "complete inline" path. Handlers +that previously read body via `ctx.receive(size)` need to migrate to +the continuation pattern (return a `BodyHandler` and read the body +from `ctx.body`). All in-tree adapters (`Router.as_handler`, +`wrap_wsgi`, `flask_handler`, `sentry_router_handler`, +`sentry_flask_handler`) have been updated; user code that bypassed +those needs the same shape. + +## Phase 9 (shipped): middleware + lean Router + HttpApp framework (2026-04-29) + +Architectural restructure across the http stack: + +1. **Middleware support.** New ``Middleware = Callable[[RequestHandler], + RequestHandler]`` type and a ``compose(*mws)`` helper. Plain Python + decorator pattern — no special chain object. Pre-body short-circuit + and post-body wrapping (via the returned BodyHandler) both work + naturally. +2. **HTTPReqCtx.attrs.** New ``dict[str, Any]`` per-request mutable + state on the Protocol. Used by the Router to attach ``RouteMatch`` + and by middlewares to thread cross-cutting state (auth, tracing). +3. **Router stripped to a lean middleware-friendly dispatcher.** The + framework-y ``RequestCtx`` / ``Response`` / ``RequestHandler`` shapes + that lived inside ``router.py`` are gone. ``Router.as_handler()`` + now matches the URI template, attaches a ``RouteMatch`` to + ``ctx.attrs["route_match"]``, and delegates to a registered + http-level :data:`localpost.http.RequestHandler`. 404 / 405 stay + inline. ``Router.wsgi`` is dropped. +4. **New ``HttpApp`` framework** (``localpost.http.app``). Decorator- + driven, with parameter injection (``HTTPReqCtx`` + path args by + name), automatic response conversion (str / bytes / dict / list / + ``NativeResponse`` / ``(NativeResponse, bytes)`` / ``None``), + app-level + per-route middleware composition, and a ``service()`` + factory that composes the worker pool + chosen backend. +5. **``streaming_pool_handler`` + ``buffer_body=False`` per route.** + For routes that need raw socket access (large uploads), the handler + runs on a worker on a borrowed conn — body **not** pre-buffered. + Reads via ``ctx.receive(...)`` chunk by chunk. Internal pool + primitive (``_Pool``) shared by both buffered and streaming dispatch. + +### Bench (8 s/cell, standard CPython 3.13 / Darwin arm64) + +| Scenario | Phase 8 RPS / p50 | Phase 9 RPS / p50 | Δ RPS | +| ----------------- | ---------------------: | ---------------------: | -------: | +| `httptools` plaintext | 15,197 / 4.13 ms | **20,868 / 3.05 ms** | **+37%** | +| `httptools` path_param | 15,312 / 4.13 ms | **20,667 / 3.08 ms** | **+35%** | +| `httptools` json_post | 15,051 / 2.11 ms | **20,152 / 1.58 ms** | **+34%** | +| `httptools` profile_update | 5,384 / 5.95 ms | 6,286 / 5.09 ms | +17% | + +p50 collapses ~25% across the fast scenarios. ``profile_update`` is +handler-CPU-dominated (4 ms of `time.sleep`), so the framework +overhead saving shows up as a smaller relative gain. + +### Why this delivers another ~35% + +The lean Router stops constructing per-request the framework objects +the old shape needed: ``RequestCtx`` (with its ``ExitStack``, headers +dict, query parse, receive shim, body cache) and ``Response`` plus +the wire-bytes encoding of its headers / iter-body. + +Now ``Router.as_handler`` is essentially: regex match + ``ctx.attrs[] +=`` + delegate. The handler chosen by the route is a regular +``RequestHandler``; if registered through ``HttpApp`` it gets the +auto-buffered Phase 8 response path with one more layer of param +resolution. + +For the bench specifically, the handlers return +``(NativeResponse, bytes)`` tuples — pre-baked wire shapes, so we +skip the str/dict→bytes conversion path. Real apps using ``str`` / +``dict`` returns will sit slightly below this number; the Pythonic +return paths are still leaner than the old Router framework. + +### One-time API breaks + +- ``Router.wsgi`` removed. +- Router-level ``RequestCtx``, ``Response``, ``RequestHandler`` (the + ``(RequestCtx) -> Response`` shape) gone. Migrate to ``HttpApp`` or + use the lean http-level :data:`localpost.http.RequestHandler` shape + directly. + +## Phase 10 (shipped): drop per-request settimeout (2026-04-30) + +Removed the two ``sock.settimeout`` (fcntl) calls per request that the +borrow / re-track boundary used to pay: + +- ``BaseServer.stop_tracking`` no longer flips the socket to + blocking-with-timeout. The conn stays non-blocking after the worker + borrows it. +- ``BaseServer.track`` no longer flips back to non-blocking. It's a + no-op on socket flags now; only the kernel-level + ``selector.register/modify`` op fires. +- New ``_send_all`` helper in ``_base.py``: tries non-blocking + ``send`` first; on ``BlockingIOError`` (kernel buffer full + mid-response), transitions to blocking-with-timeout for the + remainder, then restores non-blocking. Common-case JSON-API + responses fit in the kernel buffer in a single ``send`` — no + fallback fires. +- One-time ``setblocking(False)`` after ``accept`` (macOS / BSD don't + inherit ``O_NONBLOCK`` from the listener; Linux 2.6.28+ does). + +### Bench (8 s/cell, standard CPython 3.13 / Darwin arm64) + +| Scenario | Phase 9 RPS / p50 | Phase 10 RPS / p50 | Δ RPS | +| ----------------- | ---------------------: | ---------------------: | -------: | +| `httptools` plaintext | 20,868 / 3.05 ms | **25,230 / 2.50 ms** | **+21%** | +| `httptools` path_param | 20,667 / 3.08 ms | **25,247 / 2.51 ms** | **+22%** | +| `httptools` json_post | 20,152 / 1.58 ms | **24,712 / 1.28 ms** | **+23%** | +| `httptools` profile_update | 6,286 / 5.09 ms | 6,311 / 5.08 ms | +0% | +| `h11` plaintext | ~10,012 / 6.28 ms | **13,225 / 4.81 ms** | **+32%** | +| `h11` json_post | ~9,550 / 3.30 ms | **12,432 / 2.55 ms** | **+30%** | + +Bigger than the 3-5% I'd projected — the per-request cost of +``settimeout`` on Darwin is heavier than expected (each fcntl ~5-7 µs +under realistic load, not the textbook ~1 µs). At 25k RPS, two fcntl +saved per request is ~250-350 ms/sec of selector / worker CPU +reclaimed. + +p50 collapses ~17-19% across the fast scenarios. ``profile_update`` +remains handler-bound (~4 ms of ``time.sleep`` dominates). + +### What this validates + +The user's hypothesis: **conn stays non-blocking; ``send`` relies on +the kernel buffer absorbing small responses, falls back only on +``BlockingIOError``.** The fallback path is rare in JSON-API +workloads and the savings on the common path are real. + +The "never borrow" variant (don't even ``selector.unregister``) was +considered and dropped — synchronisation against pipelined clients +needs *something*, and the unregister is the cheapest correct +mechanism (~1 µs each on macOS kqueue). + +## Phase 11 (shipped): Tier-3 allocation diet + Request enrichment (2026-04-30) + +Mechanical follow-up to Phase 10, plus one one-time API tightening on +``Request``. Numbers are within run-to-run noise on the macOS bench +because we're already at the C-parser ceiling (Phase 6 / 10 closed the +big gaps); the wins land mostly on smaller paths and on the API +shape. + +### What shipped + +1. **No-op ``bytes()`` wraps removed.** httptools and h11 hand back real + ``bytes`` objects from their callbacks / events; ``bytes(b)`` for a + ``bytes`` argument returns the same object, so the wraps were dead + weight. The h11 backend's per-request ``[(bytes(n), bytes(v)) for n, v + in event.headers]`` is replaced by ``list(event.headers)`` — a + shallow copy that skips the per-tuple alloc but stays insulated from + h11's per-event ``Headers`` subclass. Multi-fragment URL accumulation + in httptools' ``on_url`` switched to a lazy ``bytearray`` so the rare + fragmented case doesn't pay quadratic ``bytes`` concat. +2. **Pre-serialised canned protocol-error responses.** Each + ``*_RESPONSE`` constant in ``_base.py`` now ships with a sibling + ``*_WIRE: bytes`` — full status line + headers + body, built once at + module import time. The httptools backend's ``_try_send_status`` and + ``emit_stale_408`` use the pre-built bytes via ``_send_all`` and skip + ``_serialize_response`` on the error path entirely. +3. **Cached 404 / 405 responses in Router.** The 404 ``NativeResponse`` + + body is a module-level constant; the per-route 405 pair is + pre-built at ``Routes.build()`` time and stored on ``Route`` next to + the existing ``allow_header``. ``Router.dispatch`` uses + ``ctx.complete(*cached)`` directly; the per-miss list / encode / + ``Response`` build is gone. +4. **``Request.path`` and ``Request.query_string``.** New pre-split + fields populated by the backend at parse time. Each consumer + (``Router``, ``wsgi._build_environ``, ``router_sentry``) now reads + them directly instead of doing the same ``target.decode + split`` + per dispatch. The h11 backend also normalises ``method`` to + uppercase here (h11 is lenient on method case; httptools rejects + lowercase at parse time, so it's already uppercase). + +### One implementation surprise: skip ``httptools.parse_url`` + +The first cut of #4 used ``httptools.parse_url(target)`` in the +httptools backend, on the assumption that a C-level URL parser would +beat a Python ``find`` / ``split``. **It didn't** — bench showed +plaintext regressed ~8 % on the httptools backend, and a +``timeit`` micro-bench confirmed ``parse_url`` is ~2× slower than the +manual split for typical short JSON-API targets (it builds a +``URL`` object with multiple bytes attributes — Python +object-construction overhead per parse, on top of the C parsing). +Both backends now use ``target.find(b'?')`` + slice; the API is the +same, and the implementation is consistent across backends. + +### Bench (10 s/cell, standard CPython 3.13 on Darwin arm64) + +| Scenario | Phase 10 RPS / p50 | Phase 11 RPS / p50 | Δ RPS | +| ----------------- | ---------------------: | ---------------------: | -------: | +| `httptools` plaintext | 25,230 / 2.50 ms | 25,438 / 2.49 ms | +1% | +| `httptools` path_param | 25,247 / 2.51 ms | 25,408 / 2.49 ms | +1% | +| `httptools` json_post | 24,712 / 1.28 ms | 24,342 / 1.29 ms | -1% | +| `httptools` profile_update | 6,311 / 5.08 ms | 6,315 / 5.08 ms | 0% | +| `h11` plaintext | 13,225 / 4.81 ms | 13,332 / 4.77 ms | +1% | +| `h11` path_param | ~13 k / ~4.8 ms | 13,324 / 4.77 ms | +2% | +| `h11` json_post | 12,432 / 2.55 ms | 12,420 / 2.56 ms | 0% | + +All within run-to-run noise (~±3 %) on the bench's hot scenarios. +That matches the prediction: the bench is parser-bound at the +selector for httptools and selector-bound at h11 parsing for h11; +the per-request work we trimmed is real but small relative to those +floors. **The wins are on paths the bench doesn't stress hard**: + +- 404 / 405 dispatch is now per-miss-allocation-free (matters under + scanner / probing traffic) +- consumer-side decode + split removed (smaller benefit at 25 k RPS, + larger at higher concurrency where consumers stack up) +- error-path serialisation is gone for the protocol-error responses +- the API shape is now right for httptools' speed: the backend + produces ``path`` / ``query_string`` natively, with the same + cost on h11 + +192 / 192 ``tests/http/`` green. ``just check`` clean on all +touched files. + +### One-time API break + +``Request`` gained two non-default fields (``path``, ``query_string``). +Code constructing ``Request`` by hand needs both. All in-tree +backends and the one test fixture (``tests/http/service.py``) that +built a ``Request`` directly are updated. User code using +``HTTPReqCtx.request.target`` is unaffected — ``target`` is preserved. + +### What we didn't ship + +- **Reusing ``_cur_headers`` across requests on a conn (httptools).** + Investigated and dropped: the previous Request holds the list, so + reuse requires a fresh copy at Request construction (O(N)), + replacing a per-request ``[]`` allocation that's already O(1) via + CPython's list freelist. Net loss, plus it weakens Request's + effective immutability. +- **Caching header-presence flags on ``Response``.** Per-response + scan in ``_scan_response_headers`` is real but tiny (2-4 headers + on typical JSON responses). The clean fix needs mutable cache + slots on a frozen dataclass; not worth the structural change for + the measured impact. The static error responses bypass the scan + entirely now via the pre-serialised path (see above). +- **Lowercasing common header names via an intern table** in the + httptools ``on_header`` callback. Profile evidence didn't push us + there; revisit if a future profile shows ``name.lower()`` + alloc cost climbing. + +## Phase 12 (shipped): acceptor topology — 1 acceptor + N worker selectors (2026-05-02) + +Reverses the call made in Phase 7's *"What's left"* (and the earlier +"Accept-dispatch alternative — explicitly dropped"). Phase 7b made the case +for it concrete: free-threaded scaling needs N busy selector threads, and on +macOS `SO_REUSEPORT` puts every conn on one. The acceptor topology adds a +single dedicated acceptor thread that round-robins each accepted conn onto N +worker selectors via the existing op queue + wakeup pipe. + +### What shipped + +The architectural enabler is the *Selector / ConnHandler* split (commit +`8748c66`) — `BaseServer` now composes a dumb fd→callback `Selector` with a +`ConnHandler` (after-accept policy): + +``` +Selector ── owns fd→SelectorCallback map; nothing HTTP-specific + │ + ▼ +ConnHandler ── after-accept policy; default: track on the accepting selector; + │ acceptor mode: post_track to a worker selector + ▼ +RequestHandler ── pre-body dispatch + │ + ▼ +BodyHandler ── post-body continuation +``` + +`http_server` / `wsgi_server` / `HttpApp.service` each gained an +`acceptor: bool = False` knob. With `acceptor=True, selectors=N`: +one thread runs the acceptor `BaseServer` (listen socket only, no conn +tracking), N threads each run a worker `Selector` (no listen socket); a +`RoundRobinAcceptor` `ConnHandler` builds each new conn for the next worker +and calls `worker.post_track(conn)` (cross-thread `_OpTrack` on the existing +op queue from Phase 3-B). The handoff path was already cross-thread-safe — +`ConnHandler` just gives it a stable name. + +> **Stack rename in this phase.** `localpost_native` → `localpost_h11` +> (and its `_s4` / `_acceptor_s4` variants). The old name conflated +> "default LocalPost framework" with "default backend" — confusing once +> `localpost_httptools` sat next to it in the matrix. Earlier-phase tables +> in this document still reference `localpost_native` for historical +> accuracy; from this phase onward the parser is explicit on every stack +> name (`_h11` / `_httptools`). + +### Bench (6 s/cell, Darwin arm64, two interpreters) + +The story splits cleanly along the GIL axis. Numbers: `localpost` group +only, plaintext scenario. + +**Standard CPython 3.13 (GIL):** + +| Stack | RPS | vs `s=1` baseline | +| ----------------------------------------- | -----: | ----------------: | +| `localpost_h11` (s=1, pool) | 12,504 | — | +| `localpost_h11_s4` (SO_REUSEPORT) | 12,812 | +2% | +| `localpost_h11_acceptor_s4` | 12,215 | −2% | +| `localpost_httptools` (s=1, pool) | 24,356 | — | +| `localpost_httptools_s4` | 24,598 | +1% | +| `localpost_httptools_acceptor_s4` | 21,207 | **−13%** | + +Under the GIL the topology is flat-to-slightly-worse. The cross-thread +op-queue dispatch costs more than it saves: parser callbacks + dispatch + +response-write all serialise on the GIL, so spreading the readable-event +work across N threads buys nothing, and pays one extra wakeup-pipe write per +conn for the privilege. + +**Free-threaded CPython 3.14t:** + +| Stack | RPS | vs `s=1` baseline | +| ----------------------------------------- | -----: | ----------------: | +| `localpost_h11` (s=1, pool) | 20,033 | — | +| `localpost_h11_s4` (SO_REUSEPORT) | 20,604 | +3% | +| **`localpost_h11_acceptor_s4`** | **26,684** | **+33%** | +| `localpost_httptools` (s=1, pool) | 16,262 | — | +| `localpost_httptools_s4` | 15,973 | −2% | +| **`localpost_httptools_acceptor_s4`** | **17,958** | **+10%** | + +The headline result on h11: `localpost_h11_acceptor_s4` does **26,684 vs +`localpost_h11_s4`'s 20,604 = +30% over the SO_REUSEPORT alternative**. This is precisely the +case the topology was added for — Phase 7b confirmed `SO_REUSEPORT` puts +100 % of accepts on one selector on macOS; the explicit round-robin replaces +that with a real 4-way spread. httptools shows a smaller gain because its +hot path is shorter, so the per-conn dispatch overhead occupies a larger +share of the budget. + +### Footgun: `acceptor + inline` (no pool) + +The wrong combination on the JSON-API common case: + +| Stack (3.14t) | RPS | vs inline s=1 | +| ------------------------------------------------- | -----: | ------------: | +| `localpost_httptools_inline` (s=1) | 73,536 | — | +| `localpost_httptools_inline_s4` (SO_REUSEPORT) | 74,033 | +1% | +| `localpost_httptools_inline_acceptor_s4` | 43,998 | **−40%** | + +With no pool, the handler runs on the worker selector thread, and the accept +→ cross-thread `_OpTrack` hop is pure overhead — there's no parallelism to +amortise it against. **`acceptor=True` should be paired with +`thread_pool_handler`** for the JSON-API shape; documented on the +`http_server` docstring. + +### When `acceptor + inline` is *right*: I/O-bound handlers + +The inline + acceptor combination *does* win when the handler genuinely +parallelises (e.g. blocks on I/O). The `profile_update` scenario has a +deliberate `time.sleep` totalling ~4 ms per request: + +| Stack (3.14t) | RPS | Notes | +| ------------------------------------------------- | ---: | -------------------------------- | +| `localpost_httptools_inline_acceptor_s4` | 647 | round-robin across 4 selectors | +| `localpost_httptools_inline_s4` (SO_REUSEPORT) | 164 | piled on one — same as s=1 | +| `localpost_httptools_inline` (s=1) | 164 | bound by single-thread sleep | + +**~4× throughput** — the theoretical max for 4 workers. The +`SO_REUSEPORT` variant offers no improvement because the kernel pinned every +conn to the same selector (Phase 7b finding); explicit round-robin is what +unlocks the parallelism. + +### What this validates + +- **Free-threading is now the supported fast path** (already true since + Phase 7b). The acceptor topology compounds it: +30 % over `SO_REUSEPORT` + on h11, +10 % on httptools, on the macOS dev box where `SO_REUSEPORT` + doesn't kernel-balance. +- **The Phase 7 `selectors > 1` knob is still correct on Linux.** This + Phase didn't bench Linux; the documented expectation (`SO_REUSEPORT` + distributes there since 3.9) stands. Acceptor mode is the macOS / free- + threaded option, not a Linux replacement. +- **The Selector / ConnHandler split is the right factoring.** The acceptor + topology fell out of `_service.py` in ~80 lines, with no changes to + `_base.py`'s parser-driven hot path. Future "where does this conn live" + decisions (per-host pinning, conn-shedding, deterministic-routing) are + the same shape — a different `ConnHandler`. + +### Trade-offs + +- **One more thread.** `acceptor=True, selectors=N` runs **N+1** OS threads, + not N. The acceptor thread does only `accept()` + a cross-thread enqueue; + it's nearly idle relative to the workers, but the cost is real on + thread-budget-sensitive deployments (e.g. ulimit-constrained containers). +- **Pin point per conn.** A conn is round-robined exactly once on accept + and lives on its assigned worker selector for its full keep-alive + lifetime. No re-balancing if a worker gets a hot client. Two consequences: + long-lived keep-alive conns can drift out of balance; the parser-handoff + invariant (single owner per parser instance) is preserved by design. + +## What's left for future perf work + +Within the [Optimisation boundaries](#optimisation-boundaries) at the top of +this doc: + +- **Linux multi-selector validation.** The shipped `selectors > 1` path + should scale on Linux (kernel-level `SO_REUSEPORT` distribution since + 3.9). Untested locally because dev is macOS-only; settle it on the + first Linux bench cell — no code change needed. Real deployments are + ~99% Linux, so this is the actual perf focus, not the macOS dev-box + result above. The macOS `SO_REUSEPORT` gap is now closed by the + acceptor topology (Phase 12); a Linux bench should also tell us + whether `acceptor=True` is worth using *over* `SO_REUSEPORT` on + free-threaded Linux, or whether kernel distribution dominates. +- **Worker-side syscall coalescing.** One ``sendall`` per response (status + + headers + body in a single ``bytearray``); ``socket.sendmsg`` for + chunked responses (status + first chunk + terminator in one syscall). +- **Pre-baked common headers** (Date, Server) cached per-second on the + ``BaseServer``, written by both backends — only if we ever auto-emit + them. + +Explicit non-goals (per [Optimisation boundaries](#optimisation-boundaries)): +multi-process, async/ASGI on the server side, thread-per-connection rewrite +(Cheroot already exists), sendfile (no benched workload exercises it). diff --git a/benchmarks/macro/http/README.md b/benchmarks/macro/http/README.md new file mode 100644 index 0000000..45dd4de --- /dev/null +++ b/benchmarks/macro/http/README.md @@ -0,0 +1,98 @@ +# LocalPost HTTP server benchmark + +Macro HTTP load benchmark — compare LocalPost's HTTP server against peer +servers (Gunicorn, Cheroot, Granian, Uvicorn) on a fixed Flask / +Starlette workload using [`oha`](https://github.com/hatoo/oha) as the +load generator. + +This is the **server** bench. The sibling +[`benchmarks/macro/openapi/`](../openapi/README.md) measures **framework** +overhead (typed handlers, schema validation) — different question, +different suite. + +## Quick start + +```bash +brew install oha # one-time prereq +just bench-deps # provision .venv-bench// for every interpreter + +# 8 stacks × 4 scenarios at 20s each = ~11 minutes +just bench-http + +# faster sanity sweep +just bench-http --duration 5 --stacks localpost_h11,flask_gunicorn + +# named subsets +just bench-http --group quick # ~4 stacks, fast PR check +just bench-http --filter app=flask # all Flask servers +just bench-http --filter 'backend=lp-*' --filter selectors=1 +``` + +Output lands in `benchmarks/macro/http/results///`: + +- `results.json` — raw cells (re-parseable). +- `RESULTS.md` — markdown summary, one table per scenario. +- `RESULTS.html` — interactive view with sortable tables and dim filters. + +## What's compared + +Three "app types", each implementing the same four scenarios: + +| App type | Stacks | +|------------|-------------------------------------------------------------------------------------------| +| Native | `localpost_h11`, `localpost_httptools`, plus their `_s4` (`SO_REUSEPORT`) and `_acceptor_s4` variants; `localpost_httptools_inline` (no pool) and its multi-selector variants | +| WSGI/Flask | `localpost_wsgi`, `localpost_flask`, `flask_cheroot`, `flask_gunicorn`, `flask_granian` | +| ASGI | `starlette_uvicorn`, `starlette_granian` | + +Scenarios — defined in [`scenarios.py`](scenarios.py): + +| Scenario | Method | Path | Concurrency | +|------------------|--------|-----------------------------|-------------| +| `plaintext` | GET | `/ping` | 64 | +| `path_param` | GET | `/hello/{name}` | 64 | +| `json_post` | POST | `/echo` | 32 | +| `profile_update` | POST | `/users/{user_id}/profile` | 32 | + +`profile_update` is the more application-shaped case: route parameter, +JSON request parsing, deterministic profile normalization, three short +simulated I/O waits, and JSON response serialization. + +All servers are configured to be roughly comparable — single process, +sized worker pool (`max_concurrency=32` for LocalPost, `--threads 32` +for Gunicorn/Granian-WSGI, etc.). We're measuring server overhead, not +the multiplicative effect of more processes. + +## How a run works + +For each (python, stack, scenario) cell, the shared runner +([`benchmarks/macro/_core/cli.py`](../_core/cli.py)) does: + +1. `subprocess.Popen` the stack as `python -m benchmarks.macro.http.apps.`. +2. Poll `127.0.0.1:` with TCP connect until ready (≤ 10 s). +3. Fire `oha --no-tui -j -z s -c ...`. +4. Parse JSON → RPS, p50/p90/p99, status histogram. +5. SIGTERM the stack, wait, move on. + +## Adding a new stack / scenario + +- **Stack:** drop a `python -m`-runnable module under [`apps/`](apps/) + that takes `--port`, binds 127.0.0.1, and serves the routes from + `scenarios.py`. Add a `Stack(...)` to `STACKS` in `stacks.py`. +- **Scenario:** add a `Scenario(...)` to `SCENARIOS` in `scenarios.py`, + then implement the route in every app (typically by extending + [`apps/_flask_app.py`](apps/_flask_app.py) and + [`apps/_starlette_app.py`](apps/_starlette_app.py)). + +## Caveats + +- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel + scheduling, other processes. Re-run if a cell looks anomalous; + consider running with everything else closed. The relative ordering + on the *same* run is what matters; absolute RPS will shift + run-to-run. +- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP + throughput regression gates. Run macro benchmarks locally / on a + dedicated machine. +- **Single process by design.** All servers configured `workers=1` to + isolate the server-layer overhead. Real deployments multiply by N + workers; the relative ordering still holds. diff --git a/examples/consumers/__init__.py b/benchmarks/macro/http/__init__.py similarity index 100% rename from examples/consumers/__init__.py rename to benchmarks/macro/http/__init__.py diff --git a/examples/consumers/kafka/__init__.py b/benchmarks/macro/http/apps/__init__.py similarity index 100% rename from examples/consumers/kafka/__init__.py rename to benchmarks/macro/http/apps/__init__.py diff --git a/benchmarks/macro/http/apps/_cli.py b/benchmarks/macro/http/apps/_cli.py new file mode 100644 index 0000000..5e54a1c --- /dev/null +++ b/benchmarks/macro/http/apps/_cli.py @@ -0,0 +1,42 @@ +"""Tiny shared CLI helper for app modules. + +Each app reads a ``--port`` (default 8000) and binds 127.0.0.1. LocalPost +apps additionally accept ``--selectors`` (default 1) for multi-selector +mode and ``--acceptor`` to switch to the dedicated-acceptor topology +(1 acceptor thread + N worker selectors). +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass + + +def parse_port(default: int = 8000) -> int: + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=default) + return p.parse_args().port + + +@dataclass(slots=True, frozen=True) +class AppArgs: + port: int + selectors: int + acceptor: bool + warmup: int + + +def parse_args(default_port: int = 8000) -> AppArgs: + """Port + selectors + acceptor + warmup. Used by LocalPost bench apps.""" + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=default_port) + p.add_argument("--selectors", type=int, default=1) + p.add_argument("--acceptor", action="store_true") + p.add_argument( + "--warmup", + type=int, + default=32, + help="Pre-spawn N worker threads before serving (default: 32).", + ) + a = p.parse_args() + return AppArgs(port=a.port, selectors=a.selectors, acceptor=a.acceptor, warmup=a.warmup) diff --git a/benchmarks/macro/http/apps/_flask_app.py b/benchmarks/macro/http/apps/_flask_app.py new file mode 100644 index 0000000..aed8265 --- /dev/null +++ b/benchmarks/macro/http/apps/_flask_app.py @@ -0,0 +1,35 @@ +"""Flask app shared by every WSGI-side stack.""" + +from __future__ import annotations + +import time + +from flask import Flask, Response, jsonify +from flask import request as flask_request + +from benchmarks.macro.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update + + +def build_app() -> Flask: + app = Flask(__name__) + + @app.get("/ping") + def ping() -> Response: + return Response(PING_BODY, mimetype="text/plain") + + @app.get("/hello/") + def hello(name: str) -> Response: + return Response(f"{HELLO_PREFIX}{name}".encode(), mimetype="text/plain") + + @app.post("/echo") + def echo() -> Response: + return Response(flask_request.get_data(cache=False), mimetype="application/json") + + @app.post("/users//profile") + def profile_update(user_id: str) -> Response: + response = build_profile_update(user_id, flask_request.get_json()) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return jsonify(response) + + return app diff --git a/benchmarks/macro/http/apps/_starlette_app.py b/benchmarks/macro/http/apps/_starlette_app.py new file mode 100644 index 0000000..0e7a1c0 --- /dev/null +++ b/benchmarks/macro/http/apps/_starlette_app.py @@ -0,0 +1,44 @@ +"""Starlette app shared by every ASGI-side stack.""" + +from __future__ import annotations + +import asyncio + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from benchmarks.macro.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update + + +async def _ping(_: Request) -> Response: + return Response(PING_BODY, media_type="text/plain") + + +async def _hello(req: Request) -> Response: + name = req.path_params["name"] + return Response(f"{HELLO_PREFIX}{name}".encode(), media_type="text/plain") + + +async def _echo(req: Request) -> Response: + body = await req.body() + return Response(body, media_type="application/json") + + +async def _profile_update(req: Request) -> Response: + response = build_profile_update(req.path_params["user_id"], await req.json()) + for delay_s in PROFILE_WORK_DELAYS_S: + await asyncio.sleep(delay_s) + return JSONResponse(response) + + +def build_app() -> Starlette: + return Starlette( + routes=[ + Route("/ping", _ping, methods=["GET"]), + Route("/hello/{name}", _hello, methods=["GET"]), + Route("/echo", _echo, methods=["POST"]), + Route("/users/{user_id}/profile", _profile_update, methods=["POST"]), + ] + ) diff --git a/benchmarks/macro/http/apps/flask_cheroot.py b/benchmarks/macro/http/apps/flask_cheroot.py new file mode 100644 index 0000000..d634859 --- /dev/null +++ b/benchmarks/macro/http/apps/flask_cheroot.py @@ -0,0 +1,24 @@ +"""Flask app served by Cheroot's WSGI server.""" + +from __future__ import annotations + +import sys + +from cheroot.wsgi import Server as WSGIServer + +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app + + +def main() -> int: + port = parse_port() + server = WSGIServer(("127.0.0.1", port), build_app(), numthreads=32) + try: + server.start() + except KeyboardInterrupt: + server.stop() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/flask_granian.py b/benchmarks/macro/http/apps/flask_granian.py new file mode 100644 index 0000000..408371d --- /dev/null +++ b/benchmarks/macro/http/apps/flask_granian.py @@ -0,0 +1,32 @@ +"""Flask app served by Granian (WSGI mode, 1 worker, 32 blocking threads).""" + +from __future__ import annotations + +import sys + +from granian import Granian +from granian.constants import Interfaces + +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app + +# Granian re-imports this module in each worker; the worker uses ``app`` directly. +app = build_app() + + +def main() -> int: + port = parse_port() + Granian( + target=f"{__name__}:app", + address="127.0.0.1", + port=port, + interface=Interfaces.WSGI, + workers=1, + blocking_threads=32, + log_enabled=False, + ).serve() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/flask_gunicorn.py b/benchmarks/macro/http/apps/flask_gunicorn.py new file mode 100644 index 0000000..4327d06 --- /dev/null +++ b/benchmarks/macro/http/apps/flask_gunicorn.py @@ -0,0 +1,47 @@ +"""Flask app served by Gunicorn (1 worker, gthread, 32 threads). + +Single-worker on purpose: we measure server overhead, not the multiplicative +effect of more processes. +""" + +from __future__ import annotations + +import sys + +from gunicorn.app.base import BaseApplication + +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app + + +class _GunicornApp(BaseApplication): + def __init__(self, app, options: dict): + self._app = app + self._options = options + super().__init__() + + def load_config(self) -> None: + for k, v in self._options.items(): + self.cfg.set(k, v) + + def load(self): + return self._app + + +def main() -> int: + port = parse_port() + options = { + "bind": f"127.0.0.1:{port}", + "workers": 1, + "threads": 32, + "worker_class": "gthread", + "accesslog": None, + "errorlog": "-", + "loglevel": "warning", + } + _GunicornApp(build_app(), options).run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_flask.py b/benchmarks/macro/http/apps/localpost_flask.py new file mode 100644 index 0000000..101c485 --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_flask.py @@ -0,0 +1,30 @@ +"""LocalPost ``flask_server`` (native Flask adapter) hosting the shared Flask app.""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app +from localpost import threadtools +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler +from localpost.http.flask import flask_handler + + +def main() -> int: + port = parse_port() + threadtools.warmup(32) + cfg = ServerConfig(host="127.0.0.1", port=port) + + @service + async def app(): + async with thread_pool_handler(flask_handler(build_app())) as wrapped: + async with http_server(cfg, wrapped): + yield + + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_h11.py b/benchmarks/macro/http/apps/localpost_h11.py new file mode 100644 index 0000000..d503474 --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_h11.py @@ -0,0 +1,59 @@ +"""LocalPost — HttpApp framework + h11 (pure-Python) backend.""" + +from __future__ import annotations + +import sys +import time + +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost import threadtools +from localpost.hosting import run_app +from localpost.http import HTTPReqCtx, Response, ServerConfig +from localpost.http.app import HttpApp + + +def main() -> int: + args = parse_args() + threadtools.warmup(args.warmup) + app = HttpApp() + + @app.get("/ping") + def ping(): + return Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(PING_BODY)).encode("ascii"))], + ), PING_BODY + + @app.get("/hello/{name}") + def hello(name: str): + body = hello_body(name) + return Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), body + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return Response( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(ctx.body)).encode("ascii"))], + ), ctx.body + + @app.post("/users/{user_id}/profile") + def profile_update(ctx: HTTPReqCtx, user_id: str): + body = profile_update_body(user_id, ctx.body) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return Response( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))], + ), body + + _ = (ping, hello, echo, profile_update) + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="h11") + return run_app(app.service(cfg, selectors=args.selectors, acceptor=args.acceptor)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_h11_acceptor_s4.py b/benchmarks/macro/http/apps/localpost_h11_acceptor_s4.py new file mode 100644 index 0000000..119bf1b --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_h11_acceptor_s4.py @@ -0,0 +1,17 @@ +"""LocalPost h11 backend, acceptor topology with ``selectors=4``. + +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_h11` that injects +``--selectors 4 --acceptor`` so the runner can pick it up as a separate +stack. One acceptor thread + four worker selectors via the cross-thread +op queue. +""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps.localpost_h11 import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4", "--acceptor"] + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_h11_s4.py b/benchmarks/macro/http/apps/localpost_h11_s4.py new file mode 100644 index 0000000..395002b --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_h11_s4.py @@ -0,0 +1,15 @@ +"""LocalPost h11 backend with ``selectors=4``. + +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_h11` that injects +``--selectors 4`` so the runner can pick it up as a separate stack. +""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps.localpost_h11 import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4"] + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_httptools.py b/benchmarks/macro/http/apps/localpost_httptools.py new file mode 100644 index 0000000..1a1b711 --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_httptools.py @@ -0,0 +1,78 @@ +"""LocalPost — HttpApp framework + httptools (C/llhttp) backend. + +Same behaviour as ``localpost_h11``; only the server backend differs. +""" + +from __future__ import annotations + +import sys +import time + +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost import threadtools +from localpost.hosting import run_app +from localpost.http import HTTPReqCtx, Response, ServerConfig +from localpost.http.app import HttpApp + + +def main() -> int: + args = parse_args() + threadtools.warmup(args.warmup) + app = HttpApp() + + @app.get("/ping") + def ping(): + # Wire-bytes for the tightest plaintext path (skips the str → bytes + # encode and the Content-Type rewrite). + return Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(PING_BODY)).encode("ascii")), + ], + ), PING_BODY + + @app.get("/hello/{name}") + def hello(name: str): + body = hello_body(name) + return Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), body + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return Response( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(ctx.body)).encode("ascii")), + ], + ), ctx.body + + @app.post("/users/{user_id}/profile") + def profile_update(ctx: HTTPReqCtx, user_id: str): + body = profile_update_body(user_id, ctx.body) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return Response( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), body + + # Decorators are no-op-returns of the original; suppress unused warnings. + _ = (ping, hello, echo, profile_update) + + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") + return run_app(app.service(cfg, selectors=args.selectors, acceptor=args.acceptor)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_httptools_acceptor_s4.py b/benchmarks/macro/http/apps/localpost_httptools_acceptor_s4.py new file mode 100644 index 0000000..ec3537c --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_httptools_acceptor_s4.py @@ -0,0 +1,17 @@ +"""LocalPost httptools backend, acceptor topology with ``selectors=4``. + +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_httptools` that injects +``--selectors 4 --acceptor`` so the runner can pick it up as a separate +stack. One acceptor thread + four worker selectors via the cross-thread +op queue. +""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps.localpost_httptools import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4", "--acceptor"] + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_httptools_diag.py b/benchmarks/macro/http/apps/localpost_httptools_diag.py new file mode 100644 index 0000000..49ec419 --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_httptools_diag.py @@ -0,0 +1,128 @@ +"""Diagnostic: inline httptools app that counts requests per selector thread. + +Each connection is owned by exactly one ``BaseServer`` (the one whose +selector thread accepted it), and inline mode runs the handler on the +selector thread. So ``threading.get_ident()`` inside the handler maps +1:1 to "which selector served this request" — letting us inspect whether +``SO_REUSEPORT`` is actually distributing across the N selector threads. + +On shutdown (SIGTERM/SIGINT) prints a histogram to stderr, e.g.:: + + selector tid=6175285248: 12,341 reqs (32.1%) + selector tid=6175821824: 13,012 reqs (33.9%) + selector tid=6176358400: 13,041 reqs (34.0%) + +A flat distribution → REUSEPORT is balancing. A skewed one → it isn't, +and selectors past the first are starved. +""" + +from __future__ import annotations + +import atexit +import signal +import sys +import threading +import time +from collections import Counter + +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost.hosting import run_app, service +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + Response, + Routes, + ServerConfig, + http_server, + route_match, +) + +_per_selector: Counter[int] = Counter() +_lock = threading.Lock() + + +def _record() -> None: + tid = threading.get_ident() + with _lock: + _per_selector[tid] += 1 + + +def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + + +def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: + _record() + _emit(ctx, PING_BODY) + return None + + +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + _record() + _emit(ctx, hello_body(route_match(ctx).path_args["name"])) + return None + + +def _echo_post_body(ctx: HTTPReqCtx) -> None: + _emit(ctx, ctx.body, content_type=b"application/json") + + +def _echo(_: HTTPReqCtx) -> BodyHandler: + _record() + return _echo_post_body + + +def _profile_update_post_body(ctx: HTTPReqCtx) -> None: + body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + _emit(ctx, body, content_type=b"application/json") + + +def _profile_update(_: HTTPReqCtx) -> BodyHandler: + _record() + return _profile_update_post_body + + +def _print_distribution() -> None: + with _lock: + snapshot = dict(_per_selector) + if not snapshot: + return + total = sum(snapshot.values()) + print(f"\n=== selector distribution (total={total:,}) ===", file=sys.stderr) + for tid, n in sorted(snapshot.items(), key=lambda kv: -kv[1]): + pct = 100.0 * n / total if total else 0.0 + print(f" tid={tid}: {n:,} reqs ({pct:.1f}%)", file=sys.stderr) + + +def main() -> int: + args = parse_args() + routes = Routes() + routes.get("/ping")(_ping) + routes.get("/hello/{name}")(_hello) + routes.post("/echo")(_echo) + routes.post("/users/{user_id}/profile")(_profile_update) + handler = routes.build().as_handler() + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") + + atexit.register(_print_distribution) + signal.signal(signal.SIGTERM, lambda *_: (_print_distribution(), sys.exit(0))) + + @service + async def app(): + async with http_server(cfg, handler, selectors=args.selectors): + yield + + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_httptools_inline.py b/benchmarks/macro/http/apps/localpost_httptools_inline.py new file mode 100644 index 0000000..ba800bc --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_httptools_inline.py @@ -0,0 +1,61 @@ +"""LocalPost httptools backend, inline mode (no thread pool). + +Bypasses the worker pool — every request runs on the selector thread. +Used to characterise selector-level throughput in isolation. +""" + +from __future__ import annotations + +import sys +import time + +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost.hosting import run_app +from localpost.http import HTTPReqCtx, Response, ServerConfig +from localpost.http.app import HttpApp + + +def main() -> int: + args = parse_args() + app = HttpApp(pooled=False) # inline: no pool + + @app.get("/ping") + def ping(): + return Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(PING_BODY)).encode("ascii"))], + ), PING_BODY + + @app.get("/hello/{name}") + def hello(name: str): + body = hello_body(name) + return Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), body + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return Response( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(ctx.body)).encode("ascii"))], + ), ctx.body + + @app.post("/users/{user_id}/profile") + def profile_update(ctx: HTTPReqCtx, user_id: str): + body = profile_update_body(user_id, ctx.body) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return Response( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))], + ), body + + _ = (ping, hello, echo, profile_update) + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") + return run_app(app.service(cfg, selectors=args.selectors, acceptor=args.acceptor)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_httptools_inline_acceptor_s4.py b/benchmarks/macro/http/apps/localpost_httptools_inline_acceptor_s4.py new file mode 100644 index 0000000..81c76d6 --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_httptools_inline_acceptor_s4.py @@ -0,0 +1,17 @@ +"""LocalPost httptools backend, inline (no pool), acceptor topology with ``selectors=4``. + +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_httptools_inline` that +injects ``--selectors 4 --acceptor`` so the runner can pick it up as a +separate stack. Characterises the acceptor topology in isolation — +handlers run on the worker selector thread without a pool hop. +""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps.localpost_httptools_inline import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4", "--acceptor"] + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_httptools_inline_s4.py b/benchmarks/macro/http/apps/localpost_httptools_inline_s4.py new file mode 100644 index 0000000..6ff942e --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_httptools_inline_s4.py @@ -0,0 +1,11 @@ +"""LocalPost httptools backend, inline (no pool), ``selectors=4``.""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps.localpost_httptools_inline import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4"] + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_httptools_s4.py b/benchmarks/macro/http/apps/localpost_httptools_s4.py new file mode 100644 index 0000000..d13e579 --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_httptools_s4.py @@ -0,0 +1,15 @@ +"""LocalPost httptools backend with ``selectors=4``. + +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_httptools` that injects +``--selectors 4`` so the runner can pick it up as a separate stack. +""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps.localpost_httptools import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4"] + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/localpost_wsgi.py b/benchmarks/macro/http/apps/localpost_wsgi.py new file mode 100644 index 0000000..3f16017 --- /dev/null +++ b/benchmarks/macro/http/apps/localpost_wsgi.py @@ -0,0 +1,29 @@ +"""LocalPost ``wsgi_server`` hosting the shared Flask app.""" + +from __future__ import annotations + +import sys + +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app +from localpost import threadtools +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler, wrap_wsgi + + +def main() -> int: + port = parse_port() + threadtools.warmup(32) + cfg = ServerConfig(host="127.0.0.1", port=port) + + @service + async def app(): + async with thread_pool_handler(wrap_wsgi(build_app())) as wrapped: + async with http_server(cfg, wrapped): + yield + + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/starlette_granian.py b/benchmarks/macro/http/apps/starlette_granian.py new file mode 100644 index 0000000..28371bd --- /dev/null +++ b/benchmarks/macro/http/apps/starlette_granian.py @@ -0,0 +1,30 @@ +"""Starlette app served by Granian (ASGI mode).""" + +from __future__ import annotations + +import sys + +from granian import Granian +from granian.constants import Interfaces + +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._starlette_app import build_app + +app = build_app() + + +def main() -> int: + port = parse_port() + Granian( + target=f"{__name__}:app", + address="127.0.0.1", + port=port, + interface=Interfaces.ASGI, + workers=1, + log_enabled=False, + ).serve() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/apps/starlette_uvicorn.py b/benchmarks/macro/http/apps/starlette_uvicorn.py new file mode 100644 index 0000000..f1e65d4 --- /dev/null +++ b/benchmarks/macro/http/apps/starlette_uvicorn.py @@ -0,0 +1,26 @@ +"""Starlette app served by Uvicorn.""" + +from __future__ import annotations + +import sys + +import uvicorn + +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._starlette_app import build_app + + +def main() -> int: + port = parse_port() + uvicorn.run( + build_app(), + host="127.0.0.1", + port=port, + log_level="warning", + access_log=False, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/runner.py b/benchmarks/macro/http/runner.py new file mode 100644 index 0000000..2718666 --- /dev/null +++ b/benchmarks/macro/http/runner.py @@ -0,0 +1,47 @@ +"""Macro HTTP benchmark runner — thin entry point. + +For each (python, stack, scenario) cell: + 1. Boot the stack as a subprocess (`` -m benchmarks.macro.http.apps.``). + 2. Poll ``/ping`` until ready (or fail fast). + 3. Run ``oha --json -z s -c ...``. + 4. Parse JSON; record RPS + p50/p90/p99 + status histogram. + 5. SIGTERM the stack, wait, move on. + +Output: + results///results.json — raw cells. + results///RESULTS.md — markdown summary, + one table per scenario. + results///RESULTS.html — interactive view. + +See :mod:`benchmarks.macro._core.cli` for the CLI flag surface. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from benchmarks.macro._core.cli import entrypoint +from benchmarks.macro.http.scenarios import SCENARIOS +from benchmarks.macro.http.stacks import DIM_KEYS, GROUPS, STACKS + +REPO_ROOT = Path(__file__).resolve().parents[2] +RESULTS_DIR = Path(__file__).parent / "results" + + +def main() -> int: + return entrypoint( + scenarios=SCENARIOS, + stacks=STACKS, + groups=GROUPS, + apps_pkg="benchmarks.macro.http.apps", + results_dir=RESULTS_DIR, + title="HTTP benchmark", + dim_keys=DIM_KEYS, + repo_root=REPO_ROOT, + description=__doc__, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/http/scenarios.py b/benchmarks/macro/http/scenarios.py new file mode 100644 index 0000000..bade748 --- /dev/null +++ b/benchmarks/macro/http/scenarios.py @@ -0,0 +1,114 @@ +"""Shared scenario definitions: identical contracts every stack must implement. + +Four scenarios for v1: + +* ``plaintext`` — ``GET /ping`` → ``b"pong"``, text/plain. +* ``path_param`` — ``GET /hello/{name}`` → ``f"hi {name}".encode()``. +* ``json_post`` — ``POST /echo`` with JSON body → echo body verbatim, application/json. +* ``profile_update`` — ``POST /users/{user_id}/profile`` with JSON body + → normalized user profile JSON. + +Every app module under ``benchmarks/macro/http/apps/`` implements these four. The +runner uses ``SCENARIOS`` to know what to fire at each stack and how to verify +the response shape. +""" + +from __future__ import annotations + +import json +from typing import Any, Final + +from benchmarks.macro._core.types import Scenario + +__all__ = [ + "HELLO_PREFIX", + "JSON_ECHO_BODY", + "PING_BODY", + "PROFILE_WORK_DELAYS_S", + "SCENARIOS", + "Scenario", + "build_profile_update", + "hello_body", + "profile_update_body", + "profile_update_payload", +] + + +_JSON_BODY: Final = b'{"name":"world","numbers":[1,2,3,4,5,6,7,8,9,10]}' +_PROFILE_UPDATE_BODY: Final = ( + b'{"display_name":" Alex Example ","email":"ALEX@example.COM","version":7,' + b'"tags":["Python","localpost","Python"," benchmarks "],' + b'"settings":{"theme":"dark","newsletter":true}}' +) + +SCENARIOS: Final[tuple[Scenario, ...]] = ( + Scenario( + name="plaintext", + method="GET", + path="/ping", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="path_param", + method="GET", + path="/hello/world", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="json_post", + method="POST", + path="/echo", + body=_JSON_BODY, + content_type="application/json", + expected_status=200, + concurrency=32, + ), + Scenario( + name="profile_update", + method="POST", + path="/users/42/profile", + body=_PROFILE_UPDATE_BODY, + content_type="application/json", + expected_status=200, + concurrency=32, + ), +) + + +# Wire-format constants every app implementation reuses. +PING_BODY: Final = b"pong" +HELLO_PREFIX: Final = "hi " +JSON_ECHO_BODY: Final = _JSON_BODY +PROFILE_WORK_DELAYS_S: Final = (0.001, 0.002, 0.001) + + +def hello_body(name: str) -> bytes: + return f"{HELLO_PREFIX}{name}".encode() + + +def profile_update_payload(user_id: str, body: bytes) -> dict[str, Any]: + payload = json.loads(body) + return build_profile_update(user_id, payload) + + +def build_profile_update(user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + tags = {tag.strip().lower() for tag in (str(value) for value in payload.get("tags", ())) if tag.strip()} + return { + "user_id": user_id, + "display_name": str(payload.get("display_name", "")).strip(), + "email": str(payload.get("email", "")).strip().lower(), + "version": int(payload.get("version", 0)) + 1, + "tags": sorted(tags), + "settings": payload.get("settings", {}), + } + + +def profile_update_body(user_id: str, body: bytes) -> bytes: + payload = profile_update_payload(user_id, body) + return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() diff --git a/benchmarks/macro/http/stacks.py b/benchmarks/macro/http/stacks.py new file mode 100644 index 0000000..2aaebc4 --- /dev/null +++ b/benchmarks/macro/http/stacks.py @@ -0,0 +1,99 @@ +"""HTTP bench stack registry — typed dimensions over the flat stack list. + +Each stack is one row of the matrix. Rather than a flat tuple of names, +each stack carries explicit ``dims`` — ``app``, ``backend``, ``selectors``, +``pool``, ``acceptor`` — and an optional ``tags`` set. The runner uses +these to: + +* select a subset for a run via ``--filter`` / ``--group`` / ``--stacks``; +* annotate each cell in ``results.json`` so the HTML reporter can pivot. + +The ``name`` field doubles as the module name under +``benchmarks/macro/http/apps/``, so spawning a stack stays a one-liner — no +app-module changes needed. + +See :mod:`benchmarks.macro._core.filters` for the filter language. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Final + +from benchmarks.macro._core.types import Stack + +# Ordered dim keys — drives HTML column ordering and filter dropdown order. +DIM_KEYS: Final[tuple[str, ...]] = ("app", "backend", "selectors", "pool", "acceptor") + + +def _stack( + name: str, + *, + app: str, + backend: str, + selectors: int = 1, + pool: bool = True, + acceptor: bool = False, + tags: frozenset[str] = frozenset(), +) -> Stack: + return Stack( + name=name, + dims={ + "app": app, + "backend": backend, + "selectors": str(selectors), + "pool": "true" if pool else "false", + "acceptor": "true" if acceptor else "false", + }, + tags=tags, + ) + + +STACKS: Final[tuple[Stack, ...]] = ( + _stack("localpost_h11", app="native", backend="lp-h11"), + _stack("localpost_h11_s4", app="native", backend="lp-h11", selectors=4), + _stack("localpost_h11_acceptor_s4", app="native", backend="lp-h11", selectors=4, acceptor=True), + _stack("localpost_httptools", app="native", backend="lp-httptools"), + _stack("localpost_httptools_s4", app="native", backend="lp-httptools", selectors=4), + _stack( + "localpost_httptools_acceptor_s4", + app="native", + backend="lp-httptools", + selectors=4, + acceptor=True, + ), + _stack("localpost_httptools_inline", app="native", backend="lp-httptools", pool=False), + _stack( + "localpost_httptools_inline_s4", + app="native", + backend="lp-httptools", + selectors=4, + pool=False, + ), + _stack( + "localpost_httptools_inline_acceptor_s4", + app="native", + backend="lp-httptools", + selectors=4, + pool=False, + acceptor=True, + ), + _stack("localpost_wsgi", app="wsgi", backend="lp-h11"), + _stack("localpost_flask", app="flask", backend="lp-h11"), + _stack("flask_cheroot", app="flask", backend="cheroot"), + _stack("flask_gunicorn", app="flask", backend="gunicorn"), + _stack("flask_granian", app="flask", backend="granian"), + _stack("starlette_uvicorn", app="starlette", backend="uvicorn", tags=frozenset({"reference"})), + _stack("starlette_granian", app="starlette", backend="granian", tags=frozenset({"reference"})), +) + + +GROUPS: Final[dict[str, Callable[[Stack], bool]]] = { + # Smallest sensible matrix — one representative per backend family. + "quick": lambda s: s.name in {"localpost_httptools", "flask_granian", "flask_cheroot", "starlette_uvicorn"}, + "localpost": lambda s: s.dims["backend"].startswith("lp-"), + "flask": lambda s: s.dims["app"] == "flask", + "reference": lambda s: "reference" in s.tags, + "no-reference": lambda s: "reference" not in s.tags, + "single-sel": lambda s: s.dims["selectors"] == "1", +} diff --git a/benchmarks/macro/openapi/README.md b/benchmarks/macro/openapi/README.md new file mode 100644 index 0000000..c420180 --- /dev/null +++ b/benchmarks/macro/openapi/README.md @@ -0,0 +1,114 @@ +# LocalPost OpenAPI framework benchmark + +Compares `localpost.openapi` against peer typed/OpenAPI Python web +frameworks under one fixed workload, single-process, single-host. + +This is the **framework** bench. The sibling +[`benchmarks/macro/http/`](../http/README.md) measures **server** overhead +with a thin Flask/Starlette app on top — different question, different +suite. + +## Quick start + +```bash +brew install oha # one-time prereq +just bench-deps # provision .venv-bench// for every interpreter +just bench-openapi +``` + +```bash +# Faster sanity sweep +just bench-openapi --duration 5 + +# Restrict to one stack +just bench-openapi --stacks fastapi + +# Restrict to one scenario +just bench-openapi --scenarios body_roundtrip + +# Filter by dim +just bench-openapi --filter framework=localpost +just bench-openapi --filter 'framework=fastapi,localpost' --filter schema=pydantic +``` + +Output lands in `benchmarks/macro/openapi/results///`: + +- `results.json` — raw cells (re-parseable). +- `RESULTS.md` — markdown summary, one table per scenario. +- `RESULTS.html` — interactive view with sortable tables and dim filters. + +## What's compared + +Five stacks — the three `localpost.openapi` flavours plus one peer framework each: + +| Stack ID | Framework | Server | Schema | +|--------------------------------|-----------------------|------------------------------|----------| +| `localpost_openapi` | `localpost.openapi` | `localpost.http` (h11), sync | msgspec | +| `localpost_openapi_async` | `localpost.openapi` | uvicorn (1 worker), async | msgspec | +| `localpost_openapi_granian` | `localpost.openapi` | granian (1 worker, RSGI), async | msgspec | +| `flask_openapi` | `flask-openapi` (v5) | gunicorn sync (32 threads) | pydantic | +| `fastapi` | FastAPI | uvicorn (1 worker) | pydantic | + +Single-process by design — we measure framework-layer overhead, not the +multiplicative effect of more workers. All servers configured to be +roughly comparable (`max_concurrency=32` for LocalPost, +`--threads 32` for Gunicorn, etc.). + +The three LocalPost stacks share `framework=localpost`; the `server` +dim discriminates them in result tables (`lp-h11` / `uvicorn` / +`granian`). What each pair anchors: + +- **async vs sync** (`localpost_openapi_async` vs `localpost_openapi`) + — what dropping the event loop costs or saves. +- **RSGI vs ASGI** (`localpost_openapi_granian` vs + `localpost_openapi_async`) — same handler, same uvicorn-class server + on both sides; the only delta is the wire bridge (single eager + `response_bytes` on RSGI vs ASGI's two-event start+body). +- **localpost vs FastAPI** (`localpost_openapi_granian` vs `fastapi`) + — same Granian-class deployment story, different framework. + +`flask-openapi3` was renamed to `flask-openapi` for the v5 line; the +`bench-openapi` dependency group pins `flask-openapi >=5.0.0rc1`. + +## Scenarios + +Defined in [`scenarios.py`](scenarios.py). Each app implements identical +wire contracts: + +| Scenario | Method · Path | Expected | What it exercises | +|----------------------|--------------------------------------------|---------:|---------------------------------------------------| +| `plaintext` | `GET /ping` | 200 | Pure dispatch — calibration anchor. | +| `path_param_typed` | `GET /items/42` (`item_id: int`) | 200 | Path coercion. | +| `query_validation` | `GET /search?q=hello&limit=10&offset=0` | 200 | Query parse + type coercion. | +| `body_roundtrip` | `POST /users/42/profile` JSON in/out | 200 | Schema-driven decode → normalize → encode (headline). | +| `validation_failure` | `POST /users/42/profile` (missing fields) | 422 | Error-path throughput. | + +Each app defines its own model classes idiomatically — dataclasses for +LocalPost, Pydantic v2 for FastAPI and flask-openapi. Same fields, same +types; we're comparing framework overhead on equivalent work, not +business logic. + +For `validation_failure`: LocalPost's body resolver returns +`BadRequest` (400) by default, while FastAPI and flask-openapi v5 +return 422. To keep the comparison apples-to-apples, all three +LocalPost bench apps attach a small middleware that remaps +400 → 422 on the profile endpoint. The framework default is unchanged. + +## Adding a new stack / scenario + +- **Stack:** drop a `python -m`-runnable module under `apps/` that + takes `--port`, binds 127.0.0.1, and serves the routes from + `scenarios.py`. Add a `Stack(...)` to `STACKS` in `stacks.py`. +- **Scenario:** add a `Scenario(...)` to `SCENARIOS` in `scenarios.py`, + then implement the route in every app. + +## Caveats + +- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel + scheduling, other processes. Re-run if a cell looks anomalous. The + relative ordering on the *same* run is what matters; absolute RPS + will shift run-to-run. +- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP + throughput regression gates. Run macro benchmarks locally. +- **Single process by design.** Real deployments multiply by N + workers; the relative ordering still holds. diff --git a/benchmarks/macro/openapi/__init__.py b/benchmarks/macro/openapi/__init__.py new file mode 100644 index 0000000..4380ca6 --- /dev/null +++ b/benchmarks/macro/openapi/__init__.py @@ -0,0 +1,7 @@ +"""OpenAPI framework macro benchmark suite. + +Compares ``localpost.openapi`` against peer typed/OpenAPI Python web +frameworks (flask-openapi3, FastAPI). Sibling of ``benchmarks/macro/http/``, +which measures HTTP server overhead. This suite measures *framework* +overhead — typed signatures, schema validation, response serialization. +""" diff --git a/examples/consumers/sqs/__init__.py b/benchmarks/macro/openapi/apps/__init__.py similarity index 100% rename from examples/consumers/sqs/__init__.py rename to benchmarks/macro/openapi/apps/__init__.py diff --git a/benchmarks/macro/openapi/apps/_cli.py b/benchmarks/macro/openapi/apps/_cli.py new file mode 100644 index 0000000..b330221 --- /dev/null +++ b/benchmarks/macro/openapi/apps/_cli.py @@ -0,0 +1,14 @@ +"""Tiny shared CLI helper for openapi-suite app modules. + +Each app reads ``--port`` (default 8000) and binds 127.0.0.1. +""" + +from __future__ import annotations + +import argparse + + +def parse_port(default: int = 8000) -> int: + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=default) + return p.parse_args().port diff --git a/benchmarks/macro/openapi/apps/fastapi.py b/benchmarks/macro/openapi/apps/fastapi.py new file mode 100644 index 0000000..7c4a82b --- /dev/null +++ b/benchmarks/macro/openapi/apps/fastapi.py @@ -0,0 +1,93 @@ +"""FastAPI served by Uvicorn (1 worker). + +Idiomatic FastAPI: Pydantic v2 models + ``response_model=...`` so the +typed response goes through Pydantic serialization. Validation failures +return 422 by default — what the bench's ``validation_failure`` +scenario expects. +""" + +from __future__ import annotations + +import sys +from typing import Any + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel + +from benchmarks.macro.openapi.apps._cli import parse_port + + +class Item(BaseModel): + id: int + + +class SearchResult(BaseModel): + q: str + limit: int + offset: int + + +class ProfileUpdate(BaseModel): + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +class Profile(BaseModel): + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +def build_app() -> FastAPI: + app = FastAPI(docs_url=None, redoc_url=None) + + @app.get("/ping", response_class=PlainTextResponse) + def ping() -> str: + return "pong" + + @app.get("/items/{item_id}", response_model=Item) + def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.get("/search", response_model=SearchResult) + def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + + @app.post("/users/{user_id}/profile", response_model=Profile) + def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, search, update_profile) + return app + + +def main() -> int: + port = parse_port() + uvicorn.run( + build_app(), + host="127.0.0.1", + port=port, + log_level="warning", + access_log=False, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/openapi/apps/flask_openapi.py b/benchmarks/macro/openapi/apps/flask_openapi.py new file mode 100644 index 0000000..81bd9d5 --- /dev/null +++ b/benchmarks/macro/openapi/apps/flask_openapi.py @@ -0,0 +1,125 @@ +"""flask-openapi (v5) served by Gunicorn (1 worker, gthread, 32 threads). + +Idiomatic flask-openapi v5: declare per-source Pydantic models for path, +query, body — handler signatures take them as named parameters +(``path``, ``query``, ``body``). Validation failures return 422 by +default (configured via ``validation_error_status``). +""" + +from __future__ import annotations + +import sys +from typing import Any + +from flask import Response, jsonify +from flask_openapi import OpenAPI +from gunicorn.app.base import BaseApplication +from pydantic import BaseModel + +from benchmarks.macro.openapi.apps._cli import parse_port + + +class ItemPath(BaseModel): + item_id: int + + +class ProfilePath(BaseModel): + user_id: str + + +class SearchQuery(BaseModel): + q: str + limit: int = 20 + offset: int = 0 + + +class Item(BaseModel): + id: int + + +class SearchResult(BaseModel): + q: str + limit: int + offset: int + + +class ProfileUpdate(BaseModel): + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +class Profile(BaseModel): + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +def build_app() -> OpenAPI: + app = OpenAPI(__name__) + + @app.get("/ping") + def ping() -> Response: + return Response(b"pong", mimetype="text/plain") + + @app.get("/items/") + def get_item(path: ItemPath) -> Response: + return jsonify(Item(id=path.item_id).model_dump()) + + @app.get("/search") + def search(query: SearchQuery) -> Response: + return jsonify(SearchResult(q=query.q, limit=query.limit, offset=query.offset).model_dump()) + + @app.post("/users//profile") + def update_profile(path: ProfilePath, body: ProfileUpdate) -> Response: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + result = Profile( + user_id=path.user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + return jsonify(result.model_dump()) + + _ = (ping, get_item, search, update_profile) + return app + + +class _GunicornApp(BaseApplication): + def __init__(self, app, options: dict): + self._app = app + self._options = options + super().__init__() + + def load_config(self) -> None: + for k, v in self._options.items(): + self.cfg.set(k, v) + + def load(self): + return self._app + + +def main() -> int: + port = parse_port() + options = { + "bind": f"127.0.0.1:{port}", + "workers": 1, + "threads": 32, + "worker_class": "gthread", + "accesslog": None, + "errorlog": "-", + "loglevel": "warning", + } + _GunicornApp(build_app(), options).run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/openapi/apps/localpost_openapi.py b/benchmarks/macro/openapi/apps/localpost_openapi.py new file mode 100644 index 0000000..8d7d68f --- /dev/null +++ b/benchmarks/macro/openapi/apps/localpost_openapi.py @@ -0,0 +1,115 @@ +"""LocalPost — ``localpost.openapi.HttpApp`` on ``localpost.http`` (h11). + +Idiomatic LocalPost: dataclass models + return-type annotations. Body +inputs auto-resolve via ``FromBody`` because the parameter type is a +dataclass. Path/query ``int`` coercion happens in the resolvers' +``_cast_str``. + +LocalPost's body resolver returns ``BadRequest`` (400) on schema +failure; FastAPI and flask-openapi return 422. To keep the bench's +``validation_failure`` scenario apples-to-apples we attach a tiny +middleware that remaps 400 → 422 on the profile endpoint. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any + +from benchmarks.macro.openapi.apps._cli import parse_port +from localpost import hosting, threadtools +from localpost.http import HTTPReqCtx, ServerConfig +from localpost.openapi import ( + ApiOperation, + BadRequest, + HttpApp, + OpResult, + UnprocessableEntity, + op_middleware, +) + + +@dataclass +class Item: + id: int + + +@dataclass +class SearchResult: + q: str + limit: int + offset: int + + +@dataclass +class ProfileUpdate: + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@dataclass +class Profile: + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@op_middleware +def remap_validation_status( + ctx: HTTPReqCtx, + call_next: ApiOperation, +) -> UnprocessableEntity[str] | OpResult: + result = call_next(ctx) + if isinstance(result, BadRequest): + body = result.body if isinstance(result.body, str) else str(result.body) + return UnprocessableEntity(body) + return result + + +def build_app() -> HttpApp: + app = HttpApp() + + @app.get("/ping") + def ping() -> str: + return "pong" + + @app.get("/items/{item_id}") + def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.get("/search") + def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + + @app.post("/users/{user_id}/profile", middlewares=[remap_validation_status]) + def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, search, update_profile) + return app + + +def main() -> int: + threadtools.warmup(32) + app = build_app() + port = parse_port() + return hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=port, backend="h11"))) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/openapi/apps/localpost_openapi_async.py b/benchmarks/macro/openapi/apps/localpost_openapi_async.py new file mode 100644 index 0000000..b93de07 --- /dev/null +++ b/benchmarks/macro/openapi/apps/localpost_openapi_async.py @@ -0,0 +1,122 @@ +"""LocalPost — ``localpost.openapi.HttpAsyncApp`` on Uvicorn. + +Async sibling of ``localpost_openapi.py`` — same dataclass models, same +routes, async handlers, deployed via ``app.asgi()`` under uvicorn (1 +worker). Body inputs auto-resolve via ``FromBody`` because the parameter +type is a dataclass; path/query coercion happens in the resolvers. + +Like the sync flavour, LocalPost's body resolver returns ``BadRequest`` +(400) on schema failure; FastAPI and flask-openapi return 422. The +``remap_validation_status`` middleware below remaps 400 → 422 on the +profile endpoint to keep the bench's ``validation_failure`` scenario +apples-to-apples. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any + +import uvicorn + +from benchmarks.macro.openapi.apps._cli import parse_port +from localpost.openapi import ( + AsyncApiOperation, + AsyncHTTPReqCtx, + BadRequest, + HttpAsyncApp, + OpResult, + UnprocessableEntity, + async_op_middleware, +) + + +@dataclass +class Item: + id: int + + +@dataclass +class SearchResult: + q: str + limit: int + offset: int + + +@dataclass +class ProfileUpdate: + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@dataclass +class Profile: + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@async_op_middleware +async def remap_validation_status( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, +) -> UnprocessableEntity[str] | OpResult: + result = await call_next(ctx) + if isinstance(result, BadRequest): + body = result.body if isinstance(result.body, str) else str(result.body) + return UnprocessableEntity(body) + return result + + +def build_app() -> HttpAsyncApp: + app = HttpAsyncApp() + + @app.get("/ping") + async def ping() -> str: + return "pong" + + @app.get("/items/{item_id}") + async def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.get("/search") + async def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + + @app.post("/users/{user_id}/profile", middlewares=[remap_validation_status]) + async def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, search, update_profile) + return app + + +def main() -> int: + port = parse_port() + uvicorn.run( + build_app().asgi(), + host="127.0.0.1", + port=port, + log_level="warning", + access_log=False, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/openapi/apps/localpost_openapi_granian.py b/benchmarks/macro/openapi/apps/localpost_openapi_granian.py new file mode 100644 index 0000000..60d1120 --- /dev/null +++ b/benchmarks/macro/openapi/apps/localpost_openapi_granian.py @@ -0,0 +1,132 @@ +"""LocalPost — ``localpost.openapi.HttpAsyncApp`` on Granian (RSGI). + +Granian-flavoured sibling of ``localpost_openapi_async.py`` — same +dataclass models, same routes, same async handlers, but deployed via +``app.as_rsgi()`` under Granian's native RSGI interface (1 worker for +fair comparison with the FastAPI / uvicorn stack). + +The RSGI bridge is the wire-format-only layer in +``localpost.http.rsgi``: single eager ``proto.response_bytes`` per +response (vs ASGI's two-event start+body), so the same handler chain +can be a touch faster than its ASGI sibling. This bench app exists to +quantify that on the macro-bench scenarios. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any + +from granian.constants import Interfaces +from granian.server import Server + +from benchmarks.macro.openapi.apps._cli import parse_port +from localpost.openapi import ( + AsyncApiOperation, + AsyncHTTPReqCtx, + BadRequest, + HttpAsyncApp, + OpResult, + UnprocessableEntity, + async_op_middleware, +) + + +@dataclass +class Item: + id: int + + +@dataclass +class SearchResult: + q: str + limit: int + offset: int + + +@dataclass +class ProfileUpdate: + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@dataclass +class Profile: + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@async_op_middleware +async def remap_validation_status( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, +) -> UnprocessableEntity[str] | OpResult: + result = await call_next(ctx) + if isinstance(result, BadRequest): + body = result.body if isinstance(result.body, str) else str(result.body) + return UnprocessableEntity(body) + return result + + +def build_app() -> HttpAsyncApp: + app = HttpAsyncApp() + + @app.get("/ping") + async def ping() -> str: + return "pong" + + @app.get("/items/{item_id}") + async def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.get("/search") + async def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + + @app.post("/users/{user_id}/profile", middlewares=[remap_validation_status]) + async def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, search, update_profile) + return app + + +# Module-level RSGI app. Granian's production ``Server`` takes the +# target as a ``module:attribute`` string and imports it inside each +# worker; ``rsgi_app`` is what it pulls. +rsgi_app = build_app().as_rsgi() + + +def main() -> int: + port = parse_port() + server = Server( + target=f"{__name__}:rsgi_app", + address="127.0.0.1", + port=port, + interface=Interfaces.RSGI, + workers=1, + log_enabled=False, + log_access=False, + ) + server.serve() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/openapi/runner.py b/benchmarks/macro/openapi/runner.py new file mode 100644 index 0000000..0ce09ef --- /dev/null +++ b/benchmarks/macro/openapi/runner.py @@ -0,0 +1,38 @@ +"""Macro OpenAPI-framework benchmark runner — thin entry point. + +Drives the same oha pipeline as ``benchmarks/macro/http/runner.py`` but against +typed/OpenAPI frameworks (LocalPost, flask-openapi3, FastAPI) instead of +bare HTTP servers. + +See :mod:`benchmarks.macro._core.cli` for the CLI flag surface. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from benchmarks.macro._core.cli import entrypoint +from benchmarks.macro.openapi.scenarios import SCENARIOS +from benchmarks.macro.openapi.stacks import DIM_KEYS, GROUPS, STACKS + +REPO_ROOT = Path(__file__).resolve().parents[2] +RESULTS_DIR = Path(__file__).parent / "results" + + +def main() -> int: + return entrypoint( + scenarios=SCENARIOS, + stacks=STACKS, + groups=GROUPS, + apps_pkg="benchmarks.macro.openapi.apps", + results_dir=RESULTS_DIR, + title="OpenAPI framework benchmark", + dim_keys=DIM_KEYS, + repo_root=REPO_ROOT, + description=__doc__, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/macro/openapi/scenarios.py b/benchmarks/macro/openapi/scenarios.py new file mode 100644 index 0000000..dcbc2ba --- /dev/null +++ b/benchmarks/macro/openapi/scenarios.py @@ -0,0 +1,91 @@ +"""Shared scenario definitions for the OpenAPI framework bench. + +Each scenario defines one wire contract every stack must implement — +identical request shape, identical response shape — so the comparison +measures the framework's typed-handler overhead, not differences in +business logic. + +The body for ``body_roundtrip`` mirrors +``benchmarks/macro/http/scenarios.py::_PROFILE_UPDATE_BODY`` so the http and +openapi suites can be cross-referenced. +""" + +from __future__ import annotations + +from typing import Final + +from benchmarks.macro._core.types import Scenario + +__all__ = [ + "INVALID_PROFILE_BODY", + "PING_BODY", + "PROFILE_UPDATE_BODY", + "SCENARIOS", + "Scenario", +] + + +PING_BODY: Final = b"pong" + +# Same payload as ``benchmarks/macro/http/scenarios.py``: each app must accept +# untrimmed strings, mixed-case email, duplicated/whitespaced tags, and +# return a normalized profile (trimmed, lower-cased, deduped, sorted, +# version+1). +PROFILE_UPDATE_BODY: Final = ( + b'{"display_name":" Alex Example ","email":"ALEX@example.COM","version":7,' + b'"tags":["Python","localpost","Python"," benchmarks "],' + b'"settings":{"theme":"dark","newsletter":true}}' +) + +# Missing required fields — every framework rejects with a 422 (LocalPost's +# default 400 is remapped to 422 by a small middleware in the bench app). +INVALID_PROFILE_BODY: Final = b'{"display_name":"only this field"}' + + +SCENARIOS: Final[tuple[Scenario, ...]] = ( + Scenario( + name="plaintext", + method="GET", + path="/ping", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="path_param_typed", + method="GET", + path="/items/42", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="query_validation", + method="GET", + path="/search?q=hello&limit=10&offset=0", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="body_roundtrip", + method="POST", + path="/users/42/profile", + body=PROFILE_UPDATE_BODY, + content_type="application/json", + expected_status=200, + concurrency=32, + ), + Scenario( + name="validation_failure", + method="POST", + path="/users/42/profile", + body=INVALID_PROFILE_BODY, + content_type="application/json", + expected_status=422, + concurrency=32, + ), +) diff --git a/benchmarks/macro/openapi/stacks.py b/benchmarks/macro/openapi/stacks.py new file mode 100644 index 0000000..4fe7cf5 --- /dev/null +++ b/benchmarks/macro/openapi/stacks.py @@ -0,0 +1,50 @@ +"""OpenAPI bench stack registry. + +Five stacks for v1 — the three LocalPost flavours plus one per peer +framework. Single-process by design so we measure framework overhead, +not worker multiplexing. + +* ``localpost_openapi`` — ``HttpApp`` on ``localpost.http`` (h11), sync handlers. +* ``localpost_openapi_async`` — ``HttpAsyncApp`` on Uvicorn (1 worker), async handlers. +* ``localpost_openapi_granian`` — ``HttpAsyncApp`` on Granian (1 worker, RSGI), async handlers. +* ``flask_openapi`` — ``flask-openapi3`` on Gunicorn (1 worker, 32 threads). +* ``fastapi`` — FastAPI on Uvicorn (1 worker). + +Dim keys: ``framework``, ``server``, ``schema`` (the validation library +each framework drives — ``msgspec`` for LocalPost, ``pydantic`` for the +other two). The three LocalPost stacks share ``framework=localpost`` +so the ``localpost`` group filter pulls all of them; ``server`` +discriminates them in result tables (``lp-h11`` / ``uvicorn`` / +``granian``). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Final + +from benchmarks.macro._core.types import Stack + +DIM_KEYS: Final[tuple[str, ...]] = ("framework", "server", "schema") + + +def _stack(name: str, *, framework: str, server: str, schema: str) -> Stack: + return Stack( + name=name, + dims={"framework": framework, "server": server, "schema": schema}, + ) + + +STACKS: Final[tuple[Stack, ...]] = ( + _stack("localpost_openapi", framework="localpost", server="lp-h11", schema="msgspec"), + _stack("localpost_openapi_async", framework="localpost", server="uvicorn", schema="msgspec"), + _stack("localpost_openapi_granian", framework="localpost", server="granian", schema="msgspec"), + _stack("flask_openapi", framework="flask-openapi3", server="gunicorn", schema="pydantic"), + _stack("fastapi", framework="fastapi", server="uvicorn", schema="pydantic"), +) + + +GROUPS: Final[dict[str, Callable[[Stack], bool]]] = { + "localpost": lambda s: s.dims["framework"] == "localpost", + "peers": lambda s: s.dims["framework"] != "localpost", +} diff --git a/localpost/consumers/__init__.py b/benchmarks/micro/__init__.py similarity index 100% rename from localpost/consumers/__init__.py rename to benchmarks/micro/__init__.py diff --git a/benchmarks/micro/bench_router.py b/benchmarks/micro/bench_router.py new file mode 100644 index 0000000..a0621e4 --- /dev/null +++ b/benchmarks/micro/bench_router.py @@ -0,0 +1,87 @@ +"""Micro-benchmarks for ``Router`` build + dispatch. + +Run:: + + just bench-micro + # or + pytest benchmarks/micro/bench_router.py --benchmark-only + +Build cost is measured separately from dispatch. Dispatch is exercised via +the bare ``Router._match`` — the dispatch heart, with no I/O, ctx +construction, or response writing. +""" + +from __future__ import annotations + +from localpost.http import HTTPReqCtx, Response, Routes + + +def _ok(ctx: HTTPReqCtx): + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + +def _build_routes_20() -> Routes: + """A mixed set of 20 routes — 10 literal, 10 parameterised.""" + routes = Routes() + for path in ( + "/", + "/health", + "/ready", + "/metrics", + "/version", + "/login", + "/logout", + "/me", + "/admin", + "/admin/dashboard", + ): + routes.get(path)(_ok) + for path in ( + "/books/{id}", + "/users/{id}", + "/users/{id}/posts", + "/users/{id}/posts/{post}", + "/orgs/{org}/users/{user}", + "/orgs/{org}/users/{user}/posts/{post}", + "/files/{bucket}/{key}", + "/v1/items/{id}", + "/v1/items/{id}/comments", + "/v1/items/{id}/comments/{cid}", + ): + routes.get(path)(_ok) + return routes + + +def bench_routes_build_20(benchmark) -> None: + routes = _build_routes_20() + benchmark(routes.build) + + +def bench_dispatch_literal_first(benchmark) -> None: + """Match a literal route — should be the cheapest path.""" + router = _build_routes_20().build() + benchmark(router._match, "/health", "GET") + + +def bench_dispatch_param_shallow(benchmark) -> None: + """Match ``/books/{id}`` — one variable.""" + router = _build_routes_20().build() + benchmark(router._match, "/books/00a7a2d4-18e4-11f1-899b-d33838f3bef0", "GET") + + +def bench_dispatch_param_deep(benchmark) -> None: + """Match ``/orgs/{org}/users/{user}/posts/{post}`` — three variables, longest path.""" + router = _build_routes_20().build() + benchmark(router._match, "/orgs/anthropic/users/alex/posts/42", "GET") + + +def bench_dispatch_404(benchmark) -> None: + """Path matches no route.""" + router = _build_routes_20().build() + benchmark(router._match, "/nope/nothing/here", "GET") + + +def bench_dispatch_405(benchmark) -> None: + """Path matches but method doesn't — exercises the method-not-allowed branch.""" + router = _build_routes_20().build() + benchmark(router._match, "/health", "DELETE") diff --git a/benchmarks/micro/bench_uri_template.py b/benchmarks/micro/bench_uri_template.py new file mode 100644 index 0000000..d7d8b76 --- /dev/null +++ b/benchmarks/micro/bench_uri_template.py @@ -0,0 +1,35 @@ +"""Micro-benchmarks for ``URITemplate``. + +Run:: + + just bench-micro + # or + pytest benchmarks/micro/bench_uri_template.py --benchmark-only +""" + +from __future__ import annotations + +from localpost.http.router import URITemplate + + +def bench_parse_simple(benchmark) -> None: + benchmark(URITemplate.parse, "/books/{book_id}") + + +def bench_parse_three_vars(benchmark) -> None: + benchmark(URITemplate.parse, "/orgs/{org}/users/{user}/posts/{post}") + + +def bench_match_hit_one_var(benchmark) -> None: + t = URITemplate.parse("/books/{book_id}") + benchmark(t.match, "/books/00a7a2d4-18e4-11f1-899b-d33838f3bef0") + + +def bench_match_hit_three_vars(benchmark) -> None: + t = URITemplate.parse("/orgs/{org}/users/{user}/posts/{post}") + benchmark(t.match, "/orgs/anthropic/users/alex/posts/42") + + +def bench_match_miss(benchmark) -> None: + t = URITemplate.parse("/books/{book_id}") + benchmark(t.match, "/users/alex/posts/42") diff --git a/benchmarks/micro/conftest.py b/benchmarks/micro/conftest.py new file mode 100644 index 0000000..85e67ac --- /dev/null +++ b/benchmarks/micro/conftest.py @@ -0,0 +1 @@ +"""pytest-benchmark autodiscovery — no shared fixtures yet.""" diff --git a/docs/adr/0001-anyio-as-async-runtime.md b/docs/adr/0001-anyio-as-async-runtime.md new file mode 100644 index 0000000..357a313 --- /dev/null +++ b/docs/adr/0001-anyio-as-async-runtime.md @@ -0,0 +1,59 @@ +# 0001 — AnyIO as async runtime + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +The framework needs an async runtime for the hosting layer, scheduler, +async HTTP handlers, and the ASGI/RSGI bridges. Two practical choices in +the Python ecosystem: + +- **Raw asyncio** — broadest compatibility, biggest ecosystem of + libraries, the Python "default." +- **AnyIO** — a thin abstraction over asyncio *and* Trio. Provides + structured concurrency primitives (`TaskGroup`, cancel scopes, + `to_thread`) with the same API on both backends. + +Forces in play: + +- We want **structured concurrency** as the default — task groups and + cancel scopes, not bare `asyncio.create_task` with manual lifetime + bookkeeping. +- We want users to be able to run on **Trio** if they prefer it. + `asyncio` is fine for many workloads, but Trio's strict structured + concurrency catches a class of bugs (orphaned tasks, surprising + cancellation propagation) that asyncio is more permissive about. +- The framework's surface is small and we control all internal async + code. We don't need to consume large async libraries that are + asyncio-specific. +- We pay a thin abstraction cost (one extra import layer) but gain + portability between two backends with one codebase. + +## Decision + +All async code under `localpost/` uses **AnyIO**, not raw `asyncio`. +The code runs on either asyncio or Trio depending on what +`anyio.run` is given (the hosting layer picks via +`choose_anyio_backend`, defaulting to asyncio for compatibility). + +We do not vendor an asyncio-only fast path. If a feature can't be +expressed in AnyIO, we either contribute upstream or design around it. + +## Consequences + +- Public API surfaces AnyIO types where backend-specific types would + otherwise leak (`anyio.abc.TaskGroup`, `anyio.Event`). +- Tests use `anyio_mode = "auto"` in `pyproject.toml`, which runs every + async test under both asyncio and Trio. Catches portability bugs at + CI time. +- Documentation phrases everything in AnyIO terms ("structured + concurrency", "task group", "cancel scope") rather than asyncio-isms + ("event loop", "Task", "shield"). +- Some asyncio-only third-party libraries (uvicorn, hypercorn, + grpc.aio) are still consumed via adapters in + `localpost/hosting/services/` — those adapters explicitly pin the + backend to asyncio. +- We don't get the "use any random asyncio library" ergonomics. In + practice this rarely bites, because the framework's job is to host + user code, not to consume async libraries itself. diff --git a/docs/adr/0002-h11-httptools-coexist.md b/docs/adr/0002-h11-httptools-coexist.md new file mode 100644 index 0000000..b4827fc --- /dev/null +++ b/docs/adr/0002-h11-httptools-coexist.md @@ -0,0 +1,73 @@ +# 0002 — h11 and httptools coexist as parallel HTTP backends + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +The native HTTP server in `localpost.http` needs an HTTP/1.1 parser. +Two well-maintained choices in the Python ecosystem: + +- **h11** — pure Python, sans-IO, pull-based events + (`receive_data` → iterate events). Parses *and* serialises responses. + Easy to read; portable to free-threaded builds. +- **httptools** — a thin Cython wrapper over node.js's `llhttp`. + Push-based callbacks (`on_url`, `on_header`, `on_body`, + `on_message_complete`). Parse-only — the user serialises responses + themselves. Faster header parsing. + +We initially shipped only h11 (pure Python is friendlier to debug and +to free-threaded CPython). Profiling under realistic JSON-API +workloads showed parser overhead as a meaningful slice of the hot +path; httptools cuts it materially. + +Two viable shapes for adding httptools: + +1. **Unify behind a parser Protocol** — define an abstract parser + interface, implement it on top of both h11 and httptools, switch + via config. +2. **Two parallel backends** — both parsers live as full + implementations, sharing only what already factors cleanly + (selector loop, op queue, stale-conn sweep, shutdown + coordination). + +Forces: + +- The two parsers don't fit one shape. h11 is pull-events + parse+serialise; httptools is push-callbacks parse-only. Forcing + one Protocol over both either hides h11's serialiser (we'd write + our own — duplicating h11's work) or papers over httptools's + callback model (we'd buffer events into a fake pull queue — + defeating the perf gain). +- The shared concerns (selector, accept policy, shutdown) genuinely + factor — they're already in `_base.py`. The non-shared concerns + (parser drive loop, response framing) genuinely don't. +- We don't need to swap parsers at runtime. The choice is per-server + via `ServerConfig.backend`. + +## Decision + +Two parallel backends. They share `_base.py` (selector, op queue, +listening socket, stale-conn sweep, shutdown). They each have their +own connection class, parser drive loop, and response writer. +`ServerConfig.backend` selects between them at server construction. + +There is **no** abstract parser Protocol mediating between the two. +Code that wants to compose with both backends does so by depending +only on the neutral `Request` / `NativeResponse` types in +`localpost.http`, which both backends populate. + +## Consequences + +- h11 stays the default — pure Python, no extra deps. Users opt in + to httptools via the `[http-fast]` extra. +- The httptools backend has initial-scope limitations (`Content-Length` + response bodies only, HTTP Upgrade returns 400) — documented in + [docs/design/server-backends.md](../design/server-backends.md). + These are follow-ups, not architectural constraints. +- Adding a third backend (e.g. picohttpparser) means writing another + full backend, not implementing a Protocol. That's the cost we + accept for not paying the abstraction tax on the two we have. +- Hot-path code paths are duplicated across the two backends. The + duplication is finite (request reading, response writing) and the + shared base prevents drift in everything else. diff --git a/docs/adr/0003-sync-native-http-server.md b/docs/adr/0003-sync-native-http-server.md new file mode 100644 index 0000000..b8b2766 --- /dev/null +++ b/docs/adr/0003-sync-native-http-server.md @@ -0,0 +1,71 @@ +# 0003 — Native HTTP server stays sync-only + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +`localpost.http.RequestHandler` is `Callable[[HTTPReqCtx], None]` — +synchronous. The selector accepts connections, parses HTTP/1.1, and +either dispatches the request inline on the selector thread (e.g. for +a 404 from `Router`) or hands it off to a worker thread (when the user +composes `thread_pool_handler` into the chain). + +Recurring pressure to add an async path inside this server: + +- "What if I want async DB calls in my handler?" +- "Should `RequestHandler` accept either sync or async functions?" +- "Could we run the selector inside an event loop?" + +Forces: + +- Async HTTP servers in Python are a solved problem. uvicorn, + hypercorn, and granian are mature, well-tested, and out-perform + anything we'd build. Async users have first-class options today. +- We integrate with those servers via + `localpost.http.asgi.to_asgi(handler)`, which adapts an + `AsyncRequestHandler` (`Callable[[AsyncHTTPReqCtx], Awaitable[None]]`) + to ASGI 3. The hosting adapters in `localpost.hosting.services/` + plug them into `run_app` cleanly. +- Adding a parallel async path inside the native server would mean a + second connection state machine, a second parser drive loop, a + second body-reading contract — all maintained alongside the sync + ones. The selector-thread + thread-pool model is small precisely + because there's only one path. +- The sync server's design (blocking sockets, `selectors` poller, + TRACKED/BORROWED state machine) is what makes it small and + free-threaded-ready. An event loop inside it would change all of + that. + +## Decision + +The native server in `localpost.http` is sync-only. `RequestHandler` +will not be widened to accept async callables. There is **no plan** +to add an async path here. + +Async handlers are reached via the ASGI bridge +(`localpost.http.asgi.to_asgi`) plugged into uvicorn / hypercorn / +granian. The handler still expresses the same conceptual contract +(read request, write response) — just an async-flavoured Protocol +(`AsyncHTTPReqCtx`) instead of the sync one. + +Both Protocols share the data side (`request`, `body`, `attrs`, +addrs, `disconnected`) and the terminal write methods (`complete`, +`stream`, `sendfile`, `receive`), so a handler that only touches the +core surface is portable between transports — see +[`docs/design/connection-model.md`](../design/connection-model.md#sync-vs-async-request-context-surface). + +## Consequences + +- Anyone who needs an async HTTP server uses `to_asgi(handler)` + + uvicorn / hypercorn / granian. We point them there explicitly. +- The native server stays small (~540 lines of sync code, plus a + parallel httptools backend — see ADR-0002). Free-threaded CPython + builds are a clean target because there's no event loop to + reason about. +- We don't compete with uvicorn / hypercorn / granian on async perf. + We compete on small surface area, predictable threading, and + composability with the hosting layer. +- The framework's user-facing surface (`HttpApp` / `HttpAsyncApp`) + splits along sync/async like `httpx.Client` / `httpx.AsyncClient`. + Choose flavour per app; don't mix. diff --git a/docs/adr/0004-pull-based-disconnect-detection.md b/docs/adr/0004-pull-based-disconnect-detection.md new file mode 100644 index 0000000..fa5e4fe --- /dev/null +++ b/docs/adr/0004-pull-based-disconnect-detection.md @@ -0,0 +1,80 @@ +# 0004 — Pull-based client-disconnect detection + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +When a worker thread holds a borrowed connection (the BORROWED state +in [`docs/design/connection-model.md`](../design/connection-model.md)), +we still need to detect that the client closed the socket. Two +reasons: + +- Long-running handlers (SSE generators, large computed responses) + should short-circuit when the client is gone, rather than burn CPU + / DB time producing data nobody will read. +- The hosted service's shutdown signal needs to reach in-flight + requests so they can cancel cleanly. + +Originally the server used a **push-based** design. While a worker +held a borrowed connection, the selector kept the socket registered +in a third connection mode — call it "watchdog" — separate from +TRACKED and BORROWED. The selector watched only for read-readiness +on the watchdog socket; when it fired, the selector inferred peer +FIN (no more bytes were expected from the client mid-response) and +posted a disconnect event to the worker. + +Forces in play: + +- The worker and selector both touch the same socket. The watchdog + mode meant a third concurrent reader, which created races: the + worker reading body bytes while the selector polled the same fd + could trigger spurious "disconnected" callbacks, or worse, race + the actual response write. +- The state machine grew a third node and several new edges. Every + transition had to handle "what if a watchdog event fires *during* + this transition" — fiddly, and a place we shipped real bugs. +- We were paying selector-side syscalls and book-keeping for a + signal that's only consulted occasionally inside long handlers. + The workload that benefits most (SSE generators) already loops + per-event; making the check explicit and on-demand fits naturally. + +## Decision + +Disconnect detection moves to a **pull-based** model. While a worker +holds a borrowed connection: + +- `HTTPReqCtx.disconnected` does a non-blocking + `recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. `b""` + means peer FIN; the flag is sticky once `True`. +- `check_cancelled()` raises `RequestCancelled` if the client + disconnected (same `MSG_PEEK` check) or the hosted service is + shutting down. Sync handlers without `ctx` in scope can call it + from anywhere on the request thread. +- Handlers doing regular I/O surface disconnects naturally via + `EPIPE` / `ECONNRESET` from the socket write — no explicit check + needed. + +The selector no longer has a "watchdog" mode. The connection has +two states (TRACKED / BORROWED) plus closed. + +## Consequences + +- The connection state machine collapses from three nodes to two. + Documented as the load-bearing diagram in + [`docs/design/connection-model.md`](../design/connection-model.md). +- One syscall per `check_cancelled()` call, but only when the + handler actually asks. Idle handlers pay nothing. +- The contract is symmetric across sync and async: sync uses + `MSG_PEEK`; async transports flip `ctx.disconnected` on + `http.disconnect`. Same field name, same semantics. +- The WSGI bridge has no socket handle, so it always reports + `disconnected = False` and surfaces disconnects via + `BrokenPipeError` from the host's per-chunk write. Documented as + an asymmetric corner of the surface table in the connection model + doc. +- Handlers that never poll and never write (rare — they'd have to + block on something else, like a queue) won't notice peer-gone. + That's fine: the same handler shape would also miss a service + shutdown signal, and the answer is the same — call + `check_cancelled()` periodically. diff --git a/docs/adr/0005-no-idle-timeout-for-worker-pools.md b/docs/adr/0005-no-idle-timeout-for-worker-pools.md new file mode 100644 index 0000000..e7d832f --- /dev/null +++ b/docs/adr/0005-no-idle-timeout-for-worker-pools.md @@ -0,0 +1,146 @@ +# 0005 — No idle-timeout self-exit in `WorkerExecutor` / `AsyncWorkerExecutor` + +- **Status:** Accepted +- **Date:** 2026-05-10 + +## Context + +`localpost.threadtools.WorkerExecutor` and `AsyncWorkerExecutor` were +originally channel-backed worker pools with lazy spawn (driven by +`put_nowait` / `WouldBlock`) up to `max_concurrency`, and **idle-timeout +self-exit**: a worker that saw no work for `idle_timeout` seconds would +close its receiver clone and exit, shrinking the live worker count. + +That feature interacted badly with the rest of the design: + +- Each worker held its own cloned `rx`. The executor kept a long-lived + `_rx_template` open so that, after the last worker exited, the next + `put_nowait` wouldn't raise `ClosedResourceError`. The template was a + load-bearing placeholder, not a real consumer — present in the + channel's `open_receive_channels` count but unable to receive. +- The combination created a race between worker timeout and a producer + whose `put_nowait` failed (rendezvous full, no waiting receiver). With + `_rx_template` keeping the channel "open", the channel could never + signal "no consumers" to the blocking `put` Phase 2 — so an item + buffered by a producer right as the last worker exited would sit in + the buffer forever, and `put` would block forever waiting for + `items_consumed` to advance. +- The fix was a `get_nowait` recheck under the executor's lock in the + worker's `TimeoutError` handler: if a producer slipped a task in + between the worker's wait timeout and its decision to die, the worker + would still take it. This worked, but the lifecycle bookkeeping + (`_open_receivers` list, per-worker `rx.clone()`, defensive + `rx in self._open_receivers` / `rx.close()` on every exit path) was + duplicated across both executors. + +Forces in play when reconsidering: + +- **The race is fundamental to lazy-spawn-with-idle-timeout.** A survey + of channel libraries (Rust `mpsc` / `crossbeam` / `flume` / `tokio`, + Go built-in channels, Java `BlockingQueue`, AnyIO `MemoryObjectStream`) + confirms that no mainstream channel library absorbs the race into the + channel API — channels are dumb queues; pools manage lifecycle. The + one mainstream system with the same shape, Java + `ThreadPoolExecutor`, resolves the race in the pool too (packed + `(runState, workerCount)` atomic + `addWorker(null, false)` recheck + from `execute()`). Tokio's blocking pool resolves it the same way + with a coarse `Mutex`. So the lock-dance is in good company — *if* + we keep the feature. +- **The savings idle scale-down promises are largely illusory for + in-process Python worker pools.** A worker parked in + `condvar.wait` consumes <100 KB RSS (kernel commits only touched + pages of the 8 MB virtual stack), is not scheduled, and costs the + next burst a fresh `pthread_create` (hundreds of µs to a few ms) to + rebuild. The mechanism complexity (the race, the placeholder + receiver, the duplicated handlers) buys back a small amount of RSS + that gets immediately re-spent on the next workload spike. +- **Reference implementations vote with their defaults.** Python's + stdlib `concurrent.futures.ThreadPoolExecutor` has no idle timeout + at all — workers live until `shutdown()`. Java's + `ThreadPoolExecutor` defaults `allowCoreThreadTimeOut` to `false`. + Idle scale-down is a feature people *can* opt into in those + ecosystems, not the baseline behavior. +- **Where idle scale-down genuinely matters** (per-thread DB + connections, GPU contexts, large per-thread caches), none of those + apply to `WorkerExecutor`'s contract — workers just run callables + with a propagated `contextvars.Context`. Per-task expensive + resources belong inside the task, not pinned to a long-lived worker. + +## Decision + +Workers in `WorkerExecutor` and `AsyncWorkerExecutor` live for the +**executor's lifetime**. There is no idle-timeout self-exit, and no +`idle_timeout=` parameter on either constructor. The +`DEFAULT_IDLE_TIMEOUT` module constant is removed. + +With the lifetime guarantee in place, the storage layer was further +simplified from a `Channel` (capacity / clones / broadcast-on-close +machinery, all unused once `max_concurrency` and `backlog` were also +dropped — see follow-on entry below) to a plain +`collections.deque` + `threading.Condition`. The worker loop becomes: + +```python +def _run_worker(self) -> None: + while True: + with self._cond: + self._idle += 1 + while not self._queue and not self._closed: + self._cond.wait() + self._idle -= 1 + if not self._queue: + return # closed and drained + task = self._queue.popleft() + task.run() +``` + +`submit` enqueues under the same condition; if `self._idle == 0` it +spawns a new worker before notifying. There is no cap and no backlog — +concurrency control is the caller's concern. + +Alternatives considered: + +- **Keep idle timeout, keep the lock dance.** Correct, in good company + with Java / Tokio. Rejected because the savings don't justify the + ongoing cost — the duplicated race-handling sat across two classes + and was the easiest part of the file to break in a refactor without + noticing. +- **Push the race resolution into the channel API** (e.g. a + `BrokenChannel` signal when the last receiver dies, paired with a + receiver factory that the producer can use to spawn a new consumer + on retry). Rejected after the cross-language survey: no mainstream + channel library does this, because channels and pools are different + layers and conflating them leaks pool concerns into the channel. + We'd be inventing a new abstraction with no peers. + +## Consequences + +- `WorkerExecutor(...)` and `AsyncWorkerExecutor(...)` no longer + accept `idle_timeout=`, `max_concurrency=`, or `backlog=`. This is a + **breaking API change**; given v-next is pre-1.0 and these + parameters had no non-default use in this repo, no deprecation path + is provided. Callers that need a cap should apply a + `threading.Semaphore` / `anyio.CapacityLimiter` upstream of + `submit`. +- `AsyncExecutor` (per-task spawn gated by `CapacityLimiter`) is + removed — it had no in-tree callers, and "control concurrency at the + call site" makes per-task `Future.cancel()` propagation unnecessary. +- `localpost.threadtools.DEFAULT_IDLE_TIMEOUT` is removed from the + module's public surface. +- The executors no longer depend on `Channel`. `Channel` itself stays + in the public API for users who want a thread-safe queue with + capacity / timeouts / broadcast-on-close. +- `WorkerExecutor.workers` (the `Thread` list) and + `AsyncWorkerExecutor.worker_count` (an int) replace the prior + `open_receivers` introspection. These were primarily used by tests; + downstream code that introspected them needs to switch. +- The producer-vs-worker-timeout race is gone with the idle timeout. + The submit-side lock (now the condition's lock) guards enqueue + + idle-count check + spawn under one critical section. +- Once at the high-water mark, the pool keeps that many threads alive + until `__exit__`. For a long-running hosted service this is the + intended behavior — the pool sizes itself to peak concurrency and + stays there. +- Memory expectation in production: ~100 KB RSS per parked worker. + At a peak of, say, 64 concurrent submissions that's ~6 MB held — + noise next to the Python interpreter and cheaper than re-paying + `pthread_create` on every burst. diff --git a/docs/adr/index.md b/docs/adr/index.md new file mode 100644 index 0000000..35b0f60 --- /dev/null +++ b/docs/adr/index.md @@ -0,0 +1,52 @@ +# Architecture Decision Records + +ADRs are dated, immutable notes about decisions made in this repo — +the *moments* and *trade-offs*, not the current shape of the code. +Once accepted, an ADR is not edited; if a decision is reversed or +refined, a new ADR is added that supersedes the previous one. + +This is different from `docs/design/`: design notes describe the +system *as it currently is* and are edited freely. ADRs describe +*why we got here*. + +## Format + +We use the [Nygard format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions): + +- **Status** — Proposed / Accepted / Superseded by ADR-NNNN +- **Date** — when the decision was accepted +- **Context** — what's going on, what forces are in play +- **Decision** — what we're going to do +- **Consequences** — what follows, good and bad + +See [`template.md`](template.md) for the boilerplate. + +## When to write one + +Add an ADR when: + +- A non-trivial direction was picked between viable alternatives, and + the rejected ones aren't obviously wrong. +- A constraint or invariant in the code only makes sense if you know + the history (e.g. why two parsers coexist, why the native server + is sync-only). +- A previously-accepted ADR is being reversed or narrowed. + +Don't add an ADR for things that are self-evident from the code, or +for purely tactical decisions (file naming, refactor shape). + +## Numbering + +Sequential, zero-padded to four digits, starting at `0001`. Once +issued, a number is never reused — even for superseded ADRs. Slug the +title in the filename: `0001-anyio-as-async-runtime.md`. + +## Records + +| # | Title | Status | +| ---- | ------------------------------------------------------------ | -------- | +| 0001 | [AnyIO as async runtime](0001-anyio-as-async-runtime.md) | Accepted | +| 0002 | [h11 and httptools coexist](0002-h11-httptools-coexist.md) | Accepted | +| 0003 | [Sync-only native HTTP server](0003-sync-native-http-server.md) | Accepted | +| 0004 | [Pull-based client-disconnect detection](0004-pull-based-disconnect-detection.md) | Accepted | +| 0005 | [No idle-timeout self-exit in worker pools](0005-no-idle-timeout-for-worker-pools.md) | Accepted | diff --git a/docs/adr/template.md b/docs/adr/template.md new file mode 100644 index 0000000..ac0bab2 --- /dev/null +++ b/docs/adr/template.md @@ -0,0 +1,22 @@ +# NNNN — Title + +- **Status:** Proposed | Accepted | Superseded by ADR-NNNN +- **Date:** YYYY-MM-DD + +## Context + +What's the problem? What forces are in play (constraints, requirements, +prior decisions, external factors)? Keep this neutral — describe the +situation, not the answer yet. + +## Decision + +What did we decide to do? State it as a present-tense fact: "We use X." +"The native server stays sync-only." Include the alternatives that were +seriously considered and why they were rejected. + +## Consequences + +What follows from this decision? Both the good and the bad — what does +this make easier, what does it make harder, what new constraints does +it impose? List downstream changes that need to happen. diff --git a/docs/design/connection-model.md b/docs/design/connection-model.md new file mode 100644 index 0000000..04f49e6 --- /dev/null +++ b/docs/design/connection-model.md @@ -0,0 +1,136 @@ +# HTTP connection model + +How `localpost.http` moves a connection from accept through dispatch to +release, and how the sync and async surfaces stay aligned. + +## Dispatch chain + +The internals are a three-link, loose-coupled chain: + +``` +Selector ── owns fd→SelectorCallback map; nothing HTTP-specific + │ + ▼ +ConnHandler ── after-accept policy; owns RequestHandler + ConnFactory; + │ decides which Selector tracks the new conn + ▼ +RequestHandler ── single-shot dispatch on headers-complete; handlers + that need the body call ``ctx.receive(size)`` / + ``localpost.http.read_body(ctx)`` themselves +``` + +Each link knows only the next. `Selector` doesn't carry a +`RequestHandler` — the handler is owned by the `ConnHandler` and +threaded into the conn at construction. This factoring is what makes +the acceptor topology (1 acceptor thread + N worker selectors) drop in +without touching the request hot path. + +- **`Selector`** — dumb fd→callback dispatcher. Built-in callbacks: + `_DrainWakeup` (wakeup pipe), `_AcceptListener` (listen socket), and + `BaseHTTPConn` itself (a conn *is* its own per-fd callback). +- **`ConnHandler`** — `Callable[[Selector, socket, addr], None]`, + after-accept policy. Two built-ins: `TrackHere` (default — track on + the selector that accepted) and `RoundRobinAcceptor` (acceptor + topology — spread conns across worker selectors via + `Selector.post_track`). +- All callbacks are spelled as **callable dataclasses** (not + closures), so their state is `repr`-able for debugging. + +## Two-state connection model + +A connection is either **TRACKED** (registered in the selector — the +selector owns the fd, parser, and I/O) or **BORROWED** (a worker +thread holds the parser + socket; fd is unregistered). The dispatcher +unregisters before handing off to a worker (`stop_tracking`); +`finish_response` re-registers via `_maybe_give_back`. Two states, no +third "watchdog" mode, no shared mode field for threads to race on. + +``` + accept() + │ + ▼ + ConnHandler builds conn, + routes to a selector via + track() or post_track() + │ + ▼ + ┌──────────────────────┐ ┌──────────┐ + │ TRACKED │ close │ │ + │ fd registered ├────────▶│ CLOSED │ + │ selector owns I/O │ │ │ + └──┬───────────────────┘ └──────────┘ + │ ▲ ▲ + borrow │ │ _maybe_give_back │ close + ▼ │ │ + ┌──────────────────────┐ │ + │ BORROWED │ │ + │ fd unregistered ├───────────────┘ + │ worker owns I/O │ + └──────────────────────┘ +``` + +Both topologies (single selector and acceptor + N worker selectors) +funnel through the same diagram; only the entry edge differs: + +| From → to | Method | Caller thread | Mechanism | +| ------------------------ | ---------------------- | -------------- | ------------------------------------------------ | +| accept → TRACKED | `Selector.track` | selector | inline `selectors.register` (TrackHere topology) | +| accept → TRACKED | `Selector.post_track` | acceptor | op queue + wakeup pipe (RoundRobinAcceptor) | +| TRACKED → BORROWED | `stop_tracking` | selector | inline `selectors.unregister` | +| BORROWED → TRACKED | `Selector.track` | worker | op queue + wakeup pipe | +| TRACKED → CLOSED | `BaseHTTPConn.close` | selector | inline (idle / keep-alive / error) | +| BORROWED → CLOSED | `close` + `post_close` | worker | op queue + wakeup pipe (`_fd_to_key` cleanup) | + +The op queue + wakeup pipe is the only cross-thread synchronisation +edge (the `os.write` to the wakeup pipe is a full memory barrier). +After `track()` returns, anything the worker did to the conn's parser +(`h11.Connection.send` / `httptools.HttpRequestParser.feed_data`) is +visible to the selector. After `stop_tracking()` returns, anything +the selector did is visible to the worker. The parser is never +touched concurrently from two threads. + +## Pull-based client-disconnect detection + +While the worker holds a borrowed connection, client disconnects are +detected on demand: `check_cancelled()` does a non-blocking +`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. `b""` means +peer FIN — `RequestCancelled` is raised. Handlers that do regular I/O +surface disconnects via `EPIPE` / `ECONNRESET` naturally; handlers +that compute without I/O should call `check_cancelled()` periodically +(same contract as service-shutdown cancellation). + +This replaces an earlier push-based design where the selector kept +the socket registered in a third "watchdog" mode and fired EOF +events. That worked but introduced a 3-way state machine with +cross-thread races. The pull-based variant collapses to two states +and one syscall per `check_cancelled()` call. Full rationale in +[ADR-0004](../adr/0004-pull-based-disconnect-detection.md). + +## Sync vs. async request-context surface + +`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side +(`request`, `body`, `attrs`, `response_status`, `remote_addr` / +`local_addr` / `scheme`, `disconnected`) and the terminal write +methods (`complete`, `stream`, `sendfile`, plus `receive` for body +reads). A handler that only touches that core surface is portable +between transports. + +A few members are deliberately asymmetric: + +| Member | Sync | Async | Why | +| ---------------------------- | ---- | ----- | --- | +| `borrow()` / `borrowed` | yes | — | Only the sync native server hands a connection between selector and worker threads. Async transports own the conn for the coroutine's lifetime; there's nothing to borrow. The WSGI bridge satisfies the sync member trivially (always borrowed, no-op CM) so handler code stays portable. | +| `disconnected` poll | yes | yes | Native sync does a non-blocking `MSG_PEEK` per access (sticky once True). WSGI bridge is always False — surface disconnects via `BrokenPipeError` from the host's per-chunk write instead. Async transports flip the flag on `http.disconnect`. | +| `check_cancelled()` (raises) | yes | — | Sync handlers without `ctx` in scope can call it from anywhere on the request thread; async code already has `ctx` and uses `ctx.disconnected`. | + +The wire-driver trio (`start_response` / `send` / `finish_response`) +is **not** part of the public Protocol on either side. Sync native +backends still use it internally to drive h11 / httptools, and 1xx +informational responses (100 Continue / 102 Processing) are emitted +by the server through that internal path. Handlers express responses +declaratively via `complete()` (one-shot) or `stream(response, +chunks)` (chunk iterator) — both portable across sync and async. + +Why the native server doesn't grow an async path of its own (and +why async traffic goes through the ASGI bridge instead) is covered +in [ADR-0003](../adr/0003-sync-native-http-server.md). diff --git a/docs/design/deployment-topologies.md b/docs/design/deployment-topologies.md new file mode 100644 index 0000000..bd5d727 --- /dev/null +++ b/docs/design/deployment-topologies.md @@ -0,0 +1,148 @@ +# Deployment topologies — uvicorn vs Granian + +`localpost` supports three async deployment targets: **uvicorn**, +**hypercorn**, and **Granian**. The first two are *in-process* — they +run as a hosted service inside `hosting.run_app(...)`, alongside any +other services you've registered. Granian is *out-of-process* — it's +a process supervisor that spawns workers and loads our app via its +RSGI interface. + +The two cases look superficially similar (both serve HTTP, both run +async), but the deployment topology is inverted. This note explains +why and how the framework's API surface reflects that. + +## In-process: uvicorn / hypercorn + +``` +┌─────────────────────────────────────────────┐ +│ Process owned by localpost.hosting │ +│ ├─ scheduler service │ +│ ├─ uvicorn_server(config) service │ +│ │ └─ uvicorn.Server.serve() │ +│ │ └─ ASGI app │ +│ └─ … other hosted services │ +└─────────────────────────────────────────────┘ +``` + +`uvicorn.Server` runs *inside* our process, configured with `workers=1`. +Hosting drives its lifecycle: on `set_started` we register endpoints, +on `shutting_down` we set `should_exit = True` and wait for the +serve loop to return. Memory is shared between the HTTP app and every +other hosted service. + +API: + +```python +hosting.run_app([ + scheduler.service(), + app.service(uvicorn.Config(...)), # server="uvicorn" (default) + app.service(hypercorn.Config(...), server="hypercorn"), +]) +``` + +Same pattern as gRPC / any other adapter in `localpost.hosting.services/`. + +## Out-of-process: Granian + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Granian (process supervisor, spawned by `granian` CLI) │ +│ ├─ Worker process 1 │ +│ │ └─ HostRSGIApp (single localpost process) │ +│ │ ├─ scheduler service ─┐ │ +│ │ ├─ other hosted services ─┼─ all running in-process │ +│ │ └─ RSGI request handler ─┘ in this worker │ +│ ├─ Worker process 2 (same shape) │ +│ └─ Worker process N │ +└────────────────────────────────────────────────────────────────────┘ +``` + +Granian doesn't have an in-process mode you can plug into +`hosting.run_app(...)`. It always spawns workers (one per CPU by +default). Each worker is its own Python process, loads the user's +module, picks up the RSGI app object, and serves requests. + +To deploy a hosted app under Granian, the **host itself implements +RSGI**. Inside each Granian worker process, `HostRSGIApp` runs the +full hosting stack (scheduler + other services + the HTTP request +handler) — sharing memory the way a regular `hosting.run_app` +deployment does. The HTTP request handler is dispatched per-request +via Granian's RSGI hook. + +API: + +```python +from localpost.hosting.rsgi import HostRSGIApp + +rsgi_app = HostRSGIApp( + services=[scheduler.service(), other_service.service()], + rsgi_handler=app, # HttpAsyncApp or AsyncRequestHandler +) +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +## Why the asymmetry + +It's a property of the host server, not a framework choice: + +- **uvicorn / hypercorn** are pure-Python ASGI servers. They expose + programmatic APIs (`Server.serve()`, `serve(app, config)`) that run + on whatever event loop you give them. Wrapping one as a hosted + service is natural. +- **Granian** is a Rust-native RSGI server with multi-process workers + baked in. The Python side is a thin shim Granian calls *into* — you + can't call Granian *from* an existing event loop and expect it to + share that loop with the rest of your app. + +Trying to run Granian as a `hosting.run_app(...)` service would force +either single-worker (defeats Granian's main feature) or a +process-detach (defeats the in-process service-sharing model +`hosting.run_app` is built around). Inverting the topology — Granian +spawns the workers; each worker runs `hosting.serve(...)` — keeps both +sides honest. + +## Per-worker side effects (Granian only) + +A consequence of the supervisor topology: every Granian worker runs +the *full* `services=` list. With `--workers 4`, four schedulers tick. +Cron-style "run once" jobs need either: + +- `--workers 1` — fine for low-RPS services where one Python worker + saturates upstream capacity. +- External coordination — DB lock, leader election, or a separate + cron service running outside the HTTP fleet. + +Process-shared state across workers doesn't exist (no +`multiprocessing.Manager` integration in scope). That's normal +multi-process territory; the framework doesn't try to hide it. + +## Lifecycle hook semantics + +Granian's `__rsgi_init__(loop)` and `__rsgi_del__(loop)` hooks are +**synchronous** — Granian calls them without `await`. This means +`HostRSGIApp` can't actually wait for `hosting.serve()` to reach +`started` inside `__rsgi_init__`. The implementation is: + +- `__rsgi_init__`: spawn a long-lived task on the supplied loop; the + task enters `serve()` and waits for an internal "shutdown" event + before exiting. Set a "ready" `asyncio.Event` once services are up. +- `__rsgi__`: gate the first request on the ready event — Granian may + start dispatching before the lifecycle is fully up. +- `__rsgi_del__`: signal the lifecycle task to exit its `serve()` + block; hosting drives shutdown and waits for `stopped`. + +The single-task pattern is required because anyio's cancel scopes +(used inside `serve()`) must be entered and exited by the same task — +splitting `__aenter__` / `__aexit__` across two would raise +`"different task than it was entered in"`. + +## Picking a target + +| You want… | Use | +|----------------------------------------|----------------------------------------------| +| Simplest deployment, scaling via external supervisor | uvicorn / hypercorn via `app.service(config)` | +| Maximum HTTP throughput on Python | Granian via `app.as_rsgi()` or `HostRSGIApp` | +| Multiple hosted services + Granian | `HostRSGIApp(services=..., rsgi_handler=app)` | +| Multiple hosted services + ASGI server | `app.service(uvicorn.Config(...))` alongside other services in `run_app` | +| HTTP only, ASGI server | `app.asgi()` directly (`granian --interface asgi`) | +| HTTP only, RSGI server | `app.as_rsgi()` directly (`granian --interface rsgi`) | diff --git a/docs/design/request-body-handling.md b/docs/design/request-body-handling.md new file mode 100644 index 0000000..41f0a34 --- /dev/null +++ b/docs/design/request-body-handling.md @@ -0,0 +1,117 @@ +# Request body handling across transports + +`localpost.http` exposes two surfaces for accessing the request body: +`ctx.body: bytes` (the buffered body) and `ctx.receive(size) -> bytes` +(read up to `size` bytes). Both are present on the sync +[`HTTPReqCtx`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_base.py) +and async +[`AsyncHTTPReqCtx`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_async_base.py) +Protocols. +This note explains what `receive(size)` does in each transport — and +why the same Protocol cleanly covers four very different host servers. + +## The contract + +`receive(size) -> bytes` means "give me up to `size` bytes of the +request body, or `b""` at end-of-message." Nothing more. **What the +source is** depends on what the transport has done before the handler +runs: + +| Transport | Body pre-buffered? | `receive(size)` source | +|----------------------------------|------------------------------|----------------------------------------------| +| Native h11 (sync) — JSON path | Yes, by the selector | Returns `b""` (parser is at EOM; use `ctx.body`) | +| Native h11 (sync) — streaming | No (`streaming_pool_handler`)| Reads from the socket, chunk-at-a-time | +| WSGI bridge (sync) | Yes, from `wsgi.input` | Slices the buffered `ctx.body` | +| ASGI bridge (async) | Yes, capped by `max_body_size` | Slices the buffered `ctx.body` | +| RSGI bridge (async, planned) | No (RSGI streams natively) | Reads from the protocol via `async for` | + +The handler doesn't care which row applies. It calls `receive(size)`, +processes whatever it gets, and stops at `b""`. That's the entire +contract. + +## What pre-buffers what + +The pre-buffer happens *outside* the ctx in every case — the ctx +implements one strategy that matches what its host transport gave it. + +### Native h11 (sync) + +The h11 parser exposes per-chunk events +([`server_h11.py:422`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/server_h11.py)): +`receive(size)` pumps `parser.next_event()`, calls `conn.receive(size)` +(i.e. `recv` on the socket) when h11 says `NEED_DATA`, and returns the +next `h11.Data` chunk. **It always reads from the wire** — there's no +internal buffer. + +`ctx.body` gets populated by the **selector**, not by the ctx itself, +when a [`RequestHandler`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_base.py) +returns a +[`BodyHandler`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_base.py) +continuation. The +selector reads the full body into `ctx.body` *before* invoking the +continuation, then runs it. After that, `parser.their_state == +h11.DONE`, so a follow-up `ctx.receive(...)` correctly returns `b""`. +This is the JSON-API common case: handler decides "I need the body" by +returning a `BodyHandler`, then reads `ctx.body` directly. + +The streaming case ([`streaming_pool_handler`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_pool.py)) +runs the handler on a borrowed conn *without* pre-buffering — the +handler calls `ctx.receive(size)` to pull chunks off the wire as they +arrive. Use it for streaming uploads / large bodies where buffering is +undesirable. + +### WSGI bridge (sync) + +WSGI ([`wsgi.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/wsgi.py)) doesn't expose +chunked input — the WSGI server has already buffered the body for us +in `wsgi.input`. The bridge reads it once into `ctx.body`; +`ctx.receive(size)` slices that buffer. There's no wire to read from +on this side. + +### ASGI bridge (async) + +ASGI ([`asgi.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/asgi.py)) hands us body chunks +via `http.request` events on the receive channel. The bridge consumes +them upfront (capped by `max_body_size`) into `ctx.body` before +dispatch; `ctx.receive(size)` slices the buffered body, same shape as +the WSGI path. + +A future "streaming mode" could skip the pre-buffer and have +`ctx.receive(size)` pull from the ASGI receive channel directly — same +contract, different impl. Out of scope for v1. + +### RSGI bridge (async, planned) + +RSGI exposes `async for chunk in proto` natively. A future +`localpost.http.rsgi` adapter will implement +`AsyncHTTPReqCtx.receive(size)` by pulling from the protocol directly, +not via a pre-buffer. This is the first async transport that would +*stream* on `ctx.receive(...)`. The Protocol contract already covers +it — no API change needed. + +## Why the Protocol stays clean + +There's no `streaming: bool` flag, no separate `read_chunk()` method, +no two competing surfaces. Just `receive(size)` with one consistent +contract. The transport picks the source that matches what its host +server gave it; the handler stays portable. + +The `ctx.body: bytes` attribute is a complementary convenience for +the JSON-API common case: when a transport pre-buffers (the default +on WSGI / ASGI, the JSON path on native), `ctx.body` is the +already-buffered bytes. When a transport doesn't pre-buffer, `ctx.body` +is empty and the handler reads via `receive(size)`. + +## User-facing knobs + +The one thing that *is* user-visible is the *config*: + +- **Native sync** — pick `streaming_pool_handler` (no pre-buffer) vs + `thread_pool_handler` (pre-buffer) when wiring the server. +- **ASGI** — `to_asgi(handler, max_body_size=N)` controls the + pre-buffer cap. Bodies above the cap raise 413 before the handler + runs. + +In every case, the handler code reads `ctx.body` or `await +ctx.receive(size)` (or sync `ctx.receive(size)`) without knowing which +transport ran it. diff --git a/docs/design/server-backends.md b/docs/design/server-backends.md new file mode 100644 index 0000000..5a2cfaa --- /dev/null +++ b/docs/design/server-backends.md @@ -0,0 +1,51 @@ +# HTTP server backends: h11 and httptools + +`localpost.http` ships two parser implementations that live +side-by-side. They share the listening socket, selector loop, op +queue, stale-conn sweep, and shutdown coordination (everything in +`_base.py`). They differ only in how they drive the parser. + +| `ServerConfig.backend` | Parser | Extra | Notes | +| ---------------------- | --------- | --------------- | ------------------------------------- | +| `"h11"` *(default)* | h11 | `[http-server]` | pure Python, readable | +| `"httptools"` | httptools | `[http-fast]` | C-based llhttp; faster header parsing | + +There is one entry point — `start_http_server(config, handler)` — +and one hosted-service wrapper — `http_server(config, handler)`. The +parser is selected via `ServerConfig.backend`: + +```python +from localpost.http import ServerConfig, start_http_server + +with start_http_server( + ServerConfig(backend="httptools"), my_handler +) as server: + while True: + server.run() +``` + +Pick whichever fits — handler code is identical. Both populate the +same neutral `Request` / `NativeResponse` types from +`localpost.http`. + +## Why two implementations, not one Protocol + +The two backends are intentionally **not** unified behind a parser +Protocol. h11 is pull-events + parse/serialize; httptools is +push-callbacks + parse-only. Forcing one shape over both restricts +the faster backend without buying anything portable, and the share +that *could* be hoisted (`_base.py`) already is. + +The full rationale, including alternatives we rejected, is in +[ADR-0002](../adr/0002-h11-httptools-coexist.md). + +## httptools backend caveats (initial scope) + +- `Content-Length` response bodies only — chunked transfer-encoding + is a follow-up. `Router` and `wrap_wsgi` already set + `Content-Length`, so this matches today's behaviour. +- HTTP Upgrade negotiation surfaces as 400 Bad Request — revisit + if/when WebSockets come back on the roadmap. + +For perf context, see +[`benchmarks/macro/http/PERF_FINDINGS.md`](https://github.com/alexeyshockov/localpost.py/blob/main/benchmarks/macro/http/PERF_FINDINGS.md). diff --git a/docs/design/threading-topologies.md b/docs/design/threading-topologies.md new file mode 100644 index 0000000..952410f --- /dev/null +++ b/docs/design/threading-topologies.md @@ -0,0 +1,85 @@ +# HTTP threading topologies + +`http_server` supports three accept-side shapes, all driving the same +`RequestHandler` (single-shot dispatch on headers-complete). The +underlying connection state machine is shared — see +[connection-model.md](connection-model.md) for the TRACKED / BORROWED +diagram and cross-thread sync edges. + +| Configuration | Threads | When to use | +| ------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Default (`selectors=1`) | 1 thread accepts, parses, and dispatches | Simple deployments; the thread-pool wrapper handles concurrency on the request side | +| `selectors=N` | N independent selectors, each with its own listening socket via `SO_REUSEPORT` | Linux kernel-level connection load-balancing; free-threaded builds | +| `selectors=N, acceptor=True` | 1 acceptor thread + N worker selector threads | macOS / free-threaded targets where `SO_REUSEPORT` doesn't distribute evenly. Acceptor accepts each conn and round-robins it to a worker via the cross-thread op queue. | + +The server loop runs in a worker thread +(`anyio.to_thread.run_sync`); shutdown is driven by +`lt.shutting_down` via `threadtools.check_cancelled()`. The server +hosts a single handler; whether that handler runs synchronously on +the selector thread or hops to a worker is the handler's choice. + +## Composition pattern + +`http_server`, the `Router`, and `thread_pool_handler` are three +orthogonal concepts that you compose explicitly: + +```python +from localpost.hosting import run_app, service +from localpost.http import ( + Routes, ServerConfig, http_server, thread_pool_handler, +) + + +@service +async def app(): + routes = Routes() + + @routes.get("/hello/{name}") + def hello(ctx): ... # plain RequestCtx → Response handler + + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(routes.build().as_handler()) as h: + async with http_server(config, h): + yield + + +run_app(app()) +``` + +What this gives you: + +- **404 / 405 stay on the selector thread.** When the `Router` is + the handler (wrapped or not), unmatched paths and method + mismatches go through `_send_plain` inline — no worker hop. +- **Matched routes run wherever you want.** Wrap the entire router + with `thread_pool_handler` (above) to run all matched handlers on + workers; pass the router directly to `http_server` to keep them + all on the selector; more granular per-route control is the + user's composition problem (today there is no per-route pool + API). +- **No concurrency cap on `http_server` or the pool.** The pool + dispatches every request onto a process-wide `TaskGroup`; + workers are spawned on demand and reused across all + `thread_pool_handler` instances in the process. There is no + admission gate and no 503-on-overflow — backpressure is the + deployment's concern (front-LB / OS thread limits). + +## Three orthogonal concerns + +- **Handler** — `Callable[[HTTPReqCtx], None]`. Immediate by default + (runs on whichever thread invokes it). The thread-pool variant is + just a wrapper that borrows the connection, queues it, and runs + `inner` on a worker. +- **Router** — itself an immediate handler. Runs `_match()` inline + on the calling thread; 404/405 are sent via `_send_plain` and the + conn re-tracks via the existing connection loop. Matched routes + call the registered per-route handler — which the user can choose + to pool or not. +- **`http_server`** — hosting integration only. Owns the AnyIO + bridge, drives `server.run()` in a thread, watches + `lt.shutting_down`. Doesn't know about pools, doesn't know about + routing. + +This split means the simple case (all-immediate handlers) doesn't +pay for a worker pool, and the Router's 404/405 path doesn't either +even when the matched routes run on workers. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..f34d83e --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,80 @@ +# Getting started + +## Install + +```bash +pip install localpost +``` + +For the scheduler and HTTP examples below you'll want a couple of extras: + +```bash +pip install 'localpost[scheduler,http]' +``` + +See the [extras table on the home page](index.md#install) for the full list. + +## A scheduled task + +The scheduler triggers callables on **conditions** — `every(...)`, +`after(...)`, `cron(...)` — composed with operators. `run_app` from the hosting +module wires up signal handling and runs everything until SIGINT / SIGTERM. + +```python +import random + +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first + + +@scheduled_task(every("3s") // delay((0, 1))) +async def task1(): + return random.randint(1, 22) + + +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): + print(f"task1 emitted: {task1_result}") + + +if __name__ == "__main__": + run_app(task1, task2) +``` + +What's happening: + +- `every("3s") // delay((0, 1))` — fire every 3 seconds, jittered by 0–1s. +- `after(task1) // take_first(3)` — fire when `task1` completes, take only its + first 3 emissions. +- `run_app(...)` — start every service in parallel, exit cleanly when they all + stop, then raise `SystemExit` with the resulting status code. + +## A sync function works too + +The scheduler accepts both sync and async callables. Sync ones are offloaded +to a thread pool via `anyio.to_thread`: + +```python +from localpost.scheduler import every, scheduled_task + + +@scheduled_task(every("10s")) +def collect_metrics(): # plain `def`, no `async` + ... +``` + +## Where to next + +- **[hosting](modules/hosting.md)** — service lifecycles, signal handling, + middleware, and adapters for Uvicorn / Hypercorn / gRPC. +- **[scheduler](modules/scheduler.md)** — full trigger reference, operators, + cron support. +- **[http](modules/http.md)** — the lightweight HTTP/1.1 server, WSGI / ASGI + bridges, router. +- **[di](modules/di.md)** — scoped IoC container. +- **[openapi](modules/openapi.md)** — typed HTTP framework with built-in + OpenAPI 3.2 generation. + +Working examples for every module live in +[`examples/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples) +in the repository. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..80a8fe2 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,56 @@ +# LocalPost + +A small async Python framework for long-running processes: + +- **hosting** — service lifecycle + orchestration (start / stop / signals) +- **scheduler** — in-process, composable task scheduler +- **http** — lightweight sync HTTP/1.1 server (h11, ~400 LOC) +- **di** — `.NET`-style scoped IoC container + +Built on [AnyIO](https://anyio.readthedocs.io/) — runs on **asyncio** *and* +**Trio**. Python 3.12+. + +LocalPost is not a monolith: each module is usable on its own. Pick what you +need. + +## Install + +```bash +pip install localpost +``` + +Optional extras turn on individual subsystems: + +| Extra | Adds | +| ---------------- | ------------------------------------------------------- | +| `[scheduler]` | `humanize`, `pytimeparse2` — string durations | +| `[cron]` | `croniter` — cron-expression trigger | +| `[http]` | `h11` — HTTP server | +| `[http-fast]` | `httptools` — alternative C-based parser | +| `[http-compress]`| `brotli` — Brotli compression (gzip is stdlib) | +| `[openapi]` | `msgspec` — typed HTTP framework with OpenAPI 3.2 | +| `[rsgi]` | `granian` — RSGI bridge for Granian deployments | + +## Where to next + +- **[Getting started](getting-started.md)** — the 60-second tour. +- **Modules** — per-module references: + [hosting](modules/hosting.md), + [scheduler](modules/scheduler.md), + [http](modules/http.md), + [di](modules/di.md), + [openapi](modules/openapi.md). +- **Design notes** — how the system works today and why + ([connection model](design/connection-model.md), + [threading topologies](design/threading-topologies.md), …). +- **[ADRs](adr/index.md)** — dated, immutable records of non-trivial design + decisions. + +## Status + +Beta — actively developed. All four core modules have settled public APIs and +are not expected to break in patch or minor releases. See the +[CHANGELOG](https://github.com/alexeyshockov/localpost.py/blob/main/CHANGELOG.md) +for history. + +MIT licensed. diff --git a/docs/modules/di.md b/docs/modules/di.md new file mode 100644 index 0000000..d12d9b9 --- /dev/null +++ b/docs/modules/di.md @@ -0,0 +1,132 @@ +# localpost.di + +Small, `.NET`-style inversion-of-control container: scoped service resolution +with automatic constructor wiring. No decorators, no async, no "one interface, +many implementations" — just a registry + provider + scope. + +## Install + +Comes with core `localpost` — no extra needed. The Flask adapter requires +`flask` in your environment. + +## Quick start + +```python +from dataclasses import dataclass +from localpost.di._services import ServiceRegistry + + +@dataclass +class Config: + host: str + port: int + + +class Server: + def __init__(self, config: Config): + self.config = config + + +services = ServiceRegistry() +services.register_instance(Config(host="127.0.0.1", port=8080)) +services.register(Server) # auto-wires Config via __init__ + +with services.app_scope() as sp: + server = sp.resolve(Server) + print(server.config) +``` + +With cleanup (generator factory): + +```python +def create_db_pool(conf: Config): + pool = DBPool(conf.db_dsn) + try: + yield pool + finally: + pool.close() + + +services.register(DBPool, create_db_pool) +``` + +See [`examples/di/basic.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/di/basic.py), +[`basic_cleanup.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/di/basic_cleanup.py), +[`flask_app.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/di/flask_app.py). + +## Design goals (and non-goals) + +- Inspired by .NET `Microsoft.Extensions.DependencyInjection`. +- No `async` (for now). +- **Scope is defined by its type** (`AppContext`, `RequestContext`, …). +- **Only one registration per `(service_type, scope_type)` pair** (the opposite + of .NET's `IEnumerable` pattern). A type can still be registered + multiple times — once per scope. +- **Service Locator** style — no magic `@inject` decorator. Ask the provider + for what you need. +- Constructor dependencies are **auto-wired** from type hints when resolved + via the provider. +- Factories can be plain callables, or **generator functions** for + setup/teardown (enter on resolve, `finally` on scope exit). +- Instances are **lazily resolved** by default; use `create_on_enter=True` to + build them eagerly when a scope is entered. + +## Key concepts + +- **`ResolutionContext`** (Protocol) — anything with `enter(cm)`. `AppContext` + is the default app-wide scope; `RequestContext` (in the Flask adapter) is + per-request. Define your own for other lifetimes. +- **`ServiceRegistry`** — the mutable catalogue. Holds `ServiceDescriptor`s + keyed by `(service_type, scope_type)`. +- **`ServiceProvider`** — resolves services. The default provider walks the + scope chain (request → app) to find a descriptor. +- **Scope stack** — child scopes hold a reference to their parent provider, so + resolving a parent-scoped service from inside a child scope transparently + reuses it. +- **`service_provider` proxy** — a `CurrentServiceProvider` singleton that + delegates to whichever provider is active in the current `contextvar`. + Lets request-scoped code call `service_provider.resolve(...)` without + plumbing the provider through. + +## Module layout + +Symbols currently live under `localpost.di._services` — `localpost/di/__init__.py` +is empty and a top-level re-export is pending. Treat the module path as +unstable until that lands. + +The Flask adapter (`localpost.di.flask`) provides `RequestContext` (per-request +scope) and `init_app(app, registry, provider)` which opens a `RequestContext` +per Flask request and registers `flask.Request` for auto-injection. A Quart +adapter (`localpost/di/quart.py`) exists as a stub only. + +## Defining a custom scope + +1. Create a dataclass implementing `ResolutionContext`: + + ```python + @dataclass(frozen=True, eq=False, slots=True) + class JobContext: + ctx: ExitStack = field(default_factory=ExitStack) + def enter[T](self, cm): return self.ctx.enter_context(cm) + ``` + +2. Register services under it: `services.register(JobRepo, scope=JobContext)`. + +3. Open the scope when you start a job: + + ```python + from localpost.di._services import DefaultServiceProvider, scope + job_ctx = JobContext() + provider = DefaultServiceProvider(parent_provider, registry, job_ctx, JobContext) + with job_ctx.ctx, scope(provider): + run_job() + ``` + +The Flask adapter in +[`flask.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/di/flask.py) +is a compact reference. + +## See also + +- Examples: [`examples/di/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/di/) +- Core: [`_services.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/di/_services.py) diff --git a/docs/modules/hosting.md b/docs/modules/hosting.md new file mode 100644 index 0000000..24cbc80 --- /dev/null +++ b/docs/modules/hosting.md @@ -0,0 +1,149 @@ +# localpost.hosting + +Service lifecycle management and orchestration. A `service` is any async (or +sync) function wrapped with a lifecycle — it goes through `Starting → +Running → ShuttingDown → Stopped`, reacts to signals, and can spawn child +services in the same task group. + +## Quick start + +```python +import time +from localpost.hosting import ServiceLifetime, run_app, service + + +@service +def a_sync_service(): + def svc(lt: ServiceLifetime): + print("Service started") + lt.set_started() + print("Service running") + time.sleep(5) + print("Service is done") # host stops when all services stop + return svc + + +if __name__ == "__main__": + run_app(a_sync_service()) +``` + +`run_app()` wires `shutdown_on_signal()` for you (SIGINT / SIGTERM), runs the +service with AnyIO (picking asyncio or Trio via `choose_anyio_backend`), and +raises `SystemExit` with the resulting status code. + +See [`examples/host/finite_service.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/host/finite_service.py), +[`examples/host/channel.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/host/channel.py). + +## Key concepts + +- **`ServiceLifetime`** — the handle passed to every service. Exposes + `started`, `shutting_down`, `stopped` events (as `Event` / `EventView`), + an anyio `TaskGroup` (`lt.tg`) for spawning child tasks, and `defer` / + `adefer` to stash context managers / closable resources. +- **`ServiceState`** — the union `Starting | Running | ShuttingDown | Stopped` + (immutable dataclasses). Accessible via `lt.view.state`. +- **`@service` decorator** — turns a factory (returning a service function + or async generator) into a `_ResolvedService`, a callable that doubles as + an async context manager. +- **Middleware** — ordinary function decorators over the service function. + Examples: `shutdown_on_signal(*signals)`, `start_timeout(seconds)`. +- **`current_service()` / `current_app()`** — read-only views of the enclosing + lifetimes via contextvars, without threading them through every call. + +## Writing a service + +Four signatures are supported; `@service` picks the right adapter: + +1. **Async function** — `async def svc(lt: ServiceLifetime) -> None` +2. **Sync function** — runs in a worker thread via `to_thread.run_sync` +3. **Async generator** — `@service async def factory(): setup; yield; teardown` + (wrapped with `@asynccontextmanager`; `lt.set_started()` is called after + `yield`-in). +4. **Factory returning one of the above** + +Always call `lt.set_started()` once your service is ready. Services that +never call it block `observe_services` forever. + +## Writing middleware + +Middleware is a plain decorator over `ServiceF = Callable[[ServiceLifetime], +Awaitable[None]]`. Reference: `shutdown_on_signal` in `middleware.py`: + +```python +def my_middleware(arg) -> Callable[[ServiceF], ServiceF]: + def decorator(func: ServiceF) -> ServiceF: + @wraps(func) + def wrapper(lt: ServiceLifetime) -> Awaitable[None]: + lt.tg.start_soon(my_background_task, lt.view) + return func(lt) + return wrapper + return decorator +``` + +## Adapters for external servers (`services/`) + +`uvicorn.py` wraps `uvicorn.Server` (with reload and multi-worker disabled); +`hypercorn.py` wraps `hypercorn.asyncio.serve(app, config)` with a shutdown +trigger; `grpc.py` wraps `grpc.aio.Server` with a configurable grace period; +`_asgi.py` holds shared ASGI lifespan helpers. Each adapter is decorated +with `@hosting.service`, so it plugs into `run_app()` the same way as any +other service. + +## Host as RSGI for Granian + +`localpost.hosting.rsgi.HostRSGIApp` runs the full hosting lifecycle (multiple +services + an HTTP handler) inside each Granian worker. Granian is a process +supervisor that spawns workers and loads our app via its RSGI interface, so +the topology flips: the host *itself* implements RSGI. + +```python +from localpost.hosting.rsgi import HostRSGIApp +from localpost.openapi import HttpAsyncApp +from localpost.scheduler import every, scheduled_task + + +app = HttpAsyncApp() + + +@app.get("/") +async def root() -> str: + return "ok" + + +@scheduled_task(every(seconds=5)) +async def heartbeat() -> None: ... + + +rsgi_app = HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, +) + +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +`shutdown_on_signal` is **not** applied — Granian owns signal handling; +`__rsgi_del__` is how shutdown reaches us. Every service in `services=` +runs in *each* worker, so cron-style "run once" jobs need either +`--workers 1` or external coordination (DB lock, leader election). + +For the bridge layer (RSGI translation, no hosting integration), see +[`localpost.http.rsgi`](http.md#localposthttprsgi). The asymmetry between +uvicorn-as-a-hosted-service and Granian-as-a-supervisor (plus per-worker +lifecycle details) is covered in +[deployment topologies](../design/deployment-topologies.md). + +## Implementation notes + +- A service may spawn child services via `lt.start(child_svc)` — they run in + `lt.tg`, so when the parent's service function returns, the child task group + is cancelled. If you want the children to complete, `await` them explicitly + before returning. +- `lt.defer(cm)` / `await lt.adefer(acm)` tie a resource's lifetime to the + service — it's released when the service stops. + +## See also + +- Examples: [`examples/host/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/host/) +- Middleware source: [`middleware.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/hosting/middleware.py) +- Core: [`_host.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/hosting/_host.py) diff --git a/docs/modules/http.md b/docs/modules/http.md new file mode 100644 index 0000000..c7c2ecc --- /dev/null +++ b/docs/modules/http.md @@ -0,0 +1,429 @@ +# localpost.http + +A small synchronous HTTP/1.1 server built on +[h11](https://h11.readthedocs.io/), plus a URI-template router, WSGI / ASGI +bridges, and a small framework (`HttpApp`) on top. Three layers, each +usable on its own: + +- **Server**: `start_http_server` accepts connections, parses HTTP + (h11 by default; httptools opt-in via `ServerConfig.backend`), + dispatches to a `RequestHandler`. ~540 lines of sync code. +- **Router**: thin URI-template dispatcher. Matches the request, + attaches a `RouteMatch` to `ctx.attrs[RouteMatch]`, delegates to + the registered handler. 404 / 405 inline. +- **HttpApp**: decorator-driven framework — parameter injection, + response conversion, worker-pool dispatch, middleware. + +Pair with `localpost.hosting` for lifecycle management, or run any of +the three layers standalone. + +## Scope + +In-process only (no `fork` / `spawn`); for multi-core fanout, run multiple +processes under an external supervisor. Sync server only — async handlers +are reached via `localpost.http.asgi.to_asgi(handler)` plugged into +uvicorn / hypercorn / granian. CPython 3.12+ is the baseline; free-threaded +builds are an accepted target. + +The hot path is tuned for the JSON-API common case: handlers can reject +before the body (no worker hop, `Content-Length` pre-checked against +`max_body_size`); read the body explicitly via `read_body(ctx)` / +`aread_body(ctx)` when needed; one-shot `complete()` flushes +status+headers+body in a single `sendall`. No HTTP/1.1 pipelining. + +## Install + +```bash +pip install localpost[http-server] # h11 backend (default, pure Python) +pip install localpost[http-server,http-fast] # also adds the httptools backend +``` + +## Quick start + +The recommended path is `HttpApp`: + +```python +from localpost.hosting import run_app +from localpost.http import HTTPReqCtx, ServerConfig +from localpost.http.app import HttpApp + + +app = HttpApp() + + +@app.get("/{name}") +def hello(name: str): + return f"Hello, {name}!" + + +@app.post("/{name}/profile") +def update_profile(ctx: HTTPReqCtx, name: str): + import json + from localpost.http import read_body + profile = json.loads(read_body(ctx)) + return {"updated": name, "profile": profile} + + +run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) +``` + +Or stay close to the wire — `start_http_server` directly: + +```python +import h11 +from localpost.http import HTTPReqCtx, ServerConfig, start_http_server + + +def simple_app(ctx: HTTPReqCtx): + ctx.complete( + h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), + b"Hello, World!\n", + ) + + +with start_http_server(ServerConfig(), simple_app) as server: + while True: + server.run() +``` + +Running under hosting: + +```python +from localpost.hosting import run_app +from localpost.http import http_server, ServerConfig + +# `simple_app` from the Quick start above +run_app(http_server(ServerConfig(), simple_app)) +``` + +See [`examples/http/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/http/). + +## Key concepts + +- **`ServerConfig`** — host, port, backlog, `select_timeout`, `rw_timeout`, + `keep_alive_timeout`, `max_body_size`. +- **`start_http_server(config, handler)`** — context manager; yields a + `Server` bound to a non-blocking listening socket with a `selectors` + poller. The handler is fixed for the server's lifetime. +- **`HTTPReqCtx`** — per-request context carrying the parsed h11 request, + the raw socket, headers, and `complete(response, body)`. Request bodies + are streamed via `receive(n_bytes)`. +- **`RequestHandler = Callable[[HTTPReqCtx], None]`** — the handler + interface. `Server.run()` dispatches each accepted request to it. +- **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style + variables). `match(uri) → dict | None`. +- **`Routes`** — mutable builder. Accumulate routes via decorators + (`@routes.get("/path")`, `.post`, `.put`, `.delete`, `.patch`, `.add`), + then call `.build()` to compile into a `Router`. +- **`Router`** — immutable, compiled URI-template dispatcher. One regex + alternation over all templates, templates ordered by longest literal + prefix, `Allow` headers pre-rendered. Exposes `.as_handler()` (native + `RequestHandler`) and `.wsgi` (for deployment under Gunicorn / Granian + / etc.). Build via `routes.build()` or `Router.from_routes(routes)`. + +## Sub-modules + +User-facing prose for the bridges and middlewares. The connection +model, threading topologies, and backend rationale live in `docs/design/` +— see the [Design](#design) section. + +### `localpost.http.wsgi` + +`wrap_wsgi(app)` turns a WSGI app into a `RequestHandler`; +`to_wsgi(handler)` turns a `RequestHandler` into a WSGI app. + +### `localpost.http.asgi` + +The async sibling of `localpost.http.wsgi` — bridges between a foreign +async protocol (ASGI 3) and the framework's async request-context shape +(`AsyncHTTPReqCtx`, defined in `localpost.http`). `localpost.http` +itself doesn't ship an async server; this module plugs an +`AsyncRequestHandler` into uvicorn / hypercorn / granian / any ASGI 3 +server. + +`to_asgi(handler, *, max_body_size=1<<20)` wraps an `AsyncRequestHandler` +as an ASGI 3 app. Body bytes are pulled lazily via +`await ctx.receive(size)` (or `aread_body(ctx)` for the whole-body common +case); `Content-Length` is pre-checked against `max_body_size` when +present (413 before dispatch). + +```python +# myapp.py +from localpost.http import Response +from localpost.http.asgi import to_asgi + + +async def hello(ctx): + await ctx.complete(Response(200), b"hi") + + +asgi_app = to_asgi(hello) +``` + +```bash +uvicorn myapp:asgi_app +hypercorn myapp:asgi_app +granian --interface asgi myapp:asgi_app +``` + +For the framework-flavoured surface (decorators, OpenAPI generation, +typed handlers) on top of the same bridge, see +[`localpost.openapi.HttpAsyncApp`](openapi.md#async-flavour-httpasyncapp) +— `app.asgi()` is sugar over `to_asgi(self._build_async_handler())`. + +Cancellation: `ctx.disconnected` flips on `http.disconnect`. SSE +generators / long handlers poll it between events to short-circuit +cleanly. Body-handling contract across transports lives in +[request body handling](../design/request-body-handling.md). + +### `localpost.http.rsgi` + +The Granian-flavoured sibling of `localpost.http.asgi`. Same +`AsyncHTTPReqCtx` Protocol; different wire surface (RSGI exposes +richer per-method calls than ASGI's two-event response dance), and +different deployment topology (Granian is a process supervisor, not +an in-process server). Install: `pip install 'localpost[rsgi]'`. + +`to_rsgi(handler, *, max_body_size=1<<20)` wraps an +`AsyncRequestHandler` for `granian --interface rsgi`. Single eager +`proto.response_bytes` per `complete`; zero-copy +`proto.response_file_range` for `sendfile` when the file has a path; +chunked stream fallback otherwise. + +```python +# myapp.py +from localpost.http import Response +from localpost.http.rsgi import to_rsgi + + +async def hello(ctx): + await ctx.complete(Response(200), b"hi") + + +rsgi_app = to_rsgi(hello) +``` + +```bash +granian --interface rsgi myapp:rsgi_app +``` + +For framework-flavoured deployment, see +[`localpost.openapi.HttpAsyncApp.as_rsgi()`](openapi.md#async-flavour-httpasyncapp). +For deployments where the HTTP app shares a worker process with **other +hosted services** (scheduler / gRPC / custom workers), +[`localpost.hosting.rsgi.HostRSGIApp`](hosting.md#host-as-rsgi-for-granian) +runs the full hosting lifecycle inside each Granian worker. The +asymmetry between uvicorn-as-a-hosted-service and Granian-as-a-supervisor +is covered in +[deployment topologies](../design/deployment-topologies.md). + +### `localpost.http.static` + +`static_handler(root, *, prefix=b"/", cache_control=None, +index="index.html")` builds a `RequestHandler` that serves files under +`root` via `socket.sendfile()` — zero-copy from the page cache to the +socket. Designed for **CDN-fronted deployments**: pair with proper +`Cache-Control` headers and origin sees roughly one hit per file per +edge per cache lifetime, which is why we skip on-the-fly compression +(the CDN handles `gzip` / `br` at the edge). + +Behaviour: + +- **Methods**: `GET` and `HEAD`. Anything else returns 405 + `Allow: GET, HEAD`. +- **Resolution**: percent-decoded URL path (with `prefix` stripped) is + joined under `root`, resolved, and checked with `Path.is_relative_to`. + `..` segments are rejected before resolution. +- **Conditional GET**: strong `ETag` (size + mtime in nanoseconds) plus + `Last-Modified`. `If-None-Match` (with weak comparison) and + `If-Modified-Since` short-circuit to 304 inline on the selector — no + worker hop, no file open. +- **Range**: single byte-range only (`bytes=N-M`, `bytes=N-`, `bytes=-K`). + Multi-range / unparsable falls back to 200 (RFC 7233 §3.1 compliant). + Out-of-bounds → 416 with `Content-Range: bytes */`. +- **Body**: 200 / 206 GET opens the file and calls `ctx.sendfile(...)` + — wrap in `thread_pool_handler` to dispatch the syscall to a worker. + HEAD success / 304 / 416 / 404 / 405 complete inline on the selector. + +A static handler in its own pool, separate from the API pool, so a few +slow downloads can't pin all the API workers: + +```python +from localpost.http import ( + Routes, ServerConfig, http_server, static_handler, thread_pool_handler, +) + +routes = Routes() +# ... register API routes ... + +api = thread_pool_handler(routes.build().as_handler()) +static = thread_pool_handler( + static_handler("/var/www", prefix=b"/static/", + cache_control="public, max-age=31536000, immutable"), +) + +async with api as api_h, static as static_h: + def root(ctx): + return (static_h if ctx.request.path.startswith(b"/static/") else api_h)(ctx) + async with http_server(ServerConfig(), root): + ... +``` + +Both wrappers share the process-wide worker pool — workers are spawned +on demand and reused. See +[`examples/http/static_files.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/http/static_files.py). + +`ctx.sendfile(response, file, offset, count)` is part of the public +`HTTPReqCtx` Protocol and can be used directly for any zero-copy body. +Requires `Content-Length: ` on the response (chunked is rejected); +both backends keep their parser state consistent with what the kernel +writes out-of-band. + +### `localpost.http.compress` + +`compress_handler(inner, *, algorithms=("br","gzip"), min_size=1024, +compressible_types=...)` wraps a `RequestHandler` so eligible +`complete()` responses are compressed — `gzip` (stdlib, always +available) plus `br` (optional via `[http-compress]`). Pair with a JSON +/ HTML / XML API; **not** intended for static files (compression and +zero-copy `sendfile` are at odds). + +Behind a CDN you usually don't need this: the CDN compresses at the +edge from an uncompressed origin. `compress_handler` is for deployments +*not* behind a CDN, or when the CDN doesn't compress (rare). + +The middleware skips compression when any of these hold (response sent +verbatim): + +- `Accept-Encoding` doesn't list any configured `algorithms` with q>0 +- Method is `HEAD` (no body to compress) +- `body is None` or `len(body) < min_size` +- Status is `1xx` / `204` / `304` (no body) or `206` (range — compressing + breaks byte semantics) +- Response already has `Content-Encoding` (other than `identity`) +- Response has `Cache-Control: no-transform` (RFC 9111) +- `Content-Type` main-type is not in `compressible_types` + +When eligible: body is compressed, `Content-Length` is replaced, +`Content-Encoding` is added, and `Accept-Encoding` is merged into +`Vary` (existing `Vary: Cookie` becomes `Vary: Cookie, Accept-Encoding`; +`Vary: *` is left alone). + +`compress_handler` intercepts both `complete(...)` and `stream(...)`. +The middleware decides per-request: + +- One-shot path → compress the whole body in memory; replace + `Content-Length`. +- Streaming path with **no** `Content-Length` declared → wrap the chunk + iterator with an incremental compressor; each input chunk is emitted + compressed + sync-flushed so the decompressor sees each chunk + promptly. The backend auto-frames `Transfer-Encoding: chunked` on + HTTP/1.1. +- Streaming path **with** `Content-Length` → pass through. + +For SSE (`Content-Type: text/event-stream`, in +`DEFAULT_COMPRESSIBLE_TYPES`), each event your generator yields reaches +the client decompressed and parseable by `EventSource` — same approach +nginx uses with `gzip on; gzip_types text/event-stream`. `sendfile` +always passes through uncompressed — composition with the static +handler stays zero-copy. + +Limitations: the one-shot path allocates a compressed buffer per +response (fine for typical JSON; for multi-MB single-shot payloads, +hand `compress_handler` a `stream(response, chunks)` instead). Brotli +is opt-in (`pip install localpost[http-compress]`); if `"br"` is in +`algorithms` without the extra, `compress_handler` raises +`ImportError` at construction time. See +[`examples/http/compressed_api.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/http/compressed_api.py). + +### `localpost.http.flask` + +Native Flask adapter — optional extra `[http-flask]`. `flask_handler(app)` +turns a Flask app into a `RequestHandler`; `flask_server(config, app)` +hosts it as a service (selector-thread, no pool). Bypasses WSGI on both +sides: drives Flask's pipeline directly and streams the Werkzeug +`Response` straight to h11. + +### `localpost.http.router_sentry` / `localpost.http.flask_sentry` + +Sentry tracing wrappers. `sentry_router_handler(router, *, op=...)` +wraps a `Router` in a Sentry transaction per request — transaction +named `"METHOD /books/{id}"` (the URI template, low cardinality) on a +match, or `"METHOD /raw/path"` on a miss. Optional extra +`[http-sentry]`. + +`sentry_flask_handler(app, *, op=...)` wraps the native Flask adapter. +Requires both `[http-flask]` and `[http-sentry]`. Sentry's stock +`FlaskIntegration` ends the transaction when the WSGI `wsgi_app` +returns — *before* the body is iterated. Spans / errors inside a +streaming generator land outside the request transaction (or are +dropped). Because our Flask adapter holds the request context (and the +transaction) open through `response.iter_encoded()`, this fix-pack +version keeps everything on the same transaction. Behaviour +differences from `wsgi_server`: Flask's request context is active +during response-body iteration (a generator returned from a view can +use `flask.request`, `session`, `g` without `@stream_with_context`); +`teardown_request` / `teardown_appcontext` run **after** the body is +fully sent. Adapter touches Werkzeug/Flask internals (stable across +Flask 3.x but not a long-term contract) — use `wsgi_server` for +framework-agnostic WSGI. + +### Hosting integration + +`http_server(config, handler, *, selectors=1, acceptor=False)` is the +`@hosting.service`-decorated wrapper. `wsgi_server(config, app, ...)` +is the same shape for a generic WSGI app. `flask_server(config, app)` +covers the native Flask adapter. `thread_pool_handler(inner)` is an +async CM that yields a `RequestHandler` running `inner` on a shared +worker thread (process-wide pool, workers spawned on demand, no +concurrency cap). + +Threading topology (`selectors=N`, `acceptor=True`) is covered in +[threading topologies](../design/threading-topologies.md). + +## Sync vs. async surface + +`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side +and the terminal write methods. A handler that touches only the core +surface is portable between transports. The full asymmetry table and +why each member differs lives in +[connection model](../design/connection-model.md#sync-vs-async-request-context-surface). + +## Cancellation + +`HTTPReqCtx.disconnected` is a pull-style poll for peer-gone, mirroring +`AsyncHTTPReqCtx.disconnected`. Native backends do a non-blocking +`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket and stick `True` +once seen; the WSGI bridge always returns `False` (no socket handle). + +For sync handlers without `ctx` in scope, `check_cancelled()` raises +`RequestCancelled` if the client disconnected (detected via +non-blocking `MSG_PEEK`) or the hosted service is shutting down. Call +periodically in long-running handlers. + +## Design + +The design rationale lives in `docs/design/`: + +- [Connection model](../design/connection-model.md) — dispatch chain, + two-state TRACKED/BORROWED machine, pull-based disconnect detection, + sync-vs-async asymmetry. +- [Threading topologies](../design/threading-topologies.md) — + `selectors=N` / acceptor topology, composition pattern, three orthogonal + concerns (handler / router / `http_server`). +- [Server backends](../design/server-backends.md) — h11 and httptools + coexistence, why no unified parser Protocol, httptools caveats. +- [Request body handling](../design/request-body-handling.md) — the + `ctx.receive(size)` contract across native sync, WSGI, ASGI, and RSGI. +- [Deployment topologies](../design/deployment-topologies.md) — hosted + services *inside* `run_app` (uvicorn / hypercorn) vs Granian as a + process supervisor that runs the host *inside* its workers. + +The native server is sync-only by design — async transports plug in via +`localpost.http.asgi.to_asgi` rather than growing a parallel async +request path inside this module. + +## See also + +- Examples: [`examples/http/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/http/) +- Server source: [`server_h11.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/server_h11.py) +- Router source: [`router.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/router.py) diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md new file mode 100644 index 0000000..657cdaf --- /dev/null +++ b/docs/modules/openapi.md @@ -0,0 +1,472 @@ +# localpost.openapi + +Type-driven HTTP framework with built-in **OpenAPI 3.2** generation, on top of +[`localpost.http`](http.md). FastAPI-inspired; the differences: + +- **Response shapes are union return types**, not a separate `response_model=` + declaration: + ```python + def get_book(book_id: str) -> Book | NotFound[str]: ... + ``` + Both branches end up in the OpenAPI doc with proper schemas; the runtime + short-circuits to the right status code. +- **OpenAPI-aware middleware.** Middlewares and auth contribute to the + spec themselves (security schemes, extra parameters, extra response codes) + via an `update_doc` hook. There's no second place to declare auth. +- **msgspec-first.** [msgspec](https://jcristharif.com/msgspec/) does request + body decoding, response encoding, and JSON Schema generation. Pydantic + models *and* `attrs.define`'d classes are recognised automatically — but + neither is a runtime dependency of the `openapi` extra; install them + yourself if you want to use them. + +## Install + +```bash +pip install 'localpost[http,openapi]' +``` + +For Pydantic models in handlers: + +```bash +pip install pydantic +``` + +For `attrs` classes in handlers (uses `cattrs` for structuring): + +```bash +pip install 'localpost[openapi-attrs]' +# or, equivalently: +pip install attrs cattrs +``` + +## Quick start + +```python +from dataclasses import dataclass + +from localpost import hosting +from localpost.http import ServerConfig +from localpost.openapi import HttpApp, NotFound, BadRequest, Created + + +@dataclass +class Book: + id: str + title: str + author: str + + +app = HttpApp() + + +@app.get("/hello/{name}") +def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | NotFound[str]: + if book_id != "42": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="HHGTTG", author="Adams") + + +@app.post("/books") +def create_book(book: Book) -> Created[Book]: + return Created(book, headers={"Location": f"/books/{book.id}"}) + + +if __name__ == "__main__": + hosting.run_app(app.service(ServerConfig(port=8000))) +``` + +```bash +curl http://localhost:8000/hello/world +curl http://localhost:8000/openapi.json # OpenAPI 3.2 doc +open http://localhost:8000/docs # Swagger UI +open http://localhost:8000/docs/redoc # ReDoc +open http://localhost:8000/docs/scalar # Scalar +``` + +## Concepts + +### Operations + +Plain Python functions registered with `@app.get(path)`, `.post`, `.put`, +`.delete`, `.patch`. The path follows +[`URITemplate`](http.md) syntax (`/books/{id}`). + +### Argument resolvers + +One per parameter. Picked from the annotation; explicit factories override: + +| Source | Factory | Auto-picked when… | +|---|---|---| +| Path variable | `FromPath()` | param name matches `{name}` in template | +| Query string | `FromQuery()` | scalar parameter not in path, no body type | +| Header | `FromHeader("X-…")` | only via explicit `Annotated[...]` | +| Request body | `FromBody()` | param annotated as `msgspec.Struct` / dataclass / pydantic model / `attrs` class | +| Request ctx | (none) | param annotated as `HTTPReqCtx` | + +Each resolver may short-circuit by returning an `OpResult` (e.g. validation +failure → `BadRequest`). + +### `OpResult` hierarchy + +| Class | Status | Notes | +|---|---|---| +| `Ok[T]` | 200 | Implicit when you return a plain value. | +| `Created[T]` | 201 | | +| `Accepted[T]` | 202 | | +| `NoContent` | 204 | No body. | +| `EventStreamResult[T]` | 200 | SSE stream — see below. | +| `BadRequest[T]` | 400 | | +| `Unauthorized[T]` | 401 | | +| `Forbidden[T]` | 403 | | +| `NotFound[T]` | 404 | | +| `Conflict[T]` | 409 | | +| `UnprocessableEntity[T]` | 422 | | +| `TooManyRequests[T]` | 429 | | +| `InternalServerError[T]` | 500 | | + +Use the *class* in return annotations (`Book | NotFound[str]`) so the OpenAPI +doc picks up the body type per status code. + +### `OpMiddleware` + +A middleware wraps the operation core. It receives the request context and +a `call_next` callable that runs the rest of the chain — and knows how to +describe itself in the OpenAPI doc: + +```python +ApiOperation = Callable[[HTTPReqCtx], OpResult] + + +class OpMiddleware(Protocol): + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: ... + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: ... + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: ... +``` + +- `__call__` runs around the operation. Return `call_next(ctx)` to forward + (optionally post-processing the result), or short-circuit by returning + any other `OpResult`. +- `contribute_root` is called once at spec-build time (typically to register + a `SecurityScheme`). +- `contribute_operation` is called for each operation that the middleware is + attached to (typically to add a 401 response and a `security` requirement). + +Apply app-wide: + +```python +app = HttpApp(middlewares=[my_middleware]) +``` + +or per-operation: + +```python +@app.get("/admin", middlewares=[my_middleware]) +def admin() -> str: ... +``` + +### `@op_middleware` — middlewares as tiny operations + +For the common case, write a middleware the same way you'd write a handler: +declare your inputs (plus the `call_next` parameter), declare your possible +failure responses in the return type, and let the framework do the OpenAPI +bookkeeping: + +```python +from typing import Annotated + +from localpost.http import HTTPReqCtx +from localpost.openapi import ( + ApiOperation, + FromHeader, + OpResult, + TooManyRequests, + op_middleware, +) + + +@op_middleware +def rate_limit( + ctx: HTTPReqCtx, + call_next: ApiOperation, + x_client: Annotated[str, FromHeader("X-Client-Id")], +) -> TooManyRequests[str] | OpResult: + if quota_exhausted(x_client): + return TooManyRequests("Slow down") + return call_next(ctx) + + +@app.get("/expensive", middlewares=[rate_limit]) +def expensive() -> str: ... +``` + +Without writing any spec code, every operation that uses this middleware +gets: +- the `X-Client-Id` header in its `parameters`, +- `429 Too Many Requests` in its `responses` (with the body schema for `str`). + +The return-type union *is* the OpenAPI contract. Bare `OpResult` in the +union is the *passthrough* sentinel and contributes nothing; subclasses +contribute their response code. Middlewares must return an `OpResult` +(typically by calling `call_next(ctx)`); anything else raises `TypeError` +at request time. + +> **SSE caveat.** When the wrapped operation streams an SSE response, +> `call_next` returns an `EventStreamResult` whose body is an iterator. A +> middleware can wrap that iterator (transforming/dropping events) but +> can't post-process headers after streaming has started — they're sent +> before the first event. + +### Server-Sent Events (SSE) + +Return a generator (or any iterator) and the operation auto-promotes to an +SSE stream — `Content-Type: text/event-stream`, chunked transfer encoding, +one event per yielded value: + +```python +from collections.abc import Generator +from dataclasses import dataclass + +from localpost.openapi import HttpApp, Event + + +@dataclass +class Tick: + n: int + + +app = HttpApp() + + +@app.get("/clock") +def clock() -> Generator[Event[Tick]]: + for n in range(60): + yield Event(data=Tick(n=n), id=str(n)) + time.sleep(1) +``` + +Yield bare values for `data:`-only events; yield `Event` instances for +control over `event:` / `id:` / `retry:` / comment fields. The OpenAPI doc +emits `text/event-stream` (with the schema for `Event[Tick]`) instead of +`application/json` for that operation. + +Cancellation: the framework calls `localpost.http.check_cancelled` between +events, so client disconnects (and pool shutdowns) terminate the stream +without leaking workers — provided the app runs under +`thread_pool_handler` (the default for `HttpApp.service(...)`). + +For an iterator you've already constructed, wrap it in `EventStream(...)` +to be explicit; otherwise just return the generator directly. + +### Built-in auth middlewares + +```python +from localpost.openapi import HttpApp, HttpBearerAuth, HttpBasicAuth + +def validate_token(token: str) -> dict | None: + # Return any truthy principal on success, None on failure. + # The principal is stashed on ctx.attrs[] for handlers to read. + return decode_jwt(token) + + +app = HttpApp(middlewares=[HttpBearerAuth(validator=validate_token)]) + + +@app.get("/me") +def me() -> dict: + # Pull the principal from a custom resolver, or just inject the ctx. + ... +``` + +`HttpBearerAuth` registers an HTTP `bearer` security scheme; `HttpBasicAuth` +registers `basic` and sends a `WWW-Authenticate` challenge on 401. Both +attach a 401 response and a `security` requirement to every operation they +cover. `OpenIDConnectAuth` is a follow-up. + +## Hosting + +`HttpApp.service(config)` returns a `localpost.hosting.service` you feed to +`hosting.run_app(...)` or `hosting.serve(...)`. It composes: + +1. A worker pool (`thread_pool_handler`) so user fns run on threads, not the + selector. +2. The HTTP server (`http_server`). + +Pass `selectors=N` and/or `acceptor=True` to use multi-selector topology. + +## Async flavour (`HttpAsyncApp`) + +Parallel to `HttpApp`, like `httpx.Client` and `httpx.AsyncClient`. +Same decorator API, same OpenAPI 3.2 emission, same `OpResult` +hierarchy — handlers are `async def`, middleware is built with +`@async_op_middleware`, and the deployment target is **ASGI** (uvicorn, +hypercorn, or `granian --interface asgi`): + +```python +import sys +from dataclasses import dataclass + +import uvicorn + +from localpost.openapi import HttpAsyncApp, NotFound + + +@dataclass +class Book: + id: str + title: str + + +app = HttpAsyncApp() + + +@app.get("/books/{book_id}") +async def get_book(book_id: str) -> Book | NotFound[str]: + book = await fetch_book(book_id) # your async DB call, etc. + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + +if __name__ == "__main__": + sys.exit(uvicorn.run(app.asgi(), host="127.0.0.1", port=8000)) +``` + +For `localpost.hosting` integration use +`app.service(uvicorn.Config(...))` and feed it to +`hosting.run_app(...)` — same shape as `HttpApp.service(config)` but +running the ASGI app under uvicorn. + +A side-by-side example ports the sync `examples/openapi/app.py` +verbatim: see [`examples/openapi/async_app.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/openapi/async_app.py). + +### What's different + +- **Handlers are async.** Plain `async def` for normal routes; + `async def` generators for SSE. Sync generators are rejected at + request time — `HttpAsyncApp` won't silently bridge a blocking + generator onto the event loop. +- **Middleware is async.** Use `@async_op_middleware` to build them. + Sync `OpMiddleware` instances are rejected at app construction time: + + ```python + from typing import Annotated + + from localpost.openapi import ( + AsyncApiOperation, AsyncHTTPReqCtx, FromHeader, + OpResult, Unauthorized, async_op_middleware, + ) + + + @async_op_middleware + async def require_api_key( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", + ) -> Unauthorized[str] | OpResult: + if x_api_key != "secret": + return Unauthorized("Invalid or missing X-API-Key") + return await call_next(ctx) + ``` +- **Auth.** `AsyncHttpBearerAuth` and `AsyncHttpBasicAuth` mirror their + sync siblings; the validator may be sync or async. +- **Body buffering.** The ASGI bridge pre-buffers the request body + before dispatch (matches the JSON-API common case the sync flavour + is tuned for) so the same `FromBody` resolver works in both + flavours. Cap the size with `HttpAsyncApp(max_body_size=N)` (default + 1 MiB; `-1` disables). Streaming uploads via `ctx.receive(...)` are + on the follow-up list. +- **Resolvers.** `FromPath`, `FromQuery`, `FromHeader`, `FromBody` are + shared — they only read sync attributes (`request`, `body`, `attrs`) + off the ctx, so the same factories work in both apps. + +### Deployment + +Two flavours, depending on which target you ship to. + +**ASGI** — `app.asgi()` returns a plain ASGI 3 callable; any ASGI +server runs it: + +```python +# myapp.py +from localpost.openapi import HttpAsyncApp + +app = HttpAsyncApp() +# … register routes … +asgi_app = app.asgi() +``` + +```bash +uvicorn myapp:asgi_app +hypercorn myapp:asgi_app +granian --interface asgi myapp:asgi_app +``` + +**RSGI (Granian native)** — `app.as_rsgi()` returns an RSGI application: + +```python +rsgi_app = app.as_rsgi() +``` + +```bash +granian --interface rsgi myapp:rsgi_app +``` + +The RSGI bridge (`localpost.http.to_rsgi`) is wire-format-only — the +same handler chain serves both ASGI and RSGI traffic. Granian's RSGI +gives a single eager `response_bytes` per response (vs ASGI's +two-event start+body), zero-copy `sendfile` via +`response_file_range`, and direct `async for` body reads in streaming +mode. Requires the `[rsgi]` extra (`pip install 'localpost[rsgi]'`). + +**Hosted apps under Granian** — when the HTTP app shares its worker +process with other hosted services (scheduler, gRPC, custom workers), +deploy through `localpost.hosting.rsgi.HostRSGIApp` instead, which runs the +full hosting lifecycle inside each Granian worker: + +```python +from localpost.hosting.rsgi import HostRSGIApp +from localpost.scheduler import every, scheduled_task + +@scheduled_task(every(seconds=5)) +async def heartbeat(): ... + + +rsgi_app = HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, +) + +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +See [hosting](hosting.md#host-as-rsgi-for-granian) for the topology details. + +## Design notes + +See the in-tree design doc for rationale and a layout map: keep this README +focused on user-facing concepts. + +## Sub-modules + +| Module | Provides | +|---|---| +| `app.py` | `HttpApp`, registration, hosting entrypoint, built-in `/openapi.json` + `/docs` UIs (sync) | +| `operation.py` | `Operation`: sync runtime closure | +| `_operation_core.py` | Shared type-level core (signature parsing, return-type → response-shape, doc helpers, response encoding) — used by both flavours | +| `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories — shared between sync and async | +| `results.py` | `OpResult` hierarchy (incl. `EventStreamResult`) | +| `middleware.py` | `OpMiddleware` protocol, `@op_middleware`, `ApiOperation` (sync) | +| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth middlewares (sync) | +| `aio/` | Async flavour: `HttpAsyncApp`, `AsyncOperation`, `AsyncOpMiddleware`, `@async_op_middleware`, `AsyncHttpBearerAuth`, `AsyncHttpBasicAuth`, `AsyncHTTPReqCtx`, ASGI bridge | +| `sse.py` | `Event`, `EventStream`, encoder — Server-Sent Events (sync drive) | +| `spec.py` | OpenAPI 3.2 dataclasses | +| `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | +| `pydantic.py` | Explicit pydantic helpers (auto-detection in `FromBody` works without this) | +| `_docs.py` | HTML for Swagger UI / ReDoc / Scalar (CDN-loaded) | diff --git a/docs/modules/scheduler.md b/docs/modules/scheduler.md new file mode 100644 index 0000000..d666b2b --- /dev/null +++ b/docs/modules/scheduler.md @@ -0,0 +1,130 @@ +# localpost.scheduler + +Composable in-process task scheduler. Tasks are triggered by **conditions** +(time intervals, cron expressions, completion of another task), and triggers +are built up with operators — `//` to compose middleware, `>>` to extend a +trigger's own middleware pipeline. Tasks publish their outputs as `Result[T]`, +so downstream tasks can subscribe. + +Use it when you need to schedule background work inside the same process — for +example, refresh a cached dataframe every minute. For distributed / +persistent tasks, use Celery, APScheduler with a DB store, etc. + +## Install + +```bash +pip install localpost[scheduler] # human-readable periods (pytimeparse2, humanize) +pip install localpost[scheduler,cron] # also the cron() trigger (croniter) +``` + +## Quick start + +```python +import random +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first + + +@scheduled_task(every("3s") // delay((0, 1))) +async def task1(): + return random.randint(1, 22) + + +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): + print(f"task1 emitted: {task1_result}") + + +if __name__ == "__main__": + run_app(task1, task2) +``` + +Cron: + +```python +from localpost.scheduler import delay, scheduled_task +from localpost.scheduler.cond.cron import cron + + +@scheduled_task(cron("*/1 * * * *") // delay((0, 10))) +async def job(): + print("running") +``` + +See [`examples/scheduler/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/scheduler/) +for more (`cancellation.py`, `failing_tasks.py`, `fastapi_lifespan.py`, +`finite_task.py`, `serve_sync.py`, `fastdepends.py`). + +## Key concepts + +- **`ScheduledTask[T, R]`** — the runtime object: a handler paired with a + trigger factory. Publishes a `Result[R]` stream that others can subscribe to. +- **`ScheduledTaskTemplate[T]`** — a pre-bound trigger factory waiting for a + handler. Composable with `//` (compose trigger middleware) and `>>` + (extend the trigger's own middleware tuple). `every(...)`, `after(...)`, + `after_all(...)`, and `cron(...)` all return templates. +- **`TriggerFactory[T]`** — `Callable[[TaskGroup, EventView], + AbstractAsyncContextManager[AsyncIterator[T]]]`. Under the hood, a trigger is + an async iterator of events fired inside the task's lifetime. +- **Trigger middleware** — an async generator that consumes one `AsyncIterator` + and yields another. `delay` and `take_first` are built-in; write your own the + same way. +- **`Result[T]`** — output envelope (`Ok` or failure). `after()` filters + successes and forwards the `T`; `after_all()` forwards every result. +- **Hosting integration** — both individual tasks and `Scheduler` instances + are `ServiceF`s. Pass them to `localpost.hosting.run_app(...)` (entry point, + signal handling) or `localpost.hosting.serve(...)` (async CM, e.g. for + embedding in a FastAPI lifespan). + +## Writing a custom trigger + +A trigger is a frozen dataclass / callable that, given a `TaskGroup` and a +`shutting_down` event, returns an async context manager yielding an +`AsyncIterator[T]`. Pattern (adapted from `_cond.py:Every`): + +```python +from dataclasses import dataclass, replace +from contextlib import asynccontextmanager + +@dataclass(frozen=True, slots=True) +class MyTrigger[T]: + middlewares: tuple[TriggerMiddleware, ...] = () + + def __rshift__(self, mw): # >> adds a middleware + return replace(self, middlewares=self.middlewares + (mw,)) + + @asynccontextmanager + async def __call__(self, tg, shutting_down): + async with apply_middlewares(my_source(), self.middlewares) as stream: + yield stream +``` + +Then wrap it: `my_trigger = ScheduledTaskTemplate(MyTrigger())`. + +## Writing a custom middleware + +A middleware is a regular async generator: + +```python +from collections.abc import AsyncIterator +from localpost._utils import maybe_closing + +def skip_every_other(): + async def middleware[T](events: AsyncIterator[T]) -> AsyncIterator[T]: + async with maybe_closing(events): + i = 0 + async for event in events: + if i % 2 == 0: + yield event + i += 1 + return middleware +``` + +Use it via `every("1s") // skip_every_other()`. + +## See also + +- Examples: [`examples/scheduler/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/scheduler/) +- Cron source: [`cond/cron.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/scheduler/cond/cron.py) +- Built-in triggers: [`_cond.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/scheduler/_cond.py) +- Core: [`_scheduler.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/scheduler/_scheduler.py) diff --git a/examples/app_host/app.py b/examples/app_host/app.py deleted file mode 100755 index 96168e0..0000000 --- a/examples/app_host/app.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python - -import time - -import anyio -from anyio import CancelScope - -from localpost.hosting import AppHost, ServiceLifetimeManager -from localpost.hosting.middlewares import shutdown_timeout - -app = AppHost() -app.use(shutdown_timeout(5)) - - -@app.service -def a_sync_service(service_lifetime: ServiceLifetimeManager): - print(f"{a_sync_service.__name__} started") - service_lifetime.set_started() - while not service_lifetime.shutting_down: - print(f"{a_sync_service.__name__} running") - time.sleep(1) - print(f"{a_sync_service.__name__} stopped") - - -@app.service -async def an_async_func(_): - print(f"{an_async_func.__name__} started") - while True: - print(f"{an_async_func.__name__} running") - await anyio.sleep(1) - - -@app.service -async def an_async_service(service_lifetime: ServiceLifetimeManager): - print(f"{an_async_service.__name__} started") - service_lifetime.set_started() - while not service_lifetime.shutting_down: - print(f"{an_async_service.__name__} running") - await anyio.sleep(1) - - -@app.service -async def a_graceful_async_service(service_lifetime: ServiceLifetimeManager): - print(f"{a_graceful_async_service.__name__} started") - with CancelScope() as scope: - service_lifetime.set_started(graceful_shutdown_scope=scope) - while not scope.cancel_called: - print(f"{a_graceful_async_service.__name__} running") - await anyio.sleep(1) - print(f"{a_graceful_async_service.__name__} gracefully shut down") - - -# @host.service() -# async def an_anyio_func(*, task_status: TaskStatus[None] = TASK_STATUS_IGNORED): -# print(f"{an_anyio_func.__name__} started") -# task_status.started() -# while True: -# print(f"{an_anyio_func.__name__} running") -# await anyio.sleep(1) -# -# -# @host.service() -# async def a_graceful_anyio_func(*, task_status: TaskStatus[CancelScope] = TASK_STATUS_IGNORED): -# print(f"{a_graceful_anyio_func.__name__} started") -# with CancelScope() as scope: -# task_status.started(scope) -# while not scope.cancel_called: -# print(f"{a_graceful_anyio_func.__name__} running") -# await anyio.sleep(1) -# print(f"{a_graceful_anyio_func.__name__} gracefully shut down") - - -if __name__ == "__main__": - import logging - - import localpost - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - # app.root_service //= shutdown_timeout(5) - - exit(localpost.run(app)) diff --git a/examples/app_host/failing_service.py b/examples/app_host/failing_service.py deleted file mode 100755 index 12508af..0000000 --- a/examples/app_host/failing_service.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python - -import time - -import anyio - -from localpost.hosting import AppHost, ServiceLifetimeManager - -app = AppHost() - - -@app.service -def a_sync_service(service_lifetime: ServiceLifetimeManager): - print(f"{a_sync_service.__name__} started") - service_lifetime.set_started() - # Do not do infinite loops in a sync service function, as there is no way to interrupt it from the host. - # Always check the service_lifetime.shutting_down event to see if the service should stop. - while not service_lifetime.shutting_down: - print(f"{a_sync_service.__name__} running") - time.sleep(1) - raise RuntimeError("This is a test error from _sync_") - print(f"{a_sync_service.__name__} stopped") - - -@app.service -async def an_async_func(): - print(f"{an_async_func.__name__} started") - try: - while True: - print(f"{an_async_func.__name__} running") - await anyio.sleep(1) - # raise RuntimeError("This is a test error from _async_") - finally: - print(f"{an_async_func.__name__} done") - - -if __name__ == "__main__": - import logging - - import localpost - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - exit(localpost.run(app)) diff --git a/examples/app_host/http_app.py b/examples/app_host/http_app.py deleted file mode 100755 index ed4f283..0000000 --- a/examples/app_host/http_app.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python - -from datetime import timedelta - -import anyio -from fastapi import FastAPI -from starlette.responses import JSONResponse - -from localpost.hosting import AppHost -from localpost.hosting.http import UvicornService -from localpost.scheduler import delay, every, scheduled_task - -app = AppHost() - -http_api = FastAPI() -app.service(UvicornService.for_app(http_api)) - - -@app.service -async def background_job(): - print("Background job started") - try: - while True: - print("Background job running") - await anyio.sleep(1) - finally: - print("Background job done") - - -@app.service -@scheduled_task(every(timedelta(seconds=3)) // delay((1, 5))) -async def heavy_periodic_task(): - print("Some periodic work") - - -@http_api.get("/predict") -async def predict(): - return {"result": "some"} - - -@http_api.get("/health") -async def health_check() -> JSONResponse: - return JSONResponse(app.status) - - -if __name__ == "__main__": - import logging - - import localpost - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - exit(localpost.run(app)) diff --git a/tests/consumers/__init__.py b/examples/di/__init__.py similarity index 100% rename from tests/consumers/__init__.py rename to examples/di/__init__.py diff --git a/examples/di/basic.py b/examples/di/basic.py new file mode 100644 index 0000000..ab3d2af --- /dev/null +++ b/examples/di/basic.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass + +from icecream import ic + +from localpost.di._services import ServiceRegistry + + +@dataclass +class Config: + host: str + port: int + + +class Server: + def __init__(self, config: Config): + self.config = config + + +def main(): + services = ServiceRegistry() + services.register_instance(Config(host="127.0.0.1", port=8080)) + services.register(Server) + + with services.app_scope() as service_provider: + server = service_provider.resolve(Server) + ic(server) + + +if __name__ == "__main__": + main() diff --git a/examples/di/basic_cleanup.py b/examples/di/basic_cleanup.py new file mode 100644 index 0000000..4dbb32c --- /dev/null +++ b/examples/di/basic_cleanup.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass + +from icecream import ic + +from localpost.di._services import AppContext, ServiceProvider, ServiceRegistry + + +@dataclass +class Config: + host: str + port: int + + db_dsn: str + + +# ctx and sp are just to show how to use things +def create_db_conn_pool(conf: Config, ctx: AppContext, sp: ServiceProvider): + db = DBConnectionPool(conf.db_dsn) + try: + yield db + finally: + db.close() + + +class DBConnectionPool: + def __init__(self, dsn: str): + self.dsn = dsn + + def close(self): + pass + + +class Server: + def __init__(self, config: Config, db: DBConnectionPool): + self.config = config + self.db = db + + def close(self): + pass + + +def main(): + services = ServiceRegistry() + services.register_instance(Config(host="127.0.0.1", port=8080, db_dsn=":memory:")) + services.register(DBConnectionPool, create_db_conn_pool) + services.register(Server) + + with services.app_scope() as service_provider: + server = service_provider.resolve(Server) + ic(server, server.db) + + +if __name__ == "__main__": + main() diff --git a/examples/di/flask_app.py b/examples/di/flask_app.py new file mode 100644 index 0000000..8922439 --- /dev/null +++ b/examples/di/flask_app.py @@ -0,0 +1,72 @@ +import signal +from collections.abc import Generator +from dataclasses import dataclass + +from cheroot.wsgi import Server +from flask import Flask, Request + +from localpost.di._services import ServiceRegistry, service_provider +from localpost.di.flask import RequestContext, init_app + + +@dataclass +class Config: + db_dsn: str + + +class DBConnectionPool: + def __init__(self, dsn: str): + self.dsn = dsn + + def close(self): + print(f" Closing DB pool ({self.dsn})") + + +def create_db_pool(config: Config) -> Generator[DBConnectionPool]: + pool = DBConnectionPool(config.db_dsn) + print(f" Created DB pool ({pool.dsn})") + try: + yield pool + finally: + pool.close() + + +class UserRepository: + """Request-scoped: depends on the DB pool and the current Flask request.""" + + def __init__(self, db: DBConnectionPool, req: Request): + self.db = db + self.request_path = req.path + + def get_current_user(self) -> str: + return f"user (from {self.request_path}, db={self.db.dsn})" + + +services = ServiceRegistry() +services.register_instance(Config(db_dsn=":memory:")) +services.register(DBConnectionPool, create_db_pool) +services.register(UserRepository, scope=RequestContext) + +app = Flask(__name__) + + +@app.get("/") +def index() -> str: + repo = service_provider.resolve(UserRepository) + return f"Hello, {repo.get_current_user()}!\n" + + +def main(): + server = Server(("127.0.0.1", 8080), app) + signal.signal(signal.SIGINT, lambda *_: server.stop()) + signal.signal(signal.SIGTERM, lambda *_: server.stop()) + + with services.app_scope() as app_scope: + init_app(app, services, app_scope) + print("Listening on http://127.0.0.1:8080") + server.start() + print("App scope closed, all resources cleaned up.") + + +if __name__ == "__main__": + main() diff --git a/examples/host/channel.py b/examples/host/channel.py index ebcd042..c73aefe 100755 --- a/examples/host/channel.py +++ b/examples/host/channel.py @@ -2,39 +2,38 @@ import anyio -from localpost.hosting import Host, hosted_service -from localpost.scheduler import delay, every, scheduled_task, take_first +from localpost.hosting import ServiceLifetime, run_app, service -channel_writer, channel_reader = anyio.create_memory_object_stream[str]() +@service +def channel_example(): + channel_writer, channel_reader = anyio.create_memory_object_stream[str]() -@hosted_service -async def print_channel(): - """ - A hosted service, to read the channel in the background. - """ - async with channel_reader as channel: - async for message in channel: - print(f"Message received: {message}") + async def svc(lt: ServiceLifetime): + async def reader(): + async with channel_reader as ch: + async for message in ch: + print(f"Message received: {message}") + async def writer(): + for i in range(5): + await anyio.sleep(1) + await channel_writer.send(f"hello #{i}") + await channel_writer.aclose() -@scheduled_task(every("3s") // delay((1, 3)) // take_first(3)) -async def scheduled_background_task(): - print("Scheduled work here, writing to the channel...") - await channel_writer.send("hello from the background task!") + lt.tg.start_soon(reader) + lt.tg.start_soon(writer) + lt.set_started() + await lt.shutting_down.wait() - -# Stop printing after the scheduler is done -host = Host(print_channel >> scheduled_background_task) + return svc if __name__ == "__main__": import logging - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(host)) + run_app(channel_example()) diff --git a/examples/host/click_cli.py b/examples/host/click_cli.py new file mode 100755 index 0000000..e0f1aa4 --- /dev/null +++ b/examples/host/click_cli.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Run a Click command as a hosted service. + +For a one-shot CLI you can just call ``hello()`` directly. Wrapping it as a +hosted service earns its keep when the command is composed with sibling +services (a background worker, a metrics endpoint, ...) under one host. +""" + +import click + +from localpost.hosting import run_app +from localpost.hosting.services.click import click_cmd + + +@click.command() +@click.option("--name", default="world") +def hello(name: str) -> None: + click.echo(f"Hello, {name}!") + + +if __name__ == "__main__": + run_app(click_cmd(hello)) diff --git a/examples/host/finite_service.py b/examples/host/finite_service.py index bbfee0f..3edcd8e 100755 --- a/examples/host/finite_service.py +++ b/examples/host/finite_service.py @@ -1,27 +1,27 @@ #!/usr/bin/env python - import time -from localpost.hosting import ServiceLifetimeManager, hosted_service +from localpost.hosting import ServiceLifetime, run_app, service + +@service +def a_sync_service(): + def svc(lt: ServiceLifetime): + print("Service started") + lt.set_started() + print("Service running") + time.sleep(5) + print("Service is done") + # The host should also stop after this point, as all the services have stopped -@hosted_service -def a_sync_service(service_lifetime: ServiceLifetimeManager): - print("Service started") - service_lifetime.set_started() - print("Service running") - time.sleep(5) - print("Service is done") - # The host should also stop after this point, as all the services have stopped + return svc if __name__ == "__main__": import logging - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(a_sync_service)) + run_app(a_sync_service()) diff --git a/examples/http/__init__.py b/examples/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/http/app_server.py b/examples/http/app_server.py new file mode 100644 index 0000000..7b088ed --- /dev/null +++ b/examples/http/app_server.py @@ -0,0 +1,22 @@ +import json + +from localpost import hosting +from localpost.http import HTTPReqCtx, ServerConfig, read_body +from localpost.http.app import HttpApp + +app = HttpApp() + + +@app.get("/{name}") +def hello(name: str): + return f"Hello, {name}!" + + +@app.post("/{name}/profile") +def update_user_profile(ctx: HTTPReqCtx, name: str): + profile = json.loads(read_body(ctx)) + return {"updated_for": name, "profile": profile} + + +if __name__ == "__main__": + hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) diff --git a/examples/http/compressed_api.py b/examples/http/compressed_api.py new file mode 100644 index 0000000..70fd62f --- /dev/null +++ b/examples/http/compressed_api.py @@ -0,0 +1,82 @@ +"""JSON API with response compression. + +Run:: + + uv run examples/http/compressed_api.py + + curl -H 'Accept-Encoding: gzip' --compressed http://localhost:8000/items + curl -H 'Accept-Encoding: br' --compressed http://localhost:8000/items + curl -i http://localhost:8000/items # uncompressed (no Accept-Encoding) + +Brotli requires:: + + pip install localpost[http-compress] + +Compression only intercepts ``ctx.complete(...)`` responses; streaming +paths and ``sendfile`` pass through unchanged. Behind a CDN you usually +don't need this — the CDN compresses at the edge from an uncompressed +origin. +""" + +from __future__ import annotations + +import json +import logging + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + Response, + Routes, + ServerConfig, + compress_handler, + http_server, + thread_pool_handler, +) +from localpost.threadtools import WorkerExecutor + + +def _items(ctx: HTTPReqCtx) -> None: + # Big-ish JSON so the response crosses the default ``min_size=1024``. + payload = {"items": [{"id": i, "name": f"item-{i}", "tag": "x" * 32} for i in range(64)]} + body = json.dumps(payload).encode("utf-8") + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body if ctx.request.method != b"HEAD" else None, + ) + + +def build_router(): + routes = Routes() + routes.get("/items")(_items) + return routes.build() + + +@service +async def app(): + # Brotli first if available, else gzip. + try: + h = compress_handler(build_router().as_handler(), algorithms=("br", "gzip")) + except ImportError: + h = compress_handler(build_router().as_handler(), algorithms=("gzip",)) + + config = ServerConfig(host="127.0.0.1", port=8000) + with WorkerExecutor() as ex: + async with thread_pool_handler(h, ex) as wrapped: + async with http_server(config, wrapped): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(app()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/flask_app_server.py b/examples/http/flask_app_server.py new file mode 100644 index 0000000..0062870 --- /dev/null +++ b/examples/http/flask_app_server.py @@ -0,0 +1,70 @@ +"""Flask app served via the native Flask adapter — streaming without ``@stream_with_context``. + +Run:: + + uv run --group dev-http examples/http/flask_app_server.py + + curl http://localhost:8000/hello/world + curl http://localhost:8000/stream/world # uses flask.request during streaming + +Key difference from the WSGI example: the ``/stream/...`` view returns a +generator that reads ``flask.request.headers`` while yielding — and we did +**not** wrap it in ``@stream_with_context``. It works because the native +Flask handler (:func:`localpost.http.flask.flask_handler`) keeps Flask's +request context active through response-body iteration. +""" + +from __future__ import annotations + +import logging + +from flask import Flask, Response +from flask import request as flask_request + +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler +from localpost.http.flask import flask_handler +from localpost.threadtools import WorkerExecutor + + +def build_app() -> Flask: + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + ua = flask_request.headers.get("User-Agent", "unknown") + return f"Hello, {name}! (UA={ua})\n" + + @app.route("/stream/") + def stream(name: str): + def generate(): + yield f"Hello, {name}! " + # flask.request is still live here — no stream_with_context needed. + yield f"Your User-Agent: {flask_request.headers.get('User-Agent', '?')}\n" + + return Response(generate(), mimetype="text/plain") + + @app.teardown_request + def _log_teardown(exc): + # Under the native Flask handler this runs AFTER the body is fully sent. + app.logger.info("teardown_request (exc=%r)", exc) + + return app + + +@service +async def app_svc(): + config = ServerConfig(host="127.0.0.1", port=8000) + with WorkerExecutor() as ex: + async with thread_pool_handler(flask_handler(build_app()), ex) as wrapped: + async with http_server(config, wrapped): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(app_svc()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/middleware_basic_auth.py b/examples/http/middleware_basic_auth.py new file mode 100644 index 0000000..9401eeb --- /dev/null +++ b/examples/http/middleware_basic_auth.py @@ -0,0 +1,115 @@ +"""Basic-auth middleware example. + +Demonstrates a :data:`localpost.http.Middleware` that validates the +``Authorization: Basic …`` header before dispatching to the inner handler. +Unauthenticated requests get a 401 on the selector thread — no worker hop. + +Run:: + + uv run examples/http/middleware_basic_auth.py + + curl http://localhost:8000/hello # 401 + curl -u alice:secret http://localhost:8000/hello # 200 +""" + +from __future__ import annotations + +import base64 +import logging + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + RequestHandler, + Response, + Routes, + ServerConfig, + compose, + http_server, + route_match, + thread_pool_handler, +) +from localpost.threadtools import WorkerExecutor + +# ----------- credentials store (replace with a real check) ---------------- + +_USERS: dict[str, str] = {"alice": "secret", "bob": "pass"} + + +def _check_basic_auth(authorization: bytes | None) -> bool: + if not authorization or not authorization.startswith(b"Basic "): + return False + try: + decoded = base64.b64decode(authorization[6:]).decode("latin-1") + username, _, password = decoded.partition(":") + return _USERS.get(username) == password + except Exception: # noqa: BLE001 + return False + + +# ----------- middleware --------------------------------------------------- + +_UNAUTHORIZED = Response( + status_code=401, + headers=[(b"www-authenticate", b'Basic realm="localpost"'), (b"content-length", b"0")], +) + + +def basic_auth(inner: RequestHandler) -> RequestHandler: + """Reject requests without valid Basic credentials before dispatching.""" + + def wrapped(ctx: HTTPReqCtx) -> None: + auth_header = next( + (v for k, v in ctx.request.headers if k == b"authorization"), + None, + ) + if not _check_basic_auth(auth_header): + ctx.complete(_UNAUTHORIZED, b"") + return + inner(ctx) + + return wrapped + + +# ----------- routes ------------------------------------------------------- + + +def _hello(ctx: HTTPReqCtx) -> None: + name = route_match(ctx).path_args.get("name", "world") + body = f"Hello, {name}!\n".encode() + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], + ), + body, + ) + + +def build_router() -> RequestHandler: + routes = Routes() + routes.get("/hello")(_hello) + routes.get("/hello/{name}")(_hello) + handler = routes.build().as_handler() + return compose(basic_auth)(handler) + + +# ----------- app ---------------------------------------------------------- + + +@service +async def app(): + config = ServerConfig(host="127.0.0.1", port=8000) + with WorkerExecutor() as ex: + async with thread_pool_handler(build_router(), ex) as h: + async with http_server(config, h): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(app()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/middleware_rate_limit.py b/examples/http/middleware_rate_limit.py new file mode 100644 index 0000000..16ac637 --- /dev/null +++ b/examples/http/middleware_rate_limit.py @@ -0,0 +1,125 @@ +"""Per-IP sliding-window rate-limiter middleware example. + +Demonstrates a :data:`localpost.http.Middleware` that counts requests per +client IP within a rolling time window and returns 429 when the budget is +exceeded. The check runs on the selector thread pre-body — no worker hop +for rejected requests. + +Run:: + + uv run examples/http/middleware_rate_limit.py + + # Spam quickly to trigger the limiter: + for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\\n" http://localhost:8000/; done +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections import defaultdict, deque + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + Middleware, + RequestHandler, + Response, + Routes, + ServerConfig, + compose, + http_server, + thread_pool_handler, +) +from localpost.threadtools import WorkerExecutor + +# ----------- rate-limit state (selector-thread-safe via lock) ------------- + +_TOO_MANY = Response( + status_code=429, + headers=[(b"content-length", b"0"), (b"retry-after", b"1")], +) + + +class _SlidingWindowCounter: + """Thread-safe per-key sliding-window request counter.""" + + def __init__(self, max_requests: int, window_seconds: float) -> None: + self._max = max_requests + self._window = window_seconds + self._timestamps: dict[str, deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + + def is_allowed(self, key: str) -> bool: + now = time.monotonic() + cutoff = now - self._window + with self._lock: + bucket = self._timestamps[key] + while bucket and bucket[0] < cutoff: + bucket.popleft() + if len(bucket) >= self._max: + return False + bucket.append(now) + return True + + +# ----------- middleware --------------------------------------------------- + + +def rate_limit(max_requests: int = 10, window_seconds: float = 1.0) -> Middleware: + """Limit each remote IP to ``max_requests`` within ``window_seconds``.""" + counter = _SlidingWindowCounter(max_requests, window_seconds) + + def middleware(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx: HTTPReqCtx) -> None: + client_ip = (ctx.remote_addr or "").rpartition(":")[0] or "unknown" + if not counter.is_allowed(client_ip): + ctx.complete(_TOO_MANY, b"") + return + inner(ctx) + + return wrapped + + return middleware + + +# ----------- routes ------------------------------------------------------- + + +def _root(ctx: HTTPReqCtx) -> None: + body = b"ok\n" + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], + ), + body, + ) + + +def build_handler() -> RequestHandler: + routes = Routes() + routes.get("/")(_root) + return compose(rate_limit(max_requests=10, window_seconds=1.0))(routes.build().as_handler()) + + +# ----------- app ---------------------------------------------------------- + + +@service +async def app(): + config = ServerConfig(host="127.0.0.1", port=8000) + with WorkerExecutor() as ex: + async with thread_pool_handler(build_handler(), ex) as h: + async with http_server(config, h): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(app()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py new file mode 100644 index 0000000..c5a5f4c --- /dev/null +++ b/examples/http/multithread_server.py @@ -0,0 +1,85 @@ +"""Multi-threaded HTTP app. + +Run:: + + uv run examples/http/multithread_server.py + + curl http://localhost:8000/ + curl http://localhost:8000/hello/world + curl http://localhost:8000/slow # sleeps; spam a few of these in parallel + +The whole router is wrapped with :func:`localpost.http.thread_pool_handler` so +every matched route runs on a worker thread from the shared pool. +SIGINT / SIGTERM signals shutdown; in-flight handlers see ``RequestCancelled`` +on the next ``check_cancelled`` call and the pool drains before the process +exits. +""" + +from __future__ import annotations + +import logging +import threading +import time + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + Response, + Router, + Routes, + ServerConfig, + http_server, + route_match, + thread_pool_handler, +) +from localpost.threadtools import WorkerExecutor + + +def _emit(ctx: HTTPReqCtx, body: bytes) -> None: + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + + +def _root(ctx: HTTPReqCtx) -> None: + _emit(ctx, b"hello from localpost\n") + + +def _hello(ctx: HTTPReqCtx) -> None: + name = route_match(ctx).path_args["name"] + _emit(ctx, f"Hello, {name}! (thread={threading.current_thread().name})\n".encode()) + + +def _slow(ctx: HTTPReqCtx) -> None: + time.sleep(1.0) # exercises concurrency: several of these run in parallel + _emit(ctx, f"done on thread={threading.current_thread().name}\n".encode()) + + +def build_router() -> Router: + routes = Routes() + routes.get("/")(_root) + routes.get("/hello/{name}")(_hello) + routes.get("/slow")(_slow) + return routes.build() + + +@service +async def app(): + config = ServerConfig(host="127.0.0.1", port=8000) + with WorkerExecutor() as ex: + async with thread_pool_handler(build_router().as_handler(), ex) as wrapped: + async with http_server(config, wrapped): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(app()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py new file mode 100644 index 0000000..a5a34ff --- /dev/null +++ b/examples/http/sentry_router_server.py @@ -0,0 +1,86 @@ +"""Router app with Sentry tracing. + +Run:: + + SENTRY_DSN='https://...@.../...' uv run --group dev-sentry examples/http/sentry_router_server.py + + curl http://localhost:8000/books/42 # transaction "GET /books/{id}" (route source) + curl http://localhost:8000/nope # transaction "GET /nope" (url source) + +If ``SENTRY_DSN`` is unset, Sentry is initialised in disabled mode — the app +still works, transactions are just no-ops. Useful to demo the wiring without +sending data. +""" + +from __future__ import annotations + +import logging +import os +import time + +import sentry_sdk + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + Response, + Routes, + ServerConfig, + http_server, + route_match, + thread_pool_handler, +) +from localpost.http.router_sentry import sentry_router_handler +from localpost.threadtools import WorkerExecutor + + +def _emit(ctx: HTTPReqCtx, body: bytes) -> None: + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + + +def _root(ctx: HTTPReqCtx) -> None: + _emit(ctx, b"hello\n") + + +def _get_book(ctx: HTTPReqCtx) -> None: + book_id = route_match(ctx).path_args["id"] + # Spans inside the handler land on the request transaction. + with sentry_sdk.start_span(op="db.query", name="select book"): + time.sleep(0.01) + _emit(ctx, f"book={book_id}\n".encode()) + + +def build_router(): + routes = Routes() + routes.get("/")(_root) + routes.get("/books/{id}")(_get_book) + return routes.build() + + +@service +async def app(): + handler = sentry_router_handler(build_router()) + config = ServerConfig(host="127.0.0.1", port=8000) + with WorkerExecutor() as ex: + async with thread_pool_handler(handler, ex) as wrapped: + async with http_server(config, wrapped): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), # None → Sentry runs in disabled mode + traces_sample_rate=1.0, + ) + run_app(app()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/simple_server.py b/examples/http/simple_server.py new file mode 100644 index 0000000..979c879 --- /dev/null +++ b/examples/http/simple_server.py @@ -0,0 +1,23 @@ +import logging + +from localpost.http import HTTPReqCtx, Response, ServerConfig, start_http_server + + +def _main(): + def simple_app(ctx: HTTPReqCtx): + ctx.complete( + Response( + status_code=200, + headers=[(b"Content-Type", b"text/plain"), (b"Content-Length", b"14")], + ), + b"Hello, World!\n", + ) + + with start_http_server(ServerConfig(), simple_app) as server: + while True: + server.run() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + _main() diff --git a/examples/http/sse_compressed.py b/examples/http/sse_compressed.py new file mode 100644 index 0000000..e0613b3 --- /dev/null +++ b/examples/http/sse_compressed.py @@ -0,0 +1,91 @@ +"""Server-Sent Events (SSE) with response compression. + +Run:: + + uv run examples/http/sse_compressed.py + +Open another terminal:: + + curl -i --no-buffer http://localhost:8000/events # uncompressed + curl -i --no-buffer -H 'Accept-Encoding: gzip' --compressed http://localhost:8000/events + curl -i --no-buffer -H 'Accept-Encoding: br' --compressed http://localhost:8000/events + +The handler emits one event per second. With ``Accept-Encoding`` set, +``compress_handler`` switches to streaming compression and sync-flushes +each event so the client sees them as they're produced (no buffering). +``Transfer-Encoding: chunked`` is auto-framed by the HTTP backend on +HTTP/1.1. + +In a browser:: + + const es = new EventSource('http://localhost:8000/events'); + es.onmessage = (e) => console.log(e.data); + +The browser decompresses ``Content-Encoding: gzip`` / ``br`` transparently +before the ``EventSource`` parser sees the stream. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Iterator + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + Response, + ServerConfig, + check_cancelled, + compress_handler, + http_server, + streaming_pool_handler, +) +from localpost.threadtools import WorkerExecutor + + +def _events(ctx: HTTPReqCtx) -> None: + def ticks() -> Iterator[bytes]: + n = 0 + while True: + check_cancelled() + yield f"data: tick {n} at {time.time():.3f}\n\n".encode() + n += 1 + time.sleep(1) + + ctx.stream( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/event-stream"), + (b"cache-control", b"no-cache"), + ], + ), + ticks(), + ) + + +@service +async def app(): + # Streaming SSE handlers run on a streaming pool — body is not + # pre-buffered, the worker holds the borrowed conn for the duration. + with WorkerExecutor() as ex: + async with streaming_pool_handler(_events, ex) as inner: + # Same compress_handler call as for JSON APIs; the middleware + # picks the streaming path automatically when the response has no + # Content-Length and the content type is in the allowlist (which + # text/event-stream is, by default). + wrapped = compress_handler(inner, algorithms=("br", "gzip")) + + config = ServerConfig(host="127.0.0.1", port=8000) + async with http_server(config, wrapped): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(app()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/static_files.py b/examples/http/static_files.py new file mode 100644 index 0000000..47eef32 --- /dev/null +++ b/examples/http/static_files.py @@ -0,0 +1,84 @@ +"""Static-file server alongside an API on the same worker pool. + +Run:: + + uv run examples/http/static_files.py + + curl http://localhost:8000/api/hello + curl http://localhost:8000/static/ + curl -I http://localhost:8000/static/ # HEAD: just headers + curl -H 'Range: bytes=0-99' http://localhost:8000/static/ + +The static handler uses ``socket.sendfile()`` for the body (zero-copy). +Both API and static handlers go through the same ``thread_pool_handler`` +— workers come from the process-wide pool and grow on demand. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + RequestHandler, + Response, + Routes, + ServerConfig, + http_server, + static_handler, + thread_pool_handler, +) +from localpost.threadtools import WorkerExecutor + + +def _hello(ctx: HTTPReqCtx) -> None: + body = b"hello from the API\n" + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + + +def build_api() -> RequestHandler: + routes = Routes() + routes.get("/api/hello")(_hello) + return routes.build().as_handler() + + +@service +async def app(): + root = Path(os.environ.get("STATIC_ROOT", ".")) + config = ServerConfig(host="127.0.0.1", port=8000) + + static = static_handler( + root, + prefix=b"/static/", + cache_control="public, max-age=3600", + ) + api = build_api() + + def dispatch(ctx: HTTPReqCtx) -> None: + (static if ctx.request.path.startswith(b"/static/") else api)(ctx) + + with WorkerExecutor() as ex: + async with thread_pool_handler(dispatch, ex) as wrapped: + async with http_server(config, wrapped): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(app()) + + +if __name__ == "__main__": + main() diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py new file mode 100644 index 0000000..37fd3cd --- /dev/null +++ b/examples/http/wsgi_app_server.py @@ -0,0 +1,62 @@ +"""Serve a Flask (WSGI) app under ``localpost.http`` via the WSGI bridge. + +Run:: + + uv run --group dev-http examples/http/wsgi_app_server.py + + curl http://localhost:8000/hello/world + curl http://localhost:8000/hello-stream/world +""" + +from __future__ import annotations + +import logging + +from flask import Flask +from flask import request as flask_request +from flask.helpers import stream_with_context # type: ignore[import-untyped] + +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler, wrap_wsgi +from localpost.threadtools import WorkerExecutor + + +def build_app() -> Flask: + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + return f"Hello, {name}! Your User-Agent is: {user_agent}\n" + + @app.route("/hello-stream/") + def hello_stream(name: str): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + + def generate(): + yield f"Hello, {name}! " + yield f"Your User-Agent is: {user_agent}\n" + + return stream_with_context(generate()) + + return app + + +@service +async def wsgi_app_service(): + config = ServerConfig(host="127.0.0.1", port=8000) + # WSGI views block on response-body iteration, so wrap with a thread pool + # to serve more than one request at a time. + with WorkerExecutor() as ex: + async with thread_pool_handler(wrap_wsgi(build_app()), ex) as wrapped: + async with http_server(config, wrapped): + yield + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + run_app(wsgi_app_service()) + + +if __name__ == "__main__": + main() diff --git a/examples/openapi/__init__.py b/examples/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/openapi/app.py b/examples/openapi/app.py new file mode 100644 index 0000000..bbf97d4 --- /dev/null +++ b/examples/openapi/app.py @@ -0,0 +1,144 @@ +"""Working example for ``localpost.openapi.HttpApp``. + +Run:: + + uv run python examples/openapi/app.py + +Then:: + + curl http://localhost:8000/hello/world + curl http://localhost:8000/books/42 + curl -X POST http://localhost:8000/books \\ + -H 'Content-Type: application/json' \\ + -d '{"id": "1", "title": "Dune", "author": "Frank Herbert"}' + curl -N http://localhost:8000/books/42/pages # streams as SSE + +Docs UIs: + http://localhost:8000/docs (Swagger UI) + http://localhost:8000/docs/redoc (ReDoc) + http://localhost:8000/docs/scalar (Scalar) +""" + +import time +from collections.abc import Generator +from dataclasses import dataclass +from typing import Annotated + +from localpost import hosting +from localpost.http import HTTPReqCtx, ServerConfig +from localpost.openapi import ( + ApiOperation, + BadRequest, + Created, + Event, + FromHeader, + HttpApp, + OpResult, + TooManyRequests, + Unauthorized, + op_middleware, + spec, +) + + +@dataclass +class Book: + id: str + title: str + author: str + + +@dataclass +class BookPage: + book_id: str + number: int + content: str + + +_LIBRARY: dict[str, Book] = { + "42": Book(id="42", title="The Hitchhiker's Guide to the Galaxy", author="Douglas Adams"), +} + + +app = HttpApp(info=spec.Info(title="Library API", version="1.0.0")) + + +# A middleware as a tiny operation: the framework reads the input +# (``X-API-Key`` header) and the failure response (``Unauthorized``) from +# this signature and threads them into every operation that uses it. +@op_middleware +def require_api_key( + ctx: HTTPReqCtx, + call_next: ApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", +) -> Unauthorized[str] | OpResult: + if x_api_key != "secret": + return Unauthorized("Invalid or missing X-API-Key") + return call_next(ctx) + + +# Another tiny middleware — every-N-requests rate limiter, just for the demo. +_call_counter = {"n": 0} + + +@op_middleware +def rate_limit( + ctx: HTTPReqCtx, + call_next: ApiOperation, +) -> TooManyRequests[str] | OpResult: + _call_counter["n"] += 1 + if _call_counter["n"] % 10 == 0: + return TooManyRequests("Slow down") + return call_next(ctx) + + +@app.get("/hello/{name}") +def hello(name: str) -> str | BadRequest[str]: + """Greet someone.""" + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | None: + """Look up a book by ID.""" + book = _LIBRARY.get(book_id) + # Optional[Book] is treated like "a book or NotFound" + return book + + +@app.post("/books", middlewares=[require_api_key, rate_limit]) +def create_book(book: Book) -> Created[Book]: + """Add a new book to the library. + + Requires ``X-API-Key: secret``; subject to a (silly) every-10th-request + rate limit. Both middlewares' input headers and failure responses + appear in the OpenAPI doc automatically — no spec annotations needed + here. + """ + _LIBRARY[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + +@app.get("/books/{book_id}/pages") +def stream_pages(book_id: str) -> Generator[Event[BookPage]]: + """Stream the book's pages as Server-Sent Events. + + The Generator return type auto-promotes to ``text/event-stream``. + """ + book = _LIBRARY.get(book_id) + if book is None: + # SSE handlers can only return events; surface "not found" as a + # single error event rather than a 404. (Mid-stream switching to + # JSON requires returning before the first yield — different + # design.) + yield Event(data=BookPage(book_id=book_id, number=0, content="not found"), event="error") + return + for n in range(1, 6): + yield Event(data=BookPage(book_id=book_id, number=n, content=f"page {n} of {book.title}"), id=str(n)) + time.sleep(0.5) + + +if __name__ == "__main__": + hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) diff --git a/examples/openapi/async_app.py b/examples/openapi/async_app.py new file mode 100644 index 0000000..456423f --- /dev/null +++ b/examples/openapi/async_app.py @@ -0,0 +1,164 @@ +"""Working example for ``localpost.openapi.HttpAsyncApp`` (async / ASGI). + +Mirrors ``examples/openapi/app.py`` route-for-route, but every handler +is ``async def`` and the deployment target is ASGI (uvicorn here; same +ASGI app works under hypercorn or ``granian --interface asgi``). + +Run:: + + uv run python examples/openapi/async_app.py + +Then:: + + curl http://localhost:8000/hello/world + curl http://localhost:8000/books/42 + curl -X POST http://localhost:8000/books \\ + -H 'Content-Type: application/json' \\ + -H 'X-API-Key: secret' \\ + -d '{"id": "1", "title": "Dune", "author": "Frank Herbert"}' + curl -N http://localhost:8000/books/42/pages # streams as SSE + +Docs UIs: + http://localhost:8000/docs (Swagger UI) + http://localhost:8000/docs/redoc (ReDoc) + http://localhost:8000/docs/scalar (Scalar) +""" + +import asyncio +import sys +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Annotated + +import uvicorn + +from localpost.openapi import ( + AsyncApiOperation, + AsyncHTTPReqCtx, + BadRequest, + Created, + Event, + FromHeader, + HttpAsyncApp, + OpResult, + TooManyRequests, + Unauthorized, + async_op_middleware, + spec, +) + + +@dataclass +class Book: + id: str + title: str + author: str + + +@dataclass +class BookPage: + book_id: str + number: int + content: str + + +_LIBRARY: dict[str, Book] = { + "42": Book(id="42", title="The Hitchhiker's Guide to the Galaxy", author="Douglas Adams"), +} + + +app = HttpAsyncApp(info=spec.Info(title="Library API (async)", version="1.0.0")) + + +# A middleware-as-a-tiny-operation: same shape as the sync flavour, but +# ``async def`` and built with ``@async_op_middleware``. The framework +# still reads the input header and the failure response from the +# signature and threads them into every operation that uses it. +@async_op_middleware +async def require_api_key( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", +) -> Unauthorized[str] | OpResult: + if x_api_key != "secret": + return Unauthorized("Invalid or missing X-API-Key") + return await call_next(ctx) + + +# Every-N-requests rate limiter, just for the demo. +_call_counter = {"n": 0} + + +@async_op_middleware +async def rate_limit( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, +) -> TooManyRequests[str] | OpResult: + _call_counter["n"] += 1 + if _call_counter["n"] % 10 == 0: + return TooManyRequests("Slow down") + return await call_next(ctx) + + +@app.get("/hello/{name}") +async def hello(name: str) -> str | BadRequest[str]: + """Greet someone.""" + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +async def get_book(book_id: str) -> Book | None: + """Look up a book by ID. + + ``Book | None`` is the implicit "a book or NotFound" — the framework + promotes a ``None`` return into a 404, and the OpenAPI doc declares + both branches. + """ + return _LIBRARY.get(book_id) + + +@app.post("/books", middlewares=[require_api_key, rate_limit]) +async def create_book(book: Book) -> Created[Book]: + """Add a new book to the library. + + Requires ``X-API-Key: secret``; subject to a (silly) every-10th-request + rate limit. Both middlewares' input headers and failure responses + appear in the OpenAPI doc automatically. + """ + _LIBRARY[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + +@app.get("/books/{book_id}/pages") +async def stream_pages(book_id: str) -> AsyncIterator[Event[BookPage]]: + """Stream the book's pages as Server-Sent Events. + + The ``AsyncIterator`` return type auto-promotes to ``text/event-stream``. + ``HttpAsyncApp`` rejects sync generators here — use ``async def`` + generators so I/O between yields stays on the event loop. + """ + book = _LIBRARY.get(book_id) + if book is None: + # Same caveat as the sync example: SSE handlers can only yield + # events; "not found" surfaces as a single error event. + yield Event(data=BookPage(book_id=book_id, number=0, content="not found"), event="error") + return + for n in range(1, 6): + yield Event(data=BookPage(book_id=book_id, number=n, content=f"page {n} of {book.title}"), id=str(n)) + await asyncio.sleep(0.5) + + +def main() -> int: + # ``app.asgi()`` returns a plain ASGI 3 callable — works under any + # ASGI server. For ``localpost.hosting`` integration use + # ``app.service(uvicorn.Config(...))`` instead and feed it to + # ``hosting.run_app(...)``; that wires uvicorn into the lifecycle + # alongside other LocalPost services. + uvicorn.run(app.asgi(), host="127.0.0.1", port=8000, log_level="info") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/openapi/wsgi_app.py b/examples/openapi/wsgi_app.py new file mode 100644 index 0000000..9d23fea --- /dev/null +++ b/examples/openapi/wsgi_app.py @@ -0,0 +1,78 @@ +"""Deploy ``localpost.openapi.HttpApp`` under a WSGI server (Gunicorn / uWSGI / Werkzeug). + +Run with Gunicorn:: + + uv pip install gunicorn + uv run gunicorn examples.openapi.wsgi_app:app -b 127.0.0.1:8000 + +Or with the stdlib's reference WSGI server (no extra dep):: + + uv run python examples/openapi/wsgi_app.py + +Then:: + + curl http://localhost:8000/hello/world + curl http://localhost:8000/openapi.json + curl -N http://localhost:8000/feed # SSE stream — chunks flushed per event + +Notes: + +- ``HttpApp.as_wsgi()`` returns a standard WSGI callable. Worker + concurrency is the WSGI server's responsibility — ``thread_pool_handler`` + does not apply. +- SSE / streaming via ``ctx.stream`` is handed to the WSGI server as a + lazy iterator: events flush per yield, no extra threads. +- ``check_cancelled()`` is a no-op under WSGI; a long-running stream + surfaces client disconnects as ``BrokenPipeError`` on the next yield. +""" + +from collections.abc import Iterator +from dataclasses import dataclass + +from localpost.openapi import Event, HttpApp, NotFound + + +@dataclass +class Book: + id: str + title: str + + +_books: dict[str, Book] = { + "42": Book(id="42", title="The Hitchhiker's Guide to the Galaxy"), +} + + +app = HttpApp() + + +@app.get("/hello/{name}") +def hello(name: str) -> str: + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | NotFound[str]: + book = _books.get(book_id) + if book is None: + return NotFound(f"book not found: {book_id}") + return book + + +@app.get("/feed") +def feed() -> Iterator[Event[str]]: + """Lazy SSE — each ``yield`` lands on the wire as the WSGI server iterates.""" + for i in range(5): + yield Event(data=f"event-{i}") + + +# WSGI entry point — gunicorn examples.openapi.wsgi_app:wsgi_app +wsgi_app = app.as_wsgi() + + +if __name__ == "__main__": + from wsgiref.simple_server import make_server + + with make_server("127.0.0.1", 8000, wsgi_app) as srv: + print("Serving on http://127.0.0.1:8000 (Ctrl-C to stop)") + srv.serve_forever() diff --git a/examples/scheduler/app.py b/examples/scheduler/app.py index bfcae25..6ad904c 100755 --- a/examples/scheduler/app.py +++ b/examples/scheduler/app.py @@ -1,18 +1,12 @@ #!/usr/bin/env python - import logging import random -from localpost.flow import skip_first -from localpost.scheduler import Scheduler, after, delay, every, scheduled_task - -scheduler = Scheduler() +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first -@scheduler.task(every("3s") // delay((0, 1))) -# @scheduler.task -# @scheduled_task(every("3s") // delay((0, 1))) -# async def task1(_): +@scheduled_task(every("3s") // delay((0, 1))) async def task1(): """ A simple repeating task that returns a random number. @@ -21,21 +15,17 @@ async def task1(): return random.randint(1, 22) -# @scheduler.task(after(task1) >> skip_first(3)) -@scheduled_task(after(task1) >> skip_first(3)) -async def task2(task1_result: str): +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): """ A task that runs after task1 and prints its result. """ - task1.shutdown() print(f"task2 here! task1 result: {task1_result}") if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(scheduler)) + run_app(task1, task2) diff --git a/examples/scheduler/cancellation.py b/examples/scheduler/cancellation.py index 47f852c..eac1547 100755 --- a/examples/scheduler/cancellation.py +++ b/examples/scheduler/cancellation.py @@ -1,13 +1,11 @@ #!/usr/bin/env python - import logging from asyncio import CancelledError from datetime import timedelta import anyio -from localpost.hosting import hosted_service -from localpost.hosting.middlewares import shutdown_timeout +from localpost.hosting import run_app from localpost.scheduler import every, scheduled_task @@ -15,10 +13,11 @@ async def long_async_task(): print("long_async_task is triggered!") try: - # On a normal shutdown (Ctrl+C), the scheduler will just wait for the current task execution to finish + # On a normal shutdown (Ctrl+C), the scheduler will wait for the trigger to signal stop, + # then the task loop ends gracefully. await anyio.sleep(15) except CancelledError: - # Only in case of forced shutdown (second Ctrl+C (results to Host.stop()) or the shutdown timeout) + # In case of forced shutdown (e.g. second Ctrl+C) print("Forced shutdown detected!") raise finally: @@ -26,16 +25,9 @@ async def long_async_task(): print("long_async_task has finished!") -# host = Host(long_async_task) -# host.root_service //= shutdown_timeout(1) -host = hosted_service(long_async_task) // shutdown_timeout(1) - - if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(host)) + run_app(long_async_task) diff --git a/examples/scheduler/cron.py b/examples/scheduler/cron.py index 2843024..250a175 100755 --- a/examples/scheduler/cron.py +++ b/examples/scheduler/cron.py @@ -2,6 +2,7 @@ import logging +from localpost.hosting import run_app from localpost.scheduler import delay, scheduled_task from localpost.scheduler.cond.cron import cron @@ -13,10 +14,8 @@ async def cron_job(): if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(cron_job)) + run_app(cron_job) diff --git a/examples/scheduler/failing_tasks.py b/examples/scheduler/failing_tasks.py index 8a10d67..f9232ad 100755 --- a/examples/scheduler/failing_tasks.py +++ b/examples/scheduler/failing_tasks.py @@ -3,6 +3,7 @@ import logging from datetime import timedelta +from localpost.hosting import run_app from localpost.scheduler import Scheduler, delay, every, take_first scheduler = Scheduler() @@ -21,10 +22,8 @@ def a_sync_task(): if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(scheduler)) + run_app(scheduler) diff --git a/examples/scheduler/fastapi_lifespan.py b/examples/scheduler/fastapi_lifespan.py index ed38b06..454edd9 100755 --- a/examples/scheduler/fastapi_lifespan.py +++ b/examples/scheduler/fastapi_lifespan.py @@ -5,6 +5,7 @@ from fastapi import FastAPI +from localpost.hosting import serve from localpost.scheduler import Scheduler, delay, every scheduler = Scheduler() @@ -17,9 +18,8 @@ async def heavy_background_task(): @asynccontextmanager async def lifespan(_: FastAPI): - async with scheduler.aserve(): # Scheduler in the same thread, same event loop + async with serve(scheduler): # Scheduler in the same thread, same event loop yield - scheduler.shutdown() app = FastAPI(lifespan=lifespan) diff --git a/examples/scheduler/fastdepends.py b/examples/scheduler/fastdepends.py index 83fa3b9..f2e3838 100755 --- a/examples/scheduler/fastdepends.py +++ b/examples/scheduler/fastdepends.py @@ -6,6 +6,7 @@ from fast_depends import Depends, inject +from localpost.hosting import run_app from localpost.scheduler import delay, every, scheduled_task @@ -24,10 +25,8 @@ def print_task( if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(print_task)) + run_app(print_task) diff --git a/examples/scheduler/finite_task.py b/examples/scheduler/finite_task.py index 2f73c08..61f020a 100755 --- a/examples/scheduler/finite_task.py +++ b/examples/scheduler/finite_task.py @@ -1,26 +1,24 @@ #!/usr/bin/env python - import logging import random from datetime import timedelta +from localpost.hosting import run_app from localpost.scheduler import delay, every, scheduled_task, take_first -@scheduled_task(every(timedelta(seconds=3)) // take_first(3) // delay((0, 3))) -async def task1(): +@scheduled_task(every(timedelta(seconds=3)) // delay((0, 3)) // take_first(3)) +def task1(): """ - A simple repeating task that returns a random number. + Fires three times (with jitter) and then the process exits on its own. """ print("task1 running!") return random.randint(1, 22) # Not used if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(task1)) + run_app(task1) diff --git a/examples/scheduler/serve_sync.py b/examples/scheduler/serve_sync.py index 69d1551..88fbca7 100755 --- a/examples/scheduler/serve_sync.py +++ b/examples/scheduler/serve_sync.py @@ -2,6 +2,9 @@ import time +from anyio.from_thread import start_blocking_portal + +from localpost.hosting import serve from localpost.scheduler import Scheduler, delay, every scheduler = Scheduler() @@ -13,14 +16,20 @@ async def task1(): def main(): - # Start the scheduler in a _separate_ thread, with its own event loop - with scheduler.serve(): - try: - while True: - time.sleep(1) - print("Main thread is running...") - except KeyboardInterrupt: - scheduler.shutdown() + # Drive the async hosting layer from sync code: portal runs the event loop + # in a background thread; ``wrap_async_context_manager`` exposes + # ``serve(scheduler)`` as a regular ``with`` block. ``lt.shutdown()`` is + # already cross-thread safe. + with start_blocking_portal() as portal: + with portal.wrap_async_context_manager(serve(scheduler)) as lt: + try: + while True: + time.sleep(1) + print("Main thread is running...") + except KeyboardInterrupt: + pass + finally: + lt.shutdown() print("Main thread is done, exiting the app") diff --git a/justfile b/justfile index 2dd124b..a4060ea 100755 --- a/justfile +++ b/justfile @@ -1,46 +1,31 @@ #!/usr/bin/env -S just --justfile -default: - just --list +doctor: + brew install uv oha ty pre-commit vulture actionslint + uv tool install griffe deps: uv sync --all-groups --all-extras deps-upgrade: - uv sync --all-groups --all-extras --upgrade - -check: check-style types + uv lock --upgrade + uv sync --all-groups --all-extras -[doc("Check types (using both PyRight and MyPy)")] +[doc("Check types (using ty)")] types: - -pyright --pythonpath $(which python) \ - localpost - -mypy --pretty --strict-bytes --python-executable $(which python) \ - localpost + -ty check localpost -[doc("Check types (using both PyRight and MyPy)")] +[doc("Check types strictly (using ty)")] types-strict: - -pyright --pythonpath $(which python) localpost - -mypy --pretty \ - --python-executable $(which python) \ - --strict-bytes \ - --warn-unreachable \ - localpost + -ty check localpost [doc("Check types (including examples and tests)")] types-all: types - -pyright --pythonpath $(which python) \ - examples tests - -mypy --pretty --strict-bytes --python-executable $(which python) \ - examples tests + -ty check examples tests [doc("Check that the public API is correctly typed")] type-coverage: - pyright --pythonpath $(which python) --verifytypes localpost localpost/* - -check-style: - ruff check localpost - ruff check examples tests + -basedpyright --pythonpath "$(which python)" --verifytypes localpost --ignoreexternal localpost/* format: ruff check --fix localpost @@ -50,15 +35,147 @@ format-all: format ruff check --fix examples tests ruff format examples tests -[doc("Inverse dependency tree for a package, to understand why it is installed")] -why package: - uv tree --invert --package {{ package }} +check file: + -ruff check --fix {{ file }} + -ty check {{ file }} -test: - pytest --cov-report=term --cov-report=xml --cov-branch --cov -v +tests: + pytest --cov-report=term --cov-report=xml --cov -v unit-tests: - pytest -m "not integration" --cov-report=term --cov-report=xml --cov-branch --cov -v + pytest -m "not integration" --cov-report=term --cov -v integration-tests: pytest -m "integration" -n auto -v + +[doc("Set up all venvs in the bench matrix (.venv-bench/)")] +bench-deps: + uv run python -m benchmarks.macro._setup + +[doc("Refresh lock and re-sync all bench-matrix venvs")] +bench-deps-upgrade: + uv lock --upgrade + uv run python -m benchmarks.macro._setup + +[doc("Run macro HTTP benchmarks (oha-driven, requires `brew install oha`)")] +bench-http *args: + uv run --group bench --group dev-http \ + python -m benchmarks.macro.http.runner {{ args }} + +[doc("Quick PR-time HTTP bench: representative subset of stacks")] +bench-http-quick *args: + just bench-http --group quick --duration 10 {{ args }} + +[doc("Compare Flask across all server backends")] +bench-http-flask *args: + just bench-http --filter app=flask {{ args }} + +[doc("Compare LocalPost variants only (h11/httptools, selectors, inline)")] +bench-http-localpost *args: + just bench-http --group localpost {{ args }} + +[doc("Run macro OpenAPI-framework benchmarks (LocalPost vs FastAPI vs flask-openapi3)")] +bench-openapi *args: + uv run --group bench --group bench-openapi \ + python -m benchmarks.macro.openapi.runner {{ args }} + +[doc("Run micro-benchmarks (router, URI template) via pytest-benchmark")] +bench-micro *args: + uv run --group bench pytest benchmarks/micro/ \ + --benchmark-only \ + -o python_functions='bench_*' \ + {{ args }} + +[doc("Inverse dependency tree for a package, to understand why it is installed")] +why package: + uv tree --invert --package {{ package }} + +[doc("Serve docs locally with live reload")] +docs-serve: + uv run --all-groups --all-extras zensical serve + +[doc("Build docs to ./site")] +docs-build: + uv run --all-groups --all-extras zensical build + +[doc("Find unused code with vulture (config in pyproject.toml). Pass extra args to override.")] +deadcode *args: + vulture {{ args }} + +# --------------------------------------------------------------------------- +# Release pre-flight +# +# Workflow: +# just release-check # current type coverage + API BC vs previous stable tag +# +# just release X.Y.Z # (see below) +# --------------------------------------------------------------------------- + +# Latest tag matching strictly ``vX.Y.Z`` (no ``b1`` / ``rc1`` / ``.dev0`` suffix). +previous_stable_tag := `git tag --sort=-v:refname --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1` + +[doc("Show API breaking changes vs [base] (default: previous stable tag) using griffe")] +api-diff base=previous_stable_tag: + #!/usr/bin/env bash + set -euo pipefail + if [ -z "{{ base }}" ]; then + echo "no previous stable tag found; pass [base] explicitly" >&2 + exit 1 + fi + echo "▸ Public API vs {{ base }} (griffe check, breaking changes only)" + griffe check localpost -a "{{ base }}" -s . || true + +[doc("Pre-release report: current type coverage + API BC vs [base]")] +release-check base=previous_stable_tag: + @just type-coverage + @echo + @just api-diff {{ base }} + +# --------------------------------------------------------------------------- +# Release automation +# +# Workflow: +# just release 0.6.0 # bump to 0.6.0, commit, push, open draft GH release +# -> triggers .github/workflows/pypi-publish.yaml +# just release-post 0.7.0 # bump to 0.7.0.dev0, commit, push +# --------------------------------------------------------------------------- + +[doc("Cut a release: bump version, commit, push, open draft GH release with CHANGELOG notes")] +release version: + #!/usr/bin/env bash + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + echo "working tree dirty; commit or stash first" >&2 + exit 1 + fi + if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then + echo "not on main; checkout main first" >&2 + exit 1 + fi + notes=$(awk -v v="{{ version }}" '$0 ~ "^## \\[" v "\\]" {flag=1; next} /^## \[/ {flag=0} flag' CHANGELOG.md) + if [ -z "$notes" ]; then + echo "no [{{ version }}] section in CHANGELOG.md" >&2 + exit 1 + fi + uv version {{ version }} + git commit -am "release: {{ version }}" + git push origin main + gh release create "v{{ version }}" --target main --draft --title "v{{ version }}" --notes "$notes" + echo + echo "Draft release created. Review on GitHub and click Publish to trigger the PyPI workflow." + +[doc("Open the next dev cycle: bump pyproject to .dev0, commit, push")] +release-post next: + #!/usr/bin/env bash + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + echo "working tree dirty; commit or stash first" >&2 + exit 1 + fi + if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then + echo "not on main; checkout main first" >&2 + exit 1 + fi + uv version {{ next }}.dev0 + git commit -am "chore: bump version to {{ next }}.dev0" + git push origin main diff --git a/localpost/.gitignore b/localpost/.gitignore deleted file mode 100644 index 34695c8..0000000 --- a/localpost/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Autogenerated on build -__meta__.py diff --git a/localpost/__init__.py b/localpost/__init__.py index 7152ac8..7de81b8 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -1,10 +1,19 @@ +import logging +from importlib.metadata import PackageNotFoundError, version + from ._debug import debug -from ._run import arun, run +from ._portal import Portal +from ._utils import Result try: - from .__meta__ import version as __version__ # noqa -except ImportError: + __version__ = version("localpost") +except PackageNotFoundError: __version__ = "dev" -__all__ = ["__version__", "arun", "run", "debug"] +__all__ = ["Portal", "Result", "__version__", "debug"] + + +# Set up logging according to the best practices: +# https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library +logging.getLogger("localpost").addHandler(logging.NullHandler()) diff --git a/localpost/_debug.py b/localpost/_debug.py index f2a9a6f..49f5243 100644 --- a/localpost/_debug.py +++ b/localpost/_debug.py @@ -1,18 +1,31 @@ from contextlib import AbstractContextManager +from localpost._utils import unwrap_exc -class DebugState(AbstractContextManager[None]): + +class DebugState(AbstractContextManager[None, None]): def __init__(self): self._entered = 0 - def __bool__(self): + def __bool__(self) -> bool: return self._entered > 0 def __enter__(self) -> None: self._entered += 1 - def __exit__(self, _, __, ___) -> None: + async def __aenter__(self) -> None: + return self.__enter__() + + def __exit__(self, exc_type, exc_value: BaseException | None, traceback) -> None: self._entered -= 1 + if exc_value and self._entered == 0: + source_exc = unwrap_exc(exc_value) + if source_exc != exc_value: + # Re-raise the original exception for better debugging + raise source_exc from source_exc.__cause__ + + async def __aexit__(self, exc_type, exc_value: BaseException | None, traceback) -> None: + return self.__exit__(exc_type, exc_value, traceback) debug = DebugState() diff --git a/localpost/_portal.py b/localpost/_portal.py new file mode 100644 index 0000000..60f854f --- /dev/null +++ b/localpost/_portal.py @@ -0,0 +1,77 @@ +"""Thread-aware view over :class:`anyio.from_thread.BlockingPortal`. + +Holds the loop thread's ident, captured at construction (the call site of +``Portal(...)`` *must* be on the loop thread). Lets callers schedule work on +the portal's loop without having to know whether they're already on it — +``run_sync`` does the right dispatch in either direction, and ``run_async`` +adds a clean deadlock guard for blocking awaits. +""" + +from __future__ import annotations + +import functools +import threading +from collections.abc import Awaitable, Callable +from typing import final + +from anyio.from_thread import BlockingPortal + + +@final +class Portal: + """Wraps :class:`BlockingPortal` with loop-thread awareness. + + Construct on the loop thread (``Portal(portal)`` snapshots the current + thread's ident as the loop thread). + """ + + def __init__(self, portal: BlockingPortal) -> None: + self._p = portal + self._loop_tid = threading.get_ident() + + @property + def raw(self) -> BlockingPortal: + """The underlying :class:`BlockingPortal`. Escape hatch for AnyIO + ecosystem methods (``wrap_async_context_manager``, etc.). + """ + return self._p + + @property + def same_thread(self) -> bool: + """``True`` iff the current thread is the portal's loop thread.""" + return threading.get_ident() == self._loop_tid + + def run_sync[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + """Run a sync ``fn`` on the portal's loop and return its result. + + On the loop thread the call is direct; off-loop it routes through + :meth:`BlockingPortal.call`. Either way the call completes + synchronously from the caller's POV. + """ + if self.same_thread: + return fn(*args, **kwargs) + return self._p.call(functools.partial(fn, **kwargs), *args) + + def run_async[**P, R]( + self, + fn: Callable[P, Awaitable[R]], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + """Run an async ``fn`` on the portal's loop and block this thread + until it returns. + + Off-loop only: from the loop thread we'd be blocking the loop while + waiting for itself to make progress, so :class:`RuntimeError` is + raised instead of deadlocking. + """ + if self.same_thread: + raise RuntimeError("Deadlock: synchronous wait on the portal's loop thread") + return self._p.start_task_soon(functools.partial(fn, **kwargs), *args).result() diff --git a/localpost/_run.py b/localpost/_run.py deleted file mode 100644 index 2b99391..0000000 --- a/localpost/_run.py +++ /dev/null @@ -1,51 +0,0 @@ -import logging -import threading - -import anyio -from anyio import open_signal_receiver - -from ._utils import HANDLED_SIGNALS, cancellable_from, choose_anyio_backend -from .hosting import AbstractHost, Host, HostedServiceFunc - -logger = logging.getLogger("localpost") - - -def _ensure_host(target: AbstractHost | HostedServiceFunc) -> AbstractHost: - if isinstance(target, AbstractHost): - return target - return Host(target) - - -def run(target: AbstractHost | HostedServiceFunc) -> int: - """ - Run the target host (or service) until it stops or is interrupted by a signal. - """ - return anyio.run(arun, target, **choose_anyio_backend()) - - -async def arun(target: AbstractHost | HostedServiceFunc) -> int: - """ - Run the target host (or service) until it stops or is interrupted by a signal. - """ - if threading.current_thread() is not threading.main_thread(): - raise RuntimeError("Signals can only be installed on the main thread") - - host = _ensure_host(target) - - @cancellable_from(host.stopped) - async def handle_signals(): - with open_signal_receiver(*HANDLED_SIGNALS) as signals: - async for _ in signals: - if not host.shutting_down: # First Ctrl+C (or other termination method) - logger.info("Shutting down...") - host.shutdown() - continue - # Ctrl+C again - logger.warning("Forced shutdown") - host.stop() - break - - async with host.aserve(): - await handle_signals() - - return host.exit_code diff --git a/localpost/_utils.py b/localpost/_utils.py index 45315e6..1fe3f5b 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -9,38 +9,32 @@ import sys import typing from collections.abc import Awaitable, Callable, Coroutine, Generator, Iterable -from contextlib import AbstractAsyncContextManager, AbstractContextManager +from contextlib import AbstractAsyncContextManager, AbstractContextManager, contextmanager +from contextvars import ContextVar from datetime import timedelta from functools import wraps from typing import ( Any, Final, - Generic, - ParamSpec, + NotRequired, Protocol, - TypeAlias, + Self, TypedDict, - Union, + TypeGuard, cast, final, overload, ) import anyio -from anyio import CancelScope, WouldBlock, create_task_group, from_thread, to_thread -from anyio.abc import TaskGroup, TaskStatus -from anyio.lowlevel import checkpoint -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream, MemoryObjectStreamState -from typing_extensions import NotRequired, Self, TypeGuard, TypeVar - -if sys.version_info >= (3, 11): - from builtins import ExceptionGroup # noqa -else: - from exceptiongroup import ExceptionGroup - -T = TypeVar("T", default=Any) -P = ParamSpec("P") -R = TypeVar("R") +from anyio import ( + CancelScope, + CapacityLimiter, + create_task_group, + from_thread, + to_thread, +) +from anyio.abc import TaskGroup # Sentinel object, to indicate that a value is not set (see https://python-patterns.guide/python/sentinel-object) NOT_SET: Final = object() @@ -52,63 +46,73 @@ signal.SIGTERM, # Unix signal 15. Sent by `kill `. ) if sys.platform == "win32": - HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break. - - -class _IgnoredTaskStatus(TaskStatus[Any]): - def started(self, value: Any = None) -> None: - pass - - -NO_OP_TS: Final = _IgnoredTaskStatus() + # Windows signal 21. Sent by Ctrl+Break. + HANDLED_SIGNALS += (signal.SIGBREAK,) # pyright: ignore[reportConstantRedefinition] # PyCharm has a bug when calling a TypeVarTuple-parameterized function with 0 arguments, # see https://youtrack.jetbrains.com/issue/PY-63820 def start_task_soon(tg: TaskGroup, func: Callable[[], Awaitable[Any]], name: object = None) -> None: - tg.start_soon(func, name=name) # type: ignore + tg.start_soon(func, name=name) -def unwrap_exc(exc: Exception) -> Exception: +def unwrap_exc(exc: BaseException) -> BaseException: if isinstance(exc, ExceptionGroup) and len(exc.exceptions) == 1: return unwrap_exc(exc.exceptions[0]) return exc class _SupportsClose(Protocol): - def close(self) -> object: ... + def close(self) -> Any: ... class _SupportsAsyncClose(Protocol): - async def aclose(self) -> object: ... + def aclose(self) -> Awaitable[Any]: ... -class ClosingContext(Generic[T], AbstractContextManager[T, None], AbstractAsyncContextManager[T, None]): +@contextmanager +def set_cvar[T](cvar: ContextVar[T], value: T) -> Generator[T]: + """To emulate what Python 3.14 cvar.set() does by default.""" + old_value = cvar.set(value) + try: + yield value + finally: + cvar.reset(old_value) + + +@final +class maybe_closing[T](AbstractContextManager[T, None], AbstractAsyncContextManager[T, None]): def __init__(self, enter_result: T): - self.enter_result = enter_result + self._enter_result = enter_result def __enter__(self) -> T: - return self.enter_result + return self._enter_result async def __aenter__(self) -> T: - return self.enter_result + return self._enter_result def __exit__(self, exc_type, exc_value, traceback) -> None: - if hasattr(t := self.enter_result, "close"): - cast(_SupportsClose, t).close() + getattr(self._enter_result, "close", lambda: None)() async def __aexit__(self, exc_type, exc_value, traceback) -> None: - t = self.enter_result - if hasattr(t, "aclose"): - await cast(_SupportsAsyncClose, t).aclose() - elif hasattr(t, "close"): - cast(_SupportsClose, t).close() + target = self._enter_result + close = getattr(target, "aclose", getattr(target, "close", lambda: None)) + if inspect.isawaitable(result := close()): + await result + + +def ensure_int_or_inf(value: int | float, *, min_value: int = 0, name: str = "Value") -> int | float: + if math.isinf(value): + return value + if isinstance(value, int) and value >= min_value: + return value + raise ValueError(f"{name} must be an integer (>={min_value}) or infinity, got {value!r}") @final -# Actually immutable, but frozen=True has noticeable performance impact +# Actually immutable, but frozen=True has a noticeable performance impact @dc.dataclass(slots=True, eq=True, unsafe_hash=True) -class Result(Generic[T]): +class Result[T]: value: T # | NOT_SET error: BaseException # | None @@ -130,7 +134,7 @@ def get_callable_return_type(func: Callable[..., Any], /) -> type[Any]: desc = typing.get_type_hints(func, globalns=getattr(func, "__globals__", None)) except (TypeError, NameError): try: - desc = typing.get_type_hints(func.__call__, globalns=getattr(func.__call__, "__globals__", None)) + desc = typing.get_type_hints(func.__call__, globalns=getattr(func.__call__, "__globals__", None)) # type: ignore[operator] except (TypeError, NameError, AttributeError): return type(None) @@ -140,11 +144,11 @@ def get_callable_return_type(func: Callable[..., Any], /) -> type[Any]: @overload -def is_async_callable(obj: Callable[P, Any], /) -> TypeGuard[Callable[P, Awaitable[Any]]]: ... +def is_async_callable[**P](obj: Callable[P, Any], /) -> TypeGuard[Callable[P, Awaitable[Any]]]: ... @overload -def is_async_callable(obj: Callable[P, Any], ret_t: type[R], /) -> TypeGuard[Callable[P, Awaitable[R]]]: ... +def is_async_callable[**P, R](obj: Callable[P, Any], ret_t: type[R], /) -> TypeGuard[Callable[P, Awaitable[R]]]: ... # See also: https://docs.python.org/3/library/inspect.html#inspect.markcoroutinefunction @@ -155,25 +159,29 @@ def is_async_callable(obj: Callable[..., Any] | object, _=None, /) -> TypeGuard[ return False return ( inspect.iscoroutinefunction(obj) - or inspect.iscoroutinefunction(obj.__call__) # type: ignore + or inspect.iscoroutinefunction(obj.__call__) or issubclass(get_callable_return_type(obj), Awaitable) ) -def ensure_async_callable( - func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], / +def ensure_async_callable[**P, T]( + func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], + /, + *, + max_threads: int | float | CapacityLimiter | None = None, ) -> Callable[P, Awaitable[T]]: if is_async_callable(func): return func - return functools.partial(to_thread.run_sync, func) # type: ignore[return-value] + limiter = CapacityLimiter(max_threads) if isinstance(max_threads, int | float) else max_threads + return cast("Callable[P, Awaitable[T]]", functools.partial(to_thread.run_sync, func, limiter=limiter)) -def ensure_sync_callable( +def ensure_sync_callable[**P, T]( func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], / ) -> Callable[P, T]: if is_async_callable(func): - return functools.partial(from_thread.run, func) - return func # type: ignore[return-value] + return cast("Callable[P, T]", functools.partial(from_thread.run, func)) + return cast("Callable[P, T]", func) def def_full_name(func: Any, /) -> str: @@ -192,16 +200,18 @@ def ensure_td(value: timedelta | str, /) -> timedelta: return value if isinstance(value, str): try: - import pytimeparse2 + import pytimeparse2 # noqa: PLC0415 use_dateutil = pytimeparse2.HAS_RELITIVE_TIMEDELTA try: # Make sure to get timedelta, not relativedelta from dateutil pytimeparse2.HAS_RELITIVE_TIMEDELTA = False - result = pytimeparse2.parse(value, as_timedelta=True) + # ``as_timedelta=True`` makes ``parse`` return ``timedelta`` + # exclusively, but the stubs still expose ``int | float | timedelta``. + result = cast("timedelta | None", pytimeparse2.parse(value, as_timedelta=True)) if result is None: raise ValueError(f"Invalid time period: {value!r}") - return result # type: ignore[return-value] + return result finally: pytimeparse2.HAS_RELITIVE_TIMEDELTA = use_dateutil except ImportError: @@ -211,7 +221,7 @@ def ensure_td(value: timedelta | str, /) -> timedelta: def td_str(td: timedelta, /) -> str: try: - from humanize import precisedelta + from humanize import precisedelta # noqa: PLC0415 # 23 seconds or 0.24 seconds return precisedelta(td) @@ -221,9 +231,7 @@ def td_str(td: timedelta, /) -> str: # TODO Rename to DurationFactory -DelayFactory: TypeAlias = Union[ - Callable[[], timedelta], tuple[int, int], tuple[float, float], int, float, timedelta, None -] +type DelayFactory = Callable[[], timedelta] | tuple[int | float, int | float] | int | float | timedelta | None @final @@ -249,7 +257,7 @@ class FixedDelay: def create(cls, value: int | float | timedelta | None) -> Self: if value is None or value == 0: delay = TD_ZERO - elif isinstance(value, (int, float)): + elif isinstance(value, int | float): delay = timedelta(seconds=value) elif isinstance(value, timedelta): delay = value @@ -268,11 +276,11 @@ def __call__(self) -> timedelta: # TODO Rename to ensure_duration_factory() def ensure_delay_factory(delay: DelayFactory, /) -> Callable[[], timedelta]: if isinstance(delay, tuple): # tuple[int, int] | tuple[float, float] - return RandomDelay(delay) # type: ignore - elif callable(delay): - return delay - else: # int | float | timedelta | None - return FixedDelay.create(delay) + return RandomDelay(cast("tuple[int, int] | tuple[float, float]", delay)) + if callable(delay): + return cast("Callable[[], timedelta]", delay) + # int | float | timedelta | None + return FixedDelay.create(cast("int | float | timedelta | None", delay)) # sleep(0) is used to return control to the event loop (in both Trio and AsyncIO) @@ -281,35 +289,6 @@ def sleep(i: timedelta | int | float | None, /) -> Coroutine[Any, Any, None]: return anyio.sleep(interval_sec) -@final -@dc.dataclass(eq=False) -class MemorySendStream(Generic[T], MemoryObjectSendStream[T]): - def send_or_drop(self, item: T) -> None: - try: - self.send_nowait(item) - except WouldBlock: - pass - - async def send_or_drop_async(self, item: T) -> None: - await checkpoint() - try: - self.send_nowait(item) - except WouldBlock: - pass - - -class MemoryStream(Generic[T]): - @staticmethod - def create(max_buffer_size: float = 0) -> tuple[MemorySendStream[T], MemoryObjectReceiveStream[T]]: - if max_buffer_size != math.inf and not isinstance(max_buffer_size, int): - raise ValueError("max_buffer_size must be either an integer or math.inf") - if max_buffer_size < 0: - raise ValueError("max_buffer_size cannot be negative") - - state = MemoryObjectStreamState[T](max_buffer_size) - return MemorySendStream(state), MemoryObjectReceiveStream(state) - - class AsyncBackendConfig(TypedDict): backend: str backend_options: NotRequired[dict[str, Any]] @@ -344,10 +323,7 @@ def __await__(self) -> Generator[Any, Any, None]: @dc.dataclass(frozen=True, slots=True) class Event(EventView): - _source: anyio.Event - - def __init__(self) -> None: - object.__setattr__(self, "_source", anyio.Event()) + _source: anyio.Event = dc.field(default_factory=anyio.Event) def __repr__(self) -> str: return f"{self.__class__.__name__}(is_set={self._source.is_set()})" @@ -385,15 +361,18 @@ async def wait(self) -> None: async def _cancel_when(trigger: AnyEventView | Callable[[], Awaitable[Any]], scope: CancelScope) -> None: - await (trigger() if callable(trigger) else trigger.wait()) + if callable(trigger): + await cast("Callable[[], Awaitable[Any]]", trigger)() + else: + await trigger.wait() scope.cancel() def cancellable_from(*events: AnyEventView): - def _decorator(func: Callable[P, Awaitable[Any]]) -> Callable[P, Awaitable[None]]: + def _decorator[**P](func: Callable[P, Awaitable[Any]]) -> Callable[P, Awaitable[None]]: @wraps(func) async def _wrapper(*args, **kwargs): - # await wait_any(lambda: func(*args, **kwargs), *[e.wait for e in events]) + # Short version: await wait_any(lambda: func(*args, **kwargs), *[e.wait for e in events]) async with create_task_group() as exec_tg: exec_scope = exec_tg.cancel_scope for e in events: @@ -413,6 +392,21 @@ async def wait_all(events: Iterable[EventView]) -> None: async def wait_any(*targets: EventView | Callable[[], Awaitable[Any]]) -> None: - async with create_task_group() as tg: - for t in targets: - tg.start_soon(_cancel_when, t, tg.cancel_scope) + try: + async with create_task_group() as tg: + for t in targets: + tg.start_soon(_cancel_when, t, tg.cancel_scope) + except ExceptionGroup as exc_group: + exc = unwrap_exc(exc_group) + raise exc from exc.__cause__ + + +class NullSemaphore(AbstractContextManager): + def __exit__(self, exc_type, exc_value, traceback, /): + pass + + async def acquire(self) -> None: + pass + + def release(self) -> None: + pass diff --git a/localpost/consumers/kafka.py b/localpost/consumers/kafka.py deleted file mode 100644 index cac3531..0000000 --- a/localpost/consumers/kafka.py +++ /dev/null @@ -1,260 +0,0 @@ -from __future__ import annotations - -import dataclasses as dc -import logging -import os -from collections.abc import Callable, Iterable, Mapping, Sequence -from contextlib import AbstractContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, final - -import confluent_kafka -from anyio import CapacityLimiter, create_task_group, from_thread, to_thread -from confluent_kafka import TIMESTAMP_NOT_AVAILABLE, Consumer - -from localpost._utils import EventView -from localpost.flow import AnyHandlerManager, SyncHandlerManager, ensure_sync_handler_manager -from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager - -__all__ = [ - "KafkaMessage", - "KafkaMessages", - "KafkaConsumer", - # "KafkaBroker", - "kafka_config", - "kafka_consumer", -] - -logger = logging.getLogger(__name__) - - -@final -@dc.dataclass(frozen=True, slots=True, eq=False) -class KafkaMessage(AbstractContextManager[bytes, None]): - payload: confluent_kafka.Message - _client: Consumer - _client_config: Mapping[str, Any] - - def __repr__(self): - return f"{self.__class__.__name__}(topic={self.payload.topic()!r}" - - def __enter__(self) -> bytes: - return self.value - - def __exit__(self, exc_type, exc_value, traceback) -> None: - if exc_type is None: - self.try_ack() - - @property - def key(self) -> bytes | None: - return self.payload.key() - - @property - def timestamp(self) -> int | None: - ts_type, ts = self.payload.timestamp() - return None if ts_type == TIMESTAMP_NOT_AVAILABLE else ts - - @property - def value(self) -> bytes: - return self.payload.value() - - def try_ack(self) -> None: - """ - Store the offset of the message, so it won't be redelivered (but only when `enable.auto.offset.store` is - actually disabled. - """ - if not self._client_config.get("enable.auto.offset.store", True): - self.ack() - - def ack(self) -> None: - """ - Store the offset of the message, so it won't be redelivered. - - Works only if 'enable.auto.offset.store' is set to False! - """ - self._client.store_offsets(self.payload) # Actual commit is done in the background - - -@final -@dc.dataclass(frozen=True, slots=True) -class KafkaMessages(Sequence[KafkaMessage], AbstractContextManager[Sequence[bytes], None]): - """ - Non-empty batch of Kafka messages. - """ - - payload: Sequence[KafkaMessage] - - def __init__(self, payload: Sequence[KafkaMessage]): - if isinstance(payload, KafkaMessages): - payload = payload.payload - assert payload - object.__setattr__(self, "payload", payload) - - def __enter__(self) -> Sequence[bytes]: - return [msg.value for msg in self.payload] - - def __exit__(self, exc_type, exc_value, traceback) -> None: - if exc_type is None: - self.try_ack() - - def __getitem__(self, item): - return self.payload[item] - - def __len__(self): - return len(self.payload) - - def try_ack(self) -> None: - for message in self.payload: - message.try_ack() - - def ack(self) -> None: - for message in self.payload: - message.ack() - - -@final -class KafkaConsumer(ExposedServiceBase): - def __init__( - self, - handler: SyncHandlerManager[KafkaMessage], - topics: Iterable[str], - /, - *, - client_config: Mapping[str, Any] | None = None, - consumers: int = 1, - ): - super().__init__() - if consumers < 1: - raise ValueError("At least one consumer is required") - - self.client_config: dict[str, Any] = dict(client_config or {}) - self.topics = list(topics) - self.handler = handler - self.consumers = consumers - self.poll_timeout = 0.5 - - @asynccontextmanager - async def _create_client(self): - # TODO stats_cb, to provide detailed debug information - # https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html#kafka-client-configuration - # https://github.com/confluentinc/librdkafka/blob/master/STATISTICS.md - client = Consumer( - self.client_config, - logger=logger, # noqa - ) - await to_thread.run_sync(client.subscribe, self.topics) # type: ignore - try: - yield client - finally: - await to_thread.run_sync(client.close) # type: ignore - - def _run_consumer( - self, - client: Consumer, - message_handler: Callable[[KafkaMessage], None], - shutting_down: EventView, - ) -> None: - while not shutting_down: - from_thread.check_cancelled() - poll_res = client.poll(self.poll_timeout) # Poll with a short timeout, so we can respect the cancellation - if poll_res is None: - continue # Empty poll, check for cancellation and continue - if error := poll_res.error(): - if error.retriable(): - logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) - continue - if error.fatal(): - raise RuntimeError(error.str()) - message = KafkaMessage(poll_res, client, self.client_config) - message_handler(message) - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - assert self.consumers > 0 - threads_limiter = CapacityLimiter(self.consumers) - self._lifetime = service_lifetime - - def _consumer_thread(c): - return to_thread.run_sync( - self._run_consumer, - c, - message_handler, - service_lifetime.shutting_down, - # A custom limiter, to not reduce the global capacity permanently - limiter=threads_limiter, - ) - - # Make sure to create a task group _after_ resolving the handler, so we exit it only after all the consumer - # tasks are done - async with AsyncExitStack() as clients, self.handler as message_handler, create_task_group() as tg: - service_lifetime.set_started() - await service_lifetime.host.started # Start pulling messages only after the whole app is started - for _ in range(self.consumers): - client = await clients.enter_async_context(self._create_client()) - tg.start_soon(_consumer_thread, client) - - -def kafka_config_from_env() -> dict[str, Any]: - """ - Construct a configuration dictionary for KAFKA_* environment variables. - - When translating Kafka's properties, use upper case instead and replace the . with _ (KAFKA_BOOTSTRAP_SERVERS -> - bootstrap.servers, etc.). - - Properties reference: https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md. - """ - - def _read_env_vars(): - for var_name, var_val in os.environ.items(): - if var_name.startswith("KAFKA_"): - yield var_name[6:].lower().replace("_", "."), var_val - - return dict(_read_env_vars()) - - -def kafka_config(**overrides) -> dict[str, Any]: - conf_from_args = {k.replace("_", "."): v for k, v in overrides.items()} - conf_from_env = kafka_config_from_env() - return conf_from_env | conf_from_args - - -# @final -# class KafkaBroker: -# """ -# Convenient way to create and register Kafka consumers. -# """ -# -# def __init__(self, **config): -# conf_from_args = {k.replace("_", "."): v for k, v in config.items()} -# conf_from_env = kafka_conf_from_env() -# self.client_config = conf_from_env | conf_from_args -# -# def topic_consumer( -# self, topics: str | Iterable[str], /, *, consumers: int = 1 -# # ) -> Callable[[HandlerManager[KafkaMessage] | SyncHandlerManager[KafkaMessage]], HostedService]: -# ) -> Callable[[T], T]: -# def _decorator(handler): -# consumer = KafkaTopicConsumer( -# ensure_sync_handler(handler), -# [topics] if isinstance(topics, str) else topics, -# client_config=self.client_config, -# consumers=consumers, -# ) -# # return HostedService(consumer, name=f"KafkaTopicConsumer({topics!r})") -# return handler -# -# return _decorator - - -# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function -def kafka_consumer( - topics: str | Iterable[str], client_config: Mapping[str, Any] | None = None, /, *, consumers: int = 1 -) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumer]: - def decorator(handler: AnyHandlerManager[KafkaMessage]): - consumer = KafkaConsumer( - ensure_sync_handler_manager(handler), - [topics] if isinstance(topics, str) else topics, - client_config=client_config, - consumers=consumers, - ) - return consumer - - return decorator diff --git a/localpost/consumers/kafka_otel.py b/localpost/consumers/kafka_otel.py deleted file mode 100644 index c2b4b14..0000000 --- a/localpost/consumers/kafka_otel.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable, Sequence -from contextlib import AbstractContextManager, contextmanager -from typing import TypeVar - -from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( # noqa - create_messaging_client_consumed_messages, - create_messaging_client_operation_duration, -) -from opentelemetry.trace import SpanKind, TracerProvider, get_tracer_provider -from opentelemetry.util.types import AttributeValue - -from localpost import __version__ -from localpost._otel_utils import rec_duration -from localpost.consumers.kafka import KafkaMessage -from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware - -T = TypeVar("T", KafkaMessage, Sequence[KafkaMessage]) -R = TypeVar("R", Awaitable[None], None) - -__all__ = ["trace"] - - -def create_message_tracer( - tp: TracerProvider | None, - mp: MeterProvider | None, -) -> Callable[[KafkaMessage | Sequence[KafkaMessage]], AbstractContextManager[None]]: - tracer = (tp or get_tracer_provider()).get_tracer(__name__, __version__) - meter = (mp or get_meter_provider()).get_meter(__name__, __version__) - - # Based on Semantic Conventions 1.30.0, see - # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ - - m_process_duration = create_messaging_client_operation_duration(meter) - messages_consumed = create_messaging_client_consumed_messages(meter) - - @contextmanager - def call_tracer(message: KafkaMessage | Sequence[KafkaMessage]): - is_batch = isinstance(message, KafkaMessage) - topic = message.payload.topic() if isinstance(message, KafkaMessage) else message[0].payload.topic() - attrs: dict[str, AttributeValue] = { - "messaging.operation.type": "process", - "messaging.system": "kafka", - "messaging.destination.name": topic, - } - if is_batch: - attrs["messaging.batch.message_count"] = len(message) - else: - attrs["messaging.kafka.partition"] = (message.payload.partition(),) - - messages_consumed.add(len(message) if is_batch else 1, attrs) - with tracer.start_as_current_span(f"process {topic}", kind=SpanKind.CONSUMER, attributes=attrs): - with rec_duration(m_process_duration, attrs): - yield - - return call_tracer - - -def trace(tp: TracerProvider | None = None, mp: MeterProvider | None = None, /) -> HandlerDecorator[T, T]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware diff --git a/localpost/consumers/kafka_protobuf.py b/localpost/consumers/kafka_protobuf.py deleted file mode 100644 index c53b9f3..0000000 --- a/localpost/consumers/kafka_protobuf.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Kafka Protobuf deserializer. - -Mainly to show the approach on how to create a custom deserializer. Not intended to be a generic solution. -""" - -from __future__ import annotations - -import warnings -from typing import Callable, ParamSpec, TypeAlias, TypeVar - -from confluent_kafka.schema_registry.protobuf import ProtobufDeserializer -from confluent_kafka.serialization import MessageField, SerializationContext -from google.protobuf.message import Message as PbMessage - -from localpost.consumers.kafka import KafkaMessage - -T = TypeVar("T", bound=PbMessage) -P = ParamSpec("P") - -__all__ = [ - "protobuf_deserializer_for", -] - -Deserializer: TypeAlias = Callable[[KafkaMessage | bytes], T] - - -# See also https://github.com/confluentinc/confluent-kafka-python/blob/master/examples/protobuf_consumer.py -def protobuf_deserializer_for(message_type: type[T]) -> Deserializer[T]: - # Confluent SDK uses some deprecated Protobuf methods, just skip it - # (Already fixed in confluent-kafka 2.6+?..) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - # "MessageFactory class is deprecated. Please use GetMessageClass() instead of MessageFactory.GetPrototype." - deserializer = ProtobufDeserializer( - message_type, # type: ignore[arg-type] - {"use.deprecated.format": False}, - ) - - def deserialize(m: KafkaMessage | bytes) -> T: - if isinstance(m, KafkaMessage): - context = SerializationContext(m.payload.topic(), MessageField.VALUE) - return deserializer(m.value, context) # type: ignore[return-value] - return deserializer(m) # type: ignore[return-value] - - return deserialize diff --git a/localpost/consumers/sqs.py b/localpost/consumers/sqs.py deleted file mode 100644 index 7e7299c..0000000 --- a/localpost/consumers/sqs.py +++ /dev/null @@ -1,421 +0,0 @@ -from __future__ import annotations - -import dataclasses as dc -import logging -from collections.abc import Callable, Iterable, Mapping, Sequence -from contextlib import AbstractAsyncContextManager, AsyncExitStack -from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict, cast, final - -from aiobotocore.session import get_session -from anyio import CancelScope, create_task_group - -from localpost import flow -from localpost._utils import EventView, ensure_async_callable -from localpost.flow import AsyncHandler, AsyncHandlerManager, FlowHandlerManager -from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager - -if TYPE_CHECKING: - from types_aiobotocore_sqs import SQSClient - from types_aiobotocore_sqs.literals import MessageSystemAttributeNameType - from types_aiobotocore_sqs.type_defs import ( - MessageTypeDef, - ReceiveMessageRequestTypeDef, - ) - -__all__ = [ - "delete_messages", - "SqsMessage", - "SqsMessages", - "SqsQueueConsumer", - # "SqsBroker", - "lambda_handler", - "sqs_queue_consumer", -] - -ClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager["SQSClient"]] - -logger = logging.getLogger(__name__) - - -async def _delete_message_chunk(messages: Sequence[SqsMessage]): - client = messages[0]._client # noqa - queue_url = messages[0].queue_url - await client.delete_message_batch( - QueueUrl=queue_url, - Entries=[ - { - "Id": str(i), - "ReceiptHandle": message.receipt_handle, - } - for i, message in enumerate(messages) - ], - ) - - -def _split_messages(messages: Sequence[SqsMessage]) -> Iterable[Sequence[SqsMessage]]: - partitions: dict[str, list[SqsMessage]] = {} - for message in messages: - partitions.setdefault(message.queue_url, []).append(message) - for queue_messages in partitions.values(): - for i in range(0, len(queue_messages), 10): - yield queue_messages[i : i + 10] - - -async def delete_messages(messages: Sequence[SqsMessage]): - """ - Delete multiple messages from the queue. - - AWS supports batches up to 10 messages. If the input list is longer, it will be split into chunks, and all the - chunks will be processed simultaneously. - """ - async with create_task_group() as tg: - for chunk in _split_messages(messages): - tg.start_soon(_delete_message_chunk, chunk) - - -# See also https://github.com/aio-libs/aiobotocore/blob/master/examples/sqs_queue_consumer.py - - -@final -@dc.dataclass(frozen=True, slots=True, eq=False) -class SqsMessage(AbstractAsyncContextManager[str, None]): - payload: "MessageTypeDef" - """ - Raw message data from the SQS queue. - - See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_Message.html. - """ - queue_name: str - queue_url: str - _client: "SQSClient" - - def __repr__(self): - return f"{self.__class__.__name__}(queue_name={self.queue_name!r})" - - async def __aenter__(self) -> str: - return self.body - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - if exc_type is None: - await self.ack() - - @property - def receipt_handle(self): - assert "ReceiptHandle" in self.payload - return self.payload["ReceiptHandle"] - - @property - def body(self) -> str: - assert "Body" in self.payload - return self.payload["Body"] - - @property - def attributes(self): - return self.payload.get("MessageAttributes", {}) - - async def ack(self) -> None: - """ - Delete the message from the queue (acknowledge), otherwise it will reappear after the visibility timeout. - """ - await self._client.delete_message( - QueueUrl=self.queue_url, - ReceiptHandle=self.receipt_handle, - ) - - -@final -@dc.dataclass(frozen=True, slots=True) -class SqsMessages(Sequence[SqsMessage], AbstractAsyncContextManager[Sequence[str], None]): - """ - Non-empty batch of SQS messages. - """ - - payload: Sequence[SqsMessage] - - def __repr__(self): - return f"{self.__class__.__name__}(len={len(self)})" - - def __init__(self, payload: Sequence[SqsMessage]): - if isinstance(payload, SqsMessages): - payload = payload.payload - assert payload - object.__setattr__(self, "payload", payload) - - async def __aenter__(self) -> Sequence[str]: - return [m.body for m in self.payload] - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - if exc_type is None: - await self.ack() - - def __getitem__(self, item): - return self.payload[item] - - def __len__(self): - return len(self.payload) - - @property - def queue_name(self) -> str: - return self.payload[0].queue_name - - async def ack(self) -> None: - """ - Delete the messages from the queue (acknowledge), otherwise they will reappear after the visibility timeout. - """ - await delete_messages(self.payload) - - -def _queue_name_from_url(url: str) -> str: - from urllib.parse import urlparse - - parse_result = urlparse(url) - return parse_result.path.split("/")[-1] - - -def create_client() -> AbstractAsyncContextManager["SQSClient"]: - """ - Default SQS client factory. - """ - return get_session().create_client("sqs") - - -@final -class SqsQueueConsumer(ExposedServiceBase): - _EMPTY_RECEIVE: Final[Sequence["MessageTypeDef"]] = () - - def __init__( - self, - handler: AsyncHandlerManager[SqsMessage], - queue_name: str, - *, - queue_url: str | None = None, - client_factory: ClientFactory | None = None, - consumers: int = 1, - ): - super().__init__() - if consumers < 1: - raise ValueError("Number of consumers must be at least 1") - - self.queue_name = queue_name - self.queue_url = queue_url - self.handler = handler - self.client_factory: ClientFactory = client_factory or create_client - self.consumers = consumers - self.receive_req_template: "ReceiveMessageRequestTypeDef" = { - "QueueUrl": "", # Will be filled in later - "MessageAttributeNames": ["All"], - "MaxNumberOfMessages": 10, - "WaitTimeSeconds": 20, - } - - async def _run_consumer( - self, - queue_url: str, - client: "SQSClient", - message_handler: AsyncHandler[SqsMessage], - shutdown_scope: CancelScope, - app_started: EventView, - ): - async def pull_messages() -> Sequence["MessageTypeDef"]: - # TODO Check HTTP status and retry on errors (exponential backoff) - pull_resp = await client.receive_message(**receive_req) - return pull_resp.get("Messages", self._EMPTY_RECEIVE) - - receive_req: "ReceiveMessageRequestTypeDef" = self.receive_req_template | {"QueueUrl": queue_url} - messages = self._EMPTY_RECEIVE - with shutdown_scope: - await app_started # Start pulling messages only after the whole app is started - while not shutdown_scope.cancel_called: - with shutdown_scope: - messages = await pull_messages() - if shutdown_scope.cancel_called: - break - if not messages: - logger.debug("No messages in the queue (empty receive)") - continue - for m in messages: - await message_handler(SqsMessage(m, self.queue_name, queue_url, client)) - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - self._lifetime = service_lifetime - - async def _resolve_url_from_name(): - async with self.client_factory() as c: - resolve_resp = await c.get_queue_url(QueueName=self.queue_name) - return resolve_resp["QueueUrl"] - - queue_url = self.queue_url or await _resolve_url_from_name() - consumer_scopes = [CancelScope() for _ in range(self.consumers)] - app_started = service_lifetime.host.started - # Make sure to create a task group _after_ resolving the handler, so we exit it only after all the consumer - # tasks are done - async with AsyncExitStack() as clients, self.handler as mh, create_task_group() as tg: - for shutdown_scope in consumer_scopes: - client = await clients.enter_async_context(self.client_factory()) - tg.start_soon(self._run_consumer, queue_url, client, mh, shutdown_scope, app_started) - service_lifetime.set_started() - await service_lifetime.shutting_down - for scope in consumer_scopes: - scope.cancel() - - -# @final -# class SqsBroker: -# def __init__(self, *, client_factory: ClientFactory | None = None): -# self.client_factory = client_factory or create_client -# -# def queue_consumer( -# self, queue_name_or_url: str, /, *, consumers: int = 1 -# ) -> Callable[[HandlerManager[SqsMessage]], HostedService]: -# if "/" in queue_name_or_url: -# queue_url = queue_name_or_url -# queue_name = _queue_name_from_url(queue_url) -# else: -# queue_url = None -# queue_name = queue_name_or_url -# -# def _decorator(handler) -> HostedService: -# consumer = SqsQueueConsumer( -# handler, -# queue_name=queue_name, -# queue_url=queue_url, -# client_factory=self.client_factory, -# consumers=consumers, -# ) -# return HostedService(consumer, name=f"SqsQueueConsumer({queue_name!r})") -# -# return _decorator - - -# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function -def sqs_queue_consumer( - queue_name_or_url: str, client_factory: ClientFactory | None = None, /, *, consumers: int = 1 -) -> Callable[[AsyncHandlerManager[SqsMessage]], SqsQueueConsumer]: - if "/" in queue_name_or_url: - queue_url = queue_name_or_url - queue_name = _queue_name_from_url(queue_url) - else: - queue_url = None - queue_name = queue_name_or_url - - def decorator(handler): - consumer = SqsQueueConsumer( - handler, - queue_name=queue_name, - queue_url=queue_url, - client_factory=client_factory, - consumers=consumers, - ) - return consumer - - return decorator - - -class LambdaEventRecordMessageAttributeValue(TypedDict): - dataType: str - stringValue: str - binaryValue: bytes - stringListValues: Sequence[str] - binaryListValues: Sequence[bytes] - - -class LambdaEventRecord(TypedDict): - """ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "myAttribute": { - "stringValue": "myValue", - "stringListValues": [], - "binaryListValues": [], - "dataType": "String" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-2" - } - """ - - messageId: str - receiptHandle: str - body: str - attributes: Mapping[MessageSystemAttributeNameType, str] - messageAttributes: Mapping[str, LambdaEventRecordMessageAttributeValue] - md5OfBody: str - eventSource: str - eventSourceARN: str - awsRegion: str - - -class LambdaEvent(TypedDict): - Records: Sequence[LambdaEventRecord] - - -def _message2lambda(m: SqsMessage, /) -> LambdaEventRecord: - # See https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html & - # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_ReceiveMessage.html#API_ReceiveMessage_ResponseSyntax - return { - "messageId": m.payload["MessageId"], # type: ignore - "receiptHandle": m.payload["ReceiptHandle"], # type: ignore - "body": m.payload["Body"], # type: ignore - "attributes": m.payload.get("Attributes", {}), - "messageAttributes": { - ma_name: cast( - LambdaEventRecordMessageAttributeValue, - {ma_k[0].lower() + ma_k[1:]: ma_v for ma_k, ma_v in ma_values.items()}, - ) - for ma_name, ma_values in m.payload.get("MessageAttributes", {}).items() - }, - "md5OfBody": m.payload["MD5OfBody"], # type: ignore - "eventSource": "aws:sqs", - "eventSourceARN": "TODO", - "awsRegion": "TODO", - } - - -@final -class LambdaInvocationContext: - def __init__(self) -> None: - # See https://docs.aws.amazon.com/lambda/latest/dg/python-context.html - self.function_name = "N/A" - self.function_version = "N/A" - self.invoked_function_arn = "N/A" - self.memory_limit_in_mb = 1024 - self.aws_request_id = "N/A" # Use OTEL trace ID if available?.. - self.log_group_name = "N/A" - self.log_stream_name = "N/A" - self.identity = None - self.client_context = None - - -def lambda_handler( - lambda_h: Callable[[LambdaEvent, LambdaInvocationContext], object], / -) -> FlowHandlerManager[SqsMessage | Sequence[SqsMessage]]: - lambda_inv_context = LambdaInvocationContext() - async_lambda_h = ensure_async_callable(lambda_h) - - @flow.handler - async def _handler(workload: SqsMessage | Sequence[SqsMessage]) -> None: - lambda_event: LambdaEvent = {"Records": []} - if isinstance(workload, SqsMessage): - message = workload - lambda_event["Records"] = [_message2lambda(message)] - async with message: - await async_lambda_h(lambda_event, lambda_inv_context) - else: - messages = SqsMessages(cast(Sequence[SqsMessage], workload)) - lambda_event["Records"] = [_message2lambda(m) for m in messages] - async with messages: - await async_lambda_h(lambda_event, lambda_inv_context) - - return _handler diff --git a/localpost/consumers/sqs_otel.py b/localpost/consumers/sqs_otel.py deleted file mode 100644 index 586b4f7..0000000 --- a/localpost/consumers/sqs_otel.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable, Sequence -from contextlib import AbstractContextManager, contextmanager -from typing import TypeVar - -from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( # noqa - create_messaging_client_consumed_messages, - create_messaging_client_operation_duration, -) -from opentelemetry.trace import SpanKind, TracerProvider, get_tracer_provider -from opentelemetry.util.types import AttributeValue - -from localpost import __version__ -from localpost._otel_utils import rec_duration -from localpost.consumers.sqs import SqsMessage -from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware - -T = TypeVar("T", SqsMessage, Sequence[SqsMessage]) -R = TypeVar("R", Awaitable[None], None) - -__all__ = ["trace"] - - -def create_message_tracer( - tp: TracerProvider | None, - mp: MeterProvider | None, -) -> Callable[[SqsMessage | Sequence[SqsMessage]], AbstractContextManager[None]]: - tracer = (tp or get_tracer_provider()).get_tracer(__name__, __version__) - meter = (mp or get_meter_provider()).get_meter(__name__, __version__) - - # Based on Semantic Conventions 1.30.0, see - # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ - - m_process_duration = create_messaging_client_operation_duration(meter) - messages_consumed = create_messaging_client_consumed_messages(meter) - - @contextmanager - def call_tracer(message: SqsMessage | Sequence[SqsMessage]): - is_batch = not isinstance(message, SqsMessage) - queue_name = message.queue_name if isinstance(message, SqsMessage) else message[0].queue_name - attrs: dict[str, AttributeValue] = { - "messaging.operation.type": "process", - "messaging.system": "aws_sqs", - "messaging.destination.name": queue_name, - } - if is_batch: - attrs["messaging.batch.message_count"] = len(message) - - messages_consumed.add(len(message) if is_batch else 1, attrs) - with tracer.start_as_current_span(f"process {queue_name}", kind=SpanKind.CONSUMER, attributes=attrs): - with rec_duration(m_process_duration, attrs): - yield - - return call_tracer - - -def trace(tp: TracerProvider | None = None, mp: MeterProvider | None = None, /) -> HandlerDecorator[T, T]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware diff --git a/localpost/consumers/stream.py b/localpost/consumers/stream.py deleted file mode 100644 index 432c5c7..0000000 --- a/localpost/consumers/stream.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import TypeVar, final - -from anyio.streams.memory import MemoryObjectReceiveStream - -from localpost.flow import AsyncHandlerManager -from localpost.flow._flow import stream_consumer -from localpost.hosting import ServiceLifetimeManager - -T = TypeVar("T") - -__all__ = ["StreamConsumer"] - - -@final -class StreamConsumer: - def __init__( - self, - handler: AsyncHandlerManager[T], - reader: MemoryObjectReceiveStream[T], - *, - concurrency: int = 1, - process_leftovers: bool = True, - ): - if concurrency < 1: - raise ValueError("Number of consumers must be at least 1") - - self.handler = handler - self.reader = reader - self.concurrency = concurrency - self.process_leftovers = process_leftovers - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - async with ( - self.handler as message_handler, - stream_consumer( - self.reader, - message_handler, - self.concurrency, - self.process_leftovers, - ), - ): - service_lifetime.set_started() - # Wait until the source channel is closed diff --git a/localpost/consumers/stream_otel.py b/localpost/consumers/stream_otel.py deleted file mode 100644 index 3016e4d..0000000 --- a/localpost/consumers/stream_otel.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable, Collection -from contextlib import AbstractContextManager, contextmanager -from typing import Any - -from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( # noqa - create_messaging_client_consumed_messages, - create_messaging_client_operation_duration, -) -from opentelemetry.trace import SpanKind, TracerProvider, get_tracer_provider -from opentelemetry.util.types import AttributeValue -from typing_extensions import TypeVar - -from localpost import __version__ -from localpost._otel_utils import rec_duration -from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware - -T = TypeVar("T", default=Any) -TC = TypeVar("TC", bound=Collection[object], default=Collection[object]) -R = TypeVar("R", Awaitable[None], None) - -__all__ = ["trace", "trace_batch"] - - -def create_message_tracer( - queue_name: str, - batched: bool, - tp: TracerProvider | None, - mp: MeterProvider | None, -) -> Callable[[T], AbstractContextManager[None]]: - tracer = (tp or get_tracer_provider()).get_tracer(__name__, __version__) - meter = (mp or get_meter_provider()).get_meter(__name__, __version__) - - # Based on Semantic Conventions 1.30.0, see - # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ - - m_process_duration = create_messaging_client_operation_duration(meter) - messages_consumed = create_messaging_client_consumed_messages(meter) - - @contextmanager - def call_tracer(message: T): - attrs: dict[str, AttributeValue] = { - "messaging.operation.type": "process", - "messaging.system": "localpost_streams", - "messaging.destination.name": queue_name, - } - if batched: - attrs["messaging.batch.message_count"] = len(message) - - messages_consumed.add(len(message) if batched else 1, attrs) - with tracer.start_as_current_span(f"process {queue_name}", kind=SpanKind.CONSUMER, attributes=attrs): - with rec_duration(m_process_duration, attrs): - yield - - return call_tracer - - -def trace( - stream_name: str, tp: TracerProvider | None = None, mp: MeterProvider | None = None, / -) -> HandlerDecorator[T, T]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(stream_name, False, tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware - - -def trace_batch( - stream_name: str, tp: TracerProvider | None = None, mp: MeterProvider | None = None, / -) -> HandlerDecorator[TC, TC]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(stream_name, False, tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware diff --git a/localpost/di/README.md b/localpost/di/README.md new file mode 100644 index 0000000..db7a953 --- /dev/null +++ b/localpost/di/README.md @@ -0,0 +1,31 @@ +# localpost.di + +Small, `.NET`-style inversion-of-control container: scoped service resolution +with automatic constructor wiring. No decorators, no async, no "one interface, +many implementations" — just a registry + provider + scope. Comes with core +`localpost`; the optional Flask adapter requires `flask` in your environment. + +```python +from dataclasses import dataclass +from localpost.di._services import ServiceRegistry + + +@dataclass +class Config: host: str; port: int + + +class Server: + def __init__(self, config: Config): self.config = config + + +services = ServiceRegistry() +services.register_instance(Config(host="127.0.0.1", port=8080)) +services.register(Server) # auto-wires Config + +with services.app_scope() as sp: + print(sp.resolve(Server).config) +``` + +**Full reference:** + +Examples: [`examples/di/`](../../examples/di/). diff --git a/localpost/di/__init__.py b/localpost/di/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/di/_services.py b/localpost/di/_services.py new file mode 100644 index 0000000..6b90102 --- /dev/null +++ b/localpost/di/_services.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import inspect +import threading +from collections.abc import Callable, Collection, Generator, Iterator, Mapping +from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext +from contextvars import ContextVar +from dataclasses import dataclass, field +from dataclasses import dataclass as define +from functools import partial +from typing import Any, Final, Protocol, cast, final, get_type_hints + +from localpost._utils import set_cvar + + +class ResolutionContext(Protocol): + def enter[T](self, cm: AbstractContextManager[T]) -> T: ... + + +@final +@define(frozen=True, eq=False, slots=True) +class AppContext(ResolutionContext): + ctx: ExitStack = field(default_factory=ExitStack) + + def enter[T](self, cm: AbstractContextManager[T]) -> T: + return self.ctx.enter_context(cm) + + +class ServiceProvider(Protocol): + """Service provider, scoped to the current resolution context.""" + + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + """Create an instance of the given type by calling its constructor, resolving any dependencies.""" + + def resolve[T](self, service_type: type[T], /) -> T: + """Create an instance (or take already created one) for the service type, resolving any dependencies.""" + + def __getitem__[T](self, service_type: type[T], /) -> T: + return self.resolve(service_type) + + +class ServiceNotRegisteredError(ValueError): + """Raised when resolving a service that is not registered.""" + + +class NoResolutionContextError(RuntimeError): + """Raised when trying to resolve a service outside of a DI scope.""" + + +@final +@define(frozen=True, eq=False, slots=True) +class DefaultServiceProvider(ServiceProvider): + parent: ServiceProvider + registry: ServiceRegistry + scope: ResolutionContext + scope_type: type[ResolutionContext] + services: Mapping[type, object] = field(default_factory=dict, init=False) + """Resolved services.""" + _lock: threading.RLock = field(default_factory=threading.RLock, init=False) + """Reentrant: factories resolve their own dependencies via this same provider, + which re-enters the lock on the same thread. A non-reentrant ``Lock`` would + deadlock as soon as a service has any auto-wired dependency.""" + + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + """Create an instance of the given type, resolving constructor deps not provided in kwargs.""" + deps = _collect_deps(target_type) if "__init__" in target_type.__dict__ else [] + resolved = {name: self.resolve(dep_type) for name, dep_type in deps if name not in kwargs} + return target_type(**resolved, **kwargs) + + def resolve[T](self, service_type: type[T], /) -> T: + # Well-known DI types + if service_type is ServiceProvider: + return cast(T, self) + if service_type is self.scope_type: + return cast(T, self.scope) + + if resolved_service := self.services.get(service_type): # Already resolved in this scope + return cast(T, resolved_service) + + # Look up the descriptor for this scope + if descriptor := self.registry.get(service_type, self.scope_type): + with self._lock: + if resolved_service := self.services.get(service_type): # Resolved in another thread + return cast(T, resolved_service) + resolved_service = self.scope.enter(descriptor.factory(self)) + object.__setattr__(self, "services", {**self.services, service_type: resolved_service}) + return resolved_service + + return self.parent.resolve(service_type) + + +class NullServiceProvider(ServiceProvider): + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + raise NoResolutionContextError() + + def resolve[T](self, service_type: type[T], /) -> T: + raise ServiceNotRegisteredError(f"{service_type} is not registered") + + +@contextmanager +def scope(provider: DefaultServiceProvider, /) -> Generator[ServiceProvider]: + with set_cvar(current_provider, provider): + # Eagerly resolve services marked with create_on_enter for this scope type + for desc in provider.registry.descriptors.values(): + if desc.scope_type is provider.scope_type and desc.create_on_enter: + _ = provider.resolve(desc.service_type) + yield provider + + +class CurrentServiceProvider(ServiceProvider): + @property + def _provider(self) -> DefaultServiceProvider: + if provider := current_provider.get(None): + return provider + raise NoResolutionContextError() + + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + return self._provider.create(target_type, **kwargs) + + def resolve[T](self, service_type: type[T], /) -> T: + return self._provider.resolve(service_type) + + +NULL_PROVIDER: Final[ServiceProvider] = NullServiceProvider() +current_provider: ContextVar[DefaultServiceProvider] = ContextVar("current_provider") + +# TODO Rename to "services" +service_provider: Final[CurrentServiceProvider] = CurrentServiceProvider() +"""Proxy for the current DI service provider.""" + +# A factory that returns a context manager — the provider enters it to get the instance and manage its lifecycle. +type ServiceFactory[T] = Callable[[ServiceProvider], AbstractContextManager[T]] + + +@dataclass(frozen=True, slots=True) +class ServiceDescriptor[T]: + service_type: type[T] + scope_type: type[ResolutionContext] + factory: ServiceFactory[T] = field(repr=False) + create_on_enter: bool = False + + +def _collect_deps(factory: Callable[..., object]) -> list[tuple[str, type]]: + """Inspect a callable's parameters and collect (name, type) pairs for auto-wiring. + + Handles classes, classmethods, partial functions, etc. Uses inspect.signature for the effective + parameter list (excludes self/cls, accounts for positional partial args) and get_type_hints on the + underlying callable for type annotations. + """ + # Unwrap to get the real callable for type hints + unwrapped = factory + bound_kwargs: set[str] = set() + while isinstance(unwrapped, partial): + bound_kwargs.update(unwrapped.keywords) + unwrapped = unwrapped.func + + # inspect.signature handles partial, classmethod, staticmethod, classes, etc. + params = inspect.signature(factory).parameters + # get_type_hints needs the real callable; for classes, use __init__ to get param hints + hints = get_type_hints(unwrapped.__init__ if isinstance(unwrapped, type) else unwrapped) + + factory_name = getattr(unwrapped, "__name__", repr(factory)) + deps: list[tuple[str, type]] = [] + for name in params: + if name in bound_kwargs: + continue + if name not in hints: + raise TypeError(f"Cannot auto-wire {factory_name}: parameter '{name}' has no type annotation") + deps.append((name, hints[name])) + + return deps + + +def _make_service_factory[T](factory: Callable[..., T | Generator[T]]) -> ServiceFactory[T]: + """Turn any callable (plain, generator, or already-wired) into a ServiceFactory.""" + deps = _collect_deps(factory) + + if inspect.isgeneratorfunction(factory): + + @contextmanager + def cm_gen(provider: ServiceProvider) -> Generator[T]: + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + yield from factory(**kwargs) + + return cm_gen + + @contextmanager + def cm_plain(provider: ServiceProvider) -> Generator[T]: + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + yield cast(T, factory(**kwargs)) + + return cm_plain + + +def _factory_for_type[T](service_type: type[T]) -> ServiceFactory[T]: + """Create a ServiceFactory that auto-wires a type's constructor.""" + # Classes without a custom __init__ inherit object.__init__(*args, **kwargs) — no deps to wire + deps = _collect_deps(service_type) if "__init__" in service_type.__dict__ else [] + + def factory(provider: ServiceProvider) -> AbstractContextManager[T]: + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + return nullcontext(service_type(**kwargs)) + + return factory + + +# Kinda like IServiceCollection in .NET, or svcs.Registry +@final +@define(eq=False, slots=True) +class ServiceRegistry(Collection[ServiceDescriptor]): + descriptors: dict[tuple[type, type[ResolutionContext]], ServiceDescriptor] = field(default_factory=dict) + + def __iter__(self) -> Iterator[ServiceDescriptor]: + return iter(self.descriptors.values()) + + def __len__(self) -> int: + return len(self.descriptors) + + def __contains__(self, item: ServiceDescriptor) -> bool: + return (item.service_type, item.scope_type) in self.descriptors + + def get[T]( + self, service_type: type[T], scope: type[ResolutionContext] | None = None + ) -> ServiceDescriptor[T] | None: + return self.descriptors.get((service_type, scope or AppContext)) + + def register_instance[T]( + self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None + ) -> None: + sd = ServiceDescriptor(service_type or type(value), scope or AppContext, lambda _: nullcontext(value)) + self.descriptors[(sd.service_type, sd.scope_type)] = sd + + def register[T]( + self, + service_type: type[T], + factory: Callable[..., T | Generator[T]] | None = None, + scope: type[ResolutionContext] | None = None, + create_on_enter: bool = False, + ) -> None: + wrapped = _make_service_factory(factory) if factory else _factory_for_type(service_type) + sd = ServiceDescriptor(service_type, scope or AppContext, wrapped, create_on_enter) + self.descriptors[(sd.service_type, sd.scope_type)] = sd + + @contextmanager + def app_scope(self) -> Generator[DefaultServiceProvider]: + app_scope = AppContext() + provider = DefaultServiceProvider(NULL_PROVIDER, self, app_scope, AppContext) + with app_scope.ctx, scope(provider): + yield provider diff --git a/localpost/di/flask.py b/localpost/di/flask.py new file mode 100644 index 0000000..0832782 --- /dev/null +++ b/localpost/di/flask.py @@ -0,0 +1,52 @@ +"""Flask integration""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import AbstractContextManager, ExitStack, contextmanager +from dataclasses import dataclass, field +from typing import Any, final + +from flask import Flask, Request, g, request + +from localpost.di._services import DefaultServiceProvider, ResolutionContext, ServiceProvider, ServiceRegistry, scope + +_EXT_KEY = "localpost.di.provider" + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class RequestContext(ResolutionContext): + ctx: ExitStack = field(default_factory=ExitStack) + + def enter[T](self, cm: AbstractContextManager[T]) -> T: + return self.ctx.enter_context(cm) + + +def init_app(app: Flask, registry: ServiceRegistry, provider: ServiceProvider, /) -> None: + """Initialize DI for a Flask app. + + Opens a RequestContext scope per request. Flask's Request object is automatically + available for injection in request-scoped services. + """ + registry.register(Request, lambda: request, RequestContext) + app.extensions[_EXT_KEY] = (registry, provider) + + @contextmanager + def flask_req_ctx() -> Generator[None]: + registry, parent = app.extensions[_EXT_KEY] + req_scope = RequestContext() + provider = DefaultServiceProvider(parent, registry, req_scope, RequestContext) + with req_scope.ctx, scope(provider): + yield + + @app.before_request + def _open_request_scope() -> None: + g._localpost_di_scope = ctx = flask_req_ctx() + ctx.__enter__() + + @app.teardown_request + def _close_request_scope(exc: BaseException | None) -> None: + ctx: AbstractContextManager[Any] | None = getattr(g, "_localpost_di_scope", None) + if ctx is not None: + ctx.__exit__(type(exc) if exc else None, exc, None) diff --git a/localpost/di/quart.py b/localpost/di/quart.py new file mode 100644 index 0000000..45cefd2 --- /dev/null +++ b/localpost/di/quart.py @@ -0,0 +1 @@ +"""Quart (async Flask) integration""" diff --git a/localpost/flow/__init__.py b/localpost/flow/__init__.py deleted file mode 100644 index a662e35..0000000 --- a/localpost/flow/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -from ._batching import batch -from ._flow import ( - AnyHandlerManager, - AsyncHandler, - AsyncHandlerManager, - FlowHandler, - FlowHandlerManager, - HandlerDecorator, - HandlerMiddleware, - SyncHandler, - SyncHandlerManager, - ensure_async_handler_manager, - ensure_sync_handler_manager, - handler, - handler_manager, - handler_middleware, -) -from ._ops import buffer, delay, log_errors, skip_first - -__all__ = [ - "handler", - "handler_manager", - "AnyHandlerManager", - # async - "AsyncHandler", - "AsyncHandlerManager", - "ensure_async_handler_manager", - # sync - "SyncHandler", - "SyncHandlerManager", - "ensure_sync_handler_manager", - # combinators - "HandlerMiddleware", - "HandlerDecorator", - "FlowHandler", - "FlowHandlerManager", - "handler_middleware", - # ops - "delay", - "log_errors", - "skip_first", - "buffer", - "batch", -] diff --git a/localpost/flow/_batching.py b/localpost/flow/_batching.py deleted file mode 100644 index ce64849..0000000 --- a/localpost/flow/_batching.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import math -from collections.abc import AsyncGenerator, Callable, Sequence -from contextlib import asynccontextmanager -from typing import Any, Literal, overload - -from anyio import ( - ClosedResourceError, - EndOfStream, - create_task_group, - move_on_after, -) -from anyio.abc import ObjectReceiveStream -from typing_extensions import TypeVar - -from localpost._utils import MemoryStream, start_task_soon - -from ._flow import ( - AsyncHandler, - FlowHandler, - HandlerDecorator, - ensure_async_handler, - handler_middleware, - logger, -) - -T = TypeVar("T", default=Any) -TC = TypeVar("TC", bound=Sequence[object], default=Sequence[object]) # A collection of T objects (for batching) - - -@overload -def batch( - batch_size: int, - batch_window: int | float, # Seconds - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Sequence[Any], Any]: ... - - -@overload -def batch( - batch_size: int, - batch_window: int | float, # Seconds - items_f: Callable[[Sequence[T]], TC], - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[TC, T]: ... - - -def batch( - batch_size: int, - batch_window: int | float, # Seconds - items_f: Callable[[Sequence[T]], TC] | None = None, - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Any, T]: - """ - Collect items into batches. - - A new batch is produced when `batch_size` is reached or `batch_window` expires. - """ - if batch_size < 1: - raise ValueError("Batch size must be greater than or equal to 1") - if batch_window < 0: - raise ValueError("Batch window must be greater than 0") - if capacity < 0: - raise ValueError("Buffer capacity must be greater than or equal to 0") - - @handler_middleware - async def _middleware(next_h: FlowHandler[Sequence[object]]) -> AsyncGenerator[FlowHandler[T]]: - buffer_writer, buffer_reader = MemoryStream.create(capacity) - stream_h = ensure_async_handler(next_h) - consumer = stream_batch_consumer(buffer_reader, stream_h, items_f, batch_size, batch_window, process_leftovers) - async with consumer, buffer_writer: # As usual, order matters - if math.isinf(capacity) or full_mode == "drop": - yield next_h.create(async_h=buffer_writer.send_or_drop_async, sync_h=buffer_writer.send_or_drop) - else: - yield FlowHandler.create_async(async_h=buffer_writer.send) - - return _middleware - - -@asynccontextmanager -async def stream_batch_consumer( # noqa: C901 (ignore complexity) - source: ObjectReceiveStream[T], - h: AsyncHandler[Sequence[object]], - items_f: Callable[[Sequence[T]], TC] | None, - batch_size: int, - batch_window: int | float, # Seconds - process_leftovers: bool = True, -): - async def read_batch() -> Sequence[T]: - items: list[T] = [] - try: - with move_on_after(batch_window): - while len(items) < batch_size: - message = await source.receive() - items.append(message) - return items - except EndOfStream: - if items: - return items # Return the last batch first - raise - - async def consume(): - while True: - try: - items = await read_batch() - if items: - await h(items_f(items) if items_f is not None else items) - except EndOfStream: - logger.debug("Source stream has been completed, no more items to consume") - break - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - break - - async with source, create_task_group() as tg: - start_task_soon(tg, consume) - - yield - - if process_leftovers: - # Process all the remaining items (until the source stream is completed) - pass - else: - # Immediately stop consuming (close the receiver) and ignore the remaining items - await source.aclose() diff --git a/localpost/flow/_flow.py b/localpost/flow/_flow.py deleted file mode 100644 index b8b198c..0000000 --- a/localpost/flow/_flow.py +++ /dev/null @@ -1,325 +0,0 @@ -from __future__ import annotations - -import dataclasses as dc -import inspect -import logging -import math -from collections.abc import AsyncGenerator, Awaitable, Callable -from contextlib import ( - AbstractAsyncContextManager, - AbstractContextManager, - AsyncExitStack, - asynccontextmanager, - nullcontext, -) -from typing import Any, Generic, ParamSpec, TypeAlias, cast, final - -from anyio import ClosedResourceError, EndOfStream, create_task_group, to_thread -from anyio.abc import ObjectReceiveStream -from typing_extensions import TypeVar - -from localpost._utils import ensure_async_callable, ensure_sync_callable, is_async_callable - -T = TypeVar("T", default=Any) # Default to Any to allow for more flexible typing -T2 = TypeVar("T2", default=Any) -P = ParamSpec("P") -R = TypeVar("R", Awaitable[None], None) -R2 = TypeVar("R2", Awaitable[None], None) - -AsyncHandler: TypeAlias = Callable[[T], Awaitable[None]] -AsyncHandlerManager: TypeAlias = AbstractAsyncContextManager[ - Callable[[T], Awaitable[None]] # Handler[T] -] -SyncHandler: TypeAlias = Callable[[T], None] -SyncHandlerManager: TypeAlias = AbstractAsyncContextManager[ - Callable[[T], None] # SyncHandler[T] -] - -AnyHandler: TypeAlias = Callable[[T], Awaitable[None] | None] -# AnyHandlerSource: TypeAlias = Callable[[], AsyncGenerator[Handler[T]] | Generator[Handler[T]]] -# AnyHandlerManager: TypeAlias = Union[ -# AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], # HandlerManager[T] -# AbstractAsyncContextManager[Callable[[T], None]], # SyncHandlerManager[T] -# ] -AnyHandlerManager: TypeAlias = AbstractAsyncContextManager[ - Callable[[T], Awaitable[None] | None] # Any (sync or async) handler -] - -HandlerMiddleware: TypeAlias = Callable[ - ["FlowHandler[T]"], # Next handler, as FlowHandler[T] - AsyncGenerator[Callable[[T2], Awaitable[None] | None]], # FlowHandler[T2] or a handler func -] -_HandlerMiddleware: TypeAlias = Callable[ - ["FlowHandler[T]"], # Next handler, as FlowHandler[T] - AbstractAsyncContextManager[Callable[[T2], Awaitable[None] | None]], # FlowHandler[T2] or a handler func -] - -HandlerDecorator: TypeAlias = Callable[ - ["FlowHandlerManager[T]"], # Next (wrapped) handler - "FlowHandlerManager[T2]", # Resulting handler -] - -logger = logging.getLogger("localpost.flow") - - -class _ThreadContext(AbstractAsyncContextManager): - def __init__(self, cm: AbstractContextManager): - self._source = cm - - async def __aenter__(self): - return await to_thread.run_sync(self._source.__enter__) # type: ignore - - async def __aexit__(self, exc_type, exc_value, traceback): - return await to_thread.run_sync(self._source.__exit__, exc_type, exc_value, traceback) - - -# Too complex case, maybe for the future -# def handler_manager_factory( -# func: Union[ -# Callable[P, HandlerManager[T]], -# Callable[P, AbstractContextManager[Handler[T]]], -# Callable[P, AsyncGenerator[Handler[T]]], -# Callable[P, Generator[Handler[T]]], -# ], -# ) -> Callable[P, HandlerManager[T]]: -# return cast(Callable[P, HandlerManager[T]], _HandlerManagerFactory.ensure(func)) - - -def handler(func: AnyHandler[T]) -> FlowHandlerManager[T]: - """ - Wrap a function, so it can be used as a flow handler. - """ - assert callable(func) - return FlowHandlerManager(lambda: nullcontext(FlowHandler.ensure(func))) - - -def handler_manager( - func: Callable[[], AsyncGenerator[AnyHandler[T]]], -) -> FlowHandlerManager[T]: - """ - Wrap a generator, so it can be used as a flow handler manager. - """ - assert inspect.isasyncgenfunction(func) - return FlowHandlerManager(asynccontextmanager(func)) - - -def _ensure_handler_manager_factory( - source: Callable[P, AbstractAsyncContextManager[AnyHandler[T]]], -) -> Callable[P, AbstractAsyncContextManager[FlowHandler[T]]]: - @asynccontextmanager - async def _handler_manager(*args, **kwargs): - async with source(*args, **kwargs) as h: - yield FlowHandler.ensure(h) - - return _handler_manager - - -def ensure_async_handler(source: AnyHandler[T]) -> AsyncHandler[T]: - if isinstance(source, FlowHandler): - return source.async_h - return ensure_async_callable(source) - - -def ensure_async_handler_manager(source: AnyHandlerManager[T]) -> AsyncHandlerManager[T]: - @asynccontextmanager - async def _async_handler_manager(): - async with source as h: - yield h.async_h if isinstance(h, FlowHandler) else ensure_async_callable(h) - - return _async_handler_manager() # type: ignore[return-value] - - -def ensure_sync_handler(source: AnyHandler[T]) -> SyncHandler[T]: - if isinstance(source, FlowHandler): - return source.sync_h - return ensure_sync_callable(source) - - -def ensure_sync_handler_manager(source: AnyHandlerManager[T]) -> SyncHandlerManager[T]: - @asynccontextmanager - async def _sync_handler_manager(): - async with source as h: - yield h.sync_h if isinstance(h, FlowHandler) else ensure_sync_callable(h) - - return _sync_handler_manager() # type: ignore[return-value] - - -@dc.dataclass(frozen=True, slots=True) -class _HandlerMiddlewareDecorator(Generic[T, T2]): - middleware: _HandlerMiddleware[T, T2] - - def __call__(self, next_hm: FlowHandlerManager[T]) -> FlowHandlerManager[T2]: - return next_hm << self - - -def handler_middleware(m: HandlerMiddleware[T, T2], /) -> HandlerDecorator[T, T2]: - assert inspect.isasyncgenfunction(m) - return _HandlerMiddlewareDecorator(_handler_middleware(m)) - - -def _handler_middleware(m: HandlerMiddleware[T, T2]) -> _HandlerMiddleware[T, T2]: - return _ensure_handler_manager_factory(asynccontextmanager(m)) - - -@final -@dc.dataclass(eq=False, kw_only=True) -class FlowHandler(Generic[T]): - is_async: bool - async_h: Callable[[T], Awaitable[None]] - sync_h: Callable[[T], None] - - def __call__(self, item: T, /) -> Awaitable[None] | None: - return self.async_h(item) if self.is_async else self.sync_h(item) # type: ignore[func-returns-value] - - def __post_init__(self): - self.__call__ = self.async_h if self.is_async else self.sync_h # type: ignore[assignment] - - @classmethod - def ensure(cls, h: AnyHandler[T]) -> FlowHandler[T]: - if isinstance(h, FlowHandler): - return h - if is_async_callable(h): - return cls.create_async(async_h=cast(AsyncHandler[T], h)) - else: - return cls.create_sync(sync_h=cast(SyncHandler[T], h)) - - @classmethod - def create_async( - cls, *, async_h: AsyncHandler[T] | None = None, sync_h: SyncHandler[T] | None = None - ) -> FlowHandler[T]: - if async_h is None and sync_h is None: - raise ValueError("At least one of async_h or sync_h must be provided") - return cls( - is_async=True, - async_h=async_h if async_h else ensure_async_handler(sync_h), # type: ignore - sync_h=sync_h if sync_h else ensure_sync_handler(async_h), # type: ignore - ) - - @classmethod - def create_sync( - cls, *, async_h: AsyncHandler[T] | None = None, sync_h: SyncHandler[T] | None = None - ) -> FlowHandler[T]: - if async_h is None and sync_h is None: - raise ValueError("At least one of async_h or sync_h must be provided") - return cls( - is_async=False, - async_h=async_h if async_h else ensure_async_handler(sync_h), # type: ignore - sync_h=sync_h if sync_h else ensure_sync_handler(async_h), # type: ignore - ) - - def create( - self, - *, - async_h: Callable[[T2], Awaitable[None]] | None = None, - sync_h: Callable[[T2], None] | None = None, - ) -> FlowHandler[T2]: - if self.is_async: - return FlowHandler.create_async(async_h=async_h, sync_h=sync_h) - else: - return FlowHandler.create_sync(async_h=async_h, sync_h=sync_h) - - -@final -# class _HandlerManagerFactory( -class FlowHandlerManager( # AnyHandlerManager[T] - Generic[T], - AbstractAsyncContextManager[FlowHandler[T]], -): - _source: Callable[..., AbstractAsyncContextManager[Callable[[T], Awaitable[None] | None]]] - _middlewares: tuple[_HandlerMiddleware, ...] - - _resolved_hm: AbstractAsyncContextManager[FlowHandler[T]] | None - - def __init__( - self, factory: Callable[..., AbstractAsyncContextManager[Callable[[T], Awaitable[None] | None]]] - ) -> None: - self._source = factory # Maybe accept static arguments later - self._resolved_hm = None - self._middlewares = () - - def __call__(self, *args, **kwargs) -> AbstractAsyncContextManager[FlowHandler[T]]: - if not self._middlewares: - return _ensure_handler_manager_factory(self._source)(*args, **kwargs) - - @asynccontextmanager - async def _composite_handler_manager(): - hm = self._source(*args, **kwargs) - async with AsyncExitStack() as es: - for middleware in self._middlewares: - h = await es.enter_async_context(hm) - hm = middleware(FlowHandler.ensure(h)) - h = await es.enter_async_context(hm) - yield FlowHandler.ensure(h) - - return _composite_handler_manager() - - def _use(self, m: _HandlerMiddleware[T, T2]) -> FlowHandlerManager[T2]: - f = FlowHandlerManager(self._source) - f._middlewares = self._middlewares + (m,) - return cast(FlowHandlerManager[T2], f) - - def use(self, m: HandlerMiddleware[T, T2], /) -> FlowHandlerManager[T2]: - assert inspect.isasyncgenfunction(m) - return self._use(_handler_middleware(m)) - - # handler << handler_decorator - def __lshift__(self, t: HandlerDecorator[T, T2]) -> FlowHandlerManager[T2]: - if isinstance(t, _HandlerMiddlewareDecorator): - return self._use(t.middleware) - return t(self) - - # handler | service_template - def __xor__(self, service_template: Callable[[AnyHandlerManager[T]], T2]) -> T2: - return service_template(self) - - async def __aenter__(self): - assert not self._resolved_hm # Protect against re-entering - self._resolved_hm = self() - return await self._resolved_hm.__aenter__() - - async def __aexit__(self, exc_type, exc_value, traceback): - assert self._resolved_hm - try: - return await self._resolved_hm.__aexit__(exc_type, exc_value, traceback) - finally: - self._resolved_hm = None - - -@asynccontextmanager -async def stream_consumer( - source: ObjectReceiveStream[T], - h: AsyncHandler[T], - concurrency: int = 1, - process_leftovers: bool = True, -): - async def consume(handle_soon: bool): - while True: - try: - item = await source.receive() - - if handle_soon: # Infinite concurrency, just spawn a new task for each item - tg.start_soon(h, item) - else: - await h(item) - except EndOfStream: - logger.debug("Source stream has been completed, no more items to consume") - break - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - break - - async with source, create_task_group() as tg: - if math.isinf(concurrency): - tg.start_soon(consume, True) - else: - for _ in range(concurrency): - tg.start_soon(consume, False) - - yield - - if process_leftovers: - # Process all the remaining items (until the source stream is completed) - pass - else: - # Immediately stop consuming (close the receiver) and ignore the remaining items - await source.aclose() diff --git a/localpost/flow/_ops.py b/localpost/flow/_ops.py deleted file mode 100644 index 6a1b5f2..0000000 --- a/localpost/flow/_ops.py +++ /dev/null @@ -1,198 +0,0 @@ -from __future__ import annotations - -import math -import time -from collections.abc import Awaitable, Callable, Iterable -from typing import Any, Literal - -from typing_extensions import TypeVar - -from localpost._utils import ( - DelayFactory, - MemoryStream, - ensure_async_callable, - ensure_delay_factory, - ensure_sync_callable, - sleep, -) - -from ._flow import ( - FlowHandler, - HandlerDecorator, - ensure_async_handler, - handler_middleware, - logger, - stream_consumer, -) - -T = TypeVar("T", default=Any) -T2 = TypeVar("T2", default=Any) -R = TypeVar("R", Awaitable[None], None) - - -def delay(value: DelayFactory, /) -> HandlerDecorator[Any, Any]: - jitter_f = ensure_delay_factory(value) - - @handler_middleware - async def middleware(next_h: FlowHandler): - async def _handle_async(item): - item_jitter = jitter_f() - await sleep(item_jitter) - await next_h.async_h(item) - - def _handle_sync(item): - item_jitter = jitter_f() - time.sleep(item_jitter.total_seconds()) - next_h.sync_h(item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware - - -def log_errors(custom_logger=None, /) -> HandlerDecorator[Any, Any]: - h_logger = custom_logger or logger - - @handler_middleware - async def middleware(next_h: FlowHandler): - async def _handle_async(item): - try: - await next_h.async_h(item) - except Exception: # noqa - h_logger.exception("Error while processing a message") - - def _handle_sync(item): - try: - next_h.sync_h(item) - except Exception: # noqa - h_logger.exception("Error while processing a message") - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware - - -# Does NOT work, as we cannot _stop_ the source (events) from the handler -# def take_first(n: int, /): ... - - -def skip_first(n: int, /) -> HandlerDecorator[Any, Any]: - if n < 1: - raise ValueError("n must be greater than or equal to 1") - - @handler_middleware - async def middleware(next_h: FlowHandler): - iter_n = 0 - - async def _handle_async(item): - nonlocal iter_n - if iter_n < n: - iter_n += 1 - else: - await next_h.async_h(item) - - def _handle_sync(item): - nonlocal iter_n - if iter_n < n: - iter_n += 1 - else: - next_h.sync_h(item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware - - -def buffer( - capacity: float, - /, - *, - concurrency: int = 1, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Any, Any]: - """ - Buffer items in an in-memory stream. - """ - if capacity < 0: - raise ValueError("Buffer capacity must be greater than or equal to 0") - if concurrency < 1: - raise ValueError("Concurrency must be greater than or equal to 1") - - @handler_middleware - async def middleware(next_h: FlowHandler): - buffer_writer, buffer_reader = MemoryStream.create(capacity) - stream_h = ensure_async_handler(next_h) - consumer = stream_consumer(buffer_reader, stream_h, concurrency, process_leftovers) - async with consumer, buffer_writer: # As usual, order matters - if math.isinf(capacity) or full_mode == "drop": - yield next_h.create(async_h=buffer_writer.send_or_drop_async, sync_h=buffer_writer.send_or_drop) - else: - yield FlowHandler.create_async(async_h=buffer_writer.send) - - return middleware - - -def filter( # noqa - func: Callable[[T], Awaitable[bool]] | Callable[[T], bool], -) -> HandlerDecorator[T, T]: - async_filter = ensure_async_callable(func) - sync_filter = ensure_sync_callable(func) - - @handler_middleware - async def middleware(next_h: FlowHandler[T]): - async def _handle_async(item: T): - if await async_filter(item): - await next_h.async_h(item) - - def _handle_sync(item: T): - if sync_filter(item): - next_h.sync_h(item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware - - -def map( # noqa - func: Callable[[T2], Awaitable[T]] | Callable[[T2], T], -) -> HandlerDecorator[T, T2]: - async_mapper = ensure_async_callable(func) - sync_mapper = ensure_sync_callable(func) - - @handler_middleware - async def middleware(next_h: FlowHandler[T]): - async def _handle_async(item: T2): - mapped = await async_mapper(item) - await next_h.async_h(mapped) - - def _handle_sync(item: T2): - mapped = sync_mapper(item) - next_h.sync_h(mapped) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware - - -def flatmap( - func: Callable[[T2], Awaitable[Iterable[T]]] | Callable[[T2], Iterable[T]], -) -> HandlerDecorator[T, T2]: - @handler_middleware - async def middleware(next_h: FlowHandler[T]): - async_mapper: Callable[[T2], Awaitable[Iterable[T]]] = ensure_async_callable(func) - sync_mapper: Callable[[T2], Iterable[T]] = ensure_sync_callable(func) - - async def _handle_async(item: T2) -> None: - mapped = await async_mapper(item) - for mapped_item in mapped: - await next_h.async_h(mapped_item) - - def _handle_sync(item: T2) -> None: - mapped = sync_mapper(item) - for mapped_item in mapped: - next_h.sync_h(mapped_item) - - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) - - return middleware diff --git a/localpost/hosting/README.md b/localpost/hosting/README.md new file mode 100644 index 0000000..36e1a8c --- /dev/null +++ b/localpost/hosting/README.md @@ -0,0 +1,31 @@ +# localpost.hosting + +Service lifecycle management and orchestration. A `service` is any async (or +sync) function wrapped with a lifecycle — it goes through `Starting → +Running → ShuttingDown → Stopped`, reacts to signals, and can spawn child +services in the same task group. + +```python +import time +from localpost.hosting import ServiceLifetime, run_app, service + + +@service +def my_service(): + def svc(lt: ServiceLifetime): + lt.set_started() + time.sleep(5) + return svc + + +if __name__ == "__main__": + run_app(my_service()) +``` + +`run_app()` wires `shutdown_on_signal()` (SIGINT / SIGTERM), runs services +under AnyIO (asyncio or Trio), and raises `SystemExit` with the resulting +status code. + +**Full reference:** + +Examples: [`examples/host/`](../../examples/host/). diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index cedb4e5..ae5f03d 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -1,35 +1,53 @@ -from ._app_host import AppHost +from typing import NoReturn + +import anyio + +from .._utils import choose_anyio_backend from ._host import ( - AbstractHost, - ExposedService, - ExposedServiceBase, - Host, - HostedService, - HostedServiceDecorator, - HostedServiceFunc, - HostedServiceSet, - ServiceFunc, + Running, + ServiceF, ServiceLifetime, - ServiceLifetimeManager, + ServiceLifetimeView, ServiceState, - ServiceStatus, - hosted_service, + ShuttingDown, + Starting, + Stopped, + _run_many, + current_service, + observe_services, + run, + serve, + service, ) +from .middleware import shutdown_on_signal __all__ = [ - "AbstractHost", - "AppHost", - "ExposedService", - "ExposedServiceBase", - "Host", - "HostedService", - "HostedServiceDecorator", - "HostedServiceFunc", - "HostedServiceSet", - "ServiceFunc", - "ServiceLifetime", - "ServiceLifetimeManager", + "service", + "observe_services", + "current_service", + "serve", + "run", + "run_app", "ServiceState", - "ServiceStatus", - "hosted_service", + "Starting", + "Running", + "ShuttingDown", + "ServiceLifetime", + "ServiceLifetimeView", + "Stopped", ] + + +def run_app(*svcs: ServiceF) -> NoReturn: + """Run the target app until it stops or is interrupted by a signal, then + exit the process via :class:`SystemExit` with the resulting status code + (``0`` on clean shutdown, ``1`` on failure). + + Intended as the program's ``__main__`` entrypoint — call directly, + no ``sys.exit(...)`` wrapper needed. + """ + + root_svc = svcs[0] if len(svcs) == 1 else _run_many(*svcs) + app = shutdown_on_signal()(root_svc) + exit_code = anyio.run(run, app, None, **choose_anyio_backend()) + raise SystemExit(exit_code) diff --git a/localpost/hosting/_app_host.py b/localpost/hosting/_app_host.py deleted file mode 100644 index d8abd65..0000000 --- a/localpost/hosting/_app_host.py +++ /dev/null @@ -1,76 +0,0 @@ -from collections.abc import Callable -from typing import Any, TypeVar, cast, final, overload - -from localpost.hosting._host import ( - EMPTY_SERVICE, - AbstractHost, - HostedService, - HostedServiceDecorator, - HostedServiceFunc, -) - -T = TypeVar("T", bound=Callable[..., Any]) - - -@final -class AppHost(AbstractHost): - def __init__(self, name: str | None = "app", /): - super().__init__() - self._name = name - self._middlewares: tuple[HostedServiceDecorator, ...] = () - self._root_service = EMPTY_SERVICE - - def __repr__(self): - return f"{self.__class__.__name__}(root_service={self.name!r})" - - def _prepare_for_run(self) -> HostedService: - return self._root_service.use(*self._middlewares).named(self.name) - - @property - def name(self) -> str: - return self._name or self._root_service.name - - @name.setter - def name(self, value: str | None): - self._assert_not_started() - self._name = value - - @property - def root_service(self) -> HostedService: - return self._root_service - - @root_service.setter - def root_service(self, value: HostedServiceFunc): - self._assert_not_started() - self._root_service = HostedService.ensure(value) - - def use(self, *middlewares: HostedServiceDecorator) -> None: - """ - Register middlewares for the root service. - """ - self._assert_not_started() - self._middlewares += middlewares - - def _add_service(self, target: T, name: str | None = None) -> T: - self._assert_not_started() - hs = HostedService.create(target).named(name) - self.root_service += hs - return target - - @overload - def service(self, target: T, /) -> T: ... - - @overload - def service(self, name: str | None, /) -> Callable[[T], T]: ... - - def service(self, target: T | str | None) -> T | Callable[[T], T]: - """ - Register a service in the host. - - Equivalent to: `host.root_service += hosted_service(target).named(name)` - """ - - def _decorator(func: T) -> T: - return self._add_service(func, cast(str | None, target)) - - return self._add_service(target) if callable(target) else _decorator diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 99a3e7c..522061e 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -1,90 +1,55 @@ from __future__ import annotations -import abc -import dataclasses as dc import inspect -import itertools import logging -import threading -from abc import abstractmethod -from collections.abc import AsyncGenerator, Awaitable, Callable, Collection, Generator, Iterable, Iterator, Mapping -from contextlib import asynccontextmanager, contextmanager -from functools import wraps -from typing import ( - TYPE_CHECKING, - Any, - ClassVar, - Final, - Literal, - ParamSpec, - Protocol, - TypeAlias, - TypedDict, - TypeVar, - Union, - cast, - final, - overload, +from _contextvars import ContextVar +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable +from contextlib import ( + AbstractAsyncContextManager, + AbstractContextManager, + AsyncExitStack, + asynccontextmanager, ) +from dataclasses import dataclass, field +from dataclasses import dataclass as define +from functools import cached_property, wraps +from typing import Any, ClassVar, Literal, final, get_type_hints, overload -from anyio import ( - CancelScope, - CapacityLimiter, - create_task_group, - get_cancelled_exc_class, - to_thread, -) -from anyio.abc import TaskGroup, TaskStatus -from anyio.from_thread import BlockingPortal, start_blocking_portal -from typing_extensions import Concatenate, Self +from anyio import CancelScope, CapacityLimiter, create_task_group, get_cancelled_exc_class, to_thread +from anyio.abc import TaskGroup +from anyio.from_thread import BlockingPortal -from localpost._debug import debug +from localpost._portal import Portal from localpost._utils import ( - NO_OP_TS, Event, EventView, - EventViewProxy, - choose_anyio_backend, - def_full_name, + _SupportsAsyncClose, + _SupportsClose, + cancellable_from, is_async_callable, - start_task_soon, + maybe_closing, unwrap_exc, wait_all, ) -T = TypeVar("T") -P = ParamSpec("P") - logger = logging.getLogger("localpost.hosting") -# A custom limiter for anyio.to_thread.run_sync (to avoid using the default limiter capacity for long-running tasks -# (hosted services)). Basically a custom thread pool. -sync_services_limiter = CapacityLimiter(1) - - -@final -@dc.dataclass(frozen=True, slots=True) -class Created: - name: ClassVar[Literal["created"]] = "created" - @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class Starting: name: ClassVar[Literal["starting"]] = "starting" # timeout: float @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class Running: name: ClassVar[Literal["running"]] = "running" - value: Any = None - # graceful_shutdown_scope: CancelScope | None = None @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class ShuttingDown: name: ClassVar[Literal["shutting_down"]] = "shutting_down" reason: BaseException | str | None = None @@ -92,246 +57,170 @@ class ShuttingDown: @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class Stopped: name: ClassVar[Literal["stopped"]] = "stopped" shutdown_reason: BaseException | str | None = None exception: BaseException | None = None -ServiceState = Union[Starting, Running, ShuttingDown, Stopped] -HostState = Union[Created, Starting, Running, ShuttingDown, Stopped] - - -# Just a dict, ready for (JSON) serialization -class ServiceStatus(TypedDict): - name: str - state: Literal["created", "starting", "running", "shutting_down", "stopped"] - services: Collection[ServiceStatus] - shutdown_reason: str | None - exception: str | None - - -class HostLifetime(Protocol): - exit_code: int = 0 - - @property - def name(self) -> str: ... - - @property - def state(self) -> HostState: ... - - @property - def status(self) -> ServiceStatus: ... - - # @property - # def same_thread(self) -> bool: ... - - # @property - # def portal(self) -> BlockingPortal: ... - - @property - def started(self) -> EventView: ... - - @property - def shutting_down(self) -> EventView: ... - - @property - def stopped(self) -> EventView: ... - - def shutdown(self, *, reason: BaseException | str | None = None) -> None: ... - +ServiceState = Starting | Running | ShuttingDown | Stopped -class ServiceLifetime(Protocol): - @property - def name(self) -> str: ... - - @property - def state(self) -> ServiceState: ... - @property - def status(self) -> ServiceStatus: ... +_app_lt: ContextVar[ServiceLifetime] = ContextVar("localpost.current_app") +"""The root service lifetime — set by ``_serve_root`` / ``run`` (when no parent +is given) and read by ``current_app``.""" +_svc_lt: ContextVar[ServiceLifetime] = ContextVar("localpost.current_service") +"""The innermost service lifetime — set by ``_run`` and read by +``current_service``.""" - # @property - # def child_services(self) -> Collection[ServiceLifetimeView]: ... - @property - def started(self) -> EventView: ... +def current_app() -> ServiceLifetimeView: + if lt := _app_lt.get(None): + return lt.view + raise RuntimeError("Not in hosting context") - @property - def shutting_down(self) -> EventView: ... - @property - def stopped(self) -> EventView: ... +def current_service() -> ServiceLifetimeView: + if lt := _svc_lt.get(None): + return lt.view + raise RuntimeError("Not in hosting context") - # --- Common --- - @property - def value(self) -> Any: ... +@dataclass(frozen=True, slots=True) +class ServiceLifetimeView: + _state: ServiceLifetime - @property - def exception(self) -> BaseException | None: ... + started: EventView + shutting_down: EventView + stopped: EventView - @property - def shutdown_reason(self) -> BaseException | str | None: ... - - async def wait_started(self) -> Any: ... - - def shutdown(self, *, reason: BaseException | str | None = None) -> None: ... - - -class ServiceLifetimeManager(Protocol): - @property - def name(self) -> str: ... - - @property - def state(self) -> ServiceState: ... + @asynccontextmanager + async def observe(self) -> AsyncIterator[ServiceLifetimeView]: + await self.started + try: + yield self + finally: + self.shutdown() + await self.stopped @property - def status(self) -> ServiceStatus: ... - - # @property - # def child_services(self) -> Collection[ServiceLifetimeView]: ... + def exit_code(self) -> int: + return self._state.exit_code @property - def started(self) -> EventView: ... + def state(self) -> ServiceState: + return self._state.state @property - def shutting_down(self) -> EventView: ... + def portal(self) -> Portal: + """The hosting layer's :class:`Portal` (one per app, shared with + every nested service). Use it to compose + :class:`localpost.threadtools.AsyncWorkerExecutor` / + :class:`localpost.threadtools.AsyncExecutor` against the same loop + that hosts the service. + """ + return self._state.portal - @property - def stopped(self) -> EventView: ... + def wait_started(self) -> None: + """Helper for sync code, to wait in a thread.""" + self._state.portal.run_async(self.started.wait) - # --- Common --- + def wait_shutting_down(self) -> None: + """Helper for sync code, to wait in a thread.""" + self._state.portal.run_async(self.shutting_down.wait) - @property - def host(self) -> AbstractHost: ... - - def set_started(self, value=None, /, *, graceful_shutdown_scope: CancelScope | None = None) -> None: ... - - def set_shutting_down(self, *, reason: BaseException | str | None = None) -> None: ... - - # 1. PyCharm (at least 2024.03) has a bug when calling a TypeVarTuple-parameterized function with 0 arguments (see - # https://youtrack.jetbrains.com/issue/PY-63820), - # 2. mypy (at least 1.15.0) does not like overloads ("error: Incompatible return value type ... [return-value]"), - # So just skip complex typing here, for now - def start_child_service( # type: ignore[misc] - self, - # func: Callable[[ServiceLifetimeManager, Unpack[PosArgsT]], Awaitable[None]], - func: Callable[..., Awaitable[None]], - /, - # *func_args: Unpack[PosArgsT], - *func_args, - name: str | None = None, - ) -> ServiceLifetime: ... - - -# Everything that can be used as a hosted service, see HostedService.create() -ServiceFunc: TypeAlias = Union[ - Callable[..., Awaitable[None]], - Callable[..., None], -] - -if TYPE_CHECKING: - HostedServiceFunc: TypeAlias = Callable[Concatenate[ServiceLifetimeManager, ...], Awaitable[None]] -else: - # Python 3.10 does not support ... (ellipsis) for Concatenate - HostedServiceFunc: TypeAlias = Callable[..., Awaitable[None]] -# HostedServiceFunc: TypeAlias = Callable[[ServiceLifetimeManager, ...], Awaitable[None]] -# class HostedServiceFunc(Protocol): -# def __call__(self, service_lifetime: ServiceLifetimeManager, *args) -> Awaitable[None]: ... - -HostedServiceDecorator: TypeAlias = Callable[ - [Callable[P, Awaitable[None]]], # [HostedServiceFunc] - Callable[P, Awaitable[None]], # HostedServiceFunc -] - - -def async_service(func: Callable[..., Awaitable[None]]) -> HostedServiceFunc: - """ - Decorator to create a service from an async function. - """ - if len(inspect.signature(func).parameters) >= 1: - return cast(HostedServiceFunc, func) + @overload + def cancel_on_shutdown[F: Callable[..., Any]](self) -> Callable[[F], F]: ... - @wraps(func) - async def _simple_service(lifetime: ServiceLifetimeManager): - with CancelScope() as run_scope: - lifetime.set_started(graceful_shutdown_scope=run_scope) - await func() + @overload + def cancel_on_shutdown[F: Callable[..., Any]](self, target: F, /) -> F: ... - return _simple_service + def cancel_on_shutdown(self, target: Callable[..., Any] | None = None, /) -> Any: + dec = cancellable_from(self.shutting_down) + return dec(target) if target is not None else dec + @overload + def cancel_on_stop[F: Callable[..., Any]](self) -> Callable[[F], F]: ... -def sync_service(func: Callable[..., Any]) -> HostedServiceFunc: - """ - Decorator to create a service from a target sync function (by running it in a separate thread). + @overload + def cancel_on_stop[F: Callable[..., Any]](self, target: F, /) -> F: ... - The target can be lifetime-aware (by accepting `ServiceLifetime` as the first argument). + def cancel_on_stop(self, target: Callable[..., Any] | None = None, /) -> Any: + dec = cancellable_from(self.stopped) + return dec(target) if target is not None else dec - Important: the target must call `from_thread.check_cancelled()` periodically, to check for cancellation. - """ + def shutdown(self, *, reason: BaseException | str | None = None) -> None: + def do_shutdown(): + if self.stopped or self.shutting_down: + return + self._state.shutdown_reason = reason + self._state.shutting_down.set() - @wraps(func) - async def _service(lifetime: ServiceLifetimeManager): - sync_services_limiter.total_tokens += 1 - await to_thread.run_sync(func, lifetime, limiter=sync_services_limiter) + self._state.portal.run_sync(do_shutdown) - @wraps(func) - async def _simple_service(lifetime: ServiceLifetimeManager): - sync_services_limiter.total_tokens += 1 - with CancelScope() as run_scope: - lifetime.set_started(graceful_shutdown_scope=run_scope) - await to_thread.run_sync(func, limiter=sync_services_limiter) + def stop(self) -> None: + self._state.portal.run_sync(self._state.run_scope.cancel) - lifetime_aware = len(inspect.signature(func).parameters) >= 1 - return _service if lifetime_aware else _simple_service +@define(eq=False, unsafe_hash=True) +class ServiceLifetime: + portal: Portal + tg: TaskGroup = field(default_factory=create_task_group) + scope: AsyncExitStack = field(default_factory=AsyncExitStack) -@overload -def hosted_service(target: ServiceFunc, /) -> HostedService: ... + started: Event = field(default_factory=Event) + shutting_down: Event = field(default_factory=Event) + stopped: Event = field(default_factory=Event) + shutdown_reason: BaseException | str | None = None + exception: BaseException | None = None -@overload -def hosted_service(name: str | None, /) -> Callable[[ServiceFunc], HostedService]: ... + _exit_code: int | None = None + @overload + def defer[T](self, t: AbstractContextManager[T]) -> T: ... -def hosted_service(target: ServiceFunc | str | None) -> HostedService | Callable[[ServiceFunc], HostedService]: - """ - Create a hosted service from an arbitrary callable. - """ + @overload + def defer[T: _SupportsClose](self, t: T) -> T: ... - def _decorator(func: ServiceFunc) -> HostedService: - return HostedService.create(func).named(cast(str | None, target)) + def defer(self, t): + def as_cm(t) -> AbstractContextManager: + return t if isinstance(t, AbstractContextManager) else maybe_closing(t) - return HostedService.create(target) if callable(target) else _decorator + return self.scope.enter_context(as_cm(t)) + @overload + async def adefer[T](self, t: AbstractAsyncContextManager[T]) -> T: ... -class _ServiceLifetime: - def __init__(self, name: str, host: AbstractHost, parent_tg: TaskGroup): - self.name = name - self.host = host - self.parent_tg = parent_tg - self.tg = create_task_group() - self.child_services: list[ServiceLifetime] = [] + @overload + async def adefer[T: _SupportsAsyncClose](self, t: T) -> T: ... - self.started = Event() - self.value: Any = None + async def adefer(self, t): + def as_acm(t) -> AbstractAsyncContextManager: + return t if isinstance(t, AbstractAsyncContextManager) else maybe_closing(t) - self.graceful_shutdown_scope: CancelScope | None = None - self.shutting_down = Event() - self.shutdown_reason: BaseException | str | None = None + return await self.scope.enter_async_context(as_acm(t)) - self.stopped = Event() - self.exception: BaseException | None = None + @cached_property + def view(self) -> ServiceLifetimeView: + return ServiceLifetimeView(self, self.started, self.shutting_down, self.stopped) @property - def as_view(self) -> ServiceLifetime: - return self + def exit_code(self) -> int: + if self._exit_code is not None: + return self._exit_code # Set by the user + return 1 if self.exception else 0 + + @exit_code.setter + def exit_code(self, value: int): + if not 0 <= value <= 255: + raise ValueError("Exit code must be in [0,255] range") + object.__setattr__(self, "_exit_code", value) @property - def as_manager(self) -> ServiceLifetimeManager: - return self + def run_scope(self) -> CancelScope: + return self.tg.cancel_scope @property def state(self) -> ServiceState: @@ -340,600 +229,288 @@ def state(self) -> ServiceState: if self.shutting_down: return ShuttingDown(self.shutdown_reason) if self.started: - return Running(self.value) + return Running() return Starting() - @property - def status(self) -> ServiceStatus: - return { - "name": self.name, - "state": self.state.name, - "services": [cs.status for cs in self.child_services], - "shutdown_reason": str(self.shutdown_reason) if self.shutdown_reason else None, - # "exception": traceback.format_exception(self.exception) if self.exception else None, - "exception": str(self.exception) if self.exception else None, - } - - async def wait_started(self) -> Any: - await self.started - return self.value - - def set_started(self, value=None, /, *, graceful_shutdown_scope: CancelScope | None = None): - if self.stopped or self.shutting_down or self.started: - return - - def _do(): + def set_started(self) -> None: + def do_set(): + assert not self.stopped, "Cannot mark already stopped service as started" self.started.set() - self.value = value - self.graceful_shutdown_scope = graceful_shutdown_scope - logger.debug(f"{self.name} started") - - in_host_thread(self.host, _do) - - def set_shutting_down(self, *, reason: BaseException | str | None = None): - self.shutdown(reason=reason) - - def shutdown(self, *, reason: BaseException | str | None = None): - if self.stopped or self.shutting_down: - return - - def _do(): - self.shutdown_reason = reason - self.shutting_down.set() - logger.debug(f"{self.name} shutting down") - if graceful_shutdown_scope := self.graceful_shutdown_scope: - graceful_shutdown_scope.cancel() - - in_host_thread(self.host, _do) - - def start_child_service( - self, - func, - /, - *target_args, - name: str | None = None, - ) -> _ServiceLifetime: - if self.stopped: - raise RuntimeError("Cannot start a child service for a stopped service") - def start_service(): - svc = HostedService.ensure(func).named(name) - svc_lifetime = _ServiceLifetime(svc.name, self.host, self.tg) - self.child_services.append(svc_lifetime.as_view) - self.tg.start_soon(_run_service, svc, target_args, svc_lifetime, name=svc.name) - return svc_lifetime + self.portal.run_sync(do_set) - return in_host_thread(self.host, start_service) + def set_shutting_down(self, reason: BaseException | str | None = None) -> None: + def do_set(): + assert not self.stopped, "Cannot mark already stopped service as started" + if not self.shutting_down.is_set(): + self.shutdown_reason = reason + self.shutting_down.set() + self.portal.run_sync(do_set) -async def _run_service(func, func_args: Iterable[Any], svc_lifetime: _ServiceLifetime): - async def _supervise_service(): - await func(svc_lifetime, *func_args) - if child_services := svc_lifetime.child_services: - await svc_lifetime.shutting_down - for child in child_services: - child.shutdown() + def start(self, svc_f: ServiceF, /) -> ServiceLifetimeView: + """Start a child service from the given function.""" - svc_name = svc_lifetime.name - logger.debug(f"Starting {svc_name}...") - try: - # service_tg will be used for the service itself and its child services - async with svc_lifetime.tg as service_tg: - start_task_soon(service_tg, _supervise_service) - logger.debug(f"{svc_name} stopped") - except get_cancelled_exc_class() as c_exc: # Cancellation exception inherits directly from BaseException - svc_lifetime.exception = c_exc - logger.error(f"{svc_name} got cancelled") - raise # Always propagate the cancellation - except Exception as exc: - exc = unwrap_exc(exc) - svc_lifetime.exception = exc - logger.exception(f"{svc_name} crashed", exc_info=exc) - if debug: - raise exc from exc.__cause__ # Re-raise the original exception for debugging - finally: - svc_lifetime.stopped.set() + def do_start() -> ServiceLifetimeView: + child_lt = ServiceLifetime(self.portal) + self.tg.start_soon(_run, svc_f, child_lt) + return child_lt.view + return self.portal.run_sync(do_start) -@dc.dataclass(frozen=True, eq=False, slots=True) -class _HostExecContext: - root_service_lifetime: _ServiceLifetime - portal: BlockingPortal - thread_id: int - run_scope: CancelScope +ServiceF = Callable[[ServiceLifetime], Awaitable[None]] +# AppF = Callable[[HostLifetime], Awaitable[None]] -def _hs_name(func: object) -> str: - match func: - case HostedService(s, attrs): - return attrs.get("name") or _hs_name(s) - case HostedServiceSet(services): - return "(" + " + ".join(_hs_name(s) for s in services) + ")" - case WrappedHostedService(w, t): - return _hs_name(w) + " >> " + _hs_name(t) - case _: - return getattr(func, "name", def_full_name(func)) +@define() +class _ResolvedService(AbstractAsyncContextManager[ServiceLifetimeView]): + func: ServiceF + _run: AbstractAsyncContextManager | None = field(default=None, compare=False, repr=False) -@final -@dc.dataclass(frozen=True) -class HostedService: # Also a HostedServiceFunc, see __call__() below - """ - Named hosted service callable, immutable. - """ + def __call__(self, lt: ServiceLifetime, /) -> Awaitable[None]: + return self.func(lt) - source: HostedServiceFunc - _attrs: dict[str, Any] = dc.field(compare=False, hash=False) - - @staticmethod - def wraps(wrapped: HostedServiceFunc, attrs: dict[str, Any]) -> Callable[[HostedServiceFunc], HostedService]: - def decorator(func: HostedServiceFunc) -> HostedService: - wrapped_hs = HostedService.ensure(wrapped) - return HostedService(func, **({"name": wrapped_hs.name} | attrs)) - - return decorator - - @staticmethod - def decorator(dec: Callable[..., HostedServiceFunc]) -> HostedServiceDecorator: - def decorate(func: HostedServiceFunc) -> HostedService: - svc_func = func - attrs = {} - if isinstance(func, HostedService): - svc_func = func.source - attrs = func._attrs - return HostedService.ensure(dec(svc_func, **attrs)) - - return decorate - - @staticmethod - def _unwrap(func: HostedServiceFunc, attrs: dict[str, Any]) -> tuple[HostedServiceFunc, dict[str, Any]]: - if isinstance(func, HostedService) and not func._attrs: - return HostedService._unwrap(func.source, attrs) - if isinstance(func, HostedService) and not attrs: - return func.source, func._attrs - if isinstance(func, HostedServiceSet) and len(func) == 1: - return HostedService._unwrap(next(iter(func)), attrs) - return func, attrs - - @classmethod - def create(cls, target: ServiceFunc, /) -> Self: - if isinstance(target, cls): - return target - return cls(async_service(target) if is_async_callable(target) else sync_service(target)) - - @classmethod - def ensure(cls, target: HostedServiceFunc, /) -> Self: - if isinstance(target, cls): - return target - return cls(target) - - def __init__(self, s: HostedServiceFunc, /, **attrs): - if "name" in attrs and not attrs["name"]: - del attrs["name"] - source, attrs = self._unwrap(s, attrs) - if not callable(source): - raise ValueError(f"Invalid service source: {source}") - object.__setattr__(self, "source", source) - object.__setattr__(self, "_attrs", attrs) + async def __aenter__(self) -> ServiceLifetimeView: + assert self._run is None + self._run = serve(self.func, parent=_svc_lt.get(None)) + return await self._run.__aenter__() - @property - def _services(self) -> HostedServiceSet: - if isinstance(self.source, HostedServiceSet) and not self._attrs: - return self.source - return HostedServiceSet(self) + async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool | None: + assert self._run is not None + return await self._run.__aexit__(exc_type, exc_val, exc_tb) - @property - def name(self) -> str: - return _hs_name(self) - def named(self, name: str | None) -> HostedService: - """ - Override the service name. - """ - if name == self._attrs.get("name"): - return self - attrs = self._attrs | {"name": name} - return HostedService(self.source, **attrs) - - @property - def attrs(self) -> Mapping[str, Any]: - return self._attrs - - def annotated(self, **attrs: Any) -> HostedService: - """ - Annotate the service with a set of attributes (kwargs). - """ - if self._attrs == attrs: - return self - attrs = self._attrs | attrs - return HostedService(self.source, **attrs) - - def __call__(self, service_lifetime: ServiceLifetimeManager, *args) -> Awaitable[None]: - return self.source(service_lifetime, *args) - - def __add__(self, other: HostedServiceFunc) -> HostedService: - """ - Combine two services to run in parallel. - """ - if self is EMPTY_SERVICE: - return HostedService.ensure(other) - if other is EMPTY_SERVICE: - return self - if isinstance(other, HostedService): - other = other._services - return HostedService(self._services.add(other)) - - def __iadd__(self, other: HostedServiceFunc) -> HostedService: - return self.__add__(other) - - def wrap(self, target: HostedServiceFunc | Iterable[HostedServiceFunc]) -> HostedService: - """ - Run `target` service(s) inside this service, like in a context manager. - """ - if not callable(target): # Assume multiple services - target = HostedServiceSet(*target) - return HostedService(WrappedHostedService(self, HostedService.ensure(target))) - - # s1 = s1 >> s2 - def __rshift__(self, target: HostedServiceFunc | Iterable[HostedServiceFunc]) -> HostedService: - return self.wrap(target) - - # s1 >>= s2 - def __irshift__(self, target: HostedServiceFunc | Iterable[HostedServiceFunc]) -> HostedService: - return self.wrap(target) - - def use(self, *middlewares: HostedServiceDecorator) -> HostedService: - """ - Apply a middleware to the hosted service (decorate the source callable). - """ - - def _ensure(s: HostedServiceFunc) -> HostedService: - return s if isinstance(s, HostedService) else HostedService(s, **self._attrs) - - source = self - for middleware in middlewares: - source = _ensure(middleware(source)) - return source - - # s1 = s1 // m1 - def __floordiv__(self, middleware: HostedServiceDecorator) -> HostedService: - return self.use(middleware) - - # s1 //= m1 - def __ifloordiv__(self, middleware: HostedServiceDecorator) -> HostedService: - return self.use(middleware) - - -@final -@dc.dataclass(frozen=True, slots=True) -class HostedServiceSet(Collection[HostedService]): - services: frozenset[HostedService] - - def __init__(self, *services: HostedServiceFunc): - def unwrap(): - for s in services: - if isinstance(s, HostedServiceSet): # Flatten if needed - yield from s.services - else: - yield HostedService.ensure(s) - - object.__setattr__(self, "services", frozenset(unwrap())) - - def __repr__(self): - return f"{self.__class__.__name__}(len={len(self)})" - - def __iter__(self) -> Iterator[HostedService]: - return iter(self.services) - - def __len__(self) -> int: - return len(self.services) - - def __contains__(self, x, /) -> bool: - if not callable(x): - return False - other = HostedService.ensure(cast(HostedServiceFunc, x)) - return other in self.services - - def add(self, *services: HostedServiceFunc) -> HostedServiceSet: - return HostedServiceSet(*itertools.chain(services, self.services)) # type: ignore[arg-type] - - async def __call__(self, lifetime: ServiceLifetimeManager) -> None: - async def when_all_started(): - await wait_all(lt.started for lt in svc_lifetimes) - lifetime.set_started() - - async def when_all_done(): - await wait_all(lt.stopped for lt in svc_lifetimes) - lifetime.set_shutting_down() - - async def when_done(svc: ServiceLifetime): - await svc.stopped - if svc.exception: - lifetime.set_shutting_down(reason="Child service crashed") - - svc_lifetimes = [lifetime.start_child_service(s) for s in self.services] - if not svc_lifetimes: - lifetime.set_started() - return - async with create_task_group() as observers_tg: - start_task_soon(observers_tg, when_all_started) - start_task_soon(observers_tg, when_all_done) - for s in svc_lifetimes: - observers_tg.start_soon(when_done, s) - await lifetime.shutting_down - observers_tg.cancel_scope.cancel() - - -@final -@dc.dataclass(frozen=True, slots=True) -class WrappedHostedService: - wrapper: HostedService - target: HostedService - - async def __call__(self, lifetime: ServiceLifetimeManager) -> None: - async def when_target_started(): - await target.started - lifetime.set_started() - - async def when_target_done(): - await target.stopped - lifetime.set_shutting_down() - - async def when_wrapper_done(): - await wrapper.stopped - lifetime.set_shutting_down(reason="Wrapper service stopped") - - wrapper = lifetime.start_child_service(self.wrapper) - await wrapper.started - target = lifetime.start_child_service(self.target) - async with create_task_group() as observers_tg: - start_task_soon(observers_tg, when_target_started) - start_task_soon(observers_tg, when_target_done) - start_task_soon(observers_tg, when_wrapper_done) - await lifetime.shutting_down - observers_tg.cancel_scope.cancel() - target.shutdown() - await target.stopped - wrapper.shutdown() - - -async def _empty_service(lifetime: ServiceLifetimeManager) -> None: - lifetime.set_started() - - -EMPTY_SERVICE: Final = HostedService(_empty_service, name="empty_service") +@overload +def service[**P](target: Callable[P, Any]) -> Callable[P, _ResolvedService]: + """Decorator to create a hosted service.""" -class ExposedService(Protocol): # Also a HostedServiceFunc - def __call__(self, service_lifetime: ServiceLifetimeManager) -> Awaitable[None]: ... +@overload +def service[**P]() -> Callable[[Callable[P, Any]], Callable[P, _ResolvedService]]: + """Decorator to create a hosted service.""" - @property - def started(self) -> EventView: ... - @property - def shutting_down(self) -> EventView: ... +def service(target: Callable[..., Any] | None = None): + def decorator(func: Callable[..., Any]) -> Callable[..., _ResolvedService]: + if inspect.isasyncgenfunction(func): + return _service_cm(func) + if _is_direct_service(func): + return _service_direct(func) - @property - def stopped(self) -> EventView: ... + @wraps(func) + def wrapper(*args, **kwargs): + raw_svc_f = func(*args, **kwargs) + if is_async_callable(raw_svc_f): + svc_f = raw_svc_f + else: + limiter = CapacityLimiter(1) - def shutdown(self, *, reason: BaseException | str | None = None) -> None: ... + async def svc_f(lt: ServiceLifetime) -> None: + await to_thread.run_sync(raw_svc_f, lt, limiter=limiter) + return _ResolvedService(svc_f) -class ExposedServiceBase(abc.ABC): - def __init__(self) -> None: - self._started = EventViewProxy() - self._shutting_down = EventViewProxy() - self._stopped = EventViewProxy() - self._service_lifetime: ServiceLifetimeManager | None = None + return wrapper - @abstractmethod - def __call__(self, service_lifetime: ServiceLifetimeManager) -> Awaitable[None]: ... + return decorator(target) if callable(target) else decorator - def _assert_not_started(self): - if self._service_lifetime: - raise RuntimeError("Service has already started") - @property - def _lifetime(self) -> ServiceLifetimeManager: - if self._service_lifetime: - return self._service_lifetime - raise RuntimeError("Service has not started yet") - - @_lifetime.setter - def _lifetime(self, value: ServiceLifetimeManager): - self._assert_not_started() - self._service_lifetime = value - self._started.resolve(value.started) - self._shutting_down.resolve(value.shutting_down) - self._stopped.resolve(value.stopped) - - @property - def started(self) -> EventView: - return self._started +def _is_direct_service(func: Callable[..., Any]) -> bool: + """Return True for ``@service`` applied directly to ``svc(lt)``.""" + try: + sig = inspect.signature(func) + except (TypeError, ValueError): + return False + + params = [ + p + for p in sig.parameters.values() + if p.kind + in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + } + ] + if len(params) != 1 or params[0].default is not inspect.Parameter.empty: + return False - @property - def shutting_down(self) -> EventView: - return self._shutting_down + annotation = params[0].annotation + if annotation is ServiceLifetime: + return True - @property - def stopped(self) -> EventView: - return self._stopped + try: + return get_type_hints(func).get(params[0].name) is ServiceLifetime + except (NameError, TypeError, AttributeError): + return False - def shutdown(self, *, reason: BaseException | str | None = None) -> None: - self._lifetime.set_shutting_down(reason=reason) +def _service_direct(func: Callable[[ServiceLifetime], Any]) -> Callable[[], _ResolvedService]: + """Decorator adapter for direct async/sync service functions.""" -@final -class _ExposedService(ExposedServiceBase): - def __init__(self, source: HostedServiceFunc): - super().__init__() - self._source = source + @wraps(func) + def wrapper() -> _ResolvedService: + if is_async_callable(func): + return _ResolvedService(func) - def __repr__(self): - return f"ExposedService(source={self._source!r})" + limiter = CapacityLimiter(1) - def __call__(self, service_lifetime: ServiceLifetimeManager) -> Awaitable[None]: - self._lifetime = service_lifetime - return self._source(service_lifetime) + async def svc_f(lt: ServiceLifetime) -> None: + await to_thread.run_sync(func, lt, limiter=limiter) + return _ResolvedService(svc_f) -# # What is a better name: exposed() or observable()?.. -# def exposed(func: HostedServiceFunc) -> ExposedService: -# return _ExposedService(func) + return wrapper -class AbstractHost(ExposedServiceBase, abc.ABC): - def __init__(self) -> None: - super().__init__() - self._exec_context: _HostExecContext | None = None - self._exit_code: int | None = None +def _service_cm(func: Callable[..., AsyncGenerator]): + """Decorator to transform a generator function into a service factory.""" + cm_f = asynccontextmanager(func) - def _assert_not_started(self): - if self._exec_context: - raise RuntimeError("Host has already started") + @wraps(func) + def wrapper(*args, **kwargs): + async def svc_f(lt: ServiceLifetime) -> None: + async with cm_f(*args, **kwargs): + lt.set_started() + await lt.shutting_down.wait() - @abstractmethod - def _prepare_for_run(self) -> HostedService: ... + return _ResolvedService(svc_f) - @property - @abstractmethod - def name(self) -> str: ... + return wrapper - @property - def exit_code(self) -> int: - if self._exit_code is not None: - return self._exit_code # Set by the user - if self._exec_context: - return 1 if self._exec_context.root_service_lifetime.exception else 0 - return 0 - @exit_code.setter - def exit_code(self, value: int): - if not 0 <= value <= 255: - raise ValueError("Exit code must be in [0,255] range") - self._exit_code = value +@asynccontextmanager +async def observe_services(*lifetimes: ServiceLifetimeView): + await wait_all(svc.started for svc in lifetimes) + try: + yield + finally: + for svc in lifetimes: + svc.shutdown() + await wait_all(svc.stopped for svc in lifetimes) - @property - def _exec(self) -> _HostExecContext: - if self._exec_context: - return self._exec_context - raise RuntimeError("Host has not started yet") - @property - def portal(self) -> BlockingPortal: - return self._exec.portal +async def _run(svc_f: ServiceF, lt: ServiceLifetime) -> None: + parent_svc_lt = _svc_lt.set(lt) + try: + # ``lt.scope`` is the outer context: deferred cleanups run AFTER + # the task group has finished, so handlers can still use deferred + # resources during their cancellation window. + async with lt.scope: + async with lt.tg as tg: + await svc_f(lt) + tg.cancel_scope.cancel() # Cancel any remaining tasks + except get_cancelled_exc_class() as exc: # BaseException + lt.exception = exc + raise # Always reraise cancellations + except Exception as exc: # noqa: BLE001 + lt.exception = unwrap_exc(exc) + finally: + lt.stopped.set() + _svc_lt.reset(parent_svc_lt) - @property - def state(self) -> HostState: - if self._exec_context: - return self._exec_context.root_service_lifetime.state - return Created() - @property - def status(self) -> ServiceStatus: - if self._exec_context: - return self._exec_context.root_service_lifetime.status - return { - "name": self.name, - "state": "created", - "services": [], - "shutdown_reason": None, - "exception": None, - } +def serve( + svc: ServiceF, /, *, parent: ServiceLifetime | None = None +) -> AbstractAsyncContextManager[ServiceLifetimeView]: + return _serve_in(svc, parent) if parent else _serve_root(svc) - @property - def same_thread(self) -> bool: - return threading.get_ident() == self._exec.thread_id - def stop(self) -> None: - in_host_thread(self, self._exec.run_scope.cancel) +@asynccontextmanager +async def _serve_root(svc: ServiceF) -> AsyncIterator[ServiceLifetimeView]: + async def wait_started(): + await child_lt.started + wait_tg.cancel_scope.cancel() - def __call__(self, sl: ServiceLifetimeManager) -> Awaitable[None]: - """Run the host as a service (in another host).""" - assert isinstance(sl, _ServiceLifetime) - self._lifetime = sl - self._exec_context = _HostExecContext(sl, sl.host.portal, threading.get_ident(), sl.tg.cancel_scope) - return self._prepare_for_run()(sl) + async def wait_stopped(): + await child_lt.stopped + wait_tg.cancel_scope.cancel() - @asynccontextmanager - async def _aserve_in(self, portal: BlockingPortal, exec_tg: TaskGroup | None = None): - # A premature optimization, to save one task group nesting level - tg: TaskGroup = exec_tg if exec_tg else portal._task_group # noqa - self._lifetime = sl = _ServiceLifetime(self.name, self, tg) - self._exec_context = _HostExecContext(sl, portal, threading.get_ident(), sl.tg.cancel_scope) - tg.start_soon(_run_service, self._prepare_for_run(), (), sl) + async with BlockingPortal() as raw_portal: + portal = Portal(raw_portal) + child_lt = ServiceLifetime(portal) + app_token = _app_lt.set(child_lt) try: - yield cast(HostLifetime, self) + tg = raw_portal._task_group + tg.start_soon(_run, svc, child_lt) + # Wait for either started or stopped (service may fail before starting) + async with create_task_group() as wait_tg: + wait_tg.start_soon(wait_started) + wait_tg.start_soon(wait_stopped) + yield child_lt.view + child_lt.view.shutdown() + await child_lt.stopped finally: - if not self.stopped: - # Wait till all services are stopped (act like a task group, not like a portal) - await self.stopped - - @asynccontextmanager - async def aserve(self, portal: BlockingPortal | None = None) -> AsyncGenerator[HostLifetime, Any]: - """ - Start the host in the current event loop. - - :param portal: An optional portal for the current event loop (thread), if already created. - :return: A context manager that returns the host instance. - """ - if portal is None: - async with BlockingPortal() as portal, self._aserve_in(portal) as lifetime: - yield lifetime - else: - async with create_task_group() as exec_tg, self._aserve_in(portal, exec_tg) as lifetime: - yield lifetime - - async def aexecute( - self, portal: BlockingPortal | None = None, *, task_status: TaskStatus[HostLifetime] = NO_OP_TS - ) -> None: - async with self.aserve(portal) as lifetime: - task_status.started(lifetime) - - @contextmanager - def serve(self) -> Generator[HostLifetime, Any, None]: - """ - Start the host in a separate thread, on a separate event loop. - - Intended mainly for integration with legacy apps. Like when you have an old (not async) app and want to run some - hosted services around it. - - In general, do prefer :meth:`aserve` instead. - """ - logger.debug(f"Starting a separate thread for {self.name}...") - with start_blocking_portal(**choose_anyio_backend()) as thread: - with thread.wrap_async_context_manager(self._aserve_in(thread)) as lifetime: - yield lifetime - - -# def in_host_thread(h: HostLifetime, func: Callable[..., T]) -> T: -def in_host_thread(h: AbstractHost, func: Callable[..., T]) -> T: - if h.same_thread: - return func() - return h.portal.start_task_soon(func).result() + _app_lt.reset(app_token) + + +@asynccontextmanager +async def _serve_in(svc: ServiceF, parent: ServiceLifetime) -> AsyncIterator[ServiceLifetimeView]: + # Open question: when a child service crashes, we currently only shut down + # the parent. An alternative is to re-raise the child's exception so the + # parent's task group sees it. Both have callers; revisit when there's a + # concrete need. + async def bind_parent_to(csl: ServiceLifetimeView): + await csl.stopped + parent.view.shutdown() + + child_lt = parent.start(svc) + async with create_task_group() as observe_tg: + # Bind the parent lifetime (if the child is stopped, shutdown the parent) + observe_tg.start_soon(bind_parent_to, child_lt) + # Wait for either started or stopped (service may fail before starting) + async with create_task_group() as wait_tg: + + async def wait_started(): + await child_lt.started + wait_tg.cancel_scope.cancel() + + async def wait_stopped(): + await child_lt.stopped + wait_tg.cancel_scope.cancel() + + wait_tg.start_soon(wait_started) + wait_tg.start_soon(wait_stopped) + yield child_lt + observe_tg.cancel_scope.cancel() + child_lt.shutdown() + await child_lt.stopped + + +async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: + if parent is not None: + lt = ServiceLifetime(parent.portal) + await _run(svc_f, lt) + return lt.exit_code + async with BlockingPortal() as raw_portal: + portal = Portal(raw_portal) + lt = ServiceLifetime(portal) + app_token = _app_lt.set(lt) + try: + await _run(svc_f, lt) + return lt.exit_code + finally: + _app_lt.reset(app_token) -@final -class Host(AbstractHost): - def __init__(self, root_service: HostedServiceFunc, /): - super().__init__() - self._root_service = HostedService.ensure(root_service) +def _run_many(*svcs: ServiceF) -> ServiceF: + """Compose multiple services into one. Children run concurrently in the + parent's task group; the parent waits for all of them to stop. - def __repr__(self): - return f"{self.__class__.__name__}(root_service={self.name!r})" + Known limitation: when one child crashes, sibling services keep running. + Tracked at the test level — see ``tests/hosting/multi_service.py``. + """ - def _prepare_for_run(self) -> HostedService: - return self._root_service + async def _run(lt: ServiceLifetime) -> None: + children = [lt.start(svc) for svc in svcs] - @property - def name(self) -> str: - return self._root_service.name + async def propagate_shutdown() -> None: + await lt.shutting_down.wait() + for c in children: + c.shutdown() - @property - def root_service(self) -> HostedService: - return self._root_service + if children: + lt.tg.start_soon(propagate_shutdown) + await wait_all(c.stopped for c in children) - @root_service.setter - def root_service(self, value: HostedServiceFunc): - self._assert_not_started() - self._root_service = HostedService.ensure(value) + return _run diff --git a/localpost/hosting/grpc.py b/localpost/hosting/grpc.py deleted file mode 100644 index 9effca5..0000000 --- a/localpost/hosting/grpc.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import final - -import grpc - -from localpost._utils import wait_any - -from ._host import ServiceLifetimeManager - - -@final -class AsyncGrpcService: - def __init__(self, server: grpc.aio.Server): - self._server = server - self.name = "grpc" - self.grace_termination_period = 5 - - @property - def server(self) -> grpc.aio.Server: - return self._server - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - async def handle_svc_shutdown(): - await service_lifetime.shutting_down - # During the grace period, the server won't accept new connections and allow existing RPCs to continue - # within the grace period. - await self._server.stop(self.grace_termination_period) - - await self._server.start() - service_lifetime.set_started() - await wait_any(handle_svc_shutdown, self._server.wait_for_termination) diff --git a/localpost/hosting/http.py b/localpost/hosting/http.py deleted file mode 100644 index 8a8acf8..0000000 --- a/localpost/hosting/http.py +++ /dev/null @@ -1,67 +0,0 @@ -from os import getenv -from typing import Any, Callable, cast, final - -import uvicorn -from anyio import create_task_group -from typing_extensions import Self - -from localpost._utils import start_task_soon - -from ._host import ServiceLifetimeManager - - -# Also see /health endpoint in http_app.py example -@final -class UvicornService: - def __init__(self, config: uvicorn.Config): - self.config = config - self.name = "uvicorn" - - @classmethod - def for_app(cls, app: Callable[..., Any]) -> Self: - return cls( - uvicorn.Config( - app, - host=getenv("UVICORN_HOST", "127.0.0.1"), - port=int(getenv("UVICORN_PORT", "8000")), - log_config=None, # Do not touch current logging configuration - ) - ) - - # It is hard to use server.serve() directly, because it overrides the signal handlers. A possible workaround is - # to call it in a separate thread, but currently it looks like an overkill. - # See uvicorn.Server._serve() for the original implementation. - async def __call__(self, service_lifetime: ServiceLifetimeManager): - config = self.config - server = uvicorn.Server(config) - - if config.should_reload: - raise ValueError("Reload is not supported") - elif config.workers > 1: - raise ValueError("Multiple workers are not supported") - - try: - if not config.loaded: - config.load() - server.lifespan = config.lifespan_class(config) - await server.startup() - except SystemExit as e: - service_lifetime.host.exit_code = cast(int, e.code) - raise e.__context__ if e.__context__ else RuntimeError("Server startup failed") from None - - if not server.started: - raise RuntimeError("Server did not start") - - async def serve(): - service_lifetime.set_started() - await server.main_loop() - service_lifetime.set_shutting_down() - await server.shutdown() - - async def observe_shutdown(): - await service_lifetime.shutting_down - server.should_exit = True - - async with create_task_group() as tg: - start_task_soon(tg, serve) - start_task_soon(tg, observe_shutdown) diff --git a/localpost/hosting/middleware.py b/localpost/hosting/middleware.py new file mode 100644 index 0000000..b3c2f7b --- /dev/null +++ b/localpost/hosting/middleware.py @@ -0,0 +1,59 @@ +from collections.abc import Awaitable, Callable +from functools import wraps + +from anyio import TASK_STATUS_IGNORED, move_on_after, open_signal_receiver +from anyio.abc import TaskStatus + +from localpost._utils import HANDLED_SIGNALS +from localpost.hosting._host import ServiceLifetime, ServiceLifetimeView, logger + +ServiceF = Callable[[ServiceLifetime], Awaitable[None]] + + +async def _observe_started(lt: ServiceLifetimeView, timeout: float) -> None: + with move_on_after(timeout): + await lt.started + return + raise TimeoutError(f"Service did not start within {timeout} second(s)") + + +def start_timeout(timeout: float) -> Callable[[ServiceF], ServiceF]: + def decorator(func: ServiceF) -> ServiceF: + @wraps(func) + def wrapper(lt: ServiceLifetime) -> Awaitable[None]: + lt.tg.start_soon(lt.view.cancel_on_shutdown(_observe_started), lt, timeout) + return func(lt) + + return wrapper + + return decorator + + +async def _handle_signals(h: ServiceLifetimeView, signals, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED): + with open_signal_receiver(*signals) as received: + # Signal handler is now installed. Notify the parent so it can hand + # control to the wrapped service — without this, the service can race + # ahead and a signal arriving early would kill the process. + task_status.started() + async for _ in received: + # First Ctrl+C (or other termination method) + if not h.shutting_down: + logger.info("Shutting down...") + h.shutdown() + continue + # Ctrl+C again + logger.warning("Forced shutdown") + h.stop() + break + + +def shutdown_on_signal(*signals) -> Callable[[ServiceF], ServiceF]: + def decorator(func: ServiceF) -> ServiceF: + @wraps(func) + async def wrapper(lt: ServiceLifetime) -> None: + await lt.tg.start(_handle_signals, lt.view, signals or HANDLED_SIGNALS) + await func(lt) + + return wrapper + + return decorator diff --git a/localpost/hosting/middlewares.py b/localpost/hosting/middlewares.py deleted file mode 100644 index 2f26694..0000000 --- a/localpost/hosting/middlewares.py +++ /dev/null @@ -1,99 +0,0 @@ -import math -from contextlib import AbstractAsyncContextManager -from typing import Any - -from anyio import fail_after - -from localpost._utils import wait_all, wait_any -from localpost.hosting._host import HostedService, HostedServiceDecorator, HostedServiceFunc, _ServiceLifetime, logger - -__all__ = [ - "lifespan", - "start_timeout", - "shutdown_timeout", -] - - -def lifespan(cm: AbstractAsyncContextManager[Any], /) -> HostedServiceDecorator: - def decorator(svc_func: HostedServiceFunc, **attrs) -> HostedService: - @HostedService.wraps(svc_func, attrs) - async def run(sl, *args): - assert isinstance(sl, _ServiceLifetime) - async with cm: - await svc_func(sl, *args) - if child_services := sl.child_services: - await sl.shutting_down - for child in child_services: - child.shutdown() - await wait_all(child.stopped for child in child_services) - - return run - - return decorator - - -def start_timeout(timeout: float, /) -> HostedServiceDecorator: - def decorator(svc_func: HostedServiceFunc, **attrs) -> HostedService: - if timeout > attrs.get("start_timeout", math.inf): - raise ValueError("Timeout must be less than the existing one") - attrs["start_timeout"] = timeout - - @HostedService.wraps(svc_func, attrs) - def run(sl, *args): - assert isinstance(sl, _ServiceLifetime) - sl.parent_tg.start_soon(_observe_service_start, sl, timeout) - return svc_func(sl, *args) - - return run - - return HostedService.decorator(decorator) - - -def shutdown_timeout(timeout: float, /) -> HostedServiceDecorator: - def decorator(svc_func: HostedServiceFunc, **attrs) -> HostedService: - if timeout > attrs.get("shutdown_timeout", math.inf): - raise ValueError("Timeout must be less than the existing one") - attrs["shutdown_timeout"] = timeout - - @HostedService.wraps(svc_func, attrs) - def run(sl, *args): - assert isinstance(sl, _ServiceLifetime) - sl.parent_tg.start_soon(_observe_service_shutdown, sl, timeout) - return svc_func(sl, *args) - - return run - - return HostedService.decorator(decorator) - - -async def _observe_service_shutdown(svc: _ServiceLifetime, timeout: float): - if math.isinf(timeout): - return - assert timeout >= 0 - await wait_any(svc.shutting_down, svc.stopped) - if svc.stopped: - return - service_scope = svc.tg.cancel_scope - if timeout == 0: - service_scope.cancel() - return - try: - with fail_after(timeout): - await svc.stopped - except TimeoutError as exc: - svc.exception = exc - logger.error(f"{svc.name} shutdown timeout") - service_scope.cancel() - - -async def _observe_service_start(svc: _ServiceLifetime, timeout: float): - if math.isinf(timeout): - return - assert timeout > 0 - try: - with fail_after(timeout): - await wait_any(svc.started, svc.stopped) - except TimeoutError as exc: - svc.exception = exc - logger.error(f"{svc.name} startup timeout") - svc.tg.cancel_scope.cancel() diff --git a/localpost/hosting/rsgi.py b/localpost/hosting/rsgi.py new file mode 100644 index 0000000..b481c62 --- /dev/null +++ b/localpost/hosting/rsgi.py @@ -0,0 +1,208 @@ +"""Host as RSGI application — Mode B for Granian deployments. + +When you have hosted services beyond just the HTTP app — schedulers, +gRPC servers, custom background workers — and you want Granian as your +process supervisor, :class:`HostRSGIApp` runs the *whole* hosting stack +inside each Granian worker process. The HTTP request handler is +dispatched per-request via Granian's RSGI interface; the rest of the +services run in the background, sharing memory with the handler the +way a normal :func:`localpost.hosting.run_app` deployment does. + +Granian's lifecycle hooks drive ours: + +- ``__rsgi_init__(loop)`` — enter :func:`localpost.hosting.serve`'s + context manager. Services start in dependency order; the call + returns once everything reaches ``started``. +- ``__rsgi__(scope, proto)`` — dispatch the request via the same RSGI + bridge :func:`localpost.http.to_rsgi` uses. +- ``__rsgi_del__(loop)`` — exit the context manager. Hosting fires + shutdown for every service and waits for ``stopped`` before + returning to Granian. + +Note that **per-worker side effects** are the user's concern. Granian +spawns N workers; ``services=[heartbeat.service()]`` runs in **each** +worker. Cron-style jobs that should fire once need either +``--workers 1`` or external coordination (DB lock, leader election). +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, final + +from localpost._utils import wait_all +from localpost.hosting._host import ServiceF, ServiceLifetime, serve +from localpost.http._async_base import AsyncRequestHandler +from localpost.http.rsgi import _dispatch + +if TYPE_CHECKING: + from localpost.openapi.aio.app import HttpAsyncApp + +__all__ = ["HostRSGIApp"] + + +@final +class HostRSGIApp: + """RSGI application that runs a full hosting lifecycle per Granian worker. + + Args: + services: Background hosted services to run alongside the HTTP + handler (scheduled tasks, gRPC servers, anything that returns + a :data:`localpost.hosting.ServiceF`). + rsgi_handler: Either an :data:`AsyncRequestHandler` (low-level) + or an :class:`HttpAsyncApp` (the framework will compile its + route table internally). + max_body_size: Cap on the request body. See :func:`to_rsgi`. + + Example:: + + from localpost.hosting.rsgi import HostRSGIApp + from localpost.openapi import HttpAsyncApp + from localpost.scheduler import every, scheduled_task + + + app = HttpAsyncApp() + + + @app.get("/") + async def root() -> str: + return "ok" + + + @scheduled_task(every(seconds=5)) + async def heartbeat() -> None: ... + + + rsgi_app = HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, + ) + + # granian --interface rsgi --workers 4 myapp:rsgi_app + """ + + __slots__ = ( + "_handler", + "_lifecycle_task", + "_max_body_size", + "_ready", + "_services", + "_shutdown_signal", + ) + + def __init__( + self, + *, + services: Sequence[ServiceF], + rsgi_handler: AsyncRequestHandler | HttpAsyncApp, + max_body_size: int = 1 << 20, + ) -> None: + self._services = tuple(services) + self._handler = _resolve_handler(rsgi_handler) + self._max_body_size = max_body_size + self._ready: asyncio.Event | None = None + self._shutdown_signal: asyncio.Event | None = None + self._lifecycle_task: asyncio.Task[None] | None = None + + def __rsgi_init__(self, loop: Any) -> None: + """Schedule lifecycle startup on Granian's loop. + + Granian calls this **synchronously** (no ``await``) per worker + process when the worker is set up, so we can't wait for services + to be ``started`` here. Instead we spawn a long-running + :meth:`_lifecycle` task that enters :func:`localpost.hosting.serve` + and holds the lifetime open until :meth:`__rsgi_del__` signals + shutdown. The first :meth:`__rsgi__` call waits on + :attr:`_ready` before dispatching, so requests never see a + half-started host. + + The lifecycle is owned by a single task because anyio's cancel + scopes (used inside :func:`serve`) must be entered and exited + by the same task — splitting ``__aenter__`` / ``__aexit__`` + across two would raise ``"different task than it was entered + in"``. + + We do **not** apply the ``shutdown_on_signal`` middleware: + Granian owns signal handling; ``__rsgi_del__`` is how shutdown + reaches us. + """ + if not self._services: + return + self._ready = asyncio.Event() + self._shutdown_signal = asyncio.Event() + self._lifecycle_task = loop.create_task(self._lifecycle()) + + async def _lifecycle(self) -> None: + assert self._ready is not None + assert self._shutdown_signal is not None + try: + root = self._services[0] if len(self._services) == 1 else _compose_started(self._services) + async with serve(root): + self._ready.set() + await self._shutdown_signal.wait() + finally: + # Always release the gate — if startup failed before we + # set it, requests pile up; release so they fail fast. + self._ready.set() + + async def __rsgi__(self, scope: Any, proto: Any) -> None: + if self._ready is not None and not self._ready.is_set(): + await self._ready.wait() + await _dispatch(self._handler, self._max_body_size, scope, proto) + + def __rsgi_del__(self, loop: Any) -> None: + """Signal the lifecycle task to shut down. + + Granian calls this **synchronously** on its loop thread, so + ``self._shutdown_signal.set()`` runs without scheduling + cross-thread. The lifecycle task exits its + ``async with serve(...)`` block — which fires service shutdown + and waits for ``stopped`` — then this :class:`HostRSGIApp` is + done. + """ + signal = self._shutdown_signal + if signal is not None: + signal.set() + + +def _resolve_handler(handler: AsyncRequestHandler | HttpAsyncApp) -> AsyncRequestHandler: + """Accept either a raw :data:`AsyncRequestHandler` or an + :class:`HttpAsyncApp` (compile its route handler on demand).""" + # Lazy import — hosting shouldn't pull openapi at import time. + from localpost.openapi.aio.app import HttpAsyncApp as _App # noqa: PLC0415 + + if isinstance(handler, _App): + return handler._build_async_handler() + return handler + + +def _compose_started(services: Sequence[ServiceF]) -> ServiceF: + """Multi-service composition that fires ``set_started`` on the parent + once every child reports started. + + Same shape as :func:`_run_many` but suitable for + :func:`localpost.hosting.serve` consumers — ``serve`` waits for + ``started`` (or ``stopped``), and bare ``_run_many`` never fires + its own ``started`` event since it just spawns and waits. Without + this composition, ``serve(_run_many(...))`` deadlocks. ``run_app`` + avoids the issue because it uses ``run`` (no ``started`` wait), but + ``HostRSGIApp`` runs the lifetime through ``serve`` so ``started`` + must propagate. + """ + + async def _run(lt: ServiceLifetime) -> None: + children = [lt.start(svc) for svc in services] + + async def propagate_shutdown() -> None: + await lt.shutting_down.wait() + for c in children: + c.shutdown() + + if children: + lt.tg.start_soon(propagate_shutdown) + await wait_all(c.started for c in children) + lt.set_started() + await wait_all(c.stopped for c in children) + + return _run diff --git a/localpost/hosting/services/__init__.py b/localpost/hosting/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/hosting/services/_asgi.py b/localpost/hosting/services/_asgi.py new file mode 100644 index 0000000..5cd4b54 --- /dev/null +++ b/localpost/hosting/services/_asgi.py @@ -0,0 +1,22 @@ +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any + +from localpost._utils import Event + + +def report_started(started: Event, asgi_app: Callable[..., Any]) -> Callable[..., Any]: + @wraps(asgi_app) + def asgi_app_wrapper( + scope: dict[str, Any], receive: Callable[..., Awaitable[Any]], send: Callable[..., Awaitable[Any]] + ) -> Awaitable[Any]: + def wrapped_send(message: dict[str, Any]) -> Awaitable[Any]: + if message["type"] == "lifespan.startup.complete": + started.set() + return send(message) + + if scope["type"] == "lifespan": + return asgi_app(scope, receive, wrapped_send) + return asgi_app(scope, receive, send) + + return asgi_app_wrapper diff --git a/localpost/hosting/services/click.py b/localpost/hosting/services/click.py new file mode 100644 index 0000000..72c0486 --- /dev/null +++ b/localpost/hosting/services/click.py @@ -0,0 +1,56 @@ +"""Run a Click command (or group) as a hosted service. + +The command runs once with ``standalone_mode=False`` so its outcome maps onto +the hosting layer's exit code instead of calling ``sys.exit`` directly. Click's +own exception types are translated; anything else propagates and surfaces as a +non-zero exit code. + +The Click callback executes synchronously in a worker thread (the ``@service`` +decorator handles the sync-to-async offload). Inside the callback, +``localpost.hosting.current_service()`` returns this service's lifetime view — +use ``shutting_down`` for a non-blocking poll, or ``wait_shutting_down()`` to +block from sync code. ``await ...wait()`` will not work here because the +callback is sync. + +When the command returns, only this service stops; sibling services composed +via ``run_app`` keep running until they finish or shut down on their own. +""" + +from collections.abc import Sequence + +import click + +from localpost.hosting._host import ServiceLifetime, service + + +@service +def click_cmd( + cmd: click.Command, + *, + args: Sequence[str] | None = None, + prog_name: str | None = None, +): + def run(lt: ServiceLifetime) -> None: + lt.set_started() + try: + # In non-standalone mode, ``ctx.exit(code)`` causes ``main()`` to + # *return* the integer exit code instead of raising — mirror Click's + # own ``sys.exit(rv if isinstance(rv, int) else 0)``. + rv = cmd.main( + args=list(args) if args is not None else None, + prog_name=prog_name, + standalone_mode=False, + ) + except click.ClickException as e: + e.show() + lt.exit_code = e.exit_code + except click.exceptions.Abort: + lt.exit_code = 1 + except SystemExit as e: + code = e.code + lt.exit_code = code if isinstance(code, int) else (1 if code else 0) + else: + if isinstance(rv, int): + lt.exit_code = rv + + return run diff --git a/localpost/hosting/services/grpc.py b/localpost/hosting/services/grpc.py new file mode 100644 index 0000000..05541ed --- /dev/null +++ b/localpost/hosting/services/grpc.py @@ -0,0 +1,16 @@ +import grpc + +from localpost.hosting._host import ServiceLifetime, service + + +@service +def grpc_async_server(server: grpc.aio.Server, /, *, grace_termination_period: float | None = 5.0): + async def run(sl: ServiceLifetime): + await server.start() + sl.set_started() + await sl.shutting_down + # During the grace period, the server won't accept new connections and allow existing RPCs to continue + # within the grace period. + await server.stop(grace_termination_period) + + return run diff --git a/localpost/hosting/services/hypercorn.py b/localpost/hosting/services/hypercorn.py new file mode 100644 index 0000000..0c217a2 --- /dev/null +++ b/localpost/hosting/services/hypercorn.py @@ -0,0 +1,24 @@ +from collections.abc import Awaitable + +import hypercorn +from sniffio import current_async_library + +from localpost.hosting._host import ServiceF, ServiceLifetime, service +from localpost.hosting.services._asgi import report_started + + +@service +def hypercorn_server(app, config: hypercorn.Config, /) -> ServiceF: + def run(sl: ServiceLifetime) -> Awaitable[None]: + # See https://hypercorn.readthedocs.io/en/latest/how_to_guides/api_usage.html + # Imports must stay inline — hypercorn ships separate ``trio`` and + # ``asyncio`` ``serve`` modules and we pick one based on the running + # event loop. A top-level import would force one backend at import time. + if current_async_library() == "trio": + from hypercorn.trio import serve # noqa: PLC0415 + else: + from hypercorn.asyncio import serve # type: ignore[assignment] # noqa: PLC0415 + observed_app = report_started(sl.started, app) + return serve(observed_app, config, shutdown_trigger=sl.shutting_down.wait) + + return run diff --git a/localpost/hosting/services/uvicorn.py b/localpost/hosting/services/uvicorn.py new file mode 100644 index 0000000..a90f57e --- /dev/null +++ b/localpost/hosting/services/uvicorn.py @@ -0,0 +1,43 @@ +from contextlib import nullcontext + +import uvicorn +from anyio import create_task_group + +from localpost._utils import AnyEventView +from localpost.hosting._host import ServiceLifetime, service + + +# Also see /health endpoint in http_app.py example +@service +def uvicorn_server(config: uvicorn.Config): + if config.should_reload: + raise ValueError("Uvicorn: reload is not supported") + elif config.workers > 1: + raise ValueError("Uvicorn: multiple workers are not supported") + + async def run(sl: ServiceLifetime) -> None: + server = uvicorn.Server(config) + server_main_loop = server.main_loop + + async def lf_aware_main_loop(): + sl.set_started() + await server_main_loop() + sl.set_shutting_down() + + # Monkey-patch the bound methods on this Server instance: the original + # ``main_loop`` is wrapped to fire ``set_started`` / ``set_shutting_down`` + # at the right moments, and ``capture_signals`` is replaced with a no-op + # CM so uvicorn doesn't install its own signal handlers (we want our + # ``shutdown_on_signal`` middleware to drive shutdown). + server.main_loop = lf_aware_main_loop # ty: ignore[invalid-assignment] + server.capture_signals = nullcontext # ty: ignore[invalid-assignment] + + async def observe_shutdown(trigger: AnyEventView): + await trigger.wait() + server.should_exit = True + + async with create_task_group() as tg: + tg.start_soon(server.serve, None) + tg.start_soon(observe_shutdown, sl.shutting_down) + + return run diff --git a/localpost/http/README.md b/localpost/http/README.md new file mode 100644 index 0000000..af3f810 --- /dev/null +++ b/localpost/http/README.md @@ -0,0 +1,38 @@ +# localpost.http + +A small synchronous HTTP/1.1 server built on +[h11](https://h11.readthedocs.io/), plus a URI-template router, WSGI / ASGI +bridges, and a small framework (`HttpApp`) on top. Three layers, each +usable on its own; pair with `localpost.hosting` for lifecycle, or run +standalone. + +In-process only — for multi-core fanout, run multiple processes under an +external supervisor. Sync server only; async handlers are reached via +`localpost.http.asgi.to_asgi(handler)` plugged into uvicorn / hypercorn / +granian. + +```bash +pip install localpost[http-server] # h11 backend (default, pure Python) +pip install localpost[http-server,http-fast] # also adds the httptools backend +``` + +```python +from localpost.hosting import run_app +from localpost.http import ServerConfig +from localpost.http.app import HttpApp + + +app = HttpApp() + + +@app.get("/{name}") +def hello(name: str): + return f"Hello, {name}!" + + +run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) +``` + +**Full reference:** + +Examples: [`examples/http/`](../../examples/http/). diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py new file mode 100644 index 0000000..e5d4b17 --- /dev/null +++ b/localpost/http/__init__.py @@ -0,0 +1,90 @@ +from localpost.http._async_base import AsyncHTTPReqCtx, AsyncRequestHandler +from localpost.http._base import ( + ConnFactory, + ConnHandler, + HTTPReqCtx, + Middleware, + RequestHandler, + RoundRobinAcceptor, + Selector, + SelectorCallback, + TrackHere, + compose, + start_http_server, +) +from localpost.http._body import aread_body, read_body +from localpost.http._cancel import RequestCancelled, check_cancelled +from localpost.http._pool import streaming_pool_handler, thread_pool_handler +from localpost.http._service import http_server, wsgi_server +from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response +from localpost.http.asgi import to_asgi +from localpost.http.compress import DEFAULT_COMPRESSIBLE_TYPES, compress_handler +from localpost.http.config import LOGGER_NAME, ServerConfig +from localpost.http.router import ( + Route, + RouteMatch, + Router, + Routes, + URITemplate, + route_match, +) +from localpost.http.rsgi import to_rsgi +from localpost.http.static import static_handler +from localpost.http.wsgi import to_wsgi, wrap_wsgi + +__all__ = [ + # config + "ServerConfig", + "LOGGER_NAME", + # server (sync) + "start_http_server", + "HTTPReqCtx", + "RequestHandler", + "Middleware", + "compose", + # async ctx (transport-neutral; concrete adapters in localpost.http.asgi etc.) + "AsyncHTTPReqCtx", + "AsyncRequestHandler", + # selector / accept-side topology + "Selector", + "SelectorCallback", + "ConnHandler", + "ConnFactory", + "TrackHere", + "RoundRobinAcceptor", + # neutral wire types (used directly with HTTPReqCtx) + "Request", + "Response", + "InformationalResponse", + "BodyTooLarge", + # router + "Router", + "Routes", + "Route", + "RouteMatch", + "URITemplate", + "route_match", + # WSGI adapters + "to_wsgi", + "wrap_wsgi", + # ASGI adapters + "to_asgi", + # RSGI adapters + "to_rsgi", + # hosting + "http_server", + "wsgi_server", + "thread_pool_handler", + "streaming_pool_handler", + # static files + "static_handler", + # compression + "compress_handler", + "DEFAULT_COMPRESSIBLE_TYPES", + # cancellation + "check_cancelled", + "RequestCancelled", + # body helpers + "read_body", + "aread_body", +] diff --git a/localpost/http/__main__.py b/localpost/http/__main__.py new file mode 100644 index 0000000..626b619 --- /dev/null +++ b/localpost/http/__main__.py @@ -0,0 +1,62 @@ +"""CLI entry point: ``python -m localpost.http module:handler``.""" + +from __future__ import annotations + +import importlib +import logging + +import click + +from localpost import hosting +from localpost.http import RequestHandler, ServerConfig, http_server, thread_pool_handler +from localpost.threadtools import WorkerExecutor + + +def _load_handler(app_str: str) -> RequestHandler: + if ":" not in app_str: + raise click.BadParameter(f"Expected 'module:attr', got {app_str!r}", param_hint="APP") + module_path, attr = app_str.rsplit(":", 1) + try: + module = importlib.import_module(module_path) + except ImportError as e: + raise click.ClickException(f"Cannot import {module_path!r}: {e}") from e + try: + return getattr(module, attr) # type: ignore[return-value] + except AttributeError as e: + raise click.ClickException(f"{module_path!r} has no attribute {attr!r}") from e + + +@click.command() +@click.argument("app") +@click.option("--host", default="127.0.0.1", show_default=True, help="Bind host.") +@click.option("--port", default=8000, show_default=True, help="Bind port.") +@click.option("--pool/--no-pool", default=False, show_default=True, help="Run handlers on a thread pool.") +@click.option("--selectors", default=1, show_default=True, help="Selector threads.") +@click.option("--acceptor", is_flag=True, default=False, help="Use acceptor topology.") +def main(app: str, host: str, port: int, pool: bool, selectors: int, acceptor: bool) -> None: + """Run a LocalPost HTTP/1.1 server. + + APP is a 'module:handler' reference — e.g. ``myapp:router_handler``. + The attribute must be a :data:`localpost.http.RequestHandler` callable. + Pass ``--pool`` to wrap it with :func:`localpost.http.thread_pool_handler`. + """ + logging.basicConfig(level=logging.INFO) + handler = _load_handler(app) + config = ServerConfig(host=host, port=port) + + @hosting.service + async def _serve(): + if pool: + with WorkerExecutor() as executor: + async with thread_pool_handler(handler, executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + else: + async with http_server(config, handler, selectors=selectors, acceptor=acceptor): + yield + + hosting.run_app(_serve()) + + +if __name__ == "__main__": + main() diff --git a/localpost/http/_async_base.py b/localpost/http/_async_base.py new file mode 100644 index 0000000..231e484 --- /dev/null +++ b/localpost/http/_async_base.py @@ -0,0 +1,119 @@ +"""Async HTTP request-context protocol — transport-neutral. + +Sibling of :class:`localpost.http.HTTPReqCtx` (sync). This module +defines the Protocol that async transports implement — +:mod:`localpost.http.asgi` does so today; an RSGI bridge will follow. +Frameworks on top of ``localpost.http`` (notably +:class:`localpost.openapi.HttpAsyncApp`) write handlers against this +Protocol and never import a transport-specific type. + +``localpost.http`` itself stays sync server-wise — production-grade +async HTTP servers already exist (uvicorn, hypercorn, granian). What +lives here is the Protocol + the adapters that translate those +servers' surfaces into our request-handler shape. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import TYPE_CHECKING, Any, BinaryIO, Protocol, runtime_checkable + +if TYPE_CHECKING: + from localpost.http._types import Request, Response + +__all__ = [ + "AsyncHTTPReqCtx", + "AsyncRequestHandler", +] + + +type AsyncRequestHandler = Callable[["AsyncHTTPReqCtx"], Awaitable[None]] +"""Top-level async request handler — drives one request to a response. + +Symmetric with :data:`localpost.http.RequestHandler` (sync). The async +side doesn't currently split into a pre-body / body-handler phase — +transports either pre-buffer the body (the ASGI default, capped by +``max_body_size``) or expose it via :meth:`AsyncHTTPReqCtx.receive`, +both before this handler runs. +""" + + +@runtime_checkable +class AsyncHTTPReqCtx(Protocol): + """Per-request context for async handlers. + + Mirrors :class:`localpost.http.HTTPReqCtx`'s sync attributes + (``request`` / ``body`` / ``attrs`` / ``response_status`` / addrs / + scheme) so resolvers that read those attributes work against either + flavour. Methods that touch the wire are async. + + The body is read lazily — handlers call + ``await ctx.receive(size)`` (same name as the sync ctx, same + contract: "give me up to ``size`` bytes, or ``b''`` at EOF") or use + :func:`localpost.http.aread_body` to drain into a single + :class:`bytes` object. The bridge never pre-buffers; framework + layers that want a cached body cache it themselves. + + **Members deliberately absent vs. the sync surface.** + + - No ``borrow()`` / ``borrowed`` — async transports (ASGI, RSGI) + own the connection for the lifetime of the coroutine; there is + no selector / worker thread split to hand off across. + - No 1xx :class:`InformationalResponse` path — ASGI's + ``http.response.start`` only models the final response, and + RSGI's response calls are likewise final-only. 100 Continue / + 102 Processing are emitted by the host server (or not at all). + """ + + request: Request + response_status: int | None + attrs: dict[Any, Any] + + @property + def remote_addr(self) -> str | None: ... + @property + def local_addr(self) -> str: ... + @property + def scheme(self) -> str: ... + @property + def disconnected(self) -> bool: + """True once the peer has gone away (mid-response). + + SSE generators / long handlers poll this between events to + short-circuit cleanly when the client disconnects. Mirrors + what :func:`localpost.http.check_cancelled` does on the sync + side — but there's no thread-local indirection here, the user + already holds ``ctx``. + """ + ... + + async def receive(self, size: int = ..., /) -> bytes: + """Read up to ``size`` bytes of the request body. + + If the transport pre-buffered the body before dispatch (the + ASGI default), this slices the buffer. Otherwise it reads from + the wire. Returns ``b""`` at EOF. + """ + ... + + async def complete(self, response: Response, body: bytes | None = None) -> None: + """Send the final response and body in one shot. Terminal.""" + ... + + async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: + """Send ``response`` then drain ``chunks`` to the wire. + + Declarative streaming — the transport owns iteration, so it + chooses pacing / cancellation policy. Terminal. + """ + ... + + async def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + """Send ``response`` and stream ``count`` bytes from ``file`` + starting at ``offset``. + + Transports with native zero-copy support (RSGI's + ``response_file_range``) use it; ASGI falls back to chunked + reads. Terminal. + """ + ... diff --git a/localpost/http/_base.py b/localpost/http/_base.py new file mode 100644 index 0000000..e26404a --- /dev/null +++ b/localpost/http/_base.py @@ -0,0 +1,1243 @@ +"""Parser-agnostic HTTP server infrastructure. + +This module owns the bits of the server that don't touch the HTTP parser: +the listening socket, the selector loop, the op queue / wakeup pipe, +stale-connection sweep, and shutdown. Each concrete backend (h11, httptools) +provides a ``BaseHTTPConn`` subclass driven by its parser's natural idioms. + +The dispatch chain is loose-coupled: + + Selector ── owns fd→SelectorCallback map; nothing HTTP-specific + │ + ▼ + ConnHandler ── after-accept policy; owns RequestHandler + conn_factory; + │ decides which Selector tracks the new conn + ▼ + RequestHandler ── single-shot dispatch on headers-complete; handlers + that need the body call ``ctx.receive(size)`` / + :func:`localpost.http.read_body` themselves + +ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. +""" + +from __future__ import annotations + +import collections +import fcntl +import logging +import os +import selectors +import socket +import threading +import time +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, closing, contextmanager, suppress +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, BinaryIO, Protocol, final + +from localpost.http._types import InformationalResponse, Request, Response +from localpost.http.config import LOGGER_NAME, ServerConfig + +if TYPE_CHECKING: + from collections.abc import Buffer + +__all__ = [ + "BaseHTTPConn", + "BaseServer", + "ConnFactory", + "ConnHandler", + "HTTPReqCtx", + "Middleware", + "RequestHandler", + "RoundRobinAcceptor", + "Selector", + "SelectorCallback", + "TrackHere", + "_NativeReqCtx", + "_native_stream", + "compose", + "start_http_server", +] + + +# -------------------------------------------------------------------------- +# Op queue ops (cross-thread → selector-thread mutations) +# -------------------------------------------------------------------------- + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class _OpTrack: + """Worker-enqueued op: register ``conn`` in the selector with ``data=conn``.""" + + conn: BaseHTTPConn + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class _OpClose: + """Worker-enqueued op: clean up ``selector._fd_to_key[fd]`` after ``sock.close()``. + + The kernel auto-removes the fd from kqueue/epoll on ``close()``. The + Python-side ``_fd_to_key`` dict is what we actually need to clean up so + the conn instance can be GC'd. + """ + + fd: int + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class _OpCleanup: + """Cleanup stale keep-alive connections.""" + + +_Op = _OpTrack | _OpClose | _OpCleanup + + +# -------------------------------------------------------------------------- +# Header-scanning helpers (parser-agnostic; both backends use them) +# -------------------------------------------------------------------------- + + +def content_length(headers: Any) -> int | None: + """Return the ``Content-Length`` value as an int, or ``None`` if absent / malformed. + + Both parsers normalise header names to lowercase bytes, so a direct + equality check is enough. + """ + for name, value in headers: + if name == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + + +def scan_response_headers(headers: Any) -> tuple[bool, bool, bool]: + """One-pass scan: ``(has_connection_close, has_framing, has_chunked)``. + + - ``has_framing`` — either ``Content-Length`` or ``Transfer-Encoding`` + is set, i.e. the auto-frame branch in ``start_response`` should be + skipped. + - ``has_chunked`` — ``Transfer-Encoding`` carries a ``chunked`` token + anywhere in its value (per RFC 7230 §3.3.1, ``chunked`` must be the + final encoding). When true, the httptools backend's ``_chunked`` + flag must be set so subsequent ``send`` calls actually wrap chunks + with ``\\r\\n\\r\\n`` framing. + + Combined to avoid multiple walks per response. + """ + has_close = False + has_framing = False + has_chunked = False + for name, value in headers: + n = name.lower() + if n == b"connection" and b"close" in value.lower(): + has_close = True + elif n == b"content-length": + has_framing = True + elif n == b"transfer-encoding": + has_framing = True + if b"chunked" in value.lower(): + has_chunked = True + return has_close, has_framing, has_chunked + + +# -------------------------------------------------------------------------- +# Canned protocol-error responses +# -------------------------------------------------------------------------- + +# Each backend serialises them with its own writer (h11.Connection.send for the +# h11 impl, the hand-written serialiser for the httptools impl). The httptools +# backend also has access to a fully pre-serialised wire form per canned +# response — see ``_PRESERIALIZED`` below — so error paths skip +# ``_serialize_response`` entirely. + +# RFC 7231 §6.1 reason phrases for the codes the server side actually emits. +# Single source of truth for both the canned-error wire form below and +# the httptools backend's response writer (``_serialize_response``). Kept +# here so it resolves at module import time even when httptools isn't +# installed. +REASON_PHRASES: dict[int, bytes] = { + 100: b"Continue", + 200: b"OK", + 204: b"No Content", + 301: b"Moved Permanently", + 302: b"Found", + 304: b"Not Modified", + 400: b"Bad Request", + 401: b"Unauthorized", + 403: b"Forbidden", + 404: b"Not Found", + 405: b"Method Not Allowed", + 408: b"Request Timeout", + 413: b"Payload Too Large", + 417: b"Expectation Failed", + 500: b"Internal Server Error", + 501: b"Not Implemented", + 502: b"Bad Gateway", + 503: b"Service Unavailable", +} + + +def _build_canned(status_code: int, body: bytes) -> tuple[Response, bytes]: + """Build a canned ``(Response, wire_bytes)`` pair. + + ``wire_bytes`` is the full status-line + headers + body, ready to send + via :func:`_send_all`. Used by the httptools backend for the protocol + error paths to skip per-error serialisation. + """ + response = Response( + status_code=status_code, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + (b"connection", b"close"), + ], + ) + prelude = bytearray(b"HTTP/1.1 ") + prelude += str(status_code).encode("ascii") + prelude += b" " + prelude += REASON_PHRASES[status_code] + prelude += b"\r\n" + for name, value in response.headers: + prelude += name + prelude += b": " + prelude += value + prelude += b"\r\n" + prelude += b"\r\n" + return response, bytes(prelude + body) + + +INTERNAL_ERROR_BODY = b"Internal Server Error" +INTERNAL_ERROR_RESPONSE, INTERNAL_ERROR_WIRE = _build_canned(500, INTERNAL_ERROR_BODY) + +BAD_REQUEST_BODY = b"Bad Request" +BAD_REQUEST_RESPONSE, BAD_REQUEST_WIRE = _build_canned(400, BAD_REQUEST_BODY) + +PAYLOAD_TOO_LARGE_BODY = b"Payload Too Large" +PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_WIRE = _build_canned(413, PAYLOAD_TOO_LARGE_BODY) + +REQUEST_TIMEOUT_BODY = b"Request Timeout" +REQUEST_TIMEOUT_RESPONSE, REQUEST_TIMEOUT_WIRE = _build_canned(408, REQUEST_TIMEOUT_BODY) + + +# -------------------------------------------------------------------------- +# HTTPReqCtx Protocol + handler types +# -------------------------------------------------------------------------- + + +class HTTPReqCtx(Protocol): + """Per-request context handed to a :data:`RequestHandler`. + + Transport-agnostic surface — implemented by both the native + ``localpost.http`` server backends and external transports + (e.g. :func:`localpost.http.wsgi.to_wsgi`'s WSGI bridge). + + The body is read lazily — handlers call :meth:`receive` (or the + :func:`localpost.http.read_body` helper for "give me the whole + body" semantics). The transport never pre-buffers; framework layers + that want a cached body cache it themselves + (:class:`localpost.openapi.HttpApp` populates + ``ctx.attrs[BODY_CACHE_KEY]`` for typed body parameters). + + ``attrs`` is per-request mutable state for cross-cutting concerns + to thread information through. :class:`localpost.http.Router` writes + the matched ``RouteMatch`` here; middlewares can attach auth info, + tracing transactions, etc. + + The ``remote_addr`` / ``local_addr`` / ``scheme`` fields mirror + Granian RSGI's ``scope.client`` / ``scope.server`` / ``scope.scheme`` + — addresses are formatted as ``"host:port"``. ``remote_addr`` is + ``None`` when the peer address isn't available (rare; e.g. some + UNIX-domain transports). + + ``borrowed`` / :meth:`borrow` are degenerate on transports that + don't manage their own connection lifetime (the WSGI bridge always + reports ``borrowed=True`` and :meth:`borrow` is a no-op CM). + + **Sync vs. async surface.** Most members mirror + :class:`localpost.http.AsyncHTTPReqCtx`. The sync-only members + are :meth:`borrow` / :attr:`borrowed` (the native sync server + hands a connection between selector and worker threads; async + transports own their conn for the coroutine's lifetime and have + nothing to borrow — WSGI satisfies this trivially so handler + code stays portable). + + The wire-driver trio (``start_response`` / ``send`` / + ``finish_response``) used to live here too; it has been demoted + to :class:`_NativeReqCtx` (internal). Handlers should call + :meth:`complete` (one-shot) or :meth:`stream` (declarative chunk + iterator) — both transport-portable. Internal sync wire-driver + code (the h11 / httptools backends, the ``sendfile`` path) still + uses the trio under the hood. + """ + + request: Request + response_status: int | None + attrs: dict[Any, Any] + + @property + def remote_addr(self) -> str | None: ... + @property + def local_addr(self) -> str: ... + @property + def scheme(self) -> str: ... + @property + def disconnected(self) -> bool: + """True once the peer has gone away (mid-request / mid-response). + + Native backends do a non-blocking ``recv(1, MSG_PEEK | MSG_DONTWAIT)`` + on the request socket; the result is sticky once ``True``. The WSGI + bridge has no socket handle and always returns ``False`` — surface + host-server disconnects via ``BrokenPipeError`` from the per-chunk + write path instead. + + Mirrors :attr:`localpost.http.AsyncHTTPReqCtx.disconnected` so SSE + / long-running handlers can poll the same property regardless of + transport. Sync handlers that don't have ctx in scope can still + use :func:`localpost.http.check_cancelled` (raises) — this + property is the pull-style alternative. + """ + ... + + @property + def borrowed(self) -> bool: ... + def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... + def receive(self, size: int = ..., /) -> bytes: ... + def complete(self, response: Response, body: bytes | None = None) -> None: ... + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + """Emit ``response`` then drain ``chunks`` to the wire. + + Declarative streaming — the transport owns iteration, so it can + choose its own pacing / cancellation policy. The native server + checks :func:`localpost.http.check_cancelled` between chunks and + returns silently if the client has gone away. External transports + (WSGI bridge) hand the iterator straight to their host server. + """ + + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + """Emit ``response`` and stream ``count`` bytes from ``file`` starting + at ``offset`` via :func:`socket.sendfile` (zero-copy). Terminal — like + :meth:`complete`, no further response calls are valid afterwards. + + Requires the response to declare ``Content-Length: ``; + the backend uses that framing to keep its parser state consistent + with what the kernel actually wrote. The socket is set blocking + (with ``rw_timeout``) for the duration of the syscall — restored + on selector-thread give-back. + """ + + +class _NativeReqCtx(HTTPReqCtx, Protocol): + """Internal Protocol — the native ``localpost.http`` ctx. + + Adds back the imperative wire-driver trio (``start_response`` / + ``send`` / ``finish_response``) and the connection handle that + internal callers (worker pool, handler-error recovery, the + backends' own ``stream`` / ``sendfile`` impls) need. External + transports (WSGI / ASGI / RSGI) do not implement this — they only + satisfy :class:`HTTPReqCtx`. + """ + + @property + def conn(self) -> BaseHTTPConn: ... + def start_response(self, response: Response | InformationalResponse, /) -> None: ... + def send(self, chunk: Buffer, /) -> None: ... + def finish_response(self) -> None: ... + + +RequestHandler = Callable[[HTTPReqCtx], None] +"""Top-level sync request handler — drives one request to a response. + +Dispatched once on ``on_headers_complete`` (selector thread). The handler: + +- replies inline (``ctx.complete(...)`` / ``ctx.stream(...)`` / + ``ctx.sendfile(...)``) for fast cases that don't touch the body — + e.g. router 404 / 405, header-driven auth rejections. +- or calls ``ctx.borrow()`` (or composes with + :func:`localpost.http.thread_pool_handler`) to escape the selector + before reading the body via ``ctx.receive(size)`` / + :func:`localpost.http.read_body`. + +Symmetric with :data:`localpost.http.AsyncRequestHandler` on the async side. +""" + +Middleware = Callable[[RequestHandler], RequestHandler] +"""HTTP middleware: a function that wraps a :data:`RequestHandler`. + +Plain Python decorator pattern — no special chain object. A middleware +can short-circuit before invoking ``inner`` (call ``ctx.complete(...)`` +and return without delegating), inspect / modify the ctx, or run code +after ``inner`` returns:: + + def with_logging(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx): + start = time.monotonic() + try: + inner(ctx) + finally: + if not ctx.borrowed: + _log(start, ctx) + return wrapped +""" + + +def compose(*middlewares: Middleware) -> Middleware: + """Compose middlewares left-to-right (outermost-first). + + ``compose(a, b, c)(handler)`` is equivalent to ``a(b(c(handler)))`` — + on dispatch, ``a`` runs first and ``c`` is closest to the handler. + """ + + def wrap(handler: RequestHandler) -> RequestHandler: + for mw in reversed(middlewares): + handler = mw(handler) + return handler + + return wrap + + +# -------------------------------------------------------------------------- +# I/O helpers +# -------------------------------------------------------------------------- + + +def _send_all(conn: BaseHTTPConn, payload: bytes | bytearray | memoryview) -> None: + """Send all of ``payload`` to ``conn.sock``. + + Optimised for the JSON-API common case: a small response that fits in + the kernel send buffer. Tries non-blocking ``send`` first; on + :exc:`BlockingIOError` (kernel buffer full mid-response) flips to + blocking-with-timeout and drains via ``sendall``. The blocking-mode + fallback **stays sticky** for the rest of the request when the conn + is borrowed — :meth:`HTTPReqCtx._maybe_give_back` resets the socket + to non-blocking on hand-back. On the selector thread the socket is + restored inline; the selector loop assumes non-blocking I/O. + """ + sock = conn.sock + view = payload if isinstance(payload, memoryview) else memoryview(payload) + total = len(view) + sent = 0 + while sent < total: + try: + n = sock.send(view[sent:]) + except BlockingIOError: + # Kernel send buffer full; drain the rest with a blocking + # sendall under ``rw_timeout``. On a borrowed conn we leave + # the socket blocking-with-timeout so subsequent send/recv + # calls in the same request skip the BlockingIOError dance; + # the give-back path resets it. On the selector thread we + # must restore non-blocking before returning. + sock.settimeout(conn.selector.config.rw_timeout) + try: + sock.sendall(view[sent:]) + finally: + if conn.tracked: + sock.settimeout(0) + return + if n == 0: + raise ConnectionAbortedError("socket is broken") + sent += n + + +def _peek_disconnected(sock: socket.socket) -> bool: + """Non-blocking PEEK probe for peer FIN. Used by native ``_HTTPReqCtx`` + implementations to back ``ctx.disconnected``. + + Returns ``True`` iff the peer half-closed (read side) — ``recv`` returns + ``b""`` or the socket is broken (any non-``BlockingIOError`` ``OSError``). + Returns ``False`` when no signal is available (``BlockingIOError``) or + when bytes are buffered and waiting to be read. + + ``MSG_PEEK`` doesn't consume bytes, so calling this from a worker holding + a borrowed conn is safe alongside the worker's own send/recv. + """ + try: + peeked = sock.recv(1, socket.MSG_PEEK | socket.MSG_DONTWAIT) + except BlockingIOError: + return False + except OSError: + return True + return not peeked + + +def _native_stream(ctx: _NativeReqCtx, response: Response, chunks: Iterator[bytes]) -> None: + """Internal default :meth:`HTTPReqCtx.stream` impl on top of the + imperative trio (sync native backends only). + + Delegates to ``ctx.start_response`` / ``ctx.send`` / ``ctx.finish_response``, + interleaving :func:`localpost.http.check_cancelled` between chunks. On + a clean disconnect (``RequestCancelled``) returns silently — the conn + is in an uncertain state and will be closed by the worker pool. + + ``LookupError`` from ``check_cancelled`` (no request context active — + e.g. on the selector thread) is treated as "no cancellation + available" and silently skipped. + """ + # Local import to avoid the import cycle (cancel imports HTTPReqCtx). + from localpost.http._cancel import RequestCancelled, check_cancelled # noqa: PLC0415 + + ctx.start_response(response) + try: + for chunk in chunks: + try: + check_cancelled() + except LookupError: + pass + ctx.send(chunk) + except RequestCancelled: + return + ctx.finish_response() + + +def emit_handler_error(ctx: _NativeReqCtx) -> None: + """Best-effort recovery when a request handler raises. + + Emits a 500 response if no headers have been sent yet; otherwise closes + the connection (we can't go back and prepend a status line to bytes + already on the wire). All I/O failures are swallowed — the goal is to + avoid amplifying one error into another. + + Native-only. WSGI / ASGI transports surface handler errors through + their own protocol-level error path, not through ``ctx.conn.close()``. + """ + logger = logging.getLogger(LOGGER_NAME) + if ctx.response_status is None: + try: + ctx.complete(INTERNAL_ERROR_RESPONSE, INTERNAL_ERROR_BODY) + except Exception: + logger.exception("Failed to send 500 after handler error; closing") + else: + return + ctx.conn.close() + + +# -------------------------------------------------------------------------- +# BaseHTTPConn — abstract per-connection surface +# -------------------------------------------------------------------------- + + +class BaseHTTPConn(ABC): + """Abstract per-connection surface used by :class:`Selector`. + + Subclasses own the parser instance and per-connection state. The + selector only observes the small surface declared here: tracking flag, + socket, fd, idle / close-at timestamps, ``__call__`` entry point, + ``close()``, and the stale-408 emission hook. + + The ``__call__`` signature matches :data:`SelectorCallback` — a conn + *is* the per-fd callback for its own socket. The :data:`RequestHandler` + is captured as the ``handler`` field at construction time (by the + :data:`ConnFactory`). + """ + + selector: Selector + sock: socket.socket + addr: tuple[str, int] + fd: int + handler: RequestHandler + tracked: bool + """``True`` iff this conn is registered in the selector. ``False`` while a + worker has borrowed it (between ``stop_tracking`` and the next ``track``).""" + close_at: float | None + """Used only when tracked, to enforce keep-alive and read timeouts.""" + idle: bool + """``True`` between requests (after a parser cycle reset) or before the + first byte arrives. Flips to ``False`` once any byte has been received + for the current request. Distinguishes idle keep-alive (close silently) + from a stalled mid-request (emit 408 Request Timeout).""" + + @abstractmethod + def __call__(self, sel: Selector, /) -> None: + """Drive the connection: read, parse, dispatch, write. Returns when the + worker should be released (handler took the conn / connection closed / + more data needed from selector). + + Invoked by :meth:`Selector.run` whenever this conn's fd is readable. + """ + + def close(self) -> None: + """Tear down the connection. + + Synchronously sends FIN + closes the socket so the kernel fd is + freed immediately. The selector-side ``_fd_to_key`` cleanup happens + on the selector thread — inline if we are it, else enqueued via + ``_OpClose``. Safe to call from any thread. + """ + was_tracked = self.tracked + self.tracked = False + try: + self.sock.shutdown(socket.SHUT_WR) + except OSError: + pass + try: + self.sock.close() + except OSError: + pass + if not was_tracked: + return + sel = self.selector + if threading.get_ident() == sel._selector_thread_id: + try: + sel._sel.unregister(self.fd) + except (KeyError, ValueError): + pass + else: + sel._ops.append(_OpClose(self.fd)) + sel._wake() + + @abstractmethod + def emit_stale_408(self) -> None: + """Best-effort 408 emission for a stalled mid-request connection. + + Called by :meth:`Selector._cleanup_stale` for conns that received + bytes but never produced a complete request. The h11 impl sends via + the parser; the httptools impl writes raw bytes. Implementations + no-op when no response is appropriate (idle keep-alive, response + already started). Failures are swallowed — the conn is being torn + down anyway. + """ + + +# -------------------------------------------------------------------------- +# Type aliases for the dispatch chain +# -------------------------------------------------------------------------- + + +SelectorCallback = Callable[["Selector"], None] +"""Per-fd callback invoked by :meth:`Selector.run` when an fd it owns is +readable. The selector is passed in so the callback can register or +unregister fds on it. + +Implementations are dataclasses with ``__call__`` (not closures), so their +state is repr-able for debugging. :class:`BaseHTTPConn` itself satisfies +this signature — a conn *is* its own per-fd callback. Other built-in +implementations: :class:`_DrainWakeup` (wakeup pipe), :class:`_AcceptListener` +(listen socket). +""" + +ConnFactory = Callable[ + ["Selector", socket.socket, tuple[str, int], RequestHandler], + BaseHTTPConn, +] +"""Builds a per-connection :class:`BaseHTTPConn`. Backend-specific — +:class:`localpost.http.server_h11.HTTPConn` and +:class:`localpost.http.server_httptools.HTTPConn` both satisfy this shape. +""" + +ConnHandler = Callable[["Selector", socket.socket, tuple[str, int]], None] +"""After-accept policy. Receives the just-accepted raw client socket and +addr (selector that did the accept is passed for reference). + +Default behaviour (:class:`TrackHere`): build a conn for the receiving +selector and track it locally. + +Acceptor mode (:class:`RoundRobinAcceptor`): build a conn bound to a +worker selector and ``post_track`` it across threads. +""" + + +# -------------------------------------------------------------------------- +# Built-in selector callbacks (callable dataclasses, not closures) +# -------------------------------------------------------------------------- + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class _DrainWakeup: + """Selector callback for the wakeup pipe fd. Drains the byte(s) and + pulls any pending ops onto the selector thread. + """ + + def __call__(self, sel: Selector, /) -> None: + sel._drain_wakeup() + sel._drain_ops() + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class _AcceptListener: + """Selector callback for a listen socket. Accepts one connection and + delegates to the configured :data:`ConnHandler`. + + We do **not** loop over ``accept`` here — the listening socket stays + edge-readable until drained, but the selector polls level-triggered + by default; a single accept per readable event keeps fairness with + other fds and matches the existing behaviour. + """ + + listen_sock: socket.socket + conn_handler: ConnHandler + logger: logging.Logger + + def __call__(self, sel: Selector, /) -> None: + client_sock, addr = self.listen_sock.accept() + # Linux 2.6.28+ inherits ``O_NONBLOCK`` from the listening socket; + # macOS / BSD do not. Set explicitly. While the conn is tracked + # the socket is non-blocking; the send/recv paths may flip it to + # blocking-with-timeout on a borrowed conn (``_send_all`` and the + # per-backend receive helpers) and the give-back path resets it + # before re-tracking. + client_sock.setblocking(False) + try: + self.conn_handler(sel, client_sock, addr) + except Exception: + self.logger.exception("ConnHandler raised; closing %s", addr) + with suppress(Exception): + client_sock.close() + + +# -------------------------------------------------------------------------- +# Built-in ConnHandler implementations +# -------------------------------------------------------------------------- + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class TrackHere: + """Default :data:`ConnHandler`. Builds a conn for the accepting selector + and tracks it locally. This is the behaviour the server has always had — + a single thread accepts, parses, and dispatches on the same selector. + """ + + handler: RequestHandler + conn_factory: ConnFactory + + def __call__(self, sel: Selector, sock: socket.socket, addr: tuple[str, int]) -> None: + conn = self.conn_factory(sel, sock, addr, self.handler) + sel.track(conn) + + +@final +@dataclass(slots=True, eq=False) +class RoundRobinAcceptor: + """:data:`ConnHandler` for the acceptor topology. Spreads new conns + across a tuple of worker :class:`Selector` instances using a simple + monotonic counter. + + The worker selectors must be running their own ``run()`` loop on a + separate thread; this handler enqueues ``_OpTrack`` via + :meth:`Selector.post_track`, which is cross-thread safe (op queue + + wakeup pipe). + """ + + workers: tuple[Selector, ...] + handler: RequestHandler + conn_factory: ConnFactory + _next: int = 0 + + def __call__(self, _sel: Selector, sock: socket.socket, addr: tuple[str, int]) -> None: + if not self.workers: + with suppress(Exception): + sock.close() + return + target = self.workers[self._next % len(self.workers)] + self._next += 1 + conn = self.conn_factory(target, sock, addr, self.handler) + target.post_track(conn) + + +# -------------------------------------------------------------------------- +# Selector primitive +# -------------------------------------------------------------------------- + + +@final +class Selector: + """Dumb fd→callback dispatcher with op queue + wakeup pipe. + + A :class:`Selector` is HTTP-agnostic. It owns: + + - a :class:`selectors.BaseSelector` (fd → :data:`SelectorCallback` map) + - a self-pipe wakeup so worker threads can ask the selector thread to + apply ``_OpTrack`` / ``_OpClose`` ops + - the stale-connection sweep over registered :class:`BaseHTTPConn`s + - a ``shutting_down`` flag + + The selector thread is the **single writer** to the underlying + ``selectors.BaseSelector``. ``register`` / ``unregister`` must be + called from the selector thread; the cross-thread mutation paths + (``track`` from a worker, ``close`` from any thread) enqueue ops and + wake the selector. + + Use cases: + + - **All-in-one** (default): a :class:`BaseServer` owns a Selector, + registers its listen socket on it, and runs the loop on one + thread. Same behaviour as before this refactor. + - **Worker-only**: a Selector with no listen socket. The acceptor + topology spawns N of these, each on its own thread; conns are + delivered via :meth:`post_track` from a separate acceptor thread. + """ + + def __init__(self, config: ServerConfig, *, port: int, logger: logging.Logger) -> None: + self.config = config + self.port: int = port + """Bound port the *server* is listening on. Threaded through to + worker selectors so :data:`HTTPReqCtx`-consumers (e.g. the WSGI + bridge) can populate ``SERVER_PORT`` without reaching for the + listen socket.""" + self.logger = logger + self._sel: selectors.BaseSelector = selectors.DefaultSelector() + self.shutting_down: bool = False + """Set to True on context-manager exit. Once set, ``track`` rejects + new registrations and ``_maybe_give_back`` closes connections + instead of returning them to the selector.""" + # Lock-free op queue + self-pipe wakeup. The selector thread is the + # single writer to ``self._sel``; cross-thread mutations from + # workers (``track`` from ``_maybe_give_back``, ``close`` from error + # paths) enqueue ops and write a wakeup byte. The selector drains at + # the top of every iteration and on wakeup-callback events. + self._ops: collections.deque[_Op] = collections.deque() + r, w = os.pipe() + os.set_blocking(r, False) + os.set_blocking(w, False) + for fd in (r, w): + flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) + self._wakeup_r: int = r + self._wakeup_w: int = w + self._sel.register(r, selectors.EVENT_READ, data=_DrainWakeup()) + self._selector_thread_id: int | None = None + """Cached on the first ``run()`` call. Used to route ``track`` / + ``close`` calls inline when invoked from the selector thread itself + (e.g. from the accept callback).""" + + # ----- Public registration API (selector-thread only) ----- + + def register(self, fd: int, callback: SelectorCallback, /) -> None: + """Register ``fd`` with ``callback``. Selector-thread only.""" + self._sel.register(fd, selectors.EVENT_READ, data=callback) + + def unregister(self, fd: int, /) -> None: + """Unregister ``fd``. Selector-thread only. Swallows missing-fd errors.""" + try: + self._sel.unregister(fd) + except (KeyError, ValueError): + pass + + # ----- Conn tracking ----- + + def track(self, conn: BaseHTTPConn) -> None: + """Register ``conn`` in the selector for normal HTTP processing. + + Safe from any thread. When called from a worker, the actual + ``selectors.register`` happens on the selector thread (drained from + ``self._ops`` at the top of the next iteration). ``conn.tracked`` + is flipped optimistically so concurrent readers see the intended + state. + + The socket is non-blocking whenever a conn is tracked. The send / + recv paths (see :func:`_send_all` and the receive helpers in each + backend) lazily flip the socket to blocking-with-timeout on the + first ``BlockingIOError`` and leave it sticky for the rest of + the request on a borrowed conn; ``_maybe_give_back`` resets the + socket to non-blocking before re-tracking. The common case + (response fits in the kernel buffer) pays zero ``settimeout`` + calls per request. + + **Synchronisation edge for parser ownership.** This method's + op-queue enqueue + wakeup-pipe ``os.write`` (in :meth:`_wake`) + is a full memory barrier: anything the worker did to the conn's + parser (``parser.send`` for h11; ``parser.feed_data`` callbacks + for httptools streaming) is visible to the selector after this + call. + """ + if self.shutting_down: + try: + conn.sock.close() + except OSError: + pass + conn.tracked = False + return + if conn.tracked: + return # already tracked — no-op, nothing to enqueue + conn.tracked = True # optimistic + if threading.get_ident() == self._selector_thread_id: + self._apply_track(conn) + else: + self._ops.append(_OpTrack(conn)) + self._wake() + + def stop_tracking(self, conn: BaseHTTPConn) -> None: + """Unregister ``conn`` from the selector; the worker becomes the sole I/O owner. + + Selector-thread only (called from the dispatcher inside the conn's + ``__call__``). Socket stays non-blocking — the worker's send + path (:func:`_send_all`) handles partial writes and falls back + to blocking-with-timeout only when the kernel buffer fills. + Client-disconnect detection while the conn is borrowed lives in + :func:`localpost.http.check_cancelled` (pull-based ``MSG_PEEK``). + + **Synchronisation edge for parser ownership.** Once this returns, + the selector is done with the conn's parser (h11.Connection / + httptools.HttpRequestParser); the worker has exclusive access + until :meth:`track` re-registers. See the parser field's + docstring on each backend for the full invariant. + """ + try: + self._sel.unregister(conn.sock) + except (KeyError, ValueError): + pass + conn.tracked = False + + def post_track(self, conn: BaseHTTPConn) -> None: + """Cross-thread :meth:`track`. Always enqueues ``_OpTrack`` and wakes + the selector — never applies inline. Used by the acceptor + topology to deliver fresh conns from the acceptor thread to a + worker selector. + """ + if self.shutting_down: + with suppress(OSError): + conn.sock.close() + conn.tracked = False + return + conn.tracked = True + self._ops.append(_OpTrack(conn)) + self._wake() + + def post_close(self, fd: int) -> None: + """Cross-thread close-cleanup. Enqueues a ``_OpClose`` so the + selector thread cleans its ``_fd_to_key`` map after the worker + already closed the socket. + """ + self._ops.append(_OpClose(fd)) + self._wake() + + def post_cleanup(self) -> None: + """Cross-thread: ask the selector thread to sweep stale conns. + + Coalesced inside :meth:`_drain_ops` — multiple posts before the + next drain pass run :meth:`_cleanup_stale` once. Cheap when no + conns are stale (single dict iteration). + """ + self._ops.append(_OpCleanup()) + self._wake() + + # ----- Op queue + self-pipe helpers ----- + + def _wake(self) -> None: + """Signal the selector that ``self._ops`` has work. + + ``BlockingIOError`` (pipe full) is benign: a wakeup is already pending, + the existing byte will fire ``select()`` and the queue is the source + of truth. Other ``OSError`` (pipe closed during shutdown) is also + swallowed. + """ + try: + os.write(self._wakeup_w, b"\x00") + except (BlockingIOError, OSError): + pass + + def _drain_wakeup(self) -> None: + try: + while os.read(self._wakeup_r, 4096): + pass + except (BlockingIOError, OSError): + pass + + def _drain_ops(self) -> None: + """Apply all pending ops on the selector thread. + + ``_OpCleanup`` is coalesced — multiple posts in the same drain pass + run :meth:`_cleanup_stale` once at the end. + """ + cleanup_due = False + while True: + try: + op = self._ops.popleft() + except IndexError: + break + try: + if isinstance(op, _OpTrack): + self._apply_track(op.conn) + elif isinstance(op, _OpClose) and op.fd in self._sel.get_map(): + try: + self._sel.unregister(op.fd) + except (KeyError, ValueError): + pass + elif isinstance(op, _OpCleanup): + cleanup_due = True + except Exception: + self.logger.exception("Op handler raised for %r", op) + if cleanup_due: + self._cleanup_stale() + + def _apply_track(self, conn: BaseHTTPConn) -> None: + """Selector-thread handler for ``_OpTrack``. + + Probes ``self._sel.get_map()`` (an O(1) dict-``in`` check) to choose + ``modify`` vs ``register``, instead of catching ``KeyError`` from + ``modify``. The error path inside ``selectors.modify`` builds the + exception message as ``f"{fileobj!r}"`` — and ``socket.__repr__`` + is surprisingly expensive (~25 µs/call). Per-request overhead. + """ + sock = conn.sock + sel = self._sel + try: + if conn.fd in sel.get_map(): + sel.modify(sock, selectors.EVENT_READ, data=conn) + else: + sel.register(sock, selectors.EVENT_READ, data=conn) + except Exception: # noqa: BLE001 + conn.tracked = False + try: + sock.close() + except OSError: + pass + + # ----- Stale-conn sweep ----- + + def _find_stale(self) -> Iterator[BaseHTTPConn]: + now = time.monotonic() + for key in self._sel.get_map().values(): + data = key.data + if isinstance(data, BaseHTTPConn) and data.close_at and now > data.close_at: + yield data + + def _cleanup_stale(self) -> None: + # Selector-thread only. Lock-free: ``self._sel`` and ``conn.tracked`` + # are owned by the selector thread. + stale = list(self._find_stale()) + for conn in stale: + try: + self._sel.unregister(conn.sock) + except (KeyError, ValueError): + pass + conn.tracked = False + for conn in stale: + # Stalled mid-request gets a 408; idle keep-alive gets silently + # dropped. The decision and the bytes-on-wire are the conn's + # job — backends differ. + with suppress(Exception): + conn.emit_stale_408() + with suppress(OSError): + conn.sock.close() + + # ----- Run loop ----- + + def run(self, *, timeout: float | None = None) -> None: + """One iteration of the selector loop. Should be called repeatedly until the selector is stopped. + + ``timeout`` bounds the underlying ``selectors.select`` call — it caps + how long this method blocks before returning to the caller, giving + the caller a chance to check for shutdown / cancellation. Defaults to + ``config.select_timeout``. + + Uniform dispatch — every fd in the map has a :data:`SelectorCallback` + as ``key.data``. No isinstance branches, no sentinel comparisons. + """ + if self._selector_thread_id is None: + self._selector_thread_id = threading.get_ident() + if timeout is None: + timeout = self.config.select_timeout + self._drain_ops() + for key, _ in self._sel.select(timeout=timeout): + cb: SelectorCallback = key.data + try: + cb(self) + except Exception: + self.logger.exception("Selector callback raised: %r", cb) + + # ----- Shutdown ----- + + def shutdown(self) -> None: + """Set the shutdown flag, drain residual ops, close registered conns, + and close the wakeup pipe. + + Called from the outer context manager *after* the selector loop has + stopped. Workers calling ``track`` / ``close`` post-shutdown + self-clean via the ``shutting_down`` check. + """ + self.shutting_down = True + + # Drain residual ops — selector won't process them, so the conn + # references would otherwise leak. + while True: + try: + op = self._ops.popleft() + except IndexError: + break + if isinstance(op, _OpTrack): + try: + op.conn.sock.close() + except OSError: + pass + op.conn.tracked = False + # _OpClose just cleans _fd_to_key — handled by the walk below. + + for key in list(self._sel.get_map().values()): + data = key.data + if not isinstance(data, BaseHTTPConn): + continue + try: + self._sel.unregister(data.sock) + except (KeyError, ValueError): + pass + try: + data.sock.close() + except OSError: + pass + data.tracked = False + + # Close the wakeup pipe. + with suppress(KeyError, ValueError): + self._sel.unregister(self._wakeup_r) + for fd in (self._wakeup_r, self._wakeup_w): + try: + os.close(fd) + except OSError: + pass + + def close(self) -> None: + """Close the underlying ``selectors.BaseSelector``. Idempotent.""" + with suppress(Exception): + self._sel.close() + + +# -------------------------------------------------------------------------- +# BaseServer — listen socket + Selector + ConnHandler composition +# -------------------------------------------------------------------------- + + +@final +class BaseServer: + """Listening server: composes a :class:`Selector`, a listen socket, and + a :data:`ConnHandler`. + + Parser-agnostic; the conn factory lives inside the :data:`ConnHandler` + (so ``BaseServer`` itself doesn't know whether you're using the h11 or + httptools backend). + + The default :class:`TrackHere` ``ConnHandler`` reproduces today's + behaviour: each accepted connection is built and tracked on the same + selector that accepted it. Use :class:`RoundRobinAcceptor` to + distribute conns across worker selectors. + """ + + def __init__( + self, + config: ServerConfig, + conn_handler: ConnHandler, + logger: logging.Logger, + server_sock: socket.socket, + selector: Selector, + ) -> None: + self.sock = server_sock + self.port: int = server_sock.getsockname()[1] + """Actual port the server is listening on (useful when port 0 is + specified to auto-assign a free port).""" + self.selector = selector + self.config = config + self.conn_handler = conn_handler + self.logger = logger + # Listen socket is registered with an _AcceptListener callback. + server_sock.settimeout(0) + selector.register( + server_sock.fileno(), + _AcceptListener(server_sock, conn_handler, logger), + ) + + def run(self, *, timeout: float | None = None) -> None: + """Forwarder to :meth:`Selector.run`. Convenience for the common + all-in-one shape (BaseServer owns its selector and the caller + drives one loop). + """ + self.selector.run(timeout=timeout) + + @property + def shutting_down(self) -> bool: + return self.selector.shutting_down + + def shutdown(self) -> None: + """Tear down: shut down the selector (which closes registered conns + and the wakeup pipe). The listening socket is closed by the outer + context manager. + """ + self.selector.shutdown() + + +# -------------------------------------------------------------------------- +# Public entry points +# -------------------------------------------------------------------------- + + +def _cleanup_ticker(period: float, selector: Selector, stop: threading.Event) -> None: + """Daemon-thread cleanup driver for the bare ``start_http_server`` CM. + + Posts an ``_OpCleanup`` to ``selector`` every ``period`` seconds until + ``stop`` is set. Hosted callers use the anyio variant in ``_service.py`` + instead — this exists so the bare context manager remains self-contained. + """ + while not stop.wait(period): + selector.post_cleanup() + + +@contextmanager +def _start_http_server( + config: ServerConfig, + handler: RequestHandler, + conn_factory: ConnFactory, + /, +) -> Iterator[BaseServer]: + """Open a listening socket and yield a :class:`BaseServer` driving ``conn_factory``. + + Internal helper. Public callers use :func:`start_http_server`, which + selects the per-backend ``HTTPConn`` factory based on + :attr:`ServerConfig.backend`. + """ + logger = logging.getLogger(LOGGER_NAME) + server_sock = socket.create_server( + (config.host, config.port), + backlog=config.backlog, + reuse_port=True, + ) + port = server_sock.getsockname()[1] + selector = Selector(config, port=port, logger=logger) + conn_handler = TrackHere(handler, conn_factory) + + cleanup_stop = threading.Event() + cleanup_thread = threading.Thread( + target=_cleanup_ticker, + args=(config.select_timeout, selector, cleanup_stop), + name=f"localpost-http-cleanup-{port}", + daemon=True, + ) + + with closing(server_sock): + server = BaseServer(config, conn_handler, logger, server_sock, selector) + logger.info("Serving on %s:%d", config.host, server.port) + cleanup_thread.start() + try: + yield server + finally: + cleanup_stop.set() + cleanup_thread.join(timeout=config.select_timeout * 2) + server.shutdown() + selector.close() + + +def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> AbstractContextManager[BaseServer]: + """Open a listening socket and yield a server driving the configured backend. + + Backend is read from ``config.backend``. ``"h11"`` (default) is the + pure-Python parser shipped with the core install; ``"httptools"`` is + the C-based llhttp wrapper and requires the ``[http-fast]`` extra. + """ + backend = config.backend + if backend == "h11": + from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 + elif backend == "httptools": + try: + from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 + except ImportError as e: + raise ImportError( + "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" + ) from e + else: + raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") + return _start_http_server(config, handler, HTTPConn) diff --git a/localpost/http/_body.py b/localpost/http/_body.py new file mode 100644 index 0000000..68603cf --- /dev/null +++ b/localpost/http/_body.py @@ -0,0 +1,66 @@ +"""Request-body buffering helpers. + +The server core never pre-buffers the request body — handlers that need +the whole body in memory call one of these helpers explicitly. They sit +on top of the public ``ctx.receive(size)`` Protocol method and enforce +``max_size`` while accumulating. + +Sync (:func:`read_body`) and async (:func:`aread_body`) are siblings — +same contract, different I/O flavour. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from localpost.http._types import BodyTooLarge +from localpost.http.config import DEFAULT_BUFFER_SIZE + +if TYPE_CHECKING: + from localpost.http._async_base import AsyncHTTPReqCtx + from localpost.http._base import HTTPReqCtx + +__all__ = ["aread_body", "read_body"] + + +_DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MiB — matches ServerConfig.max_body_size default. + + +def read_body(ctx: HTTPReqCtx, *, max_size: int = _DEFAULT_MAX_BODY_SIZE) -> bytes: + """Read the full request body into a single :class:`bytes` object. + + Loops :meth:`HTTPReqCtx.receive` until EOF, accumulating into a + ``bytearray`` and converting at the end. Raises :class:`BodyTooLarge` + if the running total would exceed ``max_size`` (checked before each + accumulating concat, so the helper never holds more than + ``max_size + chunk_size`` bytes in memory). + + Sync handlers must hold a borrowed connection (or be wrapped by + :func:`localpost.http.thread_pool_handler`) before calling — the + selector loop is non-blocking and a synchronous body read needs the + blocking-with-timeout socket mode that ``borrow()`` arranges. + """ + buf = bytearray() + while True: + chunk = ctx.receive(DEFAULT_BUFFER_SIZE) + if not chunk: + return bytes(buf) + if len(buf) + len(chunk) > max_size: + raise BodyTooLarge(len(buf) + len(chunk)) + buf += chunk + + +async def aread_body(ctx: AsyncHTTPReqCtx, *, max_size: int = _DEFAULT_MAX_BODY_SIZE) -> bytes: + """Async sibling of :func:`read_body`. + + Same contract: drain ``await ctx.receive(size)`` until EOF, raising + :class:`BodyTooLarge` if the cap is exceeded mid-stream. + """ + buf = bytearray() + while True: + chunk = await ctx.receive(DEFAULT_BUFFER_SIZE) + if not chunk: + return bytes(buf) + if len(buf) + len(chunk) > max_size: + raise BodyTooLarge(len(buf) + len(chunk)) + buf += chunk diff --git a/localpost/http/_cancel.py b/localpost/http/_cancel.py new file mode 100644 index 0000000..45fc016 --- /dev/null +++ b/localpost/http/_cancel.py @@ -0,0 +1,119 @@ +"""HTTP-native request cancellation. + +Independent of AnyIO and :mod:`localpost.threadtools` by design. Those layers +manage *worker* / *task* cancellation; this module manages *request* +cancellation — a distinct lifetime that ends when the HTTP client goes away +or when the hosted service is shutting down. +""" + +from __future__ import annotations + +import socket +import threading +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import final + +__all__ = ["RequestCancelled", "RequestCancel", "check_cancelled"] + + +class RequestCancelled(Exception): + """Raised by :func:`check_cancelled` when the current request is cancelled. + + Triggers: + * The HTTP client disconnected mid-request (detected by a non-blocking + ``MSG_PEEK`` on the request socket). + * The hosted service is shutting down. + + Inherits from :class:`Exception` (not :class:`BaseException`): request-scoped + cancellation is meant to be catchable by ordinary ``except Exception:`` blocks. + Worker / service cancellation lives on a separate, AnyIO-driven channel. + """ + + +@final +@dataclass(slots=True, eq=False) +class RequestCancel: + """Per-request cancellation token. + + Three signals collapsed into one: + + * Explicit :meth:`cancel` (sets ``_event``) — used by tests and any future + per-request timeout. + * Service shutdown — a single shared :class:`threading.Event` is OR-ed in. + Set once when the hosted service is winding down; every in-flight token + sees it without a registry. + * Client disconnect — :meth:`is_cancelled` does a non-blocking + ``recv(1, MSG_PEEK | MSG_DONTWAIT)`` on the request socket. ``b""`` means + EOF (peer FIN); ``BlockingIOError`` means no signal; any other ``OSError`` + treats the connection as broken. Once detected, the result is cached on + ``_event`` so subsequent reads don't re-issue the syscall. + + ``MSG_PEEK`` is safe alongside the worker's send/recv on the same socket: + peek doesn't consume bytes, and the read and write paths are independent at + the kernel level. + """ + + _sock: socket.socket + _shutdown_event: threading.Event + _event: threading.Event = field(default_factory=threading.Event) + + def cancel(self) -> None: + self._event.set() + + @property + def fired(self) -> bool: + """``True`` iff cancellation was already signalled — explicit cancel, + service shutdown, or a previous PEEK detected disconnect (cached on + ``_event``). + + Cheap (no syscall). For the cooperative ``check_cancelled`` path that + actively probes the socket for client disconnect, use :attr:`is_cancelled`. + """ + return self._event.is_set() or self._shutdown_event.is_set() + + @property + def is_cancelled(self) -> bool: + if self.fired: + return True + try: + peeked = self._sock.recv(1, socket.MSG_PEEK | socket.MSG_DONTWAIT) + except BlockingIOError: + return False + except OSError: + self._event.set() + return True + if not peeked: + self._event.set() + return True + return False + + +_current: ContextVar[RequestCancel] = ContextVar("localpost.http._cancel.current") + + +def check_cancelled() -> None: + """Cooperative cancellation check for HTTP request handlers. + + Raises: + RequestCancelled: if the current request was cancelled. + LookupError: if called outside an HTTP request context. + """ + try: + token = _current.get() + except LookupError: + raise LookupError("check_cancelled() called outside a request handler") from None + if token.is_cancelled: + raise RequestCancelled + + +@contextmanager +def _enter_request(token: RequestCancel) -> Generator[None]: + """Bind ``token`` to the calling thread for the duration of the block.""" + reset = _current.set(token) + try: + yield + finally: + _current.reset(reset) diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py new file mode 100644 index 0000000..a60676a --- /dev/null +++ b/localpost/http/_pool.py @@ -0,0 +1,160 @@ +"""Worker-pool wrapper for HTTP request handlers. + +:func:`thread_pool_handler` wraps a :data:`RequestHandler` so each +request runs on a worker thread (borrowed from a caller-owned +:class:`localpost.threadtools.Executor`) with a borrowed connection. +Body reads (``ctx.receive(size)`` / :func:`localpost.http.read_body`) +and syscalls like ``ctx.sendfile`` block the worker, not the selector. + +The executor is *not* owned by this wrapper — pass an open executor in. +The wrapper opens an internal :class:`localpost.threadtools.TaskGroup` +on the caller's executor for the duration of the ``async with`` so it +can drain only the requests it dispatched at handler shutdown. +""" + +from __future__ import annotations + +import logging +import threading +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager, suppress +from typing import cast + +from anyio import to_thread + +from localpost.http._base import ( + PAYLOAD_TOO_LARGE_BODY, + PAYLOAD_TOO_LARGE_RESPONSE, + RequestHandler, + _NativeReqCtx, + emit_handler_error, +) +from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request +from localpost.http._types import BodyTooLarge +from localpost.http.config import LOGGER_NAME +from localpost.threadtools import Executor, TaskGroup + + +class _Pool: + """Dispatcher that runs request handlers on a shared :class:`TaskGroup`.""" + + __slots__ = ("_shutdown_event", "_tg") + + def __init__(self, tg: TaskGroup, shutdown_event: threading.Event) -> None: + self._tg = tg + self._shutdown_event = shutdown_event + + def dispatch(self, inner: RequestHandler) -> RequestHandler: + """Wrap ``inner`` so each request borrows the conn and queues + for a worker. Worker runs ``inner(ctx)`` on a blocking-with-timeout + socket; ``inner`` reads the body via ``ctx.receive(...)`` and + completes the response.""" + tg = self._tg + shutdown_event = self._shutdown_event + + def dispatcher(ctx: _NativeReqCtx) -> None: + ctx.conn.selector.stop_tracking(ctx.conn) + cancel = RequestCancel(_sock=ctx.conn.sock, _shutdown_event=shutdown_event) + tg.start_soon(_run_request, ctx, cancel, inner) + + def pre_body(ctx: _NativeReqCtx) -> None: + # httptools fires callbacks inside ``parser.feed_data`` — defer + # the worker dispatch until the parser feed returns so the + # worker doesn't race the parser. h11 has no such constraint. + defer = getattr(ctx, "_defer_streaming_dispatch", None) + if defer is not None: + defer(dispatcher) + else: + dispatcher(ctx) + + return cast(RequestHandler, pre_body) + + +def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: RequestHandler) -> None: + """Run a single request on the worker thread. + + Body-too-large maps to 413; other exceptions are logged and turned + into a generic 500 (or close on the spot if the response already + started). On per-request cancellation the conn is closed because + its protocol state is uncertain. + """ + logger = logging.getLogger(LOGGER_NAME) + try: + with _enter_request(cancel): + try: + fn(ctx) + except RequestCancelled: + with suppress(Exception): + ctx.conn.close() + except BodyTooLarge: + _emit_body_too_large(ctx) + except Exception: + logger.exception( + "Pool handler raised for %s %r", + ctx.request.method, + ctx.request.target, + ) + emit_handler_error(ctx) + finally: + # On the success path the handler's response-write code already re-tracked the conn + # via ``_maybe_give_back``; we MUST NOT touch it here. The only case left is per-request + # cancellation: the handler caught the signal and returned, but the conn is in an + # uncertain state. ``cancel.fired`` is the cheap (no-syscall) check. + if cancel.fired: + with suppress(Exception): + ctx.conn.close() + + +def _emit_body_too_large(ctx: _NativeReqCtx) -> None: + """Map worker-side body-limit failures to 413 instead of generic 500.""" + if ctx.response_status is None: + with suppress(Exception): + ctx.complete(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) + return + with suppress(Exception): + ctx.conn.close() + + +@asynccontextmanager +async def thread_pool_handler( + inner: RequestHandler, + executor: Executor, + /, +) -> AsyncGenerator[RequestHandler]: + """Yields a :data:`RequestHandler` that runs each request on a worker + thread borrowed from ``executor``. + + ``inner`` runs on a worker on a blocking-with-timeout socket — body + reads (``ctx.receive(...)`` / :func:`localpost.http.read_body`) and + other blocking syscalls don't stall the selector. The executor is + caller-owned; this wrapper does not enter / close it. + + Per-request cancellation surfaces through + :func:`localpost.http.check_cancelled` (client disconnect via + non-blocking ``MSG_PEEK``; handler shutdown via a single + :class:`threading.Event` shared by every in-flight token). + + On exit, signals in-flight handlers via the cancel event and waits + for the internal task group to drain. The drain is offloaded to a + thread so the surrounding event loop stays responsive. + + Example:: + + with WorkerExecutor() as ex: + async with thread_pool_handler(router.as_handler(), ex) as h: + async with http_server(config, h): + ... + """ + shutdown_event = threading.Event() + tg = TaskGroup(executor, name="http-pool") + tg.__enter__() + try: + yield _Pool(tg, shutdown_event).dispatch(inner) + finally: + shutdown_event.set() + await to_thread.run_sync(tg.__exit__, None, None, None) + + +# Streaming wrapping shape ended up identical to ``thread_pool_handler`` after the dispatch +# unification — keep the alias so callers that prefer the streaming-shaped name still resolve. +streaming_pool_handler = thread_pool_handler diff --git a/localpost/http/_sentry.py b/localpost/http/_sentry.py new file mode 100644 index 0000000..3390800 --- /dev/null +++ b/localpost/http/_sentry.py @@ -0,0 +1,33 @@ +"""Shared helpers for the Sentry tracing wrappers. + +Used by :mod:`localpost.http.router_sentry` and +:mod:`localpost.http.flask_sentry`. Not part of the public API. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +import sentry_sdk +from sentry_sdk.tracing import Span + + +@contextmanager +def request_transaction( + *, + op: str, + name: str, + source: str, + method: str, + url: str, +) -> Iterator[Span]: + """Open a Sentry isolation scope + transaction for an HTTP request. + + Sets standard request tags (``http.method``, ``http.url``); callers + record the response status via ``tx.set_http_status`` before exit. + """ + with sentry_sdk.isolation_scope(), sentry_sdk.start_transaction(op=op, name=name, source=source) as tx: + tx.set_tag("http.method", method) + tx.set_data("http.url", url) + yield tx diff --git a/localpost/http/_service.py b/localpost/http/_service.py new file mode 100644 index 0000000..8647cc0 --- /dev/null +++ b/localpost/http/_service.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import dataclasses +import logging +import socket +from collections.abc import Awaitable +from contextlib import closing +from wsgiref.types import WSGIApplication + +import anyio +from anyio import Event, create_task_group, from_thread, to_thread + +from localpost import hosting +from localpost._utils import AnyEventView +from localpost.hosting import ServiceLifetime +from localpost.http._base import ( + BaseServer, + RequestHandler, + RoundRobinAcceptor, + Selector, + start_http_server, +) +from localpost.http.config import LOGGER_NAME, ServerConfig +from localpost.http.wsgi import wrap_wsgi + +__all__ = ["http_server", "wsgi_server"] + + +def _resolve_ephemeral_port(host: str) -> int: + """Pin an ephemeral port up-front so all selectors agree on it. + + With ``selectors > 1`` and ``config.port == 0`` each selector would + bind to a *different* ephemeral, defeating the shared-port design. + Bind once here, return the assigned port, then close — selectors + rebind via ``SO_REUSEPORT``. Tiny race window vs other processes + grabbing the port; same window the existing test fixture accepts. + """ + with socket.create_server((host, 0)) as probe: + return probe.getsockname()[1] + + +async def _periodic_cleanup(period: float, shutdown: AnyEventView, *selectors: Selector) -> None: + """Drive stale-conn cleanup on every selector at a fixed cadence. + + One coroutine per selector group is enough — the per-selector op queue + coalesces duplicates, and the sweep itself is cheap when nothing is + expired. Returns cleanly when ``shutdown`` is set so the surrounding + task group can finish; the wait is checkpointed every ``period`` seconds + via ``anyio.move_on_after``. + """ + while not shutdown.is_set(): + with anyio.move_on_after(period): + await shutdown.wait() + if shutdown.is_set(): + return + for sel in selectors: + sel.post_cleanup() + + +def _pick_conn_factory(backend: str): + """Match :func:`start_http_server`'s backend selection — used by the + acceptor topology, where we wire :class:`RoundRobinAcceptor` directly + instead of going through ``start_http_server``.""" + if backend == "h11": + from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 + + return HTTPConn + if backend == "httptools": + try: + from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 + except ImportError as e: + raise ImportError( + "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" + ) from e + return HTTPConn + raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") + + +@hosting.service +def http_server( + config: ServerConfig, + handler: RequestHandler, + /, + *, + selectors: int = 1, + acceptor: bool = False, +): + """Run an HTTP server inside a hosted service. + + Backend (``h11`` / ``httptools``) is selected via + :attr:`ServerConfig.backend`. ``handler`` is invoked on the + **selector thread** for every accepted request — synchronous handlers + (e.g. a :class:`Router` answering 404 / 405 inline) complete without + leaving that thread. Handlers that need a worker pool should be + wrapped with :func:`localpost.http.thread_pool_handler` *before* + being passed in. + + The service has no request-cancellation machinery of its own. + Per-request cancellation lives in :func:`localpost.http.check_cancelled` + and is wired up by :func:`thread_pool_handler` (the only place that + needs it — selector-thread handlers run to completion synchronously + and have no thread to signal). + + Args: + config: Listening / timeouts / buffer-size / backend config. + handler: Sync request handler. Shared across selector threads when + ``selectors > 1`` — make sure any state it captures is + thread-safe (``thread_pool_handler`` already is). + selectors: Number of selector threads. Default 1. + + With ``acceptor=False`` (default), each selector binds its own + listening socket on the same address via ``SO_REUSEPORT``; + the kernel distributes incoming connections. ``config.port == 0`` + is resolved to an ephemeral port once and shared by all + selectors. Measured impact on standard CPython / macOS is + flat (GIL holds during parser callbacks + dispatch + channel + handoff; macOS ``SO_REUSEPORT`` doesn't load-balance like + Linux). Useful on Linux (kernel-level distribution) and on + free-threaded builds; see ``benchmarks/macro/http/PERF_FINDINGS.md`` + Phase 7. + + With ``acceptor=True``, ``selectors`` is the number of + **worker** selectors fed by a single acceptor thread. See + ``acceptor`` below. + acceptor: When ``True``, run a dedicated acceptor thread that + accepts every connection on the listen socket and round-robins + it to one of ``selectors`` worker selectors via the cross-thread + op queue. Useful when ``SO_REUSEPORT`` doesn't distribute + evenly (macOS, free-threaded builds). The acceptor thread does + no parsing; worker selectors do all I/O for the conns assigned + to them. + """ + if selectors < 1: + raise ValueError(f"selectors must be >= 1 (got {selectors})") + + if acceptor: + return _run_acceptor(config, handler, workers=selectors) + + if selectors == 1: + + def run_single(lt: ServiceLifetime) -> Awaitable[None]: + def run_server() -> None: + with start_http_server(config, handler) as server: + lt.set_started() + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + server.run() + + return to_thread.run_sync(run_server) + + return run_single + + # Multi-selector: N independent ``BaseServer`` threads bound to the + # same address via ``SO_REUSEPORT`` (already enabled in + # ``_start_http_server``). The kernel hashes incoming SYNs across + # the listening sockets; established conns stay with one selector for + # their lifetime. The ``handler`` instance is shared — when wrapped + # with ``thread_pool_handler`` the underlying channel naturally accepts + # multiple producers. + actual_config = ( + dataclasses.replace(config, port=_resolve_ephemeral_port(config.host)) if config.port == 0 else config + ) + + async def run_multi(lt: ServiceLifetime) -> None: + started: list[Event] = [Event() for _ in range(selectors)] + + def run_one(idx: int) -> None: + with start_http_server(actual_config, handler) as server: + from_thread.run_sync(started[idx].set) + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + server.run() + + async def wait_started() -> None: + for ev in started: + await ev.wait() + lt.set_started() + + async with create_task_group() as tg: + tg.start_soon(wait_started) + for i in range(selectors): + tg.start_soon(to_thread.run_sync, run_one, i) + + return run_multi + + +def _run_acceptor(config: ServerConfig, handler: RequestHandler, *, workers: int): + """Acceptor topology: 1 acceptor thread + N worker-selector threads. + + The acceptor's :class:`BaseServer` runs a :class:`RoundRobinAcceptor` + as its :data:`ConnHandler`; each accepted client socket is wrapped in + a :class:`BaseHTTPConn` bound to the next worker :class:`Selector` and + delivered via :meth:`Selector.post_track` (cross-thread op queue + + wakeup pipe). + """ + conn_factory = _pick_conn_factory(config.backend) + + async def run(lt: ServiceLifetime) -> None: + logger = logging.getLogger(LOGGER_NAME) + # Bind the listen socket once on the calling (anyio) thread so we + # can compute ``port`` deterministically before any worker spins + # up. This mirrors what ``_start_http_server`` does internally. + listen_sock = socket.create_server( + (config.host, config.port), + backlog=config.backlog, + reuse_port=True, + ) + port = listen_sock.getsockname()[1] + logger.info("Serving on %s:%d (acceptor + %d workers)", config.host, port, workers) + + # Build worker selectors first so the acceptor's ConnHandler can + # capture them. Each worker has its own Selector with its own + # wakeup pipe / op queue. ``shutting_down`` is per-selector — we + # toggle them on shutdown. + worker_selectors = tuple(Selector(config, port=port, logger=logger) for _ in range(workers)) + acceptor_selector = Selector(config, port=port, logger=logger) + round_robin = RoundRobinAcceptor( + workers=worker_selectors, + handler=handler, + conn_factory=conn_factory, + ) + acceptor_server = BaseServer(config, round_robin, logger, listen_sock, acceptor_selector) + + worker_started: list[Event] = [Event() for _ in range(workers)] + acceptor_started = Event() + + def run_worker(idx: int) -> None: + sel = worker_selectors[idx] + try: + from_thread.run_sync(worker_started[idx].set) + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + sel.run() + finally: + sel.shutdown() + sel.close() + + def run_acceptor() -> None: + try: + from_thread.run_sync(acceptor_started.set) + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + acceptor_server.run() + finally: + acceptor_server.shutdown() + acceptor_selector.close() + + async def wait_started() -> None: + await acceptor_started.wait() + for ev in worker_started: + await ev.wait() + lt.set_started() + + try: + async with create_task_group() as tg: + tg.start_soon(wait_started) + tg.start_soon( + _periodic_cleanup, + config.select_timeout, + lt.shutting_down, + acceptor_selector, + *worker_selectors, + ) + tg.start_soon(to_thread.run_sync, run_acceptor) + for i in range(workers): + tg.start_soon(to_thread.run_sync, run_worker, i) + finally: + with closing(listen_sock): + pass + + return run + + +def wsgi_server( + config: ServerConfig, + app: WSGIApplication, + /, + *, + selectors: int = 1, + acceptor: bool = False, +): + """Same as :func:`http_server`, but for a WSGI application. + + A WSGI app blocks during request handling (response body iteration is + synchronous). Wrap with :func:`thread_pool_handler` if you want to + serve more than one request at a time:: + + async with thread_pool_handler(wrap_wsgi(my_app)) as h: + async with http_server(config, h): + ... + """ + return http_server(config, wrap_wsgi(app), selectors=selectors, acceptor=acceptor) diff --git a/localpost/http/_types.py b/localpost/http/_types.py new file mode 100644 index 0000000..f358ae1 --- /dev/null +++ b/localpost/http/_types.py @@ -0,0 +1,77 @@ +"""Neutral HTTP message types — independent of any specific parser. + +Both server backends (h11, httptools) populate the same shapes. User code +imports these from :mod:`localpost.http` and never touches a parser-specific +type directly. + +Field shapes intentionally match h11's wire-level conventions: + +- header names are lowercased ASCII bytes +- header values are bytes (server-side: ISO-8859-1 / ASCII per RFC 7230) +- ``http_version`` is the bare version (``b"1.1"``), not the prefixed form +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field + +__all__ = [ + "Request", + "Response", + "InformationalResponse", + "BodyTooLarge", +] + + +@dataclass(frozen=True, slots=True, eq=False) +class Request: + """Parsed HTTP request line + headers. Body is streamed via :meth:`HTTPReqCtx.receive`.""" + + method: bytes + """Uppercased ASCII method (e.g. ``b"GET"``). Both backends normalise here + so consumers can compare against ``b"GET"`` / ``b"POST"`` / ... without + case-folding per request.""" + target: bytes + """Raw request-URI from the request line, including query string.""" + path: bytes + """Path component of ``target`` (everything before ``?``). Pre-split by the + backend so consumers don't redo the split on every request — the httptools + backend uses ``httptools.parse_url`` (C-level), the h11 backend a manual + ``split(b'?', 1)``.""" + query_string: bytes + """Query string of ``target`` (everything after the first ``?``), or ``b""`` + if absent. Pre-split alongside :attr:`path`.""" + headers: Sequence[tuple[bytes, bytes]] + """Header pairs in arrival order. Names are lowercased; values are as-sent.""" + http_version: bytes = b"1.1" + """HTTP version as bare bytes (``b"1.1"`` or ``b"1.0"``).""" + + +@dataclass(frozen=True, slots=True, eq=False) +class Response: + """Final response (2xx-5xx) — exactly one per request.""" + + status_code: int + headers: Sequence[tuple[bytes, bytes]] = field(default_factory=list) + reason: bytes = b"" + """Reason phrase. Empty → backend supplies a default for the status code.""" + + +@dataclass(frozen=True, slots=True, eq=False) +class InformationalResponse: + """1xx response (100 Continue, 102 Processing, …). Multiple may precede the final response.""" + + status_code: int + headers: Sequence[tuple[bytes, bytes]] = field(default_factory=list) + reason: bytes = b"" + """Reason phrase. Empty → backend supplies a default for the status code.""" + + +class BodyTooLarge(Exception): + """Raised when an incoming request body would exceed ``ServerConfig.max_body_size``. + + Surfaces both from ``HTTPReqCtx.receive`` (when the handler is reading the body) + and from the connection's drain path (when the handler skipped reading it). The + connection loop converts it into a 413 Payload Too Large response. + """ diff --git a/localpost/http/app.py b/localpost/http/app.py new file mode 100644 index 0000000..b595e75 --- /dev/null +++ b/localpost/http/app.py @@ -0,0 +1,337 @@ +"""Simple application framework on top of LocalPost HTTP server. + +`HttpApp` is the user-friendly layer above :class:`localpost.http.Router` +and the HTTP server. It provides: + +- Decorators (``.get``, ``.post``, ``.put``, ``.delete``, ``.patch``) + that register handlers under a URI template. +- Automatic parameter injection: handler params named to match path + variables get the matched value as ``str``; params annotated as + :data:`localpost.http.HTTPReqCtx` get the request context. +- Automatic response conversion: ``str`` → ``text/plain``, ``bytes`` → + ``application/octet-stream``, ``dict`` / ``list`` → ``application/json``, + :data:`localpost.http.Response` → as-is, ``(Response, bytes)`` + tuple → with body, ``None`` → 204. +- Worker-pool dispatch for matched routes (each handler runs on a + worker with a borrowed conn — use :func:`localpost.http.read_body` + to consume the request body when needed). +- App-level and per-route middleware composition. + +For lower-level control, drop down to :class:`localpost.http.Router` + +hand-written :data:`localpost.http.RequestHandler` s. + +Example:: + + app = HttpApp() + + + @app.get("/{name}") + def hello(name: str): + return f"Hello, {name}!" + + + @app.post("/{name}/profile") + def update_profile(ctx: HTTPReqCtx, name: str): + profile = json.loads(read_body(ctx)) + return {"updated": name, "profile": profile} + + + @app.post("/{name}/avatar") + def upload_avatar(ctx: HTTPReqCtx, name: str): + with open(f"/tmp/{name}.jpg", "wb") as f: + while chunk := ctx.receive(8192): + f.write(chunk) + return Response(status_code=204, headers=[(b"content-length", b"0")]) + + + run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) +""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from http import HTTPMethod +from typing import Any, get_type_hints + +from localpost import hosting +from localpost.http._base import HTTPReqCtx, Middleware, RequestHandler, compose +from localpost.http._pool import thread_pool_handler +from localpost.http._service import http_server +from localpost.http._types import Response +from localpost.http.config import ServerConfig +from localpost.http.router import Routes, URITemplate, route_match +from localpost.threadtools import AsyncWorkerExecutor, Executor + +__all__ = ["HttpApp"] + + +_ParamResolver = Callable[[HTTPReqCtx], Any] + + +def _build_resolvers(fn: Callable[..., Any], path: str) -> dict[str, _ParamResolver]: + """Inspect ``fn``'s signature once, build a name → resolver dict. + + Resolution rules (checked in order per parameter): + 1. Param name matches a ``{var}`` in the path template → injected + as the matched ``str``. + 2. Annotated as :data:`HTTPReqCtx` → injected as the request ctx. + + Anything else fails at registration time. + """ + template_vars = set(URITemplate.parse(path).variable_names) + sig = inspect.signature(fn) + try: + hints = get_type_hints(fn) + except Exception: # noqa: BLE001 + hints = {} + fn_name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + + resolvers: dict[str, _ParamResolver] = {} + for name, param in sig.parameters.items(): + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + raise ValueError(f"handler {fn_name!r}: *args / **kwargs not supported in handler signatures") + if name in template_vars: + resolvers[name] = _make_path_arg_resolver(name) + continue + ann = hints.get(name) + if ann is HTTPReqCtx: + resolvers[name] = _resolve_ctx + continue + raise ValueError( + f"handler {fn_name!r}: cannot resolve parameter {name!r} " + f"(not a path variable {sorted(template_vars)!r}, not annotated as HTTPReqCtx)" + ) + return resolvers + + +def _resolve_ctx(ctx: HTTPReqCtx) -> HTTPReqCtx: + return ctx + + +def _make_path_arg_resolver(name: str) -> _ParamResolver: + return lambda ctx: route_match(ctx).path_args[name] + + +def _wrap_response(value: Any) -> tuple[Response, bytes]: + """Convert a handler's return value into ``(Response, body)``. + + Supported shapes: + + - ``str`` — ``200 text/plain; charset=utf-8`` + - ``bytes`` / ``bytearray`` / ``memoryview`` — ``200 application/octet-stream`` + - ``dict`` / ``list`` — ``200 application/json`` (via ``json.dumps``) + - :class:`Response` — passed through, empty body + - ``(Response, bytes)`` tuple — passed through, with body + - ``None`` — ``204 No Content`` + """ + if isinstance(value, Response): + return value, b"" + if value is None: + return ( + Response(status_code=204, headers=[(b"content-length", b"0")]), + b"", + ) + if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], Response): + response, body = value + return response, body if isinstance(body, bytes) else bytes(body) + if isinstance(value, str): + body = value.encode("utf-8") + return _build_response(200, b"text/plain; charset=utf-8", body), body + if isinstance(value, (bytes, bytearray, memoryview)): + body = bytes(value) + return _build_response(200, b"application/octet-stream", body), body + if isinstance(value, (dict, list)): + body = json.dumps(value, separators=(",", ":")).encode("utf-8") + return _build_response(200, b"application/json", body), body + raise TypeError( + f"unsupported return type {type(value).__name__!r} from handler — " + f"return str, bytes, dict, list, Response, (Response, bytes), or None" + ) + + +def _build_response(status: int, content_type: bytes, body: bytes) -> Response: + return Response( + status_code=status, + headers=[ + (b"content-type", content_type), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + + +@dataclass(slots=True) +class _Route: + """One registered route — the inputs needed to wire it at service time.""" + + method: HTTPMethod + path: str + fn: Callable[..., Any] + middleware: tuple[Middleware, ...] + + +class HttpApp: + """Decorator-driven HTTP app on top of :class:`Router`. + + Args: + pooled: When ``True`` (default), each matched-route handler runs + on a shared worker pool (:func:`thread_pool_handler`). Set to + ``False`` to run handlers inline on the selector thread — + only viable when every handler is short and non-blocking + (no body reads, no slow I/O). + middleware: App-level middlewares wrapping the entire dispatcher + (after Router). Outermost-first. + """ + + def __init__( + self, + *, + pooled: bool = True, + middleware: Sequence[Middleware] = (), + ) -> None: + self.pooled = pooled + self._middleware = tuple(middleware) + self._routes: list[_Route] = [] + + # ----- Decorators ----- + + def get( + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.GET, path, middleware) + + def post( + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.POST, path, middleware) + + def put( + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.PUT, path, middleware) + + def delete( + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.DELETE, path, middleware) + + def patch( + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.PATCH, path, middleware) + + def _decorator( + self, + method: HTTPMethod, + path: str, + middleware: Sequence[Middleware], + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def deco(fn: Callable[..., Any]) -> Callable[..., Any]: + # Validate signature eagerly so registration-time errors fire + # at decoration, not at service() time. + _build_resolvers(fn, path) + self._routes.append( + _Route( + method=method, + path=path, + fn=fn, + middleware=tuple(middleware), + ) + ) + return fn # return original for tests + + return deco + + # ----- Building ----- + + def _build_route_handler(self, route: _Route) -> RequestHandler: + resolvers = _build_resolvers(route.fn, route.path) + fn = route.fn + + def inner(ctx: HTTPReqCtx) -> None: + kwargs = {n: r(ctx) for n, r in resolvers.items()} + result = fn(**kwargs) + response, body = _wrap_response(result) + ctx.complete(response, body) + + return self._with_route_middleware(inner, route.middleware) + + def _with_route_middleware(self, handler: RequestHandler, middleware: tuple[Middleware, ...]) -> RequestHandler: + if not middleware: + return handler + return compose(*middleware)(handler) + + def _build_router_handler(self) -> RequestHandler: + routes = Routes() + for route in self._routes: + routes.add(route.method, route.path, self._build_route_handler(route)) + router = routes.build().as_handler() + if self._middleware: + router = compose(*self._middleware)(router) + return router + + # ----- Hosting ----- + + def service( + self, + config: ServerConfig, + *, + executor: Executor | None = None, + selectors: int = 1, + acceptor: bool = False, + ): + """Return a :func:`localpost.hosting.service` that runs the app. + + Composes worker pool + the HTTP server (backend selected via + :attr:`ServerConfig.backend`). Use with + :func:`localpost.hosting.run_app` or :func:`localpost.hosting.serve`. + + ``executor`` is the thread executor that runs handlers when + ``pooled=True``; pass an already-open + :class:`localpost.threadtools.Executor` to share one across + services. When omitted (and ``pooled=True``), an + :class:`AsyncWorkerExecutor` is opened on the hosting layer's + :class:`localpost.Portal` for the lifetime of the service — + handlers can call :func:`anyio.from_thread.check_cancelled` for + free. + + ``selectors`` and ``acceptor`` forward to :func:`http_server`. + """ + pooled = self.pooled + inner = self._build_router_handler() + + @hosting.service + async def _app_service(): + if not pooled: + async with http_server(config, inner, selectors=selectors, acceptor=acceptor): + yield + return + if executor is not None: + async with thread_pool_handler(inner, executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + return + portal = hosting.current_service().portal + async with AsyncWorkerExecutor(portal=portal) as own_executor: + async with thread_pool_handler(inner, own_executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + + return _app_service() diff --git a/localpost/http/asgi.py b/localpost/http/asgi.py new file mode 100644 index 0000000..7e6423f --- /dev/null +++ b/localpost/http/asgi.py @@ -0,0 +1,413 @@ +"""ASGI 3 transport bridge — adapt :data:`AsyncRequestHandler` ⇆ ASGI app. + +Symmetric with :mod:`localpost.http.wsgi`: this module owns the +translation between the foreign protocol (ASGI 3) and our async +request-context shape (:class:`AsyncHTTPReqCtx`). The handler doesn't +know anything about ASGI — it just reads ``ctx.request`` / +``await ctx.receive(size)``, and calls ``await ctx.complete(...)`` +/ ``await ctx.stream(...)``. + +``localpost.http`` itself doesn't ship an async server — production +ASGI servers (uvicorn, hypercorn, granian) already exist. This module +plugs an :data:`AsyncRequestHandler` into one of them via :func:`to_asgi`. + +Body bytes are read lazily — the bridge does *not* pre-buffer. The +handler pulls chunks via ``await ctx.receive(size)`` (or the +:func:`localpost.http.aread_body` helper for the "give me the whole +body" common case). ``max_body_size`` is enforced via the +``Content-Length`` header when present (413 before dispatch); chunked +uploads without ``Content-Length`` aren't capped at the bridge — trust +your ASGI server's limit or check ``len(chunk)`` in the handler. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, BinaryIO, final + +import anyio + +from localpost.http._async_base import AsyncHTTPReqCtx, AsyncRequestHandler +from localpost.http._types import Request +from localpost.http._types import Response as _Response +from localpost.http.config import DEFAULT_BUFFER_SIZE + +if TYPE_CHECKING: + from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +__all__ = [ + "ASGIScope", + "ASGIReceive", + "ASGISend", + "ASGIApp", + "to_asgi", + "build_request_from_scope", + "addrs_from_scope", +] + + +# ASGI 3 callable types — kept loose so we don't depend on a specific +# asgiref TypedDict. Scope / event dicts pass through verbatim. +type ASGIScope = dict[str, Any] +type ASGIReceive = Callable[[], Awaitable[dict[str, Any]]] +type ASGISend = Callable[[dict[str, Any]], Awaitable[None]] +type ASGIApp = Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]] + + +# --- Public adapter ----------------------------------------------------- + + +def to_asgi( + handler: AsyncRequestHandler, + *, + max_body_size: int = 1 << 20, +) -> ASGIApp: + """Wrap an :data:`AsyncRequestHandler` as an ASGI 3 application. + + Deploy with any ASGI server:: + + from localpost.http.asgi import to_asgi + + + async def my_handler(ctx): + await ctx.complete(Response(200), b"hi") + + + asgi_app = to_asgi(my_handler) + # uvicorn myapp:asgi_app + + Args: + handler: The async request handler. + max_body_size: Cap on the request body, in bytes. The bridge + pre-checks ``Content-Length`` (when present) and replies + ``413 Payload Too Large`` before the handler runs. + Chunk-by-chunk enforcement during ``ctx.receive`` is the + handler's / :func:`localpost.http.aread_body`'s job. + ``-1`` disables the cap. Defaults to ``1 << 20`` (1 MiB). + + The returned callable handles ``lifespan`` (no-op accept) and + ``http`` scopes; WebSocket scopes are rejected with + :class:`ValueError`. A peer ``http.disconnect`` flips + ``ctx.disconnected``; long handlers / SSE generators poll it + between events to short-circuit cleanly. + + Body bytes are pulled lazily — handlers call + ``await ctx.receive(size)`` (or :func:`localpost.http.aread_body` + for the whole body in one call). The bridge never pre-buffers. + """ + + async def asgi_app(scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None: + kind = scope.get("type") + if kind == "lifespan": + await _handle_lifespan(receive, send) + return + if kind != "http": + raise ValueError(f"to_asgi: unsupported ASGI scope type: {kind!r}") + await _handle_http(handler, max_body_size, scope, receive, send) + + return asgi_app + + +async def _handle_http( + handler: AsyncRequestHandler, + max_body_size: int, + scope: ASGIScope, + receive: ASGIReceive, + send: ASGISend, +) -> None: + """Lazy-body dispatch — single channel-pump task demuxes body chunks + + disconnect events. The ASGI receive channel is single-consumer, so + the pump owns it and feeds an in-process body queue that backs + ``ctx.receive(size)``. + """ + request = build_request_from_scope(scope) + + # Pre-check Content-Length when present — friendlier than detecting + # cap exhaustion mid-stream. + if max_body_size >= 0: + cl = _content_length(request) + if cl is not None and cl > max_body_size: + await _send_canned(send, 413, b"Payload Too Large") + return + + remote, local = addrs_from_scope(scope) + disconnected = anyio.Event() + body_send, body_recv = anyio.create_memory_object_stream[bytes](0) + ctx = _ASGIReqCtx( + request=request, + remote_addr=remote, + local_addr=local, + scheme=str(scope.get("scheme", "http")), + _send=send, + _disconnected=disconnected, + _body_stream=body_recv, + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(_pump_channel, receive, body_send, disconnected) + try: + await handler(ctx) + finally: + tg.cancel_scope.cancel() + + +def _content_length(request: Request) -> int | None: + for name, value in request.headers: + if name == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + + +# --- Concrete ctx ------------------------------------------------------- + + +class _ResponseAlreadyStarted(RuntimeError): + """Raised if a handler tries to start two responses on one request.""" + + +@final +@dataclass(slots=True, eq=False) +class _ASGIReqCtx: + """:class:`AsyncHTTPReqCtx` backed by an ASGI 3 ``scope`` + ``send``. + + Body bytes are pulled lazily from an in-process queue that the + :func:`_pump_channel` task feeds from ASGI ``http.request`` events. + Trailing partials beyond ``size`` are stashed in + ``_stream_leftover``. ``complete`` and ``stream`` translate into + ``http.response.start`` + ``http.response.body`` events. + ``disconnected`` flips when an ``http.disconnect`` event arrives. + """ + + request: Request + remote_addr: str | None + local_addr: str + scheme: str + _send: ASGISend + _disconnected: anyio.Event + _body_stream: MemoryObjectReceiveStream[bytes] + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _started: bool = False + _stream_eof: bool = False + _stream_leftover: bytes = b"" + + @property + def disconnected(self) -> bool: + return self._disconnected.is_set() + + async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + if self._stream_leftover: + if len(self._stream_leftover) <= size: + chunk = self._stream_leftover + self._stream_leftover = b"" + return chunk + chunk = self._stream_leftover[:size] + self._stream_leftover = self._stream_leftover[size:] + return chunk + if self._stream_eof: + return b"" + try: + chunk = await self._body_stream.receive() + except anyio.EndOfStream: + self._stream_eof = True + return b"" + if len(chunk) <= size: + return chunk + self._stream_leftover = chunk[size:] + return chunk[:size] + + async def complete(self, response: _Response, body: bytes | None = None) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + await self._send(_response_start_event(response)) + await self._send({"type": "http.response.body", "body": body or b"", "more_body": False}) + + async def stream(self, response: _Response, chunks: AsyncIterator[bytes], /) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + await self._send(_response_start_event(response)) + try: + async for chunk in chunks: + if self._disconnected.is_set(): + return + await self._send({"type": "http.response.body", "body": bytes(chunk), "more_body": True}) + finally: + # Always close the response — the ASGI server expects a + # final ``more_body=False`` event to release the + # connection. Skip when the peer is already gone. + if not self._disconnected.is_set(): + await self._send({"type": "http.response.body", "body": b"", "more_body": False}) + + async def sendfile(self, response: _Response, file: BinaryIO, offset: int, count: int) -> None: + """ASGI fallback: chunked read + stream. + + Native zero-copy isn't standardised across ASGI servers; some + expose ``http.response.pathsend`` extension, but it's optional. + We always go through the chunked path here. + """ + file.seek(offset) + chunks = _read_file_chunks(file, count, DEFAULT_BUFFER_SIZE) + await self.stream(response, chunks) + + def _check_not_started(self) -> None: + if self._started: + raise _ResponseAlreadyStarted("Response already started") + + +# Concrete ctx implements the AsyncHTTPReqCtx Protocol — verify at import. +# (Cheap insurance against drift; structural protocols are easy to break +# silently.) +_: type[AsyncHTTPReqCtx] = _ASGIReqCtx + + +# --- Scope / event translation ------------------------------------------ + + +def build_request_from_scope(scope: ASGIScope) -> Request: + """Build a localpost :class:`Request` from an ASGI ``http`` scope.""" + method = scope["method"].encode("ascii") + raw_path: bytes = scope.get("raw_path") or scope["path"].encode("utf-8") + query_string: bytes = scope.get("query_string", b"") or b"" + target = raw_path + (b"?" + query_string if query_string else b"") + headers = tuple((bytes(name).lower(), bytes(value)) for name, value in scope.get("headers", ())) + http_version = scope.get("http_version", "1.1").encode("ascii") + return Request( + method=method, + target=target, + path=raw_path, + query_string=query_string, + headers=headers, + http_version=http_version, + ) + + +def addrs_from_scope(scope: ASGIScope) -> tuple[str | None, str]: + """Return ``(remote_addr, local_addr)`` from an ASGI scope. + + ASGI ``client`` / ``server`` are ``[host, port]`` lists or ``None``. + Mirrors the sync ctx ``"host:port"`` formatting. + """ + client = scope.get("client") + server = scope.get("server") or [None, None] + remote = _fmt_addr(client[0], client[1]) if client else None + local = _fmt_addr(server[0], server[1]) or "" + return remote, local + + +def _fmt_addr(host: Any, port: Any) -> str | None: + if host is None: + return None + if port is None or port == "": + return str(host) + return f"{host}:{port}" + + +def _response_start_event(response: _Response) -> dict[str, Any]: + return { + "type": "http.response.start", + "status": response.status_code, + "headers": [(bytes(name), bytes(value)) for name, value in response.headers], + } + + +# --- Body buffering / disconnect / lifespan / canned responses --------- + + +async def _pump_channel( + receive: ASGIReceive, + body_send: MemoryObjectSendStream[bytes], + flag: anyio.Event, +) -> None: + """Body / disconnect demuxer: consume ASGI events, route body chunks + to ``body_send`` and flip ``flag`` on ``http.disconnect``. + + Closes ``body_send`` once body is at EOM (or peer disconnected) so + the handler's ``ctx.receive`` sees ``b""``. Continues consuming + after EOM to catch a later disconnect. Cancellation by the parent + task group ends the pump silently. + """ + body_done = False + try: + async with body_send: + while True: + event = await receive() + kind = event.get("type") + if kind == "http.disconnect": + flag.set() + return + if kind != "http.request": + continue + if body_done: + continue + body: bytes = event.get("body", b"") or b"" + if body: + await body_send.send(body) + if not event.get("more_body", False): + body_done = True + break + # Body is done; stay on the channel for late http.disconnect. + while True: + event = await receive() + if event.get("type") == "http.disconnect": + flag.set() + return + except Exception: # noqa: BLE001 + return + + +async def _handle_lifespan(receive: ASGIReceive, send: ASGISend) -> None: + """Minimal lifespan loop — accept startup / shutdown events without + plugging into the user's hosting service. (Hosting integration + drives lifecycle through :mod:`localpost.hosting`.)""" + while True: + event = await receive() + kind = event.get("type") + if kind == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif kind == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + +async def _send_canned( + send: ASGISend, + status: int, + body: bytes, + *, + extra_headers: Sequence[tuple[bytes, bytes]] = (), +) -> None: + headers = [ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + *extra_headers, + ] + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": body, "more_body": False}) + + +def _read_file_chunks(file: BinaryIO, count: int, blksize: int) -> AsyncIterator[bytes]: + """Async-iterator wrapper around a sync file's chunked read. + + Used by :meth:`_ASGIReqCtx.sendfile` — the ``file`` is a sync + handle (``BinaryIO``); doing the read on the event loop is fine + for small files but blocks for large ones. A real production + sendfile path should bridge to a thread; for now we keep the + semantics simple and document. + """ + + async def gen() -> AsyncIterator[bytes]: + remaining = count + while remaining > 0: + chunk = file.read(min(blksize, remaining)) + if not chunk: + return + remaining -= len(chunk) + yield chunk + + return gen() diff --git a/localpost/http/compress.py b/localpost/http/compress.py new file mode 100644 index 0000000..6093a52 --- /dev/null +++ b/localpost/http/compress.py @@ -0,0 +1,582 @@ +"""Response compression middleware (dynamic responses only). + +Wraps a :data:`RequestHandler` so that responses emitted via +:meth:`HTTPReqCtx.complete` and :meth:`HTTPReqCtx.stream` are +compressed when the client accepts it, the response type / size is +eligible, and the content type is in the allowlist. + +The middleware intercepts at the declarative level: + +- :meth:`complete` — compress the whole body in memory, replace + ``Content-Length``, set ``Content-Encoding``. +- :meth:`stream` — wrap the chunk iterator with an incremental + compressor (sync-flush after each input chunk) so streaming + responses (incl. SSE) reach the client decompressed and parseable. + +Zero-copy :meth:`sendfile` passes through uncompressed by design — +composition with the static handler stays zero-copy. See +``plans/compression-middleware.md`` for rationale and follow-ups. + +Encodings: + +- ``gzip`` — stdlib, always available +- ``br`` — optional via the ``[http-compress]`` extra (``brotli``) + +If ``"br"`` appears in ``algorithms`` but :mod:`brotli` isn't +installed, :func:`compress_handler` raises :class:`ImportError` at +construction time — fail fast rather than mid-request. +""" + +from __future__ import annotations + +import functools +import gzip +import importlib +import importlib.util +import zlib +from collections.abc import Callable, Iterator, Sequence +from contextlib import AbstractContextManager +from dataclasses import dataclass +from typing import Any, BinaryIO, Final + +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._types import Request, Response + +__all__ = [ + "DEFAULT_COMPRESSIBLE_TYPES", + "compress_handler", +] + + +DEFAULT_COMPRESSIBLE_TYPES: Final[frozenset[bytes]] = frozenset( + { + b"text/html", + b"text/plain", + b"text/css", + b"text/xml", + b"text/csv", + b"text/javascript", + b"text/event-stream", + b"application/json", + b"application/javascript", + b"application/xml", + b"application/xhtml+xml", + b"application/manifest+json", + b"application/x-yaml", + b"application/rss+xml", + b"application/atom+xml", + b"image/svg+xml", + } +) + + +# -------------------------------------------------------------------------- +# Encoder registry +# -------------------------------------------------------------------------- + + +def _compress_gzip(data: bytes) -> bytes: + return gzip.compress(data, compresslevel=6) + + +# Imported lazily — the dependency is in the optional ``[http-compress]`` +# extra. ``importlib`` keeps the static import out of ty/pyright's sight, +# so missing ``brotli`` doesn't fail type-checking. +@functools.cache +def _brotli() -> Any: + return importlib.import_module("brotli") + + +def _compress_brotli(data: bytes) -> bytes: + # quality=4 favours speed over ratio (brotli default 11 is very slow). + return _brotli().compress(data, quality=4) + + +_ENCODERS: dict[str, Callable[[bytes], bytes]] = { + "gzip": _compress_gzip, + "br": _compress_brotli, +} + + +# -------------------------------------------------------------------------- +# Streaming encoders (used by the streaming-response path) +# -------------------------------------------------------------------------- + + +class _StreamEncoder: + """Per-request streaming compressor. + + ``compress(data)`` is incremental — output may be empty until the + encoder has buffered enough. ``flush()`` emits a sync-flush block so + the decompressor can produce the bytes seen so far (the SSE + invariant: each event reaches the client promptly). ``finish()`` is + the terminal flush. + """ + + def compress(self, data: bytes) -> bytes: # pragma: no cover — abstract + raise NotImplementedError + + def flush(self) -> bytes: # pragma: no cover — abstract + raise NotImplementedError + + def finish(self) -> bytes: # pragma: no cover — abstract + raise NotImplementedError + + +class _GzipStreamEncoder(_StreamEncoder): + """``zlib.compressobj(wbits=31)`` produces gzip-format output (header + + deflate + trailer). ``Z_SYNC_FLUSH`` between events; ``Z_FINISH`` at end. + """ + + def __init__(self) -> None: + self._cobj = zlib.compressobj(level=6, method=zlib.DEFLATED, wbits=31) + + def compress(self, data: bytes) -> bytes: + return self._cobj.compress(data) + + def flush(self) -> bytes: + return self._cobj.flush(zlib.Z_SYNC_FLUSH) + + def finish(self) -> bytes: + return self._cobj.flush(zlib.Z_FINISH) + + +class _BrotliStreamEncoder(_StreamEncoder): + """``brotli.Compressor.flush()`` emits a flush-block (sync-flush + semantics); ``finish()`` ends the stream. + """ + + def __init__(self) -> None: + self._c = _brotli().Compressor(quality=4) + + def compress(self, data: bytes) -> bytes: + return self._c.process(data) + + def flush(self) -> bytes: + return self._c.flush() + + def finish(self) -> bytes: + return self._c.finish() + + +_STREAM_ENCODER_FACTORIES: dict[str, Callable[[], _StreamEncoder]] = { + "gzip": _GzipStreamEncoder, + "br": _BrotliStreamEncoder, +} + + +def _compress_stream(chunks: Iterator[bytes], encoder: _StreamEncoder) -> Iterator[bytes]: + """Wrap ``chunks`` so each non-empty input chunk emits a compressed + + sync-flushed block, and the iterator's tail produces the encoder's + final flush. + + Empty chunks pass through untouched — handlers sometimes yield ``b""`` + to flush headers without committing payload bytes; emitting a + sync-marker for nothing would be a wasted byte on the wire. + """ + for chunk in chunks: + if not chunk: + yield chunk + continue + out = encoder.compress(chunk) + encoder.flush() + if out: + yield out + tail = encoder.finish() + if tail: + yield tail + + +def _check_algorithm_available(name: str) -> None: + if name == "br": + if importlib.util.find_spec("brotli") is None: + raise ImportError( + "compress_handler(algorithms=...) includes 'br' but the optional " + "[http-compress] extra (brotli) is not installed." + ) + elif name == "gzip": + return # stdlib + else: + raise ValueError(f"Unsupported compression algorithm: {name!r}") + + +# -------------------------------------------------------------------------- +# Negotiation +# -------------------------------------------------------------------------- + + +def _negotiate(accept_encoding: bytes, server_pref: Sequence[str]) -> str | None: + """Pick the best mutually-supported encoding. + + Parses ``Accept-Encoding`` per RFC 7231 §5.3.4: comma-separated + tokens, optional ``;q=``, default ``q=1.0``. ``q=0`` disables. + ``*`` is a wildcard. Returns the highest-server-preference encoding + with q>0, or ``None`` if nothing matches. + """ + qs: dict[str, float] = {} + star_q: float | None = None + for raw in accept_encoding.split(b","): + token = raw.strip() + if not token: + continue + name_b, sep, params = token.partition(b";") + name = name_b.strip().decode("ascii", errors="replace").lower() + q = 1.0 + if sep: + for param in params.split(b";"): + k, kv_sep, v = param.strip().partition(b"=") + if kv_sep and k.strip().lower() == b"q": + try: + q = float(v.strip()) + except ValueError: + q = 0.0 + break + if name == "*": + star_q = q + else: + qs[name] = q + for pref in server_pref: + q = qs.get(pref) + if q is None and star_q is not None: + q = star_q + if q is not None and q > 0: + return pref + return None + + +# -------------------------------------------------------------------------- +# Header inspection / rewrite +# -------------------------------------------------------------------------- + + +def _get_header(headers: Sequence[tuple[bytes, bytes]], name: bytes) -> bytes | None: + name_lc = name.lower() + for n, v in headers: + if n.lower() == name_lc: + return v + return None + + +def _is_compressible_response( + response: Response, + body: bytes | None, + *, + method: bytes, + min_size: int, + compressible_types: frozenset[bytes], +) -> bool: + """Apply the v1 decision matrix from the design doc.""" + if method == b"HEAD": + return False + if body is None or len(body) < min_size: + return False + status = response.status_code + if status in {204, 304} or 100 <= status < 200 or status == 206: + return False + # Header-driven exclusions. + has_compressible_type = False + for name, value in response.headers: + n = name.lower() + if n == b"content-encoding": + v = value.strip().lower() + if v and v != b"identity": + return False + elif n == b"cache-control": + # Conservative substring check — ``no-transform`` is a directive + # token, not a value, so substring matches without false positives. + if b"no-transform" in value.lower(): + return False + elif n == b"content-type" and not has_compressible_type: + main_type = value.split(b";", 1)[0].strip().lower() + if main_type in compressible_types: + has_compressible_type = True + return has_compressible_type + + +def _rewrite_headers( + headers: Sequence[tuple[bytes, bytes]], + *, + encoding: str, + new_length: int, +) -> list[tuple[bytes, bytes]]: + """Replace ``Content-Length``, add ``Content-Encoding``, merge + ``Vary: Accept-Encoding``. + """ + enc_value = encoding.encode("ascii") + out: list[tuple[bytes, bytes]] = [] + vary_seen = False + cl_replaced = False + for name, value in headers: + n = name.lower() + if n == b"content-length": + if not cl_replaced: + out.append((name, str(new_length).encode("ascii"))) + cl_replaced = True + # Drop duplicate Content-Length headers if any. + elif n == b"vary": + vary_seen = True + out.append((name, _merge_vary(value))) + else: + out.append((name, value)) + if not cl_replaced: + out.append((b"content-length", str(new_length).encode("ascii"))) + if not vary_seen: + out.append((b"vary", b"Accept-Encoding")) + out.append((b"content-encoding", enc_value)) + return out + + +def _is_streaming_eligible( + response: Response, + *, + method: bytes, + compressible_types: frozenset[bytes], +) -> bool: + """Decision matrix for the streaming-response path. + + No body-size check (we don't know it). Otherwise mirrors + :func:`_is_compressible_response` plus an additional rule: skip when + the handler declared a ``Content-Length`` (a known-length response + is contractually fixed; compressing mid-stream would lie about the + declared length). + """ + if method == b"HEAD": + return False + status = response.status_code + if status in {204, 304} or 100 <= status < 200 or status == 206: + return False + has_compressible_type = False + for name, value in response.headers: + n = name.lower() + if n == b"content-length": + return False # known-length stream — pass through verbatim + if n == b"content-encoding": + v = value.strip().lower() + if v and v != b"identity": + return False + elif n == b"cache-control": + if b"no-transform" in value.lower(): + return False + elif n == b"content-type" and not has_compressible_type: + main_type = value.split(b";", 1)[0].strip().lower() + if main_type in compressible_types: + has_compressible_type = True + return has_compressible_type + + +def _streaming_rewrite_headers( + headers: Sequence[tuple[bytes, bytes]], + *, + encoding: str, +) -> list[tuple[bytes, bytes]]: + """Add ``Content-Encoding``, merge ``Vary``. ``Content-Length`` is + dropped (eligibility already rejected the case where it was set). + ``Transfer-Encoding`` is left to the backend — both auto-add + ``chunked`` for HTTP/1.1 peers when no framing is present. + """ + enc_value = encoding.encode("ascii") + out: list[tuple[bytes, bytes]] = [] + vary_seen = False + for name, value in headers: + n = name.lower() + if n == b"content-length": + continue # defensive — eligibility check rejects this case + if n == b"vary": + vary_seen = True + out.append((name, _merge_vary(value))) + else: + out.append((name, value)) + if not vary_seen: + out.append((b"vary", b"Accept-Encoding")) + out.append((b"content-encoding", enc_value)) + return out + + +def _merge_vary(existing: bytes) -> bytes: + """Merge ``Accept-Encoding`` into an existing ``Vary`` header value.""" + stripped = existing.strip() + if stripped == b"*": + return existing + tokens = [t.strip() for t in stripped.split(b",") if t.strip()] + for t in tokens: + if t.lower() == b"accept-encoding": + return existing + tokens.append(b"Accept-Encoding") + return b", ".join(tokens) + + +# -------------------------------------------------------------------------- +# Proxy ctx +# -------------------------------------------------------------------------- + + +@dataclass(slots=True, eq=False) +class _CompressedCtx: + """Forwarding proxy that intercepts :meth:`complete` and :meth:`stream` + to compress eligible responses. + + All other methods / attributes pass through to ``_inner``. The + proxy is allocated once per request — only when the request is + actually eligible for compression — so the overhead is bounded. + """ + + _inner: HTTPReqCtx + _encoding: str + _min_size: int + _compressible_types: frozenset[bytes] + + # ----- forwarded attributes ----- + + @property + def request(self) -> Request: + return self._inner.request + + @property + def attrs(self) -> dict[Any, Any]: + return self._inner.attrs + + @property + def response_status(self) -> int | None: + return self._inner.response_status + + @property + def remote_addr(self) -> str | None: + return self._inner.remote_addr + + @property + def local_addr(self) -> str: + return self._inner.local_addr + + @property + def scheme(self) -> str: + return self._inner.scheme + + @property + def disconnected(self) -> bool: + return self._inner.disconnected + + @property + def borrowed(self) -> bool: + return self._inner.borrowed + + def borrow(self) -> AbstractContextManager[HTTPReqCtx]: + return self._inner.borrow() + + def receive(self, size: int = 65536, /) -> bytes: + return self._inner.receive(size) + + # ----- pass-through (zero-copy) ----- + + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + # Zero-copy stays uncompressed by design (compression and sendfile + # are at odds; see plans/compression-middleware.md). + self._inner.sendfile(response, file, offset, count) + + # ----- intercepted streaming-response API ----- + + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + if not _is_streaming_eligible( + response, + method=self._inner.request.method, + compressible_types=self._compressible_types, + ): + self._inner.stream(response, chunks) + return + encoder = _STREAM_ENCODER_FACTORIES[self._encoding]() + new_headers = _streaming_rewrite_headers(response.headers, encoding=self._encoding) + new_response = Response(status_code=response.status_code, headers=new_headers, reason=response.reason) + self._inner.stream(new_response, _compress_stream(chunks, encoder)) + + # ----- intercepted one-shot path ----- + + def complete(self, response: Response, body: bytes | None = None) -> None: + if not _is_compressible_response( + response, + body, + method=self._inner.request.method, + min_size=self._min_size, + compressible_types=self._compressible_types, + ): + self._inner.complete(response, body) + return + assert body is not None # _is_compressible_response gates on body is not None + compressed = _ENCODERS[self._encoding](body) + new_headers = _rewrite_headers( + response.headers, + encoding=self._encoding, + new_length=len(compressed), + ) + self._inner.complete( + Response(status_code=response.status_code, headers=new_headers, reason=response.reason), + compressed, + ) + + +# -------------------------------------------------------------------------- +# Public entry +# -------------------------------------------------------------------------- + + +def compress_handler( + inner: RequestHandler, + *, + algorithms: Sequence[str] = ("br", "gzip"), + min_size: int = 1024, + compressible_types: frozenset[bytes] = DEFAULT_COMPRESSIBLE_TYPES, +) -> RequestHandler: + """Wrap ``inner`` so eligible responses are compressed. + + Args: + inner: The wrapped :data:`RequestHandler`. + algorithms: Server preference order. Each entry must be one of + ``"gzip"`` (stdlib) or ``"br"`` (requires the + ``[http-compress]`` extra). The first algorithm that the + client accepts (per ``Accept-Encoding`` q-values) is chosen. + min_size: Minimum response body size to trigger compression. Below + this, the framing / re-allocation overhead dominates the + compression gain. Default ``1024``. + compressible_types: Allowlist of lowercased ``Content-Type`` + main-types (split on ``;``). See + :data:`DEFAULT_COMPRESSIBLE_TYPES` for the default. + + Raises: + ValueError: ``algorithms`` is empty or contains an unknown name. + ImportError: ``"br"`` is requested but :mod:`brotli` is not + installed (install via ``pip install localpost[http-compress]``). + + Returns: + A :data:`RequestHandler` that wraps ``inner``. Compression + intercepts :meth:`HTTPReqCtx.complete` (whole-body compress) + and :meth:`HTTPReqCtx.stream` (per-chunk incremental compress + with sync-flush); :meth:`HTTPReqCtx.sendfile` and the imperative + trio (``start_response`` / ``send`` / ``finish_response``) pass + through unchanged. Use :meth:`HTTPReqCtx.stream` for true + streaming compression — the trio path is for low-level + wire-driver code (e.g. the WSGI bridge). + """ + if not algorithms: + raise ValueError("compress_handler: ``algorithms`` must be non-empty") + for name in algorithms: + _check_algorithm_available(name) + if min_size < 0: + raise ValueError("compress_handler: ``min_size`` must be >= 0") + + server_pref = tuple(algorithms) + + def wrapped(ctx: HTTPReqCtx) -> None: + accept = _get_header(ctx.request.headers, b"accept-encoding") + if accept is None or ctx.request.method == b"HEAD": + inner(ctx) + return + chosen = _negotiate(accept, server_pref) + if chosen is None: + inner(ctx) + return + proxy = _CompressedCtx( + _inner=ctx, + _encoding=chosen, + _min_size=min_size, + _compressible_types=compressible_types, + ) + inner(proxy) # type: ignore[arg-type] # _CompressedCtx structurally satisfies HTTPReqCtx + + return wrapped diff --git a/localpost/http/config.py b/localpost/http/config.py new file mode 100644 index 0000000..4ba386b --- /dev/null +++ b/localpost/http/config.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Literal, final + +__all__ = [ + "LOGGER_NAME", + "ServerConfig", +] + +DEFAULT_BUFFER_SIZE: Final = 64 * 1024 # 64 KiB + +LOGGER_NAME: Final = "localpost.http" + + +@final +@dataclass(frozen=True, slots=True) +class ServerConfig: + host: str = "0.0.0.0" # noqa: S104 — listen on all interfaces by default; explicit choice for a server lib + port: int = 8000 + backend: Literal["h11", "httptools"] = "h11" + """HTTP parser backend. ``"h11"`` (default) is pure-Python and ships with + the core install. ``"httptools"`` is the C-based llhttp wrapper; requires + the ``[http-fast]`` extra.""" + backlog: int = 1024 + """Maximum number of queued (in the kernel) connections.""" + rw_timeout: float = 1.0 + """Timeout (seconds) for receive/send operations on a borrowed client connection, + and for the keep-alive read deadline extended after each chunk arrives.""" + select_timeout: float = 1.0 + """Default upper bound (seconds) on ``selector.select`` per ``Server.run`` iteration. + Caps how long the loop blocks before returning to the caller for a cancellation / + shutdown check. Callers may override per-iteration via ``Server.run(timeout=…)``.""" + keep_alive_timeout: float = 15.0 + """Timeout (seconds) for idle connections. Advertised to HTTP/1.1 clients + via the ``Keep-Alive: timeout=N`` response header so they can size their + keep-alive pool to match.""" + max_body_size: int = 10 * 1024 * 1024 # 10 MiB + """Maximum request body size (bytes).""" diff --git a/localpost/http/flask.py b/localpost/http/flask.py new file mode 100644 index 0000000..b61781c --- /dev/null +++ b/localpost/http/flask.py @@ -0,0 +1,94 @@ +"""Native Flask adapter — drives a Flask app without going through WSGI. + +Compared to :func:`localpost.http.wrap_wsgi` / :func:`localpost.http.wsgi_server`: + +- Flask's request context stays active for the **entire** request lifetime, + including response-body iteration. Generators returned from a view can use + ``flask.request`` / ``session`` / ``g`` without ``@stream_with_context``. + ``stream_with_context`` still works (becomes a no-op). +- ``teardown_request`` / ``teardown_appcontext`` callbacks run **after** the + response body has been fully sent, not before — the opposite of standard + WSGI. This means DB sessions and similar resources live through streaming, + and teardown sees the true end of the request. + +Trade-off: this couples to Flask/Werkzeug internals +(``app.request_context``, ``app.full_dispatch_request``, ``app.handle_exception``, +``werkzeug.Response.iter_encoded``). All documented, stable across Flask 3.x. +""" + +from __future__ import annotations + +from flask import Flask + +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._body import read_body +from localpost.http._service import http_server +from localpost.http._types import Response as _Response +from localpost.http.config import ServerConfig +from localpost.http.wsgi import _build_environ + +__all__ = ["flask_handler", "flask_server"] + + +def flask_handler(app: Flask) -> RequestHandler: + """Wrap a Flask app as a native :class:`RequestHandler`. + + The handler reads the full request body via + :func:`localpost.http.read_body`, exposes it to Flask as + ``wsgi.input``, and streams the response straight to h11. + + Reading the body blocks on the request socket, so this handler + **must** be composed with :func:`localpost.http.thread_pool_handler` + (or run inside an explicit ``ctx.borrow()`` block) — running it on + the selector thread will stall the loop while the upload finishes. + + See the module docstring for behaviour differences vs. WSGI — notably, + the request context stays active during response body streaming. + """ + + def run_flask(http_ctx: HTTPReqCtx) -> None: + body = read_body(http_ctx) + environ = _build_environ(http_ctx, body) + with app.request_context(environ): + try: + response = app.full_dispatch_request() + except Exception as exc: # noqa: BLE001 + # app.handle_exception returns a Response; it only re-raises in + # debug / propagate_exceptions mode. If it does re-raise, let + # the exception bubble up to the service task group. + response = app.handle_exception(exc) + try: + _write_response(http_ctx, response) + finally: + response.close() # Fire werkzeug's call_on_close callbacks + + return run_flask + + +def _write_response(http_ctx: HTTPReqCtx, response) -> None: + # response is a werkzeug.Response (Flask's Response subclasses it) + reason = (response.status.split(" ", 1)[1] if " " in response.status else "").encode("iso-8859-1") + wire_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items()] + http_ctx.stream( + _Response( + status_code=response.status_code, + headers=wire_headers, + reason=reason, + ), + (chunk for chunk in response.iter_encoded() if chunk), + ) + + +def flask_server(config: ServerConfig, app: Flask, /): + """Hosted service serving a Flask app via :func:`flask_handler`. + + Flask views block during request handling, so to serve more than one + request at a time wrap the handler with + :func:`localpost.http.thread_pool_handler`:: + + with WorkerExecutor() as ex: + async with thread_pool_handler(flask_handler(app), ex) as h: + async with http_server(config, h): + ... + """ + return http_server(config, flask_handler(app)) diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py new file mode 100644 index 0000000..43c0ba6 --- /dev/null +++ b/localpost/http/flask_sentry.py @@ -0,0 +1,79 @@ +"""Sentry tracing wrapper for :func:`localpost.http.flask.flask_handler`. + +Each request becomes a Sentry transaction that **covers the entire request +lifetime including response-body streaming**. Sentry's stock +``FlaskIntegration`` ends the transaction when the WSGI ``wsgi_app`` returns, +which is *before* the body is iterated — so any spans / errors inside a +streaming generator land outside the transaction. This adapter doesn't have +that bug because we hold the transaction open through ``response.iter_encoded()``. + +The transaction is named after Flask's matched URL rule (e.g. +``"GET /books/"``) once routing has run; before then it's ``METHOD path``. + +Install:: + + pip install localpost[http-flask,http-sentry] + +Usage:: + + sentry_sdk.init(dsn=..., traces_sample_rate=1.0) + handler = sentry_flask_handler(my_flask_app) + with WorkerExecutor() as ex: + async with thread_pool_handler(handler, ex) as wrapped: + async with http_server(config, wrapped): + ... +""" + +from __future__ import annotations + +import sentry_sdk +from flask import Flask +from flask import request as flask_request + +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._body import read_body +from localpost.http._sentry import request_transaction +from localpost.http.flask import _write_response +from localpost.http.wsgi import _build_environ + +__all__ = ["sentry_flask_handler"] + + +def sentry_flask_handler(app: Flask, *, op: str = "http.server") -> RequestHandler: + """Wrap a Flask app with a Sentry transaction covering the full request, streaming included. + + Reads the full request body via :func:`localpost.http.read_body` + (Flask's WSGI surface needs ``wsgi.input``); composing with + :func:`localpost.http.thread_pool_handler` is required. + """ + + def run_flask_with_sentry(ctx: HTTPReqCtx) -> None: + body = read_body(ctx) + environ = _build_environ(ctx, body) + method = environ["REQUEST_METHOD"] + path = environ["PATH_INFO"] + + with ( + request_transaction(op=op, name=f"{method} {path}", source="url", method=method, url=path) as tx, + app.request_context(environ), + ): + # Once routing has matched, rename the transaction to the route rule + # for low cardinality. + rule = flask_request.url_rule + if rule is not None: + sentry_sdk.get_current_scope().set_transaction_name( + f"{method} {rule.rule}", + source="route", + ) + + try: + response = app.full_dispatch_request() + except Exception as exc: # noqa: BLE001 + response = app.handle_exception(exc) + tx.set_http_status(response.status_code) + try: + _write_response(ctx, response) + finally: + response.close() + + return run_flask_with_sentry diff --git a/localpost/http/router.py b/localpost/http/router.py new file mode 100644 index 0000000..9a8dfe3 --- /dev/null +++ b/localpost/http/router.py @@ -0,0 +1,429 @@ +"""URI-template router for LocalPost HTTP — a thin dispatcher middleware. + +Matches the request URI against a table of compiled :class:`URITemplate` s, +attaches the :class:`RouteMatch` to ``ctx.attrs[RouteMatch]``, and +delegates to the matched route's :data:`localpost.http.RequestHandler`. + +404 / 405 are answered inline on the selector. The :class:`Router` +itself is just a :data:`localpost.http.RequestHandler` — wrap it with +:func:`localpost.http.thread_pool_handler` if you want matched routes +to run on workers, or compose with middleware via +:func:`localpost.http.compose`. + +For Pythonic helpers (decorators, response conversion, param injection), +see :class:`localpost.http.app.HttpApp`. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from http import HTTPMethod +from http.client import responses as _http_phrases +from typing import Self, final + +from localpost.http._base import HTTPReqCtx +from localpost.http._base import RequestHandler as NativeRequestHandler +from localpost.http._types import Response as _Response + +__all__ = [ + "URITemplate", + "RouteMatch", + "Route", + "Routes", + "Router", + "route_match", +] + +_VAR_PATTERN = re.compile(r"\{([^}]+)\}") + + +@final +@dataclass(frozen=True, slots=True) +class URITemplate: + """Basic URI Template (RFC 6570) implementation, just the first level from the spec.""" + + template: str + variable_names: tuple[str, ...] + _regex: re.Pattern[str] + + @classmethod + def parse(cls, template: str) -> Self: + variable_names: list[str] = [] + regex_parts: list[str] = [] + last_end = 0 + for m in _VAR_PATTERN.finditer(template): + regex_parts.append(re.escape(template[last_end : m.start()])) + var_name = m.group(1) + variable_names.append(var_name) + regex_parts.append(f"(?P<{var_name}>[^/]+)") + last_end = m.end() + regex_parts.append(re.escape(template[last_end:])) + pattern = re.compile("^" + "".join(regex_parts) + "$") + return cls( + template=template, + variable_names=tuple(variable_names), + _regex=pattern, + ) + + def match(self, uri: str) -> dict[str, str] | None: + m = self._regex.match(uri) + return m.groupdict() if m else None + + +@final +@dataclass(frozen=True, slots=True) +class RouteMatch: + """Attached to ``ctx.attrs[RouteMatch]`` by :class:`Router` on a successful match. + + Read via :func:`route_match` from inside a route handler or middleware. + """ + + method: HTTPMethod + matched_template: URITemplate + path_args: Mapping[str, str] + + +def route_match(ctx: HTTPReqCtx) -> RouteMatch: + """Return the :class:`RouteMatch` the :class:`Router` placed on ``ctx``. + + Raises :exc:`KeyError` if called outside a route handler (or before + the Router has run). + """ + return ctx.attrs[RouteMatch] + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class Route: + """One compiled route inside a :class:`Router`.""" + + template: URITemplate + methods: Mapping[HTTPMethod, NativeRequestHandler] + """Method → handler table for this template.""" + + allow_header: str + """Pre-rendered ``Allow`` header value (e.g. ``"GET, POST"``).""" + + method_not_allowed: tuple[_Response, bytes] + """Pre-built ``(Response, body)`` for the 405 path on this route. Avoids + rebuilding the response (status, headers list, ``content-length`` ASCII + encode) on every method-mismatch.""" + + _group_prefix: str + """Internal: the named-group prefix this route uses inside the combined regex.""" + + +@final +@dataclass(slots=True, eq=False) +class Routes: + """Mutable route builder. + + The decorator forms (``.get``, ``.post``, ...) are thin sugar for + :meth:`add` — they register the handler as a plain + :data:`localpost.http.RequestHandler`, no framework wrapping. For + decorator-driven registration with response conversion, param + injection, etc. use :class:`localpost.http.app.HttpApp`. + + Usage:: + + routes = Routes() + + + @routes.get("/hello/{name}") + def hello(ctx: HTTPReqCtx) -> None: + match = route_match(ctx) + ctx.complete(Response(...), b"hi " + match.path_args["name"].encode()) + + + router = routes.build() + """ + + paths: dict[URITemplate, dict[HTTPMethod, NativeRequestHandler]] = field(default_factory=dict) + + def add( + self, + method: HTTPMethod | str, + template: str, + handler: NativeRequestHandler, + ) -> None: + m = method if isinstance(method, HTTPMethod) else HTTPMethod(method.upper()) + key = _find_template(self.paths, template) or URITemplate.parse(template) + self.paths.setdefault(key, {})[m] = handler + + def _decorator(self, method: HTTPMethod, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + def deco(handler: NativeRequestHandler) -> NativeRequestHandler: + self.add(method, template, handler) + return handler + + return deco + + def get(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + return self._decorator(HTTPMethod.GET, template) + + def post(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + return self._decorator(HTTPMethod.POST, template) + + def put(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + return self._decorator(HTTPMethod.PUT, template) + + def delete(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + return self._decorator(HTTPMethod.DELETE, template) + + def patch(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + return self._decorator(HTTPMethod.PATCH, template) + + def build(self) -> Router: + return Router.from_routes(self) + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class Router: + """Immutable, compiled URI-template dispatcher. + + Build via :meth:`from_routes` or :meth:`Routes.build`. As a + :data:`localpost.http.RequestHandler`-producer, the dispatcher is + just :meth:`as_handler`'s return value — wrap it with middleware + via :func:`localpost.http.compose` and feed it to + :func:`localpost.http.http_server`. + """ + + routes: tuple[Route, ...] + """Routes in dispatch order — longest literal prefix first, stable on ties.""" + + _regex: re.Pattern[str] + + @classmethod + def from_routes(cls, routes: Routes) -> Self: + # Deduplicate: Routes.add already merges by template string, but guard + # against anyone constructing the builder's paths dict by hand. + seen: set[str] = set() + for t in routes.paths: + if t.template in seen: + raise ValueError(f"duplicate template: {t.template!r}") + seen.add(t.template) + + ordered = sorted( + routes.paths.items(), + key=lambda item: _literal_prefix_len(item[0]), + reverse=True, + ) + + compiled_routes: list[Route] = [] + alternatives: list[str] = [] + + for i, (tmpl, method_map) in enumerate(ordered): + prefix = f"r{i}" + allow_header = _format_allow_header(method_map) + compiled_routes.append( + Route( + template=tmpl, + methods=dict(method_map), + allow_header=allow_header, + method_not_allowed=_build_method_not_allowed(allow_header), + _group_prefix=prefix, + ) + ) + alternatives.append(f"(?P<{prefix}>{_reprefixed_regex_source(tmpl, prefix)})") + + combined = re.compile("^(?:" + "|".join(alternatives) + ")$") if alternatives else re.compile(r"^(?!)") + + return cls( + routes=tuple(compiled_routes), + _regex=combined, + ) + + # --- Dispatch ----------------------------------------------------- + + def _match(self, path: str, method_str: str) -> _MatchResult: + m = self._regex.match(path) + if m is None: + return _MATCH_NOT_FOUND + + try: + method = HTTPMethod(method_str) + except ValueError: + method = None + + allowed_methods: set[HTTPMethod] = set() + matched_routes = 0 + single_route_405: tuple[_Response, bytes] | None = None + + for route in self.routes: + path_args = route.template.match(path) + if path_args is None: + continue + matched_routes += 1 + allowed_methods.update(route.methods) + single_route_405 = route.method_not_allowed + + tmpl = route.template + method_map = route.methods + if method is None: + continue + + handler = method_map.get(method) + if handler is None: + continue + + return _MatchOk( + handler=handler, + match=RouteMatch( + method=method, + matched_template=tmpl, + path_args=path_args, + ), + ) + + if matched_routes == 1: + assert single_route_405 is not None + return _MatchMethodNotAllowed(*single_route_405) + if allowed_methods: + return _MatchMethodNotAllowed(*_build_method_not_allowed(_format_allow_header(allowed_methods))) + + raise AssertionError("unreachable: regex matched but no route template matched") + + def as_handler(self) -> NativeRequestHandler: + """Return a :data:`localpost.http.RequestHandler` that dispatches via this router. + + On a match, attaches :class:`RouteMatch` to ``ctx.attrs[RouteMatch]`` + and delegates to the registered per-route handler. 404 / 405 are + answered inline (via ``ctx.complete``) using pre-built responses; + the body bytes (if any) are silently drained by the http layer. + """ + + def dispatch(ctx: HTTPReqCtx) -> None: + req = ctx.request + # ``req.path`` and ``req.method`` are pre-split / pre-uppercased + # by the backend (httptools.parse_url for httptools, manual + # split for h11) — no per-dispatch decode + split. + path = req.path.decode("iso-8859-1") + method_str = req.method.decode("ascii") + + match = self._match(path, method_str) + + if isinstance(match, _MatchNotFound): + ctx.complete(_NOT_FOUND_RESPONSE, _NOT_FOUND_BODY) + return + if isinstance(match, _MatchMethodNotAllowed): + ctx.complete(match.response, match.body) + return + + ctx.attrs[RouteMatch] = match.match + match.handler(ctx) + + return dispatch + + def as_wsgi(self): + """Return a WSGI application that dispatches via this router. + + Sugar over :func:`localpost.http.wsgi.to_wsgi(self.as_handler())`. + Deploy with Gunicorn / uWSGI / Werkzeug — the WSGI worker model + is the request-side concurrency layer (``thread_pool_handler`` + does not apply). + """ + from localpost.http.wsgi import to_wsgi # noqa: PLC0415 + + return to_wsgi(self.as_handler()) + + +# -------------------------------------------------------------------------- +# Match result types +# -------------------------------------------------------------------------- + + +@final +@dataclass(frozen=True, slots=True) +class _MatchOk: + handler: NativeRequestHandler + match: RouteMatch + + +@final +@dataclass(frozen=True, slots=True) +class _MatchNotFound: + pass + + +@final +@dataclass(frozen=True, slots=True) +class _MatchMethodNotAllowed: + response: _Response + body: bytes + + +_MatchResult = _MatchOk | _MatchNotFound | _MatchMethodNotAllowed +_MATCH_NOT_FOUND = _MatchNotFound() + + +# -------------------------------------------------------------------------- +# Internal helpers +# -------------------------------------------------------------------------- + + +def _literal_prefix_len(t: URITemplate) -> int: + """Length of the literal prefix up to the first ``{var}`` placeholder.""" + m = _VAR_PATTERN.search(t.template) + return len(t.template) if m is None else m.start() + + +def _reprefixed_regex_source(t: URITemplate, prefix: str) -> str: + """Rebuild ``t``'s regex source with ``{prefix}_`` prepended to every named group.""" + parts: list[str] = [] + last_end = 0 + for m in _VAR_PATTERN.finditer(t.template): + parts.append(re.escape(t.template[last_end : m.start()])) + var_name = m.group(1) + parts.append(f"(?P<{prefix}_{var_name}>[^/]+)") + last_end = m.end() + parts.append(re.escape(t.template[last_end:])) + return "".join(parts) + + +def _find_template( + paths: Mapping[URITemplate, Mapping[HTTPMethod, NativeRequestHandler]], + template_str: str, +) -> URITemplate | None: + for t in paths: + if t.template == template_str: + return t + return None + + +def _format_allow_header(methods: Mapping[HTTPMethod, object] | set[HTTPMethod]) -> str: + return ", ".join(m.value for m in sorted(methods, key=lambda hm: hm.value)) + + +def _build_plain_response( + status_code: int, + body: bytes, + *, + extra_headers: tuple[tuple[bytes, bytes], ...] = (), +) -> _Response: + return _Response( + status_code=status_code, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + *extra_headers, + ], + reason=_http_phrases.get(status_code, "Unknown").encode("iso-8859-1"), + ) + + +_NOT_FOUND_BODY = b"Not Found" +_NOT_FOUND_RESPONSE = _build_plain_response(404, _NOT_FOUND_BODY) +_METHOD_NOT_ALLOWED_BODY = b"Method Not Allowed" + + +def _build_method_not_allowed(allow_header: str) -> tuple[_Response, bytes]: + """Pre-build the 405 response for a route. Called once at ``Routes.build()`` + time so the dispatch hot path skips the list / encode / Response build.""" + response = _build_plain_response( + 405, + _METHOD_NOT_ALLOWED_BODY, + extra_headers=((b"Allow", allow_header.encode("ascii")),), + ) + return response, _METHOD_NOT_ALLOWED_BODY diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py new file mode 100644 index 0000000..deca2d2 --- /dev/null +++ b/localpost/http/router_sentry.py @@ -0,0 +1,75 @@ +"""Sentry tracing wrapper for :class:`localpost.http.Router`. + +Each request becomes a Sentry transaction named after the matched URI template +(e.g. ``"GET /books/{id}"``) — a low-cardinality identifier suitable for +Sentry's transaction metrics. Unmatched URLs fall back to the raw path with +``source="url"``. + +Install:: + + pip install localpost[http-sentry] + +Usage:: + + routes = Routes() + + + @routes.get("/books/{id}") + def get_book(ctx): ... + + + router = routes.build() + + sentry_sdk.init(dsn=..., traces_sample_rate=1.0) + handler = sentry_router_handler(router) + + with WorkerExecutor() as ex: + async with thread_pool_handler(handler, ex) as wrapped: + async with http_server(config, wrapped): + ... + +Pure Router instrumentation — no Flask import. Use +:mod:`localpost.http.flask_sentry` for Flask apps. +""" + +from __future__ import annotations + +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._sentry import request_transaction +from localpost.http.router import Router, _MatchOk + +__all__ = ["sentry_router_handler"] + + +def sentry_router_handler(router: Router, *, op: str = "http.server") -> RequestHandler: + """Wrap a :class:`Router` with a Sentry transaction per request. + + The transaction spans the full request handler invocation. 404 / 405 + paths complete inline and end the transaction in the same call. + """ + inner = router.as_handler() + + def handle(ctx: HTTPReqCtx) -> None: + req = ctx.request + method = req.method.decode("ascii") + target = req.target.decode("iso-8859-1") + path = req.path.decode("iso-8859-1") + + # Pre-match so the transaction name uses the URI template (low + # cardinality). Router's dispatch will match again — cheap. + match = router._match(path, method) + if isinstance(match, _MatchOk): + tx_name = f"{method} {match.match.matched_template.template}" + source = "route" + else: + tx_name = f"{method} {path}" + source = "url" + + with request_transaction(op=op, name=tx_name, source=source, method=method, url=target) as tx: + try: + inner(ctx) + finally: + if ctx.response_status is not None: + tx.set_http_status(ctx.response_status) + + return handle diff --git a/localpost/http/rsgi.py b/localpost/http/rsgi.py new file mode 100644 index 0000000..cea79e9 --- /dev/null +++ b/localpost/http/rsgi.py @@ -0,0 +1,402 @@ +"""RSGI transport bridge — adapt :data:`AsyncRequestHandler` ⇆ Granian's RSGI app. + +Symmetric with :mod:`localpost.http.asgi` and :mod:`localpost.http.wsgi`: +this module owns the translation between the foreign protocol (Granian's +RSGI) and our async request-context shape (:class:`AsyncHTTPReqCtx`). +The handler doesn't know anything about RSGI — it just reads +``ctx.request`` / ``await ctx.receive(size)``, and calls +``await ctx.complete(...)`` / ``await ctx.stream(...)`` / +``await ctx.sendfile(...)``. + +RSGI's wire surface is richer than ASGI's, so the bridge gets a few +wins for free: + +- **Eager responses** are a single sync call (``proto.response_bytes``) + rather than ASGI's two-event dance. +- **Sendfile** uses ``proto.response_file_range`` for true zero-copy + when the file has a path; falls back to chunked stream otherwise. +- **Streaming uploads** don't need a pump task — RSGI's ``proto`` is + directly async-iterable, so :meth:`receive` wraps that iterator. + +Body bytes are read lazily via ``await ctx.receive(size)`` (or the +:func:`localpost.http.aread_body` helper). The bridge never +pre-buffers. + +This module covers Mode A (single HTTP app under Granian); Mode B +(host-as-RSGI for hosted apps with multiple services) lives in +:mod:`localpost.hosting.rsgi`. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, BinaryIO, final + +import anyio + +from localpost.http._async_base import AsyncHTTPReqCtx, AsyncRequestHandler +from localpost.http._types import Request +from localpost.http._types import Response as _Response +from localpost.http.config import DEFAULT_BUFFER_SIZE + +if TYPE_CHECKING: + from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +__all__ = [ + "RSGIScope", + "RSGIProtocol", + "RSGIApplication", + "to_rsgi", + "build_request_from_scope", + "addrs_from_scope", +] + + +# RSGI types — kept loose so we don't depend on a particular Granian +# version's TypedDict layout. Granian provides ``Scope`` / ``HTTPProtocol`` +# at ``granian.rsgi``; we type against ``Any`` here and trust the wire shape. +type RSGIScope = Any +type RSGIProtocol = Any +type RSGIApplication = Any + + +# --- Public adapter ----------------------------------------------------- + + +def to_rsgi( + handler: AsyncRequestHandler, + *, + max_body_size: int = 1 << 20, +) -> RSGIApplication: + """Wrap an :data:`AsyncRequestHandler` as an RSGI application. + + Deploy under Granian:: + + from localpost.http.rsgi import to_rsgi + + + async def my_handler(ctx): + await ctx.complete(Response(200), b"hi") + + + rsgi_app = to_rsgi(my_handler) + # granian --interface rsgi myapp:rsgi_app + + Args: + handler: The async request handler. + max_body_size: Cap on the request body, in bytes. The bridge + pre-checks ``Content-Length`` (when present) and replies + ``413 Payload Too Large`` before the handler runs. + Chunk-by-chunk enforcement during ``ctx.receive`` is the + handler's / :func:`localpost.http.aread_body`'s job. + ``-1`` disables the cap. Defaults to ``1 << 20`` (1 MiB). + + Returns: + An object with ``__rsgi__`` (and no-op ``__rsgi_init__`` / + ``__rsgi_del__``) suitable as Granian's RSGI target. + + Body bytes are pulled lazily via ``await ctx.receive(size)`` (or + :func:`localpost.http.aread_body`); the bridge never pre-buffers. + """ + return _RSGIApp(handler, max_body_size=max_body_size) + + +@final +class _RSGIApp: + """Concrete RSGI app object exposing ``__rsgi__`` and the lifecycle hooks. + + No-op lifecycle here — :class:`localpost.hosting.rsgi.HostRSGIApp` + is the variant that runs a full hosting lifecycle alongside. + """ + + __slots__ = ("_handler", "_max_body_size") + + def __init__( + self, + handler: AsyncRequestHandler, + *, + max_body_size: int, + ) -> None: + self._handler = handler + self._max_body_size = max_body_size + + async def __rsgi__(self, scope: RSGIScope, proto: RSGIProtocol) -> None: + await _dispatch(self._handler, self._max_body_size, scope, proto) + + def __rsgi_init__(self, loop: Any) -> None: + # No background services to start. + pass + + def __rsgi_del__(self, loop: Any) -> None: + # No background services to stop. + pass + + +# --- Dispatch ----------------------------------------------------------- + + +async def _dispatch( + handler: AsyncRequestHandler, + max_body_size: int, + scope: RSGIScope, + proto: RSGIProtocol, +) -> None: + request = build_request_from_scope(scope) + if max_body_size >= 0: + cl = _content_length(request) + if cl is not None and cl > max_body_size: + await _send_canned(proto, 413, b"Payload Too Large") + return + + remote, local = addrs_from_scope(scope) + disconnected = anyio.Event() + body_send, body_recv = anyio.create_memory_object_stream[bytes](0) + ctx = _RSGIReqCtx( + request=request, + remote_addr=remote, + local_addr=local, + scheme=str(scope.scheme), + _proto=proto, + _disconnected=disconnected, + _body_stream=body_recv, + ) + async with anyio.create_task_group() as tg: + tg.start_soon(_pump_body, proto, body_send) + tg.start_soon(_watch_disconnect, proto, disconnected) + try: + await handler(ctx) + finally: + tg.cancel_scope.cancel() + + +# --- Concrete ctx ------------------------------------------------------- + + +class _ResponseAlreadyStarted(RuntimeError): + """Raised if a handler tries to start two responses on one request.""" + + +@final +@dataclass(slots=True, eq=False) +class _RSGIReqCtx: + """:class:`AsyncHTTPReqCtx` backed by Granian's RSGI ``proto``. + + Body bytes come lazily through :meth:`receive`, which pulls chunks + from the in-process queue that :func:`_pump_body` feeds from + ``async for chunk in proto``. Response paths translate directly + to RSGI calls: + + - :meth:`complete` → ``proto.response_bytes`` (or ``response_empty``). + - :meth:`stream` → ``proto.response_stream`` + ``transport.send_bytes``. + - :meth:`sendfile` → ``proto.response_file_range`` for true + zero-copy when the file has a filesystem path; chunked stream + fallback otherwise. + + ``disconnected`` flips when :func:`_watch_disconnect` resolves the + awaitable returned by ``proto.client_disconnect()``. + """ + + request: Request + remote_addr: str | None + local_addr: str + scheme: str + _proto: RSGIProtocol + _disconnected: anyio.Event + _body_stream: MemoryObjectReceiveStream[bytes] + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _started: bool = False + _stream_eof: bool = False + _stream_leftover: bytes = b"" + + @property + def disconnected(self) -> bool: + return self._disconnected.is_set() + + async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + if self._stream_leftover: + if len(self._stream_leftover) <= size: + chunk = self._stream_leftover + self._stream_leftover = b"" + return chunk + chunk = self._stream_leftover[:size] + self._stream_leftover = self._stream_leftover[size:] + return chunk + if self._stream_eof: + return b"" + try: + chunk = await self._body_stream.receive() + except anyio.EndOfStream: + self._stream_eof = True + return b"" + if len(chunk) <= size: + return chunk + self._stream_leftover = chunk[size:] + return chunk[:size] + + async def complete(self, response: _Response, body: bytes | None = None) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + headers = _str_headers(response.headers) + if not body: + self._proto.response_empty(response.status_code, headers) + else: + self._proto.response_bytes(response.status_code, headers, body) + + async def stream(self, response: _Response, chunks: AsyncIterator[bytes], /) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + headers = _str_headers(response.headers) + transport = self._proto.response_stream(response.status_code, headers) + async for chunk in chunks: + if self._disconnected.is_set(): + return + await transport.send_bytes(bytes(chunk)) + + async def sendfile(self, response: _Response, file: BinaryIO, offset: int, count: int) -> None: + """Try ``proto.response_file_range`` for zero-copy when ``file`` + has a filesystem path; fall back to chunked stream otherwise.""" + self._check_not_started() + path = _file_path(file) + if path is not None: + self._started = True + self.response_status = response.status_code + headers = _str_headers(response.headers) + self._proto.response_file_range(response.status_code, headers, path, offset, offset + count) + return + # No filesystem path — chunked read fallback. + file.seek(offset) + chunks = _read_file_chunks(file, count, DEFAULT_BUFFER_SIZE) + await self.stream(response, chunks) + + def _check_not_started(self) -> None: + if self._started: + raise _ResponseAlreadyStarted("Response already started") + + +# Concrete ctx implements the AsyncHTTPReqCtx Protocol — verify at import. +_: type[AsyncHTTPReqCtx] = _RSGIReqCtx + + +# --- Scope translation -------------------------------------------------- + + +def build_request_from_scope(scope: RSGIScope) -> Request: + """Build a localpost :class:`Request` from an RSGI ``scope``.""" + method = scope.method.encode("ascii") + path = scope.path.encode("utf-8") + query_string = scope.query_string.encode("ascii") if scope.query_string else b"" + target = path + (b"?" + query_string if query_string else b"") + headers = tuple( + (str(name).lower().encode("ascii"), str(value).encode("iso-8859-1")) for name, value in scope.headers.items() + ) + http_version = scope.http_version.encode("ascii") + return Request( + method=method, + target=target, + path=path, + query_string=query_string, + headers=headers, + http_version=http_version, + ) + + +def addrs_from_scope(scope: RSGIScope) -> tuple[str | None, str]: + """Return ``(remote_addr, local_addr)`` from an RSGI scope. + + RSGI uses ``"host:port"`` strings on ``scope.client`` / + ``scope.server`` — pass them through. + """ + client = getattr(scope, "client", None) or None + server = getattr(scope, "server", "") or "" + return (str(client) if client else None, str(server)) + + +def _content_length(request: Request) -> int | None: + for name, value in request.headers: + if name == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + + +def _str_headers(headers: Any) -> list[tuple[str, str]]: + """Convert localpost's bytes headers into RSGI's ``(str, str)`` shape.""" + return [ + ( + name.decode("iso-8859-1") if isinstance(name, bytes) else str(name), + value.decode("iso-8859-1") if isinstance(value, bytes) else str(value), + ) + for name, value in headers + ] + + +def _file_path(file: BinaryIO) -> str | None: + name = getattr(file, "name", None) + if name is None or not isinstance(name, str): + return None + return name + + +# --- Body / disconnect / canned responses ------------------------------- + + +async def _pump_body( + proto: RSGIProtocol, + body_send: MemoryObjectSendStream[bytes], +) -> None: + """Streaming-mode body pump: relay ``async for chunk in proto`` into + ``body_send`` so :meth:`_RSGIReqCtx.receive` can consume chunks + serialised through anyio's memory stream. + + RSGI's proto is single-consumer (you can't iterate it from two + tasks), so we own iteration here and the ctx pulls from the + queue. Disconnect detection runs in a sibling task — RSGI exposes + ``proto.client_disconnect()`` separately, so we don't need ASGI's + demux pattern. + """ + try: + async with body_send: + async for chunk in proto: + if chunk: + await body_send.send(bytes(chunk)) + except Exception: # noqa: BLE001 + return + + +async def _watch_disconnect(proto: RSGIProtocol, flag: anyio.Event) -> None: + """Set ``flag`` when ``proto.client_disconnect()`` resolves.""" + try: + await proto.client_disconnect() + except Exception: # noqa: BLE001 + return + flag.set() + + +async def _send_canned(proto: RSGIProtocol, status: int, body: bytes) -> None: + headers = [ + ("content-type", "text/plain; charset=utf-8"), + ("content-length", str(len(body))), + ] + proto.response_bytes(status, headers, body) + + +def _read_file_chunks(file: BinaryIO, count: int, blksize: int) -> AsyncIterator[bytes]: + """Async-iterator wrapper around a sync file's chunked read. + Used by :meth:`_RSGIReqCtx.sendfile` when zero-copy isn't available.""" + + async def gen() -> AsyncIterator[bytes]: + remaining = count + while remaining > 0: + chunk = file.read(min(blksize, remaining)) + if not chunk: + return + remaining -= len(chunk) + yield chunk + + return gen() diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py new file mode 100644 index 0000000..b12fa3b --- /dev/null +++ b/localpost/http/server_h11.py @@ -0,0 +1,491 @@ +"""HTTP/1.1 server backend driven by h11. + +Pure-Python parser/state-machine. The default backend; readable, no C deps. +For the C-based alternative see :mod:`localpost.http.server_httptools`. + +Like the httptools backend, this one: + +- dispatches the handler exactly once on ``Request`` (headers complete) + — handlers that need the request body call ``ctx.receive(size)`` + themselves (or :func:`localpost.http.read_body`); the selector never + pre-buffers +- auto-buffers the response headers; the next ``send`` (or + ``finish_response`` for empty bodies) emits headers + first body chunk + in a single ``sendall`` +- does not parallelise pipelined requests on the same connection + (sequential serving via h11's own state machine) +""" + +from __future__ import annotations + +import socket +import time +from collections.abc import Buffer, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any, BinaryIO, cast, final + +import h11 + +from localpost.http._base import ( + BAD_REQUEST_BODY, + BAD_REQUEST_RESPONSE, + INTERNAL_ERROR_BODY, + INTERNAL_ERROR_RESPONSE, + PAYLOAD_TOO_LARGE_BODY, + PAYLOAD_TOO_LARGE_RESPONSE, + REQUEST_TIMEOUT_BODY, + REQUEST_TIMEOUT_RESPONSE, + BaseHTTPConn, + RequestHandler, + Selector, + _native_stream, + _peek_disconnected, + _send_all, + content_length, + emit_handler_error, + scan_response_headers, +) +from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response +from localpost.http.config import DEFAULT_BUFFER_SIZE + +__all__ = ["HTTPConn"] + + +def _to_h11_response(r: Response | InformationalResponse) -> h11.Response | h11.InformationalResponse: + headers = cast(list, r.headers) + if isinstance(r, Response): + return h11.Response(status_code=r.status_code, headers=headers, reason=r.reason) + return h11.InformationalResponse(status_code=r.status_code, headers=headers, reason=r.reason) + + +@final +@dataclass(frozen=True, slots=True, eq=False) +class _SendfilePlaceholder: + """Stand-in for body bytes that will be written by ``socket.sendfile``. + + h11 explicitly supports this pattern: from ``h11.Data.data``'s docstring + — *"any object that your socket writing code knows what to do with, + and for which calling len() returns the number of bytes that will be + written"*. Feeding it through ``send_with_data_passthrough`` advances + h11's ``ContentLengthWriter`` counter without forcing an allocation + of N zero bytes for a multi-MB / GB file. + """ + + n: int + + def __len__(self) -> int: + return self.n + + +@final +@dataclass(slots=True, eq=False) +class HTTPConn(BaseHTTPConn): + selector: Selector + sock: socket.socket + addr: tuple[str, int] + handler: RequestHandler + fd: int = field(init=False) + """The integer file descriptor captured at construction time. Used to + clean up ``selector._fd_to_key`` after ``sock.close()`` (where + ``sock.fileno()`` returns -1).""" + recv_closed: bool = False + parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) + """The h11 state machine — used for **both** parsing the request + (``parser.next_event`` / ``parser.receive_data``) and serialising + the response (``parser.send``). + + **Single-thread invariant.** The selector owns the parser from + ``__call__`` entry until ``stop_tracking`` (in the + :data:`BodyHandler` dispatcher); the worker owns it from then until + ``track`` re-registers the conn. The op-queue / wakeup-pipe + handoff in :class:`localpost.http._base.Selector` is the + synchronisation edge — `os.write` to the wakeup pipe is a full + memory barrier across threads. The parser is **never** touched + concurrently from two threads. + """ + close_at: float | None = None + tracked: bool = False + body_bytes_received: int = 0 + """Cumulative body bytes received for the current request — reset on + ``parser.start_next_cycle``. Compared against ``ServerConfig.max_body_size`` + to enforce the upload cap.""" + idle: bool = True + + def __post_init__(self) -> None: + self.fd = self.sock.fileno() + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> None: + data = self.sock.recv(size) + self.parser.receive_data(data) + if not data: + self.recv_closed = True + else: + self.idle = False + + def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11.EndOfMessage) -> None: + payload = self.parser.send(event) + if payload is None: + return + _send_all(self, payload) + + def __call__(self, _sel: Selector, /) -> None: + try: + self._loop() + except h11.RemoteProtocolError as e: + self.selector.logger.warning("Bad client input from %s: %s", self.addr, e) + self._try_send_status(BAD_REQUEST_RESPONSE, BAD_REQUEST_BODY) + self.close() + except h11.LocalProtocolError: + self.selector.logger.exception("Local protocol error from %s", self.addr) + self._try_send_status(INTERNAL_ERROR_RESPONSE, INTERNAL_ERROR_BODY) + self.close() + except BodyTooLarge: + self.selector.logger.warning( + "Request body from %s exceeds max_body_size=%d", self.addr, self.selector.config.max_body_size + ) + self._try_send_status(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) + self.close() + + def _loop(self) -> None: + parser = self.parser + h = self.handler + + while self.tracked: + if parser.our_state is h11.MUST_CLOSE: + self.close() + return + if parser.our_state is h11.DONE and parser.their_state is h11.DONE: + parser.start_next_cycle() + self.body_bytes_received = 0 + self.idle = True + self.close_at = time.monotonic() + self.selector.config.keep_alive_timeout + + event = parser.next_event() + + if event is h11.NEED_DATA: + try: + self.receive() + except BlockingIOError: + return # Wait for it in the selector + self.close_at = time.monotonic() + self.selector.config.rw_timeout + elif isinstance(event, h11.Request): + # Pre-emptive Content-Length cap: bail before the handler + # ever sees an over-large body. + cl = content_length(event.headers) + if cl is not None and cl > self.selector.config.max_body_size: + raise BodyTooLarge(cl) + # h11 hands us ``bytes`` for method/target/version and a list + # of ``(bytes, bytes)`` tuples for headers. ``list(event.headers)`` + # is a shallow copy that insulates Request from h11's per-event + # Headers subclass. h11 is lenient on method case (per RFC the + # method is case-sensitive but most clients send uppercase); + # normalise here so consumers can rely on it. + target = event.target + qix = target.find(b"?") + if qix >= 0: + path = target[:qix] + query_string = target[qix + 1 :] + else: + path = target + query_string = b"" + method = event.method + if not method.isupper(): + method = method.upper() + req = Request( + method=method, + target=target, + path=path, + query_string=query_string, + headers=list(event.headers), + http_version=event.http_version, + ) + ctx = _HTTPReqCtx(self.selector, self, req) + try: + h(ctx) + except BodyTooLarge: + raise + except Exception: + self.selector.logger.exception("Handler raised for %s %r", event.method, event.target) + emit_handler_error(ctx) + if ctx.borrowed: + return # Worker owns the conn; it'll give back later. + if not self.tracked: + return # Conn closed during error recovery. + # Handler ran on the selector thread. If it didn't fully + # consume the body, keep-alive isn't safe — close the conn + # (matches RFC 7230 §6.5 "close after sending response if + # the request body wasn't read"). Covers both the + # plain "404 without reading body" and the + # ``Expect: 100-continue`` "responded without sending 100" + # cases. + if parser.their_state is not h11.DONE: + self.close() + return + elif isinstance(event, h11.ConnectionClosed): + self.selector.logger.debug("Client closed connection") + self.close() + return + else: + # Stray Data / EndOfMessage events arrive only when the + # handler partially consumed the body and returned. The + # ``their_state != DONE`` check above closes us before we + # get here, so reaching this branch is a programming error. + raise RuntimeError(f"Unexpected {event!r} in the connection loop") + + def _try_send_status(self, response: Response, body: bytes) -> None: + """Best-effort: try to send a response if the parser is still in a writable state. + + Used as a recovery path when the connection is about to be closed due to a + protocol error. Failures are silently swallowed. + """ + if self.parser.our_state is not h11.IDLE and self.parser.our_state is not h11.SEND_RESPONSE: + return + try: + self.send(_to_h11_response(response)) + if body: + self.send(h11.Data(data=body)) + self.send(h11.EndOfMessage()) + except Exception: # noqa: BLE001, S110 — connection is being closed anyway + pass + + def emit_stale_408(self) -> None: + """Stalled mid-request → 408. Idle keep-alive → silently dropped.""" + if self.idle or self.parser.our_state is not h11.IDLE: + return + try: + payload = self.parser.send(_to_h11_response(REQUEST_TIMEOUT_RESPONSE)) + if payload: + _send_all(self, payload) + payload = self.parser.send(h11.Data(data=REQUEST_TIMEOUT_BODY)) + if payload: + _send_all(self, payload) + payload = self.parser.send(h11.EndOfMessage()) + if payload: + _send_all(self, payload) + except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway + pass + + +@dataclass(slots=True, eq=False) +class _HTTPReqCtx: + """Per-request context for the h11 backend. + + Structurally satisfies :class:`localpost.http._base.HTTPReqCtx`. + + The response-write path auto-buffers: ``start_response`` advances h11 + and stashes the returned bytes; the next ``send`` (or + ``finish_response`` for empty bodies) emits headers + first body + chunk in a single ``sendall``. + """ + + selector: Selector + conn: HTTPConn + request: Request + + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _pending_header_bytes: bytes | None = None + _disconnected: bool = False + + @property + def remote_addr(self) -> str | None: + host, port = self.conn.addr + return f"{host}:{port}" if host else None + + @property + def local_addr(self) -> str: + sel = self.selector + return f"{sel.config.host}:{sel.port}" + + @property + def scheme(self) -> str: + # No TLS support today; native server is plain HTTP. + return "http" + + @property + def disconnected(self) -> bool: + if self._disconnected: + return True + if _peek_disconnected(self.conn.sock): + self._disconnected = True + return True + return False + + @property + def borrowed(self) -> bool: + return not self.conn.tracked + + @contextmanager + def borrow(self) -> Iterator[_HTTPReqCtx]: + """Switch the conn out of selector tracking for the duration of the block.""" + assert not self.borrowed + self.selector.stop_tracking(self.conn) + try: + yield self + finally: + self._maybe_give_back() + + def _maybe_give_back(self) -> None: + if not self.borrowed: + return + # If a fallback path inside this request flipped the socket to + # blocking-with-timeout, reset to non-blocking before handing the + # conn back to the selector — the selector loop assumes a + # non-blocking socket and a stray BlockingIOError is its only + # "no more data" signal. ``gettimeout`` reads cached state on the + # socket object (no syscall), so the no-fallback common case + # pays nothing. + sock = self.conn.sock + if sock.gettimeout() != 0: + try: + sock.settimeout(0) + except OSError: + pass + self.selector.track(self.conn) + + def complete(self, response: Response, body: bytes | None = None) -> None: + self.start_response(response) + if body is not None: + self.send(body) + self.finish_response() + + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + _native_stream(self, response, chunks) + + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + # ``Content-Length`` framing is required — otherwise h11's writer + # state can't reconcile what we wrote out-of-band. The static handler + # always sets it; bail loudly if a caller forgets. + if content_length(response.headers) != count: + raise ValueError("sendfile requires Content-Length matching ``count``") + self.start_response(response) + # Advance h11's ContentLengthWriter counter via a placeholder Data + # event — h11 only calls ``len(data)`` and ``write(data)``. The + # placeholder lands in the returned data list (which we ignore); + # the real bytes go on the wire via ``socket.sendfile`` below. + # h11 supports placeholders in ``Data`` events for sendfile — see + # ``h11.Data.data`` docstring. Type-checkers see ``data: bytes``. + placeholder_event = h11.Data(data=_SendfilePlaceholder(count)) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + self.conn.parser.send_with_data_passthrough(placeholder_event) + if self._pending_header_bytes is not None: + self._sock_sendall(self._pending_header_bytes) + self._pending_header_bytes = None + sock = self.conn.sock + sock.settimeout(self.selector.config.rw_timeout) + try: + sock.sendfile(file, offset=offset, count=count) + finally: + if self.conn.tracked: + try: + sock.settimeout(0) + except OSError: + pass + # Consume EOM + drain request side + give back via the existing path. + # Counter is at 0, so EOM produces no extra wire bytes for + # Content-Length framing. + self.finish_response() + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + """Streaming-read API: pull up to ``size`` bytes of the request + body off the wire (or ``b""`` at EOF / end-of-message). Drives + the h11 state machine — sends 100 Continue inline if the client + is awaiting it. Use :func:`localpost.http.read_body` for the + "give me the whole body" common case.""" + parser = self.conn.parser + if parser.their_state is h11.DONE: + return b"" + if parser.they_are_waiting_for_100_continue: + self.conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) + while True: + event = parser.next_event() + if event is h11.NEED_DATA: + # Sync handlers can't tolerate BlockingIOError on a non-blocking + # socket: switch to a blocking read bounded by ``rw_timeout``. + # On a borrowed conn we leave the socket blocking-with-timeout + # so the next iteration's ``recv`` skips the BlockingIOError + # path entirely; the give-back path resets it on hand-back. + # On the selector thread we restore non-blocking inline. + sock = self.conn.sock + try: + self.conn.receive(size) + except BlockingIOError: + sock.settimeout(self.selector.config.rw_timeout) + try: + self.conn.receive(size) + finally: + if self.conn.tracked: + sock.settimeout(0) + elif isinstance(event, h11.Data): + self.conn.body_bytes_received += len(event.data) + if self.conn.body_bytes_received > self.selector.config.max_body_size: + raise BodyTooLarge(self.conn.body_bytes_received) + return bytes(event.data) + elif isinstance(event, h11.EndOfMessage): + return b"" + else: # h11.ConnectionClosed is not possible, it will be a protocol error + raise RuntimeError(f"Unexpected h11 event: {event!r}") + + def start_response(self, response: Response | InformationalResponse, /) -> None: + if isinstance(response, Response): + self.response_status = response.status_code + if self.request.method == b"HEAD" and not scan_response_headers(response.headers)[1]: + response = Response( + status_code=response.status_code, + headers=[*response.headers, (b"content-length", b"0")], + reason=response.reason, + ) + # Drive the h11 state machine, but buffer the bytes for a + # coalesced ``sendall`` with the first body chunk. + payload = self.conn.parser.send(_to_h11_response(response)) + self._pending_header_bytes = bytes(payload) if payload else b"" + else: + # Informational responses (100 Continue, etc.) flush immediately. + self.conn.send(_to_h11_response(response)) + + def send(self, chunk: Buffer, /) -> None: + # h11 wants bytes; widen the public API to any Buffer (memoryview, + # bytearray, …) so callers can avoid an explicit copy. + chunk_bytes = chunk if isinstance(chunk, bytes) else bytes(chunk) + payload = self.conn.parser.send(h11.Data(data=chunk_bytes)) + if payload is None: + return + if self._pending_header_bytes is not None: + combined = self._pending_header_bytes + payload + self._pending_header_bytes = None + self._sock_sendall(combined) + elif payload: + self._sock_sendall(payload) + + def finish_response(self) -> None: + # Coalesce: ``EndOfMessage`` payload (chunked terminator or nothing) + # plus any still-pending header bytes (empty-body case) emit in one + # ``sendall``. + eom_payload = self.conn.parser.send(h11.EndOfMessage()) + eom_bytes = bytes(eom_payload) if eom_payload else b"" + if self._pending_header_bytes is not None: + combined = self._pending_header_bytes + eom_bytes + self._pending_header_bytes = None + if combined: + self._sock_sendall(combined) + elif eom_bytes: + self._sock_sendall(eom_bytes) + # Drain h11's pending ``EndOfMessage`` for the request side before + # giving the conn back. For a no-body request the selector parsed + # ``Request`` and stopped — h11 still has the implicit EndOfMessage + # queued, and ``their_state`` won't reach ``DONE`` until something + # consumes it. Without this drain, the next selector wake on a + # keep-alive request hits ``PAUSED`` from ``parser.next_event``. + # + # If h11 needs more bytes (handler didn't read a non-empty body), + # close the conn — keep-alive isn't safe with un-drained body bytes. + parser = self.conn.parser + while parser.their_state is not h11.DONE: + event = parser.next_event() + if event is h11.NEED_DATA or event is h11.PAUSED or isinstance(event, h11.ConnectionClosed): + self.conn.close() + return + self._maybe_give_back() + + def _sock_sendall(self, payload: bytes) -> None: + _send_all(self.conn, payload) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py new file mode 100644 index 0000000..7e1c521 --- /dev/null +++ b/localpost/http/server_httptools.py @@ -0,0 +1,636 @@ +"""HTTP/1.1 server backend driven by ``httptools`` (llhttp wrapper). + +Faster, opt-in alternative to the default :mod:`localpost.http.server_h11` +backend. httptools is a parse-only C extension — response bytes and +keep-alive bookkeeping are handled here. + +Scope: + +- request body is buffered in full into ``ctx.body`` before the + :data:`BodyHandler` continuation is invoked (JSON-API common case) +- ``Expect: 100-continue`` is sent inline only when the pre-body + :data:`RequestHandler` returns a continuation (i.e., the body is + actually wanted) +- keep-alive driven by ``parser.should_keep_alive()`` and response + headers +- HTTP/1.1 pipelining is **not supported**: pipelined clients are served + one request at a time on the same connection (no parallelism gained, + no incorrect interleaving emitted) +- response framing: ``Content-Length`` (set by the caller) or auto + ``Transfer-Encoding: chunked`` if neither is present + +Out of scope: HTTP upgrade negotiation (WebSockets), HTTP/2. +""" + +from __future__ import annotations + +import socket +import time +from collections.abc import Buffer, Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any, BinaryIO, final + +import httptools + +from localpost.http._base import ( + BAD_REQUEST_WIRE, + PAYLOAD_TOO_LARGE_WIRE, + REASON_PHRASES, + REQUEST_TIMEOUT_WIRE, + BaseHTTPConn, + RequestHandler, + Selector, + _native_stream, + _peek_disconnected, + _send_all, + content_length, + emit_handler_error, + scan_response_headers, +) +from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response +from localpost.http.config import DEFAULT_BUFFER_SIZE + +__all__ = ["HTTPConn"] + + +def _serialize_response(r: Response | InformationalResponse) -> bytes: + """Serialise a status + headers block to wire bytes (no body).""" + out = bytearray(b"HTTP/1.1 ") + out += str(r.status_code).encode("ascii") + out += b" " + out += r.reason or REASON_PHRASES.get(r.status_code, b"") + out += b"\r\n" + for name, value in r.headers: + out += name + out += b": " + out += value + out += b"\r\n" + out += b"\r\n" + return bytes(out) + + +def _response_allows_body(request_method: bytes, status_code: int) -> bool: + return request_method != b"HEAD" and not (100 <= status_code < 200 or status_code in {204, 304}) + + +@final +@dataclass(slots=True, eq=False) +class HTTPConn(BaseHTTPConn): + selector: Selector + sock: socket.socket + addr: tuple[str, int] + handler: RequestHandler + fd: int = field(init=False) + parser: httptools.HttpRequestParser = field(init=False) + """The httptools (llhttp) parser — parse-only on our side; the + response wire bytes are hand-built via ``_serialize_response`` + plus ``_send_all`` (no parser involved on the response path). + + **Single-thread invariant.** The selector owns the parser during + ``parser.feed_data`` (and the callbacks it fires) for the + request-headers phase; on streaming routes (``buffer_body=False``) + the worker also calls ``parser.feed_data`` from inside + ``ctx.receive`` to drain remaining body bytes. The op-queue / + wakeup-pipe handoff in + :class:`localpost.http._base.Selector` is the synchronisation + edge — `os.write` to the wakeup pipe is a full memory barrier. + The parser is **never** touched concurrently from two threads; + ownership is strict (selector → worker on ``stop_tracking``; + worker → selector on ``track``). + """ + close_at: float | None = None + tracked: bool = False + idle: bool = True + + # Per-request scratch state populated by the parser callbacks. + _cur_target: bytes = b"" + """Request target. Most requests fit in one ``on_url`` call; for the rare + fragmented case we accumulate via ``_cur_target_buf`` and flatten in + ``on_headers_complete``.""" + _cur_target_buf: bytearray | None = None + """Allocated lazily on the second ``on_url`` callback to merge fragments + without creating a new ``bytes`` per fragment.""" + _cur_headers: list[tuple[bytes, bytes]] = field(default_factory=list) + _cur_expect_100: bool = False + _cur_oversize: bool = False # set by on_headers_complete if Content-Length exceeds cap + + # Per-conn dispatch state. One in-flight request at a time (no pipelining): + # ``_ctx`` is built in ``on_headers_complete`` and the handler is invoked + # inline. ``on_body`` populates ``_body_buf`` (drained by ``ctx.receive`` + # on a borrowed conn); ``on_message_complete`` flips ``_body_eom``. + _ctx: _HTTPReqCtx | None = None + _body_buf: bytearray = field(default_factory=bytearray) + _body_eom: bool = False + _message_complete: bool = False + _body_too_large: int | None = None + + # Streaming-pool dispatch hook. ``_defer_streaming_dispatch`` (called by + # the streaming-pool wrapper from inside ``on_headers_complete``) + # stashes a callback here; ``_feed`` runs it after ``parser.feed_data`` + # returns so the worker dispatch happens outside the parser callback. + _deferred_streaming_dispatch: Callable[[], None] | None = None + + # Set when the response indicates ``Connection: close`` or the request lacked + # keep-alive support — the conn is closed once finish_response returns. + _close_after_response: bool = False + _response_started: bool = False + + def __post_init__(self) -> None: + self.fd = self.sock.fileno() + self.parser = httptools.HttpRequestParser(self) + + # ----- httptools callbacks (fired inside parser.feed_data) ----- + + def on_message_begin(self) -> None: + self._cur_target = b"" + self._cur_target_buf = None + self._cur_headers = [] + self._cur_expect_100 = False + self._cur_oversize = False + + def on_url(self, url: bytes) -> None: + if not self._cur_target: + self._cur_target = url + return + # Second+ fragment: keep a bytearray so we don't allocate a new bytes + # per fragment. Common case (single fragment) never enters this branch. + if self._cur_target_buf is None: + self._cur_target_buf = bytearray(self._cur_target) + self._cur_target_buf += url + + def on_header(self, name: bytes, value: bytes) -> None: + n = name.lower() + # httptools strips leading OWS but leaves trailing SP/HTAB intact; + # RFC 7230 §3.2.4 requires both sides stripped, and h11 does so. Trim + # trailing OWS here to keep cross-backend parity. + if value.endswith((b" ", b"\t")): + value = value.rstrip(b" \t") + self._cur_headers.append((n, value)) + if n == b"expect" and value.lower() == b"100-continue": + self._cur_expect_100 = True + + def on_headers_complete(self) -> None: + # Build the neutral Request envelope and dispatch the pre-body handler. + method = self.parser.get_method() + version = self.parser.get_http_version().encode("ascii") + keep_alive = self.parser.should_keep_alive() + + # Flatten the multi-fragment target if the rare path was hit. + if self._cur_target_buf is not None: + self._cur_target = bytes(self._cur_target_buf) + self._cur_target_buf = None + + # Eager content-length cap check. + cl = content_length(self._cur_headers) + if cl is not None and cl > self.selector.config.max_body_size: + self._body_too_large = cl + self._cur_oversize = True + return + + # Pre-split the URL once. Manually find/slice — measured ~2x faster + # than ``httptools.parse_url`` (which is C-level but pays Python + # object-construction overhead per parse). The split is moved into + # the backend so consumers (Router / wsgi) skip per-dispatch work. + target = self._cur_target + qix = target.find(b"?") + if qix >= 0: + path = target[:qix] + query_string = target[qix + 1 :] + else: + path = target + query_string = b"" + + # ``method`` and ``self._cur_target`` are already ``bytes`` (httptools + # callbacks deliver real ``bytes``, not memoryview/bytearray); no copy. + # httptools rejects lowercase methods at parse time, so ``method`` is + # already uppercase ASCII. + req = Request( + method=method, + target=self._cur_target, + path=path, + query_string=query_string, + headers=self._cur_headers, + http_version=version, + ) + ctx = _HTTPReqCtx( + self.selector, + self, + req, + _expect_100_continue=self._cur_expect_100, + _keep_alive=keep_alive, + ) + self._ctx = ctx + self._body_buf = bytearray() + self._body_eom = False + self._message_complete = False + + # Dispatch the handler inline. It runs on the selector thread; it + # can call ``ctx.complete(...)`` (response sent inline) or + # ``ctx.borrow()`` (escape to worker — handler will then drive + # ``ctx.receive`` to drain the body). Body bytes that arrive in + # the same packet land in ``_body_buf`` regardless; ``on_body`` + # decides whether to buffer or drop based on whether the handler + # is still expected to read. + try: + self.handler(ctx) + except BodyTooLarge: + raise + except Exception: + self.selector.logger.exception("Handler raised for %s %r", req.method, req.target) + emit_handler_error(ctx) + + # ``Expect: 100-continue`` edge case: handler responded inline + # without ever sending the 100 Continue (so the client is still + # waiting and will never deliver the body). The parser would + # never observe EOM. Mark the message complete and force the + # conn closed after the response — matches nginx-style "send + # final response + Connection: close" behaviour. + if not ctx.borrowed and self._response_started and self._cur_expect_100 and not ctx._continue_sent: + self._close_after_response = True + self._message_complete = True + + def on_body(self, data: bytes) -> None: + if self._cur_oversize: + return + # If the handler responded inline without borrowing, it's done — + # remaining body bytes won't be read (we'll close the conn after + # the response). Drop to keep the body buf bounded. + ctx = self._ctx + if self._response_started and ctx is not None and not ctx.borrowed: + return + new_total = len(self._body_buf) + len(data) + if new_total > self.selector.config.max_body_size: + self._body_too_large = new_total + return + self._body_buf += data + + def on_message_complete(self) -> None: + self._body_eom = True + self._message_complete = True + + # ----- BaseHTTPConn surface ----- + + def __call__(self, _sel: Selector, /) -> None: + try: + self._loop() + except _ProtocolError as e: + self.selector.logger.warning("Bad client input from %s: %s", self.addr, e) + self._try_send_wire(BAD_REQUEST_WIRE) + self.close() + except BodyTooLarge: + self.selector.logger.warning( + "Request body from %s exceeds max_body_size=%d", self.addr, self.selector.config.max_body_size + ) + self._try_send_wire(PAYLOAD_TOO_LARGE_WIRE) + self.close() + + def _feed(self, data: bytes) -> None: + try: + self.parser.feed_data(data) + except httptools.HttpParserUpgrade as e: + self._deferred_streaming_dispatch = None + raise _ProtocolError(f"HTTP upgrade not supported: {e}") from e + except httptools.HttpParserError as e: + self._deferred_streaming_dispatch = None + raise _ProtocolError(str(e)) from e + if self._body_too_large is not None: + n = self._body_too_large + self._body_too_large = None + self._deferred_streaming_dispatch = None + raise BodyTooLarge(n) + if self._deferred_streaming_dispatch is not None: + dispatch = self._deferred_streaming_dispatch + self._deferred_streaming_dispatch = None + dispatch() + + def _loop(self) -> None: + config = self.selector.config + while self.tracked: + # Did the previous request just complete? Either roll into the + # next one (keep-alive) or close. + if self._message_complete: + ctx = self._ctx + assert ctx is not None + if ctx.borrowed: + return # worker has the conn now + if not self.tracked: + return # closed during error recovery + if self._close_after_response or not ctx._keep_alive: + self.close() + return + self._reset_for_next_request() + # Fall through to read more bytes (or to dispatch a request + # whose headers were already buffered by the parser). + continue + + # Pump bytes from the socket. Parser callbacks fire inline: + # ``on_headers_complete`` dispatches the pre-body handler; + # ``on_message_complete`` invokes the continuation if any and + # sets ``_message_complete``. + try: + data = self.sock.recv(DEFAULT_BUFFER_SIZE) + except BlockingIOError: + return # back to selector + if not data: + self.close() + return + self.idle = False + self.close_at = time.monotonic() + config.rw_timeout + self._feed(data) + + def _reset_for_next_request(self) -> None: + # Per-request parsing scratch is cleared by ``on_message_begin``. + self._ctx = None + self._body_buf = bytearray() + self._body_eom = False + self._message_complete = False + self._response_started = False + self._close_after_response = False + self._deferred_streaming_dispatch = None + self.idle = True + self.close_at = time.monotonic() + self.selector.config.keep_alive_timeout + + def _try_send_wire(self, wire: bytes) -> None: + """Best-effort: write a pre-serialised status+headers+body block. + + Used for canned protocol-error responses (400 / 408 / 413). The wire + bytes are pre-built at module import time (see ``_base.py``) so this + path skips ``_serialize_response`` entirely. + """ + if self._response_started: + return + try: + _send_all(self, wire) + except Exception: # noqa: BLE001, S110 — connection is being closed anyway + pass + + def emit_stale_408(self) -> None: + """Stalled mid-request → 408. Idle keep-alive → silently dropped.""" + if self.idle or self._response_started: + return + try: + _send_all(self, REQUEST_TIMEOUT_WIRE) + except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway + pass + + +class _ProtocolError(Exception): + """Translated httptools parser error. Mapped to 400 by the conn loop.""" + + +@dataclass(slots=True, eq=False) +class _HTTPReqCtx: + """Per-request context for the httptools backend. + + The response-write path auto-buffers: ``start_response`` stores the + serialised status + headers without flushing; the next ``send`` (or + ``finish_response`` for empty bodies) emits a single ``sendall`` with + headers + body framed together. One syscall for the canonical + ``ctx.complete(response, body)`` path; two for SSE (headers + first + chunk together; one per subsequent chunk). + """ + + selector: Selector + conn: HTTPConn + request: Request + _expect_100_continue: bool = False + _keep_alive: bool = True + + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _disconnected: bool = False + _continue_sent: bool = False + _chunked: bool = False + """``True`` if the backend auto-added ``Transfer-Encoding: chunked`` because + the response had neither ``Content-Length`` nor an explicit + ``Transfer-Encoding`` — chunks must be framed and a terminator written.""" + _pending_header_bytes: bytes | None = None + """Buffered status line + headers, awaiting flush on first body chunk + or ``finish_response``. ``None`` once flushed.""" + _body_allowed: bool = True + """False for HEAD / 1xx / 204 / 304 responses, where response body bytes + must not be written even if the handler passes a body to ``complete``.""" + + @property + def remote_addr(self) -> str | None: + host, port = self.conn.addr + return f"{host}:{port}" if host else None + + @property + def local_addr(self) -> str: + sel = self.selector + return f"{sel.config.host}:{sel.port}" + + @property + def scheme(self) -> str: + # No TLS support today; native server is plain HTTP. + return "http" + + @property + def disconnected(self) -> bool: + if self._disconnected: + return True + if _peek_disconnected(self.conn.sock): + self._disconnected = True + return True + return False + + @property + def borrowed(self) -> bool: + return not self.conn.tracked + + @contextmanager + def borrow(self) -> Iterator[_HTTPReqCtx]: + assert not self.borrowed + self.selector.stop_tracking(self.conn) + try: + yield self + finally: + self._maybe_give_back() + + def _defer_streaming_dispatch(self, dispatcher: Callable[[_HTTPReqCtx], None]) -> None: + """Start a worker after the current parser feed returns. + + httptools fires callbacks from inside ``parser.feed_data``. Starting + the worker directly from ``on_headers_complete`` would let it call + ``ctx.receive`` and re-enter the parser while the selector thread is + still inside the same feed. Instead we stash the dispatcher here and + let ``_feed`` run it once parser callbacks are done. + """ + self.conn._deferred_streaming_dispatch = lambda: dispatcher(self) + + def _maybe_give_back(self) -> None: + if not self.borrowed: + return + # If a fallback path inside this request flipped the socket to + # blocking-with-timeout, reset to non-blocking before handing the + # conn back to the selector — the selector loop assumes a + # non-blocking socket. ``gettimeout`` reads cached state on the + # socket object (no syscall), so the no-fallback common case + # pays nothing. + sock = self.conn.sock + if sock.gettimeout() != 0: + try: + sock.settimeout(0) + except OSError: + pass + self.selector.track(self.conn) + + def complete(self, response: Response, body: bytes | None = None) -> None: + self.start_response(response) + if body is not None: + self.send(body) + self.finish_response() + + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + _native_stream(self, response, chunks) + + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + # ``Content-Length`` framing is required: chunked needs per-chunk + # framing bytes that ``socket.sendfile`` can't produce, and a + # mismatched Content-Length corrupts the wire stream. + declared: int | None = None + for name, value in response.headers: + n = name.lower() + if n == b"transfer-encoding": + raise ValueError("sendfile requires Content-Length framing (no Transfer-Encoding)") + if n == b"content-length": + try: + declared = int(value) + except ValueError as e: + raise ValueError("Content-Length is not a valid integer") from e + if declared != count: + raise ValueError("sendfile requires Content-Length matching ``count``") + self.start_response(response) + if self._pending_header_bytes is not None: + _send_all(self.conn, self._pending_header_bytes) + self._pending_header_bytes = None + sock = self.conn.sock + sock.settimeout(self.selector.config.rw_timeout) + try: + sock.sendfile(file, offset=offset, count=count) + finally: + if self.conn.tracked: + try: + sock.settimeout(0) + except OSError: + pass + # No EOM / chunked terminator with Content-Length framing — just + # advance the conn lifecycle (give back / close-after-response). + self.finish_response() + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + """Streaming-read API: pull up to ``size`` bytes of the request + body off the wire (or ``b""`` at EOM / peer FIN). + + Sends ``100 Continue`` lazily if the client is awaiting it. Pulls + more bytes from the socket and feeds them through the parser + (``on_body`` populates ``conn._body_buf``, ``on_message_complete`` + flips ``conn._body_eom``) when the buffer is empty. + + Use :func:`localpost.http.read_body` for the "give me the whole + body" common case. + """ + if self._expect_100_continue and not self._continue_sent: + self._send_continue() + + conn = self.conn + rw = self.selector.config.rw_timeout + + while not conn._body_buf and not conn._body_eom: + try: + data = conn.sock.recv(DEFAULT_BUFFER_SIZE) + except BlockingIOError: + conn.sock.settimeout(rw) + try: + data = conn.sock.recv(DEFAULT_BUFFER_SIZE) + finally: + # On a borrowed conn keep the socket blocking-with-timeout + # for the rest of the request; subsequent ``recv`` calls + # in this loop (and in later sends) skip the + # BlockingIOError path entirely. The give-back path + # resets the socket on hand-back. On the selector thread + # we restore inline. + if conn.tracked: + conn.sock.settimeout(0) + if not data: + break # peer FIN mid-body + # Feed through the parser; ``on_body`` populates ``_body_buf``, + # ``on_message_complete`` flips ``_body_eom``. Errors + # (BodyTooLarge / _ProtocolError) propagate to the caller; + # the worker pool's exception handler emits a 500. + conn._feed(data) + if conn._body_buf: + n = min(size, len(conn._body_buf)) + chunk = bytes(conn._body_buf[:n]) + del conn._body_buf[:n] + return chunk + return b"" + + def _send_continue(self) -> None: + _send_all(self.conn, b"HTTP/1.1 100 Continue\r\n\r\n") + self._continue_sent = True + + def start_response(self, response: Response | InformationalResponse, /) -> None: + # State updates first (response_status, _close_after_response, _chunked + # for the Response case); for Informational responses we just write + # bytes without buffering (rare path). + if isinstance(response, Response): + self.response_status = response.status_code + self.conn._response_started = True + self._body_allowed = _response_allows_body(self.request.method, response.status_code) + has_close, has_framing, has_chunked = scan_response_headers(response.headers) + if has_close or not self._keep_alive: + self.conn._close_after_response = True + if self._body_allowed: + if not has_framing: + # Auto-frame: no Content-Length / Transfer-Encoding → chunked. + # Without framing, an HTTP/1.1 client would wait for the + # connection to close before considering the response done. + response = Response( + status_code=response.status_code, + headers=[*response.headers, (b"transfer-encoding", b"chunked")], + reason=response.reason, + ) + self._chunked = True + elif has_chunked: + # User / middleware supplied ``Transfer-Encoding: chunked`` + # explicitly. Skip auto-add but still flip ``_chunked`` so + # ``send`` frames each chunk — otherwise the header would + # advertise chunked while the body went out raw, corrupting + # the wire. + self._chunked = True + self._pending_header_bytes = _serialize_response(response) + else: + # Informational responses (e.g., 100 Continue) flush immediately. + _send_all(self.conn, _serialize_response(response)) + + def send(self, chunk: Buffer, /) -> None: + if not isinstance(chunk, bytes): + chunk = bytes(chunk) + if not self._body_allowed: + if self._pending_header_bytes is not None: + _send_all(self.conn, self._pending_header_bytes) + self._pending_header_bytes = None + return + if not chunk and self._pending_header_bytes is None: + return + framed = (f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n") if self._chunked and chunk else chunk + if self._pending_header_bytes is not None: + # Headers + first body chunk in one syscall. + _send_all(self.conn, self._pending_header_bytes + framed) + self._pending_header_bytes = None + elif framed: + _send_all(self.conn, framed) + + def finish_response(self) -> None: + # Flush any still-buffered headers (empty-body case) plus the + # chunked terminator, in one sendall. + terminator = b"0\r\n\r\n" if self._chunked else b"" + if self._pending_header_bytes is not None: + _send_all(self.conn, self._pending_header_bytes + terminator) + self._pending_header_bytes = None + elif terminator: + _send_all(self.conn, terminator) + self._maybe_give_back() diff --git a/localpost/http/static.py b/localpost/http/static.py new file mode 100644 index 0000000..1e4877d --- /dev/null +++ b/localpost/http/static.py @@ -0,0 +1,365 @@ +"""Static file serving via ``socket.sendfile()``. + +Serves files from a root directory with zero-copy bodies, conditional GET +(``ETag`` / ``If-None-Match``, ``Last-Modified`` / ``If-Modified-Since``), +single-range support, and ``Cache-Control`` passthrough. Designed to be +wrapped in :func:`localpost.http.thread_pool_handler` with its own +concurrency budget so slow clients can't pin API workers. + +Header-only decisions (404 / 405 / 304 / 416, plus HEAD success) finish +inline via :meth:`HTTPReqCtx.complete`. GET 200 / 206 opens the file and +calls :meth:`HTTPReqCtx.sendfile` — when the handler is wrapped in +:func:`localpost.http.thread_pool_handler` (the recommended composition) +this happens on a worker thread. +""" + +from __future__ import annotations + +import mimetypes +import os +from collections.abc import Mapping +from email.utils import formatdate, parsedate_to_datetime +from pathlib import Path +from stat import S_ISREG +from typing import Final, Literal +from urllib.parse import unquote_to_bytes + +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._types import Response + +__all__ = ["static_handler"] + +_ALLOW_METHODS: Final = b"GET, HEAD" +_NOT_FOUND_BODY: Final = b"Not Found" +_METHOD_NOT_ALLOWED_BODY: Final = b"Method Not Allowed" +_RANGE_NOT_SATISFIABLE_BODY: Final = b"Range Not Satisfiable" + + +def static_handler( + root: str | os.PathLike[str], + *, + prefix: bytes = b"/", + cache_control: str | None = None, + index: str | None = "index.html", +) -> RequestHandler: + """Build a :data:`RequestHandler` that serves files under ``root``. + + Args: + root: Directory to serve from. Resolved at construction; every + request path is verified to stay under this directory. + prefix: URL prefix to strip from ``ctx.request.path`` before + resolving against ``root``. Default ``b"/"`` matches a + handler mounted at the URL root. + cache_control: If set, the value is sent verbatim as the + ``Cache-Control`` response header on 200 / 206 / 304. + index: Filename to serve when the resolved path is a directory. + ``None`` disables directory → index resolution (returns 404). + + Returns: + A :data:`RequestHandler`. For 200 / 206 GET, opens the file and + calls :meth:`HTTPReqCtx.sendfile` — wrap with + :func:`thread_pool_handler` so the syscall runs on a worker + thread. Header-only outcomes (404 / 405 / 304 / 416 / HEAD) + finish inline. + + Example:: + + from localpost.http import http_server, thread_pool_handler + from localpost.http.static import static_handler + from localpost.threadtools import WorkerExecutor + + with WorkerExecutor() as ex: + h = thread_pool_handler( + static_handler( + "/var/www", + prefix=b"/static/", + cache_control="public, max-age=31536000, immutable", + ), + ex, + ) + """ + root_path = Path(os.fspath(root)).resolve(strict=True) + if not root_path.is_dir(): + raise ValueError(f"root must be a directory: {root_path}") + cc_bytes = cache_control.encode("ascii") if cache_control is not None else None + + def handle(ctx: HTTPReqCtx) -> None: + method = ctx.request.method + if method not in (b"GET", b"HEAD"): + # Always include the body — the caller used a non-HEAD method. + ctx.complete( + Response( + status_code=405, + headers=[ + (b"allow", _ALLOW_METHODS), + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_METHOD_NOT_ALLOWED_BODY)).encode("ascii")), + ], + ), + _METHOD_NOT_ALLOWED_BODY, + ) + return + + url_path = ctx.request.path + if not url_path.startswith(prefix): + _send_not_found(ctx, method) + return + + target = _resolve(root_path, url_path[len(prefix) :], index=index) + if target is None: + _send_not_found(ctx, method) + return + + try: + st = target.stat() + except (FileNotFoundError, NotADirectoryError, PermissionError): + _send_not_found(ctx, method) + return + if not S_ISREG(st.st_mode): + _send_not_found(ctx, method) + return + + size = st.st_size + etag = _etag(st) + last_modified = formatdate(st.st_mtime, usegmt=True).encode("ascii") + headers = _headers_index(ctx.request.headers) + + # Conditional GET — If-None-Match takes precedence over If-Modified-Since. + inm = headers.get(b"if-none-match") + ims = headers.get(b"if-modified-since") + if inm is not None and _if_none_match_matches(inm, etag): + _send_not_modified(ctx, etag, last_modified, cc_bytes) + return + if inm is None and ims is not None and _if_modified_since_satisfied(ims, st.st_mtime): + _send_not_modified(ctx, etag, last_modified, cc_bytes) + return + + # Range — single byte-range only. Multi-range / unparsable falls back to 200. + offset = 0 + length = size + status = 200 + content_range: bytes | None = None + if (rng := headers.get(b"range")) is not None: + parsed = _parse_range(rng, size) + if parsed == "unsatisfiable": + _send_range_not_satisfiable(ctx, method, size, etag, last_modified) + return + if parsed is not None: + offset, length = parsed + status = 206 + content_range = f"bytes {offset}-{offset + length - 1}/{size}".encode("ascii") + + response_headers: list[tuple[bytes, bytes]] = [ + (b"content-type", _content_type(target)), + (b"content-length", str(length).encode("ascii")), + (b"etag", etag), + (b"last-modified", last_modified), + (b"accept-ranges", b"bytes"), + ] + if content_range is not None: + response_headers.append((b"content-range", content_range)) + if cc_bytes is not None: + response_headers.append((b"cache-control", cc_bytes)) + + response = Response(status_code=status, headers=response_headers) + + if method == b"HEAD" or length == 0: + ctx.complete(response) + return + + with target.open("rb") as f: + ctx.sendfile(response, f, offset, length) + + return handle + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + + +def _resolve(root: Path, rel: bytes, *, index: str | None) -> Path | None: + """Decode ``rel`` and resolve under ``root``. Returns ``None`` on + decode failure, traversal, or a directory request without an + ``index``. The returned path may not exist — caller stats it. + """ + try: + decoded = unquote_to_bytes(rel).decode("utf-8") + except UnicodeDecodeError: + return None + if "\0" in decoded: + return None + # Defensive: reject ``..`` segments before path resolution. ``Path.resolve`` + # would normalise them out, but we'd still want to refuse the request + # rather than silently serve a sibling. + if any(p == ".." for p in decoded.split("/")): + return None + candidate = (root / decoded.lstrip("/")).resolve() + if not candidate.is_relative_to(root): + return None + # Directory → append index, re-resolve so the same containment check + # applies. ``index`` is a config-supplied filename, so it can't escape, + # but the re-resolution is cheap and keeps the invariant explicit. + try: + if candidate.is_dir(): + if index is None: + return None + candidate = (candidate / index).resolve() + if not candidate.is_relative_to(root): + return None + except OSError: + return None + return candidate + + +def _etag(st: os.stat_result) -> bytes: + """Strong ETag from inode size + mtime nanoseconds. + + Cheap and stable across restarts — no need for a content hash. + """ + return f'"{st.st_size:x}-{st.st_mtime_ns:x}"'.encode("ascii") + + +def _if_none_match_matches(header: bytes, etag: bytes) -> bool: + """RFC 7232 §3.2: weak comparison. ``W/"x"`` matches ``"x"`` and vice + versa; both compare equal to our strong tag. + """ + if header.strip() == b"*": + return True + bare = etag[2:] if etag.startswith(b"W/") else etag + for tag in header.split(b","): + tag = tag.strip() + if tag.startswith(b"W/"): + tag = tag[2:] + if tag == bare: + return True + return False + + +def _if_modified_since_satisfied(header: bytes, mtime: float) -> bool: + """Return ``True`` iff the resource has *not* been modified since the + header date — i.e. caller should respond 304. + """ + try: + since = parsedate_to_datetime(header.decode("ascii")) + except (UnicodeDecodeError, TypeError, ValueError): + return False + if since is None: + return False + # HTTP-date carries whole-second precision; compare on integer seconds. + return int(mtime) <= int(since.timestamp()) + + +def _parse_range(header: bytes, size: int) -> tuple[int, int] | Literal["unsatisfiable"] | None: + """Parse a ``Range:`` header. Returns ``(offset, length)`` for a + satisfiable single byte-range, ``"unsatisfiable"`` for a syntactically + valid but out-of-bounds range (→ 416), or ``None`` for absent / + unparsable / multi-range (→ fall back to full body). + """ + unit, _, spec = header.strip().partition(b"=") + if unit.lower() != b"bytes" or not spec: + return None + if b"," in spec: + # Multi-range. RFC 7233 allows it; our writer doesn't speak + # multipart/byteranges. Falling back to 200 is RFC-compliant. + return None + start_b, sep, end_b = spec.partition(b"-") + if not sep: + return None + if size == 0: + # Any range against an empty file is unsatisfiable per RFC 7233 §2.1. + return "unsatisfiable" + try: + if not start_b: + # Suffix range: last N bytes. + if not end_b: + return None + n = int(end_b) + if n <= 0: + return None + n = min(n, size) + return size - n, n + start = int(start_b) + if start < 0: + return None + if start >= size: + return "unsatisfiable" + if not end_b: + return start, size - start + end = int(end_b) + if end < start: + return None + end = min(end, size - 1) + return start, end - start + 1 + except ValueError: + return None + + +def _content_type(path: Path) -> bytes: + mime, _ = mimetypes.guess_type(path.name) + return (mime or "application/octet-stream").encode("ascii") + + +def _headers_index(headers) -> Mapping[bytes, bytes]: + """Last-value-wins lookup over header pairs. Sufficient for the + request headers we read here (``If-None-Match``, ``If-Modified-Since``, + ``Range``) — none of them are list-valued in practice. + """ + return dict(headers) + + +# -------------------------------------------------------------------------- +# Canned non-success responses (selector-thread inline) +# -------------------------------------------------------------------------- + + +def _send_not_found(ctx: HTTPReqCtx, method: bytes) -> None: + ctx.complete( + Response( + status_code=404, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_NOT_FOUND_BODY)).encode("ascii")), + ], + ), + # HEAD: headers describe the body that GET would receive; the bytes + # themselves must not go on the wire. + None if method == b"HEAD" else _NOT_FOUND_BODY, + ) + + +def _send_not_modified( + ctx: HTTPReqCtx, + etag: bytes, + last_modified: bytes, + cache_control: bytes | None, +) -> None: + headers: list[tuple[bytes, bytes]] = [ + (b"etag", etag), + (b"last-modified", last_modified), + ] + if cache_control is not None: + headers.append((b"cache-control", cache_control)) + ctx.complete(Response(status_code=304, headers=headers)) + + +def _send_range_not_satisfiable( + ctx: HTTPReqCtx, + method: bytes, + size: int, + etag: bytes, + last_modified: bytes, +) -> None: + ctx.complete( + Response( + status_code=416, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_RANGE_NOT_SATISFIABLE_BODY)).encode("ascii")), + (b"content-range", f"bytes */{size}".encode("ascii")), + (b"etag", etag), + (b"last-modified", last_modified), + ], + ), + None if method == b"HEAD" else _RANGE_NOT_SATISFIABLE_BODY, + ) diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py new file mode 100644 index 0000000..c718840 --- /dev/null +++ b/localpost/http/wsgi.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import contextlib +import sys +from collections.abc import Buffer, Callable, Iterable, Iterator +from dataclasses import dataclass, field +from http import HTTPStatus +from io import RawIOBase +from typing import Any, BinaryIO, final, override +from urllib.parse import unquote_to_bytes +from wsgiref.types import WSGIApplication + +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._body import read_body +from localpost.http._types import Request +from localpost.http._types import Response as _Response +from localpost.http.config import DEFAULT_BUFFER_SIZE + +__all__ = ["to_wsgi", "wrap_wsgi"] + + +@final +class _RequestBodyStream(RawIOBase): + """Expose buffered request bytes as a ``wsgi.input`` file-like. + + :func:`wrap_wsgi` reads the full body via + :func:`localpost.http.read_body` before driving the WSGI app, then + wraps the resulting ``bytes`` here — WSGI apps see a synchronous + ``read`` / ``readinto`` API backed by an in-memory buffer. + """ + + def __init__(self, body: bytes) -> None: + self._buf = body + self._pos = 0 + + @override + def writable(self) -> bool: + return False + + @override + def seekable(self) -> bool: + return False + + @override + def readable(self) -> bool: + return True + + @override + def readall(self) -> bytes: + result = self._buf[self._pos :] + self._pos = len(self._buf) + return result + + @override + def readinto(self, buffer: Buffer, /) -> int: + view = memoryview(buffer).cast("B") + n = len(view) + avail = len(self._buf) - self._pos + if avail == 0: + return 0 + take = min(n, avail) + view[:take] = self._buf[self._pos : self._pos + take] + self._pos += take + return take + + +def _wsgi_write_unsupported(_: bytes) -> None: + raise NotImplementedError("The WSGI write() callable is deprecated and not supported") + + +def wrap_wsgi(app: WSGIApplication) -> RequestHandler: + """Wrap a WSGI application as a native :class:`RequestHandler`. + + The handler reads the full request body via + :func:`localpost.http.read_body`, exposes it to the WSGI app as + ``wsgi.input``, and translates the WSGI response into a + :meth:`HTTPReqCtx.stream` call (the WSGI body iterable is already + a chunk iterator). + + Reading the body blocks on the request socket, so this handler + **must** be composed with :func:`localpost.http.thread_pool_handler` + (or run inside an explicit ``ctx.borrow()`` block) — running it on + the selector thread will stall the loop while the upload finishes. + """ + + def run_wsgi(ctx: HTTPReqCtx) -> None: + body = read_body(ctx) + environ = _build_environ(ctx, body) + + response_state: dict[str, Any] = {} + + def start_response( + status: str, + headers: list[tuple[str, str]], + exc_info: Any = None, + ) -> Callable[[bytes], None]: + if exc_info: + try: + if response_state.get("started"): + raise exc_info[1].with_traceback(exc_info[2]) + finally: + exc_info = None + status_code = int(status.split(" ", 1)[0]) + reason = status.split(" ", 1)[1] if " " in status else "" + wire_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in headers] + response_state["response"] = _Response( + status_code=status_code, + headers=wire_headers, + reason=reason.encode("iso-8859-1") if reason else b"", + ) + return _wsgi_write_unsupported + + body_iter = app(environ, start_response) + try: + iterator = iter(body_iter) + # Pull the first chunk so the WSGI app commits to calling + # start_response before we hand the response to ctx.stream + # (PEP 3333 only guarantees start_response is called by the + # time the iterable yields its first value). + first_chunk: bytes | None = None + for chunk in iterator: + if chunk: + first_chunk = chunk + break + response = response_state.get("response") + if response is None: + raise RuntimeError("WSGI app returned without calling start_response") + response_state["started"] = True + + def chunks() -> Iterator[bytes]: + if first_chunk is not None: + yield first_chunk + for c in iterator: + if c: + yield c + + ctx.stream(response, chunks()) + finally: + close = getattr(body_iter, "close", None) + if close is not None: + close() + + return run_wsgi + + +def _build_environ(ctx: HTTPReqCtx, body: bytes) -> dict[str, Any]: + request = ctx.request + # ``request.path`` / ``request.query_string`` are pre-split by the + # backend; only the percent-decode + ISO-8859-1 decode happens here. + path = unquote_to_bytes(request.path).decode("iso-8859-1") + query_string = request.query_string.decode("iso-8859-1") + + server_host, _, server_port = ctx.local_addr.rpartition(":") + if not server_host: + server_host, server_port = ctx.local_addr, "" + + environ: dict[str, Any] = { + "REQUEST_METHOD": request.method.decode("ascii"), + "SCRIPT_NAME": "", + "PATH_INFO": path, + "QUERY_STRING": query_string, + "CONTENT_TYPE": "", + "CONTENT_LENGTH": "", + "SERVER_NAME": server_host, + "SERVER_PORT": server_port, + "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", + "wsgi.version": (1, 0), + "wsgi.url_scheme": ctx.scheme, + "wsgi.input": _RequestBodyStream(body), + "wsgi.errors": sys.stderr, + "wsgi.multithread": True, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + } + if ctx.remote_addr is not None: + client_host, _, client_port = ctx.remote_addr.rpartition(":") + if client_host: + environ["REMOTE_ADDR"] = client_host + environ["REMOTE_PORT"] = client_port + else: + environ["REMOTE_ADDR"] = ctx.remote_addr + + # Single header pass: h11 normalizes names to lowercase bytes, so we can + # work with bytes directly and decode each name/value exactly once. + for name, value in request.headers: + value_str = value.decode("iso-8859-1") if isinstance(value, bytes) else bytes(value).decode("iso-8859-1") + if name == b"content-type": + environ["CONTENT_TYPE"] = value_str + elif name == b"content-length": + environ["CONTENT_LENGTH"] = value_str + else: + # bytes-level upper + replace, then single decode (ASCII for HTTP_* keys). + key = b"HTTP_" + name.upper().replace(b"-", b"_") + environ[key.decode("ascii")] = value_str + + return environ + + +# --------------------------------------------------------------------------- +# Reverse direction: serve a localpost RequestHandler under a WSGI server. +# --------------------------------------------------------------------------- + + +@final +@dataclass(slots=True, eq=False) +class _WSGIReqCtx: + """:class:`HTTPReqCtx` implementation backed by a WSGI ``environ``. + + Body bytes are read lazily — :meth:`receive` calls ``wsgi.input.read(size)`` + on demand. The bridge does not pre-buffer; WSGI servers are usually + multi-thread / multi-process worker pools, so synchronous reads are + safe. + + Three response shapes are supported, all via the public + :class:`HTTPReqCtx` Protocol: + + - **Eager** (``complete``): captured into ``_completed`` and returned + as a single-element iterable. + - **Declarative streaming** (``stream``): the chunk iterator is + handed to the WSGI server lazily — no thread / queue, the WSGI + worker drives iteration and flushing. + - **Zero-copy** (``sendfile``): mapped to ``wsgi.file_wrapper`` when + the host server provides it, else a chunked read+yield fallback. + + ``borrow()`` is a no-op CM (the WSGI worker already owns the + connection); ``borrowed`` is always ``True``. + """ + + request: Request + remote_addr: str | None + local_addr: str + scheme: str + _environ: dict[str, Any] + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + + # Response capture state — at most one of these is populated. + _completed: tuple[_Response, bytes] | None = None + _streaming: tuple[_Response, Iterator[bytes]] | None = None + _sendfile: tuple[_Response, BinaryIO, int, int] | None = None + + @property + def disconnected(self) -> bool: + # WSGI exposes no socket handle; host-server disconnects surface as + # ``BrokenPipeError`` from the per-chunk write path during streaming. + return False + + @property + def borrowed(self) -> bool: + return True + + def borrow(self) -> contextlib.AbstractContextManager[HTTPReqCtx]: + # The WSGI worker already owns the connection — borrow is degenerate. + return contextlib.nullcontext(self) + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + stream = self._environ.get("wsgi.input") + if stream is None: + return b"" + chunk = stream.read(size) + return chunk if chunk else b"" + + # ----- response capture ----- + + def complete(self, response: _Response, body: bytes | None = None) -> None: + self._check_not_started() + self.response_status = response.status_code + self._completed = (response, body or b"") + + def stream(self, response: _Response, chunks: Iterator[bytes], /) -> None: + self._check_not_started() + self.response_status = response.status_code + self._streaming = (response, chunks) + + def sendfile(self, response: _Response, file: BinaryIO, offset: int, count: int) -> None: + self._check_not_started() + self.response_status = response.status_code + self._sendfile = (response, file, offset, count) + + # ----- WSGI-side dispatch ----- + + def _respond(self, start_response: Callable[..., Any]) -> Iterable[bytes]: + if self._completed is not None: + response, body = self._completed + start_response(_status_line(response), _wsgi_headers(response.headers)) + return [body] if body else [] + if self._streaming is not None: + response, chunks = self._streaming + start_response(_status_line(response), _wsgi_headers(response.headers)) + return chunks + if self._sendfile is not None: + response, file, offset, count = self._sendfile + start_response(_status_line(response), _wsgi_headers(response.headers)) + file.seek(offset) + file_wrapper = self._environ.get("wsgi.file_wrapper") + if file_wrapper is not None: + return file_wrapper(_LimitedReader(file, count), DEFAULT_BUFFER_SIZE) + return _read_chunks(file, count, DEFAULT_BUFFER_SIZE) + raise RuntimeError("Handler returned without producing a response") + + def _check_not_started(self) -> None: + if self._completed is not None or self._streaming is not None or self._sendfile is not None: + raise RuntimeError("Response already started") + + +def to_wsgi(handler: RequestHandler) -> WSGIApplication: + """Serve a localpost :data:`RequestHandler` under a WSGI server. + + The returned WSGI app builds a :class:`HTTPReqCtx` from the WSGI + ``environ``, drives the handler (pre-body and body-handler phases + both run synchronously inside the WSGI app call), and translates the + ctx's captured response into the WSGI return shape: + + - ``ctx.complete(...)`` → ``[body]`` iterable, single ``start_response``. + - ``ctx.stream(...)`` → the chunk iterator handed straight to the WSGI + server (lazy, true per-chunk flushing). + - ``ctx.sendfile(...)`` → ``wsgi.file_wrapper`` if the host server + provides it, else a chunked read+yield fallback. + + Deployment:: + + # myapp.py + from localpost.http.wsgi import to_wsgi + from localpost.openapi import HttpApp + + app = HttpApp() + + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + + wsgi_app = to_wsgi(app._build_router_handler()) # or app.as_wsgi() + + Then ``gunicorn myapp:wsgi_app``. + + :func:`localpost.http.thread_pool_handler` does not apply — the WSGI + server's worker model is the pool. :func:`check_cancelled` is a + no-op (the WSGI app has no socket handle); SSE streams over + :meth:`HTTPReqCtx.stream` surface client disconnects as + :class:`BrokenPipeError` from the host's per-chunk write. + """ + + def wsgi_app(environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: + ctx = _build_wsgi_ctx(environ) + handler(ctx) + return ctx._respond(start_response) + + return wsgi_app + + +def _build_wsgi_ctx(environ: dict[str, Any]) -> _WSGIReqCtx: + method = environ.get("REQUEST_METHOD", "GET").encode("ascii") + path_info = environ.get("PATH_INFO", "").encode("iso-8859-1") + query_string = environ.get("QUERY_STRING", "").encode("iso-8859-1") + target = path_info + (b"?" + query_string if query_string else b"") + + headers: list[tuple[bytes, bytes]] = [] + if environ.get("CONTENT_TYPE"): + headers.append((b"content-type", str(environ["CONTENT_TYPE"]).encode("iso-8859-1"))) + if environ.get("CONTENT_LENGTH"): + headers.append((b"content-length", str(environ["CONTENT_LENGTH"]).encode("iso-8859-1"))) + for key, value in environ.items(): + if not key.startswith("HTTP_"): + continue + # HTTP_X_FOO -> x-foo + name = key[5:].replace("_", "-").lower().encode("ascii") + headers.append((name, str(value).encode("iso-8859-1"))) + + proto = environ.get("SERVER_PROTOCOL", "HTTP/1.1") + http_version = proto.split("/", 1)[1].encode("ascii") if "/" in proto else b"1.1" + + request = Request( + method=method, + target=target, + path=path_info, + query_string=query_string, + headers=tuple(headers), + http_version=http_version, + ) + + server_host = str(environ.get("SERVER_NAME", "")) + server_port = str(environ.get("SERVER_PORT", "")) + local_addr = f"{server_host}:{server_port}" if server_port else server_host + + remote_host = environ.get("REMOTE_ADDR") + if remote_host: + remote_port = environ.get("REMOTE_PORT") + remote_addr: str | None = f"{remote_host}:{remote_port}" if remote_port else str(remote_host) + else: + remote_addr = None + + return _WSGIReqCtx( + request=request, + remote_addr=remote_addr, + local_addr=local_addr, + scheme=str(environ.get("wsgi.url_scheme", "http")), + _environ=environ, + ) + + +def _status_line(response: _Response) -> str: + if response.reason: + reason = response.reason.decode("iso-8859-1") + else: + try: + reason = HTTPStatus(response.status_code).phrase + except ValueError: + reason = "" + return f"{response.status_code} {reason}" + + +def _wsgi_headers(headers: Any) -> list[tuple[str, str]]: + return [(name.decode("iso-8859-1"), value.decode("iso-8859-1")) for name, value in headers] + + +@final +class _LimitedReader: + """``file_wrapper``-friendly view of a file restricted to ``count`` bytes + starting from the current position. Servers that consume + ``wsgi.file_wrapper`` either ``sendfile()`` the underlying fd or fall + back to ``.read(blksize)`` — both paths are honoured here. + """ + + __slots__ = ("_file", "_remaining") + + def __init__(self, file: BinaryIO, count: int) -> None: + self._file = file + self._remaining = count + + def read(self, n: int = -1) -> bytes: + if self._remaining <= 0: + return b"" + if n < 0 or n > self._remaining: + n = self._remaining + data = self._file.read(n) + self._remaining -= len(data) + return data + + def fileno(self) -> int: + return self._file.fileno() + + +def _read_chunks(file: BinaryIO, count: int, blksize: int) -> Iterator[bytes]: + """Fallback for hosts without ``wsgi.file_wrapper``.""" + remaining = count + while remaining > 0: + chunk = file.read(min(blksize, remaining)) + if not chunk: + return + remaining -= len(chunk) + yield chunk diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md new file mode 100644 index 0000000..76367c0 --- /dev/null +++ b/localpost/openapi/README.md @@ -0,0 +1,45 @@ +# localpost.openapi + +Type-driven HTTP framework with built-in **OpenAPI 3.2** generation, on top of +[`localpost.http`](../http/README.md). FastAPI-inspired; response shapes are +union return types (`Book | NotFound[str]`), middleware is OpenAPI-aware +(security schemes and extra responses contributed by the middleware itself), +and msgspec drives encoding / decoding / schema generation. Pydantic and +`attrs` classes are recognised automatically when present. + +```bash +pip install 'localpost[http,openapi]' +``` + +```python +from dataclasses import dataclass +from localpost import hosting +from localpost.http import ServerConfig +from localpost.openapi import HttpApp, NotFound + + +@dataclass +class Book: + id: str; title: str + + +app = HttpApp() + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | NotFound[str]: + if book_id != "42": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="HHGTTG") + + +if __name__ == "__main__": + hosting.run_app(app.service(ServerConfig(port=8000))) +``` + +`/openapi.json` plus `/docs` (Swagger UI), `/docs/redoc`, `/docs/scalar` are +served automatically. + +**Full reference:** + +Examples: [`examples/openapi/`](../../examples/openapi/). diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py new file mode 100644 index 0000000..e758c48 --- /dev/null +++ b/localpost/openapi/__init__.py @@ -0,0 +1,138 @@ +"""Type-driven HTTP framework with OpenAPI 3.2 generation built in. + +Sits on top of the ``localpost.http`` server. The user surface mirrors +FastAPI's decorator API; the difference is that the OpenAPI doc and the +runtime request handling are derived from the *same* type annotations, +including return-type unions for response shapes:: + + from typing import Annotated + from dataclasses import dataclass + + from localpost import hosting + from localpost.http import ServerConfig + from localpost.openapi import HttpApp, NotFound + + + @dataclass + class Book: + id: str + title: str + + + app = HttpApp() + + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: + if book_id != "42": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="The Hitchhiker's Guide") + + + hosting.run_app(app.service(ServerConfig(port=8000))) + +Pydantic models are recognised automatically when pydantic is installed. +To plug in another schema library, supply a custom +:class:`localpost.openapi.adapters.AdapterRegistry` via ``HttpApp(adapters=...)``. +""" + +from localpost.openapi import spec +from localpost.openapi.adapters import AdapterRegistry, TypeAdapter, default_registry +from localpost.openapi.aio import ( + AsyncApiOperation, + AsyncHttpBasicAuth, + AsyncHttpBearerAuth, + AsyncHTTPReqCtx, + AsyncOperation, + AsyncOpMiddleware, + HttpAsyncApp, + async_op_middleware, +) +from localpost.openapi.app import HttpApp +from localpost.openapi.auth import HttpBasicAuth, HttpBearerAuth +from localpost.openapi.middleware import ApiOperation, OpMiddleware, op_middleware +from localpost.openapi.operation import Operation +from localpost.openapi.resolvers import ( + ArgResolver, + ArgResolverFactory, + FromBody, + FromHeader, + FromPath, + FromQuery, + ResolverCtx, +) +from localpost.openapi.results import ( + Accepted, + BadRequest, + Conflict, + Created, + EventStreamResult, + Forbidden, + InternalServerError, + NoContent, + NotFound, + Ok, + OpResult, + TooManyRequests, + Unauthorized, + UnprocessableEntity, +) +from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry +from localpost.openapi.sse import Event, EventStream + +__all__ = [ + # core (sync) + "HttpApp", + "Operation", + # core (async) + "HttpAsyncApp", + "AsyncOperation", + "AsyncHTTPReqCtx", + # spec sub-module (advanced; not all symbols flattened) + "spec", + # middleware (sync) + "ApiOperation", + "OpMiddleware", + "op_middleware", + "HttpBearerAuth", + "HttpBasicAuth", + # middleware (async) + "AsyncApiOperation", + "AsyncOpMiddleware", + "async_op_middleware", + "AsyncHttpBearerAuth", + "AsyncHttpBasicAuth", + # arg resolvers + "ArgResolver", + "ArgResolverFactory", + "ResolverCtx", + "FromPath", + "FromQuery", + "FromHeader", + "FromBody", + # OpResult hierarchy + "OpResult", + "Ok", + "Created", + "Accepted", + "NoContent", + "EventStreamResult", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "Conflict", + "UnprocessableEntity", + "TooManyRequests", + "InternalServerError", + # schema utilities + "SchemaRegistry", + "REF_TEMPLATE", + # type adapters (pluggable schema libraries) + "TypeAdapter", + "AdapterRegistry", + "default_registry", + # SSE + "Event", + "EventStream", +] diff --git a/localpost/openapi/_docs.py b/localpost/openapi/_docs.py new file mode 100644 index 0000000..1ad16bd --- /dev/null +++ b/localpost/openapi/_docs.py @@ -0,0 +1,62 @@ +"""Built-in doc UI HTML templates (loaded from CDN).""" + +from __future__ import annotations + + +def swagger_html(openapi_url: str) -> bytes: + return f""" + + + + + Swagger UI + + + +
+ + + +""".encode() + + +def redoc_html(openapi_url: str) -> bytes: + return f""" + + + + + ReDoc + + + + + + +""".encode() + + +def scalar_html(openapi_url: str) -> bytes: + return f""" + + + + + Scalar API Reference + + +
+ + + +""".encode() diff --git a/localpost/openapi/_operation_core.py b/localpost/openapi/_operation_core.py new file mode 100644 index 0000000..9ca4575 --- /dev/null +++ b/localpost/openapi/_operation_core.py @@ -0,0 +1,443 @@ +"""Type-level core shared by sync :class:`Operation` and async :class:`AsyncOperation`. + +Everything here is independent of the runtime invocation style — signature +parsing, return-type inference, OpenAPI doc contribution helpers, response +encoding. The runtime closures (``_run`` / ``_run_core`` / ``_write_response`` +/ ``_stream_sse``) live in the per-flavour ``operation.py`` files. +""" + +from __future__ import annotations + +import inspect +from collections.abc import ( + AsyncGenerator, + AsyncIterable, + AsyncIterator, + Callable, + Generator, + Iterable, + Iterator, + Mapping, +) +from dataclasses import dataclass, replace +from http import HTTPMethod +from types import UnionType +from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints + +from localpost.http._types import Response as _Response +from localpost.openapi import spec as openapi_spec +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.resolvers import ( + ArgResolver, + ArgResolverFactory, + FromBody, + FromPath, + FromQuery, + is_body_type, +) +from localpost.openapi.results import EventStreamResult, NoContent, OpResult +from localpost.openapi.schemas import SchemaRegistry +from localpost.openapi.sse import EventStream + +__all__ = [ + "ResponseShape", + "build_arg_resolvers", + "extract_response_shapes", + "build_responses", + "build_http_response", + "encode_body", + "iter_response_headers", + "qualname", + "operation_id", + "is_sse_payload", + "is_async_sse_payload", + "is_sync_iterable", + "SSE_RESPONSE_HEADERS", + "resolve_ctx", +] + + +# Sentinel — handed back as an :class:`ArgResolver` for parameters explicitly +# typed as :class:`HTTPReqCtx` (or its async sibling). The actual ctx object +# is passed in at request time, sync and async runtimes share the closure. +def resolve_ctx(ctx: Any) -> Any: + return ctx + + +@dataclass(frozen=True, slots=True) +class ResponseShape: + """One branch of the return type — a (status, body type, content type).""" + + status_code: int + description: str + body_type: Any | None + content_type: str = "application/json" + + +# --- Signature parsing --------------------------------------------------- + + +def qualname(fn: Callable[..., Any]) -> str: + return getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + + +def build_arg_resolvers( + fn: Callable[..., Any], + *, + path_var_names: set[str] | None = None, + adapters: AdapterRegistry | None = None, + exclude: set[str] | None = None, + ctx_types: tuple[type, ...] = (), +) -> tuple[ + inspect.Signature, + list[tuple[str, ArgResolver]], + list[tuple[str, inspect.Parameter, ArgResolverFactory | None]], +]: + """Inspect ``fn`` and return ``(resolved_signature, runtime_resolvers, + spec_factories)`` for use by sync :class:`Operation`, async + :class:`AsyncOperation`, or the ``op_middleware`` decorators. + + Path-template binding is *not* validated here — pass ``path_var_names`` + so :class:`FromPath` is auto-picked for matching parameter names; the + caller is responsible for any "unbound var" error. + + ``adapters`` flows into auto-picked / un-bound :class:`FromBody` + factories so body decoding hits the per-app type adapter registry. If + ``None``, falls back to :func:`default_registry`. + + ``exclude`` skips parameters by name — used by ``op_middleware`` to drop + the ``call_next`` parameter from resolver inspection. + + ``ctx_types`` lists the request-context types that should be treated + as the framework pass-through (no factory, no doc contribution). Sync + callers pass ``(HTTPReqCtx,)``; async callers pass + ``(AsyncHTTPReqCtx,)``. Defaults to ``()`` so a parameter must opt in + to the pass-through via the caller-supplied tuple. + """ + path_vars = path_var_names or set() + registry = adapters or default_registry() + skip = exclude or set() + + # Resolve PEP 563 string annotations (``from __future__ import + # annotations`` is in effect for most callers) into the real types + # before feeding them to the resolver factories. We build a localns + # from the function's closure cells so types defined in an enclosing + # scope (common in tests, factories) resolve too. + localns = _closure_locals(fn) + try: + sig = inspect.signature(fn, eval_str=True, locals=localns) + except Exception: # noqa: BLE001 + sig = inspect.signature(fn) + try: + hints = get_type_hints(fn, localns=localns, include_extras=True) + except Exception: # noqa: BLE001 + hints = {} + sig = _signature_with_hints(sig, hints) + + runtime: list[tuple[str, ArgResolver]] = [] + factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] + for name, param in sig.parameters.items(): + if name in skip: + continue + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + raise ValueError(f"handler {qualname(fn)!r}: *args / **kwargs not supported") + factory = _pick_factory(name, param, path_vars, registry, ctx_types) + if factory is None: + # Request ctx pass-through. + runtime.append((name, resolve_ctx)) + factories.append((name, param, None)) + else: + # Inject the registry into FromBody so its closure binds the + # right adapter at build time. User-supplied factories are left + # alone — they're trusted to handle their own decoding. + if isinstance(factory, FromBody) and factory.adapters is None: + factory = replace(factory, adapters=registry) + runtime.append((name, factory(param))) + factories.append((name, param, factory)) + return sig, runtime, factories + + +def _closure_locals(fn: Callable[..., Any]) -> dict[str, Any]: + """Return a name → value dict from ``fn``'s closure cells. + + Used as ``localns`` for type-annotation resolution so types defined in + an enclosing function scope are visible (common in tests, model + factories). + """ + closure = getattr(fn, "__closure__", None) or () + code = getattr(fn, "__code__", None) + if not closure or code is None: + return {} + free_vars = code.co_freevars + locals_dict: dict[str, Any] = {} + for name, cell in zip(free_vars, closure, strict=False): + try: + locals_dict[name] = cell.cell_contents + except ValueError: + continue + return locals_dict + + +def _signature_with_hints(sig: inspect.Signature, hints: dict[str, Any]) -> inspect.Signature: + """Return ``sig`` with each parameter's ``annotation`` replaced by the + resolved type from ``hints``. Same for the return annotation.""" + new_params = [ + param.replace(annotation=hints[name]) if name in hints else param for name, param in sig.parameters.items() + ] + return_annotation = hints.get("return", sig.return_annotation) + return sig.replace(parameters=new_params, return_annotation=return_annotation) + + +def _path_to_id(path: str) -> str: + cleaned = path.replace("/", "_").replace("{", "").replace("}", "").strip("_") + return cleaned or "root" + + +def operation_id(method: HTTPMethod, path: str, fn: Callable[..., Any]) -> str: + """Pick an operationId. + + Prefer ``fn.__name__`` when it's a real, non-anonymous identifier — it + matches what users see in their codebase (and what FastAPI emits, modulo + the path suffix). Fall back to a path-mangled id for lambdas / wrapped + callables / anything without a usable name. + """ + name = getattr(fn, "__name__", "") or "" + if name and name not in {"", "_", ""}: + return name + return f"{method.value.lower()}_{_path_to_id(path)}" + + +def _pick_factory( + name: str, + param: inspect.Parameter, + path_var_names: set[str], + adapters: AdapterRegistry, + ctx_types: tuple[type, ...], +) -> ArgResolverFactory | None: + """Return the resolver factory for one parameter, or ``None`` for the + request-ctx pass-through.""" + annotation = param.annotation + + # Explicit ``Annotated[T, FromX(...)]`` wins. + if get_origin(annotation) is Annotated: + for arg in get_args(annotation)[1:]: + if _is_resolver_factory(arg): + return arg + + target = annotation + if get_origin(target) is Annotated: + target = get_args(target)[0] + + if name in path_var_names: + return FromPath(name=name) + if ctx_types and target in ctx_types: + return None + if is_body_type(target, adapters): + return FromBody() + return FromQuery(name=name) + + +def _is_resolver_factory(arg: Any) -> ArgResolverFactory | None: + """Duck-typing for our :class:`ArgResolverFactory` ``Protocol``. + + We can't ``isinstance``-check it because the protocol is structural and + not ``@runtime_checkable``; resolver factories are tagged by carrying an + ``update_doc`` method on top of being callable. + """ + if callable(arg) and hasattr(arg, "update_doc"): + return cast(ArgResolverFactory, arg) + return None + + +# --- Return-type → response-shape extraction ---------------------------- + + +def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape], bool]: + """Return ``(shapes, null_is_not_found)`` from a function's return annotation.""" + if return_annotation is inspect.Signature.empty: + return [ResponseShape(200, "Successful response", None)], False + + origin = get_origin(return_annotation) + members = list(get_args(return_annotation)) if origin is Union or origin is UnionType else [return_annotation] + + shapes: list[ResponseShape] = [] + seen_codes: set[int] = set() + has_success = False + has_none = False + for member in members: + if member is type(None): + has_none = True + continue + member_origin = get_origin(member) + cls = member_origin if member_origin is not None else member + sse_payload = _sse_payload_type(member) + if sse_payload is not _NOT_SSE: + code = 200 + description = "Successful response" + body_type = sse_payload + content_type = "text/event-stream" + has_success = True + elif isinstance(cls, type) and issubclass(cls, EventStreamResult): + code = cls._status_code + description = cls._description + generic_args = get_args(member) if member_origin is not None else () + body_type = generic_args[0] if generic_args else None + content_type = "text/event-stream" + has_success = True + elif isinstance(cls, type) and issubclass(cls, OpResult): + code = cls._status_code + description = cls._description + body_type = None + content_type = "application/json" + if cls is not NoContent: + generic_args = get_args(member) if member_origin is not None else () + body_type = generic_args[0] if generic_args else None + if code < 400: + has_success = True + else: + code = 200 + description = "Successful response" + body_type = member + content_type = "application/json" + has_success = True + if code in seen_codes: + continue + seen_codes.add(code) + shapes.append(ResponseShape(code, description, body_type, content_type)) + + # ``T | None`` is the implicit "T or 404 NotFound" — only when paired + # with at least one non-None success type (a bare ``-> None`` annotation + # still means "204 / empty success", not 404), and only when the user + # didn't already declare 404 explicitly via ``NotFound[X]``. + from localpost.openapi.results import NotFound as _NotFound # noqa: PLC0415 + + null_is_not_found = has_none and has_success and 404 not in seen_codes + if null_is_not_found: + shapes.append(ResponseShape(_NotFound._status_code, _NotFound._description, None)) + seen_codes.add(404) + + if not has_success and not shapes: + shapes.append(ResponseShape(200, "Successful response", None)) + return shapes, null_is_not_found + + +# Sentinel: unique object so callers can distinguish "not an SSE return" +# from "SSE return with no payload type" (e.g. a bare Iterator). +_NOT_SSE: Any = object() + + +def _sse_payload_type(annotation: Any) -> Any: + """If ``annotation`` is one of the SSE-shaped iterables / generators + (sync or async) or :class:`EventStream`, return the element type + (or :data:`_NOT_SSE`).""" + origin = get_origin(annotation) + if origin is None: + return _NOT_SSE + # ``EventStream[T]`` — origin is ``EventStream`` itself. + if origin is EventStream: + args = get_args(annotation) + return args[0] if args else None + # ``Generator[T, send, return]`` / ``Iterator[T]`` / ``Iterable[T]`` + # plus their async counterparts. All live in ``collections.abc``. + if origin not in (Generator, Iterator, Iterable, AsyncGenerator, AsyncIterator, AsyncIterable): + return _NOT_SSE + args = get_args(annotation) + return args[0] if args else None + + +def build_responses(shapes: tuple[ResponseShape, ...], registry: SchemaRegistry) -> dict[str, openapi_spec.Response]: + responses: dict[str, openapi_spec.Response] = {} + for shape in shapes: + if shape.body_type is None: + responses[str(shape.status_code)] = openapi_spec.Response(description=shape.description) + continue + schema = registry.schema_for(shape.body_type) + responses[str(shape.status_code)] = openapi_spec.Response( + description=shape.description, + content={shape.content_type: openapi_spec.MediaType(schema=schema)}, + ) + return responses + + +# --- Response building --------------------------------------------------- + +_TEXT_CONTENT_TYPE = b"text/plain; charset=utf-8" +_OCTET_CONTENT_TYPE = b"application/octet-stream" + + +def encode_body(value: object, adapters: AdapterRegistry) -> tuple[bytes, bytes]: + """Return ``(body_bytes, content_type)`` for ``value``. + + Pass-through for ``bytes`` / ``str``; structured values go through the + :class:`TypeAdapter` that claims ``type(value)``. + """ + if value is None: + return b"", b"" + if isinstance(value, bytes): + return value, _OCTET_CONTENT_TYPE + if isinstance(value, bytearray): + return bytes(value), _OCTET_CONTENT_TYPE + if isinstance(value, str): + return value.encode("utf-8"), _TEXT_CONTENT_TYPE + body, content_type = adapters.for_value(value).encode(value) + return body, content_type.encode("ascii") + + +def build_http_response(result: OpResult, adapters: AdapterRegistry) -> tuple[_Response, bytes]: + body_bytes, default_ct = encode_body(result.body, adapters) + headers: list[tuple[bytes, bytes]] = [] + headers_seen: set[bytes] = set() + for name, value in iter_response_headers(result.headers): + headers.append((name, value)) + headers_seen.add(name.lower()) + if body_bytes and b"content-type" not in headers_seen and default_ct: + headers.append((b"content-type", default_ct)) + if b"content-length" not in headers_seen: + headers.append((b"content-length", str(len(body_bytes)).encode("ascii"))) + return _Response(status_code=result.status_code, headers=headers), body_bytes + + +def iter_response_headers(headers: Mapping[str, str]): + for name, value in headers.items(): + yield name.encode("ascii"), value.encode("iso-8859-1") + + +# --- SSE detection ------------------------------------------------------- + +SSE_RESPONSE_HEADERS: list[tuple[bytes, bytes]] = [ + (b"content-type", b"text/event-stream; charset=utf-8"), + (b"cache-control", b"no-cache"), + (b"x-accel-buffering", b"no"), # Disable proxy buffering (nginx). +] + + +def is_sse_payload(value: object) -> bool: + """True if ``value`` should be streamed as SSE (sync side). + + Recognises explicit :class:`EventStream` and any ``Iterator`` (the + result of calling a generator function or building one explicitly). + Bare ``Iterable`` is too broad — ``list`` / ``tuple`` / ``dict`` are + all iterable but should land in JSON. + """ + return isinstance(value, (EventStream, Iterator)) + + +def is_async_sse_payload(value: object) -> bool: + """True if ``value`` should be streamed as SSE (async side). + + Recognises explicit :class:`EventStream` and any ``AsyncIterator`` + (the result of calling an ``async def`` generator function). Plain + sync iterators are *not* recognised here — the async runtime rejects + them with a clear error so users don't accidentally block the event + loop with a sync generator. + """ + return isinstance(value, (EventStream, AsyncIterator)) + + +def is_sync_iterable(value: object) -> bool: + """True if ``value`` is a sync iterator/generator (and not an + :class:`EventStream`). Used by the async runtime to detect — and + reject — sync generators in async handlers.""" + if isinstance(value, EventStream): + return False + return isinstance(value, Iterator) diff --git a/localpost/openapi/adapters/__init__.py b/localpost/openapi/adapters/__init__.py new file mode 100644 index 0000000..ca5f7da --- /dev/null +++ b/localpost/openapi/adapters/__init__.py @@ -0,0 +1,181 @@ +"""Pluggable type adapters for JSON Schema, request decode, and response encode. + +A :class:`TypeAdapter` owns a family of types — msgspec for the catch-all, +pydantic for :class:`pydantic.BaseModel` subclasses, attrs for +:func:`attrs.define`'d classes, and so on. The :class:`AdapterRegistry` +dispatches by type, with the catch-all (msgspec) always last. + +The default registry is auto-built from what's installed: msgspec is a +hard runtime dependency; pydantic and attrs/cattrs are detected at import +time. To plug in a custom adapter (e.g. protobuf), pass an explicit +registry to :class:`localpost.openapi.HttpApp`:: + + from localpost.openapi import HttpApp + from localpost.openapi.adapters import AdapterRegistry, default_registry + + registry = AdapterRegistry([MyProtobufAdapter(), *default_registry().adapters]) + app = HttpApp(adapters=registry) +""" + +from __future__ import annotations + +import functools +from collections.abc import Callable, Sequence +from typing import Any, Protocol + +__all__ = [ + "AdapterRegistry", + "SchemaFor", + "TypeAdapter", + "default_registry", +] + + +SchemaFor = Callable[[Any], dict[str, Any]] +"""Registry callback an adapter can use to delegate nested-type schemas back to the registry. + +Returns a JSON Schema fragment (a ``$ref`` for named types, inline for primitives / unions), +exactly like :meth:`SchemaRegistry.schema_for`. Adapters that don't recurse into foreign types +can ignore the kwarg. +""" + + +class TypeAdapter(Protocol): + """Library-specific bridge for JSON Schema, decode, and encode. + + Implementations are expected to be stateless and cheap to instantiate. + The catch-all adapter (msgspec by default) is the last one in the + registry; its :meth:`claims` always returns ``True``. + """ + + name: str + validation_errors: tuple[type[Exception], ...] + + def claims(self, t: Any, /) -> bool: + """True if this adapter handles ``t``. + + The first adapter in the registry that returns ``True`` wins. + """ + ... + + def is_body_type(self, t: Any, /) -> bool: + """True if ``t`` should be parsed from the request body. + + Falsey types (primitives, ``Annotated`` scalars, …) are left as + query / header parameters. Implementations should return ``False`` + for types that look scalar even if they technically claim them. + """ + ... + + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + """Return the JSON Schema fragment for ``t``. + + For named types, return ``{"$ref": ...}`` and let + :meth:`components` produce the body. + + ``schema_for`` is the registry's own :meth:`SchemaRegistry.schema_for`, + passed when the adapter may need to recurse into types it doesn't + own (e.g. an attrs class with a nested :class:`msgspec.Struct` + field). Adapters that own a closed type universe can ignore it. + """ + ... + + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + """Return the ``components.schemas`` entries for every type + previously passed to :meth:`schema`. + + ``schema_for`` has the same meaning as in :meth:`schema`. + """ + ... + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + """Parse ``body`` (raw request bytes) into an instance of ``t``. + + ``content_type`` is the request's declared content type — adapters + carrying multiple wire formats (e.g. protobuf binary vs JSON) use + it to dispatch; pure-JSON adapters can ignore it. + """ + ... + + def encode(self, value: object, /) -> tuple[bytes, str]: + """Serialise ``value`` to ``(body_bytes, content_type)``.""" + ... + + +class AdapterRegistry: + """Ordered list of :class:`TypeAdapter` s with type-keyed dispatch. + + The last adapter in the list is the catch-all; its :meth:`claims` is + expected to return ``True`` for every type, so :meth:`for_type` always + yields a usable adapter. + """ + + __slots__ = ("_adapters", "_validation_errors") + + def __init__(self, adapters: Sequence[TypeAdapter]) -> None: + if not adapters: + raise ValueError("AdapterRegistry requires at least one adapter") + self._adapters: tuple[TypeAdapter, ...] = tuple(adapters) + # Flatten + dedupe per-adapter validation errors so call sites can + # use a single ``except`` clause. + seen: set[type[Exception]] = set() + flat: list[type[Exception]] = [] + for a in self._adapters: + for exc in a.validation_errors: + if exc in seen: + continue + seen.add(exc) + flat.append(exc) + self._validation_errors: tuple[type[Exception], ...] = tuple(flat) + + @property + def adapters(self) -> tuple[TypeAdapter, ...]: + return self._adapters + + @property + def validation_errors(self) -> tuple[type[Exception], ...]: + return self._validation_errors + + def for_type(self, t: Any) -> TypeAdapter: + """Return the first adapter that claims ``t`` (catch-all if none).""" + for adapter in self._adapters: + if adapter.claims(t): + return adapter + return self._adapters[-1] + + def for_value(self, value: object) -> TypeAdapter: + return self.for_type(type(value)) + + +@functools.cache +def default_registry() -> AdapterRegistry: + """Return a process-wide :class:`AdapterRegistry` autoconfigured from + installed libraries. + + Order: pydantic (if importable), attrs (if both ``attrs`` and ``cattrs`` are importable), + then msgspec as the catch-all. Cached. + """ + from localpost.openapi.adapters._msgspec import MsgspecAdapter # noqa: PLC0415 + + adapters: list[TypeAdapter] = [] + try: + from localpost.openapi.adapters._pydantic import PydanticAdapter # noqa: PLC0415 + + adapters.append(PydanticAdapter()) + except ImportError: + pass + try: + from localpost.openapi.adapters._attrs import AttrsAdapter # noqa: PLC0415 + + adapters.append(AttrsAdapter()) + except ImportError: + pass + adapters.append(MsgspecAdapter()) + return AdapterRegistry(adapters) diff --git a/localpost/openapi/adapters/_attrs.py b/localpost/openapi/adapters/_attrs.py new file mode 100644 index 0000000..09c80ff --- /dev/null +++ b/localpost/openapi/adapters/_attrs.py @@ -0,0 +1,171 @@ +"""attrs/cattrs :class:`TypeAdapter`. Imported lazily by ``default_registry``; +import succeeds only when both :mod:`attrs` and :mod:`cattrs` are installed. + +Schema generation walks ``attrs.fields(cls)`` directly. Container and union +field types are decomposed by the walker; leaf types fall back through +the supplied ``schema_for`` callback so foreign nested types (msgspec +Structs, pydantic models, plain primitives) hit the right adapter via +:class:`SchemaRegistry`. + +Decode/encode is delegated to a ``cattrs`` JSON converter (default: +:func:`cattrs.preconf.json.make_converter`). Pass a custom converter to +``AttrsAdapter(converter=...)`` to register your own structure hooks. +""" + +from __future__ import annotations + +import json +import types +import typing +from collections.abc import Mapping, Sequence +from typing import Any + +import attrs +import cattrs +from cattrs.errors import BaseValidationError + +from localpost.openapi.adapters import SchemaFor + +__all__ = ["AttrsAdapter"] + + +_JSON_CONTENT_TYPE = "application/json" + +_PRIMITIVE_SCHEMAS: dict[type, dict[str, Any]] = { + str: {"type": "string"}, + bool: {"type": "boolean"}, + int: {"type": "integer"}, + float: {"type": "number"}, + bytes: {"type": "string", "format": "binary"}, +} + + +class AttrsAdapter: + name = "attrs" + validation_errors: tuple[type[Exception], ...] = (BaseValidationError,) + + def __init__(self, converter: cattrs.Converter | None = None) -> None: + # We deliberately use a plain ``Converter`` rather than the JSON preconf so users can + # plug in their own with custom structure hooks. The (un)structure step handles the + # type system; ``json`` handles the wire format. + self._converter = converter or cattrs.Converter() + # Classes whose forward-ref annotations have already been resolved. ``attrs.resolve_types`` + # mutates the class in place, so once is enough. + self._resolved: set[type] = set() + + def claims(self, t: Any, /) -> bool: + return isinstance(t, type) and attrs.has(t) + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + del schema_for # The class body itself is rendered in components(); we only emit a ref here. + return {"$ref": ref_template.format(name=t.__name__)} + + def components( + self, + types_: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for t in types_: + self._resolve(t) + out[t.__name__] = self._object_schema(t, ref_template=ref_template, schema_for=schema_for) + return out + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + del content_type + if not body: + raise ValueError("empty request body") + return self._converter.structure(json.loads(body), t) + + def encode(self, value: object, /) -> tuple[bytes, str]: + return json.dumps(self._converter.unstructure(value)).encode("utf-8"), _JSON_CONTENT_TYPE + + # --- internals ------------------------------------------------------ + + def _resolve(self, t: type) -> None: + if t in self._resolved: + return + # ``attrs.resolve_types`` walks ``__annotations__`` and replaces string forward refs + # with real types. Idempotent and safe to call multiple times, but we cache to avoid + # re-entering ``typing.get_type_hints`` on every spec rebuild. + attrs.resolve_types(t) + self._resolved.add(t) + + def _object_schema(self, t: type, *, ref_template: str, schema_for: SchemaFor | None) -> dict[str, Any]: + properties: dict[str, dict[str, Any]] = {} + required: list[str] = [] + for f in attrs.fields(t): + field_schema = self._field_schema(f.type, ref_template=ref_template, schema_for=schema_for) + properties[f.name] = field_schema + if f.default is attrs.NOTHING: + required.append(f.name) + out: dict[str, Any] = { + "type": "object", + "title": t.__name__, + "properties": properties, + } + if required: + out["required"] = required + return out + + def _field_schema(self, t: Any, *, ref_template: str, schema_for: SchemaFor | None) -> dict[str, Any]: + if t is None or t is type(None): + return {"type": "null"} + if t in _PRIMITIVE_SCHEMAS: + return dict(_PRIMITIVE_SCHEMAS[t]) + + origin = typing.get_origin(t) + if origin is typing.Union or origin is types.UnionType: + args = typing.get_args(t) + return {"oneOf": [self._field_schema(a, ref_template=ref_template, schema_for=schema_for) for a in args]} + if origin is typing.Literal: + return {"enum": list(typing.get_args(t))} + if origin in (list, set, frozenset) or ( + isinstance(origin, type) and issubclass(origin, Sequence) and origin is not str + ): + (item_t,) = typing.get_args(t) or (Any,) + return { + "type": "array", + "items": self._field_schema(item_t, ref_template=ref_template, schema_for=schema_for), + } + if origin is tuple: + args = typing.get_args(t) + # ``tuple[T, ...]`` -> homogeneous array; fixed-length tuples fall through to a + # heterogeneous prefix-items array. + if len(args) == 2 and args[1] is Ellipsis: + return { + "type": "array", + "items": self._field_schema(args[0], ref_template=ref_template, schema_for=schema_for), + } + return { + "type": "array", + "prefixItems": [self._field_schema(a, ref_template=ref_template, schema_for=schema_for) for a in args], + "minItems": len(args), + "maxItems": len(args), + } + if origin is dict or (isinstance(origin, type) and issubclass(origin, Mapping)): + args = typing.get_args(t) + value_t = args[1] if len(args) == 2 else Any + return { + "type": "object", + "additionalProperties": self._field_schema(value_t, ref_template=ref_template, schema_for=schema_for), + } + + # Nested attrs class — emit ref + register via the registry so its body lands in + # components.schemas. + if isinstance(t, type) and attrs.has(t): + if schema_for is not None: + return schema_for(t) + return {"$ref": ref_template.format(name=t.__name__)} + + # Anything else (foreign types: msgspec.Struct, dataclass, pydantic model, datetime, + # UUID, …): defer to the registry. Without a callback, fall back to a permissive schema. + if schema_for is not None: + return schema_for(t) + return {} diff --git a/localpost/openapi/adapters/_msgspec.py b/localpost/openapi/adapters/_msgspec.py new file mode 100644 index 0000000..dd84107 --- /dev/null +++ b/localpost/openapi/adapters/_msgspec.py @@ -0,0 +1,74 @@ +"""msgspec-based :class:`TypeAdapter` — the catch-all in the registry. + +Understands :class:`msgspec.Struct`, dataclasses, ``TypedDict``, +``NamedTuple``, primitives, collections, ``Annotated[T, msgspec.Meta(...)]``, +``Literal``, ``Enum``, ``Union``, ``UUID``, ``datetime`` — i.e. everything +:func:`msgspec.json.schema_components` and :func:`msgspec.json.decode` +already handle. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import msgspec + +from localpost.openapi.adapters import SchemaFor + +__all__ = ["MsgspecAdapter"] + + +_JSON_CONTENT_TYPE = "application/json" + + +class MsgspecAdapter: + name = "msgspec" + validation_errors: tuple[type[Exception], ...] = (msgspec.ValidationError,) + + def claims(self, t: Any, /) -> bool: + # Catch-all — placed last in the registry so more specific adapters + # win first. Returning True unconditionally lets us also handle + # primitives, generics, unions, and the long tail (UUID, datetime). + return True + + def is_body_type(self, t: Any, /) -> bool: + if not isinstance(t, type): + return False + if issubclass(t, msgspec.Struct): + return True + if hasattr(t, "__dataclass_fields__"): + return True + # NamedTuple: subclass of tuple with class-level field metadata. + return hasattr(t, "__annotations__") and hasattr(t, "_fields") + + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + del schema_for # msgspec owns its full type universe; no recursion needed. + try: + (schema,), _ = msgspec.json.schema_components([t], ref_template=ref_template) + except (TypeError, RuntimeError): + # Fall back to a permissive schema for types msgspec can't introspect. + return {} + return schema + + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + del schema_for + if not types: + return {} + _, components = msgspec.json.schema_components(list(types), ref_template=ref_template) + return dict(components) + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + if not body: + raise ValueError("empty request body") + return msgspec.json.decode(body, type=t) + + def encode(self, value: object, /) -> tuple[bytes, str]: + return msgspec.json.encode(value), _JSON_CONTENT_TYPE diff --git a/localpost/openapi/adapters/_pydantic.py b/localpost/openapi/adapters/_pydantic.py new file mode 100644 index 0000000..520277f --- /dev/null +++ b/localpost/openapi/adapters/_pydantic.py @@ -0,0 +1,61 @@ +"""pydantic :class:`TypeAdapter`. Imported lazily by ``default_registry``; +import succeeds only when pydantic is installed. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from pydantic import BaseModel, ValidationError + +from localpost.openapi.adapters import SchemaFor + +__all__ = ["PydanticAdapter"] + + +_JSON_CONTENT_TYPE = "application/json" + + +class PydanticAdapter: + name = "pydantic" + validation_errors: tuple[type[Exception], ...] = (ValidationError,) + + def claims(self, t: Any, /) -> bool: + return isinstance(t, type) and issubclass(t, BaseModel) + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + del schema_for # Pydantic models are self-contained; no recursion needed. + # Body emitted in components(); inline ref keeps the schema fragment consistent with + # msgspec's $ref-for-named-types behaviour. + return {"$ref": ref_template.format(name=t.__name__)} + + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + del schema_for + # Pydantic's placeholder is ``{model}``, not ``{name}``. + pydantic_template = ref_template.replace("{name}", "{model}") + out: dict[str, dict[str, Any]] = {} + for t in types: + raw: dict[str, Any] = t.model_json_schema(ref_template=pydantic_template) + raw.pop("$defs", None) + out[t.__name__] = raw + return out + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + if not body: + raise ValueError("empty request body") + return t.model_validate_json(body) + + def encode(self, value: object, /) -> tuple[bytes, str]: + assert isinstance(value, BaseModel) + return value.model_dump_json().encode("utf-8"), _JSON_CONTENT_TYPE diff --git a/localpost/openapi/aio/__init__.py b/localpost/openapi/aio/__init__.py new file mode 100644 index 0000000..1285ab2 --- /dev/null +++ b/localpost/openapi/aio/__init__.py @@ -0,0 +1,34 @@ +"""Async-flavour public API for ``localpost.openapi``. + +Symbols here mirror the sync API at ``localpost.openapi`` — :class:`HttpApp` +becomes :class:`HttpAsyncApp`, :class:`OpMiddleware` becomes +:class:`AsyncOpMiddleware`, etc. The two flavours share the type-level +machinery (signature parsing, OpenAPI doc emission, ``OpResult`` hierarchy, +SSE encoding) and only diverge at the request-time invocation: async apps +``await`` user functions, async middleware, and async ASGI send/receive. + +The async app is deployed to an ASGI server by default — +:meth:`HttpAsyncApp.asgi` returns the ASGI 3 callable; :meth:`HttpAsyncApp.service` +wires it up under uvicorn for ``localpost.hosting``. +""" + +from localpost.openapi.aio._ctx import AsyncHTTPReqCtx +from localpost.openapi.aio.app import HttpAsyncApp +from localpost.openapi.aio.auth import AsyncHttpBasicAuth, AsyncHttpBearerAuth +from localpost.openapi.aio.middleware import ( + AsyncApiOperation, + AsyncOpMiddleware, + async_op_middleware, +) +from localpost.openapi.aio.operation import AsyncOperation + +__all__ = [ + "HttpAsyncApp", + "AsyncOperation", + "AsyncHTTPReqCtx", + "AsyncOpMiddleware", + "AsyncApiOperation", + "async_op_middleware", + "AsyncHttpBearerAuth", + "AsyncHttpBasicAuth", +] diff --git a/localpost/openapi/aio/_ctx.py b/localpost/openapi/aio/_ctx.py new file mode 100644 index 0000000..a445deb --- /dev/null +++ b/localpost/openapi/aio/_ctx.py @@ -0,0 +1,13 @@ +"""Re-export the async ctx Protocol from :mod:`localpost.http`. + +Kept as a thin alias for back-compat with code that imported +``AsyncHTTPReqCtx`` from this module before the Protocol was hoisted +into ``localpost.http``. New code should import from +``localpost.http`` (or the top-level ``localpost.openapi`` namespace). +""" + +from __future__ import annotations + +from localpost.http._async_base import AsyncHTTPReqCtx + +__all__ = ["AsyncHTTPReqCtx"] diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py new file mode 100644 index 0000000..7d85f69 --- /dev/null +++ b/localpost/openapi/aio/app.py @@ -0,0 +1,431 @@ +"""``HttpAsyncApp`` — async sibling of :class:`localpost.openapi.HttpApp`. + +Same decorator API, same OpenAPI doc emission, same ``OpResult`` +hierarchy. The user fns are ``async def``; the app deploys to ASGI by +default — :meth:`asgi` returns an ASGI 3 callable suitable for +``uvicorn``, ``hypercorn``, or ``granian --interface asgi``. +:meth:`service` wires up uvicorn under :mod:`localpost.hosting` for +``hosting.run_app(app.service(config))`` parity with the sync flavour. + +Transport translation lives in :mod:`localpost.http.asgi` — +:meth:`asgi` is sugar over :func:`localpost.http.to_asgi`. The route +table + 404/405/built-ins are built once in +:meth:`_build_async_handler` as a plain :data:`AsyncRequestHandler`, +exactly mirroring how :class:`HttpApp` builds a sync +:data:`RequestHandler` for :func:`localpost.http.to_wsgi`. +""" + +from __future__ import annotations + +import threading +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, replace +from http import HTTPMethod +from typing import Any, Literal + +from localpost import hosting +from localpost.http import AsyncHTTPReqCtx, AsyncRequestHandler, to_asgi, to_rsgi +from localpost.http._types import Response +from localpost.http.asgi import ASGIApp +from localpost.http.router import RouteMatch, URITemplate +from localpost.http.rsgi import RSGIApplication +from localpost.openapi import spec as openapi_spec +from localpost.openapi._docs import redoc_html, scalar_html, swagger_html +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.aio.middleware import AsyncOpMiddleware +from localpost.openapi.aio.operation import AsyncOperation +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["HttpAsyncApp"] + + +_FluentDecorator = Callable[[Callable[..., Any]], Callable[..., Any]] +DocsUI = Literal["swagger", "redoc", "scalar", "all"] + + +class HttpAsyncApp: + """Async type-driven HTTP application that emits an OpenAPI 3.2 spec. + + Mirrors :class:`localpost.openapi.HttpApp` argument-for-argument; the + differences are: + + - Handlers must be ``async def`` (or ``async def`` generators for SSE). + - Middlewares must implement :class:`AsyncOpMiddleware`. + - :meth:`asgi` returns an ASGI 3 callable; :meth:`service` runs it + under uvicorn (or hypercorn) via :mod:`localpost.hosting`. + + Args: + info: Top-level OpenAPI :class:`Info` block. + middlewares: App-level :class:`AsyncOpMiddleware`-s. + openapi_path: URL the generated spec is served on; ``None`` to disable. + docs_path: Base URL for the built-in doc UIs. + docs_ui: Which doc UIs to mount. + adapters: Type adapters used for JSON Schema / decode / encode. + max_body_size: Maximum request-body bytes buffered before + dispatch. ``-1`` disables the limit. Defaults to 1 MiB. + """ + + def __init__( + self, + *, + info: openapi_spec.Info | None = None, + middlewares: Sequence[AsyncOpMiddleware] = (), + openapi_path: str | None = "/openapi.json", + docs_path: str | None = "/docs", + docs_ui: DocsUI = "all", + adapters: AdapterRegistry | None = None, + max_body_size: int = 1 << 20, + ) -> None: + for mw in middlewares: + _ensure_async_middleware(mw) + self._info = info or openapi_spec.Info() + self._middlewares = tuple(middlewares) + self._openapi_path = openapi_path + self._docs_path = docs_path + self._docs_ui = docs_ui + self._adapters = adapters or default_registry() + self._max_body_size = max_body_size + self._operations: list[AsyncOperation] = [] + self._lock = threading.Lock() + self._cached_spec: openapi_spec.OpenAPI | None = None + self._cached_spec_bytes: bytes | None = None + + # ----- Decorators ----- + + def get(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.GET, path, middlewares) + + def post(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.POST, path, middlewares) + + def put(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PUT, path, middlewares) + + def delete(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.DELETE, path, middlewares) + + def patch(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PATCH, path, middlewares) + + def _decorator( + self, + method: HTTPMethod, + path: str, + op_middlewares: Sequence[AsyncOpMiddleware], + ) -> _FluentDecorator: + for mw in op_middlewares: + _ensure_async_middleware(mw) + combined = (*self._middlewares, *op_middlewares) + + def deco(fn: Callable[..., Any]) -> Callable[..., Any]: + op = AsyncOperation.create(method, path, fn, middlewares=combined, adapters=self._adapters) + with self._lock: + self._operations.append(op) + self._cached_spec = None + self._cached_spec_bytes = None + return fn + + return deco + + # ----- Spec ----- + + @property + def operations(self) -> Sequence[AsyncOperation]: + return tuple(self._operations) + + @property + def openapi_doc(self) -> openapi_spec.OpenAPI: + """Return the (cached) OpenAPI 3.2 document.""" + with self._lock: + cached = self._cached_spec + if cached is not None: + return cached + registry = SchemaRegistry(self._adapters) + doc = openapi_spec.OpenAPI(info=self._info) + seen: set[int] = set() + for mw in self._all_middlewares(): + key = id(mw) + if key in seen: + continue + seen.add(key) + doc = mw.contribute_root(doc, registry) + for op in self._operations: + spec_op = op.build_spec(registry) + doc = doc.add_operation(op.path, op.method.value, spec_op) + doc = doc.with_components( + replace(doc.components, schemas={**doc.components.schemas, **registry.components()}) + ) + self._cached_spec = doc + return doc + + def _all_middlewares(self): + yield from self._middlewares + for op in self._operations: + yield from op.middlewares + + def _openapi_bytes(self) -> bytes: + with self._lock: + cached = self._cached_spec_bytes + if cached is not None: + return cached + body = self.openapi_doc.to_json() + with self._lock: + self._cached_spec_bytes = body + return body + + # ----- Hosting ----- + + def asgi(self) -> ASGIApp: + """Return an ASGI 3 callable that dispatches this app. + + Sugar over :func:`localpost.http.to_asgi` — the app's route + table plus built-in ``/openapi.json`` and ``/docs`` UIs are + compiled once into a single :data:`AsyncRequestHandler`, then + wrapped by the ASGI bridge. Deploy with e.g. ``uvicorn + myapp:asgi_app`` or ``granian --interface asgi myapp:asgi_app``. + """ + return to_asgi(self._build_async_handler(), max_body_size=self._max_body_size) + + def as_rsgi(self) -> RSGIApplication: + """Return an RSGI application object for native Granian deployment. + + Sugar over :func:`localpost.http.to_rsgi`. Same handler chain as + :meth:`asgi` (route table + built-ins), but wrapped for RSGI + instead of ASGI — single eager ``response_bytes`` per response, + zero-copy ``response_file_range`` for sendfile, no pump task on + streaming uploads. Deploy with:: + + granian --interface rsgi myapp:rsgi_app + + For deployments with **other hosted services** (scheduler etc.) + co-located with the HTTP app inside each Granian worker, use + :class:`localpost.hosting.rsgi.HostRSGIApp` instead — it drives + the full hosting lifecycle from Granian's per-worker hooks. + """ + return to_rsgi(self._build_async_handler(), max_body_size=self._max_body_size) + + def service( + self, + config: Any, + *, + server: Literal["uvicorn", "hypercorn"] = "uvicorn", + ) -> hosting.ServiceF: + """Return a :func:`localpost.hosting.service` running this app. + + ``config`` is the host server's config object — + :class:`uvicorn.Config` for ``server="uvicorn"`` (the default) or + :class:`hypercorn.Config` for ``server="hypercorn"``. Both + servers are configured with the ASGI app from :meth:`asgi`. + """ + asgi_app = self.asgi() + if server == "uvicorn": + from localpost.hosting.services.uvicorn import uvicorn_server # noqa: PLC0415 + + config.app = asgi_app + return uvicorn_server(config) + if server == "hypercorn": + from localpost.hosting.services.hypercorn import hypercorn_server # noqa: PLC0415 + + return hypercorn_server(asgi_app, config) + raise ValueError(f"Unknown ASGI server: {server!r}") + + # ----- Internals ----- + + def _build_async_handler(self) -> AsyncRequestHandler: + """Compile the route table + built-ins into a single + :data:`AsyncRequestHandler`. Symmetric with + :meth:`HttpApp._build_router_handler`.""" + bucket: dict[str, _RouteEntry] = {} + + def add(method: HTTPMethod, template: URITemplate, handler: _AsyncMatchedHandler) -> None: + entry = bucket.setdefault(template.template, _RouteEntry(template=template, methods={})) + if method in entry.methods: + raise ValueError(f"Duplicate route: {method.value} {template.template}") + entry.methods[method] = handler + + for op in self._operations: + add(op.method, op.template, _async_op_handler(op)) + + if self._openapi_path: + add( + HTTPMethod.GET, + URITemplate.parse(self._openapi_path), + _static_handler(self._openapi_bytes, b"application/json"), + ) + if self._docs_path and self._openapi_path: + ui = self._docs_ui + html_ct = b"text/html; charset=utf-8" + if ui in ("swagger", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(self._docs_path), + _static_handler(_const_bytes(swagger_html(self._openapi_path)), html_ct), + ) + if ui in ("redoc", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(f"{self._docs_path}/redoc"), + _static_handler(_const_bytes(redoc_html(self._openapi_path)), html_ct), + ) + if ui in ("scalar", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(f"{self._docs_path}/scalar"), + _static_handler(_const_bytes(scalar_html(self._openapi_path)), html_ct), + ) + + entries = tuple(bucket.values()) + + async def dispatch(ctx: AsyncHTTPReqCtx) -> None: + path = ctx.request.path.decode("iso-8859-1") + method_str = ctx.request.method.decode("ascii") + try: + method = HTTPMethod(method_str) + except ValueError: + method = None + + match = _match(entries, path, method) + if isinstance(match, _MatchNotFound): + await ctx.complete(_canned_response(404, b"Not Found"), b"Not Found") + return + if isinstance(match, _MatchMethodNotAllowed): + await ctx.complete( + _canned_response( + 405, + b"Method Not Allowed", + extra_headers=[(b"allow", match.allow.encode("ascii"))], + ), + b"Method Not Allowed", + ) + return + + ctx.attrs[RouteMatch] = RouteMatch( + method=method or HTTPMethod.GET, + matched_template=match.template, + path_args=match.path_args, + ) + await match.handler(ctx) + + return dispatch + + +def _ensure_async_middleware(mw: object) -> None: + """Reject sync :class:`OpMiddleware` instances at registration time. + + The whole point of the split is two parallel pipelines — silently + treating a sync middleware as async would block the event loop. + The :class:`AsyncOpMiddleware` Protocol is structural (it shares + method names with the sync :class:`OpMiddleware`), so isinstance + isn't enough — we additionally check that ``__call__`` is a + coroutine function. + """ + import inspect # noqa: PLC0415 + + call = getattr(type(mw), "__call__", None) # noqa: B004 — inspecting unbound method, not testing callability + if not inspect.iscoroutinefunction(call): + name = type(mw).__name__ + raise TypeError( + f"HttpAsyncApp middleware {name!r} is not an AsyncOpMiddleware " + f"(its ``__call__`` must be ``async def``). " + f"Use @async_op_middleware (or AsyncHttpBearerAuth / AsyncHttpBasicAuth) for the async app." + ) + + +# --- Route-table helpers ------------------------------------------------ + +# Matched-handler shape — same as AsyncRequestHandler but spelled out so +# the route table types stay readable. +type _AsyncMatchedHandler = Callable[[AsyncHTTPReqCtx], Awaitable[None]] + + +@dataclass(frozen=True, slots=True) +class _RouteEntry: + template: URITemplate + methods: dict[HTTPMethod, _AsyncMatchedHandler] + + +@dataclass(frozen=True, slots=True) +class _MatchOk: + template: URITemplate + path_args: dict[str, str] + handler: _AsyncMatchedHandler + + +@dataclass(frozen=True, slots=True) +class _MatchNotFound: + pass + + +@dataclass(frozen=True, slots=True) +class _MatchMethodNotAllowed: + allow: str + + +_MATCH_NOT_FOUND = _MatchNotFound() + + +def _match( + entries: tuple[_RouteEntry, ...], + path: str, + method: HTTPMethod | None, +) -> _MatchOk | _MatchNotFound | _MatchMethodNotAllowed: + any_template_matched = False + allow: set[HTTPMethod] = set() + for entry in entries: + args = entry.template.match(path) + if args is None: + continue + any_template_matched = True + allow.update(entry.methods) + if method is None: + continue + handler = entry.methods.get(method) + if handler is None: + continue + return _MatchOk(template=entry.template, path_args=args, handler=handler) + if any_template_matched: + return _MatchMethodNotAllowed(allow=", ".join(sorted(m.value for m in allow))) + return _MATCH_NOT_FOUND + + +def _async_op_handler(op: AsyncOperation) -> _AsyncMatchedHandler: + async def run(ctx: AsyncHTTPReqCtx) -> None: + await op.run(ctx) + + return run + + +def _static_handler(body_provider: Callable[[], bytes], content_type: bytes) -> _AsyncMatchedHandler: + async def run(ctx: AsyncHTTPReqCtx) -> None: + body = body_provider() + response = Response( + status_code=200, + headers=[ + (b"content-type", content_type), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + await ctx.complete(response, body) + + return run + + +def _const_bytes(value: bytes) -> Callable[[], bytes]: + def provider() -> bytes: + return value + + return provider + + +def _canned_response( + status: int, + body: bytes, + *, + extra_headers: Sequence[tuple[bytes, bytes]] = (), +) -> Response: + headers: list[tuple[bytes, bytes]] = [ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + *extra_headers, + ] + return Response(status_code=status, headers=headers) diff --git a/localpost/openapi/aio/auth.py b/localpost/openapi/aio/auth.py new file mode 100644 index 0000000..999b210 --- /dev/null +++ b/localpost/openapi/aio/auth.py @@ -0,0 +1,170 @@ +"""Async sibling of :mod:`localpost.openapi.auth`. + +Same API surface as the sync :class:`HttpBearerAuth` / +:class:`HttpBasicAuth` — the differences: + +- Wraps an ``async def`` middleware (built via :func:`async_op_middleware`). +- The validator may be sync **or** async; async validators are awaited. +- :class:`AsyncOpMiddleware` :meth:`__call__` is async. + +OpenAPI contributions are identical to the sync flavour: the +``Authorization`` header parameter and the ``401`` response come from +the wrapped function's signature; the :class:`SecurityScheme` registration +is hand-written. +""" + +from __future__ import annotations + +import base64 +import binascii +import inspect +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from typing import Annotated, Any + +from localpost.openapi import spec +from localpost.openapi.aio._ctx import AsyncHTTPReqCtx +from localpost.openapi.aio.middleware import AsyncApiOperation, _AsyncFunctionMiddleware, async_op_middleware +from localpost.openapi.resolvers import FromHeader +from localpost.openapi.results import OpResult, Unauthorized +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["AsyncHttpBearerAuth", "AsyncHttpBasicAuth"] + + +def _add_security_scheme(doc: spec.OpenAPI, name: str, scheme: spec.SecurityScheme) -> spec.OpenAPI: + components = replace( + doc.components, + security_schemes={**doc.components.security_schemes, name: scheme}, + ) + return replace(doc, components=components) + + +def _add_security_requirement(op: spec.Operation, scheme_name: str, scopes: tuple[str, ...] = ()) -> spec.Operation: + requirement: dict[str, tuple[str, ...]] = {scheme_name: scopes} + return replace(op, security=(*op.security, requirement)) + + +async def _maybe_await(value: Any) -> Any: + """Await ``value`` if it's awaitable; otherwise return it unchanged. + + Lets the user pick a sync or async validator without two parallel + code paths in the auth middleware. + """ + if inspect.isawaitable(value): + return await value + return value + + +@dataclass(slots=True, eq=False) +class AsyncHttpBearerAuth: + """Async ``Authorization: Bearer `` middleware. + + Args: + validator: ``token_str -> principal | None`` (sync or async). + Async validators are awaited. + scheme_name: Key under ``components.securitySchemes``. + bearer_format: Hint shown in the OpenAPI doc (e.g. ``"JWT"``). + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str], Any | None] | Callable[[str], Awaitable[Any | None]] + scheme_name: str = "bearerAuth" + bearer_format: str = "JWT" + description: str = "" + + _wrapped: _AsyncFunctionMiddleware = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator + principal_key = self # stable identity across requests + + @async_op_middleware + async def _bearer( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + if not authorization.startswith("Bearer "): + return Unauthorized("Missing or malformed Authorization header") + principal = await _maybe_await(validator(authorization[7:])) + if principal is None: + return Unauthorized("Invalid token") + ctx.attrs[principal_key] = principal + return await call_next(ctx) + + self._wrapped = _bearer + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + return await self._wrapped(ctx, call_next) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + scheme = spec.SecurityScheme( + type="http", + scheme="bearer", + bearer_format=self.bearer_format, + description=self.description, + ) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + op = self._wrapped.contribute_operation(op, registry) + return _add_security_requirement(op, self.scheme_name) + + +@dataclass(slots=True, eq=False) +class AsyncHttpBasicAuth: + """Async ``Authorization: Basic `` middleware. + + Args: + validator: ``(username, password) -> principal | None`` (sync or async). + scheme_name: Key under ``components.securitySchemes``. + realm: Realm sent in the ``WWW-Authenticate`` header on 401. + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str, str], Any | None] | Callable[[str, str], Awaitable[Any | None]] + scheme_name: str = "basicAuth" + realm: str = "localpost" + description: str = "" + + _wrapped: _AsyncFunctionMiddleware = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator + challenge = {"WWW-Authenticate": f'Basic realm="{self.realm}"'} + principal_key = self + + @async_op_middleware + async def _basic( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + if not authorization.startswith("Basic "): + return Unauthorized("Missing or malformed Authorization header", headers=challenge) + try: + decoded = base64.b64decode(authorization[6:], validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return Unauthorized("Malformed Basic credentials", headers=challenge) + username, sep, password = decoded.partition(":") + if not sep: + return Unauthorized("Malformed Basic credentials", headers=challenge) + principal = await _maybe_await(validator(username, password)) + if principal is None: + return Unauthorized("Invalid credentials", headers=challenge) + ctx.attrs[principal_key] = principal + return await call_next(ctx) + + self._wrapped = _basic + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + return await self._wrapped(ctx, call_next) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + scheme = spec.SecurityScheme(type="http", scheme="basic", description=self.description) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + op = self._wrapped.contribute_operation(op, registry) + return _add_security_requirement(op, self.scheme_name) diff --git a/localpost/openapi/aio/middleware.py b/localpost/openapi/aio/middleware.py new file mode 100644 index 0000000..2728bf2 --- /dev/null +++ b/localpost/openapi/aio/middleware.py @@ -0,0 +1,188 @@ +"""``AsyncOpMiddleware`` — async sibling of :class:`OpMiddleware`. + +Same idea as the sync version: middleware wraps an operation core, +receives the request ctx + a ``call_next`` callable, returns an +:class:`OpResult`. The async flavour awaits ``call_next``, the user +function, and any user-side I/O. + +The OpenAPI doc-contribution surface (:meth:`contribute_root` / +:meth:`contribute_operation`) is identical to the sync protocol — those +methods build static spec documents and don't touch the event loop. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from localpost.openapi.results import OpResult + +if TYPE_CHECKING: + from localpost.openapi import spec + from localpost.openapi.aio._ctx import AsyncHTTPReqCtx + from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["AsyncApiOperation", "AsyncOpMiddleware", "async_op_middleware"] + + +type AsyncApiOperation = Callable[["AsyncHTTPReqCtx"], Awaitable["OpResult"]] +"""The shape of an async operation as seen by middleware. + +A middleware's ``call_next`` is an :class:`AsyncApiOperation` — given the +request context, it returns an awaitable :class:`OpResult` (which may be +an :class:`~localpost.openapi.results.EventStreamResult` for SSE handlers). +""" + + +@runtime_checkable +class AsyncOpMiddleware(Protocol): + """Async operation middleware that knows how to describe itself in OpenAPI. + + Differences from :class:`localpost.openapi.OpMiddleware`: + + - :meth:`__call__` is ``async`` and awaits ``call_next``. + - The ``ctx`` parameter is an :class:`AsyncHTTPReqCtx` rather than the + sync :class:`HTTPReqCtx`. + """ + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + """Run around the operation. Return an :class:`OpResult` — + typically by awaiting ``call_next(ctx)`` and forwarding (or + post-processing) its result, or by short-circuiting with a + middleware-specific result (e.g. ``Unauthorized``).""" + ... + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + """App-level OpenAPI contribution. Default: return ``doc`` unchanged.""" + ... + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + """Per-operation OpenAPI contribution. Default: return ``op`` unchanged.""" + ... + + +@dataclass(eq=False, slots=True) +class _AsyncFunctionMiddleware: + """Concrete :class:`AsyncOpMiddleware` produced by :func:`async_op_middleware`. + + Same shape as the sync :class:`_FunctionMiddleware`, but the wrapped + function is awaited and the OpenAPI contribution closure is identical + (it builds spec data, no I/O). + """ + + target: Callable[..., Awaitable[Any]] + call_next_param: str + arg_resolvers: tuple[tuple[str, Any], ...] + arg_factories: tuple[tuple[str, Any, Any | None], ...] + return_shapes: tuple[Any, ...] + + root_contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] | None = field(default=None) + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + kwargs: dict[str, object] = {self.call_next_param: call_next} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, OpResult): + return value + kwargs[name] = value + result = await self.target(**kwargs) + if isinstance(result, OpResult): + return result + name = getattr(self.target, "__qualname__", None) or repr(self.target) + raise TypeError( + f"@async_op_middleware {name!r} returned {type(result).__name__}; " + f"middlewares must return an OpResult (typically by awaiting call_next)" + ) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + if self.root_contribution is None: + return doc + return self.root_contribution(doc, registry) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + from localpost.openapi._operation_core import build_responses # noqa: PLC0415 + + # Parameters / requestBody come from the resolver factories. + for _name, param, factory in self.arg_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + # Response codes come from the return-type union (sans the bare + # ``OpResult`` passthrough sentinel). Don't overwrite codes the + # operation already declared (the op's own response is more + # specific than the middleware's generic one). + for code, response in build_responses(self.return_shapes, registry).items(): + if code in op.responses: + continue + op = replace(op, responses={**op.responses, code: response}) + return op + + def with_root( + self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] + ) -> _AsyncFunctionMiddleware: + """Return a copy that also contributes ``contribution`` at the root. + + Used by async auth middlewares to register their ``SecurityScheme`` + while still inheriting the parameter / response contribution from + the wrapped function's signature. + """ + return replace(self, root_contribution=contribution) + + +def async_op_middleware(fn: Callable[..., Awaitable[Any]]) -> _AsyncFunctionMiddleware: + """Wrap an ``async def`` function as an :class:`AsyncOpMiddleware`. + + The wrapped function looks like an async operation handler with one + extra parameter — the ``call_next`` callable — which the framework + passes in to let the middleware invoke the rest of the chain. Other + parameters are resolved from the request (via :class:`FromHeader`, + :class:`FromQuery`, :class:`FromBody`, …); the function must return + an :class:`OpResult` — typically by ``return await call_next(ctx)`` + for the passthrough path, or a short-circuit result like + ``Unauthorized(...)``. + + The OpenAPI contribution mirrors the sync :func:`op_middleware`: the + declared parameters land on every operation that uses this middleware, + and the response codes from the return-type union are added to those + operations' ``responses`` (without overwriting codes the operation + already declared). A bare ``OpResult`` member of the return union is + the passthrough sentinel and contributes nothing. + """ + if not inspect.iscoroutinefunction(fn): + name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + raise TypeError( + f"@async_op_middleware {name!r}: function must be an ``async def`` " + f"(use @op_middleware for sync middlewares)" + ) + + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + from localpost.openapi.aio._ctx import AsyncHTTPReqCtx as _AsyncCtx # noqa: PLC0415 + + call_next_param = _identify_call_next_param(fn) + sig, runtime, factories = build_arg_resolvers( + fn, + exclude={call_next_param}, + ctx_types=(_AsyncCtx,), + ) + shapes_list, _null_is_not_found = extract_response_shapes(sig.return_annotation) + return _AsyncFunctionMiddleware( + target=fn, + call_next_param=call_next_param, + arg_resolvers=tuple(runtime), + arg_factories=tuple(factories), + return_shapes=tuple(shapes_list), + ) + + +def _identify_call_next_param(fn: Callable[..., Any]) -> str: + """Pick the ``call_next`` parameter on ``fn``. Identification is by name.""" + sig = inspect.signature(fn) + if "call_next" in sig.parameters: + return "call_next" + name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + raise ValueError( + f"@async_op_middleware {name!r}: missing required 'call_next' parameter " + f"(of type AsyncApiOperation = Callable[[AsyncHTTPReqCtx], Awaitable[OpResult]])" + ) diff --git a/localpost/openapi/aio/operation.py b/localpost/openapi/aio/operation.py new file mode 100644 index 0000000..a4c82d8 --- /dev/null +++ b/localpost/openapi/aio/operation.py @@ -0,0 +1,250 @@ +"""Async per-operation wiring. + +:class:`AsyncOperation` is the async sibling of +:class:`localpost.openapi.Operation`. The signature parsing, +return-type inference, and OpenAPI doc emission are shared via +:mod:`localpost.openapi._operation_core`; only the request-time +invocation differs — every step that touches the user fn or the wire +is awaited. +""" + +from __future__ import annotations + +import inspect +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, replace +from http import HTTPMethod +from typing import Any, Self + +from localpost.http import aread_body +from localpost.http._types import Response as _Response +from localpost.http.router import URITemplate +from localpost.openapi import spec as openapi_spec +from localpost.openapi._operation_core import ( + SSE_RESPONSE_HEADERS as _SSE_RESPONSE_HEADERS, +) +from localpost.openapi._operation_core import ( + ResponseShape, + build_arg_resolvers, + build_http_response, + build_responses, + extract_response_shapes, + is_async_sse_payload, + is_sync_iterable, + iter_response_headers, +) +from localpost.openapi._operation_core import ( + operation_id as _make_operation_id, +) +from localpost.openapi._operation_core import ( + qualname as _qualname, +) +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.aio._ctx import AsyncHTTPReqCtx +from localpost.openapi.aio.middleware import AsyncApiOperation, AsyncOpMiddleware +from localpost.openapi.aio.sse import async_iter_events +from localpost.openapi.resolvers import ( + BODY_CACHE_KEY, + ArgResolver, + ArgResolverFactory, + FromBody, + FromPath, +) +from localpost.openapi.results import EventStreamResult, NotFound, Ok, OpResult +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["AsyncOperation"] + + +@dataclass(frozen=True, slots=True) +class AsyncOperation: + """Async sibling of :class:`localpost.openapi.Operation`. + + Same shape (method/path/template/target/resolvers/middlewares/return + shapes) — only the runtime methods are async. The build-spec path is + identical to the sync side and shares the type-level helpers. + """ + + method: HTTPMethod + path: str + template: URITemplate + target: Callable[..., Any] + """Async user fn — either a plain ``async def`` (returns a coroutine) + or an ``async def`` generator (returns an async iterator for SSE). + Both shapes are handled in :meth:`_run_core`.""" + arg_resolvers: tuple[tuple[str, ArgResolver], ...] + arg_resolver_factories: tuple[tuple[str, inspect.Parameter, ArgResolverFactory | None], ...] + middlewares: tuple[AsyncOpMiddleware, ...] + return_shapes: tuple[ResponseShape, ...] + null_is_not_found: bool + summary: str + operation_id: str + description: str + adapters: AdapterRegistry + needs_body: bool + """``True`` iff at least one resolved parameter is a :class:`FromBody` + factory. The runtime pre-buffers the request body via + :func:`localpost.http.aread_body` and stashes it in + ``ctx.attrs[BODY_CACHE_KEY]`` before invoking resolvers.""" + + @classmethod + def create( + cls, + method: HTTPMethod, + path: str, + fn: Callable[..., Any], + /, + *, + middlewares: tuple[AsyncOpMiddleware, ...] = (), + adapters: AdapterRegistry | None = None, + ) -> Self: + if not (inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn)): + raise TypeError( + f"HttpAsyncApp handler {_qualname(fn)!r} must be ``async def`` (use HttpApp for sync handlers)" + ) + + template = URITemplate.parse(path) + path_var_names = set(template.variable_names) + registry = adapters or default_registry() + + sig, arg_resolvers, arg_factories = build_arg_resolvers( + fn, + path_var_names=path_var_names, + adapters=registry, + ctx_types=(AsyncHTTPReqCtx,), + ) + + # Validate path bindings: every {var} in the template must be + # claimed by a parameter. + bound_path_vars: set[str] = set() + for _name, param, factory in arg_factories: + if isinstance(factory, FromPath): + bound = factory.name or param.name + if bound not in path_var_names: + raise ValueError( + f"handler {_qualname(fn)!r}: parameter {param.name!r} uses FromPath" + f"({bound!r}) but path template {path!r} has no such variable" + f" (available: {sorted(path_var_names) or 'none'})" + ) + bound_path_vars.add(bound) + unbound = path_var_names - bound_path_vars + if unbound: + raise ValueError( + f"handler {_qualname(fn)!r}: path template {path!r} declares variable(s)" + f" {sorted(unbound)!r} that no parameter resolves" + ) + + shapes_list, null_is_not_found = extract_response_shapes(sig.return_annotation) + return_shapes = tuple(shapes_list) + + doc = inspect.getdoc(fn) or "" + summary = doc.split("\n", 1)[0] if doc else f"{method.value} {path}" + description = doc[len(summary) :].lstrip("\n") if doc else "" + op_id = _make_operation_id(method, path, fn) + + needs_body = any(isinstance(factory, FromBody) for _, _, factory in arg_factories) + + return cls( + method=method, + path=path, + template=template, + target=fn, + arg_resolvers=tuple(arg_resolvers), + arg_resolver_factories=tuple(arg_factories), + middlewares=middlewares, + return_shapes=return_shapes, + null_is_not_found=null_is_not_found, + summary=summary, + operation_id=op_id, + description=description, + adapters=registry, + needs_body=needs_body, + ) + + # ----- runtime ----- + + async def run(self, ctx: AsyncHTTPReqCtx) -> None: + """Drive the full pipeline: middleware chain → arg resolvers → + user fn → response build → ``ctx.complete`` / ``ctx.stream``.""" + chain: AsyncApiOperation = self._run_core + for mw in reversed(self.middlewares): + chain = _wrap_middleware(mw, chain) + result = await chain(ctx) + await self._write_response(ctx, result) + + async def _run_core(self, ctx: AsyncHTTPReqCtx) -> OpResult: + if self.needs_body and BODY_CACHE_KEY not in ctx.attrs: + ctx.attrs[BODY_CACHE_KEY] = await aread_body(ctx) + kwargs: dict[str, object] = {} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, OpResult): + return value + kwargs[name] = value + result = self.target(**kwargs) + # ``async def`` -> coroutine; ``async def`` with ``yield`` -> async iterator. + if inspect.iscoroutine(result): + result = await result + if isinstance(result, OpResult): + return result + if is_sync_iterable(result): + name = _qualname(self.target) + raise TypeError( + f"HttpAsyncApp handler {name!r} returned a sync iterator/generator; " + f"use an ``async def`` generator (yields inside ``async def``) for SSE responses" + ) + if is_async_sse_payload(result): + return EventStreamResult(result) + if result is None and self.null_is_not_found: + return NotFound(None) + return Ok(result) + + async def _write_response(self, ctx: AsyncHTTPReqCtx, result: OpResult) -> None: + if isinstance(result, EventStreamResult): + await _stream_sse(ctx, result, self.adapters) + return + response, body = build_http_response(result, self.adapters) + await ctx.complete(response, body) + + # ----- spec build ----- + + def build_spec(self, registry: SchemaRegistry) -> openapi_spec.Operation: + op = openapi_spec.Operation( + summary=self.summary, + operation_id=self.operation_id, + description=self.description, + ) + for _name, param, factory in self.arg_resolver_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + responses = build_responses(self.return_shapes, registry) + op = replace(op, responses=responses) + for mw in self.middlewares: + op = mw.contribute_operation(op, registry) + return op + + +def _wrap_middleware(mw: AsyncOpMiddleware, call_next: AsyncApiOperation) -> AsyncApiOperation: + async def wrapped(ctx: AsyncHTTPReqCtx) -> OpResult: + return await mw(ctx, call_next) + + return wrapped + + +async def _stream_sse( + ctx: AsyncHTTPReqCtx, + result: EventStreamResult[Any], + adapters: AdapterRegistry, +) -> None: + """Run ``result``'s body as an SSE stream over an + :class:`AsyncHTTPReqCtx`. Mirrors the sync ``_stream_sse``.""" + headers = list(_SSE_RESPONSE_HEADERS) + seen = {name for name, _ in headers} + for name, value in iter_response_headers(result.headers): + if name.lower() in seen: + continue + headers.append((name, value)) + + chunks: AsyncIterator[bytes] = async_iter_events(result.body, adapters) + await ctx.stream(_Response(status_code=result.status_code, headers=headers), chunks) diff --git a/localpost/openapi/aio/sse.py b/localpost/openapi/aio/sse.py new file mode 100644 index 0000000..8d53f8d --- /dev/null +++ b/localpost/openapi/aio/sse.py @@ -0,0 +1,42 @@ +"""Async SSE drive — mirror of :mod:`localpost.openapi.sse`'s +:func:`iter_events`. + +The wire-format helpers (:class:`Event`, :func:`encode_event`, +:func:`format_data_field`) live in :mod:`localpost.openapi.sse` and are +shared between sync and async runtimes. Only the iteration over the +source stream is async-specific. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, cast + +from localpost.openapi.sse import EventStream, encode_event + +if TYPE_CHECKING: + from localpost.openapi.adapters import AdapterRegistry + +__all__ = ["async_iter_events"] + + +async def async_iter_events( + source: object, + adapters: AdapterRegistry | None = None, +) -> AsyncIterator[bytes]: + """Drive ``source`` (an async generator, async iterator, or + :class:`EventStream` whose ``.source`` is itself async-iterable) into + a stream of SSE-encoded event bytes. + + Sync iterators are explicitly rejected — the async runtime won't + silently bridge a blocking generator onto the event loop. + """ + if isinstance(source, EventStream): + source = source.source + if not hasattr(source, "__aiter__"): + raise TypeError( + f"AsyncOperation: SSE source must be an async iterator/generator, got {type(source).__name__}; " + f"use ``async def`` generators in HttpAsyncApp handlers" + ) + async for item in cast(AsyncIterator[Any], source): + yield encode_event(item, adapters) diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py new file mode 100644 index 0000000..2cb341d --- /dev/null +++ b/localpost/openapi/app.py @@ -0,0 +1,296 @@ +"""``HttpApp`` — type-driven OpenAPI 3.2 framework on top of localpost.http. + +The user surface mirrors FastAPI's decorator API. Each registered +operation becomes a :class:`localpost.openapi.operation.Operation` whose +arg resolvers, response shapes, and OpenAPI doc contributions are derived +from the function signature. + +:: + + from localpost import hosting + from localpost.http import ServerConfig + from localpost.openapi import HttpApp, NotFound + + app = HttpApp() + + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + + hosting.run_app(app.service(ServerConfig(port=8000))) +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable, Sequence +from dataclasses import replace +from http import HTTPMethod +from typing import Any, Literal + +from localpost import hosting +from localpost.http import HTTPReqCtx, RequestHandler +from localpost.http._pool import thread_pool_handler +from localpost.http._service import http_server +from localpost.http._types import Response +from localpost.http.config import ServerConfig +from localpost.http.router import Routes +from localpost.openapi import spec as openapi_spec +from localpost.openapi._docs import redoc_html, scalar_html, swagger_html +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.middleware import OpMiddleware +from localpost.openapi.operation import Operation +from localpost.openapi.schemas import SchemaRegistry +from localpost.threadtools import AsyncWorkerExecutor, Executor + +__all__ = ["HttpApp"] + + +_FluentDecorator = Callable[[Callable[..., Any]], Callable[..., Any]] +DocsUI = Literal["swagger", "redoc", "scalar", "all"] + + +class HttpApp: + """Type-driven HTTP application that emits an OpenAPI 3.2 spec. + + Args: + info: Top-level OpenAPI :class:`Info` block. + middlewares: App-level :class:`OpMiddleware` s. Wrap every operation, + outermost first. Their :meth:`OpMiddleware.contribute_root` is + called once at spec build; their + :meth:`OpMiddleware.contribute_operation` is called for every + operation. + openapi_path: URL the generated spec is served on. ``None`` to + disable. + docs_path: Base URL for the built-in doc UIs. Each UI is served + under it: ``{docs_path}`` (Swagger), ``{docs_path}/redoc``, + ``{docs_path}/scalar``. ``None`` to disable all UIs. + docs_ui: Which doc UIs to mount. Default ``"all"``. + adapters: Type adapters used for JSON Schema generation, request + body decoding, and response encoding. Defaults to + :func:`localpost.openapi.adapters.default_registry` (msgspec + as catch-all, plus pydantic if installed). Pass a custom + :class:`AdapterRegistry` to plug in attrs / protobuf / etc. + """ + + def __init__( + self, + *, + info: openapi_spec.Info | None = None, + middlewares: Sequence[OpMiddleware] = (), + openapi_path: str | None = "/openapi.json", + docs_path: str | None = "/docs", + docs_ui: DocsUI = "all", + adapters: AdapterRegistry | None = None, + ) -> None: + self._info = info or openapi_spec.Info() + self._middlewares = tuple(middlewares) + self._openapi_path = openapi_path + self._docs_path = docs_path + self._docs_ui = docs_ui + self._adapters = adapters or default_registry() + self._operations: list[Operation] = [] + self._lock = threading.Lock() + # Cached spec; invalidated whenever an operation is added. + self._cached_spec: openapi_spec.OpenAPI | None = None + self._cached_spec_bytes: bytes | None = None + + # ----- Decorators ----- + + def get(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.GET, path, middlewares) + + def post(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.POST, path, middlewares) + + def put(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PUT, path, middlewares) + + def delete(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.DELETE, path, middlewares) + + def patch(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PATCH, path, middlewares) + + def _decorator(self, method: HTTPMethod, path: str, op_middlewares: Sequence[OpMiddleware]) -> _FluentDecorator: + # App-level middlewares wrap outermost; per-op middlewares are inside. + combined = (*self._middlewares, *op_middlewares) + + def deco(fn: Callable[..., Any]) -> Callable[..., Any]: + op = Operation.create(method, path, fn, middlewares=combined, adapters=self._adapters) + with self._lock: + self._operations.append(op) + self._cached_spec = None + self._cached_spec_bytes = None + return fn + + return deco + + # ----- Spec ----- + + @property + def operations(self) -> Sequence[Operation]: + return tuple(self._operations) + + @property + def openapi_doc(self) -> openapi_spec.OpenAPI: + """Return the (cached) OpenAPI 3.2 document for the registered ops.""" + with self._lock: + cached = self._cached_spec + if cached is not None: + return cached + registry = SchemaRegistry(self._adapters) + doc = openapi_spec.OpenAPI(info=self._info) + # Every middleware (app-level and per-op) gets its + # contribute_root called exactly once. We dedupe by identity so + # the same middleware attached to several ops registers its + # securityScheme just once. + seen: set[int] = set() + for mw in self._all_middlewares(): + key = id(mw) + if key in seen: + continue + seen.add(key) + doc = mw.contribute_root(doc, registry) + for op in self._operations: + spec_op = op.build_spec(registry) + doc = doc.add_operation(op.path, op.method.value, spec_op) + # Merge in the schemas the registry collected. Middlewares may + # have already populated other Components fields above; + # preserve them by replacing only ``schemas``. + doc = doc.with_components( + replace(doc.components, schemas={**doc.components.schemas, **registry.components()}) + ) + self._cached_spec = doc + return doc + + def _all_middlewares(self): + """Yield every middleware that participates in the doc — app-level + first, then per-op (in registration order).""" + yield from self._middlewares + for op in self._operations: + yield from op.middlewares + + def _openapi_bytes(self) -> bytes: + with self._lock: + cached = self._cached_spec_bytes + if cached is not None: + return cached + # Compute outside the lock so doc rendering doesn't hold contention. + body = self.openapi_doc.to_json() + with self._lock: + self._cached_spec_bytes = body + return body + + # ----- Hosting ----- + + def service( + self, + config: ServerConfig, + *, + executor: Executor | None = None, + selectors: int = 1, + acceptor: bool = False, + ) -> hosting.ServiceF: + """Return a :func:`localpost.hosting.service` running this app. + + Composes a worker pool (via :func:`thread_pool_handler`) and the + HTTP server (via :func:`http_server`). The user fn for each + registered operation runs on a worker after the request body is + buffered by the selector. + + ``executor`` is the thread executor that runs handlers; pass an + already-open :class:`localpost.threadtools.Executor` to share one + across services. When omitted, an :class:`AsyncWorkerExecutor` is + opened on the hosting layer's :class:`localpost.Portal` for the + lifetime of the service — handlers can call + :func:`anyio.from_thread.check_cancelled` for free. + + ``selectors`` and ``acceptor`` forward to :func:`http_server`. + """ + router = self._build_router_handler() + + @hosting.service + async def _app_service(): + if executor is not None: + async with thread_pool_handler(router, executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + return + portal = hosting.current_service().portal + async with AsyncWorkerExecutor(portal=portal) as own_executor: + async with thread_pool_handler(router, own_executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + + return _app_service() + + def as_wsgi(self): + """Return a WSGI application that serves this :class:`HttpApp`. + + Sugar over :func:`localpost.http.wsgi.to_wsgi`: builds the same + router used by :meth:`service` and adapts it for deployment + under Gunicorn / uWSGI / Werkzeug. Deploy with e.g. + ``gunicorn myapp:app.as_wsgi()``. + + The WSGI worker model is the request-side concurrency layer — + :func:`localpost.http.thread_pool_handler` does not apply, and + :func:`localpost.http.check_cancelled` is a no-op (no socket + handle inside the WSGI app). + """ + from localpost.http.wsgi import to_wsgi # noqa: PLC0415 + + return to_wsgi(self._build_router_handler()) + + # ----- Internals ----- + + def _build_router_handler(self) -> RequestHandler: + routes = Routes() + for op in self._operations: + routes.add(op.method, op.path, op.as_handler()) + self._mount_built_in(routes) + return routes.build().as_handler() + + def _mount_built_in(self, routes: Routes) -> None: + openapi_path = self._openapi_path + if openapi_path: + self_ref = self + + def openapi_handler(ctx: HTTPReqCtx): + body = self_ref._openapi_bytes() + response = Response( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + ctx.complete(response, body) + + routes.add(HTTPMethod.GET, openapi_path, openapi_handler) + docs_path = self._docs_path + if docs_path and self._openapi_path: + ui = self._docs_ui + if ui in ("swagger", "all"): + self._add_html_route(routes, docs_path, swagger_html(self._openapi_path)) + if ui in ("redoc", "all"): + self._add_html_route(routes, f"{docs_path}/redoc", redoc_html(self._openapi_path)) + if ui in ("scalar", "all"): + self._add_html_route(routes, f"{docs_path}/scalar", scalar_html(self._openapi_path)) + + @staticmethod + def _add_html_route(routes: Routes, path: str, body: bytes) -> None: + response = Response( + status_code=200, + headers=[ + (b"content-type", b"text/html; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + + def handler(ctx: HTTPReqCtx): + ctx.complete(response, body) + + routes.add(HTTPMethod.GET, path, handler) diff --git a/localpost/openapi/auth.py b/localpost/openapi/auth.py new file mode 100644 index 0000000..8d0b8fc --- /dev/null +++ b/localpost/openapi/auth.py @@ -0,0 +1,182 @@ +"""Concrete :class:`OpMiddleware` implementations for HTTP authentication. + +Both middlewares are thin wrappers around an +:func:`@op_middleware`-decorated inner function that reads the +``Authorization`` header and validates it. The OpenAPI parameter +(``Authorization``) and the ``401`` response are contributed +*automatically* by ``@op_middleware`` — only the +:class:`SecurityScheme` registration at the root is custom code here. + +On success the validated principal is stashed on ``ctx.attrs[]`` +so user code can pull it via a tiny custom resolver — see the README +for the pattern. The validator is a sync callable returning either the +principal (any object) on success or ``None`` on rejection. + +Use either app-wide: + + app = HttpApp(middlewares=[HttpBearerAuth(validate_token)]) + +or per-operation: + + @app.get("/me", middlewares=[HttpBearerAuth(validate_token)]) + def me(ctx: HTTPReqCtx) -> dict: ... +""" + +from __future__ import annotations + +import base64 +import binascii +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from typing import Annotated, Any + +from localpost.http import HTTPReqCtx +from localpost.openapi import spec +from localpost.openapi.middleware import ApiOperation, _FunctionMiddleware, op_middleware +from localpost.openapi.resolvers import FromHeader +from localpost.openapi.results import OpResult, Unauthorized +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["HttpBearerAuth", "HttpBasicAuth"] + + +def _add_security_scheme(doc: spec.OpenAPI, name: str, scheme: spec.SecurityScheme) -> spec.OpenAPI: + components = replace( + doc.components, + security_schemes={**doc.components.security_schemes, name: scheme}, + ) + return replace(doc, components=components) + + +def _add_security_requirement(op: spec.Operation, scheme_name: str, scopes: tuple[str, ...] = ()) -> spec.Operation: + requirement: dict[str, tuple[str, ...]] = {scheme_name: scopes} + return replace(op, security=(*op.security, requirement)) + + +@dataclass(slots=True, eq=False) +class HttpBearerAuth: + """``Authorization: Bearer `` middleware. + + Args: + validator: ``token_str -> principal | None``. Called for every + request that carries a ``Bearer`` Authorization header. Return + anything truthy on success (it gets stashed on + ``ctx.attrs[self]``); return ``None`` to reject with 401. + scheme_name: Key under ``components.securitySchemes``. Default + ``"bearerAuth"``. + bearer_format: Hint shown in the OpenAPI doc (e.g. ``"JWT"``). + Default ``"JWT"``. + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str], Any | None] + scheme_name: str = "bearerAuth" + bearer_format: str = "JWT" + description: str = "" + + _wrapped: _FunctionMiddleware = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator + principal_key = self # stable identity across requests + + @op_middleware + def _bearer( + ctx: HTTPReqCtx, + call_next: ApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + # Default ``""`` makes the header optional at the resolver level so + # absence is reported as 401 (with the spec-aware Unauthorized) rather + # than a generic 400 from FromHeader's "missing required" branch. + if not authorization.startswith("Bearer "): + return Unauthorized("Missing or malformed Authorization header") + principal = validator(authorization[7:]) + if principal is None: + return Unauthorized("Invalid token") + ctx.attrs[principal_key] = principal + return call_next(ctx) + + self._wrapped = _bearer + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + return self._wrapped(ctx, call_next) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + scheme = spec.SecurityScheme( + type="http", + scheme="bearer", + bearer_format=self.bearer_format, + description=self.description, + ) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + # ``@op_middleware`` already adds the Authorization header parameter + # and the 401 response from the wrapped function's signature. + # We only add the security requirement on top. + op = self._wrapped.contribute_operation(op, registry) + return _add_security_requirement(op, self.scheme_name) + + +@dataclass(slots=True, eq=False) +class HttpBasicAuth: + """``Authorization: Basic `` middleware. + + Args: + validator: ``(username, password) -> principal | None``. Called + for every request that carries a ``Basic`` Authorization + header. Return anything truthy on success (stashed on + ``ctx.attrs[self]``); ``None`` to reject with 401. + scheme_name: Key under ``components.securitySchemes``. Default + ``"basicAuth"``. + realm: Realm sent in the ``WWW-Authenticate`` header on 401. + Default ``"localpost"``. + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str, str], Any | None] + scheme_name: str = "basicAuth" + realm: str = "localpost" + description: str = "" + + _wrapped: _FunctionMiddleware = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator + challenge = {"WWW-Authenticate": f'Basic realm="{self.realm}"'} + principal_key = self + + @op_middleware + def _basic( + ctx: HTTPReqCtx, + call_next: ApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + if not authorization.startswith("Basic "): + return Unauthorized("Missing or malformed Authorization header", headers=challenge) + try: + decoded = base64.b64decode(authorization[6:], validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return Unauthorized("Malformed Basic credentials", headers=challenge) + username, sep, password = decoded.partition(":") + if not sep: + return Unauthorized("Malformed Basic credentials", headers=challenge) + principal = validator(username, password) + if principal is None: + return Unauthorized("Invalid credentials", headers=challenge) + ctx.attrs[principal_key] = principal + return call_next(ctx) + + self._wrapped = _basic + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + return self._wrapped(ctx, call_next) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + scheme = spec.SecurityScheme(type="http", scheme="basic", description=self.description) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + op = self._wrapped.contribute_operation(op, registry) + return _add_security_requirement(op, self.scheme_name) diff --git a/localpost/openapi/middleware.py b/localpost/openapi/middleware.py new file mode 100644 index 0000000..cbc7467 --- /dev/null +++ b/localpost/openapi/middleware.py @@ -0,0 +1,247 @@ +"""``OpMiddleware`` — middleware that also describes itself in the OpenAPI doc. + +A middleware wraps an :class:`~localpost.openapi.operation.Operation`'s +core. It receives the request context plus a ``call_next`` callable that +runs the rest of the chain (next middleware, then the operation itself). +A middleware can: + +- Short-circuit by returning an :class:`OpResult` without invoking + ``call_next`` — the typical auth pattern. +- Forward to the chain (``return call_next(ctx)``) and return its result + unchanged — pure observers / metrics. +- Forward, then post-process the :class:`OpResult` — add headers, swap + the body, wrap an SSE stream, etc. + +The OpenAPI improvement vs. plain wrap-and-forget middleware: the *same* +object that runs at request time also describes its contribution to the +spec — security schemes at the root, additional response codes / security +requirements / parameters on each operation. There's no second place to +declare auth. + +Two contribution hooks: + +- :meth:`OpMiddleware.contribute_root` — called once at spec build time. + Typical use: register a :class:`SecurityScheme` under + ``components.securitySchemes``. +- :meth:`OpMiddleware.contribute_operation` — called per operation that + has this middleware attached (app-level middlewares are attached to + *every* op). Typical use: add a 401 / 403 response, a ``security`` + requirement, or an extra header parameter. + +For the common case — a middleware built like a tiny operation, with arg +resolvers and a typed return — use :func:`op_middleware`. It does the +signature inspection for you and the OpenAPI contribution comes from the +same annotations that run the resolvers:: + + from localpost.openapi import FromHeader, Unauthorized, op_middleware + + + @op_middleware + def require_token( + ctx: HTTPReqCtx, + call_next: ApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + if not authorization.startswith("Bearer "): + return Unauthorized("Missing or malformed Authorization header") + if not validate(authorization[7:]): + return Unauthorized("Invalid token") + return call_next(ctx) + + + @app.get("/me", middlewares=[require_token]) + def me() -> User: ... + +Bare ``OpResult`` in the return-type union is the *passthrough* sentinel: +it represents the wrapped operation's own result and contributes nothing +to the spec. Subclasses (``Unauthorized[str]`` etc.) add their response +codes to every operation that uses the middleware. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from localpost.openapi.results import OpResult + +if TYPE_CHECKING: + from localpost.http import HTTPReqCtx + from localpost.openapi import spec + from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["ApiOperation", "OpMiddleware", "op_middleware"] + + +type ApiOperation = Callable[["HTTPReqCtx"], "OpResult"] +"""The shape of an operation as seen by middleware. + +A middleware's ``call_next`` is an :class:`ApiOperation` — given the +request context, it returns an :class:`OpResult` (which may be an +:class:`~localpost.openapi.results.EventStreamResult` for SSE handlers). +""" + + +@runtime_checkable +class OpMiddleware(Protocol): + """Operation middleware that knows how to describe itself in OpenAPI.""" + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + """Run around the operation. Return an :class:`OpResult` — + typically by calling ``call_next(ctx)`` and forwarding (or + post-processing) its result, or by short-circuiting with a + middleware-specific result (e.g. ``Unauthorized``).""" + ... + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + """App-level OpenAPI contribution. + + Called once when the spec is built. Typical use: register a + ``SecurityScheme`` under ``components.securitySchemes``. Default: + return ``doc`` unchanged. + """ + ... + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + """Per-operation OpenAPI contribution. + + Called once per operation that this middleware is attached to + (every operation for app-level middlewares). Typical use: add a + ``401`` / ``403`` response and a ``security`` requirement. + Default: return ``op`` unchanged. + """ + ... + + +@dataclass(eq=False, slots=True) +class _FunctionMiddleware: + """Concrete :class:`OpMiddleware` produced by :func:`op_middleware`. + + Inspects the wrapped function's signature once: arg resolvers come + from the parameters (just like an :class:`Operation`), the + ``call_next`` parameter is identified and threaded through at request + time, and the declared response codes come from the return-type + union. The OpenAPI contribution mirrors what an operation with the + same signature would emit. + """ + + target: Callable[..., Any] + call_next_param: str + arg_resolvers: tuple[tuple[str, Any], ...] + arg_factories: tuple[tuple[str, Any, Any | None], ...] + return_shapes: tuple[Any, ...] + + # Optional root-level contribution (set by ``with_root``); auth + # middlewares use this to register their securityScheme. Defaults to + # a no-op. + root_contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] | None = field(default=None) + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + kwargs: dict[str, object] = {self.call_next_param: call_next} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, OpResult): + return value + kwargs[name] = value + result = self.target(**kwargs) + if isinstance(result, OpResult): + return result + name = getattr(self.target, "__qualname__", None) or repr(self.target) + raise TypeError( + f"@op_middleware {name!r} returned {type(result).__name__}; " + f"middlewares must return an OpResult (typically by calling call_next)" + ) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + if self.root_contribution is None: + return doc + return self.root_contribution(doc, registry) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + from localpost.openapi._operation_core import build_responses # noqa: PLC0415 + + # Parameters / requestBody come from the resolver factories. + for _name, param, factory in self.arg_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + # Response codes come from the return-type union (sans the bare + # ``OpResult`` passthrough sentinel). Don't overwrite codes the + # operation already declared (the op's own response is more + # specific than the middleware's generic one). + for code, response in build_responses(self.return_shapes, registry).items(): + if code in op.responses: + continue + op = replace(op, responses={**op.responses, code: response}) + return op + + def with_root(self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI]) -> _FunctionMiddleware: + """Return a copy that also contributes ``contribution`` at the root. + + Used by auth middlewares to register their ``SecurityScheme`` + while still inheriting the parameter / response contribution from + the wrapped function's signature. + """ + return replace(self, root_contribution=contribution) + + +def op_middleware(fn: Callable[..., Any]) -> _FunctionMiddleware: + """Wrap ``fn`` as an :class:`OpMiddleware`. + + The wrapped function looks like an operation handler with one extra + parameter — the ``call_next`` callable — which the framework passes + in to let the middleware invoke the rest of the chain. Other + parameters are resolved from the request (via :class:`FromHeader`, + :class:`FromQuery`, :class:`FromBody`, …); the function must return + an :class:`OpResult` — typically by ``return call_next(ctx)`` for + the passthrough path, or a short-circuit result like + ``Unauthorized(...)``. + + The ``call_next`` parameter is identified by name (``call_next``) or + by annotation as :class:`ApiOperation` / + ``Callable[[HTTPReqCtx], OpResult]``. + + The OpenAPI contribution is inferred from the same annotations: the + declared parameters land on every operation that uses this + middleware, and the response codes from the return-type union are + added to those operations' ``responses`` (without overwriting codes + the operation already declared). A bare ``OpResult`` member of the + return union is the passthrough sentinel and contributes nothing. + """ + from localpost.http import HTTPReqCtx # noqa: PLC0415 + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + + call_next_param = _identify_call_next_param(fn) + sig, runtime, factories = build_arg_resolvers( + fn, + exclude={call_next_param}, + ctx_types=(HTTPReqCtx,), + ) + shapes_list, _null_is_not_found = extract_response_shapes(sig.return_annotation) + return _FunctionMiddleware( + target=fn, + call_next_param=call_next_param, + arg_resolvers=tuple(runtime), + arg_factories=tuple(factories), + return_shapes=tuple(shapes_list), + ) + + +def _identify_call_next_param(fn: Callable[..., Any]) -> str: + """Pick the ``call_next`` parameter on ``fn``. + + Identification is by name (``call_next``); the parameter must exist + and may be annotated as :class:`ApiOperation` / + ``Callable[[HTTPReqCtx], OpResult]``. The annotation is informational + — we don't require it. + """ + sig = inspect.signature(fn) + if "call_next" in sig.parameters: + return "call_next" + name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + raise ValueError( + f"@op_middleware {name!r}: missing required 'call_next' parameter " + f"(of type ApiOperation = Callable[[HTTPReqCtx], OpResult])" + ) diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py new file mode 100644 index 0000000..8f83c41 --- /dev/null +++ b/localpost/openapi/operation.py @@ -0,0 +1,270 @@ +"""Per-operation wiring: signature parsing, return-type inference, the +``(ctx) -> OpResult`` closure that runs at request time. + +An :class:`Operation` is built once at decoration time and used in two +places: its :meth:`as_handler` populates the :class:`localpost.http.Routes` +table for dispatch, and its :meth:`build_spec` contributes a +:class:`localpost.openapi.spec.Operation` to the OpenAPI doc. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass, replace +from http import HTTPMethod +from typing import Any, Self + +from localpost.http import HTTPReqCtx, RequestHandler, read_body +from localpost.http._types import Response as _Response +from localpost.http.router import URITemplate +from localpost.openapi import spec as openapi_spec +from localpost.openapi._operation_core import SSE_RESPONSE_HEADERS as _SSE_RESPONSE_HEADERS +from localpost.openapi._operation_core import ( + ResponseShape, + build_arg_resolvers, + build_http_response, + build_responses, + extract_response_shapes, + is_sse_payload, + iter_response_headers, +) +from localpost.openapi._operation_core import ( + operation_id as _make_operation_id, +) +from localpost.openapi._operation_core import ( + qualname as _qualname, +) +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.middleware import ApiOperation, OpMiddleware +from localpost.openapi.resolvers import ( + BODY_CACHE_KEY, + ArgResolver, + ArgResolverFactory, + FromBody, + FromPath, +) +from localpost.openapi.results import EventStreamResult, NotFound, Ok, OpResult +from localpost.openapi.schemas import SchemaRegistry +from localpost.openapi.sse import iter_events + +__all__ = [ + "Operation", + "ResponseShape", + "build_arg_resolvers", + "extract_response_shapes", +] + + +@dataclass(frozen=True, slots=True) +class Operation: + method: HTTPMethod + path: str + template: URITemplate + target: Callable[..., Any] + arg_resolvers: tuple[tuple[str, ArgResolver], ...] + arg_resolver_factories: tuple[tuple[str, inspect.Parameter, ArgResolverFactory | None], ...] + """Per-parameter ``(name, inspect.Parameter, factory)``. ``factory`` is + ``None`` for the ``HTTPReqCtx`` pass-through, which contributes nothing to + the OpenAPI doc.""" + + middlewares: tuple[OpMiddleware, ...] + """Middlewares wrapping the operation core. Includes both app-level + middlewares (injected by :class:`HttpApp`) and per-operation middlewares. + Outermost first; the chain is built in :meth:`_run` so each middleware's + ``call_next`` invokes the next one.""" + + return_shapes: tuple[ResponseShape, ...] + null_is_not_found: bool + """True when the return annotation was ``T | None`` and we therefore + map a runtime ``None`` to a 404 instead of a 200 with empty body.""" + + summary: str + operation_id: str + description: str + + adapters: AdapterRegistry + """Type adapters used at request time for body decode and response + encode. Threaded down from :class:`HttpApp`.""" + + needs_body: bool + """``True`` iff at least one resolved parameter is a :class:`FromBody` + factory. The runtime pre-buffers the request body via + :func:`localpost.http.read_body` and stashes it in + ``ctx.attrs[BODY_CACHE_KEY]`` before invoking resolvers.""" + + @classmethod + def create( + cls, + method: HTTPMethod, + path: str, + fn: Callable[..., Any], + /, + *, + middlewares: tuple[OpMiddleware, ...] = (), + adapters: AdapterRegistry | None = None, + ) -> Self: + template = URITemplate.parse(path) + path_var_names = set(template.variable_names) + registry = adapters or default_registry() + + sig, arg_resolvers, arg_factories = build_arg_resolvers( + fn, + path_var_names=path_var_names, + adapters=registry, + ctx_types=(HTTPReqCtx,), + ) + + # Validate path bindings: every {var} in the template must be + # claimed by a parameter (whether implicitly via name match or + # explicitly via Annotated[..., FromPath("var")]). + bound_path_vars: set[str] = set() + for _name, param, factory in arg_factories: + if isinstance(factory, FromPath): + bound = factory.name or param.name + if bound not in path_var_names: + raise ValueError( + f"handler {_qualname(fn)!r}: parameter {param.name!r} uses FromPath" + f"({bound!r}) but path template {path!r} has no such variable" + f" (available: {sorted(path_var_names) or 'none'})" + ) + bound_path_vars.add(bound) + unbound = path_var_names - bound_path_vars + if unbound: + raise ValueError( + f"handler {_qualname(fn)!r}: path template {path!r} declares variable(s)" + f" {sorted(unbound)!r} that no parameter resolves" + ) + + shapes_list, null_is_not_found = extract_response_shapes(sig.return_annotation) + return_shapes = tuple(shapes_list) + + doc = inspect.getdoc(fn) or "" + summary = doc.split("\n", 1)[0] if doc else f"{method.value} {path}" + description = doc[len(summary) :].lstrip("\n") if doc else "" + op_id = _make_operation_id(method, path, fn) + + needs_body = any(isinstance(factory, FromBody) for _, _, factory in arg_factories) + + return cls( + method=method, + path=path, + template=template, + target=fn, + arg_resolvers=tuple(arg_resolvers), + arg_resolver_factories=tuple(arg_factories), + middlewares=middlewares, + return_shapes=return_shapes, + null_is_not_found=null_is_not_found, + summary=summary, + operation_id=op_id, + description=description, + adapters=registry, + needs_body=needs_body, + ) + + # ----- runtime ----- + + def as_handler(self) -> RequestHandler: + """Build the ``RequestHandler`` registered with the router. + + Runs the full operation pipeline in one shot: middleware chain → + arg resolvers → user fn → response build → ``ctx.complete``. + Operations that consume the request body (parameters with a + :class:`FromBody` factory) call :func:`localpost.http.read_body` + from inside :meth:`_run_core` — the conn must therefore be on a + worker thread (composed via + :func:`localpost.http.thread_pool_handler`) before this handler + runs. + """ + return self._run + + def _run(self, ctx: HTTPReqCtx) -> None: + chain: ApiOperation = self._run_core + for mw in reversed(self.middlewares): + chain = _wrap_middleware(mw, chain) + result = chain(ctx) + self._write_response(ctx, result) + + def _run_core(self, ctx: HTTPReqCtx) -> OpResult: + """Resolve args, invoke the target, normalise the return value to + an :class:`OpResult`. SSE-shaped returns (generator, iterator, + :class:`EventStream`) are wrapped in :class:`EventStreamResult` + so middleware can post-process / wrap the stream uniformly.""" + if self.needs_body and BODY_CACHE_KEY not in ctx.attrs: + ctx.attrs[BODY_CACHE_KEY] = read_body(ctx) + kwargs: dict[str, object] = {} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, OpResult): + return value + kwargs[name] = value + result = self.target(**kwargs) + if isinstance(result, OpResult): + return result + if is_sse_payload(result): + return EventStreamResult(result) + if result is None and self.null_is_not_found: + # ``T | None`` returning None → implicit 404. + return NotFound(None) + return Ok(result) + + def _write_response(self, ctx: HTTPReqCtx, result: OpResult) -> None: + if isinstance(result, EventStreamResult): + _stream_sse(ctx, result, self.adapters) + return + response, body = build_http_response(result, self.adapters) + ctx.complete(response, body) + + # ----- spec build ----- + + def build_spec(self, registry: SchemaRegistry) -> openapi_spec.Operation: + op = openapi_spec.Operation( + summary=self.summary, + operation_id=self.operation_id, + description=self.description, + ) + for _name, param, factory in self.arg_resolver_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + responses = build_responses(self.return_shapes, registry) + op = replace(op, responses=responses) + for mw in self.middlewares: + op = mw.contribute_operation(op, registry) + return op + + +# --- SSE streaming ------------------------------------------------------- + + +def _wrap_middleware(mw: OpMiddleware, call_next: ApiOperation) -> ApiOperation: + def wrapped(ctx: HTTPReqCtx) -> OpResult: + return mw(ctx, call_next) + + return wrapped + + +def _stream_sse(ctx: HTTPReqCtx, result: EventStreamResult[Any], adapters: AdapterRegistry) -> None: + """Run ``result``'s body as an SSE stream over ``ctx``. + + Builds the SSE response (chunked transfer encoding is implicit — we + don't set ``content-length`` so h11 picks chunked) and hands the + event iterator to :meth:`HTTPReqCtx.stream`. The transport owns + iteration: the native server interleaves cancellation checks per + chunk, the WSGI bridge hands the iterator straight to the WSGI + server. Any user-set headers on ``result`` are merged in (without + overriding the framework-set ``content-type`` / ``cache-control`` / + ``x-accel-buffering``). + """ + headers = list(_SSE_RESPONSE_HEADERS) + seen = {name for name, _ in headers} + for name, value in iter_response_headers(result.headers): + if name.lower() in seen: + continue + headers.append((name, value)) + + ctx.stream( + _Response(status_code=result.status_code, headers=headers), + iter_events(result.body, adapters), + ) diff --git a/localpost/openapi/resolvers.py b/localpost/openapi/resolvers.py new file mode 100644 index 0000000..a59712a --- /dev/null +++ b/localpost/openapi/resolvers.py @@ -0,0 +1,432 @@ +"""Argument resolvers — pull function parameters from the request. + +Each resolver factory inspects an :class:`inspect.Parameter` once and +returns a runtime closure ``(HTTPReqCtx) -> object | OpResult``. Returning +an :class:`OpResult` short-circuits the operation (e.g. validation +failure) before the user function runs. + +Resolvers also describe themselves to OpenAPI via :meth:`update_doc` +— this keeps the spec aligned with the runtime behaviour without a +separate declaration step. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from types import NoneType, UnionType +from typing import TYPE_CHECKING, Annotated, Any, Protocol, Union, get_args, get_origin, runtime_checkable +from urllib.parse import parse_qs + +import msgspec + +from localpost.http.router import RouteMatch +from localpost.openapi import spec as openapi_spec +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.results import BadRequest, OpResult +from localpost.openapi.schemas import SchemaRegistry + +if TYPE_CHECKING: + from localpost.http._types import Request + +__all__ = [ + "ArgResolver", + "ArgResolverFactory", + "ResolverCtx", + "FromBody", + "FromHeader", + "FromPath", + "FromQuery", + "is_body_type", +] + + +# Sentinel attached to ctx.attrs once we've parsed the query string for +# this request, so multiple FromQuery resolvers don't re-parse. +_QUERY_CACHE_KEY = "__lp_openapi_query__" + +# Sentinel — the operation layer pre-buffers the request body into +# ``ctx.attrs[_BODY_CACHE_KEY]`` when at least one parameter is a +# ``FromBody`` resolver. The framework decides whether to call it (sync: +# ``read_body``, async: ``aread_body``); resolvers just read from cache. +BODY_CACHE_KEY = "__lp_openapi_body__" + + +@runtime_checkable +class ResolverCtx(Protocol): + """Minimal request-context shape an :class:`ArgResolver` reads. + + Both :class:`localpost.http.HTTPReqCtx` (sync) and + :class:`localpost.openapi.aio.AsyncHTTPReqCtx` (async) are + structurally compatible — they both expose ``request`` / ``attrs`` + synchronously. Restricting :class:`ArgResolver` to this minimal + protocol means the same factories work in both flavours. + + The body itself is *not* on this Protocol — the wire-level Protocols + only expose ``await ctx.receive(size)``, which an :class:`ArgResolver` + can't drive (sync resolver, async ctx). Operations pre-buffer the + body into ``ctx.attrs[BODY_CACHE_KEY]`` before resolvers run; the + :class:`FromBody` resolver reads from there. + """ + + request: Request + attrs: dict[Any, Any] + + +class ArgResolver(Protocol): + def __call__(self, ctx: ResolverCtx, /) -> object | OpResult: ... + + +class ArgResolverFactory(Protocol): + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: ... + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: ... + + +# --- Helpers ------------------------------------------------------------- + + +def _unwrap_annotated(t: Any) -> Any: + """Strip ``Annotated[T, ...]`` to ``T``; return ``Any`` if no annotation.""" + if t is inspect.Parameter.empty: + return Any + if get_origin(t) is Annotated: + return get_args(t)[0] + return t + + +def _unwrap_optional(t: Any) -> tuple[Any, bool]: + """Strip ``T | None`` / ``Optional[T]`` to ``(T, True)``. + + Returns ``(t, False)`` for non-optional types. + """ + origin = get_origin(t) + if origin is Union or origin is UnionType: + args = [a for a in get_args(t) if a is not NoneType] + if len(args) < len(get_args(t)): + # ``T | None`` collapses to T; ``T | U | None`` keeps the union. + inner: Any = args[0] if len(args) == 1 else Union[tuple(args)] # noqa: UP007 + return inner, True + return t, False + + +def _has_default(param: inspect.Parameter) -> bool: + return param.default is not inspect.Parameter.empty + + +def _is_optional_param(param: inspect.Parameter) -> tuple[Any, bool, Any]: + """For a param, return ``(target_type, is_optional, default_value)``. + + ``is_optional`` is true if the annotation is ``T | None`` *or* the param + has a default. ``default_value`` is the param's default, or ``None`` when + the type is optional but no default is supplied. + """ + target = _unwrap_annotated(param.annotation) + inner, is_nullable = _unwrap_optional(target) + has_default = _has_default(param) + if has_default: + return inner, True, param.default + if is_nullable: + return inner, True, None + return target, False, None + + +def _cast_str(value: str, target: Any) -> Any: + """Coerce a single string value into ``target``. + + Falls back to :func:`msgspec.convert` with ``strict=False`` for the + long tail (UUID, datetime, Decimal, Enum, …). URL-borne parameters + are always JSON-shaped strings, so msgspec is the right caster + regardless of which adapter would otherwise own ``target``. + """ + if target is str or target is Any or target is inspect.Parameter.empty: + return value + if target is int: + return int(value) + if target is float: + return float(value) + if target is bool: + return value.lower() in ("true", "1", "yes", "on") + if target is bytes: + return value.encode() + return msgspec.convert(value, type=target, strict=False) + + +def _list_element_type(t: Any) -> Any | None: + """If ``t`` is ``list[X]`` / ``Sequence[X]`` / ``set[X]``, return ``X``.""" + origin = get_origin(t) + if origin in (list, set, tuple, frozenset, Sequence): + args = get_args(t) + return args[0] if args else Any + return None + + +def is_body_type(t: Any, adapters: AdapterRegistry | None = None) -> bool: + """True if ``t`` looks like something we should parse from the request body. + + Delegates to the matching :class:`TypeAdapter`'s ``is_body_type``; + msgspec recognises :class:`msgspec.Struct`, dataclasses, ``TypedDict``, + ``NamedTuple``; pydantic recognises :class:`pydantic.BaseModel`. + Primitives stay as query params. + """ + if not isinstance(t, type): + return False + registry = adapters or default_registry() + return registry.for_type(t).is_body_type(t) + + +def _route_match(ctx: ResolverCtx) -> RouteMatch: + return ctx.attrs[RouteMatch] + + +def _query_args(ctx: ResolverCtx) -> dict[str, list[str]]: + cached = ctx.attrs.get(_QUERY_CACHE_KEY) + if cached is not None: + return cached + qs = ctx.request.query_string.decode("iso-8859-1") if ctx.request.query_string else "" + parsed: dict[str, list[str]] = parse_qs(qs, keep_blank_values=True) if qs else {} + ctx.attrs[_QUERY_CACHE_KEY] = parsed + return parsed + + +# --- FromPath ------------------------------------------------------------ + + +@dataclass(frozen=True, slots=True) +class FromPath: + """Resolve a parameter from a URI template variable.""" + + name: str | None = None + description: str = "" + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + var_name = self.name or param.name + target = _unwrap_annotated(param.annotation) + + def resolve(ctx: ResolverCtx) -> object | OpResult: + raw = _route_match(ctx).path_args.get(var_name) + if raw is None: + return BadRequest(f"Missing path parameter: {var_name}") + try: + return _cast_str(raw, target) + except (ValueError, msgspec.ValidationError) as exc: + return BadRequest(f"Invalid path parameter {var_name!r}: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target = _unwrap_annotated(param.annotation) + schema = registry.schema_for(target) if target is not Any else {"type": "string"} + parameter = openapi_spec.Parameter( + name=self.name or param.name, + location="path", + required=True, + description=self.description, + schema=schema, + ) + return replace(op, parameters=(*op.parameters, parameter)) + + +# --- FromQuery ----------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class FromQuery: + """Resolve a parameter from the URL query string. + + For ``list[T]`` / ``Sequence[T]`` annotations all values for the key + are returned. For scalar annotations the *first* value is taken and + coerced. + + Optional handling: if the annotation is ``T | None`` *or* the param + has a default, the parameter is not required. When absent it returns + the default (``None`` if the type was simply made nullable). + """ + + name: str | None = None + description: str = "" + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + param_name = self.name or param.name + target, optional, default = _is_optional_param(param) + elem_type = _list_element_type(target) + is_list = elem_type is not None + + def resolve(ctx: ResolverCtx) -> object | OpResult: + values = _query_args(ctx).get(param_name) + if not values: + if optional: + return default + return BadRequest(f"Missing required query parameter: {param_name}") + try: + if is_list: + return [_cast_str(v, elem_type) for v in values] + return _cast_str(values[0], target) + except (ValueError, msgspec.ValidationError) as exc: + return BadRequest(f"Invalid query parameter {param_name!r}: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target, optional, _ = _is_optional_param(param) + schema = registry.schema_for(target) if target is not Any else {"type": "string"} + parameter = openapi_spec.Parameter( + name=self.name or param.name, + location="query", + required=not optional, + description=self.description, + schema=schema, + ) + return replace(op, parameters=(*op.parameters, parameter)) + + +# --- FromHeader ---------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class FromHeader: + """Resolve a parameter from a request header. + + Optional handling: if the annotation is ``T | None`` *or* the param + has a default, the header is not required. + """ + + name: str | None = None + description: str = "" + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + header_name = (self.name or param.name.replace("_", "-")).lower().encode("ascii") + target, optional, default = _is_optional_param(param) + + def resolve(ctx: ResolverCtx) -> object | OpResult: + value: str | None = None + for name, val in ctx.request.headers: + if name == header_name: + value = val.decode("iso-8859-1") + break + if value is None: + if optional: + return default + return BadRequest(f"Missing required header: {header_name.decode()}") + try: + return _cast_str(value, target) + except (ValueError, msgspec.ValidationError) as exc: + return BadRequest(f"Invalid header {header_name.decode()!r}: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target, optional, _ = _is_optional_param(param) + schema = registry.schema_for(target) if target is not Any else {"type": "string"} + parameter = openapi_spec.Parameter( + name=self.name or param.name.replace("_", "-"), + location="header", + required=not optional, + description=self.description, + schema=schema, + ) + return replace(op, parameters=(*op.parameters, parameter)) + + +# --- FromBody ------------------------------------------------------------ + + +@dataclass(frozen=True, slots=True) +class FromBody: + """Resolve a parameter from the request body. + + Decoding is delegated to the :class:`TypeAdapter` that claims the + parameter's type (msgspec for the catch-all, pydantic for + :class:`pydantic.BaseModel` subclasses, …). Raw ``bytes`` / ``str`` + annotations get the body verbatim. + + A custom ``converter`` (``(bytes, type) -> object``) bypasses the + adapter dispatch entirely. + """ + + content_type: str = "application/json" + description: str = "" + converter: Callable[[bytes, Any], object] | None = None + adapters: AdapterRegistry | None = None + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + target = _unwrap_annotated(param.annotation) + registry = self.adapters or default_registry() + validation_errors = registry.validation_errors + content_type = self.content_type + converter: Callable[[bytes, Any], object] + if self.converter is not None: + converter = self.converter + elif target is bytes: + converter = _bytes_passthrough + elif target is str: + converter = _str_decode + else: + adapter = registry.for_type(target) + + def _adapter_decode(body: bytes, _t: Any) -> object: + if not body: + raise ValueError("empty request body") + return adapter.decode(body, target, content_type=content_type) + + converter = _adapter_decode + + def resolve(ctx: ResolverCtx) -> object | OpResult: + body = ctx.attrs.get(BODY_CACHE_KEY, b"") + try: + return converter(body, target) + except validation_errors as exc: + return BadRequest(f"Invalid request body: {exc}") + except (ValueError, TypeError) as exc: + return BadRequest(f"Invalid request body: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target = _unwrap_annotated(param.annotation) + schema = registry.schema_for(target) if target is not Any else {} + request_body = openapi_spec.RequestBody( + description=self.description, + required=not _has_default(param), + content={self.content_type: openapi_spec.MediaType(schema=schema)}, + ) + return replace(op, request_body=request_body) + + +def _bytes_passthrough(body: bytes, _target: Any) -> object: + return body + + +def _str_decode(body: bytes, _target: Any) -> object: + return body.decode("utf-8") diff --git a/localpost/openapi/results.py b/localpost/openapi/results.py new file mode 100644 index 0000000..684a1d2 --- /dev/null +++ b/localpost/openapi/results.py @@ -0,0 +1,260 @@ +"""Operation results. + +Functions registered with :class:`localpost.openapi.HttpApp` return a +plain value (treated as ``200 OK``) or one of the :class:`OpResult` +subclasses below. The class chosen carries both the HTTP status code at +runtime *and* the type-level information the OpenAPI doc builder reads +from the function's return annotation. + +Use ``BadRequest[T]``, ``NotFound[T]`` etc. in the return annotation so +the schema generator knows the body type per status code:: + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: ... +""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from localpost.openapi.sse import Event, EventStream + +__all__ = [ + "OpResult", + "Ok", + "Created", + "Accepted", + "NoContent", + "EventStreamResult", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "Conflict", + "UnprocessableEntity", + "TooManyRequests", + "InternalServerError", +] + + +_EMPTY_HEADERS: Mapping[str, str] = MappingProxyType({}) + + +@dataclass(frozen=True, eq=False, slots=True) +class OpResult: + """Concrete operation outcome — body + status + headers. + + Subclasses (:class:`Ok`, :class:`BadRequest`, …) override + :attr:`_status_code` and :attr:`_description`. The class itself is the + type-level marker the OpenAPI doc reads — keep return annotations + using the *class*, not the instance:: + + def f() -> Book | NotFound[str]: # ✅ correct + def f() -> Book | NotFound("nope"): # ❌ instance, won't type-check + """ + + body: object + headers: Mapping[str, str] = field(default=_EMPTY_HEADERS) + + _status_code: ClassVar[int] = 200 + _description: ClassVar[str] = "Successful response" + + @property + def status_code(self) -> int: + return self._status_code + + @property + def description(self) -> str: + return self._description + + +def _normalize_headers(headers: Mapping[str, str] | None) -> Mapping[str, str]: + return MappingProxyType(dict(headers)) if headers else _EMPTY_HEADERS + + +# --- 2xx ----------------------------------------------------------------- + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Ok[T](OpResult): + body: T + + _status_code: ClassVar[int] = 200 + _description: ClassVar[str] = "Successful response" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Created[T](OpResult): + body: T + + _status_code: ClassVar[int] = 201 + _description: ClassVar[str] = "Created" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Accepted[T](OpResult): + body: T + + _status_code: ClassVar[int] = 202 + _description: ClassVar[str] = "Accepted" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class NoContent(OpResult): + """No body. Ideal for ``DELETE`` / ``PUT`` returns.""" + + _status_code: ClassVar[int] = 204 + _description: ClassVar[str] = "No Content" + + def __init__(self, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", None) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class EventStreamResult[T](OpResult): + """Server-Sent Events response. + + Body is the stream source — an :class:`~localpost.openapi.sse.EventStream`, + a generator/iterator of payloads, or an iterable of + :class:`~localpost.openapi.sse.Event` instances. The response is sent + with ``Content-Type: text/event-stream`` and chunked transfer encoding; + one wire event per yielded item. + + Returning a generator/iterator/``EventStream`` directly from an operation + is auto-promoted to ``EventStreamResult`` — you only construct one + explicitly to attach extra response headers. + """ + + body: EventStream[T] | Iterator[T] | Iterator[Event[T]] | Iterable[T] | Iterable[Event[T]] + + _status_code: ClassVar[int] = 200 + _description: ClassVar[str] = "Successful response" + + def __init__( + self, + body: Any, + /, + *, + headers: Mapping[str, str] | None = None, + ) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +# --- 4xx ----------------------------------------------------------------- + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class BadRequest[T](OpResult): + body: T + + _status_code: ClassVar[int] = 400 + _description: ClassVar[str] = "Bad Request" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Unauthorized[T](OpResult): + body: T + + _status_code: ClassVar[int] = 401 + _description: ClassVar[str] = "Unauthorized" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Forbidden[T](OpResult): + body: T + + _status_code: ClassVar[int] = 403 + _description: ClassVar[str] = "Forbidden" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class NotFound[T](OpResult): + body: T + + _status_code: ClassVar[int] = 404 + _description: ClassVar[str] = "Not Found" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Conflict[T](OpResult): + body: T + + _status_code: ClassVar[int] = 409 + _description: ClassVar[str] = "Conflict" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class UnprocessableEntity[T](OpResult): + body: T + + _status_code: ClassVar[int] = 422 + _description: ClassVar[str] = "Unprocessable Entity" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class TooManyRequests[T](OpResult): + body: T + + _status_code: ClassVar[int] = 429 + _description: ClassVar[str] = "Too Many Requests" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +# --- 5xx ----------------------------------------------------------------- + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class InternalServerError[T](OpResult): + body: T + + _status_code: ClassVar[int] = 500 + _description: ClassVar[str] = "Internal Server Error" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) diff --git a/localpost/openapi/schemas.py b/localpost/openapi/schemas.py new file mode 100644 index 0000000..4621fad --- /dev/null +++ b/localpost/openapi/schemas.py @@ -0,0 +1,102 @@ +"""JSON Schema accumulator for OpenAPI 3.2 components. + +Schema work is delegated to :class:`localpost.openapi.adapters.TypeAdapter` +implementations — see :mod:`localpost.openapi.adapters` for the protocol +and the built-in msgspec / pydantic bridges. +""" + +from __future__ import annotations + +import threading +from typing import Any + +from localpost.openapi.adapters import AdapterRegistry, TypeAdapter, default_registry + +__all__ = ["REF_TEMPLATE", "SchemaRegistry"] + + +REF_TEMPLATE = "#/components/schemas/{name}" + + +class SchemaRegistry: + """Accumulator for types referenced across operations. + + Call :meth:`schema_for` for each type the spec needs to describe; the + registry returns a JSON Schema fragment (``$ref`` for named types, + inline for primitives / unions). At spec emission time, call + :meth:`components` to resolve the accumulated named types into + ``components.schemas`` entries. + + Schema/components production is delegated per type to the matching + :class:`TypeAdapter` from the supplied :class:`AdapterRegistry`. + + Thread-safe: registration is guarded so concurrent doc builds don't + race on the cached components dict. + """ + + __slots__ = ("_adapters", "_components", "_lock", "_types_by_adapter") + + def __init__(self, adapters: AdapterRegistry | None = None) -> None: + # Re-entrant: adapters that recurse via the ``schema_for`` callback (attrs -> + # msgspec for foreign nested types) take the same lock from the same thread. + self._lock = threading.RLock() + self._adapters = adapters or default_registry() + # We bucket types by the adapter that claims them. Insertion order + # is preserved (CPython dict semantics), so generated component + # output is stable across runs. + self._types_by_adapter: dict[TypeAdapter, list[Any]] = {} + self._components: dict[str, dict[str, Any]] | None = None + + @property + def adapters(self) -> AdapterRegistry: + return self._adapters + + def schema_for(self, t: Any) -> dict[str, Any]: + """Return a JSON Schema fragment describing ``t``. + + For named types (:class:`msgspec.Struct`, dataclass, pydantic model, + ``TypedDict``, ``NamedTuple``, ``Enum``) this returns a ``$ref`` and + registers the type. For primitives / unions / generics the schema is + inlined. + """ + if t is None or t is type(None): + return {"type": "null"} + adapter = self._adapters.for_type(t) + with self._lock: + self._components = None # invalidate + bucket = self._types_by_adapter.setdefault(adapter, []) + if t not in bucket: + bucket.append(t) + return adapter.schema(t, ref_template=REF_TEMPLATE, schema_for=self.schema_for) + + def components(self) -> dict[str, dict[str, Any]]: + """Return the resolved ``components.schemas`` dict for every type + ever passed to :meth:`schema_for`. + + Result is cached until the next :meth:`schema_for` call. + """ + with self._lock: + if self._components is not None: + return self._components + schemas: dict[str, dict[str, Any]] = {} + # Drain in passes: an adapter's ``components()`` may register more types via + # ``schema_for`` (e.g. attrs adapter delegating a nested msgspec.Struct field + # back to the registry). Track per-adapter how many items have been processed + # and loop until no adapter has unprocessed work. + processed: dict[TypeAdapter, int] = {} + while True: + made_progress = False + # Snapshot keys: more adapters may appear during iteration. + for adapter in list(self._types_by_adapter.keys()): + bucket = self._types_by_adapter[adapter] + start = processed.get(adapter, 0) + if start >= len(bucket): + continue + pending = bucket[start:] + processed[adapter] = len(bucket) + schemas.update(adapter.components(pending, ref_template=REF_TEMPLATE, schema_for=self.schema_for)) + made_progress = True + if not made_progress: + break + self._components = schemas + return schemas diff --git a/localpost/openapi/spec.py b/localpost/openapi/spec.py new file mode 100644 index 0000000..1394200 --- /dev/null +++ b/localpost/openapi/spec.py @@ -0,0 +1,425 @@ +"""Immutable dataclasses for the OpenAPI 3.2 specification. + +The dataclasses are version-agnostic enough that a 3.1 / 3.0 serializer +could be added later as a sibling of :meth:`OpenAPI.to_dict` without +touching the data shape. v1 emits 3.2 only. +""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field, replace +from typing import Any, Literal + +import msgspec + +ParameterLocation = Literal["query", "header", "path", "cookie"] +SecuritySchemeType = Literal["apiKey", "http", "oauth2", "openIdConnect", "mutualTLS"] + + +# --- Serialisation helpers ------------------------------------------------ + +# Field-name → output-key overrides for fields whose JSON name doesn't +# follow the snake_case → camelCase rule (e.g. ``ref`` → ``$ref``). +_KEY_OVERRIDES: dict[str, str] = { + "ref": "$ref", + "location": "in", +} + + +def _camel(name: str) -> str: + head, *tail = name.split("_") + return head + "".join(p.capitalize() for p in tail) + + +def _key(name: str) -> str: + return _KEY_OVERRIDES.get(name, _camel(name)) + + +def _dump(value: Any) -> Any: + """Recursively serialise dataclasses/containers for JSON output. + + Tuples become lists; nested dataclasses are flattened via their own + ``to_dict``. Plain JSON-compatible values pass through. + """ + if hasattr(value, "to_dict"): + return value.to_dict() + if isinstance(value, dict): + return {k: _dump(v) for k, v in value.items()} + if isinstance(value, (tuple, list)): + return [_dump(v) for v in value] + return value + + +_MISSING = dataclasses.MISSING + + +def _to_dict(obj: Any, *, always: tuple[str, ...] = ()) -> dict[str, Any]: + """Generic ``to_dict`` for the spec dataclasses. + + Walks ``dataclasses.fields(obj)``, skipping any field whose value + matches its declared default (so ``""`` strings, empty dicts/tuples, + ``None`` and ``False`` are omitted unless their name is listed in + ``always``). Field names map to JSON keys via :func:`_key` + (snake_case → camelCase, with overrides for ``ref`` and ``location``). + """ + out: dict[str, Any] = {} + for f in dataclasses.fields(obj): + v = getattr(obj, f.name) + if f.name not in always: + if f.default is not _MISSING and v == f.default: + continue + if f.default_factory is not _MISSING and v == f.default_factory(): + continue + out[_key(f.name)] = _dump(v) + return out + + +# --- Spec dataclasses ----------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class Reference: + """JSON ``$ref`` to another component.""" + + ref: str + summary: str = "" + description: str = "" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self, always=("ref",)) + + +@dataclass(frozen=True, slots=True) +class Contact: + name: str = "" + url: str = "" + email: str = "" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class License: + name: str + identifier: str = "" + url: str = "" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Info: + title: str = "API" + version: str = "0.1.0" + summary: str = "" + description: str = "" + terms_of_service: str = "" + contact: Contact | None = None + license: License | None = None + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self, always=("title", "version")) + + +@dataclass(frozen=True, slots=True) +class ServerVariable: + default: str + enum: tuple[str, ...] = () + description: str = "" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Server: + url: str + description: str = "" + variables: dict[str, ServerVariable] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class ExternalDocs: + url: str + description: str = "" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Tag: + name: str + description: str = "" + summary: str = "" + external_docs: ExternalDocs | None = None + parent: str = "" + """3.2 addition: name of the parent tag for nested grouping.""" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class TagGroup: + """3.2 addition: tag group declared at the root level.""" + + name: str + tags: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Encoding: + content_type: str = "" + headers: dict[str, Header | Reference] = field(default_factory=dict) + style: str = "" + explode: bool | None = None + allow_reserved: bool = False + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Example: + summary: str = "" + description: str = "" + value: Any = None + external_value: str = "" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class MediaType: + schema: dict[str, Any] = field(default_factory=dict) + example: Any = None + examples: dict[str, Example | Reference] = field(default_factory=dict) + encoding: dict[str, Encoding] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Header: + description: str = "" + required: bool = False + deprecated: bool = False + schema: dict[str, Any] = field(default_factory=dict) + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Parameter: + name: str + location: ParameterLocation = "query" + required: bool = False + description: str = "" + deprecated: bool = False + schema: dict[str, Any] = field(default_factory=dict) + style: str = "" + explode: bool | None = None + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self, always=("location",)) + + +@dataclass(frozen=True, slots=True) +class RequestBody: + description: str = "" + required: bool = False + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Response: + description: str = "" + headers: dict[str, Header | Reference] = field(default_factory=dict) + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self, always=("description",)) + + +SecurityRequirement = dict[str, tuple[str, ...]] +"""``{schemeName: (scope1, scope2, ...)}`` — multiple keys = AND; multiple +elements in the surrounding tuple = OR.""" + + +@dataclass(frozen=True, slots=True) +class Operation: + summary: str = "" + operation_id: str = "" + description: str = "" + parameters: tuple[Parameter | Reference, ...] = () + request_body: RequestBody | Reference | None = None + responses: dict[str, Response | Reference] = field(default_factory=dict) + deprecated: bool = False + tags: tuple[str, ...] = () + security: tuple[SecurityRequirement, ...] = () + servers: tuple[Server, ...] = () + external_docs: ExternalDocs | None = None + callbacks: dict[str, dict[str, Any]] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class PathItem: + summary: str = "" + description: str = "" + operations: dict[str, Operation] = field(default_factory=dict) + """Keyed by lowercase HTTP method: get, post, put, delete, patch, + head, options, trace, query (3.2).""" + parameters: tuple[Parameter | Reference, ...] = () + servers: tuple[Server, ...] = () + ref: str = "" + + def to_dict(self) -> dict[str, Any]: + if self.ref: + return {"$ref": self.ref} + d: dict[str, Any] = {} + if self.summary: + d["summary"] = self.summary + if self.description: + d["description"] = self.description + for method, op in self.operations.items(): + d[method] = op.to_dict() + if self.parameters: + d["parameters"] = [_dump(p) for p in self.parameters] + if self.servers: + d["servers"] = [_dump(s) for s in self.servers] + return d + + +# --- Security schemes ----------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class OAuthFlow: + scopes: dict[str, str] = field(default_factory=dict) + authorization_url: str = "" + token_url: str = "" + refresh_url: str = "" + device_authorization_url: str = "" + """3.2 addition: device-authorization URL for the device flow.""" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self, always=("scopes",)) + + +@dataclass(frozen=True, slots=True) +class OAuthFlows: + implicit: OAuthFlow | None = None + password: OAuthFlow | None = None + client_credentials: OAuthFlow | None = None + authorization_code: OAuthFlow | None = None + device_authorization: OAuthFlow | None = None + """3.2 addition.""" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class SecurityScheme: + type: SecuritySchemeType + description: str = "" + name: str = "" + """``apiKey``: parameter name.""" + location: Literal["query", "header", "cookie", ""] = "" + """``apiKey``: where the key lives.""" + scheme: str = "" + """``http``: e.g. ``basic`` / ``bearer``.""" + bearer_format: str = "" + flows: OAuthFlows | None = None + open_id_connect_url: str = "" + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + +@dataclass(frozen=True, slots=True) +class Components: + schemas: dict[str, dict[str, Any]] = field(default_factory=dict) + responses: dict[str, Response | Reference] = field(default_factory=dict) + parameters: dict[str, Parameter | Reference] = field(default_factory=dict) + request_bodies: dict[str, RequestBody | Reference] = field(default_factory=dict) + headers: dict[str, Header | Reference] = field(default_factory=dict) + security_schemes: dict[str, SecurityScheme | Reference] = field(default_factory=dict) + examples: dict[str, Example | Reference] = field(default_factory=dict) + path_items: dict[str, PathItem] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self) + + def is_empty(self) -> bool: + return not ( + self.schemas + or self.responses + or self.parameters + or self.request_bodies + or self.headers + or self.security_schemes + or self.examples + or self.path_items + ) + + +@dataclass(frozen=True, slots=True) +class OpenAPI: + """Root of an OpenAPI 3.2 document.""" + + openapi: str = "3.2.0" + info: Info = field(default_factory=Info) + servers: tuple[Server, ...] = () + paths: dict[str, PathItem] = field(default_factory=dict) + webhooks: dict[str, PathItem | Reference] = field(default_factory=dict) + components: Components = field(default_factory=Components) + security: tuple[SecurityRequirement, ...] = () + tags: tuple[Tag, ...] = () + tag_groups: tuple[TagGroup, ...] = () + """3.2 addition.""" + external_docs: ExternalDocs | None = None + json_schema_dialect: str = "" + + def add_operation(self, path: str, method: str, operation: Operation) -> OpenAPI: + """Return a new :class:`OpenAPI` with ``operation`` registered at + ``method`` (case-insensitive) on ``path``.""" + m = method.lower() + new_paths = dict(self.paths) + existing = new_paths.get(path) + if existing is None: + new_paths[path] = PathItem(operations={m: operation}) + else: + new_ops = dict(existing.operations) + new_ops[m] = operation + new_paths[path] = replace(existing, operations=new_ops) + return replace(self, paths=new_paths) + + def with_components(self, components: Components) -> OpenAPI: + return replace(self, components=components) + + def to_dict(self) -> dict[str, Any]: + return _to_dict(self, always=("openapi", "info")) + + def to_json(self) -> bytes: + return msgspec.json.encode(self.to_dict()) diff --git a/localpost/openapi/sse.py b/localpost/openapi/sse.py new file mode 100644 index 0000000..80b26a3 --- /dev/null +++ b/localpost/openapi/sse.py @@ -0,0 +1,121 @@ +"""Server-Sent Events (SSE) primitives for ``localpost.openapi``. + +Operations whose return type is a ``Generator[T, ...]`` / +``Iterator[T, ...]`` (or ``EventStream[T]``) are auto-promoted to an SSE +response: ``Content-Type: text/event-stream``, chunked transfer encoding, +one ``data:`` block per yielded value. + +Yield :class:`Event` instances for full control over the SSE fields +(``event``, ``id``, ``retry``); yielding a bare value emits ``data: `` +only. Encoding follows the WHATWG SSE spec (newline-prefixed, double-newline +terminator). +""" + +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import msgspec + +if TYPE_CHECKING: + from localpost.openapi.adapters import AdapterRegistry + +__all__ = ["Event", "EventStream", "encode_event", "format_data_field", "iter_events"] + + +class Event[T](msgspec.Struct, eq=False, omit_defaults=True): + """One SSE event. + + Args: + data: Payload. Encoded as JSON unless it's already a ``str``. + event: Optional event name (``event:`` field). + id: Optional last event id (``id:`` field). + retry: Optional reconnection delay in milliseconds (``retry:`` field). + comment: Optional comment line. Useful for keep-alives — the spec says comment lines (``: ...``) are ignored + by clients but keep the connection alive. + """ + + data: T + event: str | None = None + id: str | None = None + retry: int | None = None + comment: str | None = None + + +@dataclass(frozen=True, slots=True, eq=False) +class EventStream[T]: + """Explicit SSE wrapper around a source iterator. + + Use when you want SSE behaviour without the framework auto-detecting + a ``Generator`` return type — e.g. when returning from an iterator + you've already constructed:: + + @app.get("/feed") + def feed() -> EventStream[Update]: + return EventStream(updates_queue) + + For most cases just write a generator function and return the generator + object directly; the framework recognises both shapes. + """ + + source: Iterable[T] | Iterable[Event[T]] + + +# --- Wire encoding ------------------------------------------------------- + + +def format_data_field(payload: object, adapters: "AdapterRegistry | None" = None) -> str: + """Encode an event's ``data`` value into the ``data:`` lines per the + WHATWG SSE spec. + + A multi-line payload becomes one ``data:`` line per source line; a + string is sent as-is; everything else is encoded via the matching + :class:`TypeAdapter` from ``adapters`` (default registry if ``None``). + """ + if isinstance(payload, str): + text = payload + elif isinstance(payload, (bytes, bytearray, memoryview)): + text = bytes(payload).decode("utf-8") + else: + from localpost.openapi.adapters import default_registry # noqa: PLC0415 + + registry = adapters or default_registry() + body, _ct = registry.for_value(payload).encode(payload) + text = body.decode("utf-8") + return "\n".join(f"data: {line}" for line in text.split("\n")) + + +def encode_event(event: Event[object] | object, adapters: "AdapterRegistry | None" = None) -> bytes: + """Encode a single event (or bare payload) into SSE wire bytes. + + The output ends with the mandatory blank-line terminator (``\\n\\n``). + """ + if isinstance(event, Event): + lines: list[str] = [] + if event.comment is not None: + lines.extend(f": {line}" for line in event.comment.split("\n")) + if event.event is not None: + lines.append(f"event: {event.event}") + if event.id is not None: + lines.append(f"id: {event.id}") + if event.retry is not None: + lines.append(f"retry: {event.retry}") + lines.append(format_data_field(event.data, adapters)) + return ("\n".join(lines) + "\n\n").encode("utf-8") + return (format_data_field(event, adapters) + "\n\n").encode("utf-8") + + +def iter_events(source: object, adapters: "AdapterRegistry | None" = None) -> Iterator[bytes]: + """Drive ``source`` (a generator, iterator, or :class:`EventStream`) + into a stream of SSE-encoded event bytes.""" + if isinstance(source, EventStream): + source = source.source + iterator: Iterator[object] + if isinstance(source, Iterator): + iterator = source + elif isinstance(source, Iterable): + iterator = iter(source) + else: + raise TypeError(f"SSE source must be iterable, got {type(source).__name__}") + for item in iterator: + yield encode_event(item, adapters) diff --git a/localpost/scheduler/README.md b/localpost/scheduler/README.md new file mode 100644 index 0000000..bc6d79b --- /dev/null +++ b/localpost/scheduler/README.md @@ -0,0 +1,31 @@ +# localpost.scheduler + +Composable in-process task scheduler. Tasks are triggered by **conditions** +(time intervals, cron expressions, completion of another task), and triggers +are built up with operators — `//` to compose middleware, `>>` to extend a +trigger's own middleware pipeline. Tasks publish `Result[T]` so downstream +tasks can subscribe. + +```bash +pip install localpost[scheduler] # human-readable periods +pip install localpost[scheduler,cron] # also the cron() trigger +``` + +```python +import random +from localpost.hosting import run_app +from localpost.scheduler import every, scheduled_task + + +@scheduled_task(every("3s")) +async def task1(): + return random.randint(1, 22) + + +if __name__ == "__main__": + run_app(task1) +``` + +**Full reference:** + +Examples: [`examples/scheduler/`](../../examples/scheduler/). diff --git a/localpost/scheduler/__init__.py b/localpost/scheduler/__init__.py index a36e44e..e28ba0a 100644 --- a/localpost/scheduler/__init__.py +++ b/localpost/scheduler/__init__.py @@ -1,16 +1,11 @@ -from ._cond import after, every +from ._cond import after, after_all, every from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Scheduler, Task, scheduled_task from ._trigger import delay, take_first, trigger_factory_middleware __all__ = [ - # "TaskHandler", "Task", "ScheduledTaskTemplate", "ScheduledTask", - # "Trigger", - # "TriggerFactory", - # "TriggerFactoryMiddleware", - # "TriggerFactoryDecorator", "Scheduler", "scheduled_task", "trigger_factory_middleware", @@ -18,4 +13,5 @@ "take_first", "every", "after", + "after_all", ] diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index 0a0d601..2eb08fc 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -6,23 +6,24 @@ from collections.abc import Iterable from contextlib import asynccontextmanager from datetime import timedelta -from typing import Any, Generic, TypeVar, final +from typing import Any, final from anyio import ( BrokenResourceError, EndOfStream, WouldBlock, + create_memory_object_stream, create_task_group, get_cancelled_exc_class, ) from localpost._utils import ( TD_ZERO, - ClosingContext, EventView, - MemoryStream, + Result, cancellable_from, ensure_td, + maybe_closing, sleep, start_task_soon, td_str, @@ -30,15 +31,12 @@ from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Task, Trigger -T = TypeVar("T") -ResT = TypeVar("ResT") - logger = logging.getLogger("localpost.scheduler.cond") @asynccontextmanager async def wait_trigger(time_spans: Iterable[timedelta], shutting_down: EventView): - events, events_reader = MemoryStream[None].create(0) + events, events_reader = create_memory_object_stream[None](0) @cancellable_from(shutting_down) # DO NOT cancel the main task group async def generate(): @@ -89,13 +87,12 @@ def every(period: timedelta | str, /) -> ScheduledTaskTemplate[None]: """ Trigger an event every `period`. """ - # return ScheduledTaskTemplate(Every(ensure_td(period))) >> buffer(0, full_mode="drop") return ScheduledTaskTemplate(Every(ensure_td(period))) @final @dc.dataclass(frozen=True, slots=True) -class After(Generic[ResT]): +class After[ResT]: target: Task[Any, ResT] def __repr__(self): @@ -117,11 +114,41 @@ async def run(): finally: task_exec_results.close() - return ClosingContext(run()) + return maybe_closing(run()) -def after(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[T]: +def after[T](target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[T]: """ Trigger an event every time the target task completes successfully. """ return ScheduledTaskTemplate(After(target if isinstance(target, Task) else target.task)) + + +@final +@dc.dataclass(frozen=True, slots=True) +class AfterAll[ResT]: + target: Task[Any, ResT] + + def __repr__(self): + return f"after_all({self.target!r})" + + def __call__(self, this_task: ScheduledTask) -> Trigger[Result[ResT]]: + task_exec_results = self.target.subscribe() + + async def run(): + try: + while True: + yield await task_exec_results.receive() + except EndOfStream: + logger.info("Target task completed, stopping") + finally: + task_exec_results.close() + + return maybe_closing(run()) + + +def after_all[T](target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[Result[T]]: + """ + Trigger an event every time the target task completes (successfully or not). + """ + return ScheduledTaskTemplate(AfterAll(target if isinstance(target, Task) else target.task)) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 8dd2041..f2e06b7 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -4,39 +4,37 @@ import inspect import logging import math -from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, ExitStack -from typing import Any, Generic, Protocol, TypeAlias, TypeVar, Union, cast, final +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, final from anyio import BrokenResourceError, WouldBlock, create_memory_object_stream, to_thread from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( + Event, + EventView, Result, def_full_name, is_async_callable, + wait_all, ) -from localpost.flow import AsyncHandlerManager, FlowHandlerManager, HandlerDecorator, ensure_async_handler_manager -from localpost.hosting import ( - AbstractHost, - ExposedService, - ExposedServiceBase, - HostedService, - HostedServiceSet, - ServiceLifetimeManager, -) +if TYPE_CHECKING: + from localpost.hosting import ServiceLifetime + +# We keep module-level TypeVars (and ``Generic[...]``) for the entire module +# rather than mixing PEP 695 ``class Foo[T]:`` with these TypeVars. ty's +# checker treats them as distinct identifiers — a class declared with +# ``Foo[T]`` does NOT see the module-level ``T`` and ends up with +# ``T@Foo`` vs ``T@module``, which breaks variance inside nested +# generic functions like ``scheduled_task → _decorator``. Once ty supports +# better outer-scope capture, the whole module can move to PEP 695. T = TypeVar("T") T2 = TypeVar("T2") R = TypeVar("R") -DecF = TypeVar("DecF", bound=Callable[..., Any]) -TaskHandler: TypeAlias = Union[ - Callable[[T], Awaitable[R]], - Callable[[], Awaitable[R]], - Callable[[T], R], - Callable[[], R], -] +type TaskHandler[T, R] = Callable[[T], Awaitable[R]] | Callable[[], Awaitable[R]] | Callable[[T], R] | Callable[[], R] logger = logging.getLogger("localpost.scheduler") @@ -44,8 +42,8 @@ @final @dc.dataclass() class Task( - Generic[T, R], - AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], # AsyncHandlerManager[T] + Generic[T, R], # noqa: UP046 + AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], ): name: str event_aware: bool @@ -65,15 +63,19 @@ def e_handler(t) -> Callable[[T], Awaitable[R]]: self._cm = ExitStack() self._subscribers: list[MemoryObjectSendStream[Result[R]]] = [] self._users = 0 + self._finished = False def __repr__(self): return f"<{self.__class__.__name__} {self.name!r}>" def subscribe(self, buffer_max_size: float = math.inf) -> MemoryObjectReceiveStream[Result[R]]: - # By default, a stream is created with a buffer size of 0, which means that any write will be blocked until - # there is a free reader. We do not want to block the task execution flow in any way, so: - # - the buffer is unbounded by default - # - if the buffer is full, the result is dropped (see publish method below) + # ``_publish_result`` always uses ``send_nowait`` and never blocks the task. Defaulting to an + # unbounded buffer means a slow consumer trades memory for never missing a result; pass a finite + # ``buffer_max_size`` to switch to drop-on-full instead (see WouldBlock branch in publish). + if self._finished: + # Task is one-shot: once the last user has exited, the underlying ExitStack is closed and + # cannot be re-entered. A fresh subscriber would silently never receive anything. + raise RuntimeError(f"Cannot subscribe to {self!r}: task has already finished") send_stream, receive_stream = create_memory_object_stream[Result[R]](buffer_max_size) self._subscribers.append(self._cm.enter_context(send_stream)) return receive_stream @@ -90,14 +92,11 @@ def _publish_result(self, result: Result[R]) -> None: # MessageHandler[T] async def __call__(self, event: T) -> None: try: - result = Result.ok(await self._handle(event)) - self._publish_result(result) - except TypeError: - raise + result: Result[R] = Result.ok(await self._handle(event)) except Exception as e: + logger.exception("Task %r raised", self.name) result = Result.failure(e) - self._publish_result(result) - raise + self._publish_result(result) async def __aenter__(self): self._users += 1 @@ -108,12 +107,13 @@ async def __aexit__(self, exc_type, exc_value, traceback) -> bool | None: # A task can be scheduled multiple times, so we need to keep the results streams open until all the scheduled # tasks are completed if self._users == 0: + self._finished = True return self._cm.__exit__(exc_type, exc_value, traceback) return False # Do not suppress exceptions @final -class ScheduledTaskTemplate(Generic[T]): +class ScheduledTaskTemplate[T]: @classmethod def ensure(cls, tpl: TriggerFactory[T]) -> ScheduledTaskTemplate[T]: if isinstance(tpl, cls): @@ -123,14 +123,13 @@ def ensure(cls, tpl: TriggerFactory[T]) -> ScheduledTaskTemplate[T]: def __init__(self, tf: TriggerFactory[T]): self._tf = tf self._tf_queue: tuple[TriggerFactoryDecorator, ...] = () - self._handler_decorators: tuple[HandlerDecorator, ...] = () # TriggerFactory[T] def __call__(self, *args, **kwargs) -> Trigger[T]: return self.tf(*args, **kwargs) def __truediv__(self, middleware: TriggerFactoryMiddleware[T, T2]) -> ScheduledTaskTemplate[T2]: - from ._trigger import trigger_factory_middleware + from ._trigger import trigger_factory_middleware # noqa: PLC0415 return self // trigger_factory_middleware(middleware) @@ -139,19 +138,6 @@ def __floordiv__(self, decorator: TriggerFactoryDecorator[T, T2]) -> ScheduledTa n._tf_queue = self._tf_queue + (decorator,) return cast(ScheduledTaskTemplate[T2], n) - def __rshift__(self, decorator: HandlerDecorator) -> ScheduledTaskTemplate[T]: - n = ScheduledTaskTemplate[T](self._tf) - n._handler_decorators = self._handler_decorators + (decorator,) - return n - - def resolve_handler(self, task: Task[T, Any]) -> AsyncHandlerManager[T]: - if not self._handler_decorators: - return task - hm = FlowHandlerManager(lambda: task) - for decorator in self._handler_decorators: - hm = decorator(hm) - return ensure_async_handler_manager(hm) - @property def tf(self) -> TriggerFactory[T]: tf = self._tf @@ -160,64 +146,65 @@ def tf(self) -> TriggerFactory[T]: return tf -class ScheduledTask(ExposedService, Protocol[T, R]): +class ScheduledTask(Protocol[T, R]): + @property + def shutting_down(self) -> EventView: ... + @property def task(self) -> Task[T, R]: ... @final -class _ScheduledTask(Generic[T, R], ExposedServiceBase): +class _ScheduledTask[T, R]: def __init__(self, task: Task[T, R], tf: TriggerFactory[T]): - super().__init__() self.task = task self._trigger_factory = tf - tpl = ScheduledTaskTemplate.ensure(tf) - self._handler = tpl.resolve_handler(task) + self._shutting_down: EventView = Event() # Placeholder, resolved in run() def __repr__(self): return f"ScheduledTask({self.name!r})" + @property + def shutting_down(self) -> EventView: + return self._shutting_down + @property def name(self) -> str: return self.task.name - async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: - self._lifetime = service_lifetime + async def run(self, shutting_down: EventView) -> None: + self._shutting_down = shutting_down trigger = self._trigger_factory(self) - async with trigger as t_events, self._handler as message_handler: - service_lifetime.set_started() + async with trigger as t_events, self.task as message_handler: async for t_event in t_events: await message_handler(t_event) - logger.debug(f"{self!r} trigger is completed") - logger.debug(f"{self!r} is done") - - -Trigger: TypeAlias = AbstractAsyncContextManager[AsyncIterator[T]] -TriggerFactory: TypeAlias = Callable[ - [ScheduledTask[T, Any]], AbstractAsyncContextManager[AsyncIterator[T]] # Trigger[T] -] -TriggerFactoryMiddleware: TypeAlias = Callable[ - [ - AbstractAsyncContextManager[AsyncIterator[T]], # Trigger[T] (source) - ScheduledTask, - ], - AsyncIterable[T2], # TODO AbstractAsyncContextManager[AsyncIterator[T2]] -] -TriggerFactoryDecorator: TypeAlias = Callable[ - [Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T]]]], # TriggerFactory[T] - Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T2]]], # TriggerFactory[T2] -] - - -def scheduled_task( + logger.debug("%r trigger is completed", self) + logger.debug("%r is done", self) + + async def __call__(self, lt: ServiceLifetime) -> None: + """ServiceF entry point: drives the trigger lifecycle from a hosting lifetime.""" + lt.set_started() + await self.run(lt.shutting_down) + + +type Trigger[T] = AbstractAsyncContextManager[AsyncIterator[T]] +type TriggerFactory[T] = Callable[[ScheduledTask[T, Any]], Trigger[T]] +# Middleware is written as an async generator function: it consumes the source ``Trigger[T]`` (a context +# manager so it can install/clean up a task group) and yields ``T2`` items. ``trigger_factory_middleware`` +# wraps the returned async generator into a ``Trigger[T2]`` via ``maybe_closing``. +type TriggerFactoryMiddleware[T, T2] = Callable[[Trigger[T], ScheduledTask], AsyncIterator[T2]] +type TriggerFactoryDecorator[T, T2] = Callable[[TriggerFactory[T]], TriggerFactory[T2]] + + +def scheduled_task( # noqa: UP047 tf: TriggerFactory[T], /, *, name: str | None = None -) -> Callable[[TaskHandler[T, R] | Task[T, R]], ScheduledTask[T, R]]: +) -> Callable[[TaskHandler[T, R] | Task[T, R]], _ScheduledTask[T, R]]: """ Schedule a task with the given trigger. """ - def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> ScheduledTask[T, R]: + def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> _ScheduledTask[T, R]: t = func if isinstance(func, Task) else Task(func) if name: t.name = name @@ -226,34 +213,28 @@ def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> ScheduledTask[T, R]: return _decorator -class Scheduler(AbstractHost): +class Scheduler: """ - Custom host type, tailored to schedule periodic tasks. - - If you need to combine multiple different services together (like a Kafka consumer and a scheduled task), use the - generic Host instead. + Manages a collection of periodic tasks. ``Scheduler`` is itself a ``ServiceF`` — + pass it to ``localpost.hosting.run_app(...)`` or ``serve(...)`` to run. """ def __init__(self, name: str = "scheduler"): - super().__init__() self._name = name - self._scheduled_tasks: list[ScheduledTask[Any, Any]] = [] + self._scheduled_tasks: list[_ScheduledTask[Any, Any]] = [] @property def name(self) -> str: return self._name - def _prepare_for_run(self) -> HostedService: - return HostedService(HostedServiceSet(*self._scheduled_tasks)) - def task( self, tf: TriggerFactory[T], /, *, name: str | None = None - ) -> Callable[[TaskHandler[T, R] | Task[T, R] | ScheduledTask[T, R]], ScheduledTask[T, R]]: + ) -> Callable[[TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]], _ScheduledTask[T, R]]: """ Schedule a task with the given trigger. """ - def _decorator(func: TaskHandler[T, R] | Task[T, R] | ScheduledTask[T, R]): + def _decorator(func: TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]): if isinstance(func, _ScheduledTask): func = func.task st = scheduled_task(tf, name=name)(cast(TaskHandler[T, R] | Task[T, R], func)) @@ -261,3 +242,19 @@ def _decorator(func: TaskHandler[T, R] | Task[T, R] | ScheduledTask[T, R]): return st return _decorator + + async def __call__(self, lt: ServiceLifetime) -> None: + """ServiceF entry point: starts every registered task as a child service.""" + children = [lt.start(st) for st in self._scheduled_tasks] + lt.set_started() + if not children: + await lt.shutting_down.wait() + return + + async def propagate_shutdown() -> None: + await lt.shutting_down.wait() + for c in children: + c.shutdown() + + lt.tg.start_soon(propagate_shutdown) + await wait_all(c.stopped for c in children) diff --git a/localpost/scheduler/_trigger.py b/localpost/scheduler/_trigger.py index c75e8e9..d22400a 100644 --- a/localpost/scheduler/_trigger.py +++ b/localpost/scheduler/_trigger.py @@ -1,45 +1,44 @@ from __future__ import annotations import logging -from contextlib import AbstractAsyncContextManager from functools import wraps -from typing import TypeVar, cast -from localpost._utils import ClosingContext, DelayFactory, cancellable_from, ensure_delay_factory, sleep, td_str +from localpost._utils import DelayFactory, cancellable_from, ensure_delay_factory, maybe_closing, sleep, td_str from ._scheduler import ScheduledTask, Trigger, TriggerFactory, TriggerFactoryDecorator, TriggerFactoryMiddleware -T = TypeVar("T") -T2 = TypeVar("T2") - logger = logging.getLogger("localpost.scheduler.cond") -def trigger_factory_middleware(middleware: TriggerFactoryMiddleware[T, T2]) -> TriggerFactoryDecorator[T, T2]: +def trigger_factory_middleware[T, T2]( + middleware: TriggerFactoryMiddleware[T, T2], +) -> TriggerFactoryDecorator[T, T2]: + """Adapt an async-generator middleware into a ``TriggerFactoryDecorator``. + + The middleware receives the source ``Trigger[T]`` and yields ``T2`` items; we wrap the resulting + async generator with ``maybe_closing`` so it satisfies the ``Trigger[T2]`` (context manager) shape. + """ + def _decorator(source: TriggerFactory[T]) -> TriggerFactory[T2]: @wraps(source) - def _run(task): - source_events = source(task) - events = middleware(source_events, task) - return cast( - Trigger[T2], events if isinstance(events, AbstractAsyncContextManager) else ClosingContext(events) - ) + def _run(task) -> Trigger[T2]: + return maybe_closing(middleware(source(task), task)) return _run return _decorator -def take_first(n: int, /) -> TriggerFactoryDecorator[T, T]: +def take_first[T](n: int, /) -> TriggerFactoryDecorator[T, T]: if n < 0: raise ValueError("n must be a non-negative integer") @trigger_factory_middleware async def middleware(source: Trigger[T], _): - iter_n = 0 - if iter_n >= n: + if n == 0: return async with source as events: + iter_n = 0 async for event in events: iter_n += 1 yield event @@ -49,7 +48,7 @@ async def middleware(source: Trigger[T], _): return middleware -def delay(value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: +def delay[T](value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: delay_f = ensure_delay_factory(value) @trigger_factory_middleware diff --git a/localpost/threadtools/README.md b/localpost/threadtools/README.md new file mode 100644 index 0000000..d2375a9 --- /dev/null +++ b/localpost/threadtools/README.md @@ -0,0 +1,136 @@ +# `localpost.threadtools` + +Thread-friendly building blocks for moving work off the event loop: + +- [`Channel`](#channel) — a typed, thread-safe queue with `timeout` on `put` / `get` and broadcast-on-close. +- [`Executor`](#executors) — two implementations, one `submit` contract. +- [`Portal`](#portal) — thread-aware view over `anyio.from_thread.BlockingPortal`. +- [`TaskGroup`](#taskgroup) — Trio-style structured concurrency over an `Executor`. +- [`run_async`](#run_async) — sync→async bridge: dispatch a coroutine onto the current service's loop from a worker thread. + +`localpost.threadtools` is built on plain locks; the AnyIO loop is needed only for the `AsyncWorkerExecutor`. + +## Channel + +```python +from localpost.threadtools import Channel + +tx, rx = Channel.create(capacity=8) +tx.put(item, timeout=1.0) # raises TimeoutError on expiry +got = rx.get(timeout=1.0) +got = rx.get_nowait() # raises WouldBlock / EndOfStream +``` + +`capacity=None` is unbounded; `0` is rendezvous (put waits until a receiver consumes); `N>0` is bounded. Both ends can be cloned (`tx.clone()`, `rx.clone()`); closing either side broadcasts to every waiter so cloned receivers all observe `EndOfStream` / `ClosedResourceError`. + +## Executors + +Two implementations share the same `submit(fn, *args, **kwargs) -> Future` contract. Both are spawn-on-demand pools with no cap and no backlog — concurrency is the caller's concern (a Cloud Run-like upstream gate, a consumer-level `Semaphore`, etc.). Both expose `worker_count: int` and a `stop()` method that's safe to call from any thread. Lifecycle and the effect of `stop()` differ: + +| Executor | CM | `stop()` | Loop required | +|-------------------------|--------------|-----------------------------------------------------------------------|---------------| +| `WorkerExecutor` | `with` | Soft close — idle workers exit, busy workers finish their current task| No | +| `AsyncWorkerExecutor` | `async with` | Soft close + cancel scope (per-worker `check_cancelled`) | Yes | + +Both propagate `contextvars.Context` to the task, matching `asyncio.to_thread` / Trio / AnyIO spawn semantics. + +### `WorkerExecutor` — sync, deque + Condition + +Plain `threading.Thread` workers. Lazy spawn: `submit` enqueues onto a shared `deque` under a `threading.Condition`; if no worker is idle, a new one is spawned. Workers live until the executor closes (no idle-timeout self-exit — see [ADR-0005](../../docs/adr/0005-no-idle-timeout-for-worker-pools.md)). No event loop. + +```python +from localpost.threadtools import WorkerExecutor + +with WorkerExecutor() as ex: + fut = ex.submit(work, x) + result = fut.result() +``` + +`stop()` is safe to call from any thread (e.g. a signal handler): it marks the pool closed and wakes idle workers so they exit; busy workers finish their current task and then exit on the next loop iteration. Subsequent `submit` calls raise `RuntimeError`. + +### `AsyncWorkerExecutor` — deque + AnyIO threadlocals + +Same shape as `WorkerExecutor`, but workers run inside `anyio.to_thread.run_sync(..., abandon_on_cancel=False)` so user code can call `anyio.from_thread.check_cancelled`. + +The worker's cancel scope spans its whole lifetime (one worker handles many tasks), so cancellation granularity is **per-worker**, not per-task. Use `stop()` for fast cooperative shutdown. + +```python +from localpost import Portal + +async with anyio.from_thread.BlockingPortal() as raw_portal: + portal = Portal(raw_portal) + async with AsyncWorkerExecutor(portal=portal) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, work, x) + # … + ex.stop() # safe from any thread +``` + +`submit` and `stop` are safe to call from any thread — the wrapping `Portal` does the on-loop / off-loop dispatch internally. + +## TaskGroup + +A thin sync bookkeeping layer over an `Executor`. Tracks `Future`s; on `__exit__` waits for every submitted task and re-raises failures as a `BaseExceptionGroup` (Trio `strict_exception_groups=True` semantics — body and task exceptions are merged into one group). + +```python +from localpost.threadtools import TaskGroup, WorkerExecutor + +with WorkerExecutor() as ex: + with TaskGroup(ex) as tg: + tg.start_soon(do_work, arg) # fire-and-forget + fut = tg.create_task(other_work) # observe via Future + # On exit: drain in-flight tasks; raise BaseExceptionGroup if any failed. +``` + +`TaskGroup` is **collect-and-raise only** — running tasks are not interrupted on the first failure. If you want cooperative cancel, give it an `AsyncWorkerExecutor` so tasks can poll `check_cancelled`. + +## `run_async` + +The reverse of `anyio.to_thread.run_sync` — call from a worker thread to dispatch an async function back onto the loop and wait for its result. + +```python +from localpost.threadtools import run_async + + +async def fetch_user(user_id: int) -> User: + ... + + +def worker(user_id: int) -> str: + user = run_async(fetch_user, user_id) # blocks the worker thread + return user.name +``` + +Resolves the portal via `localpost.hosting.current_service`, so the calling thread must inherit the hosting context (true for any thread spawned through AnyIO or the executors above). Must be called from a non-loop thread; raises `RuntimeError` otherwise (would deadlock the loop). + +## Portal + +`Portal` wraps `anyio.from_thread.BlockingPortal` with loop-thread awareness. Construct it on the loop thread (it snapshots `threading.get_ident()` at creation), then pass it to `AsyncWorkerExecutor` or any code that needs to schedule onto the loop without caring about the calling thread. + +```python +from localpost import Portal + +portal.same_thread # is the current thread the loop thread? +portal.run_sync(fn, *args) # call sync fn on the loop, return its result +portal.run_async(coro, *args) # await coro on the loop, off-loop only +portal.raw # underlying BlockingPortal (escape hatch) +``` + +`run_sync` does the right thing in either direction: direct call on-loop, `BlockingPortal.call` off-loop. `run_async` raises `RuntimeError` on the loop thread instead of deadlocking. + +## Composing with `localpost.hosting` + +`localpost.hosting._serve_root` already runs a single `Portal` for the whole app. It's exposed on the service lifetime view so you can layer an executor on top of it without opening a second portal: + +```python +from localpost import hosting +from localpost.threadtools import AsyncWorkerExecutor + +@hosting.service +async def my_service(): + portal = hosting.current_service().portal + async with AsyncWorkerExecutor(portal=portal) as ex: + # … use ex.submit from worker threads … + yield +``` + +This is how `localpost.http.HttpApp.service` and `localpost.openapi.HttpApp.service` default their internal pool when no `executor=` is passed — handlers automatically get `from_thread.check_cancelled` support. diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py new file mode 100644 index 0000000..deef7d1 --- /dev/null +++ b/localpost/threadtools/__init__.py @@ -0,0 +1,72 @@ +from collections.abc import Awaitable, Callable, Generator +from contextlib import AbstractAsyncContextManager, contextmanager + +from localpost._portal import Portal +from localpost.hosting import current_service + +from ._channel import Channel, ReceiveChannel, SendChannel +from ._executor import ( + AsyncWorkerExecutor, + Executor, + WorkerExecutor, +) +from ._task_group import TaskGroup + +__all__ = [ + "AsyncWorkerExecutor", + "Channel", + "Executor", + "Portal", + "ReceiveChannel", + "SendChannel", + "TaskGroup", + "WorkerExecutor", + "as_sync_cm", + "run_async", +] + + +def run_async[**P, R]( + func: Callable[P, Awaitable[R]], + /, + *args: P.args, + **kwargs: P.kwargs, +) -> R: + """Run an async ``func`` on the current service's loop and return its result. + + Counterpart to :func:`anyio.to_thread.run_sync`: call from a worker thread + to dispatch back into the event loop. Resolves the portal via + :func:`localpost.hosting.current_service`, so the caller's + :class:`contextvars.Context` must carry the hosting context (true for any + thread spawned through AnyIO / the threadtools executors). + + Must be called from a non-loop thread; raises :class:`RuntimeError` + otherwise (would deadlock the loop). + """ + return current_service().portal.run_async(func, *args, **kwargs) + + +@contextmanager +def as_sync_cm[T]( + cm: AbstractAsyncContextManager[T], + /, + *, + portal: Portal | None = None, +) -> Generator[T]: + """Sync view over an async context manager. + + Counterpart to :func:`run_async` for context managers: lets a worker + thread enter an async CM via a portal, transparently dispatching + ``__aenter__`` / ``__aexit__`` onto the loop. Resolves the portal via + :func:`localpost.hosting.current_service` when omitted, so the caller's + :class:`contextvars.Context` must carry the hosting context (true for any + thread spawned through AnyIO / the threadtools executors). + + Must be entered from a non-loop thread; raises :class:`RuntimeError` + otherwise (would deadlock the loop). + """ + p = portal if portal is not None else current_service().portal + if p.same_thread: + raise RuntimeError("Deadlock: synchronous wait on the portal's loop thread") + with p.raw.wrap_async_context_manager(cm) as result: + yield result diff --git a/localpost/threadtools/_channel.py b/localpost/threadtools/_channel.py new file mode 100644 index 0000000..2940820 --- /dev/null +++ b/localpost/threadtools/_channel.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +import dataclasses as dc +import threading +import time +from collections import deque +from collections.abc import Iterator +from types import TracebackType +from typing import Protocol, Self, final, override + +from anyio import ( + ClosedResourceError, + EndOfStream, + WouldBlock, +) + + +@final +class Channel[T]: + @staticmethod + def create(capacity: int | None = None) -> tuple[SendChannel[T], ReceiveChannel[T]]: + """Create a channel sender/receiver pair. + + Args: + capacity: Buffer size. ``None`` means unbounded; ``0`` means rendezvous + (put blocks until a receiver consumes the item); ``N > 0`` means + bounded (put blocks while ``len(buffer) >= N``). + """ + with ChannelState[T](capacity) as state: + state.open_send_channels += 1 + tx = _SendChannel(state) + state.open_receive_channels += 1 + rx = _ReceiveChannel(state) + return tx, rx + + +class ReceiveChannel[T](Protocol): + """Public receive-side of a :class:`Channel`. Concrete instances are produced + by :meth:`Channel.create` and the clone methods; users program against this + Protocol. + """ + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def __iter__(self) -> Iterator[T]: + while True: + try: + yield self.get() + except EndOfStream: + break + + def clone(self) -> ReceiveChannel[T]: ... + + # Raises: + # EndOfStream - all senders closed cleanly and the buffer is drained. + # TimeoutError - ``timeout`` elapsed without an item. + # ClosedResourceError - this receive channel was already closed. + def get(self, timeout: float | None = None) -> T: ... + + def get_nowait(self) -> T: ... + + def close(self) -> None: ... + + +class SendChannel[T](Protocol): + """Public send-side of a :class:`Channel`. Concrete instances are produced + by :meth:`Channel.create` and the clone methods; users program against this + Protocol. + """ + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def clone(self) -> SendChannel[T]: ... + + # Raises: + # TimeoutError - ``timeout`` elapsed without buffer space (or, in rendezvous mode, without + # the item being consumed). + # ClosedResourceError - this send channel was already closed, or no receivers remain. + def put(self, item: T, /, timeout: float | None = None) -> None: ... + + def put_nowait(self, item: T, /) -> None: ... + + def close(self) -> None: ... + + +@final +@dc.dataclass(slots=True) +class ChannelState[T]: + buffer: deque[T] + capacity: int | None + open_send_channels: int + open_receive_channels: int + waiting_receivers: int + pending_handoffs: int + items_consumed: int + _lock: threading.RLock + not_empty: threading.Condition + not_full: threading.Condition + + def __init__(self, capacity: int | None = None): + if capacity is not None and capacity < 0: + raise ValueError("capacity must be >= 0 or None") + self.buffer = deque() + self.capacity = capacity + # capacity semantics: + # None: unbounded + # 0: rendezvous — put blocks until its own item is consumed by a receiver; put_nowait + # succeeds only if an unclaimed waiting receiver is available. With N waiting + # receivers, up to N puts can be in flight concurrently. + # Positive int: bounded (put blocks until len(buffer) < capacity) + self.open_send_channels = 0 + self.open_receive_channels = 0 + self.waiting_receivers = 0 + # Rendezvous bookkeeping (capacity=0 only). ``pending_handoffs`` is a budget counter + # for buffered items already paired with a waiting receiver; ``items_consumed`` is + # monotonic and lets a blocking ``put`` detect consumption of its own item even when + # the buffer holds other in-flight items. + self.pending_handoffs = 0 + self.items_consumed = 0 + self._lock = threading.RLock() + self.not_empty = threading.Condition(self._lock) + self.not_full = threading.Condition(self._lock) + + @property + def can_put(self) -> bool: + if self.open_receive_channels == 0: + raise ClosedResourceError("no more receivers") + if self.capacity == 0: + return self.waiting_receivers > self.pending_handoffs or len(self.buffer) == 0 + return self.capacity is None or len(self.buffer) < self.capacity + + @property + def can_put_nowait(self) -> bool: + if self.open_receive_channels == 0: + raise ClosedResourceError("no more receivers") + if self.capacity == 0: + return self.waiting_receivers > self.pending_handoffs + return self.capacity is None or len(self.buffer) < self.capacity + + def __enter__(self) -> Self: + self._lock.acquire() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + if self.open_send_channels == 0 or self.open_receive_channels == 0: + self.not_empty.notify_all() + self.not_full.notify_all() + self._lock.release() + + +def _deadline(timeout: float | None) -> float | None: + if timeout is None: + return None + if timeout < 0: + raise ValueError("timeout must be >= 0") + return time.monotonic() + timeout + + +def _remaining(deadline: float | None) -> float | None: + if deadline is None: + return None + return deadline - time.monotonic() + + +class _SendChannel[T](SendChannel[T]): + def __init__(self, state: ChannelState[T]) -> None: + self._state = state + self._closed = False + + def __repr__(self) -> str: + return f"" + + @override + def clone(self) -> _SendChannel[T]: + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel is already closed") + state.open_send_channels += 1 + return _SendChannel(state) + + @override + def put_nowait(self, item: T, /) -> None: + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if not state.can_put_nowait: + raise WouldBlock + state.buffer.append(item) + if state.capacity == 0: + # ``can_put_nowait`` already gated on ``waiting_receivers > pending_handoffs``, + # so this is always a real claim. + state.pending_handoffs += 1 + state.not_empty.notify() + + @override + def put(self, item: T, /, timeout: float | None = None) -> None: + state = self._state + deadline = _deadline(timeout) + my_target = 0 + # Phase 1: wait for buffer space. + while True: + with state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if state.can_put: + state.buffer.append(item) + if state.capacity == 0: + if state.waiting_receivers > state.pending_handoffs: + state.pending_handoffs += 1 + # Snapshot a target for Phase 2: consumption of *our* item bumps + # ``items_consumed`` to (at least) this value, regardless of any + # other in-flight items the buffer holds. + my_target = state.items_consumed + len(state.buffer) + state.not_empty.notify() + break + wait_for = _remaining(deadline) + if wait_for is not None and wait_for <= 0: + raise TimeoutError + state.not_full.wait(timeout=wait_for) + + # Phase 2 (rendezvous only): wait until *our* item is consumed. + # On timeout the item stays in the buffer; some later receiver may still consume it. + if state.capacity == 0: + while True: + with state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if state.items_consumed >= my_target: + return # consumed + if state.open_receive_channels == 0: + return # no receivers left + wait_for = _remaining(deadline) + if wait_for is not None and wait_for <= 0: + raise TimeoutError + state.not_full.wait(timeout=wait_for) + + @override + def close(self) -> None: + with self._state as state: + if not self._closed: + self._closed = True + state.open_send_channels -= 1 + # Broadcast: any sender blocked in put()'s Phase 2 needs to re-check; receivers + # blocked in get() need to see open_send_channels==0 and raise EndOfStream. + state.not_empty.notify_all() + state.not_full.notify_all() + + +class _ReceiveChannel[T](ReceiveChannel[T]): + def __init__(self, state: ChannelState[T]) -> None: + self._state = state + self._closed = False + + def __repr__(self) -> str: + return f"" + + @override + def clone(self) -> _ReceiveChannel[T]: + with self._state as state: + if self._closed: + raise ClosedResourceError("receive channel is already closed") + state.open_receive_channels += 1 + return _ReceiveChannel(state) + + def _take(self, state: ChannelState[T]) -> T: + item = state.buffer.popleft() + if state.capacity == 0: + state.items_consumed += 1 + if state.pending_handoffs > 0: + state.pending_handoffs -= 1 + state.not_full.notify() + return item + + @override + def get_nowait(self) -> T: + with self._state as state: + if self._closed: + raise ClosedResourceError("receive channel has been closed") + if state.buffer: + return self._take(state) + if state.open_send_channels == 0: + raise EndOfStream("no more senders") + raise WouldBlock + + @override + def get(self, timeout: float | None = None) -> T: + state = self._state + deadline = _deadline(timeout) + while True: + with state: + if self._closed: + raise ClosedResourceError("receive channel has been closed") + if state.buffer: + return self._take(state) + if state.open_send_channels == 0: + raise EndOfStream("no more senders") + wait_for = _remaining(deadline) + if wait_for is not None and wait_for <= 0: + raise TimeoutError + state.waiting_receivers += 1 + try: + state.not_empty.wait(timeout=wait_for) + finally: + state.waiting_receivers -= 1 + + @override + def close(self) -> None: + with self._state as state: + if not self._closed: + self._closed = True + state.open_receive_channels -= 1 + # Broadcast: any thread blocked in get() on this rx needs to see _closed and + # raise; senders blocked in put() need to see open_receive_channels==0. + state.not_empty.notify_all() + state.not_full.notify_all() diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py new file mode 100644 index 0000000..19daa24 --- /dev/null +++ b/localpost/threadtools/_executor.py @@ -0,0 +1,300 @@ +"""Thread-management primitives for ``localpost.threadtools``. + +Two flavors of executor, each with the same ``submit`` contract but +different lifecycle and cancellation properties: + +* :class:`WorkerExecutor` — sync context manager. Spawn-on-demand pool of + plain ``threading.Thread`` workers backed by a ``deque`` and a + :class:`threading.Condition`. No event loop required. +* :class:`AsyncWorkerExecutor` — async context manager. Same shape, but + workers run inside ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` + so user code can call :func:`anyio.from_thread.check_cancelled`. The + cancel scope of each worker spans its whole lifetime (many tasks reuse + the same worker), so cancellation granularity is *per-worker*, not + per-task. + +Both propagate :class:`contextvars.Context` to the task, matching +:func:`asyncio.to_thread` / Trio / AnyIO spawn semantics. Both expose +:meth:`stop` (safe from any thread) for cooperative shutdown — the sync +variant only wakes idle workers and rejects new submits; the async +variant additionally cancels its internal task group so tasks polling +:func:`anyio.from_thread.check_cancelled` raise. + +There is no cap on the number of workers and no backlog — concurrency is +the caller's concern (a Cloud Run-like upstream gate, a consumer-level +``Semaphore``, etc.). Workers are spawned lazily as submissions arrive +with no idle worker available, and live until the executor closes; see +``docs/adr/0005-no-idle-timeout-for-worker-pools.md`` for the rationale. + +The async variant takes a caller-owned :class:`localpost.Portal` and runs +its internal :class:`anyio.abc.TaskGroup` on its loop. +""" + +from __future__ import annotations + +import contextvars +import threading +from collections import deque +from collections.abc import Callable +from concurrent.futures import Future +from dataclasses import dataclass, field +from types import TracebackType +from typing import Any, Protocol, Self, final, runtime_checkable + +from anyio import create_task_group, to_thread + +from localpost._portal import Portal + + +@runtime_checkable +class Executor(Protocol): + """Minimal executor contract: just :meth:`submit`. + + Lifecycle (sync ``with`` vs ``async with``) is implementation-specific — + callers depend only on the submit shape. + """ + + def submit[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> Future[R]: ... + + +@dataclass(eq=False, slots=True) +class Task: + """A single submission carried through the executor pipeline. + + The caller's :class:`contextvars.Context` is snapshotted at construction; + the task runs inside that copy so mutations don't leak back. + """ + + fn: Callable[..., Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] + context: contextvars.Context = field(default_factory=contextvars.copy_context) + future: Future[Any] = field(default_factory=Future) + + def run(self) -> None: + if not self.future.set_running_or_notify_cancel(): + return # Future was cancelled while queued + try: + result = self.context.run(self.fn, *self.args, **self.kwargs) + except BaseException as exc: # noqa: BLE001 + self.future.set_exception(exc) + else: + self.future.set_result(result) + + +# -------------------------------------------------------------------------- +# Shared queue + spawn-on-demand protocol +# -------------------------------------------------------------------------- + + +class _WorkerPoolBase: + """Internal base: deque + condition queue protocol shared by the + executors. Subclasses provide :meth:`_spawn_worker` and own their + own context-manager lifecycle. + + The protocol is: ``submit`` enqueues onto ``_queue`` under ``_cond``; + if no worker is idle, it asks the subclass to spawn one. + ``_run_worker`` is the worker's loop — wait for work, pop, run, + repeat. ``stop`` marks the pool closed and wakes idle workers; busy + workers exit on their next loop iteration. + """ + + def __init__(self) -> None: + self._cond = threading.Condition() + self._queue: deque[Task] = deque() + self._idle = 0 + self._worker_count = 0 + self._opened = False + self._closed = False + + @property + def worker_count(self) -> int: + return self._worker_count + + def submit[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> Future[R]: + task = Task(fn, args, kwargs) + with self._cond: + if not self._opened or self._closed: + raise RuntimeError(f"{type(self).__name__} is not open") + self._queue.append(task) + if self._idle == 0: + self._spawn_worker() + self._worker_count += 1 + self._cond.notify() + return task.future + + def stop(self) -> None: + """Cooperatively shut down: mark closed and wake idle workers. + Safe from any thread. Idempotent. + + Idle workers wake from the condition and exit; busy workers + finish their current task and exit on the next loop iteration. + Subsequent ``submit`` calls raise :class:`RuntimeError`. + """ + if not self._opened: + return + self._mark_closed() + + def _mark_closed(self) -> bool: + """Mark closed under the condition; notify all waiters. Returns True + iff this call was the one that closed the pool. + """ + with self._cond: + if self._closed: + return False + self._closed = True + self._cond.notify_all() + return True + + def _spawn_worker(self) -> None: + raise NotImplementedError + + def _run_worker(self) -> None: + while True: + with self._cond: + self._idle += 1 + while not self._queue and not self._closed: + self._cond.wait() + self._idle -= 1 + if not self._queue: + return # closed and drained + task = self._queue.popleft() + task.run() + + +# -------------------------------------------------------------------------- +# WorkerExecutor — sync, plain threads +# -------------------------------------------------------------------------- + + +@final +class WorkerExecutor(_WorkerPoolBase): + """Spawn-on-demand pool of plain ``threading.Thread`` workers. + + No event loop required. There is no cap and no backlog — + concurrency is the caller's concern. Once spawned, a worker lives + until the executor closes (via ``__exit__`` or :meth:`stop`); see + ``docs/adr/0005-no-idle-timeout-for-worker-pools.md``. + """ + + def __init__(self, *, thread_name_prefix: str = "lp-worker") -> None: + super().__init__() + self._thread_name_prefix = thread_name_prefix + self._workers: list[threading.Thread] = [] + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("WorkerExecutor cannot be reused") + self._opened = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + del exc_type, exc, tb + self._mark_closed() + for t in list(self._workers): + t.join() + + def _spawn_worker(self) -> None: + # ``_worker_count`` is the about-to-be index — base increments after we return. + wid = self._worker_count + t = threading.Thread( + target=self._run_worker, + name=f"{self._thread_name_prefix}-{wid}", + daemon=True, + ) + self._workers.append(t) + t.start() + + +# -------------------------------------------------------------------------- +# AsyncWorkerExecutor — AnyIO-backed worker threads +# -------------------------------------------------------------------------- + + +@final +class AsyncWorkerExecutor(_WorkerPoolBase): + """Like :class:`WorkerExecutor`, but workers run via + ``anyio.to_thread.run_sync`` so user code can call + :func:`anyio.from_thread.check_cancelled`. + + Async context manager. The internal :class:`anyio.abc.TaskGroup` runs on + the loop the ``portal`` is attached to and hosts every worker's + ``host_task``. ``portal`` is required and caller-owned. + + Cancel granularity is per-worker (cancel scope spans the worker's whole + lifetime, which serves many tasks). :meth:`stop` cancels the internal + task group and wakes idle workers; active tasks see their next + ``check_cancelled`` raise. + """ + + def __init__(self, *, portal: Portal) -> None: + super().__init__() + self._portal = portal + # Constructed eagerly: callers always instantiate inside ``async with`` on the + # portal's loop (the only loop ``tg.start_soon`` can target via ``portal``), so + # ``get_async_backend()`` is satisfied. Loop-affinity is set by ``__aenter__``. + self._tg = create_task_group() + + @property + def portal(self) -> Portal: + return self._portal + + async def __aenter__(self) -> Self: + if self._closed: + raise RuntimeError("AsyncWorkerExecutor cannot be reused") + await self._tg.__aenter__() + self._opened = True + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Invariant: ``_mark_closed`` must run synchronously before any await. + # It wakes idle workers (via ``cond.notify_all``) so their ``to_thread`` + # awaits can resolve. If an await sneaks in before this, an outer-scope + # cancellation hitting that await would leave workers stuck in + # ``cond.wait`` forever (``abandon_on_cancel=False`` waits for the + # thread to return naturally). Guarded by + # ``test_async_worker_executor_cancel_unblocks_idle_workers``. + self._mark_closed() + await self._tg.__aexit__(exc_type, exc, tb) + + def stop(self) -> None: + """Cooperatively shut down: cancel the internal task group and wake + idle workers. Safe from any thread. Idempotent. + + Idle workers wake from the condition; active tasks see their next + :func:`anyio.from_thread.check_cancelled` raise. Tasks that don't poll + run to natural completion. + """ + if not self._opened: + return + if self._mark_closed(): + self._portal.run_sync(self._tg.cancel_scope.cancel) + + def _spawn_worker(self) -> None: + async def host_task() -> None: + await to_thread.run_sync(self._run_worker, abandon_on_cancel=False) + + # Schedule the host task into our internal task group from any thread. + self._portal.run_sync(self._tg.start_soon, host_task) diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py new file mode 100644 index 0000000..261495b --- /dev/null +++ b/localpost/threadtools/_task_group.py @@ -0,0 +1,139 @@ +"""A thread-friendly task group that owns a logical set of submissions to an +:class:`Executor`. + +The group enforces structured concurrency: every task started inside a +``with TaskGroup(executor) as tg:`` block must complete before the block +exits. Failures are collected and surfaced as a :class:`BaseExceptionGroup`. + +There is no cancel signal — running tasks are not interrupted on the first +failure. Use an executor that propagates AnyIO cancellation +(:class:`AsyncWorkerExecutor`) and have your tasks poll +:func:`anyio.from_thread.check_cancelled` if cooperative cancel is needed +in your application. +""" + +from __future__ import annotations + +import threading +from collections.abc import Awaitable, Callable +from concurrent.futures import ALL_COMPLETED, Future, wait +from types import TracebackType +from typing import Any, Self, final, overload + +from ._executor import Executor + + +@final +class TaskGroup: + """Trio-style task group backed by a user-supplied :class:`Executor`. + + Tasks are submitted via :meth:`start_soon` (fire-and-forget) or + :meth:`create_task` (returns a :class:`~concurrent.futures.Future`). + The group is a sync context manager; on exit it waits for every + in-flight task — including ones spawned recursively from inside other + tasks — to settle, then re-raises any failures wrapped in a + :class:`BaseExceptionGroup` (Trio ``strict_exception_groups=True`` + semantics: a body exception and task exceptions are merged into one + group). + + ``contextvars`` propagation, thread-of-execution, and concurrency + limits are the executor's concern. The task group is a thin + bookkeeping layer. + """ + + def __init__(self, executor: Executor, *, name: str | None = None) -> None: + self._executor = executor + self._name = name + self._lock = threading.Lock() + # ``_tasks`` holds every Future the group has produced. Successful tasks + # are removed by ``_on_done``; failed / cancelled tasks remain so we can + # collect their exceptions at exit. + self._tasks: list[Future[Any]] = [] + self._opened = False + self._closed = False + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("TaskGroup cannot be reused") + self._opened = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Drain. Tasks may spawn more tasks; loop until no pending remain. + while True: + with self._lock: + pending = [t for t in self._tasks if not t.done()] + if not pending: + break + wait(pending, return_when=ALL_COMPLETED) + + with self._lock: + self._closed = True + failed = list(self._tasks) + + all_errors: list[BaseException] = [] + for f in failed: + if f.cancelled(): + continue + e = f.exception() + if e is not None: + all_errors.append(e) + # Dedup body exception by identity, prepended to preserve task-completion + # order for everything else. + if exc is not None and all(e is not exc for e in all_errors): + all_errors.insert(0, exc) + if all_errors: + label = f"TaskGroup {self._name!r} failed" if self._name else "TaskGroup failed" + raise BaseExceptionGroup(label, all_errors) + + @overload + def start_soon[**P](self, fn: Callable[P, Awaitable[Any]], /, *args: P.args, **kwargs: P.kwargs) -> None: ... + @overload + def start_soon[**P](self, fn: Callable[P, Any], /, *args: P.args, **kwargs: P.kwargs) -> None: ... + def start_soon(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> None: + """Submit ``fn(*args, **kwargs)`` to the executor. Fire-and-forget. + + Errors still surface via the :class:`BaseExceptionGroup` raised at + ``__exit__``. Use :meth:`create_task` if you need to observe the + task's outcome via a :class:`~concurrent.futures.Future`. + """ + self.create_task(fn, *args, **kwargs) + + @overload + def create_task[**P, R](self, fn: Callable[P, Awaitable[R]], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: ... + @overload + def create_task[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: ... + def create_task(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Future[Any]: + """Submit ``fn(*args, **kwargs)``. Returns the executor's future. + + Reading ``Future.exception()`` does *not* suppress the + :class:`BaseExceptionGroup` raised at ``__exit__`` — body code that + re-raises a task exception will see it deduped (by identity) inside + the group. + """ + with self._lock: + if self._closed: + raise RuntimeError("TaskGroup is closed") + if not self._opened: + raise RuntimeError("TaskGroup must be entered before submitting tasks") + fut = self._executor.submit(fn, *args, **kwargs) + with self._lock: + self._tasks.append(fut) + fut.add_done_callback(self._on_done) + return fut + + def _on_done(self, f: Future[Any]) -> None: + if f.cancelled(): + return + if f.exception() is not None: + return # keep in ``_tasks`` for collection at __exit__ + with self._lock: + try: + self._tasks.remove(f) + except ValueError: + pass diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..fbd34d0 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,70 @@ +site_name: LocalPost +site_description: >- + A small async Python framework for long-running processes: service hosting, + in-process task scheduling, and a lightweight HTTP server. +site_url: https://alexeyshockov.github.io/localpost.py/ +repo_url: https://github.com/alexeyshockov/localpost.py +repo_name: alexeyshockov/localpost.py +edit_uri: edit/main/docs/ + +docs_dir: docs + +theme: + name: material + features: + - navigation.sections + - navigation.top + - navigation.tracking + - content.code.copy + - content.action.edit + - search.suggest + - search.highlight + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + toggle: + icon: material/weather-sunny + name: Switch to light mode + +plugins: + - search + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - pymdownx.details + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - toc: + permalink: true + +nav: + - Home: index.md + - Getting started: getting-started.md + - Modules: + - hosting: modules/hosting.md + - scheduler: modules/scheduler.md + - http: modules/http.md + - di: modules/di.md + - openapi: modules/openapi.md + - Design notes: + - Connection model: design/connection-model.md + - Threading topologies: design/threading-topologies.md + - Server backends: design/server-backends.md + - Request body handling: design/request-body-handling.md + - Deployment topologies: design/deployment-topologies.md + - ADRs: + - Index: adr/index.md + - 0001 — AnyIO as async runtime: adr/0001-anyio-as-async-runtime.md + - 0002 — h11 + httptools coexist: adr/0002-h11-httptools-coexist.md + - 0003 — Sync-only native HTTP server: adr/0003-sync-native-http-server.md + - 0004 — Pull-based disconnect detection: adr/0004-pull-based-disconnect-detection.md diff --git a/pdm.lock b/pdm.lock deleted file mode 100644 index ec42cb6..0000000 --- a/pdm.lock +++ /dev/null @@ -1,2726 +0,0 @@ -# This file is @generated by PDM. -# It is not intended for manual editing. - -[metadata] -groups = ["default", "cron", "dev", "dev-consumers", "dev-otel", "dev-types", "examples", "grpc", "http", "integration-tests", "kafka", "nats", "rabbitmq", "scheduler", "sqs", "tests", "unit-tests"] -strategy = ["inherit_metadata"] -lock_version = "4.5.0" -content_hash = "sha256:e81209488e404ddd541f07180a6ce3343eea258fcd615c45b0f908bdb6b3d0fd" - -[[metadata.targets]] -requires_python = ">=3.10" - -[[package]] -name = "aio-pika" -version = "9.5.5" -requires_python = "<4.0,>=3.9" -summary = "Wrapper around the aiormq for asyncio and humans" -groups = ["rabbitmq"] -dependencies = [ - "aiormq<6.9,>=6.8", - "exceptiongroup<2,>=1", - "typing-extensions; python_version < \"3.10\"", - "yarl", -] -files = [ - {file = "aio_pika-9.5.5-py3-none-any.whl", hash = "sha256:94e0ac3666398d6a28b0c3b530c1febf4c6d4ececb345620727cfd7bfe1c02e0"}, - {file = "aio_pika-9.5.5.tar.gz", hash = "sha256:3d2f25838860fa7e209e21fc95555f558401f9b49a832897419489f1c9e1d6a4"}, -] - -[[package]] -name = "aiobotocore" -version = "2.23.0" -requires_python = ">=3.9" -summary = "Async client for aws services using botocore and aiohttp" -groups = ["sqs"] -dependencies = [ - "aiohttp<4.0.0,>=3.9.2", - "aioitertools<1.0.0,>=0.5.1", - "botocore<1.38.28,>=1.38.23", - "jmespath<2.0.0,>=0.7.1", - "multidict<7.0.0,>=6.0.0", - "python-dateutil<3.0.0,>=2.1", - "wrapt<2.0.0,>=1.10.10", -] -files = [ - {file = "aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:8202cebbf147804a083a02bc282fbfda873bfdd0065fd34b64784acb7757b66e"}, - {file = "aiobotocore-2.23.0.tar.gz", hash = "sha256:0333931365a6c7053aee292fe6ef50c74690c4ae06bb019afdf706cb6f2f5e32"}, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -requires_python = ">=3.9" -summary = "Happy Eyeballs for asyncio" -groups = ["sqs"] -files = [ - {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, - {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, -] - -[[package]] -name = "aiohttp" -version = "3.12.13" -requires_python = ">=3.9" -summary = "Async http client/server framework (asyncio)" -groups = ["sqs"] -dependencies = [ - "aiohappyeyeballs>=2.5.0", - "aiosignal>=1.1.2", - "async-timeout<6.0,>=4.0; python_version < \"3.11\"", - "attrs>=17.3.0", - "frozenlist>=1.1.1", - "multidict<7.0,>=4.5", - "propcache>=0.2.0", - "yarl<2.0,>=1.17.0", -] -files = [ - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6"}, - {file = "aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad"}, - {file = "aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3"}, - {file = "aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd"}, - {file = "aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5"}, - {file = "aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf"}, - {file = "aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3"}, - {file = "aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd"}, - {file = "aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706"}, - {file = "aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce"}, -] - -[[package]] -name = "aioitertools" -version = "0.12.0" -requires_python = ">=3.8" -summary = "itertools and builtins for AsyncIO and mixed iterables" -groups = ["sqs"] -dependencies = [ - "typing-extensions>=4.0; python_version < \"3.10\"", -] -files = [ - {file = "aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796"}, - {file = "aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b"}, -] - -[[package]] -name = "aiormq" -version = "6.8.1" -requires_python = "<4.0,>=3.8" -summary = "Pure python AMQP asynchronous client library" -groups = ["rabbitmq"] -dependencies = [ - "pamqp==3.3.0", - "setuptools; python_version < \"3.8\"", - "yarl", -] -files = [ - {file = "aiormq-6.8.1-py3-none-any.whl", hash = "sha256:5da896c8624193708f9409ffad0b20395010e2747f22aa4150593837f40aa017"}, - {file = "aiormq-6.8.1.tar.gz", hash = "sha256:a964ab09634be1da1f9298ce225b310859763d5cf83ef3a7eae1a6dc6bd1da1a"}, -] - -[[package]] -name = "aiosignal" -version = "1.3.2" -requires_python = ">=3.9" -summary = "aiosignal: a list of registered asynchronous callbacks" -groups = ["sqs"] -dependencies = [ - "frozenlist>=1.1.0", -] -files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -requires_python = ">=3.8" -summary = "Reusable constraint types to use with typing.Annotated" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.0.0; python_version < \"3.9\"", -] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.9.0" -requires_python = ">=3.9" -summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["default", "dev-consumers", "examples", "tests"] -dependencies = [ - "exceptiongroup>=1.0.2; python_version < \"3.11\"", - "idna>=2.8", - "sniffio>=1.1", - "typing-extensions>=4.5; python_version < \"3.13\"", -] -files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, -] - -[[package]] -name = "anyio" -version = "4.9.0" -extras = ["test"] -requires_python = ">=3.9" -summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["tests"] -dependencies = [ - "anyio==4.9.0", - "anyio[trio]", - "blockbuster>=1.5.23", - "coverage[toml]>=7", - "exceptiongroup>=1.2.0", - "hypothesis>=4.0", - "psutil>=5.9", - "pytest>=7.0", - "trustme", - "truststore>=0.9.1; python_version >= \"3.10\"", - "uvloop>=0.21; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\"", -] -files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, -] - -[[package]] -name = "anyio" -version = "4.9.0" -extras = ["trio"] -requires_python = ">=3.9" -summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["tests"] -dependencies = [ - "anyio==4.9.0", - "trio>=0.26.1", -] -files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, -] - -[[package]] -name = "asttokens" -version = "3.0.0" -requires_python = ">=3.8" -summary = "Annotate AST trees with source code positions" -groups = ["dev"] -files = [ - {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, - {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -requires_python = ">=3.8" -summary = "Timeout context manager for asyncio programs" -groups = ["sqs"] -marker = "python_version < \"3.11\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - -[[package]] -name = "attrs" -version = "25.3.0" -requires_python = ">=3.8" -summary = "Classes Without Boilerplate" -groups = ["dev-consumers", "sqs", "tests"] -files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, -] - -[[package]] -name = "authlib" -version = "1.6.0" -requires_python = ">=3.9" -summary = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -groups = ["dev-consumers"] -dependencies = [ - "cryptography", -] -files = [ - {file = "authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d"}, - {file = "authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210"}, -] - -[[package]] -name = "aws-lambda-powertools" -version = "3.15.1" -requires_python = "<4.0.0,>=3.9" -summary = "Powertools for AWS Lambda (Python) is a developer toolkit to implement Serverless best practices and increase developer velocity." -groups = ["dev-consumers"] -dependencies = [ - "jmespath<2.0.0,>=1.0.1", - "typing-extensions<5.0.0,>=4.11.0", -] -files = [ - {file = "aws_lambda_powertools-3.15.1-py3-none-any.whl", hash = "sha256:673ccfb27087d2940e798b2618e9cb992981ccb1d0310bc17c4d1c69ebcbec11"}, - {file = "aws_lambda_powertools-3.15.1.tar.gz", hash = "sha256:31c19887e012287da539f8c319da04e3f50c5f37d7b7ea1f0cd1c011f7ff05a8"}, -] - -[[package]] -name = "blockbuster" -version = "1.5.24" -requires_python = ">=3.8" -summary = "Utility to detect blocking calls in the async event loop" -groups = ["tests"] -dependencies = [ - "forbiddenfruit>=0.1.4; implementation_name == \"cpython\"", -] -files = [ - {file = "blockbuster-1.5.24-py3-none-any.whl", hash = "sha256:e703497b55bc72af09d60d1cd746c2f3ba7ce0c446fa256be6ccda5e7d403520"}, - {file = "blockbuster-1.5.24.tar.gz", hash = "sha256:97645775761a5d425666ec0bc99629b65c7eccdc2f770d2439850682567af4ec"}, -] - -[[package]] -name = "boto3" -version = "1.38.27" -requires_python = ">=3.9" -summary = "The AWS SDK for Python" -groups = ["integration-tests"] -dependencies = [ - "botocore<1.39.0,>=1.38.27", - "jmespath<2.0.0,>=0.7.1", - "s3transfer<0.14.0,>=0.13.0", -] -files = [ - {file = "boto3-1.38.27-py3-none-any.whl", hash = "sha256:95f5fe688795303a8a15e8b7e7f255cadab35eae459d00cc281a4fd77252ea80"}, - {file = "boto3-1.38.27.tar.gz", hash = "sha256:94bd7fdd92d5701b362d4df100d21e28f8307a67ff56b6a8b0398119cf22f859"}, -] - -[[package]] -name = "botocore" -version = "1.38.27" -requires_python = ">=3.9" -summary = "Low-level, data-driven core of boto 3." -groups = ["integration-tests", "sqs"] -dependencies = [ - "jmespath<2.0.0,>=0.7.1", - "python-dateutil<3.0.0,>=2.1", - "urllib3!=2.2.0,<3,>=1.25.4; python_version >= \"3.10\"", - "urllib3<1.27,>=1.25.4; python_version < \"3.10\"", -] -files = [ - {file = "botocore-1.38.27-py3-none-any.whl", hash = "sha256:a785d5e9a5eda88ad6ab9ed8b87d1f2ac409d0226bba6ff801c55359e94d91a8"}, - {file = "botocore-1.38.27.tar.gz", hash = "sha256:9788f7efe974328a38cbade64cc0b1e67d27944b899f88cb786ae362973133b6"}, -] - -[[package]] -name = "botocore-stubs" -version = "1.38.30" -requires_python = ">=3.8" -summary = "Type annotations and code completion for botocore" -groups = ["dev-types"] -dependencies = [ - "types-awscrt", -] -files = [ - {file = "botocore_stubs-1.38.30-py3-none-any.whl", hash = "sha256:2efb8bdf36504aff596c670d875d8f7dd15205277c15c4cea54afdba8200c266"}, - {file = "botocore_stubs-1.38.30.tar.gz", hash = "sha256:291d7bf39a316c00a8a55b7255489b02c0cea1a343482e7784e8d1e235bae995"}, -] - -[[package]] -name = "cachetools" -version = "6.1.0" -requires_python = ">=3.9" -summary = "Extensible memoizing collections and decorators" -groups = ["dev-consumers"] -files = [ - {file = "cachetools-6.1.0-py3-none-any.whl", hash = "sha256:1c7bb3cf9193deaf3508b7c5f2a79986c13ea38965c5adcff1f84519cf39163e"}, - {file = "cachetools-6.1.0.tar.gz", hash = "sha256:b4c4f404392848db3ce7aac34950d17be4d864da4b8b66911008e430bc544587"}, -] - -[[package]] -name = "certifi" -version = "2025.6.15" -requires_python = ">=3.7" -summary = "Python package for providing Mozilla's CA Bundle." -groups = ["dev-consumers", "dev-otel", "integration-tests"] -files = [ - {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, - {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, -] - -[[package]] -name = "cffi" -version = "1.17.1" -requires_python = ">=3.8" -summary = "Foreign Function Interface for Python calling C code." -groups = ["dev-consumers", "tests"] -marker = "os_name == \"nt\" and implementation_name != \"pypy\" or platform_python_implementation != \"PyPy\"" -dependencies = [ - "pycparser", -] -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.2" -requires_python = ">=3.7" -summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -groups = ["dev-otel", "integration-tests"] -files = [ - {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, - {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, - {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, -] - -[[package]] -name = "click" -version = "8.2.1" -requires_python = ">=3.10" -summary = "Composable command line interface toolkit" -groups = ["http"] -dependencies = [ - "colorama; platform_system == \"Windows\"", -] -files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -summary = "Cross-platform colored terminal text." -groups = ["dev", "http", "tests", "unit-tests"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "confluent-kafka" -version = "2.10.1" -requires_python = ">=3.7" -summary = "Confluent's Python client for Apache Kafka" -groups = ["dev-consumers", "kafka"] -files = [ - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ad6a9ba5aba014b3a45d560edccd8864be5cc4a9402ba8520201b18c18b7c899"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:682b67ec638142fe090e71f76d698484d3199e70b67474e16938c196e635fa4b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:2b7e85876c8b714e20a54cdc313509767885d00b9ced3cbff2285d881853151b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f3aec168ec1817d89102e0d517f8523fc7efbf0058859dcf5aa42cd9d99a12fa"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:95d72e1b9576bc4cd269dbba0dda65047d316ff2fab84c88aa3417f2a3aae0b6"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:147c83ea89b0e93ecc7dac6d42eebc4fc541cfb1f0f7d8ab05fdda76b7d7adcc"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:33c2ca92920d0c7e5079f9814523ed709b16d369ab0cca1941abc72b703a8c59"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0addf6fe866ade4d13f94057a6908dff7ddec7d68002a3f6b596bc72d975ed8b"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f17b45fcdecf0d58c584d5ae95257078759796aaed73ebbe1d32431228c50467"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:fdd032a8c7637263defcf577b36d1d4dba6495877e49662e4af4cc15e6950c77"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d5303e42f94fc044e27e0224476173fe454d039f7cad5011bae5775086161c23"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a277eec260fd048cfcc32a95da9e14e0701d7c04b276dcc79de7f09fdfa74235"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c4282213d9066ba37d5508d01c1e1e63963f4ea2273d918ebcf8990311347ad"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:08aef1602a5dd2fc448dac5009b345d93304d8ab53640c8a63f06714f4420294"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:352152e0562a5feb2fa4ec4f91b801fed56695f5508946ff084aa23b4495b201"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:325b774ebf37d5d5e7fdcda58bea2925c5bf536da365db0e80e4e3e1d1e4b748"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:d2190269874554e5b2dd8b8d08968d3f205107fbe58c7199bde7c89aabeabff2"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3a10d099efbdddd58d71172db50de784991c4f2d47a55c09e5023e943aef88bf"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:deb4d95114ada2020abc24b48c3861dd96f164955652e7bd9f9b8e84b257c03a"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:b8040e89e89d462331fc5c46d6f6b79d22f1b9482afc8005776029965b86b41c"}, - {file = "confluent_kafka-2.10.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a7724d6fb4526240980fcae7900c62ffb93ce5c5926a4e19133ec79e26558b61"}, - {file = "confluent_kafka-2.10.1.tar.gz", hash = "sha256:536c76f6c39a93c367a70016781cf7e73808300228b8e33b444c846fdef801e4"}, -] - -[[package]] -name = "confluent-kafka" -version = "2.10.1" -extras = ["protobuf", "schemaregistry"] -requires_python = ">=3.7" -summary = "Confluent's Python client for Apache Kafka" -groups = ["dev-consumers"] -dependencies = [ - "attrs", - "attrs", - "authlib>=1.0.0", - "authlib>=1.0.0", - "cachetools", - "cachetools", - "confluent-kafka==2.10.1", - "googleapis-common-protos", - "httpx>=0.26", - "httpx>=0.26", - "protobuf", -] -files = [ - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ad6a9ba5aba014b3a45d560edccd8864be5cc4a9402ba8520201b18c18b7c899"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:682b67ec638142fe090e71f76d698484d3199e70b67474e16938c196e635fa4b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:2b7e85876c8b714e20a54cdc313509767885d00b9ced3cbff2285d881853151b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f3aec168ec1817d89102e0d517f8523fc7efbf0058859dcf5aa42cd9d99a12fa"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:95d72e1b9576bc4cd269dbba0dda65047d316ff2fab84c88aa3417f2a3aae0b6"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:147c83ea89b0e93ecc7dac6d42eebc4fc541cfb1f0f7d8ab05fdda76b7d7adcc"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:33c2ca92920d0c7e5079f9814523ed709b16d369ab0cca1941abc72b703a8c59"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0addf6fe866ade4d13f94057a6908dff7ddec7d68002a3f6b596bc72d975ed8b"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f17b45fcdecf0d58c584d5ae95257078759796aaed73ebbe1d32431228c50467"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:fdd032a8c7637263defcf577b36d1d4dba6495877e49662e4af4cc15e6950c77"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d5303e42f94fc044e27e0224476173fe454d039f7cad5011bae5775086161c23"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a277eec260fd048cfcc32a95da9e14e0701d7c04b276dcc79de7f09fdfa74235"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c4282213d9066ba37d5508d01c1e1e63963f4ea2273d918ebcf8990311347ad"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:08aef1602a5dd2fc448dac5009b345d93304d8ab53640c8a63f06714f4420294"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:352152e0562a5feb2fa4ec4f91b801fed56695f5508946ff084aa23b4495b201"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:325b774ebf37d5d5e7fdcda58bea2925c5bf536da365db0e80e4e3e1d1e4b748"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:d2190269874554e5b2dd8b8d08968d3f205107fbe58c7199bde7c89aabeabff2"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3a10d099efbdddd58d71172db50de784991c4f2d47a55c09e5023e943aef88bf"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:deb4d95114ada2020abc24b48c3861dd96f164955652e7bd9f9b8e84b257c03a"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:b8040e89e89d462331fc5c46d6f6b79d22f1b9482afc8005776029965b86b41c"}, - {file = "confluent_kafka-2.10.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a7724d6fb4526240980fcae7900c62ffb93ce5c5926a4e19133ec79e26558b61"}, - {file = "confluent_kafka-2.10.1.tar.gz", hash = "sha256:536c76f6c39a93c367a70016781cf7e73808300228b8e33b444c846fdef801e4"}, -] - -[[package]] -name = "coverage" -version = "7.9.1" -requires_python = ">=3.9" -summary = "Code coverage measurement for Python" -groups = ["tests", "unit-tests"] -files = [ - {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, - {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, - {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, - {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, - {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, - {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, - {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, - {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, - {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, - {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, - {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, - {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, - {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, - {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, - {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, - {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, - {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, - {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, - {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, -] - -[[package]] -name = "coverage" -version = "7.9.1" -extras = ["toml"] -requires_python = ">=3.9" -summary = "Code coverage measurement for Python" -groups = ["tests", "unit-tests"] -dependencies = [ - "coverage==7.9.1", - "tomli; python_full_version <= \"3.11.0a6\"", -] -files = [ - {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, - {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, - {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, - {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, - {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, - {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, - {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, - {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, - {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, - {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, - {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, - {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, - {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, - {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, - {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, - {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, - {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, - {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, - {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, -] - -[[package]] -name = "croniter" -version = "3.0.4" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6" -summary = "croniter provides iteration for datetime object with cron like format" -groups = ["cron"] -dependencies = [ - "python-dateutil", - "pytz>2021.1", -] -files = [ - {file = "croniter-3.0.4-py2.py3-none-any.whl", hash = "sha256:96e14cdd5dcb479dd48d7db14b53d8434b188dfb9210448bef6f65663524a6f0"}, - {file = "croniter-3.0.4.tar.gz", hash = "sha256:f9dcd4bdb6c97abedb6f09d6ed3495b13ede4d4544503fa580b6372a56a0c520"}, -] - -[[package]] -name = "cryptography" -version = "45.0.4" -requires_python = "!=3.9.0,!=3.9.1,>=3.7" -summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -groups = ["dev-consumers", "tests"] -dependencies = [ - "cffi>=1.14; platform_python_implementation != \"PyPy\"", -] -files = [ - {file = "cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999"}, - {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750"}, - {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2"}, - {file = "cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257"}, - {file = "cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8"}, - {file = "cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6"}, - {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872"}, - {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4"}, - {file = "cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97"}, - {file = "cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d"}, - {file = "cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57"}, -] - -[[package]] -name = "docker" -version = "7.1.0" -requires_python = ">=3.8" -summary = "A Python library for the Docker Engine API." -groups = ["integration-tests"] -dependencies = [ - "pywin32>=304; sys_platform == \"win32\"", - "requests>=2.26.0", - "urllib3>=1.26.0", -] -files = [ - {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, - {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -requires_python = ">=3.7" -summary = "Backport of PEP 654 (exception groups)" -groups = ["default", "dev-consumers", "examples", "rabbitmq", "tests", "unit-tests"] -dependencies = [ - "typing-extensions>=4.6.0; python_version < \"3.13\"", -] -files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, -] - -[[package]] -name = "execnet" -version = "2.1.1" -requires_python = ">=3.8" -summary = "execnet: rapid multi-Python deployment" -groups = ["tests"] -files = [ - {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, - {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, -] - -[[package]] -name = "executing" -version = "2.2.0" -requires_python = ">=3.8" -summary = "Get the currently executing AST node of a frame, and other information" -groups = ["dev"] -files = [ - {file = "executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa"}, - {file = "executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755"}, -] - -[[package]] -name = "fast-depends" -version = "2.4.12" -requires_python = ">=3.8" -summary = "FastDepends - extracted and cleared from HTTP domain logic FastAPI Dependency Injection System. Async and sync are both supported." -groups = ["examples"] -dependencies = [ - "anyio<5.0.0,>=3.0.0", - "pydantic!=1.8,!=1.8.1,<3.0.0,>=1.7.4", - "typing-extensions<4.12.1; python_version < \"3.9\"", -] -files = [ - {file = "fast_depends-2.4.12-py3-none-any.whl", hash = "sha256:9e5d110ddc962329e46c9b35e5fe65655984247a13ee3ca5a33186db7d2d75c2"}, - {file = "fast_depends-2.4.12.tar.gz", hash = "sha256:9393e6de827f7afa0141e54fa9553b737396aaf06bd0040e159d1f790487b16d"}, -] - -[[package]] -name = "fastapi-slim" -version = "0.115.13" -requires_python = ">=3.8" -summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -groups = ["examples"] -dependencies = [ - "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", - "starlette<0.47.0,>=0.40.0", - "typing-extensions>=4.8.0", -] -files = [ - {file = "fastapi_slim-0.115.13-py3-none-any.whl", hash = "sha256:f54d4aacd1928db5a171dc499be84f4f70fa55e297561718d7d162bfb85b4b27"}, - {file = "fastapi_slim-0.115.13.tar.gz", hash = "sha256:b2b18fd04bd52b304ae8c0f58e2d3a65775612c9755c3d3ec7732b2e0eb7c262"}, -] - -[[package]] -name = "forbiddenfruit" -version = "0.1.4" -summary = "Patch python built-in objects" -groups = ["tests"] -marker = "implementation_name == \"cpython\"" -files = [ - {file = "forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"}, -] - -[[package]] -name = "frozenlist" -version = "1.7.0" -requires_python = ">=3.9" -summary = "A list-like structure which implements collections.abc.MutableSequence" -groups = ["sqs"] -files = [ - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, - {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, - {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, - {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, - {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, - {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, - {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, - {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, - {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, - {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, - {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.70.0" -requires_python = ">=3.7" -summary = "Common protobufs used in Google APIs" -groups = ["dev-consumers", "dev-otel"] -dependencies = [ - "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", -] -files = [ - {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, - {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, -] - -[[package]] -name = "grpcio" -version = "1.73.0" -requires_python = ">=3.9" -summary = "HTTP/2-based RPC framework" -groups = ["dev-consumers", "dev-otel", "grpc"] -files = [ - {file = "grpcio-1.73.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:d050197eeed50f858ef6c51ab09514856f957dba7b1f7812698260fc9cc417f6"}, - {file = "grpcio-1.73.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ebb8d5f4b0200916fb292a964a4d41210de92aba9007e33d8551d85800ea16cb"}, - {file = "grpcio-1.73.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:c0811331b469e3f15dda5f90ab71bcd9681189a83944fd6dc908e2c9249041ef"}, - {file = "grpcio-1.73.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12787c791c3993d0ea1cc8bf90393647e9a586066b3b322949365d2772ba965b"}, - {file = "grpcio-1.73.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c17771e884fddf152f2a0df12478e8d02853e5b602a10a9a9f1f52fa02b1d32"}, - {file = "grpcio-1.73.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:275e23d4c428c26b51857bbd95fcb8e528783597207ec592571e4372b300a29f"}, - {file = "grpcio-1.73.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9ffc972b530bf73ef0f948f799482a1bf12d9b6f33406a8e6387c0ca2098a833"}, - {file = "grpcio-1.73.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d269df64aff092b2cec5e015d8ae09c7e90888b5c35c24fdca719a2c9f35"}, - {file = "grpcio-1.73.0-cp310-cp310-win32.whl", hash = "sha256:072d8154b8f74300ed362c01d54af8b93200c1a9077aeaea79828d48598514f1"}, - {file = "grpcio-1.73.0-cp310-cp310-win_amd64.whl", hash = "sha256:ce953d9d2100e1078a76a9dc2b7338d5415924dc59c69a15bf6e734db8a0f1ca"}, - {file = "grpcio-1.73.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:51036f641f171eebe5fa7aaca5abbd6150f0c338dab3a58f9111354240fe36ec"}, - {file = "grpcio-1.73.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d12bbb88381ea00bdd92c55aff3da3391fd85bc902c41275c8447b86f036ce0f"}, - {file = "grpcio-1.73.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:483c507c2328ed0e01bc1adb13d1eada05cc737ec301d8e5a8f4a90f387f1790"}, - {file = "grpcio-1.73.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c201a34aa960c962d0ce23fe5f423f97e9d4b518ad605eae6d0a82171809caaa"}, - {file = "grpcio-1.73.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:859f70c8e435e8e1fa060e04297c6818ffc81ca9ebd4940e180490958229a45a"}, - {file = "grpcio-1.73.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e2459a27c6886e7e687e4e407778425f3c6a971fa17a16420227bda39574d64b"}, - {file = "grpcio-1.73.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e0084d4559ee3dbdcce9395e1bc90fdd0262529b32c417a39ecbc18da8074ac7"}, - {file = "grpcio-1.73.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef5fff73d5f724755693a464d444ee0a448c6cdfd3c1616a9223f736c622617d"}, - {file = "grpcio-1.73.0-cp311-cp311-win32.whl", hash = "sha256:965a16b71a8eeef91fc4df1dc40dc39c344887249174053814f8a8e18449c4c3"}, - {file = "grpcio-1.73.0-cp311-cp311-win_amd64.whl", hash = "sha256:b71a7b4483d1f753bbc11089ff0f6fa63b49c97a9cc20552cded3fcad466d23b"}, - {file = "grpcio-1.73.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fb9d7c27089d9ba3746f18d2109eb530ef2a37452d2ff50f5a6696cd39167d3b"}, - {file = "grpcio-1.73.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:128ba2ebdac41e41554d492b82c34586a90ebd0766f8ebd72160c0e3a57b9155"}, - {file = "grpcio-1.73.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:068ecc415f79408d57a7f146f54cdf9f0acb4b301a52a9e563973dc981e82f3d"}, - {file = "grpcio-1.73.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ddc1cfb2240f84d35d559ade18f69dcd4257dbaa5ba0de1a565d903aaab2968"}, - {file = "grpcio-1.73.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53007f70d9783f53b41b4cf38ed39a8e348011437e4c287eee7dd1d39d54b2f"}, - {file = "grpcio-1.73.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4dd8d8d092efede7d6f48d695ba2592046acd04ccf421436dd7ed52677a9ad29"}, - {file = "grpcio-1.73.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:70176093d0a95b44d24baa9c034bb67bfe2b6b5f7ebc2836f4093c97010e17fd"}, - {file = "grpcio-1.73.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:085ebe876373ca095e24ced95c8f440495ed0b574c491f7f4f714ff794bbcd10"}, - {file = "grpcio-1.73.0-cp312-cp312-win32.whl", hash = "sha256:cfc556c1d6aef02c727ec7d0016827a73bfe67193e47c546f7cadd3ee6bf1a60"}, - {file = "grpcio-1.73.0-cp312-cp312-win_amd64.whl", hash = "sha256:bbf45d59d090bf69f1e4e1594832aaf40aa84b31659af3c5e2c3f6a35202791a"}, - {file = "grpcio-1.73.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:da1d677018ef423202aca6d73a8d3b2cb245699eb7f50eb5f74cae15a8e1f724"}, - {file = "grpcio-1.73.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:36bf93f6a657f37c131d9dd2c391b867abf1426a86727c3575393e9e11dadb0d"}, - {file = "grpcio-1.73.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:d84000367508ade791d90c2bafbd905574b5ced8056397027a77a215d601ba15"}, - {file = "grpcio-1.73.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c98ba1d928a178ce33f3425ff823318040a2b7ef875d30a0073565e5ceb058d9"}, - {file = "grpcio-1.73.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a73c72922dfd30b396a5f25bb3a4590195ee45ecde7ee068acb0892d2900cf07"}, - {file = "grpcio-1.73.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:10e8edc035724aba0346a432060fd192b42bd03675d083c01553cab071a28da5"}, - {file = "grpcio-1.73.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f5cdc332b503c33b1643b12ea933582c7b081957c8bc2ea4cc4bc58054a09288"}, - {file = "grpcio-1.73.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:07ad7c57233c2109e4ac999cb9c2710c3b8e3f491a73b058b0ce431f31ed8145"}, - {file = "grpcio-1.73.0-cp313-cp313-win32.whl", hash = "sha256:0eb5df4f41ea10bda99a802b2a292d85be28958ede2a50f2beb8c7fc9a738419"}, - {file = "grpcio-1.73.0-cp313-cp313-win_amd64.whl", hash = "sha256:38cf518cc54cd0c47c9539cefa8888549fcc067db0b0c66a46535ca8032020c4"}, - {file = "grpcio-1.73.0.tar.gz", hash = "sha256:3af4c30918a7f0d39de500d11255f8d9da4f30e94a2033e70fe2a720e184bd8e"}, -] - -[[package]] -name = "grpcio-tools" -version = "1.71.0" -requires_python = ">=3.9" -summary = "Protobuf code generator for gRPC" -groups = ["dev-consumers"] -dependencies = [ - "grpcio>=1.71.0", - "protobuf<6.0dev,>=5.26.1", - "setuptools", -] -files = [ - {file = "grpcio_tools-1.71.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:f4ad7f0d756546902597053d70b3af2606fbd70d7972876cd75c1e241d22ae00"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:64bdb291df61cf570b5256777ad5fe2b1db6d67bc46e55dc56a0a862722ae329"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:8dd9795e982d77a4b496f7278b943c2563d9afde2069cdee78c111a40cc4d675"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1b5860c41a36b26fec4f52998f1a451d0525a5c9a4fb06b6ea3e9211abdb925"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3059c14035e5dc03d462f261e5900b9a077fd1a36976c3865b8507474520bad4"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f360981b215b1d5aff9235b37e7e1826246e35bbac32a53e41d4e990a37b8f4c"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bfe3888c3bbe16a5aa39409bc38744a31c0c3d2daa2b0095978c56e106c85b42"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:145985c0bf12131f0a1503e65763e0f060473f7f3928ed1ff3fb0e8aad5bc8ac"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-win32.whl", hash = "sha256:82c430edd939bb863550ee0fecf067d78feff828908a1b529bbe33cc57f2419c"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-win_amd64.whl", hash = "sha256:83e90724e3f02415c628e4ead1d6ffe063820aaaa078d9a39176793df958cd5a"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:1f19b16b49afa5d21473f49c0966dd430c88d089cd52ac02404d8cef67134efb"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:459c8f5e00e390aecd5b89de67deb3ec7188a274bc6cb50e43cef35ab3a3f45d"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:edab7e6518de01196be37f96cb1e138c3819986bf5e2a6c9e1519b4d716b2f5a"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8b93b9f6adc7491d4c10144c0643409db298e5e63c997106a804f6f0248dbaf4"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ae5f2efa9e644c10bf1021600bfc099dfbd8e02b184d2d25dc31fcd6c2bc59e"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:65aa082f4435571d65d5ce07fc444f23c3eff4f3e34abef599ef8c9e1f6f360f"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1331e726e08b7bdcbf2075fcf4b47dff07842b04845e6e220a08a4663e232d7f"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6693a7d3ba138b0e693b3d1f687cdd9db9e68976c3fa2b951c17a072fea8b583"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-win32.whl", hash = "sha256:6d11ed3ff7b6023b5c72a8654975324bb98c1092426ba5b481af406ff559df00"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-win_amd64.whl", hash = "sha256:072b2a5805ac97e4623b3aa8f7818275f3fb087f4aa131b0fce00471065f6eaa"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:61c0409d5bdac57a7bd0ce0ab01c1c916728fe4c8a03d77a25135ad481eb505c"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:28784f39921d061d2164a9dcda5164a69d07bf29f91f0ea50b505958292312c9"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:192808cf553cedca73f0479cc61d5684ad61f24db7a5f3c4dfe1500342425866"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:989ee9da61098230d3d4c8f8f8e27c2de796f1ff21b1c90110e636d9acd9432b"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:541a756276c8a55dec991f6c0106ae20c8c8f5ce8d0bdbfcb01e2338d1a8192b"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:870c0097700d13c403e5517cb7750ab5b4a791ce3e71791c411a38c5468b64bd"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:abd57f615e88bf93c3c6fd31f923106e3beb12f8cd2df95b0d256fa07a7a0a57"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:753270e2d06d37e6d7af8967d1d059ec635ad215882041a36294f4e2fd502b2e"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-win32.whl", hash = "sha256:0e647794bd7138b8c215e86277a9711a95cf6a03ff6f9e555d54fdf7378b9f9d"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-win_amd64.whl", hash = "sha256:48debc879570972d28bfe98e4970eff25bb26da3f383e0e49829b2d2cd35ad87"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:9a78d07d6c301a25ef5ede962920a522556a1dfee1ccc05795994ceb867f766c"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:580ac88141c9815557e63c9c04f5b1cdb19b4db8d0cb792b573354bde1ee8b12"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f7c678e68ece0ae908ecae1c4314a0c2c7f83e26e281738b9609860cc2c82d96"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56ecd6cc89b5e5eed1de5eb9cafce86c9c9043ee3840888cc464d16200290b53"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52a041afc20ab2431d756b6295d727bd7adee813b21b06a3483f4a7a15ea15f"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a1712f12102b60c8d92779b89d0504e0d6f3a59f2b933e5622b8583f5c02992"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:41878cb7a75477e62fdd45e7e9155b3af1b7a5332844021e2511deaf99ac9e6c"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:682e958b476049ccc14c71bedf3f979bced01f6e0c04852efc5887841a32ad6b"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-win32.whl", hash = "sha256:0ccfb837152b7b858b9f26bb110b3ae8c46675d56130f6c2f03605c4f129be13"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-win_amd64.whl", hash = "sha256:ffff9bc5eacb34dd26b487194f7d44a3e64e752fc2cf049d798021bf25053b87"}, - {file = "grpcio_tools-1.71.0.tar.gz", hash = "sha256:38dba8e0d5e0fb23a034e09644fdc6ed862be2371887eee54901999e8f6792a8"}, -] - -[[package]] -name = "h11" -version = "0.16.0" -requires_python = ">=3.8" -summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["dev-consumers", "http"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -requires_python = ">=3.8" -summary = "A minimal low-level HTTP client." -groups = ["dev-consumers"] -dependencies = [ - "certifi", - "h11>=0.16", -] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[[package]] -name = "httpx" -version = "0.28.1" -requires_python = ">=3.8" -summary = "The next generation HTTP client." -groups = ["dev-consumers"] -dependencies = [ - "anyio", - "certifi", - "httpcore==1.*", - "idna", -] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[[package]] -name = "humanize" -version = "4.12.3" -requires_python = ">=3.9" -summary = "Python humanize utilities" -groups = ["scheduler"] -files = [ - {file = "humanize-4.12.3-py3-none-any.whl", hash = "sha256:2cbf6370af06568fa6d2da77c86edb7886f3160ecd19ee1ffef07979efc597f6"}, - {file = "humanize-4.12.3.tar.gz", hash = "sha256:8430be3a615106fdfceb0b2c1b41c4c98c6b0fc5cc59663a5539b111dd325fb0"}, -] - -[[package]] -name = "hypothesis" -version = "6.135.14" -requires_python = ">=3.9" -summary = "A library for property-based testing" -groups = ["tests"] -dependencies = [ - "attrs>=22.2.0", - "exceptiongroup>=1.0.0; python_version < \"3.11\"", - "sortedcontainers<3.0.0,>=2.1.0", -] -files = [ - {file = "hypothesis-6.135.14-py3-none-any.whl", hash = "sha256:0dd5b8095e36bd288367c631f864a16c30500b01b17943dcea681233f7421860"}, - {file = "hypothesis-6.135.14.tar.gz", hash = "sha256:2666df50b3cc40ea08b161a5389d6a1cd5aa3cab0dd8fde0ae339389714a4f67"}, -] - -[[package]] -name = "icecream" -version = "2.1.4" -summary = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call." -groups = ["dev"] -dependencies = [ - "asttokens>=2.0.1", - "colorama>=0.3.9", - "executing>=2.1.0", - "pygments>=2.2.0", -] -files = [ - {file = "icecream-2.1.4-py3-none-any.whl", hash = "sha256:7bb715f69102cae871b3a361c3b656536db02cfcadac9664c673581cac4df4fd"}, - {file = "icecream-2.1.4.tar.gz", hash = "sha256:58755e58397d5350a76f25976dee7b607f5febb3c6e1cddfe6b1951896e91573"}, -] - -[[package]] -name = "idna" -version = "3.10" -requires_python = ">=3.6" -summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default", "dev-consumers", "dev-otel", "examples", "integration-tests", "rabbitmq", "sqs", "tests"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.0" -requires_python = ">=3.9" -summary = "Read metadata from Python packages" -groups = ["dev-otel"] -dependencies = [ - "typing-extensions>=3.6.4; python_version < \"3.8\"", - "zipp>=3.20", -] -files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -requires_python = ">=3.8" -summary = "brain-dead simple config-ini parsing" -groups = ["tests", "unit-tests"] -files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, -] - -[[package]] -name = "jmespath" -version = "1.0.1" -requires_python = ">=3.7" -summary = "JSON Matching Expressions" -groups = ["dev-consumers", "integration-tests", "sqs"] -files = [ - {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, - {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, -] - -[[package]] -name = "multidict" -version = "6.5.0" -requires_python = ">=3.9" -summary = "multidict implementation" -groups = ["rabbitmq", "sqs"] -dependencies = [ - "typing-extensions>=4.1.0; python_version < \"3.11\"", -] -files = [ - {file = "multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469"}, - {file = "multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9"}, - {file = "multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69"}, - {file = "multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4"}, - {file = "multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4"}, - {file = "multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877"}, - {file = "multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138"}, - {file = "multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0"}, - {file = "multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf"}, - {file = "multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851"}, - {file = "multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743"}, - {file = "multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b"}, - {file = "multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af"}, - {file = "multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06"}, - {file = "multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461"}, - {file = "multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1"}, - {file = "multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1"}, - {file = "multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4"}, - {file = "multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc"}, - {file = "multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2"}, -] - -[[package]] -name = "nats-py" -version = "2.10.0" -requires_python = ">=3.7" -summary = "NATS client for Python" -groups = ["integration-tests", "nats"] -files = [ - {file = "nats_py-2.10.0.tar.gz", hash = "sha256:9d44265a097edb30d40e214c1dd1a7405c1451d33480ce714c041fb73bb66a10"}, -] - -[[package]] -name = "opentelemetry-api" -version = "1.34.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Python API" -groups = ["dev-otel"] -dependencies = [ - "importlib-metadata<8.8.0,>=6.0", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c"}, - {file = "opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp" -version = "1.34.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Collector Exporters" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-exporter-otlp-proto-grpc==1.34.1", - "opentelemetry-exporter-otlp-proto-http==1.34.1", -] -files = [ - {file = "opentelemetry_exporter_otlp-1.34.1-py3-none-any.whl", hash = "sha256:f4a453e9cde7f6362fd4a090d8acf7881d1dc585540c7b65cbd63e36644238d4"}, - {file = "opentelemetry_exporter_otlp-1.34.1.tar.gz", hash = "sha256:71c9ad342d665d9e4235898d205db17c5764cd7a69acb8a5dcd6d5e04c4c9988"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.34.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Protobuf encoding" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-proto==1.34.1", -] -files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.34.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Collector Protobuf over gRPC Exporter" -groups = ["dev-otel"] -dependencies = [ - "googleapis-common-protos~=1.52", - "grpcio<2.0.0,>=1.63.2; python_version < \"3.13\"", - "grpcio<2.0.0,>=1.66.2; python_version >= \"3.13\"", - "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.34.1", - "opentelemetry-proto==1.34.1", - "opentelemetry-sdk~=1.34.1", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6"}, - {file = "opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.34.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Collector Protobuf over HTTP Exporter" -groups = ["dev-otel"] -dependencies = [ - "googleapis-common-protos~=1.52", - "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.34.1", - "opentelemetry-proto==1.34.1", - "opentelemetry-sdk~=1.34.1", - "requests~=2.7", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad"}, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.55b1" -requires_python = ">=3.9" -summary = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api~=1.4", - "opentelemetry-semantic-conventions==0.55b1", - "packaging>=18.0", - "wrapt<2.0.0,>=1.0.0", -] -files = [ - {file = "opentelemetry_instrumentation-0.55b1-py3-none-any.whl", hash = "sha256:cbb1496b42bc394e01bc63701b10e69094e8564e281de063e4328d122cc7a97e"}, - {file = "opentelemetry_instrumentation-0.55b1.tar.gz", hash = "sha256:2dc50aa207b9bfa16f70a1a0571e011e737a9917408934675b89ef4d5718c87b"}, -] - -[[package]] -name = "opentelemetry-instrumentation-confluent-kafka" -version = "0.55b1" -requires_python = ">=3.9" -summary = "OpenTelemetry Confluent Kafka instrumentation" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api~=1.12", - "opentelemetry-instrumentation==0.55b1", - "wrapt<2.0.0,>=1.0.0", -] -files = [ - {file = "opentelemetry_instrumentation_confluent_kafka-0.55b1-py3-none-any.whl", hash = "sha256:0e101e4541b1ec0122fabb749ceb859eeb0a992041f10ab8669e1fd53e9a5f5c"}, - {file = "opentelemetry_instrumentation_confluent_kafka-0.55b1.tar.gz", hash = "sha256:d95d2740720ee9f52db35c90af5e4ce5d3b49edd2332dbe69da2431525bbf226"}, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.34.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Python Proto" -groups = ["dev-otel"] -dependencies = [ - "protobuf<6.0,>=5.0", -] -files = [ - {file = "opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2"}, - {file = "opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e"}, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.34.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Python SDK" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api==1.34.1", - "opentelemetry-semantic-conventions==0.55b1", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e"}, - {file = "opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d"}, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.55b1" -requires_python = ">=3.9" -summary = "OpenTelemetry Semantic Conventions" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api==1.34.1", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed"}, - {file = "opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3"}, -] - -[[package]] -name = "outcome" -version = "1.3.0.post0" -requires_python = ">=3.7" -summary = "Capture the outcome of Python function calls." -groups = ["tests"] -dependencies = [ - "attrs>=19.2.0", -] -files = [ - {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, - {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, -] - -[[package]] -name = "packaging" -version = "25.0" -requires_python = ">=3.8" -summary = "Core utilities for Python packages" -groups = ["dev-otel", "tests", "unit-tests"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pamqp" -version = "3.3.0" -requires_python = ">=3.7" -summary = "RabbitMQ Focused AMQP low-level library" -groups = ["rabbitmq"] -files = [ - {file = "pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0"}, - {file = "pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -requires_python = ">=3.9" -summary = "plugin and hook calling mechanisms for python" -groups = ["tests", "unit-tests"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[[package]] -name = "propcache" -version = "0.3.2" -requires_python = ">=3.9" -summary = "Accelerated property cache" -groups = ["rabbitmq", "sqs"] -files = [ - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c"}, - {file = "propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70"}, - {file = "propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e"}, - {file = "propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897"}, - {file = "propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1"}, - {file = "propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1"}, - {file = "propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43"}, - {file = "propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02"}, - {file = "propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330"}, - {file = "propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394"}, - {file = "propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198"}, - {file = "propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f"}, - {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, -] - -[[package]] -name = "protobuf" -version = "5.29.5" -requires_python = ">=3.8" -summary = "" -groups = ["dev-consumers", "dev-otel"] -files = [ - {file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"}, - {file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"}, - {file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"}, - {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"}, - {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"}, - {file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"}, - {file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"}, -] - -[[package]] -name = "psutil" -version = "7.0.0" -requires_python = ">=3.6" -summary = "Cross-platform lib for process and system monitoring in Python. NOTE: the syntax of this script MUST be kept compatible with Python 2.7." -groups = ["tests"] -files = [ - {file = "psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25"}, - {file = "psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993"}, - {file = "psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99"}, - {file = "psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553"}, - {file = "psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456"}, -] - -[[package]] -name = "psycopg" -version = "3.2.9" -requires_python = ">=3.8" -summary = "PostgreSQL database adapter for Python" -groups = ["examples"] -dependencies = [ - "backports-zoneinfo>=0.2.0; python_version < \"3.9\"", - "typing-extensions>=4.6; python_version < \"3.13\"", - "tzdata; sys_platform == \"win32\"", -] -files = [ - {file = "psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6"}, - {file = "psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700"}, -] - -[[package]] -name = "psycopg-binary" -version = "3.2.9" -requires_python = ">=3.8" -summary = "PostgreSQL database adapter for Python -- C optimisation distribution" -groups = ["examples"] -marker = "implementation_name != \"pypy\"" -files = [ - {file = "psycopg_binary-3.2.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-win_amd64.whl", hash = "sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb"}, -] - -[[package]] -name = "psycopg-pool" -version = "3.2.6" -requires_python = ">=3.8" -summary = "Connection Pool for Psycopg" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.6", -] -files = [ - {file = "psycopg_pool-3.2.6-py3-none-any.whl", hash = "sha256:5887318a9f6af906d041a0b1dc1c60f8f0dda8340c2572b74e10907b51ed5da7"}, - {file = "psycopg_pool-3.2.6.tar.gz", hash = "sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5"}, -] - -[[package]] -name = "psycopg" -version = "3.2.9" -extras = ["binary", "pool"] -requires_python = ">=3.8" -summary = "PostgreSQL database adapter for Python" -groups = ["examples"] -dependencies = [ - "psycopg-binary==3.2.9; implementation_name != \"pypy\"", - "psycopg-pool", - "psycopg==3.2.9", -] -files = [ - {file = "psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6"}, - {file = "psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700"}, -] - -[[package]] -name = "pycparser" -version = "2.22" -requires_python = ">=3.8" -summary = "C parser in Python" -groups = ["dev-consumers", "tests"] -marker = "os_name == \"nt\" and implementation_name != \"pypy\" or platform_python_implementation != \"PyPy\"" -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - -[[package]] -name = "pydantic" -version = "2.11.7" -requires_python = ">=3.9" -summary = "Data validation using Python type hints" -groups = ["examples"] -dependencies = [ - "annotated-types>=0.6.0", - "pydantic-core==2.33.2", - "typing-extensions>=4.12.2", - "typing-inspection>=0.4.0", -] -files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, -] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -requires_python = ">=3.9" -summary = "Core functionality for Pydantic validation and serialization" -groups = ["examples"] -dependencies = [ - "typing-extensions!=4.7.0,>=4.6.0", -] -files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, -] - -[[package]] -name = "pygments" -version = "2.19.1" -requires_python = ">=3.8" -summary = "Pygments is a syntax highlighting package written in Python." -groups = ["dev", "tests", "unit-tests"] -files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, -] - -[[package]] -name = "pytest" -version = "8.4.1" -requires_python = ">=3.9" -summary = "pytest: simple powerful testing with Python" -groups = ["tests", "unit-tests"] -dependencies = [ - "colorama>=0.4; sys_platform == \"win32\"", - "exceptiongroup>=1; python_version < \"3.11\"", - "iniconfig>=1", - "packaging>=20", - "pluggy<2,>=1.5", - "pygments>=2.7.2", - "tomli>=1; python_version < \"3.11\"", -] -files = [ - {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, - {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, -] - -[[package]] -name = "pytest-cov" -version = "6.2.1" -requires_python = ">=3.9" -summary = "Pytest plugin for measuring coverage." -groups = ["unit-tests"] -dependencies = [ - "coverage[toml]>=7.5", - "pluggy>=1.2", - "pytest>=6.2.5", -] -files = [ - {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, - {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, -] - -[[package]] -name = "pytest-mock" -version = "3.14.1" -requires_python = ">=3.8" -summary = "Thin-wrapper around the mock package for easier use with pytest" -groups = ["unit-tests"] -dependencies = [ - "pytest>=6.2.5", -] -files = [ - {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, - {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, -] - -[[package]] -name = "pytest-xdist" -version = "3.7.0" -requires_python = ">=3.9" -summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -groups = ["tests"] -dependencies = [ - "execnet>=2.1", - "pytest>=7.0.0", -] -files = [ - {file = "pytest_xdist-3.7.0-py3-none-any.whl", hash = "sha256:7d3fbd255998265052435eb9daa4e99b62e6fb9cfb6efd1f858d4d8c0c7f0ca0"}, - {file = "pytest_xdist-3.7.0.tar.gz", hash = "sha256:f9248c99a7c15b7d2f90715df93610353a485827bc06eefb6566d23f6400f126"}, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Extensions to the standard Python datetime module" -groups = ["cron", "integration-tests", "sqs"] -dependencies = [ - "six>=1.5", -] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[[package]] -name = "python-dotenv" -version = "1.1.0" -requires_python = ">=3.9" -summary = "Read key-value pairs from a .env file and set them as environment variables" -groups = ["integration-tests"] -files = [ - {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, - {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, -] - -[[package]] -name = "pytimeparse2" -version = "1.7.1" -requires_python = ">=3.6" -summary = "Time expression parser." -groups = ["scheduler"] -files = [ - {file = "pytimeparse2-1.7.1-py3-none-any.whl", hash = "sha256:a162ea6a7707fd0bb82dd99556efb783935f51885c8bdced0fce3fffe85ab002"}, - {file = "pytimeparse2-1.7.1.tar.gz", hash = "sha256:98668cdcba4890e1789e432e8ea0059ccf72402f13f5d52be15bdfaeb3a8b253"}, -] - -[[package]] -name = "pytz" -version = "2025.2" -summary = "World timezone definitions, modern and historical" -groups = ["cron"] -files = [ - {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, - {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, -] - -[[package]] -name = "pywin32" -version = "310" -summary = "Python for Window Extensions" -groups = ["integration-tests"] -marker = "sys_platform == \"win32\"" -files = [ - {file = "pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1"}, - {file = "pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d"}, - {file = "pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213"}, - {file = "pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd"}, - {file = "pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c"}, - {file = "pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582"}, - {file = "pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d"}, - {file = "pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060"}, - {file = "pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966"}, - {file = "pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab"}, - {file = "pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e"}, - {file = "pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33"}, -] - -[[package]] -name = "requests" -version = "2.32.4" -requires_python = ">=3.8" -summary = "Python HTTP for Humans." -groups = ["dev-otel", "integration-tests"] -dependencies = [ - "certifi>=2017.4.17", - "charset-normalizer<4,>=2", - "idna<4,>=2.5", - "urllib3<3,>=1.21.1", -] -files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, -] - -[[package]] -name = "s3transfer" -version = "0.13.0" -requires_python = ">=3.9" -summary = "An Amazon S3 Transfer Manager" -groups = ["integration-tests"] -dependencies = [ - "botocore<2.0a.0,>=1.37.4", -] -files = [ - {file = "s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be"}, - {file = "s3transfer-0.13.0.tar.gz", hash = "sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177"}, -] - -[[package]] -name = "setuptools" -version = "80.9.0" -requires_python = ">=3.9" -summary = "Easily download, build, install, upgrade, and uninstall Python packages" -groups = ["dev-consumers"] -files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, -] - -[[package]] -name = "six" -version = "1.17.0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Python 2 and 3 compatibility utilities" -groups = ["cron", "integration-tests", "sqs"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -requires_python = ">=3.7" -summary = "Sniff out which async library your code is running under" -groups = ["default", "dev-consumers", "examples", "tests"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -summary = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -groups = ["tests"] -files = [ - {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, - {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, -] - -[[package]] -name = "starlette" -version = "0.46.2" -requires_python = ">=3.9" -summary = "The little ASGI library that shines." -groups = ["examples"] -dependencies = [ - "anyio<5,>=3.6.2", - "typing-extensions>=3.10.0; python_version < \"3.10\"", -] -files = [ - {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, - {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, -] - -[[package]] -name = "structlog" -version = "25.4.0" -requires_python = ">=3.8" -summary = "Structured Logging for Python" -groups = ["dev"] -dependencies = [ - "typing-extensions; python_version < \"3.11\"", -] -files = [ - {file = "structlog-25.4.0-py3-none-any.whl", hash = "sha256:fe809ff5c27e557d14e613f45ca441aabda051d119ee5a0102aaba6ce40eed2c"}, - {file = "structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4"}, -] - -[[package]] -name = "testcontainers" -version = "4.10.0" -requires_python = "<4.0,>=3.9" -summary = "Python library for throwaway instances of anything that can run in a Docker container" -groups = ["integration-tests"] -dependencies = [ - "docker", - "python-dotenv", - "typing-extensions", - "urllib3", - "wrapt", -] -files = [ - {file = "testcontainers-4.10.0-py3-none-any.whl", hash = "sha256:31ed1a81238c7e131a2a29df6db8f23717d892b592fa5a1977fd0dcd0c23fc23"}, - {file = "testcontainers-4.10.0.tar.gz", hash = "sha256:03f85c3e505d8b4edeb192c72a961cebbcba0dd94344ae778b4a159cb6dcf8d3"}, -] - -[[package]] -name = "testcontainers" -version = "4.10.0" -extras = ["kafka", "localstack", "nats"] -requires_python = "<4.0,>=3.9" -summary = "Python library for throwaway instances of anything that can run in a Docker container" -groups = ["integration-tests"] -dependencies = [ - "boto3", - "nats-py", - "testcontainers==4.10.0", -] -files = [ - {file = "testcontainers-4.10.0-py3-none-any.whl", hash = "sha256:31ed1a81238c7e131a2a29df6db8f23717d892b592fa5a1977fd0dcd0c23fc23"}, - {file = "testcontainers-4.10.0.tar.gz", hash = "sha256:03f85c3e505d8b4edeb192c72a961cebbcba0dd94344ae778b4a159cb6dcf8d3"}, -] - -[[package]] -name = "tomli" -version = "2.2.1" -requires_python = ">=3.8" -summary = "A lil' TOML parser" -groups = ["tests", "unit-tests"] -marker = "python_version < \"3.11\"" -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "trio" -version = "0.30.0" -requires_python = ">=3.9" -summary = "A friendly Python library for async concurrency and I/O" -groups = ["tests"] -dependencies = [ - "attrs>=23.2.0", - "cffi>=1.14; os_name == \"nt\" and implementation_name != \"pypy\"", - "exceptiongroup; python_version < \"3.11\"", - "idna", - "outcome", - "sniffio>=1.3.0", - "sortedcontainers", -] -files = [ - {file = "trio-0.30.0-py3-none-any.whl", hash = "sha256:3bf4f06b8decf8d3cf00af85f40a89824669e2d033bb32469d34840edcfc22a5"}, - {file = "trio-0.30.0.tar.gz", hash = "sha256:0781c857c0c81f8f51e0089929a26b5bb63d57f927728a5586f7e36171f064df"}, -] - -[[package]] -name = "trustme" -version = "1.2.1" -requires_python = ">=3.9" -summary = "#1 quality TLS certs while you wait, for the discerning tester" -groups = ["tests"] -dependencies = [ - "cryptography>=3.1", - "idna>=2.0", -] -files = [ - {file = "trustme-1.2.1-py3-none-any.whl", hash = "sha256:d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a"}, - {file = "trustme-1.2.1.tar.gz", hash = "sha256:6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f"}, -] - -[[package]] -name = "truststore" -version = "0.10.1" -requires_python = ">=3.10" -summary = "Verify certificates using native system trust stores" -groups = ["tests"] -marker = "python_version >= \"3.10\"" -files = [ - {file = "truststore-0.10.1-py3-none-any.whl", hash = "sha256:b64e6025a409a43ebdd2807b0c41c8bff49ea7ae6550b5087ac6df6619352d4c"}, - {file = "truststore-0.10.1.tar.gz", hash = "sha256:eda021616b59021812e800fa0a071e51b266721bef3ce092db8a699e21c63539"}, -] - -[[package]] -name = "types-aiobotocore" -version = "2.23.0" -requires_python = ">=3.8" -summary = "Type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" -groups = ["dev-types"] -dependencies = [ - "botocore-stubs", - "typing-extensions>=4.1.0; python_version < \"3.12\"", -] -files = [ - {file = "types_aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:f62d9a58d730bbde825355feedb045713b6de2feab4c1b68b071afd1da60dea8"}, - {file = "types_aiobotocore-2.23.0.tar.gz", hash = "sha256:d363a584726b9582e18cadbed9f88053ac4b488373e0e643d292b57b2f182ac8"}, -] - -[[package]] -name = "types-aiobotocore-sqs" -version = "2.23.0" -requires_python = ">=3.8" -summary = "Type annotations for aiobotocore SQS 2.23.0 service generated with mypy-boto3-builder 8.11.0" -groups = ["dev-types"] -dependencies = [ - "typing-extensions; python_version < \"3.12\"", -] -files = [ - {file = "types_aiobotocore_sqs-2.23.0-py3-none-any.whl", hash = "sha256:88f1bd8b0767d40ef8b607c1158f4e80309bf99e9678657cc1fd671efc09caea"}, - {file = "types_aiobotocore_sqs-2.23.0.tar.gz", hash = "sha256:1701584bb8e6b89cbe7297f56205f0d26b42d7e0241ce9aa1779c0433cdbb29c"}, -] - -[[package]] -name = "types-aiobotocore" -version = "2.23.0" -extras = ["sqs"] -requires_python = ">=3.8" -summary = "Type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" -groups = ["dev-types"] -dependencies = [ - "types-aiobotocore-sqs<2.24.0,>=2.23.0", - "types-aiobotocore==2.23.0", -] -files = [ - {file = "types_aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:f62d9a58d730bbde825355feedb045713b6de2feab4c1b68b071afd1da60dea8"}, - {file = "types_aiobotocore-2.23.0.tar.gz", hash = "sha256:d363a584726b9582e18cadbed9f88053ac4b488373e0e643d292b57b2f182ac8"}, -] - -[[package]] -name = "types-awscrt" -version = "0.27.2" -requires_python = ">=3.8" -summary = "Type annotations and code completion for awscrt" -groups = ["dev-types"] -files = [ - {file = "types_awscrt-0.27.2-py3-none-any.whl", hash = "sha256:49a045f25bbd5ad2865f314512afced933aed35ddbafc252e2268efa8a787e4e"}, - {file = "types_awscrt-0.27.2.tar.gz", hash = "sha256:acd04f57119eb15626ab0ba9157fc24672421de56e7bd7b9f61681fedee44e91"}, -] - -[[package]] -name = "types-boto3" -version = "1.38.41" -requires_python = ">=3.8" -summary = "Type annotations for boto3 1.38.41 generated with mypy-boto3-builder 8.11.0" -groups = ["dev-types"] -dependencies = [ - "botocore-stubs", - "types-s3transfer", - "typing-extensions>=4.1.0; python_version < \"3.12\"", -] -files = [ - {file = "types_boto3-1.38.41-py3-none-any.whl", hash = "sha256:d0f5cbe3861ca2a108990e8c8075f03cd1f1b803b87e3f6bddf0a75c779957a5"}, - {file = "types_boto3-1.38.41.tar.gz", hash = "sha256:cdc55bd5932e9395884dd34c01ec74a6b1102f5cc94162651da35a8a1215c733"}, -] - -[[package]] -name = "types-boto3-sqs" -version = "1.38.0" -requires_python = ">=3.8" -summary = "Type annotations for boto3 SQS 1.38.0 service generated with mypy-boto3-builder 8.10.1" -groups = ["dev-types"] -dependencies = [ - "typing-extensions; python_version < \"3.12\"", -] -files = [ - {file = "types_boto3_sqs-1.38.0-py3-none-any.whl", hash = "sha256:3bf05864d2c635ad4ea32554223365b51d945859d2ed87f08aa3f2d09ee08518"}, - {file = "types_boto3_sqs-1.38.0.tar.gz", hash = "sha256:7713556cb9a7ab6b6b21a0a2a194b9c13fdeeb6082b87f58e2179a601d964082"}, -] - -[[package]] -name = "types-boto3" -version = "1.38.41" -extras = ["sqs"] -requires_python = ">=3.8" -summary = "Type annotations for boto3 1.38.41 generated with mypy-boto3-builder 8.11.0" -groups = ["dev-types"] -dependencies = [ - "types-boto3-sqs<1.39.0,>=1.38.0", - "types-boto3==1.38.41", -] -files = [ - {file = "types_boto3-1.38.41-py3-none-any.whl", hash = "sha256:d0f5cbe3861ca2a108990e8c8075f03cd1f1b803b87e3f6bddf0a75c779957a5"}, - {file = "types_boto3-1.38.41.tar.gz", hash = "sha256:cdc55bd5932e9395884dd34c01ec74a6b1102f5cc94162651da35a8a1215c733"}, -] - -[[package]] -name = "types-croniter" -version = "6.0.0.20250411" -requires_python = ">=3.9" -summary = "Typing stubs for croniter" -groups = ["dev-types"] -files = [ - {file = "types_croniter-6.0.0.20250411-py3-none-any.whl", hash = "sha256:4acdaccf4190017daa51699bd3110a0617c5df72459e62dea8b72549c62fad90"}, - {file = "types_croniter-6.0.0.20250411.tar.gz", hash = "sha256:ee97025b7768f2cc556ef52a2f10c97c503c1634f372fd3e9683d8f7c960eeb7"}, -] - -[[package]] -name = "types-protobuf" -version = "6.30.2.20250516" -requires_python = ">=3.9" -summary = "Typing stubs for protobuf" -groups = ["dev-types"] -files = [ - {file = "types_protobuf-6.30.2.20250516-py3-none-any.whl", hash = "sha256:8c226d05b5e8b2623111765fa32d6e648bbc24832b4c2fddf0fa340ba5d5b722"}, - {file = "types_protobuf-6.30.2.20250516.tar.gz", hash = "sha256:aecd1881770a9bb225ede66872ef7f0da4505edd0b193108edd9892e48d49a41"}, -] - -[[package]] -name = "types-s3transfer" -version = "0.13.0" -requires_python = ">=3.8" -summary = "Type annotations and code completion for s3transfer" -groups = ["dev-types"] -files = [ - {file = "types_s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:79c8375cbf48a64bff7654c02df1ec4b20d74f8c5672fc13e382f593ca5565b3"}, - {file = "types_s3transfer-0.13.0.tar.gz", hash = "sha256:203dadcb9865c2f68fb44bc0440e1dc05b79197ba4a641c0976c26c9af75ef52"}, -] - -[[package]] -name = "typing-extensions" -version = "4.14.0" -requires_python = ">=3.9" -summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default", "dev", "dev-consumers", "dev-otel", "dev-types", "examples", "http", "integration-tests", "rabbitmq", "sqs", "tests", "unit-tests"] -files = [ - {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, - {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.1" -requires_python = ">=3.9" -summary = "Runtime typing introspection tools" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.12.0", -] -files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, -] - -[[package]] -name = "tzdata" -version = "2025.2" -requires_python = ">=2" -summary = "Provider of IANA time zone data" -groups = ["examples"] -marker = "sys_platform == \"win32\"" -files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -requires_python = ">=3.9" -summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["dev-otel", "integration-tests", "sqs"] -files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, -] - -[[package]] -name = "uvicorn" -version = "0.34.3" -requires_python = ">=3.9" -summary = "The lightning-fast ASGI server." -groups = ["http"] -dependencies = [ - "click>=7.0", - "h11>=0.8", - "typing-extensions>=4.0; python_version < \"3.11\"", -] -files = [ - {file = "uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885"}, - {file = "uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a"}, -] - -[[package]] -name = "uvloop" -version = "0.21.0" -requires_python = ">=3.8.0" -summary = "Fast implementation of asyncio event loop on top of libuv" -groups = ["tests"] -marker = "platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\"" -files = [ - {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, - {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, - {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26"}, - {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb"}, - {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f"}, - {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c"}, - {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"}, - {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"}, - {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"}, - {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"}, - {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"}, - {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"}, - {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"}, - {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"}, - {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"}, - {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"}, - {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"}, - {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"}, - {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281"}, - {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af"}, - {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6"}, - {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816"}, - {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc"}, - {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553"}, - {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, -] - -[[package]] -name = "wrapt" -version = "1.17.2" -requires_python = ">=3.8" -summary = "Module for decorators, wrappers and monkey patching." -groups = ["dev-otel", "integration-tests", "sqs"] -files = [ - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62"}, - {file = "wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563"}, - {file = "wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72"}, - {file = "wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317"}, - {file = "wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9"}, - {file = "wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9"}, - {file = "wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504"}, - {file = "wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a"}, - {file = "wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f"}, - {file = "wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555"}, - {file = "wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c"}, - {file = "wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8"}, - {file = "wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3"}, -] - -[[package]] -name = "yarl" -version = "1.20.1" -requires_python = ">=3.9" -summary = "Yet another URL library" -groups = ["rabbitmq", "sqs"] -dependencies = [ - "idna>=2.0", - "multidict>=4.0", - "propcache>=0.2.1", -] -files = [ - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13"}, - {file = "yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8"}, - {file = "yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e"}, - {file = "yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773"}, - {file = "yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004"}, - {file = "yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5"}, - {file = "yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1"}, - {file = "yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7"}, - {file = "yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e"}, - {file = "yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d"}, - {file = "yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f"}, - {file = "yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77"}, - {file = "yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac"}, -] - -[[package]] -name = "zipp" -version = "3.23.0" -requires_python = ">=3.9" -summary = "Backport of pathlib-compatible object wrapper for zip files" -groups = ["dev-otel"] -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] diff --git a/plans/compression-middleware.md b/plans/compression-middleware.md new file mode 100644 index 0000000..2bbab4d --- /dev/null +++ b/plans/compression-middleware.md @@ -0,0 +1,279 @@ +# Compression middleware (dynamic responses) + +## Context + +We added `static_handler` (zero-copy `socket.sendfile()`) in +`plans/static-files.md`. The follow-up question was whether one +middleware could compress *both* static and dynamic responses. +Answer: not without giving up sendfile — compression and zero-copy +are at odds. Cleanest split: + +- **Dynamic path (this plan):** a `compress_handler` middleware that + intercepts `complete(response, body)`, compresses the body, and + rewrites headers. +- **Static path (future, not this plan):** opt-in `precompressed=` + on `static_handler` that picks `foo.css.br` / `foo.css.zst` off + disk and `sendfile` s it. Build- or deploy-time compression, no + per-request CPU. Re-uses the same negotiation helper. + +## Design + +### Public API + +```python +def compress_handler( + inner: RequestHandler, + *, + algorithms: Sequence[str] = ("br", "gzip"), # server preference order + min_size: int = 1024, + compressible_types: frozenset[bytes] = DEFAULT_COMPRESSIBLE_TYPES, +) -> RequestHandler: ... +``` + +Standard `Middleware` shape (`Callable[[RequestHandler], RequestHandler]`). +Defaults: + +- `algorithms = ("br", "gzip")` — brotli first (better ratio for text); + gzip as universal fallback. zstd added later when `compression.zstd` + (Python 3.14 stdlib) is the floor. +- `min_size = 1024` — below this, framing overhead dominates compression + savings. +- `compressible_types` — allowlist of exact lowercased main-types + (text/*, JSON, XML, JS, SVG, YAML, manifest+json…). + +`brotli` is optional via the new `[http-compress]` extra. If `"br"` is +in `algorithms` but the dependency is missing, `compress_handler` +raises `ImportError` at construction time (fail fast — don't surprise +the user mid-request). + +### Mechanism — wrap the ctx + +The middleware can't observe `ctx.complete(...)` from outside the +handler call, so it passes a **proxy ctx** to the inner handler: + +``` +inner(proxy_ctx) + ↓ +proxy_ctx.complete(response, body) + ↓ +[decision + body compress + header rewrite] + ↓ +real_ctx.complete(rewritten_response, compressed_body) +``` + +`_CompressedCtx` forwards every attribute / method (`request`, `body`, +`attrs`, `selector`, `conn`, `borrowed`, `borrow`, `receive`, +`response_status`, `start_response`, `send`, `finish_response`, +`sendfile`) verbatim, and intercepts only `complete()`. + +`start_response` / `send` / `finish_response` / `sendfile` pass through +**uncompressed** in v1. Streaming compression (chunked-encoded SSE, +generator responses) is a separate piece of work; sendfile compression +is intentionally out of scope (zero-copy rationale). + +### Decision logic — per-request, ordered + +The middleware skips compression when any of these hold: + +| Condition | Why | +|-----------|-----| +| `Accept-Encoding` absent or no `algorithms` entry has q>0 | Client doesn't want it | +| `method == b"HEAD"` | No body to compress; faking one to compute length is silly | +| `body is None` or `len(body) < min_size` | Below threshold; framing dominates | +| Status in `{204, 304}` or `1xx` | No body | +| Status `206` | Range — compressing breaks byte-range semantics | +| Existing `Content-Encoding` (not `identity` or empty) | Don't double-compress | +| `Cache-Control: no-transform` | Honor the directive (RFC 9111 §5.2.1.6) | +| `Content-Type` main-type not in `compressible_types` | Allowlist miss | + +When eligible: compress, then **rewrite headers** — + +- replace `Content-Length` with the compressed length +- add `Content-Encoding: ` +- merge `Accept-Encoding` into `Vary` (existing `Vary: Cookie` → + `Vary: Cookie, Accept-Encoding`; existing `Vary: *` left alone) + +### Negotiation helper + +```python +def _negotiate(accept_encoding: bytes, server_pref: Sequence[str]) -> str | None: ... +``` + +Parses comma-separated tokens with optional `;q=`, default q=1.0. +`q=0` disables, `*` is a wildcard, `identity` is "no compression". +Returns the highest-server-preference encoding with q>0, or `None`. + +This same helper will back precompressed-sidecar negotiation in +`static_handler` later — the negotiation logic is identical, only the +"deliver the bytes" step differs. + +### Default compressible types + +```python +DEFAULT_COMPRESSIBLE_TYPES = frozenset({ + b"text/html", b"text/plain", b"text/css", b"text/xml", b"text/csv", + b"text/javascript", + b"application/json", b"application/javascript", b"application/xml", + b"application/xhtml+xml", b"application/manifest+json", + b"application/x-yaml", b"application/rss+xml", b"application/atom+xml", + b"image/svg+xml", +}) +``` + +Exact main-type match: split `Content-Type` on `;`, lowercase, lookup. +Users override by passing their own `frozenset` to `compress_handler`. + +## Plan + +### Step 1 — `compress.py` + +1. New module `localpost/http/compress.py` with: + - `compress_handler(inner, *, algorithms, min_size, compressible_types)` — public. + - `_CompressedCtx` — proxy. Forwards all attrs/methods to + `_inner`; overrides `complete`. + - `_negotiate`, `_rewrite_headers`, `_compress(encoding, body)`, + `_should_compress(method, response, body, min_size, + compressible_types)`. + - `DEFAULT_COMPRESSIBLE_TYPES`. +2. Re-export `compress_handler` and `DEFAULT_COMPRESSIBLE_TYPES` from + `localpost/http/__init__.py`. +3. Add `[http-compress]` extra to `pyproject.toml`: + + ```toml + http-compress = [ + "brotli ~=1.1", + ] + ``` + +4. Run `just check localpost/http/compress.py`. + +### Step 2 — Tests + +`tests/http/compress.py`: +- Negotiation: empty / single / multiple / q-values / wildcard / + identity-only / unsupported. +- Allowlist hit/miss (HTML, JSON, image/png, application/octet-stream, + user-supplied set). +- Skip conditions: HEAD, 204/304/206, `Cache-Control: no-transform`, + existing `Content-Encoding`, body < min_size, body is None. +- Header rewrite: `Content-Length` replaced; `Content-Encoding` added; + `Vary` merged (empty / single / `*` cases). +- Round-trip via httpx (auto-decodes `Content-Encoding`) — confirms + wire format works for gzip; brotli skipped if dep missing. +- Pass-through: `ctx.sendfile(...)` from a static handler stays + uncompressed when wrapped. +- Pass-through: streaming handler (`start_response` + `send` + + `finish_response`) stays uncompressed. + +Run via `just unit-tests`. + +### Step 3 — Docs + example + +- New `localpost.http.compress` section in + `localpost/http/README.md` — placement after `localpost.http.static`. + Cover the API, decision matrix, and the `Vary` semantics. Note the + v1 limitation (only `complete()` intercepted). +- New `examples/http/compressed_api.py` — wraps a small JSON router + with `compress_handler`. + +## Step 4 — Streaming compression (SSE etc.) + +`compress_handler` was originally only the `complete()` path. This +extension adds the streaming-response path through the same call site +— no new flag, no behavior change for users who don't stream. + +### Decision: enable streaming compression when… + +`start_response(response)` is called with a **final** Response (not +1xx) and: + +- Method != HEAD +- No `Content-Length` declared (a known-length response is contractually + fixed; compressing it mid-stream would make the declared length wrong) +- No existing `Content-Encoding` +- No `Cache-Control: no-transform` +- Status not in `{1xx, 204, 304, 206}` +- `Content-Type` main-type is in `compressible_types` + +Otherwise: pass `start_response` / `send` / `finish_response` through +verbatim. + +`text/event-stream` is added to `DEFAULT_COMPRESSIBLE_TYPES`. + +### Header rewrite (streaming) + +- Drop `Content-Length` (only present if user set it, in which case we + already passed through; defensive). +- Ensure `Transfer-Encoding: chunked` (httptools auto-frames; h11 + needs it explicit to switch the writer). +- Add `Content-Encoding: `. +- Merge `Vary: Accept-Encoding` (same as `_merge_vary`). + +### Per-chunk semantics + +Each `ctx.send(chunk)` (with non-empty `chunk`) is compressed and +followed by a `Z_SYNC_FLUSH`-equivalent (`brotli.Compressor.flush()`) +so the bytes reach the wire promptly: + +``` +send(chunk): + out = encoder.compress(chunk) + encoder.flush() + if out: inner.send(out) + +finish_response(): + tail = encoder.finish() + if tail: inner.send(tail) + inner.finish_response() +``` + +Empty `send(b"")` (sometimes used to flush headers without body bytes) +passes through verbatim — no sync marker injected. + +This is the same approach nginx uses (`gzip on; gzip_types text/event-stream`). +All major browsers transparently decompress `Content-Encoding: gzip` / +`br` streams before the EventSource parser sees them. + +### Encoder interface + +A tiny internal Protocol with two implementations: + +```python +class _StreamEncoder(Protocol): + def compress(self, data: bytes) -> bytes: ... + def flush(self) -> bytes: ... # mid-stream, sync flush + def finish(self) -> bytes: ... # final flush + +class _GzipStreamEncoder: + # zlib.compressobj(level=6, wbits=31) → gzip-format output + # flush(Z_SYNC_FLUSH) for mid-stream, flush(Z_FINISH) for end. + +class _BrotliStreamEncoder: + # brotli.Compressor(quality=4) + # process / flush / finish. +``` + +### Tests (additional) + +- SSE-shaped round-trip: `Accept-Encoding: gzip` → + `Content-Encoding: gzip`, body decompresses to all events + concatenated. Same for brotli (skip if dep missing). +- Per-chunk flushing: read raw socket with timeout between sends; + verify the first event's compressed bytes arrive before the second + send. +- Pass-through when Content-Length is set on `start_response`. +- Pass-through when content type is not compressible. +- Pass-through when `Cache-Control: no-transform`. +- Empty `send(b"")` doesn't emit sync-marker bytes. + +## Followups (separate PRs) + +- **zstd** — add when `compression.zstd` (Python 3.14 stdlib) is the + supported floor, or as a third-party-backed extra + (`zstandard`) sooner. Same negotiation logic; one more entry in the + preference order. +- **Per-algorithm levels** — `levels: Mapping[str, int]` parameter. + Defaults today are gzip `compresslevel=6`, brotli `quality=4` (favor + speed). Adding the parameter is small but adds a knob. +- **Precompressed sidecars in `static_handler`** — the natural reuse + of `_negotiate`. When we ship that, the negotiation helper moves to + a shared location (`localpost/http/_negotiate.py` or similar). diff --git a/plans/openapi-attrs.md b/plans/openapi-attrs.md new file mode 100644 index 0000000..c8f690c --- /dev/null +++ b/plans/openapi-attrs.md @@ -0,0 +1,301 @@ +# attrs / cattrs support in `localpost.openapi` + +## Context + +`localpost.openapi` already has a pluggable type-adapter seam +(`localpost/openapi/adapters/__init__.py`): a `TypeAdapter` protocol with +`claims` / `is_body_type` / `schema` / `components` / `decode` / `encode`, +dispatched by an `AdapterRegistry` whose last entry is a catch-all. Today +two adapters ship: `MsgspecAdapter` (catch-all, in `_msgspec.py`) and +`PydanticAdapter` (lazy-imported, in `_pydantic.py`). The README's +adapters docstring even names attrs as a future plug-in example. + +Goal: make `attrs.define`'d classes first-class — bodies decoded, +responses encoded, OpenAPI schemas generated — by adding a third adapter, +without touching anything outside `localpost/openapi/adapters/` +(plus the protocol shim in step 1, the registry wiring, and packaging). + +`attrs` is already installed transitively in the dev env (26.1.0); +`cattrs` is not. + +## Decisions + +1. **One adapter, attrs + cattrs together.** Require both at runtime when + the adapter is active. attrs alone forces us to reinvent + structuring/unstructuring; cattrs alone has no schema generator. + Treat cattrs as the implementation detail of decode/encode and attrs + as the introspection source for schemas. +2. **Position in `default_registry()`: `[pydantic?, attrs?, msgspec]`.** + attrs goes before msgspec because msgspec's `is_body_type` would + otherwise miss attrs classes (no `__dataclass_fields__`). +3. **Default cattrs converter**, overridable. Module-level + `cattrs.preconf.json.make_converter()` for default; allow + `AttrsAdapter(converter=...)` for users with custom hooks. Keep the + constructor tiny — no DI, no decorators. +4. **Schema strategy: hand-rolled walker that re-enters the registry.** + Walk `attrs.fields(cls)` and produce JSON Schema; for nested non-attrs + types, ask the registry. This requires a small additive change to the + `TypeAdapter` protocol (step 1 below). Other options were considered + and rejected: + - Convert attrs → dataclass at registration time and hand to msgspec. + Fragile: validators, converters, `field(default=...)` semantics + diverge from `dataclasses.field`. + - Use `cattrs-extras` or similar for schema. Adds another moving part. +5. **Validators-as-constraints out of scope for the first cut.** Emit + structural schema only (types, requiredness, `$ref`s). attrs + validators don't have a uniform `__metadata__` shape; mapping + `validators.in_` → `enum`, `matches_re` → `pattern`, etc., is a + follow-up. +6. **Keep `attrs`/`cattrs` out of the base `openapi` extra.** Same posture + as pydantic — users opt in. + +## Work breakdown + +### Step 1 — Extend `TypeAdapter` to allow registry recursion (additive) + +Files: `localpost/openapi/adapters/__init__.py`, `_msgspec.py`, +`_pydantic.py`, `localpost/openapi/schemas.py`. + +Why: the attrs adapter's `schema()` needs to recurse into nested types +(another attrs class, a msgspec.Struct, a pydantic model, a primitive) +without knowing which adapter owns each one. The registry already has +that dispatch. + +Change shape (kept additive — no breakage to external `TypeAdapter` +implementers): + +```python +# adapters/__init__.py +SchemaFor = Callable[[Any], dict[str, Any]] # ref or inline + +class TypeAdapter(Protocol): + ... + def schema(self, t: Any, /, *, ref_template: str, + schema_for: SchemaFor | None = None) -> dict[str, Any]: ... + def components(self, types: Sequence[Any], /, *, ref_template: str, + schema_for: SchemaFor | None = None + ) -> dict[str, dict[str, Any]]: ... +``` + +- Existing implementations (`MsgspecAdapter`, `PydanticAdapter`) accept + the new kwarg and ignore it. +- `SchemaRegistry.schema_for` passes itself as the `schema_for` callback + when delegating, so nested types coming back through the registry hit + the right adapter. +- `SchemaRegistry.components()` likewise threads `schema_for` through. + +Tradeoff: the protocol grows by one optional kwarg. Acceptable — the +README already calls these adapters "expected to be stateless and cheap", +and the kwarg is opt-in. + +### Step 2 — `AttrsAdapter` + +New file: `localpost/openapi/adapters/_attrs.py`. + +Skeleton: + +```python +from __future__ import annotations +from collections.abc import Sequence +from typing import Any, get_args, get_origin, Union, Literal +import attrs +import cattrs +from cattrs.errors import BaseValidationError +from cattrs.preconf.json import make_converter + +_JSON = "application/json" +_DEFAULT_CONVERTER = make_converter() + + +class AttrsAdapter: + name = "attrs" + validation_errors: tuple[type[Exception], ...] = (BaseValidationError,) + + def __init__(self, converter: cattrs.Converter | None = None) -> None: + self._converter = converter or _DEFAULT_CONVERTER + + def claims(self, t: Any, /) -> bool: + return isinstance(t, type) and attrs.has(t) + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t, /, *, ref_template, schema_for=None): + if self.claims(t): + return {"$ref": ref_template.format(name=t.__name__)} + # Non-attrs nested type — defer to the registry if we have it, + # otherwise emit empty (permissive) schema. + return schema_for(t) if schema_for else {} + + def components(self, types, /, *, ref_template, schema_for=None): + out: dict[str, dict[str, Any]] = {} + for t in types: + out[t.__name__] = self._build_object_schema( + t, ref_template=ref_template, schema_for=schema_for + ) + return out + + def decode(self, body, t, /, *, content_type): + if not body: + raise ValueError("empty request body") + return self._converter.loads(body, t) + + def encode(self, value, /): + return self._converter.dumps(value).encode("utf-8"), _JSON + + def _build_object_schema(self, t, *, ref_template, schema_for): + properties: dict[str, dict[str, Any]] = {} + required: list[str] = [] + for f in attrs.fields(t): + field_schema = self._field_schema( + f.type, ref_template=ref_template, schema_for=schema_for + ) + properties[f.name] = field_schema + if f.default is attrs.NOTHING: + required.append(f.name) + out: dict[str, Any] = { + "type": "object", + "title": t.__name__, + "properties": properties, + } + if required: + out["required"] = required + return out + + def _field_schema(self, t, *, ref_template, schema_for): + # Resolve string annotations once at registration time; + # attrs.resolve_types(cls) called from _build_object_schema. + ... + # Dispatch: + # - None / type(None) -> {"type": "null"} + # - Union/Optional -> oneOf, dropping None for required check + # - list[T], tuple[T, ...] -> {"type": "array", "items": ...} + # - dict[str, V] -> {"type": "object", "additionalProperties": ...} + # - Literal[...] -> {"enum": [...]} + # - attrs class -> $ref via self.schema(...) + # - everything else -> schema_for(t) if available else {} +``` + +Key points: + +- Call `attrs.resolve_types(t)` once per class in `components()` so + `f.type` is a real type, not a string. Cache the resolved set on the + adapter instance (`set[type]`) to avoid repeated work. +- Validation error mapping: cattrs raises `ClassValidationError` which is + a `BaseValidationError`; using the base catches the whole family. This + flows through the existing `FromBody` catch (`resolvers.py:374`) which + produces a `BadRequest`. +- Nested attrs types referenced by a field: emit `$ref` from + `_field_schema`, then **also** notify the registry so it gets added to + components. The cleanest way is to call `schema_for(nested_attrs_t)` — + the registry both records the type and returns the `$ref`. So + `_field_schema` can route every non-trivial branch through `schema_for` + for a uniform path. + +### Step 3 — Wire into `default_registry()` + +`localpost/openapi/adapters/__init__.py`: + +```python +@functools.cache +def default_registry() -> AdapterRegistry: + from localpost.openapi.adapters._msgspec import MsgspecAdapter + adapters: list[TypeAdapter] = [] + try: + from localpost.openapi.adapters._pydantic import PydanticAdapter + adapters.append(PydanticAdapter()) + except ImportError: + pass + try: + from localpost.openapi.adapters._attrs import AttrsAdapter + adapters.append(AttrsAdapter()) + except ImportError: + pass + adapters.append(MsgspecAdapter()) + return AdapterRegistry(adapters) +``` + +Order matters: pydantic first (most specific — `BaseModel` subclass +check), then attrs (`attrs.has`), then msgspec catch-all. + +### Step 4 — Packaging + +`pyproject.toml`: + +- New optional extra (does NOT touch the base `openapi` extra): + ```toml + openapi-attrs = [ + "attrs >=23", + "cattrs >=24", + ] + ``` +- `dev-openapi` group adds `attrs` and `cattrs` so our own examples and + tests exercise the path: + ```toml + dev-openapi = [ + "pydantic ~=2.7", + "attrs ~=24.2", + "cattrs ~=24.1", + ] + ``` + +### Step 5 — Tests + +- `tests/openapi/schemas.py`: mirror the pydantic block (lines 114-) with + two tests: + - `test_attrs_class_routes_to_attrs` — registry dispatches to + `name == "attrs"`. + - `test_attrs_schema_registered_in_components` — components include + the attrs class with expected `properties` / `required`. + - Bonus: `test_attrs_with_nested_msgspec_struct` — exercise the + `schema_for` recursion path (attrs class containing a + `msgspec.Struct` field, components must contain both). +- `tests/openapi/app.py`: end-to-end `test_attrs_body_is_parsed_and_serialized` + modeled on the pydantic test at line 405. Validation error should + produce a 400. +- All gated with `pytest.importorskip("attrs")` and + `pytest.importorskip("cattrs")`. + +### Step 6 — Example + +Add `examples/openapi/app_attrs.py` (small variant of `app.py`) using +`@attrs.define` for `Book`. Keeps the README quickstart untouched. + +### Step 7 — Docs + +- `localpost/openapi/README.md`: + - Argument-resolvers table (line 102): widen `FromBody` row to + "msgspec.Struct / dataclass / pydantic model / **attrs class**". + - Install section (line 22): note `pip install attrs cattrs` for attrs + handler support, alongside the existing pydantic note. +- `localpost/openapi/adapters/__init__.py` module docstring already names + attrs as the example custom adapter — update the snippet to reference + the now-built-in `AttrsAdapter` and clarify users only need a custom + one for *other* libraries. + +## Risks / open questions + +1. **Forward references in field types.** `attrs.fields(cls).type` may be + a string if the class uses `from __future__ import annotations`. + Mitigation: `attrs.resolve_types(cls)` at the start of + `_build_object_schema`. Wraps cleanly; `attrs` ships this helper. +2. **cattrs version.** Major releases have shuffled `preconf.json` and + exception class names. Pin `cattrs >=24` and verify on 24.1 + latest. +3. **Generic attrs classes (`@attrs.define` with `Generic[T]`).** Punt + for the first cut — emit the un-parameterised schema. Document. +4. **Free-threading.** `_DEFAULT_CONVERTER` is a module-level shared + object; cattrs converters are not documented as thread-safe for + register-on-first-use, but our default has no late registration. + Should be safe; flag for verification. +5. **Schema-walker scope creep.** The walker WILL grow (Annotated, + nested generics, recursive types). Keep it in `_attrs.py` and don't + let it leak into `schemas.py`. If a future adapter (e.g. protobuf) + needs the same primitives, factor then — not now. + +## Out of scope (explicit) + +- attrs without cattrs. +- attrs validators → JSON Schema constraints. +- Custom cattrs hook registration via `@app`-level decorators. +- `attr.s` (legacy API) — only the modern `attrs.define` / `@attrs.frozen` + are tested. Should work via `attrs.has` but not promised. diff --git a/plans/openapi-benchmarks.md b/plans/openapi-benchmarks.md new file mode 100644 index 0000000..bdfc0e8 --- /dev/null +++ b/plans/openapi-benchmarks.md @@ -0,0 +1,133 @@ +# OpenAPI framework benchmarks + +Add a second macro-bench suite that compares `localpost.openapi` against +peer typed/OpenAPI Python web frameworks. Today's `benchmarks/http/` +measures **HTTP server overhead** with thin Flask/Starlette apps; +the new `benchmarks/openapi/` measures **framework overhead** — +typed signatures, schema validation, response serialization, +OpenAPI metadata cost — with the server held fixed per framework. + +## 1. Layout — extract shared core + +Refactor first, then add the new suite. Three siblings under `benchmarks/`: + +``` +benchmarks/ + _core/ # shared runner infra + runner.py # spawn → readiness probe → oha → parse → kill + types.py # Scenario, Stack, Cell, RunReport + pythons.py # PYTHONS matrix (moved from http/_pythons.py) + render.py # markdown + HTML reports + cli.py # --duration / --filter / --scenarios / --pythons / --stacks / --group + http/ # existing suite, slimmed + scenarios.py + stacks.py + runner.py # thin entry point → benchmarks._core.cli.main(...) + apps/ + results/ + openapi/ # new + scenarios.py + stacks.py + runner.py + apps/ + results/ +``` + +`_core.cli.main(scenarios, stacks, apps_pkg, results_dir, ...)` parameterizes +the suite-specific bits. `apps_pkg` is the dotted prefix used by the runner +to spawn each stack as `python -m .`. + +`Cell` keeps `stack`, `scenario`, `rps`, `p50/p90/p99`, etc., and replaces +today's hard-coded `app`/`backend`/`selectors`/`pool`/`acceptor`/`tags` with +a generic `dims: dict[str, str]`. Suite-specific dimensions: + +- http suite dims: `{app, backend, selectors, pool, acceptor}` +- openapi suite dims: `{framework, server, pydantic}` + +The HTML renderer reads `dims` keys and exposes them as filters generically — +the http suite's existing UI is preserved with no behavior change. + +`just bench-http` stays as-is. `just bench-openapi` is added with the same +flag surface. + +## 2. Stacks (v1) + +| Stack ID | Framework | Server | +|---------------------|-------------------------------------|---------------------------------| +| `localpost_openapi` | `localpost.openapi.HttpApp` | `localpost.http` (h11) | +| `flask_openapi` | `flask-openapi3 ~=5.0` | gunicorn sync, `--threads 32` | +| `fastapi` | FastAPI current | uvicorn, 1 worker | + +`flask-openapi3` was renamed to `flask-openapi` upstream; we pin the v5 line. + +Pinned in a new `[dependency-groups.bench-openapi]` group so the main install +isn't polluted. Pydantic v2 is implicit via FastAPI + flask-openapi3. + +Future variants (out of scope for v1): granian, httptools, multi-selector +for `localpost_openapi`. Stack-variant slots are already supported by the +shared runner. + +## 3. Scenarios (v1) + +Each scenario defines one wire contract; every stack implements it +idiomatically per framework. + +| Scenario | Method · Path | Expected | What it exercises | +|----------------------|----------------------------------------------|----------|-------------------------------------------------------| +| `plaintext` | `GET /ping` | 200 | Pure dispatch — calibration anchor. | +| `path_param_typed` | `GET /items/{item_id}` (int) | 200 | Path coercion. | +| `query_validation` | `GET /search?q=…&limit=…&offset=…` | 200 | Query parse + constrained validation. | +| `body_roundtrip` | `POST /users/{id}/profile` JSON in/out | 200 | Schema-driven decode → normalize → encode (headline). | +| `validation_failure` | `POST /users/{id}/profile` with bad JSON | 422 | Error-path throughput. | + +The `body_roundtrip` payload mirrors +`benchmarks/http/scenarios.py::_PROFILE_UPDATE_BODY` so wire contracts +align across both suites for cross-checking. + +For `validation_failure`, the runner needs a small tweak: today success is +`status_2xx`. Move success classification to a per-`Scenario` +`expected_status` (already on the dataclass) and rename +`status_2xx`/`status_other` on `Cell` to `status_expected`/`status_other`. +The http suite is unaffected — every existing scenario expects 200. + +## 4. Workload definitions + +One model schema, defined three times in idiomatic style — not shared via a +common library: + +- **LocalPost**: `@dataclass` model + return-type union + (`Profile | BadRequest[ProblemDetails]`). +- **FastAPI**: Pydantic v2 model + `response_model=Profile` + + `HTTPException(422)` for the failure case. +- **flask-openapi3**: Pydantic v2 model + decorator-attached schema classes + per its v5 API. + +Same fields, same types, same constraints across all three — the +validation-failure body is rejected by all three for the same field reason, +so we're comparing equivalent work. + +We test the **full typed cycle**, including framework-default response +serialization. No "raw FastAPI without `response_model`" variant — that +isn't a realistic deployment shape and isn't what we're measuring. + +## 5. Implementation order + +1. Refactor `benchmarks/http/` into `benchmarks/_core/` + thin + `benchmarks/http/`. Verify `just bench-http --duration 5 + --stacks localpost_h11,flask_gunicorn` produces equivalent output. +2. Scaffold `benchmarks/openapi/` with `plaintext` only across all three + stacks. Confirm runner spawns/probes/kills each. +3. Add `path_param_typed` and `body_roundtrip`. +4. Add `query_validation` and `validation_failure` (the latter requires the + `expected_status` plumbing). +5. README under `benchmarks/openapi/` mirroring + `benchmarks/http/README.md`. Top-level `benchmarks/README.md` updated to + describe the split. + +## 6. Caveats (carry over from http suite README) + +- Single-host noise: relative ordering on the same run is what matters. +- Single-process by design: real deployments multiply by N workers; the + relative ordering still holds. +- Not for CI gates: GitHub Actions runners are too noisy for HTTP throughput + regression. diff --git a/plans/static-files.md b/plans/static-files.md new file mode 100644 index 0000000..b0ce18e --- /dev/null +++ b/plans/static-files.md @@ -0,0 +1,188 @@ +# Static file serving + +## Context + +The HTTP server (`localpost/http/`) is tuned for the JSON-API common case +— small bodies, one-chunk responses, headers + first body chunk coalesced +into a single `sendall` (`server_h11.py:420`, `server_httptools.py:639`). +Serving static files through this path means reading every byte into +Python and shipping it through `send` — no zero-copy, no Range, no +conditionals, no cache-control story. + +The user's deployment is **CDN-fronted** (CloudFront / Cloud CDN / +Cloudflare). With `Cache-Control: public, max-age=…, immutable` on +fingerprinted assets, origin is hit roughly once per file per edge per +forever. That shrinks the optimization target: + +- **In scope (v1):** zero-copy body via `socket.sendfile()`, conditional + GET (`ETag` / `If-None-Match`, `Last-Modified` / `If-Modified-Since`), + single-range support, `Cache-Control` passthrough. +- **Out of scope (CDN handles them, or rare):** on-the-fly compression, + precompressed sidecars (`foo.css.br`, `foo.css.zst`), multipart byte + ranges, symlink-escape hardening beyond `Path.resolve()`. + +## Design + +### `static_handler` — public entry + +New module: `localpost/http/static.py`. Exports a single function: + +```python +def static_handler( + root: str | os.PathLike[str], + *, + prefix: bytes = b"/", + cache_control: str | None = None, + index: str | None = "index.html", +) -> RequestHandler: ... +``` + +`prefix` is stripped off `ctx.request.path` before resolution — lets the +caller mount the handler under any URL prefix without a router. `root` is +resolved once at construction (`Path(root).resolve(strict=True)`), and +every request path resolves under it via `is_relative_to`. + +Returns a closure that satisfies `RequestHandler` — +`Callable[[HTTPReqCtx], BodyHandler | None]`. + +### Pre-body / body split (mirrors `Router`) + +Stat happens once in pre-body. Decisions that don't need the body run +inline on the selector via `ctx.complete()`; the actual sendfile runs in +a returned `BodyHandler` (which `thread_pool_handler` will dispatch to a +worker). Open happens in the body handler — no syscall wasted on a 304. + +| Outcome | Phase | Action | +|---------|-------|--------| +| 405 wrong method | pre-body, selector | `complete()` with `Allow: GET, HEAD` | +| 404 not found / traversal / not file | pre-body, selector | `complete()` | +| 304 If-None-Match / If-Modified-Since | pre-body, selector | `complete()` | +| 416 unsatisfiable Range | pre-body, selector | `complete()` with `Content-Range: bytes */size` | +| 200 / 206 HEAD | pre-body, selector | `complete()` (no body) | +| 200 / 206 GET | returns `BodyHandler` | sendfile on worker | + +### Sendfile path + +`socket.sendfile()` requires a blocking socket — non-blocking is +documented as unsupported. The existing `_send_all` blocking-with-timeout +pattern (`_base.py:275`) and `_maybe_give_back` non-blocking reset +(`server_h11.py:340`) already cover the lifecycle: + +```python +sock = ctx.conn.sock +sock.settimeout(ctx.selector.config.rw_timeout) +ctx.start_response(Response(status, headers)) # buffers header bytes +ctx.send(b"") # flushes header bytes +with open(path, "rb") as f: + sock.sendfile(f, offset=range_start, count=range_len) +ctx.finish_response() # empty EOM under fixed CL +# give-back path resets timeout to non-blocking +``` + +`send(b"")` flushes the buffered headers on both backends — verified: +- h11: `parser.send(h11.Data(b""))` returns `b""`, the + `_pending_header_bytes is not None` branch combines and sends + (`server_h11.py:425`). +- httptools: `if not chunk and self._pending_header_bytes is None: return` + is bypassed when headers are pending, falls through to the + `_pending_header_bytes is not None` send (`server_httptools.py:639`). + +No new method on `HTTPReqCtx` Protocol. + +### Helpers in `static.py` + +- `_resolve(root: Path, url_path: bytes) -> Path | None` — percent-decode, + reject `..` segments defensively, `Path.resolve()`, then + `is_relative_to(root_resolved)`. Returns `None` on traversal / missing. +- `_etag(st) -> bytes` — `f'"{st.st_size:x}-{st.st_mtime_ns:x}"'`. +- `_if_none_match(header: bytes, etag: bytes) -> bool` — comma-split, + trim whitespace, weak-prefix-aware compare. +- `_parse_range(header: bytes, size: int) -> tuple[int, int] | None | "unsatisfiable"` + — `(start, length)`, `None` for absent / multi-range / unparsable + (200 fallback per RFC 7233 §3.1), unsatisfiable sentinel for 416. +- `_content_type(path: Path) -> bytes` — `mimetypes.guess_type`, + `application/octet-stream` fallback. +- `_serve_file(ctx, path, headers, offset, length)` — the sendfile body + handler. + +### Composition (user-visible) + +Documented in the README, not built-in: + +```python +api = thread_pool_handler(routes.build().as_handler(), max_concurrency=8) +static = thread_pool_handler( + static_handler("/var/www", prefix=b"/static/", + cache_control="public, max-age=31536000, immutable"), + max_concurrency=128, backlog=64, +) + +def root(ctx): + return (static if ctx.request.path.startswith(b"/static/") else api)(ctx) + +async with http_server(config, root): + ... +``` + +Two pools, sized for their workloads — slow-client downloads can't pin +API workers. + +## Plan + +### Step 1 — `static_handler` skeleton + selector-side outcomes + +1. Create `localpost/http/static.py` with `_resolve`, `_etag`, + `_if_none_match`, `_parse_range`, `_content_type`, and the public + `static_handler` returning a `RequestHandler` closure. +2. Implement pre-body branches: 405, 404, 304, 416, HEAD-200/206. All via + `ctx.complete()` inline. Return `None` from those paths. +3. Implement the GET 200 / 206 branch: stat + headers built pre-body, + `BodyHandler` returned that opens, sets timeout, sendfile, finish. +4. Re-export `static_handler` from `localpost/http/__init__.py`. +5. Run `just check localpost/http/static.py`. + +### Step 2 — Tests + +`tests/http/test_static.py`: +- Path traversal (`/static/../etc/passwd` → 404). +- Range: full, `0-99`, `100-199`, suffix `-50`, beyond-EOF (416), + unparsable (200 fallback), multi-range (200 fallback). +- Conditionals: `If-None-Match` hit/miss; `If-Modified-Since` before / + equal / after mtime. +- HEAD parity: same headers, no body. +- Content-Type: `.html`, `.css`, `.png`, unknown extension. +- Index: directory → `index.html`; missing index → 404. +- Method: `PUT` → 405 + `Allow: GET, HEAD`. +- `Cache-Control` passthrough verbatim. +- Large file (>1 MB) round-trip — exercises sendfile multi-iteration. + +Run via `just unit-tests`. + +### Step 3 — Docs + example + +1. New section in `localpost/http/README.md` covering the handler, the + sendfile / cache-control story, and the two-pool composition pattern. +2. New `examples/http/static_files.py` — minimal working server. + +## Followups (separate PRs / not now) + +- **Precompressed sidecars** — `foo.css.br`, `foo.css.zst`. Useful when + the CDN doesn't negotiate the encoding the client wants (zstd today). + Pick `path + ".br"` / `".zst"` / `".gz"` if it exists and the client's + `Accept-Encoding` includes it. Adds a stat-per-encoding to the + pre-body cost; keep behind an opt-in arg. +- **`compress_handler`** — on-the-fly gzip/br/zstd middleware for the + *dynamic* path (JSON API responses), for users not behind a CDN. + Wraps an inner `RequestHandler`, intercepts `complete(...)`, + compresses, adjusts `Content-Length` + `Content-Encoding`. SSE / + chunked streaming compression is a further follow-up. +- **Multipart byte ranges** — multi-range `Range:` requests serialised + as `multipart/byteranges`. Niche; current behaviour is to fall back + to a 200 full-body response, which is RFC-compliant. +- **Per-route pool API** — currently composition is the user's problem; + a `Routes` builder that takes a per-route pool argument would let the + static / API split happen behind one router instead of a hand-rolled + dispatcher. +- **Symlink-escape hardening** — current `Path.resolve()` follows + symlinks before the `is_relative_to` check, which is correct, but + some deployments want symlinks rejected outright. Opt-in flag. diff --git a/plans/websocket-support.md b/plans/websocket-support.md new file mode 100644 index 0000000..8419910 --- /dev/null +++ b/plans/websocket-support.md @@ -0,0 +1,94 @@ +# WebSocket support — sketch (not yet implemented) + +## Context + +`HTTPReqCtx` (after the WSGI deployment refactor in +`plans/wsgi-deployment.md`) is shaped for a request/response lifecycle. +WebSocket connections need a different lifecycle — long-lived, bidirectional +message stream after the handshake. They don't fit `HTTPReqCtx` cleanly, +so this is a sibling Protocol. + +This plan is a forward-compatibility sketch — we're not implementing +WS now. The point is to make sure the design we do now (Protocol shape, +router shape, transport adapters) leaves the door open. + +## Protocol — `WebSocketCtx` + +```python +class WebSocketCtx(Protocol): + request: Request # initial handshake request + remote_addr: str | None + local_addr: str | None + scheme: str # "ws" | "wss" + attrs: dict[Any, Any] + + def accept(self, *, subprotocol: str | None = None) -> None: ... + def reject(self, status: int = 403) -> None: ... + def receive(self) -> WSMessage: ... # blocks; returns text / bytes / Close + def send_bytes(self, data: bytes) -> None: ... + def send_str(self, data: str) -> None: ... + def close(self, code: int = 1000) -> None: ... + +WSHandler = Callable[[WebSocketCtx], None] +``` + +`WSMessage` is a small ADT — `WSText(str)` / `WSBinary(bytes)` / +`WSClose(code, reason)`. + +`request` is the initial handshake request (path, headers, etc.) so +auth / routing decisions reuse existing helpers. + +## Router + +`Routes.add_ws("/path", handler)` — a separate dispatch table from +the HTTP method table. The connection-upgrade decision (when the native +server sees `Upgrade: websocket`) consults this table; success → +`WebSocketCtx` flow, miss → 404 over HTTP. + +`Router._match` gains one more variant in the result type. Per-route +middlewares stay HTTP-only; WS middlewares are a follow-up if needed. + +## Transport fit + +| Transport | WS support | +| --------- | ---------- | +| native `localpost.http` | needs WS framing in a backend; long-lived borrow already fits the borrow/return state machine. Sync handlers; ~hundreds of concurrent sockets per process. | +| WSGI | **not supported.** WSGI has no upgrade primitive. `to_wsgi(handler)` raises if any WS routes are registered, or dispatches HTTP-only. | +| ASGI / RSGI | Protocol-side fit is clean (both have native WS scopes). Out of scope for this sketch. | + +## Concurrency model + +Sync `WebSocketCtx`. Each connection holds one worker thread for its +lifetime. Suitable for "simplicity first" — users who need 10k concurrent +sockets deploy under an async transport (ASGI/RSGI adapter, future). + +Cancellation: same `check_cancelled()` shape as HTTP, called between +`receive()` returns and during `send_*`. Selector-side disconnect detection +already exists for the borrowed-conn case; reuses without changes. + +## OpenAPI integration (future) + +OpenAPI 3.x doesn't formally describe WebSocket endpoints. Options: + +- AsyncAPI 2/3 spec emitted alongside the OpenAPI doc. +- Keep WS routes opaque in the OpenAPI doc (don't list them). +- Vendor extension (`x-websocket: true`) — non-standard but discoverable. + +Decision deferred until WS is actually implemented. + +## Pre-work in scope of the WSGI plan + +The Protocol additions in `plans/wsgi-deployment.md` (`remote_addr`, +`local_addr`, `scheme`) are the same fields `WebSocketCtx` needs. No +extra design work today — just don't paint ourselves into a corner. + +## Open questions to revisit before implementing + +1. **Backpressure on send.** Native sync impl: blocking write. Is that + sufficient, or do we need a queue + `try_send`? +2. **Per-message vs streaming frames.** RSGI exposes per-message; we + probably do too. Continuation frames are server-internal. +3. **Subprotocol negotiation.** Surface as `accept(subprotocol=...)` + parameter and a `request.headers` lookup helper. +4. **Sentry integration.** A WS connection is one transaction; spans + per `receive()` / `send_*()`? Defer until use-case is concrete. diff --git a/plans/wsgi-deployment.md b/plans/wsgi-deployment.md new file mode 100644 index 0000000..482070c --- /dev/null +++ b/plans/wsgi-deployment.md @@ -0,0 +1,229 @@ +# WSGI deployment for `Router` and `HttpApp` + +## Context + +Today `Router` and `localpost.openapi.HttpApp` only run under +`localpost.http`'s native server (`http_server` / `start_http_server`). +The router and the OpenAPI framework only touch a small slice of +`HTTPReqCtx`, but the Protocol exposes localpost-server-only bits +(`selector`, `conn`, `borrow`) that prevent a WSGI implementation from +satisfying it. + +Goal: deploy `HttpApp` (and bare `Router`) under Gunicorn / uWSGI / +Werkzeug. Single Protocol — no parallel `RequestCtx` — by trimming +`HTTPReqCtx` until both transports can satisfy it. ASGI is out of scope +for this plan. + +## Surface audit + +Used by router + `localpost.openapi`: +- `request` (`.path` / `.method` / `.query_string` / `.headers`) +- `body`, `attrs`, `response_status` +- `start_response` / `send` / `finish_response` / `complete` + +Used by static handler: +- `sendfile(response, file, offset, count)` + +Used by user-facing examples (`middleware_rate_limit.py`): +- `ctx.conn.sock.getpeername()[0]` — replace with `ctx.remote_addr`. + +Used internally only (native server / `_pool.py` / `emit_handler_error`): +- `selector`, `conn`, `borrow`, `borrowed`. + +`ctx.selector` is read in three places: `_pool.py` (concrete native; not +in Protocol after the trim), and `wsgi.py:_build_environ` for +``SERVER_NAME`` / ``SERVER_PORT``. A new `local_addr: str | None` +covers the latter without exposing the selector. + +## Protocol changes + +After the trim: + +```python +class HTTPReqCtx(Protocol): + request: Request + body: bytes + response_status: int | None + attrs: dict[Any, Any] + remote_addr: str | None + local_addr: str | None + scheme: str # "http" | "https" + borrowed: bool + def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... + def receive(self, size: int = ..., /) -> bytes: ... + def start_response(self, r: Response | InformationalResponse, /) -> None: ... + def send(self, chunk: Buffer, /) -> None: ... + def finish_response(self) -> None: ... + def complete(self, r: Response, body: bytes | None = None) -> None: ... + def stream(self, r: Response, chunks: Iterator[bytes]) -> None: ... + def sendfile(self, r: Response, file: BinaryIO, offset: int, count: int) -> None: ... +``` + +Removed from Protocol (kept on the concrete native ctx): +- `selector` — two callsites in `wsgi.py` (`_build_environ` for the + inverse `wrap_wsgi` adapter); thread `(host, port)` through directly + or reach the concrete ctx at those sites. +- `conn` — internal callers (`_pool.py`, `_base.emit_handler_error`, + native backends) re-typed against the concrete native ctx; user-facing + need (peer address) is covered by the new `remote_addr` field. + +No-op on WSGI: +- `borrowed` — always `True` (the WSGI worker already owns the + connection — that's exactly what borrowed means). +- `borrow()` — `nullcontext(self)`. + +Added: +- `remote_addr: str | None` — natively from `socket.getpeername()`, + under WSGI from `environ['REMOTE_ADDR']`. +- `local_addr: str | None` — natively from the listening socket + (replaces the `ctx.selector.config.host` / `ctx.selector.port` reach), + under WSGI from `environ['SERVER_NAME']` + `environ['SERVER_PORT']`. +- `scheme: str` — `"http"` or `"https"`. Natively defaults to `"http"` + (no TLS today); under WSGI from `environ['wsgi.url_scheme']`. Useful + for the OpenAPI ``servers`` list and redirect builders behind a + TLS-terminating load balancer. +- `stream(response, chunks)` — declarative streaming (see below). + +## SSE refactor — `ctx.stream(response, chunks)` + +Today `_stream_sse` (`operation.py:576`) drives a manual +`start_response` / `send` loop with cancellation interleaving. This +becomes: + +```python +def _stream_sse(ctx, result, adapters): + headers = _merge_sse_headers(result.headers) + ctx.stream( + _Response(status_code=result.status_code, headers=headers), + iter_events(result.body, adapters), + ) +``` + +Native impl wraps the existing imperative trio with cancellation: + +```python +def stream(self, response, chunks): + self.start_response(response) + try: + for chunk in chunks: + try: check_cancelled() + except LookupError: pass + self.send(chunk) + except RequestCancelled: + return + self.finish_response() +``` + +WSGI impl captures `(response, chunks)` and returns the iterator +straight to the WSGI server — zero threads, zero queue: + +```python +def stream(self, response, chunks): + self._streaming = (response, chunks) +``` + +The imperative trio (`start_response` / `send` / `finish_response`) +stays on the Protocol — `compress.py` still wraps it for incremental +compression, and hand-written streaming handlers keep working. WSGI +falls back to thread+queue for the imperative trio only. + +## WSGI adapter + +```python +def to_wsgi(handler: RequestHandler) -> WSGIApplication: + def wsgi_app(environ, start_response): + ctx = _WSGIReqCtx(environ) + body_handler = handler(ctx) + if body_handler is not None: + body_handler(ctx) + return ctx._respond(start_response) + return wsgi_app +``` + +`_WSGIReqCtx._respond(start_response)` branches: +- eager `complete()` → `start_response(...)`; return `[body]`. +- `stream(...)` captured → `start_response(...)`; return `chunks`. +- imperative `start_response/send/.../finish_response` → thread+queue + fallback. + +`sendfile` — use `environ['wsgi.file_wrapper']` if present, else +chunked read+yield. + +## Public API + +```python +HttpApp.as_wsgi(self) -> WSGIApplication +Router.as_wsgi(self) -> WSGIApplication +``` + +Deployment: `gunicorn myapp:app.as_wsgi()`. `thread_pool_handler` does +not apply — the WSGI server's worker model is the pool. +`check_cancelled()` is a no-op under WSGI (no socket handle inside the +WSGI app); `_stream_sse` already gracefully handles `LookupError`. + +## Footprint + +- `_base.py` — slim Protocol; concrete native ctx adds `selector` / + `conn` as instance attributes (not in Protocol). Add `stream()` + default impl. +- `_pool.py`, `emit_handler_error` — re-typed to concrete native ctx + (~5 lines). +- `wsgi.py` (existing inverse adapter) — drop `ctx.selector.config.host` + reach; thread `(host, port)` into `_build_environ`. +- `examples/http/middleware_rate_limit.py` — `ctx.conn.sock.getpeername()` + → `ctx.remote_addr`. +- New: `to_wsgi(handler)` + `_WSGIReqCtx` in `wsgi.py`. +- New: `HttpApp.as_wsgi()` / `Router.as_wsgi()`. +- `_stream_sse` rewritten to use `ctx.stream(...)`. + +## Order of work + +1. Slim Protocol (drop `selector` / `conn` / `borrow*`); add + `remote_addr`. Re-type internal callers. +2. Add `ctx.stream()` to Protocol + native impl; rewrite `_stream_sse`. +3. `to_wsgi(handler)` + `_WSGIReqCtx` with eager / `stream` / + imperative-fallback branches. +4. `HttpApp.as_wsgi()` / `Router.as_wsgi()` + example + tests. + +## Forward compatibility — RSGI alignment + +The shape after the trim is intentionally close to Granian's RSGI: +single ctx object, methods drive the response 0→100, handler keeps +control until exit. The mapping: + +| `HTTPReqCtx` | RSGI | +| ------------------------------------- | --------------------------------- | +| `request.{path,method,headers,...}` | `scope` | +| `request.http_version` (`b"1.1"` …) | `scope.http_version` | +| `remote_addr` / `local_addr` | `scope.client` / `scope.server` | +| `scheme` | `scope.scheme` | +| `body`, `receive(size)` | `protocol.__call__()` / `__aiter__()` | +| `complete(response, body)` | `response_bytes(...)` | +| `stream(response, chunks)` | `response_stream(...)` + `send_bytes(...)` | +| `sendfile(response, file, ...)` | `response_file_range(...)` | + +This means an RSGI deployment adapter (`to_rsgi(handler)`) is a +straight protocol translation if/when we want it — no Protocol redesign. + +### HTTP/2 + +Transparent. No Protocol changes — `request.http_version` already +takes `b"2"`. When we eventually want HTTP/2, swap the parser +(h2 / Hypercorn / Granian) — handler code is unchanged. `stream()` runs +over an HTTP/2 stream the same as it does over `Transfer-Encoding: +chunked`. + +### WebSocket + +Sibling Protocol, not a member of `HTTPReqCtx`. See +`plans/websocket-support.md` for the sketch. WSGI cannot carry WS; +`to_wsgi(handler)` raises if the registered routes include any WS +handlers, or just dispatches HTTP-only. + +## Out of scope + +- ASGI / RSGI deployment. +- HTTP/2 transport (Protocol is ready; needs a parser swap). +- WebSocket support (separate plan). +- Compression on the WSGI streaming path (works today via thread+queue + fallback; revisit if needed). diff --git a/pypi_readme.md b/pypi_readme.md index 44b38e7..84eace0 100644 --- a/pypi_readme.md +++ b/pypi_readme.md @@ -1,15 +1,50 @@ # localpost -Simple in-process task scheduler & consumers framework for different message brokers. +A small async Python framework for long-running processes: service hosting, +in-process task scheduler, and a lightweight HTTP server. Built on +[AnyIO](https://anyio.readthedocs.io/) — runs on asyncio **and** Trio. -## Scheduler +Python 3.12+ required. -TBD +## Features -## Consumers +- **Hosting** — structured service lifecycle, signal handling, middleware. +- **Scheduler** — declarative triggers (`every`, `after`, `cron`) composed with + operators like `every("1m") // delay((0, 10))`. +- **HTTP** — small h11-based sync server; wrap any WSGI app. +- **DI** — `.NET`-style scoped IoC container; optional Flask integration. +- **Free-threaded ready** — pure Python, no C extensions; runs on free-threaded + CPython 3.14t (no-GIL) with the default `[http]` (h11) backend. Verified by + the bench: ~3x RPS at `selectors=1`. The optional `[http-fast]` (httptools) + backend will be no-GIL-clean once httptools 0.8 lands; the currently-released + 0.7.x re-enables the GIL on import. -TBD +## Quick start -## Hosting +```python +import random +from localpost.hosting import run_app +from localpost.scheduler import after, every, scheduled_task -TBD + +@scheduled_task(every("3s")) +async def task1(): + return random.randint(1, 22) + + +@scheduled_task(after(task1)) +async def task2(x: int): + print(f"task1 emitted: {x}") + + +if __name__ == "__main__": + run_app(task1, task2) +``` + +## Docs + +- [GitHub README](https://github.com/alexeyshockov/localpost.py) — full overview. +- [Module docs](https://github.com/alexeyshockov/localpost.py/tree/main/localpost) — per-module READMEs. +- [Changelog](https://github.com/alexeyshockov/localpost.py/blob/main/CHANGELOG.md). + +MIT licensed. diff --git a/pyproject.toml b/pyproject.toml index 2fa596f..01cf95f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["pdm-backend", "pdm-build-locked"] -build-backend = "pdm.backend" +requires = ["uv_build>=0.9.10,<0.13"] +build-backend = "uv_build" # See also https://daniel.feldroy.com/posts/2023-08-pypi-project-urls-cheatsheet [project.urls] @@ -10,24 +10,24 @@ Docs = 'https://alexeyshockov.github.io/localpost.py/' [project] name = "localpost" -description = "Consumers framework for different message brokers & simple in-process task scheduler" -requires-python = ">=3.10" -dynamic = ["version"] +version = "0.6.0.b1" +description = "Service hosting, in-process task scheduler, and a lightweight HTTP/1.1 server for long-running async Python processes" +requires-python = ">=3.12" readme = "pypi_readme.md" license = "MIT" # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#pep-639-license-declaration authors = [ { name = "Alexey Shokov" }, ] keywords = [ - "scheduler", "cron", - "message brokers", "sqs", "kafka", "nats", "rabbitmq", + "hosting", "scheduler", "cron", + "http", "wsgi", "anyio", + "free-threading", "no-gil", ] classifiers = [ "Development Status :: 4 - Beta", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", "Framework :: AsyncIO", "Framework :: AnyIO", @@ -42,94 +42,197 @@ classifiers = [ "Intended Audience :: System Administrators", ] dependencies = [ - "typing_extensions ~=4.10", - "anyio >=3.6,<5.0", +# "typing_extensions ~=4.10", + "anyio ~=4.12", ] [project.optional-dependencies] -http = [ # Hosting service - "uvicorn ~=0.30", -] -grpc = [ # Hosting service - "grpcio ~=1.68", -] cron = [ "croniter >=2.0,<4.0", ] -scheduler = [ # TODO Better name +scheduler = [ # "localpost[cron]", "humanize >=3.0,<5.0", "pytimeparse2 ~=1.6", ] -sqs = [ - "aiobotocore ~=2.15", -# "aws-lambda-powertools", # To migrate from Lambda +http = [ + "h11 ~=0.16", +# "click ~=8.0", # HTTP Server CLI +] +http-fast = [ + # C-based HTTP/1.1 parser (llhttp) + # NOTE! Under free-threaded CPython, 0.7.1 and below auto-re-enables the GIL on import. + "httptools @ git+https://github.com/MagicStack/httptools.git", +] +http-compress = [ + # Brotli compression for compress_handler. Gzip is stdlib and always available. + "brotli ~=1.1", +] +rsgi = [ + # Granian's RSGI bridge — pulled in by users who deploy under Granian + # via ``localpost.http.to_rsgi`` or ``localpost.hosting.HostRSGIApp``. + "granian ~=2.7", ] -kafka = [ - "confluent-kafka ~=2.4", -# "confluent-kafka[schemaregistry,protobuf] ~=2.4", +click = [ + "click ~=8.0", ] -nats = [ - "nats-py ~=2.8", +openapi = [ +# "localpost[http]", + "msgspec ~=0.19", ] -rabbitmq = [ - "aio-pika >=9.1,<10.0", +openapi-attrs = [ + # attrs handlers + cattrs for structuring/unstructuring. Schema generation walks + # ``attrs.fields(cls)`` directly. Both libraries are user-installed; not a runtime + # dep of the base ``openapi`` extra. + "attrs >=23", + "cattrs >=24", ] -# Yay, https://peps.python.org/pep-0735/ is finally here! -#[tool.pdm.dev-dependencies] [dependency-groups] dev = [ "icecream ~=2.1", "structlog ~=25.0", + "trio ~=0.32", ] -dev-consumers = [ - "aws-lambda-powertools", - "confluent-kafka[schemaregistry,protobuf]", - "grpcio-tools ~=1.68", - "protobuf ~=5.0", +dev-tools = [ + # Linters / type checkers used by ``just check``, ``just format``, and CI. + "ruff ~=0.15", + "ty ~=0.0.34", + "basedpyright ~=1.39", +] +dev-hosting-services = [ + "uvicorn ~=0.30", + "hypercorn ~=0.17", + "grpcio ~=1.68", + "click ~=8.0", +] +dev-http = [ + "flask ~=3.1", + "httpx", +] +dev-openapi = [ + # Pydantic is supported via localpost.openapi.pydantic but is NOT a runtime + # dep of the openapi extra — end users install pydantic themselves. We pull + # it in here so our own examples and tests can exercise that path. + "pydantic ~=2.7", + # Same posture for attrs/cattrs (see the ``openapi-attrs`` extra). + "attrs >=23", + "cattrs >=24", +] +dev-sentry = [ + "sentry-sdk ~=2.51", ] dev-otel = [ "opentelemetry-exporter-otlp", # Both gRPC and HTTP - "opentelemetry-instrumentation-confluent-kafka >=0.50b0", ] dev-types = [ "types-croniter", -# "types-confluent-kafka", # Many signatures do not match to the actual implementation - "types-protobuf", -# "grpc-stubs", # Hasn't been updated for 2+ years... - "types-boto3[sqs]", - "types-aiobotocore[sqs] ~=2.15", + "types-grpcio", # Replaces grpc-stubs, see: https://github.com/python/typeshed/pull/11204 ] examples = [ - "fast-depends ~=2.4", - "fastapi-slim ~=0.111", - 'psycopg[binary,pool] ~=3.2', + "fastapi-slim ~=0.128", ] tests = [ - "pytest ~=8.1", - "anyio[test]", + "pytest ~=9.0", "pytest-xdist", + "pytest-timeout ~=2.3", + "setproctitle", # For pytest-xdist, to have descriptive process names ] -unit-tests = [ +tests-unit = [ "pytest-mock ~=3.14", - "pytest-cov ~=6.0", + "pytest-cov ~=7.0", "coverage[toml] ~=7.6", + "hypothesis ~=6.100", ] -integration-tests = [ - "boto3 ~=1.38", - "testcontainers[kafka,localstack,nats] ~=4.8", +bench = [ + # Macro (HTTP load): peer servers + ASGI app stack. + "starlette ~=1.0", + "uvicorn ~=0.30", + "a2wsgi", + "cheroot ~=11.1", + "gunicorn ~=25.0", + "granian ~=2.7", + # Micro (pytest-benchmark): regression smoke for router / URI template. + "pytest-benchmark ~=5.2", +] +bench-openapi = [ + # Macro (OpenAPI framework comparison): peer typed-API frameworks. + "fastapi ~=0.136", + # flask-openapi3 was renamed to flask-openapi for the v5 line. v5 is + # currently rc; we accept pre-releases here (uv tool flag `--prerelease=allow` + # at install time, see benchmarks/_setup.py). + "flask-openapi >=5.0.0rc1,<6", + # Pydantic v2 is what both peers drive; LocalPost tests its msgspec path. + "pydantic ~=2.7", +] +docs = [ + # Pre-1.0; pin to 0.x and accept early-adopter risk. Reads mkdocs.yml + # natively. Replaces mkdocs + mkdocs-material. + "zensical >=0.0.40,<0.1", ] [tool.coverage.run] -omit = ["tests/*"] +source = ["localpost"] +branch = true +# Internal-only entry points and dev/debug shims that don't merit coverage tracking. +omit = [ + "localpost/_debug.py", + "localpost/http/__main__.py", +] +[tool.coverage.report] +# Anything below this fails the report. Set a few points under the current +# total so day-to-day churn doesn't break CI; raise it as the suite grows. +fail_under = 85 +show_missing = true +skip_covered = false +precision = 2 +exclude_also = [ + # Type-checking-only branches don't run at test time. + "if TYPE_CHECKING:", + "if typing\\.TYPE_CHECKING:", + # Stub / Protocol / abstract method bodies. + "@overload", + "@(typing\\.)?overload", + "@(abc\\.)?abstractmethod", + "raise NotImplementedError", + "^\\s*\\.\\.\\.\\s*$", + # Defensive guards that aren't reachable from tests. + "if __name__ == .__main__.:", + # Explicit opt-out marker. + "pragma: no cover", +] -[tool.pyright] -include = ["localpost"] +[tool.coverage.paths] +# Map matrix-cell paths back to a single canonical layout so combined coverage +# from multiple Python versions / runners merges cleanly. +source = [ + "localpost/", + "*/localpost/", +] + +[tool.vulture] +# Dead-code finder. Run via ``just deadcode``. +# +# ``min_confidence`` is set high (80) so vulture stays a useful signal: the +# 60-confidence band is dominated by public API surface vulture can't see +# being used (decorated routes, hosting services, dataclass fields exposed +# through reflection, etc.). The whitelist below names the 80+ confidence +# false positives explicitly. +paths = ["localpost"] +min_confidence = 80 +sort_by_size = true +ignore_names = [ + # Overload signature parameters — present for type-checker consumption only, + # implementation uses ``_=None``. + "ret_t", + # Bound-method protocol parameters that ARE used in other implementations + # of the same protocol; vulture sees only this file. + "this_task", +] [[tool.mypy.overrides]] -module = ["grpc.*", "pytimeparse2.*", "confluent_kafka.*"] +module = ["grpc.*", "pytimeparse2.*"] follow_untyped_imports = true [tool.ruff] @@ -137,32 +240,89 @@ line-length = 120 format.docstring-code-format = true [tool.ruff.lint] +pyupgrade.keep-runtime-typing = true +pydocstyle.convention = "google" +# For the reference, check https://docs.astral.sh/ruff/rules/ select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings "F", # pyflakes + "E", "W", # pycodestyle "I", # isort - "C", # flake8-comprehensions - "B", # flake8-bugbear "Q", # flake8-quotes + "UP", # pyupgrade + "YTT", # flake8-2020 + # TODO Uncomment after adding annotations to all modules + # All "ANN" rules are covered by mypy, except "ANN204" +# "ANN204", # Missing return type annotation for special method ... + "ASYNC", # flake8-async + "S", # flake8-bandit + "BLE", # flake8-blind-except + "B", # flake8-bugbear + "A", # flake8-builtins + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "EXE", # flake8-executable + "FA", # flake8-future-annotations + # XXX Causes warning https://github.com/astral-sh/ruff/issues/8272 + "ISC", # flake8-implicit-str-concat + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "PT", # flake8-pytest-style + # The rest of RET rules don't improve code and may even worsen readability + "RET501", "RET502", "RET503", # flake8-return + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "PTH", # flake8-use-pathlib +# "FIX", # flake8-fixme + "PGH", # pygrep-hooks + "PL", # Pylint + "TRY", # tryceratops + "PERF", # Perflint + "LOG", # flake8-logging + "RUF", # Ruff-specific ] -[tool.ruff.lint.pydocstyle] -convention = "google" - -[tool.pdm] -distribution = true - -[tool.pdm.version] -source = "scm" -write_to = "localpost/__meta__.py" -write_template = "version = '{}'" +ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter + "RUF022", # `__all__` is not sorted + "PGH003", "PGH004", # Use specific rule codes when ignoring type issues + "RUF005", + "PERF203", # ``try``/``except`` in a loop — kept off; readability >> the micro-cost on 3.12+ "zero-cost" exception handling + "TID252", # Prefer absolute imports over relative imports from parent modules + "S101", # Use of `assert` detected + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + "B011", # Do not `assert False` + "SIM105", # Use `contextlib.suppress(GeneratorExit)` instead of `try`-`except`-`pass` + "SIM117", # Use a single ``with`` statement with multiple contexts — wide-cast nesting reads cleaner with separate ``with``s + "PLR0913", # Too many arguments in function definition + "PLR2004", # Magic value used in comparison + # TODO Reduce complexity + "PLR0911", "PLR0912", "PLR0915", # Too many ... + # Too many false positives + "PLW2901", # ... loop variable ... overwritten by assignment target + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` +] +[tool.ruff.lint.per-file-ignores] +"examples/*" = ["T201"] +"benchmarks/*" = ["T201"] +# Tests legitimately use lazy/conditional imports inside test bodies (optional +# extras, internals under test). Top-of-file imports are not always feasible. +"tests/*" = ["PLC0415"] +# Not a Python package; just a hook file mkdocs loads by path. +"docs/*" = ["INP001"] -[tool.pdm.build] -excludes = ["./**/.git", "tests", "examples"] +[tool.uv] +build-backend.module-root = "" +build-backend.module-name = "localpost" [tool.pytest.ini_options] -minversion = "8.0" +anyio_mode = "auto" #addopts = "-q -m 'not integration'" +timeout = 30 testpaths = [ "tests", ] @@ -170,5 +330,10 @@ python_files = [ "*.py", ] markers = [ - "integration: Consumers (SQS, Kafka, NATS, RabbitMQ) integration tests", + "integration: Consumers (SQS, Kafka,..) integration tests", + "no_ambient_pool: opt out of the autouse threadtools ambient pool fixture", ] + +[tool.typos.default.extend-words] +# ``ANDs`` (verb) — selector group spec ANDs with filter spec. +ands = "ands" diff --git a/sonar-project.properties b/sonar-project.properties index b9b4520..a35466c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.projectKey=alexeyshockov_localpost.py sonar.organization=alexeyshockov # Optional -sonar.python.version=3.10 +sonar.python.version=3.12, 3.13, 3.14 sonar.sources=localpost sonar.tests=tests sonar.python.coverage.reportPaths=coverage.xml diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/benchmarks/http_runner.py b/tests/benchmarks/http_runner.py new file mode 100644 index 0000000..8bde599 --- /dev/null +++ b/tests/benchmarks/http_runner.py @@ -0,0 +1,48 @@ +"""Tests for the shared macro benchmark runner internals (oha parser).""" + +from __future__ import annotations + +from benchmarks.macro._core.runner import parse_oha + + +def test_parse_oha_normal(): + raw = { + "summary": {"requestsPerSec": 1234.5, "successRate": 1.0}, + "latencyPercentiles": {"p50": 0.001, "p90": 0.002, "p99": 0.005}, + "statusCodeDistribution": {"200": 100, "500": 2}, + } + parsed = parse_oha(raw, expected_status=200) + assert parsed["rps"] == 1234.5 + assert parsed["p50_ms"] == 1.0 + assert parsed["p99_ms"] == 5.0 + assert parsed["total_requests"] == 102 + assert parsed["status_expected"] == 100 + assert parsed["status_other"] == 2 + + +def test_parse_oha_null_percentiles(): + # oha emits JSON null when there were ~0 responses to bucket. dict.get's + # default does NOT kick in for present-but-null keys. + raw = { + "summary": {"requestsPerSec": None, "successRate": None}, + "latencyPercentiles": {"p50": None, "p90": None, "p99": None}, + "statusCodeDistribution": {}, + } + parsed = parse_oha(raw, expected_status=200) + assert parsed == { + "rps": 0.0, + "p50_ms": 0.0, + "p90_ms": 0.0, + "p99_ms": 0.0, + "total_requests": 0, + "success_rate": 0.0, + "status_expected": 0, + "status_other": 0, + } + + +def test_parse_oha_missing_keys(): + parsed = parse_oha({}, expected_status=200) + assert parsed["rps"] == 0.0 + assert parsed["p50_ms"] == 0.0 + assert parsed["total_requests"] == 0 diff --git a/tests/benchmarks/http_stacks.py b/tests/benchmarks/http_stacks.py new file mode 100644 index 0000000..71c607b --- /dev/null +++ b/tests/benchmarks/http_stacks.py @@ -0,0 +1,109 @@ +"""Tests for the HTTP stack registry + the shared filter language.""" + +from __future__ import annotations + +import pytest + +from benchmarks.macro._core.filters import collect_dim_keys, parse_filters, select_stacks +from benchmarks.macro.http.stacks import GROUPS, STACKS + +_VALID_KEYS = frozenset({"name", "tags"} | collect_dim_keys(STACKS)) + + +def _select(*, names=None, group=None, filters=()): + return select_stacks(STACKS, GROUPS, names=names, group=group, filters=filters) + + +def test_stacks_registry_unique_names(): + names = [s.name for s in STACKS] + assert len(names) == len(set(names)) + + +def test_select_all_by_default(): + assert _select() == STACKS + + +def test_select_by_app(): + selected = _select(filters=["app=flask"]) + assert {s.name for s in selected} == { + "localpost_flask", + "flask_cheroot", + "flask_gunicorn", + "flask_granian", + } + + +def test_select_by_backend_glob_and_selectors(): + selected = _select(filters=["backend=lp-*", "selectors=1"]) + names = {s.name for s in selected} + assert names == { + "localpost_h11", + "localpost_httptools", + "localpost_httptools_inline", + "localpost_wsgi", + "localpost_flask", + } + + +def test_select_negation(): + selected = _select(filters=["app!=starlette"]) + assert all(s.dims["app"] != "starlette" for s in selected) + assert len(selected) == len(STACKS) - 2 + + +def test_select_multi_value_or(): + selected = _select(filters=["app=wsgi,starlette"]) + assert {s.dims["app"] for s in selected} == {"wsgi", "starlette"} + + +def test_select_pool_off(): + selected = _select(filters=["pool=false"]) + assert all(s.dims["pool"] == "false" for s in selected) + assert len(selected) == 3 # the three inline httptools variants + + +def test_group_localpost(): + selected = _select(group="localpost") + assert all(s.dims["backend"].startswith("lp-") for s in selected) + + +def test_group_quick_is_small(): + selected = _select(group="quick") + assert 2 <= len(selected) <= 6 + + +def test_group_plus_filter_ands(): + selected = _select(group="localpost", filters=["selectors=1"]) + assert all(s.dims["backend"].startswith("lp-") and s.dims["selectors"] == "1" for s in selected) + + +def test_explicit_names_bypass_filters(): + selected = _select(names=["flask_gunicorn"], filters=["app=starlette"]) + assert [s.name for s in selected] == ["flask_gunicorn"] + + +def test_unknown_filter_key_raises(): + with pytest.raises(ValueError, match="unknown filter key"): + parse_filters(["foo=bar"], _VALID_KEYS) + + +def test_unknown_name_raises(): + with pytest.raises(ValueError, match="unknown stack name"): + _select(names=["does_not_exist"]) + + +def test_unknown_group_raises(): + with pytest.raises(ValueError, match="unknown group"): + _select(group="not-a-group") + + +def test_filter_without_equals_raises(): + with pytest.raises(ValueError, match="must be 'key="): + parse_filters(["just-a-word"], _VALID_KEYS) + + +def test_groups_have_predicates(): + # Smoke check: every advertised group is callable + returns a non-empty subset. + for name, pred in GROUPS.items(): + hits = [s for s in STACKS if pred(s)] + assert hits, f"group {name!r} is empty" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..75ffe68 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import logging + +import localpost # noqa + +logging.getLogger("localpost").setLevel(logging.DEBUG) diff --git a/tests/consumers/RedpandaContainer.py b/tests/consumers/RedpandaContainer.py deleted file mode 100644 index 2b230bd..0000000 --- a/tests/consumers/RedpandaContainer.py +++ /dev/null @@ -1,35 +0,0 @@ -from textwrap import dedent - -from testcontainers.kafka import RedpandaContainer as BaseRedpandaContainer - - -# See: -# - https://github.com/abiosoft/colima/issues/71#issuecomment-1985144767 -# - https://stackoverflow.com/q/78149093/322079 -class RedpandaContainer(BaseRedpandaContainer): - def get_container_host_ip(self) -> str: - ip = super().get_container_host_ip() - if ip == "localhost": - # At least on macOS (with Lima/Colima), localhost resolves to IPv6 by default, which causes issues - # (see https://github.com/testcontainers/testcontainers-python/issues/553 and others) - return "127.0.0.1" - return ip - - def tc_start(self) -> None: - internal = f"internal://{self.get_docker_client().bridge_ip(self.get_wrapped_container().id)}:29092" - external = f"external://{self.get_container_host_ip()}:{self.get_exposed_port(self.redpanda_port)}" - data = ( - dedent( - f""" - #!/bin/bash - rpk redpanda start --mode dev-container --smp 1 \ - --kafka-addr internal://0.0.0.0:29092,external://0.0.0.0:{self.redpanda_port} \ - --advertise-kafka-addr {internal},{external} \ - --schema-registry-addr internal://0.0.0.0:28081,external://0.0.0.0:{self.schema_registry_port} - """ - ) - .strip() - .encode("utf-8") - ) - - self.create_file(data, RedpandaContainer.TC_START_SCRIPT) diff --git a/tests/consumers/conftest.py b/tests/consumers/conftest.py deleted file mode 100644 index 40f93dd..0000000 --- a/tests/consumers/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -import logging - -from testcontainers.core import utils - -# See https://github.com/testcontainers/testcontainers-python/pull/758 -utils.setup_logger = logging.getLogger diff --git a/tests/consumers/kafka.py b/tests/consumers/kafka.py deleted file mode 100644 index 93adb58..0000000 --- a/tests/consumers/kafka.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging -import random -import string - -import anyio -import pytest -from confluent_kafka import Producer - -from localpost import flow -from localpost.consumers.kafka import KafkaMessage, KafkaMessages, kafka_consumer -from localpost.hosting import Host - -from .RedpandaContainer import RedpandaContainer - -pytestmark = [pytest.mark.anyio, pytest.mark.integration] - -logger = logging.getLogger(__name__) - - -@pytest.fixture(scope="module") -def local_kafka(): - with RedpandaContainer("redpandadata/redpanda:v24.3.3") as kafka_broker: - conn_config = { - "bootstrap.servers": kafka_broker.get_bootstrap_server(), - } - yield conn_config - - -async def test_normal_case(local_kafka): - topic_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - # Arrange - - sent = ["London: cloudy", "Paris: rainy"] - p = Producer(local_kafka, logger=logger) - for message in sent: # Redpanda creates a topic automatically if it doesn't exist - p.produce(topic_name, message) - p.flush() - - # Act - - received = [] - client_config = local_kafka | { - "group.id": "integration_tests", - "auto.offset.reset": "earliest", - } - - @kafka_consumer(topic_name, client_config) - @flow.handler - def handle(m: KafkaMessage) -> None: - received.append(m.value.decode()) - - host = Host(handle) - async with host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == sent - - -async def test_batching(local_kafka): - topic_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - # Arrange - - sent = ["London: cloudy", "Paris: rainy"] - p = Producer(local_kafka, logger=logger) - for message in sent: - p.produce(topic_name, message) - p.flush() - - # Act - - received = [] - client_config = local_kafka | { - "group.id": "integration_tests", - "auto.offset.reset": "earliest", - } - - @kafka_consumer(topic_name, client_config) - @flow.batch(10, 1, KafkaMessages) - @flow.handler - async def handle(messages: KafkaMessages): - nonlocal received - received += [[m.value.decode() for m in messages]] - - host = Host(handle) - async with host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == [sent] diff --git a/tests/consumers/sqs.py b/tests/consumers/sqs.py deleted file mode 100644 index 9d8df04..0000000 --- a/tests/consumers/sqs.py +++ /dev/null @@ -1,107 +0,0 @@ -import random -import string - -import anyio -import boto3 -import pytest -from aiobotocore.session import get_session -from testcontainers.localstack import LocalStackContainer - -from localpost import flow -from localpost.consumers.sqs import SqsMessage, SqsMessages, sqs_queue_consumer -from localpost.hosting import Host - -pytestmark = [pytest.mark.anyio, pytest.mark.integration] - - -# See https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on -@pytest.fixture -def anyio_backend(): - """ - SQS consumer uses aioboto3 which is asyncio only. - """ - return "asyncio" - - -@pytest.fixture(scope="module") -def local_sqs(): - with LocalStackContainer("localstack/localstack:4.0").with_services("sqs") as aws: - aws_url = aws.get_url() - - conn_params = { - "endpoint_url": aws_url, - "region_name": aws.region_name, - "aws_access_key_id": "test", - "aws_secret_access_key": "test", - } - - yield conn_params - - -async def test_normal_case(local_sqs): - queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - # Arrange - - sent = ["hello", "world", "!"] - sqs_queue = boto3.resource("sqs", **local_sqs).create_queue(QueueName=queue_name) - for message in sent: - sqs_queue.send_message(MessageBody=message) - - # Act - - received = [] - - def create_client(): - return get_session().create_client("sqs", **local_sqs) - - @sqs_queue_consumer(queue_name, create_client) - @flow.handler - async def handle(m: SqsMessage): - nonlocal received - received += [m.body] - - host = Host(handle) - async with host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == sent - - -async def test_batching(local_sqs): - queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - # Arrange - - sent = ["hello", "world", "!"] - sqs_queue = boto3.resource("sqs", **local_sqs).create_queue(QueueName=queue_name) - for message in sent: - sqs_queue.send_message(MessageBody=message) - - # Act - - received = [] - - def create_client(): - return get_session().create_client("sqs", **local_sqs) - - @sqs_queue_consumer(queue_name, create_client) - @flow.batch(10, 1, SqsMessages) - @flow.handler - async def handle(messages: SqsMessages): - nonlocal received - received += [[m.body for m in messages]] - - host = Host(handle) - async with host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == [sent] diff --git a/tests/di/__init__.py b/tests/di/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/di/services.py b/tests/di/services.py new file mode 100644 index 0000000..50a6046 --- /dev/null +++ b/tests/di/services.py @@ -0,0 +1,405 @@ +from collections.abc import Generator +from dataclasses import dataclass +from functools import partial + +import pytest + +from localpost._utils import set_cvar +from localpost.di._services import ( + AppContext, + DefaultServiceProvider, + ServiceNotRegisteredError, + ServiceProvider, + ServiceRegistry, + _factory_for_type, + current_provider, + service_provider, +) + +# --- Test fixtures (service classes) --- + + +@dataclass +class Config: + host: str + port: int + + +class Database: + def __init__(self, config: Config): + self.config = config + + +class UserRepository: + def __init__(self, db: Database): + self.db = db + + +class NoAnnotations: + def __init__(self, something): + self.something = something + + +class DBPoolWithClassmethod: + def __init__(self, dsn: str): + self.dsn = dsn + + @classmethod + def from_config(cls, config: Config) -> "DBPoolWithClassmethod": + return cls(dsn=f"postgres://{config.host}:{config.port}") + + +# --- Tests --- + + +class TestFactoryFor: + def test_resolves_constructor_params(self): + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + db = provider.resolve(Database) + assert isinstance(db, Database) + assert db.config.host == "localhost" + assert db.config.port == 5432 + + def test_raises_on_missing_annotation(self): + with pytest.raises(TypeError, match="has no type annotation"): + _factory_for_type(NoAnnotations) + + def test_no_params(self): + """A class with no __init__ params (besides self) should just be instantiated.""" + + class Simple: + pass + + factory = _factory_for_type(Simple) + registry = ServiceRegistry() + with registry.app_scope() as provider: + with factory(provider) as instance: + assert isinstance(instance, Simple) + + +class TestServiceProvider: + def test_resolve_registered_value(self): + registry = ServiceRegistry() + config = Config(host="127.0.0.1", port=8080) + registry.register_instance(config) + + with registry.app_scope() as provider: + resolved = provider.resolve(Config) + assert resolved is config + + def test_resolve_with_auto_wiring(self): + registry = ServiceRegistry() + registry.register_instance(Config(host="127.0.0.1", port=8080)) + registry.register(Database) + + with registry.app_scope() as provider: + db = provider.resolve(Database) + assert isinstance(db, Database) + assert db.config.host == "127.0.0.1" + + def test_resolve_transitive_dependencies(self): + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Database) + registry.register(UserRepository) + + with registry.app_scope() as provider: + repo = provider.resolve(UserRepository) + assert isinstance(repo, UserRepository) + assert isinstance(repo.db, Database) + assert repo.db.config.host == "localhost" + + def test_resolve_caches_within_scope(self): + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + db1 = provider.resolve(Database) + db2 = provider.resolve(Database) + assert db1 is db2 + + def test_resolve_unregistered_raises(self): + registry = ServiceRegistry() + + with registry.app_scope() as provider: + with pytest.raises(ServiceNotRegisteredError): + provider.resolve(Config) + + def test_resolve_unregistered_in_nested_scope_raises(self): + """Unregistered type should raise even when a parent scope exists.""" + registry = ServiceRegistry() + registry.register_instance(Config(host="outer", port=1)) + # Database is not registered + + with registry.app_scope() as provider: + with pytest.raises(ServiceNotRegisteredError): + provider.resolve(Database) + + def test_getitem(self): + registry = ServiceRegistry() + config = Config(host="localhost", port=5432) + registry.register_instance(config) + + with registry.app_scope() as provider: + assert provider[Config] is config + + def test_register_with_custom_factory(self): + def custom_factory() -> Config: + return Config(host="custom", port=9999) + + registry = ServiceRegistry() + registry.register(Config, custom_factory) + + with registry.app_scope() as provider: + config = provider.resolve(Config) + assert config.host == "custom" + assert config.port == 9999 + + def test_register_with_classmethod_factory(self): + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(DBPoolWithClassmethod, DBPoolWithClassmethod.from_config) + + with registry.app_scope() as provider: + pool = provider.resolve(DBPoolWithClassmethod) + assert pool.dsn == "postgres://localhost:5432" + + def test_register_with_partial_factory(self): + def make_config(host: str, port: int) -> Config: + return Config(host=host, port=port) + + factory = partial(make_config, host="partial-host") + + registry = ServiceRegistry() + registry.register_instance(4321, int) + registry.register(Config, factory) + + with registry.app_scope() as provider: + config = provider.resolve(Config) + assert config.host == "partial-host" + assert config.port == 4321 + + +class TestCreate: + def test_create_resolves_deps(self): + """create() should resolve constructor dependencies from the provider.""" + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + repo = provider.create(UserRepository) + assert isinstance(repo, UserRepository) + assert isinstance(repo.db, Database) + assert repo.db.config.host == "localhost" + + def test_create_with_kwargs_override(self): + """create() should allow overriding specific dependencies via kwargs.""" + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + + custom_db = Database(Config(host="custom", port=9999)) + + with registry.app_scope() as provider: + repo = provider.create(UserRepository, db=custom_db) + assert repo.db is custom_db + + def test_create_no_caching(self): + """create() should return a new instance each time (not cached).""" + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + repo1 = provider.create(UserRepository) + repo2 = provider.create(UserRepository) + assert repo1 is not repo2 + + +class TestScope: + def test_sets_context_var(self): + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + + with registry.app_scope(): + # current_provider() should work inside the scope + provider = current_provider.get() + config = provider.resolve(Config) + assert config.host == "localhost" + + def test_resets_context_var_after_exit(self): + registry = ServiceRegistry() + + with registry.app_scope(): + pass + + assert current_provider.get(None) is None + + def test_service_provider_proxy(self): + """The module-level `service_provider` proxy should delegate to the current scope.""" + registry = ServiceRegistry() + config = Config(host="proxy-test", port=1234) + registry.register_instance(config) + + with registry.app_scope(): + assert service_provider.resolve(Config) is config + + def test_nested_scopes(self): + """Inner scope should shadow the outer one in the context var.""" + registry = ServiceRegistry() + outer_config = Config(host="outer", port=1) + inner_config = Config(host="inner", port=2) + + registry.register_instance(outer_config) + + with registry.app_scope() as outer_provider: + assert outer_provider.resolve(Config).host == "outer" + + # Override the value in the same registry for the inner scope + inner_registry = ServiceRegistry() + inner_registry.register_instance(inner_config) + + inner_ctx = AppContext() + inner_provider = DefaultServiceProvider(outer_provider, inner_registry, inner_ctx, AppContext) + with inner_ctx.ctx, set_cvar(current_provider, inner_provider): + assert inner_provider.resolve(Config).host == "inner" + assert current_provider.get().resolve(Config).host == "inner" + + # After exiting inner scope, outer is restored + assert current_provider.get().resolve(Config).host == "outer" + + +class TestCreateOnEnter: + def test_eager_resolution(self): + """Services with create_on_enter=True should be resolved when the scope is entered.""" + created = [] + + class Pool: + def __init__(self, config: Config): + self.config = config + created.append("pool") + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Pool, create_on_enter=True) + + assert created == [] + with registry.app_scope() as provider: + assert created == ["pool"] + # Should return the same cached instance + pool = provider.resolve(Pool) + assert isinstance(pool, Pool) + assert created == ["pool"] + + def test_eager_generator_factory(self): + """Generator factories with create_on_enter=True should run setup on scope entry and cleanup on exit.""" + events: list[str] = [] + + def create_pool(config: Config): + events.append("setup") + yield f"pool({config.host})" + events.append("cleanup") + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(str, create_pool, create_on_enter=True) + + assert events == [] + with registry.app_scope() as provider: + assert events == ["setup"] + assert provider.resolve(str) == "pool(localhost)" + assert events == ["setup", "cleanup"] + + def test_non_eager_not_resolved(self): + """Services without create_on_enter should NOT be resolved on scope entry.""" + created = [] + + class Pool: + def __init__(self, config: Config): + created.append("pool") + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Pool) + + with registry.app_scope(): + assert created == [] + + +class TestAppScopeLifecycle: + def test_no_auto_close_on_scope_exit(self): + """Services registered without a factory should NOT be auto-closed on scope exit.""" + closed = [] + + class Pool: + def __init__(self, config: Config): + self.config = config + + def close(self): + closed.append("pool") + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Pool) + + with registry.app_scope() as provider: + provider.resolve(Pool) + + assert closed == [] + + def test_generator_factory_cleanup(self): + """A generator factory should run cleanup code after scope exit.""" + events: list[str] = [] + + class Pool: + def __init__(self, name: str): + self.name = name + + def pool_factory(sp: ServiceProvider) -> Generator[Pool]: + events.append("create") + pool = Pool("test-pool") + yield pool + events.append("cleanup") + + registry = ServiceRegistry() + registry.register(Pool, pool_factory) + + with registry.app_scope() as provider: + pool = provider.resolve(Pool) + assert pool.name == "test-pool" + assert events == ["create"] + + assert events == ["create", "cleanup"] + + def test_generator_factory_with_dependencies(self): + """A generator factory should be able to resolve dependencies from the provider.""" + closed = False + + class DBPool: + def __init__(self, config: Config): + self.config = config + + def close(self): + nonlocal closed + closed = True + + def db_pool_factory(sp: ServiceProvider) -> Generator[DBPool]: + pool = DBPool(sp[Config]) + yield pool + pool.close() + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(DBPool, db_pool_factory) + + with registry.app_scope() as provider: + pool = provider.resolve(DBPool) + assert pool.config.host == "localhost" + assert not closed + + assert closed diff --git a/tests/hosting/_signal_app.py b/tests/hosting/_signal_app.py new file mode 100644 index 0000000..54ec136 --- /dev/null +++ b/tests/hosting/_signal_app.py @@ -0,0 +1,21 @@ +"""Subprocess entrypoint for signal-handling tests. + +Boots a tiny long-running service under ``run_app`` (which wires +``shutdown_on_signal``) and prints a marker on shutdown so the parent test +can verify it observed the signal cleanly. +""" + +from __future__ import annotations + +from localpost.hosting import ServiceLifetime, run_app + + +async def long_running(lt: ServiceLifetime) -> None: + print("READY", flush=True) # noqa: T201 + lt.set_started() + await lt.shutting_down.wait() + print("STOPPED", flush=True) # noqa: T201 + + +if __name__ == "__main__": + run_app(long_running) diff --git a/tests/hosting/host.py b/tests/hosting/host.py index 05b0eb0..9a19033 100644 --- a/tests/hosting/host.py +++ b/tests/hosting/host.py @@ -1,77 +1,124 @@ import anyio import pytest -from anyio import CancelScope -import localpost -from localpost._utils import wait_any # noqa -from localpost.hosting import Host, ServiceLifetimeManager +from localpost.hosting import Running, ServiceLifetime, ServiceLifetimeView, ShuttingDown, Starting, Stopped, run, serve pytestmark = pytest.mark.anyio -async def test_host_status(): - async def test_service(lifetime): - shutdown_scope = CancelScope() - lifetime.set_started(graceful_shutdown_scope=shutdown_scope) - with shutdown_scope: - await anyio.sleep_forever() +async def test_serve_and_state_transitions(): + """Test the full lifecycle: starting → running → shutting_down → stopped.""" + states_seen = [] - host = Host(test_service) - assert not host.started - async with host.aserve(): - await host.started - assert host.status # TODO Check - host.shutdown() + async def test_service(lt: ServiceLifetime): + states_seen.append(type(lt.state)) + lt.set_started() + states_seen.append(type(lt.state)) + await lt.shutting_down.wait() + states_seen.append(type(lt.state)) + async with serve(test_service) as lt: + await lt.started + assert isinstance(lt.state, Running) + lt.shutdown() + await lt.stopped -async def test_exit_code_on_error(): - async def failing_service(_): - raise Exception("Test error") + assert isinstance(lt.state, Stopped) + assert states_seen == [Starting, Running, ShuttingDown] - host = Host(failing_service) - async with host.aserve(): - pass - assert host.exit_code == 1 +async def test_serve_yields_lifetime_view(): + async def test_service(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + async with serve(test_service) as lt: + assert isinstance(lt, ServiceLifetimeView) + lt.shutdown() + await lt.stopped -async def test_shutdown(): - shutdown_communicated = False - shutdown_reason = "Test shutdown" - async def test_service(lifetime: ServiceLifetimeManager) -> None: - lifetime.set_started() - await wait_any(anyio.sleep_forever, lifetime.shutting_down) - nonlocal shutdown_communicated - shutdown_communicated = True +async def test_exit_code_on_error(): + async def failing_service(lt: ServiceLifetime): + lt.set_started() + raise RuntimeError("Test error") - host = Host(test_service) - async with host.aserve(): - await host.started.wait() - assert not host.shutting_down - host.shutdown(reason=shutdown_reason) - assert host.shutting_down + async with serve(failing_service) as lt: + await lt.stopped - assert shutdown_communicated # Shutdown was communicated to the service - assert host.status # TODO Check + assert lt.exit_code == 1 -async def test_arun(): - async def a_service(_: ServiceLifetimeManager): - await anyio.sleep(0.1) +async def test_exit_code_on_success(): + async def ok_service(lt: ServiceLifetime): + lt.set_started() - with localpost.debug: - host = Host(a_service) - exit_code = await localpost.arun(host) + async with serve(ok_service) as lt: + await lt.stopped - assert exit_code == 0 + assert lt.exit_code == 0 -def test_run(): - async def a_service(_: ServiceLifetimeManager): - await anyio.sleep(0.1) +async def test_run(): + async def finite_service(lt: ServiceLifetime): + lt.set_started() + await anyio.sleep(0.05) - with localpost.debug: - host = Host(a_service) - exit_code = localpost.run(host) + exit_code = await run(finite_service) assert exit_code == 0 + + +async def test_run_failing(): + async def failing_service(lt: ServiceLifetime): + lt.set_started() + raise RuntimeError("boom") + + exit_code = await run(failing_service) + assert exit_code == 1 + + +async def test_shutdown(): + shutdown_communicated = False + + async def test_service(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + nonlocal shutdown_communicated + shutdown_communicated = True + + async with serve(test_service) as lt: + await lt.started + assert not lt.shutting_down + lt.shutdown(reason="Test shutdown") + await lt.stopped + + assert shutdown_communicated + assert isinstance(lt.state, Stopped) + + +async def test_child_service(): + child_started = False + child_stopped = False + + async def child(lt: ServiceLifetime): + nonlocal child_started, child_stopped + lt.set_started() + child_started = True + await lt.shutting_down.wait() + child_stopped = True + + async def parent(lt: ServiceLifetime): + child_lt = lt.start(child) + await child_lt.started + lt.set_started() + await lt.shutting_down.wait() + child_lt.shutdown() + await child_lt.stopped + + async with serve(parent) as lt: + await lt.started + assert child_started + lt.shutdown() + await lt.stopped + + assert child_stopped diff --git a/tests/hosting/hosted_service.py b/tests/hosting/hosted_service.py index 7cca367..a0a043f 100644 --- a/tests/hosting/hosted_service.py +++ b/tests/hosting/hosted_service.py @@ -1,16 +1,134 @@ +import threading + import pytest -from localpost.hosting import HostedService, hosted_service +from localpost.hosting import ServiceLifetime, serve, service pytestmark = pytest.mark.anyio -async def test_decorator(): - @hosted_service - def simple_sync_service(): - pass +async def test_service_decorator_async(): + @service + def my_service(): + async def svc(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + + return svc + + resolved = my_service() + async with serve(resolved) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + + +async def test_service_decorator_direct_async(): + stopped = False + + @service + async def direct(lt: ServiceLifetime): + nonlocal stopped + lt.set_started() + await lt.shutting_down.wait() + stopped = True + + resolved = direct() + async with serve(resolved) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert stopped + assert lt.exit_code == 0 + + +async def test_service_decorator_sync(): + work_done = False + + @service + def my_sync_service(): + def svc(lt: ServiceLifetime): + nonlocal work_done + lt.set_started() + lt.view.wait_shutting_down() + work_done = True + + return svc + + resolved = my_sync_service() + async with serve(resolved) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert work_done + + +async def test_service_decorator_direct_sync(): + worker_thread_id = None + + @service + def direct_sync(lt: ServiceLifetime): + nonlocal worker_thread_id + worker_thread_id = threading.get_ident() + lt.set_started() + lt.view.wait_shutting_down() + + resolved = direct_sync() + async with serve(resolved) as lt: + host_thread_id = threading.get_ident() + await lt.started + lt.shutdown() + await lt.stopped + + assert worker_thread_id is not None + assert worker_thread_id != host_thread_id + + +async def test_service_decorator_context_manager(): + entered = False + exited = False + + @service + async def my_cm_service(): + nonlocal entered, exited + entered = True + yield + exited = True + + resolved = my_cm_service() + async with serve(resolved) as lt: + await lt.started + assert entered + lt.shutdown() + await lt.stopped + + assert exited + + +async def test_service_as_context_manager(): + """_ResolvedService can be used as an async context manager directly.""" + + @service + def my_service(): + async def svc(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() - assert isinstance(simple_sync_service, HostedService) + return svc + # When used inside another service context + async def parent(lt: ServiceLifetime): + async with my_service() as child_lt: + lt.set_started() + await lt.shutting_down.wait() + child_lt.shutdown() + await child_lt.stopped -# TODO Test attributes (name especially, separately) + async with serve(parent) as lt: + await lt.started + lt.shutdown() + await lt.stopped diff --git a/tests/hosting/hosts_combine.py b/tests/hosting/hosts_combine.py deleted file mode 100644 index 45e2234..0000000 --- a/tests/hosting/hosts_combine.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest -from anyio import sleep - -from localpost.hosting import Host, HostedService - -pytestmark = pytest.mark.anyio - - -async def test_combine(): - async def service1(lifetime): - lifetime.set_started() - await lifetime.shutting_down.wait() - - async def service2(lifetime): - lifetime.set_started() - await lifetime.shutting_down.wait() - - async def service3(lifetime): - lifetime.set_started() - await lifetime.shutting_down.wait() - - combined = HostedService(service1) + service2 + service3 - assert isinstance(combined, HostedService) - - host = Host(combined) - assert not host.started - async with host.aserve(): - await host.started - await sleep(0.1) - assert host.status # TODO Check - host.shutdown() diff --git a/tests/hosting/lifetime_extras.py b/tests/hosting/lifetime_extras.py new file mode 100644 index 0000000..5493949 --- /dev/null +++ b/tests/hosting/lifetime_extras.py @@ -0,0 +1,231 @@ +"""Tests for the smaller pieces of the hosting public surface that the +existing host.py / hosted_service.py tests don't cover: contextvar accessors, +defer/adefer, observe_services, cancel_on_shutdown / cancel_on_stop. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager, contextmanager + +import anyio +import pytest + +from localpost.hosting import ( + ServiceLifetime, + ServiceLifetimeView, + observe_services, + serve, +) +from localpost.hosting._host import current_app, current_service + +pytestmark = pytest.mark.anyio + + +# --- current_service / current_app ------------------------------------------- + + +class TestCurrentLifetimeAccessors: + async def test_current_service_outside_hosting_raises(self): + with pytest.raises(RuntimeError, match="Not in hosting context"): + current_service() + + async def test_current_app_outside_hosting_raises(self): + with pytest.raises(RuntimeError, match="Not in hosting context"): + current_app() + + async def test_current_service_inside_hosting_returns_view(self): + seen: dict = {} + + async def svc(lt: ServiceLifetime) -> None: + seen["view"] = current_service() + seen["is_view"] = isinstance(seen["view"], ServiceLifetimeView) + lt.set_started() + + async with serve(svc) as lt: + await lt.stopped + + assert seen["is_view"] is True + + async def test_current_app_inside_hosting_returns_root(self): + """``current_app`` in a child service points at the root, not the child.""" + seen: dict = {} + + async def child(lt: ServiceLifetime) -> None: + seen["child"] = current_service() + seen["app"] = current_app() + lt.set_started() + await lt.shutting_down.wait() + + async def parent(lt: ServiceLifetime) -> None: + child_lt = lt.start(child) + await child_lt.started + lt.set_started() + await lt.shutting_down.wait() + child_lt.shutdown() + await child_lt.stopped + + async with serve(parent) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + # In the child, current_service was the child; current_app was the root. + assert seen["child"] is not seen["app"] + + +# --- defer / adefer ---------------------------------------------------------- + + +class TestDefer: + async def test_defer_releases_sync_cm_on_stop(self): + events: list[str] = [] + + @contextmanager + def resource(): + events.append("acquire") + try: + yield "value" + finally: + events.append("release") + + async def svc(lt: ServiceLifetime) -> None: + value = lt.defer(resource()) + assert value == "value" + events.append(f"running:{value}") + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + assert events == ["acquire", "running:value"] + lt.shutdown() + await lt.stopped + + assert events == ["acquire", "running:value", "release"] + + async def test_defer_releases_closable(self): + closed: list[bool] = [False] + + class Closable: + def close(self) -> None: + closed[0] = True + + async def svc(lt: ServiceLifetime) -> None: + lt.defer(Closable()) + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert closed[0] is True + + async def test_adefer_releases_async_cm_on_stop(self): + events: list[str] = [] + + @asynccontextmanager + async def resource(): + events.append("acquire") + try: + yield "value" + finally: + events.append("release") + + async def svc(lt: ServiceLifetime) -> None: + value = await lt.adefer(resource()) + assert value == "value" + events.append(f"running:{value}") + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + assert events == ["acquire", "running:value"] + lt.shutdown() + await lt.stopped + + assert events == ["acquire", "running:value", "release"] + + +# --- observe_services -------------------------------------------------------- + + +class TestObserveServices: + async def test_waits_for_all_started_then_shuts_down_on_exit(self): + timeline: list[str] = [] + + async def make_svc(label: str): + async def svc(lt: ServiceLifetime) -> None: + timeline.append(f"{label}:starting") + lt.set_started() + timeline.append(f"{label}:running") + await lt.shutting_down.wait() + timeline.append(f"{label}:shutting-down") + + return svc + + a_svc, b_svc = await make_svc("a"), await make_svc("b") + + async with serve(a_svc) as a_lt, serve(b_svc) as b_lt: + async with observe_services(a_lt, b_lt): + # Both must be running by the time observe_services yields. + assert "a:running" in timeline + assert "b:running" in timeline + + # On exit both are shut down. + await a_lt.stopped + await b_lt.stopped + + assert "a:shutting-down" in timeline + assert "b:shutting-down" in timeline + + +# --- cancel_on_shutdown / cancel_on_stop ------------------------------------- + + +class TestCancelOnLifetimeEvent: + async def test_cancel_on_shutdown_unblocks_when_service_shuts_down(self): + completed = anyio.Event() + + async def svc(lt: ServiceLifetime) -> None: + async def long_task() -> None: + try: + await anyio.sleep(60) + finally: + completed.set() + + lt.tg.start_soon(lt.view.cancel_on_shutdown(long_task)) + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + # cancel_on_shutdown wraps the task so its outer scope cancels when + # the service starts shutting down. + assert completed.is_set() + + async def test_cancel_on_stop_unblocks_after_full_stop(self): + completed = anyio.Event() + + async def child(lt: ServiceLifetime) -> None: + async def long_task() -> None: + try: + await anyio.sleep(60) + finally: + completed.set() + + lt.tg.start_soon(lt.view.cancel_on_stop(long_task)) + lt.set_started() + await lt.shutting_down.wait() + + async with serve(child) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert completed.is_set() diff --git a/tests/hosting/multi_service.py b/tests/hosting/multi_service.py new file mode 100644 index 0000000..c2adcf7 --- /dev/null +++ b/tests/hosting/multi_service.py @@ -0,0 +1,85 @@ +"""Tests for multi-service composition (run_app(*svcs) → _run_many). + +The composed parent service started by ``_run_many`` doesn't call +``set_started`` itself — it just spawns each child via ``lt.start`` and waits +for them to stop. So the natural shape for these tests is to give each child +a finite task and observe that the composition reaches ``stopped`` only after +every child has. +""" + +from __future__ import annotations + +import anyio +import pytest + +from localpost.hosting import ServiceLifetime, run +from localpost.hosting._host import _run_many + +pytestmark = pytest.mark.anyio + + +class TestRunMany: + async def test_runs_all_children_to_completion(self): + """All children start and complete; the composition stops cleanly.""" + events: list[str] = [] + events_lock = anyio.Lock() + + async def make_finite_child(label: str, sleep: float): + async def svc(lt: ServiceLifetime) -> None: + async with events_lock: + events.append(f"{label}:start") + lt.set_started() + await anyio.sleep(sleep) + async with events_lock: + events.append(f"{label}:stop") + + return svc + + a = await make_finite_child("a", 0.05) + b = await make_finite_child("b", 0.10) + c = await make_finite_child("c", 0.02) + + composed = _run_many(a, b, c) + exit_code = await run(composed) + + assert exit_code == 0 + # All children have started and stopped. + assert {"a:start", "b:start", "c:start", "a:stop", "b:stop", "c:stop"} <= set(events) + + +class TestRunManyChildCrashSiblingsKeepRunningToday: + """Pin the documented limitation of ``_run_many``: when one child crashes, + siblings keep running until they finish on their own. + + See the docstring on ``_run_many``. When the behavior is hardened (so a + crashed child cancels its siblings), this test should flip — assert that + ``b`` was cancelled instead of allowed to finish. + """ + + async def test_sibling_runs_to_completion_when_other_child_crashes(self): + b_finished_naturally = anyio.Event() + a_crashed = anyio.Event() + + async def a(lt: ServiceLifetime) -> None: + lt.set_started() + a_crashed.set() + raise RuntimeError("a crashed") + + async def b(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.sleep(0.2) # finite; long enough to outlive a's crash + b_finished_naturally.set() + + composed = _run_many(a, b) + exit_code = await run(composed) + + # `a` crashed (errors absorbed at the lifetime level → exit_code stays 0 + # for the composition since the parent's _run isn't the one raising; + # the child's own exit_code is 1, but _run_many doesn't propagate that). + assert a_crashed.is_set() + # Today: b ran to natural completion despite a's crash. + assert b_finished_naturally.is_set(), ( + "If this fails, _run_many learned to cancel siblings on child error — flip the assertion." + ) + # exit_code reflects the composed parent, which didn't itself raise. + assert exit_code == 0 diff --git a/tests/hosting/rsgi.py b/tests/hosting/rsgi.py new file mode 100644 index 0000000..9a8ff56 --- /dev/null +++ b/tests/hosting/rsgi.py @@ -0,0 +1,274 @@ +"""Tests for ``localpost.hosting.rsgi.HostRSGIApp`` — host-as-RSGI for +hosted apps under Granian. + +Drives the lifecycle hooks (``__rsgi_init__`` / ``__rsgi__`` / +``__rsgi_del__``) directly with a mocked RSGI scope + proto, bypassing +Granian itself. End-to-end coverage with a real Granian worker lives +in ``tests/openapi/aio_rsgi_integration.py`` (marked ``integration``). + +Granian's hooks are sync (``callback_init(loop)``, ``callback_del(loop)``); +:class:`HostRSGIApp` schedules async startup/shutdown on the loop and +gates the first request on a "ready" event. These tests exercise the +gate by yielding control back to the loop after init. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from localpost import hosting +from localpost.hosting.rsgi import HostRSGIApp +from localpost.http import AsyncHTTPReqCtx, Response + +# All tests in this file run under asyncio (HostRSGIApp uses asyncio +# primitives — Granian's loop is asyncio-only). +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +# --- Mocks -------------------------------------------------------------- + + +class _FakeHeaders: + def __init__(self, pairs: list[tuple[str, str]] | None = None) -> None: + self._pairs = pairs or [] + + def items(self) -> list[tuple[str, str]]: + return list(self._pairs) + + +@dataclass(slots=True, eq=False) +class _FakeScope: + method: str = "GET" + path: str = "/" + query_string: str = "" + http_version: str = "1.1" + scheme: str = "http" + client: str = "127.0.0.1:54321" + server: str = "127.0.0.1:8000" + headers: _FakeHeaders = field(default_factory=_FakeHeaders) + + +@dataclass(slots=True, eq=False) +class _FakeProto: + body_chunks: list[bytes] = field(default_factory=list) + response_status: int | None = None + response_body: bytes | None = None + + async def __call__(self) -> bytes: + return b"".join(self.body_chunks) + + def __aiter__(self) -> AsyncIterator[bytes]: + async def gen() -> AsyncIterator[bytes]: + for c in self.body_chunks: + yield c + + return gen() + + async def client_disconnect(self) -> None: + await asyncio.Event().wait() # never resolves + + def response_empty(self, status: int, headers: Any) -> None: + self.response_status = status + self.response_body = b"" + + def response_bytes(self, status: int, headers: Any, body: bytes) -> None: + self.response_status = status + self.response_body = body + + +# --- Test services ------------------------------------------------------ + + +def _tracking_service(name: str, log: list[str]) -> hosting.ServiceF: + """A service that records its lifecycle into ``log``.""" + + @hosting.service + async def svc(sl: hosting.ServiceLifetime) -> None: + log.append(f"{name}:starting") + sl.set_started() + log.append(f"{name}:running") + await sl.shutting_down.wait() + log.append(f"{name}:stopping") + + return svc() + + +# --- Helpers ------------------------------------------------------------ + + +async def _drive_startup(app: HostRSGIApp) -> None: + """Trigger ``__rsgi_init__`` then yield to the loop until services + are started — Granian's real flow is async, so we mimic that here.""" + loop = asyncio.get_running_loop() + app.__rsgi_init__(loop) + if app._ready is not None: + await app._ready.wait() + + +async def _drive_shutdown(app: HostRSGIApp) -> None: + """Trigger ``__rsgi_del__`` and let the loop drain the lifecycle task.""" + loop = asyncio.get_running_loop() + app.__rsgi_del__(loop) + task = app._lifecycle_task + if task is not None: + try: + await asyncio.wait_for(task, timeout=5) + except asyncio.CancelledError: + pass + + +# --- Tests -------------------------------------------------------------- + + +class TestLifecycle: + async def test_services_started_before_requests(self) -> None: + log: list[str] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + log.append("request") + await ctx.complete(Response(200), b"ok") + + app = HostRSGIApp( + services=[_tracking_service("svc", log)], + rsgi_handler=handler, + ) + + await _drive_startup(app) + # By the time the ready event fires, the service is running. + assert "svc:starting" in log + assert "svc:running" in log + assert "request" not in log + + await app.__rsgi__(_FakeScope(), _FakeProto()) + assert "request" in log + + await _drive_shutdown(app) + assert "svc:stopping" in log + + async def test_request_dispatch_works(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"hello-from-host") + + app = HostRSGIApp(services=[], rsgi_handler=handler) + await _drive_startup(app) + try: + proto = _FakeProto() + await app.__rsgi__(_FakeScope(), proto) + assert proto.response_status == 200 + assert proto.response_body == b"hello-from-host" + finally: + await _drive_shutdown(app) + + async def test_no_services_no_lifecycle_overhead(self) -> None: + """When ``services=[]``, init / del are no-ops — a pure dispatch + path symmetric with :func:`to_rsgi`.""" + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"ok") + + app = HostRSGIApp(services=[], rsgi_handler=handler) + await _drive_startup(app) + await _drive_shutdown(app) + # ``_ready`` stays ``None`` when there are no services — dispatch + # still works without any wait. + assert app._ready is None + + async def test_multiple_services_all_run(self) -> None: + log: list[str] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"ok") + + app = HostRSGIApp( + services=[ + _tracking_service("a", log), + _tracking_service("b", log), + ], + rsgi_handler=handler, + ) + + await _drive_startup(app) + try: + assert {"a:starting", "b:starting"}.issubset(log) + assert {"a:running", "b:running"}.issubset(log) + finally: + await _drive_shutdown(app) + + assert {"a:stopping", "b:stopping"}.issubset(log) + + +class TestHandlerResolution: + async def test_accepts_async_request_handler(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"raw") + + app = HostRSGIApp(services=[], rsgi_handler=handler) + await _drive_startup(app) + try: + proto = _FakeProto() + await app.__rsgi__(_FakeScope(), proto) + assert proto.response_body == b"raw" + finally: + await _drive_shutdown(app) + + async def test_accepts_http_async_app(self) -> None: + from localpost.openapi import HttpAsyncApp + + oapi_app = HttpAsyncApp(openapi_path=None, docs_path=None) + + @oapi_app.get("/hello") + async def hello() -> str: + return "world" + + _ = hello + app = HostRSGIApp(services=[], rsgi_handler=oapi_app) + await _drive_startup(app) + try: + proto = _FakeProto() + await app.__rsgi__(_FakeScope(path="/hello"), proto) + assert proto.response_status == 200 + assert proto.response_body == b"world" + finally: + await _drive_shutdown(app) + + +class TestRequestGate: + async def test_first_request_waits_for_startup(self) -> None: + """A request that arrives before startup completes should block + on the gate until services are ``started``.""" + log: list[str] = [] + + @hosting.service + async def slow_starter(sl: hosting.ServiceLifetime) -> None: + log.append("starting") + await asyncio.sleep(0.05) # simulate a real startup hook + sl.set_started() + log.append("started") + await sl.shutting_down.wait() + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + log.append("request") + await ctx.complete(Response(200), b"ok") + + app = HostRSGIApp(services=[slow_starter()], rsgi_handler=handler) + loop = asyncio.get_running_loop() + app.__rsgi_init__(loop) + # Fire the request immediately — must not be served until + # ``slow_starter`` reports started. + await app.__rsgi__(_FakeScope(), _FakeProto()) + # Ordering: started must come before the first request. + started_idx = log.index("started") + request_idx = log.index("request") + assert started_idx < request_idx, log + + await _drive_shutdown(app) diff --git a/tests/hosting/run_app.py b/tests/hosting/run_app.py new file mode 100644 index 0000000..bfba6cd --- /dev/null +++ b/tests/hosting/run_app.py @@ -0,0 +1,63 @@ +"""Tests for ``localpost.hosting.run_app`` — the documented sync entrypoint. + +``run_app`` is the headline API users call from ``__main__``. It composes +``shutdown_on_signal`` middleware with the supplied services, drives them +under ``anyio.run`` with the platform-default backend, and exits the process +via ``SystemExit`` with the resulting status code. + +These tests run synchronously (not under pytest-anyio) because ``run_app`` +itself takes care of starting an event loop. +""" + +from __future__ import annotations + +import threading + +import anyio +import pytest + +from localpost.hosting import ServiceLifetime, run_app + + +def test_run_app_with_single_service_exits_zero(): + """A finite single service runs to completion and ``run_app`` exits 0.""" + + async def finite_svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.sleep(0.05) + + with pytest.raises(SystemExit) as exc_info: + run_app(finite_svc) + assert exc_info.value.code == 0 + + +def test_run_app_with_failing_service_exits_nonzero(): + """If the service raises, the process exits with code 1.""" + + async def boom(lt: ServiceLifetime) -> None: + lt.set_started() + raise RuntimeError("boom") + + with pytest.raises(SystemExit) as exc_info: + run_app(boom) + assert exc_info.value.code == 1 + + +def test_run_app_with_multiple_services_runs_all(): + """``run_app(*svcs)`` composes via _run_many and runs every service.""" + finished: list[str] = [] + lock = threading.Lock() + + def make_svc(label: str): + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.sleep(0.02) + with lock: + finished.append(label) + + return svc + + with pytest.raises(SystemExit) as exc_info: + run_app(make_svc("a"), make_svc("b"), make_svc("c")) + assert exc_info.value.code == 0 + assert set(finished) == {"a", "b", "c"} diff --git a/tests/hosting/service_middlewares.py b/tests/hosting/service_middlewares.py new file mode 100644 index 0000000..28b45bd --- /dev/null +++ b/tests/hosting/service_middlewares.py @@ -0,0 +1,32 @@ +import anyio +import pytest + +from localpost.hosting import ServiceLifetime, serve +from localpost.hosting.middleware import start_timeout + +pytestmark = pytest.mark.anyio + + +async def test_start_timeout_ok(): + @start_timeout(1.0) + async def fast_starter(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + + async with serve(fast_starter) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + + +async def test_start_timeout_exceeded(): + @start_timeout(0.1) + async def slow_starter(lt: ServiceLifetime): + await anyio.sleep(10) # Never calls set_started() + + async with serve(slow_starter) as lt: + await lt.stopped + + assert lt.exit_code == 1 diff --git a/tests/hosting/service_wrap.py b/tests/hosting/service_wrap.py deleted file mode 100644 index b225534..0000000 --- a/tests/hosting/service_wrap.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -pytestmark = pytest.mark.anyio - - -async def test_wrap_service(): - # Like HostedService(service_func1) >> service_func2 - pass # TODO Implement - - -async def test_wrap_multiple_services(): - # Like HostedService(service_func1) >> [service_func2, service_func3] - pass # TODO Implement - - -async def test_wrap_empty_set(): - # Like HostedService(service_func1) >> [] - pass # TODO Implement diff --git a/tests/hosting/services/__init__.py b/tests/hosting/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hosting/services/click.py b/tests/hosting/services/click.py new file mode 100644 index 0000000..e42c062 --- /dev/null +++ b/tests/hosting/services/click.py @@ -0,0 +1,96 @@ +import click +import pytest + +from localpost.hosting import current_service, serve +from localpost.hosting.services.click import click_cmd + +pytestmark = pytest.mark.anyio + + +async def test_success(): + ran: list[bool] = [] + + @click.command() + def ok(): + ran.append(True) + + async with serve(click_cmd(ok, args=[])) as lt: + await lt.stopped + + assert ran == [True] + assert lt.exit_code == 0 + + +async def test_click_exception_maps_to_exit_code(): + @click.command() + def boom(): + raise click.ClickException("boom") + + async with serve(click_cmd(boom, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 1 + + +async def test_usage_error_maps_to_exit_code_2(): + @click.command() + def bad(): + raise click.UsageError("bad") + + async with serve(click_cmd(bad, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 2 + + +async def test_abort_maps_to_exit_code_1(): + @click.command() + def aborted(): + raise click.exceptions.Abort() + + async with serve(click_cmd(aborted, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 1 + + +async def test_ctx_exit_propagates_exit_code(): + # In non-standalone mode ``ctx.exit(N)`` makes ``cmd.main()`` *return* N. + @click.command() + @click.pass_context + def exit42(ctx: click.Context): + ctx.exit(42) + + async with serve(click_cmd(exit42, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 42 + + +async def test_raw_sys_exit_propagates(): + # ``sys.exit`` from inside the command body bypasses Click and surfaces as + # ``SystemExit`` — the wrapper still maps it onto the lifetime's exit code. + import sys + + @click.command() + def hard_exit(): + sys.exit(7) + + async with serve(click_cmd(hard_exit, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 7 + + +async def test_current_service_visible_inside_callback(): + captured: list[object] = [] + + @click.command() + def grab(): + captured.append(current_service()) + + async with serve(click_cmd(grab, args=[])) as lt: + await lt.stopped + + assert len(captured) == 1 + assert lt.exit_code == 0 diff --git a/tests/hosting/signal_handling.py b/tests/hosting/signal_handling.py new file mode 100644 index 0000000..deaea26 --- /dev/null +++ b/tests/hosting/signal_handling.py @@ -0,0 +1,80 @@ +"""Tests for ``localpost.hosting.middleware.shutdown_on_signal``. + +Signals are process-wide, so we exercise the middleware from a subprocess. +``run_app`` wires ``shutdown_on_signal`` automatically, so these tests +double as smoke coverage for the full integration. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time + +import pytest + + +def _spawn() -> subprocess.Popen: + return subprocess.Popen( + [sys.executable, "-u", "-m", "tests.hosting._signal_app"], + env={**os.environ}, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def _wait_for_marker(proc: subprocess.Popen, marker: bytes, timeout: float = 5.0) -> bool: + """Read stdout line-by-line waiting for ``marker``. Returns True on hit.""" + end = time.monotonic() + timeout + assert proc.stdout is not None + while time.monotonic() < end: + line = proc.stdout.readline() + if not line: + time.sleep(0.02) + continue + if marker in line: + return True + return False + + +@pytest.mark.parametrize("sig", [signal.SIGTERM, signal.SIGINT]) +def test_shutdown_on_signal_triggers_clean_exit(sig): + """SIGTERM and SIGINT both trigger ``lt.shutdown`` and return exit code 0.""" + proc = _spawn() + try: + assert _wait_for_marker(proc, b"READY"), "service never reported READY" + proc.send_signal(sig) + rc = proc.wait(timeout=5) + assert rc == 0, f"unexpected exit code {rc} on {sig.name}" + + # The service printed "STOPPED" before exiting cleanly. + assert proc.stdout is not None + remaining = proc.stdout.read() + assert b"STOPPED" in remaining, f"service did not log clean stop: {remaining!r}" + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +def test_second_sigint_forces_immediate_stop(): + """Two consecutive SIGINTs: the second one triggers ``lt.stop`` (forced).""" + proc = _spawn() + try: + assert _wait_for_marker(proc, b"READY"), "service never reported READY" + + # First SIGINT: graceful shutdown begins. Service is in + # ``await lt.shutting_down.wait()`` and will move to the print. + proc.send_signal(signal.SIGINT) + # Second SIGINT immediately: ``stop`` cancels the run scope. + proc.send_signal(signal.SIGINT) + + rc = proc.wait(timeout=5) + # Either path produces a successful exit — the cancellation is internal. + assert rc in (0, 1), f"unexpected exit {rc}" + finally: + if proc.poll() is None: + proc.kill() + proc.wait() diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/http/_helpers.py b/tests/http/_helpers.py new file mode 100644 index 0000000..7302f41 --- /dev/null +++ b/tests/http/_helpers.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import socket + + +def drain_socket(sock: socket.socket, deadline: float = 2.0) -> bytes: + """Read until the peer closes or no bytes arrive before ``deadline``.""" + sock.settimeout(deadline) + out = bytearray() + while True: + try: + chunk = sock.recv(4096) + except TimeoutError: + break + if not chunk: + break + out.extend(chunk) + return bytes(out) + + +def read_until(sock: socket.socket, marker: bytes, deadline: float = 2.0) -> bytes: + """Read until ``marker`` appears in the accumulated bytes.""" + sock.settimeout(deadline) + out = bytearray() + while marker not in out: + chunk = sock.recv(4096) + if not chunk: + break + out.extend(chunk) + return bytes(out) + + +def read_http_response(sock: socket.socket, deadline: float = 2.0) -> bytes: + """Read one HTTP/1.1 response with Content-Length framing.""" + data = read_until(sock, b"\r\n\r\n", deadline=deadline) + header_end = data.find(b"\r\n\r\n") + if header_end == -1: + return data + + content_length = 0 + for line in data[:header_end].split(b"\r\n")[1:]: + name, sep, value = line.partition(b":") + if sep and name.lower() == b"content-length": + content_length = int(value.strip()) + break + + body_start = header_end + 4 + remaining = content_length - (len(data) - body_start) + while remaining > 0: + chunk = sock.recv(remaining) + if not chunk: + break + data += chunk + remaining -= len(chunk) + return data diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py new file mode 100644 index 0000000..1d3fb78 --- /dev/null +++ b/tests/http/_integration_app.py @@ -0,0 +1,120 @@ +"""Subprocess entry point for integration tests. + +Runs a hosted localpost HTTP server (Router-based by default; switchable +to a WSGI / Flask app via the ``LP_TEST_MODE`` env var) on the port given +by ``LP_TEST_PORT`` and the anyio backend given by ``LP_TEST_BACKEND`` +(``asyncio`` or ``trio``). +""" + +from __future__ import annotations + +import logging +import os +import sys +import threading +import time + +import anyio + + +def _build_handler(): + mode = os.environ.get("LP_TEST_MODE", "router") + if mode == "router": + from localpost.http import ( + HTTPReqCtx, + Response, + Routes, + route_match, + ) + + def _emit(ctx: HTTPReqCtx, body: bytes) -> None: + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + + def _ping(ctx: HTTPReqCtx) -> None: + _emit(ctx, b"pong") + + def _slow(ctx: HTTPReqCtx) -> None: + time.sleep(float(os.environ.get("LP_TEST_SLOW_S", "0.2"))) + _emit(ctx, str(threading.get_ident()).encode()) + + def _hello(ctx: HTTPReqCtx) -> None: + _emit(ctx, f"hi {route_match(ctx).path_args['name']}".encode()) + + routes = Routes() + routes.get("/ping")(_ping) + routes.get("/slow")(_slow) + routes.get("/hello/{name}")(_hello) + return routes.build().as_handler() + + if mode == "wsgi": + from localpost.http import wrap_wsgi + + def wsgi_app(environ, start_response): + path = environ.get("PATH_INFO", "/") + body = f"wsgi:{path}".encode() + start_response( + "200 OK", + [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))], + ) + return [body] + + return wrap_wsgi(wsgi_app) + + if mode == "flask": + from flask import Flask + + from localpost.http.flask import flask_handler + + flask_app = Flask(__name__) + + @flask_app.route("/ping") + def ping(): + return "pong-flask" + + @flask_app.route("/hello/") + def hello(name: str): + return f"hi {name} (flask)" + + assert ping + assert hello + return flask_handler(flask_app) + + raise SystemExit(f"unknown LP_TEST_MODE: {mode!r}") + + +def _main() -> int: + logging.basicConfig(level=logging.INFO) + + from localpost.hosting import run, service + from localpost.hosting.middleware import shutdown_on_signal + from localpost.http import ServerConfig, http_server, thread_pool_handler + from localpost.threadtools import WorkerExecutor + + # Honor LP_TEST_BACKEND so tests can pin asyncio vs trio. + backend = os.environ.get("LP_TEST_BACKEND", "asyncio") + port = int(os.environ["LP_TEST_PORT"]) + handler = _build_handler() + cfg = ServerConfig(host="127.0.0.1", port=port) + + @service + async def app(): + with WorkerExecutor() as executor: + async with thread_pool_handler(handler, executor) as wrapped: + async with http_server(cfg, wrapped): + yield + + svc = shutdown_on_signal()(app()) + return anyio.run(run, svc, None, backend=backend) + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/http/_sentry_helpers.py b/tests/http/_sentry_helpers.py new file mode 100644 index 0000000..a989f0e --- /dev/null +++ b/tests/http/_sentry_helpers.py @@ -0,0 +1,44 @@ +"""Shared helpers for the Sentry-handler tests.""" + +from __future__ import annotations + +import sentry_sdk +from sentry_sdk.envelope import Envelope +from sentry_sdk.transport import Transport + + +class CapturingTransport(Transport): + """Sentry transport that just records envelopes (no network).""" + + def __init__(self) -> None: + super().__init__({}) + self.envelopes: list[Envelope] = [] + + def capture_envelope(self, envelope: Envelope) -> None: + self.envelopes.append(envelope) + + def flush(self, timeout: float, callback=None) -> None: # type: ignore[override] + pass + + +def transactions(transport: CapturingTransport) -> list[dict]: + out: list[dict] = [] + for env in transport.envelopes: + for item in env.items: + if item.headers.get("type") == "transaction": + payload = item.payload.json + if payload is not None: + out.append(payload) + return out + + +def init_sentry(transport: CapturingTransport) -> None: + """Boot a Sentry SDK with traces enabled and the in-memory transport.""" + sentry_sdk.init( + dsn="https://public@example.com/1", + transport=transport, + traces_sample_rate=1.0, + # Disable default integrations that might pollute envelopes. + default_integrations=False, + auto_enabling_integrations=False, + ) diff --git a/tests/http/acceptor_stress.py b/tests/http/acceptor_stress.py new file mode 100644 index 0000000..534309e --- /dev/null +++ b/tests/http/acceptor_stress.py @@ -0,0 +1,221 @@ +"""Stress tests for the acceptor + multi-selector topology. + +The recent acceptor refactor (``selectors=N, acceptor=True``) introduced a +dedicated acceptor thread that round-robins fresh conns onto N worker +selectors via ``Selector.post_track`` (cross-thread op queue + wakeup pipe). +Existing example tests in ``service.py`` cover the basic happy paths; +these tests push harder on: + +- exact distribution under burst (counter is single-threaded → deterministic) +- no conn loss when many clients connect concurrently +- keep-alive conns stay pinned to their initial worker +- clean shutdown leaves no orphan listening socket (port can be re-bound) +""" + +from __future__ import annotations + +import socket +import threading +import time +from collections import Counter +from concurrent.futures import ThreadPoolExecutor + +import pytest +from anyio import to_thread + +from localpost.hosting import serve +from localpost.http import ( + HTTPReqCtx, + Response, + ServerConfig, + http_server, +) +from tests.http._helpers import read_http_response + +pytestmark = pytest.mark.anyio + + +def _capturing_handler(seen: Counter, lock: threading.Lock): + def handler(ctx: HTTPReqCtx) -> None: + with lock: + seen[threading.get_ident()] += 1 + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + return handler + + +def _wait_ready(port: int, deadline: float = 5.0) -> None: + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return + except OSError: + time.sleep(0.05) + raise RuntimeError(f"server on port {port} not ready within {deadline}s") + + +def _request_close(port: int) -> bytes: + """Open a fresh TCP conn, send GET / with Connection: close, return response bytes.""" + with socket.create_connection(("127.0.0.1", port), timeout=5.0) as sock: + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + return read_http_response(sock, deadline=5.0) + + +class TestRoundRobinDistribution: + async def test_exact_round_robin_with_fresh_conns(self, free_port): + """N=4 workers, K=N*10 fresh conns: each worker handles exactly 10. + + ``RoundRobinAcceptor`` runs single-threaded on the acceptor thread, + so its ``_next`` counter increments deterministically. With every + client opening a brand-new TCP conn, each ``accept()`` is a fresh + round-robin assignment. + """ + seen: Counter = Counter() + lock = threading.Lock() + n_workers = 4 + n_conns = n_workers * 10 + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=n_workers, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def fire_all() -> list[bytes]: + with ThreadPoolExecutor(max_workers=n_conns) as ex: + futures = [ex.submit(_request_close, free_port) for _ in range(n_conns)] + return [f.result() for f in futures] + + responses = await to_thread.run_sync(fire_all) + + for r in responses: + assert b"HTTP/1.1 200" in r, r + + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + # Every worker selector thread must have served exactly n_conns/n_workers requests. + assert len(seen) == n_workers, seen + assert all(count == n_conns // n_workers for count in seen.values()), seen + + +class TestBurst: + async def test_no_conn_loss_under_burst(self, free_port): + """64 concurrent fresh conns through a 4-worker acceptor: every one returns 200. + + No assertion on distribution — the kernel ``accept()`` queue can + reorder under heavy concurrent connect()s, and SYN-flood mitigation + on the loopback may drop conns under extreme load. The point is no + request silently disappears or hangs. + """ + seen: Counter = Counter() + lock = threading.Lock() + n_conns = 64 + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=4, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def fire_all() -> list[bytes]: + with ThreadPoolExecutor(max_workers=n_conns) as ex: + futures = [ex.submit(_request_close, free_port) for _ in range(n_conns)] + return [f.result() for f in futures] + + responses = await to_thread.run_sync(fire_all) + + for r in responses: + assert b"HTTP/1.1 200" in r, r + + assert sum(seen.values()) == n_conns, seen + + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + + +class TestKeepAlivePinning: + async def test_keepalive_conn_stays_on_one_worker(self, free_port): + """A single keep-alive conn issuing N requests is served by ONE worker. + + A conn is round-robined once on accept; afterwards it lives on that + worker's selector for its whole lifetime. Verifying this matters + because a stray re-routing would corrupt the parser handoff + invariant (parser owned by exactly one selector). + """ + seen: Counter = Counter() + lock = threading.Lock() + n_requests = 5 + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=4, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def hammer_one_socket() -> list[bytes]: + with socket.create_connection(("127.0.0.1", free_port), timeout=5.0) as sock: + out = [] + for _ in range(n_requests): + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + out.append(read_http_response(sock, deadline=5.0)) + return out + + responses = await to_thread.run_sync(hammer_one_socket) + for r in responses: + assert b"HTTP/1.1 200" in r, r + + lt.shutdown() + await lt.stopped + + # All N requests landed on the same worker selector thread. + assert len(seen) == 1, seen + assert next(iter(seen.values())) == n_requests, seen + + +class TestShutdownReleasesPort: + async def test_port_re_bindable_after_shutdown(self, free_port): + """After ``lt.stopped``, the listening socket is fully released — the + same port can be re-bound immediately. This is a stronger leak check + than ``exit_code == 0``: it proves the kernel-level fd is freed.""" + seen: Counter = Counter() + lock = threading.Lock() + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=2, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def fire() -> bytes: + return _request_close(free_port) + + for _ in range(3): + resp = await to_thread.run_sync(fire) + assert b"HTTP/1.1 200" in resp, resp + + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + + # Re-bind the same port to confirm the listening fd is released. + # ``SO_REUSEADDR`` clears noise from per-conn sockets that are still + # in ``TIME_WAIT`` (server actively closed them via ``Connection: + # close``); it does NOT bypass an active bind, so the test still + # fails if we leaked the listening fd. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind(("127.0.0.1", free_port)) + probe.listen(1) diff --git a/tests/http/app.py b/tests/http/app.py new file mode 100644 index 0000000..4f5acda --- /dev/null +++ b/tests/http/app.py @@ -0,0 +1,723 @@ +"""Tests for ``localpost.http.app.HttpApp`` — the framework layer. + +Covers decorator registration, parameter injection, response conversion, +middleware (app-level + per-route), and the hosted-service path. +""" + +from __future__ import annotations + +import contextlib +import json +import socket +import threading +import time +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any, cast + +import anyio +import httpx +import pytest +from anyio import to_thread + +from localpost.hosting import ServiceLifetimeView, serve +from localpost.http import ( + HTTPReqCtx, + Middleware, + RequestHandler, + Response, + ServerConfig, + http_server, + read_body, + start_http_server, +) +from localpost.http.app import HttpApp +from tests.http._helpers import drain_socket + +pytestmark = pytest.mark.anyio + + +# --- helpers ------------------------------------------------------------- + + +@asynccontextmanager +async def _serve_app( + app: HttpApp, + cfg: ServerConfig, +) -> AsyncGenerator[ServiceLifetimeView]: + async with serve(app.service(cfg)) as lt: + yield lt + + +async def _wait_ready(port: int, deadline: float = 5.0) -> bool: + def probe(): + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + return await to_thread.run_sync(probe) + + +async def _get(url: str, **kw) -> httpx.Response: + return await to_thread.run_sync(lambda: httpx.get(url, **kw)) + + +async def _post(url: str, **kw) -> httpx.Response: + return await to_thread.run_sync(lambda: httpx.post(url, **kw)) + + +# --- response conversion ------------------------------------------------- + + +class TestResponseConversion: + async def test_str_returns_text(self, free_port): + app = HttpApp() + + @app.get("/{name}") + def hello(name: str): + return f"Hello, {name}!" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.status_code == 200 + assert r.text == "Hello, world!" + assert r.headers["content-type"] == "text/plain; charset=utf-8" + lt.shutdown() + await lt.stopped + + async def test_dict_returns_json(self, free_port): + app = HttpApp() + + @app.get("/data") + def data(): + return {"x": 1, "y": [2, 3]} + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/data") + assert r.status_code == 200 + assert r.headers["content-type"] == "application/json" + assert r.json() == {"x": 1, "y": [2, 3]} + lt.shutdown() + await lt.stopped + + async def test_none_returns_204(self, free_port): + app = HttpApp() + + @app.get("/empty") + def empty(): + return None + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/empty") + assert r.status_code == 204 + lt.shutdown() + await lt.stopped + + async def test_bytes_returns_octet_stream(self, free_port): + app = HttpApp() + + @app.get("/bin") + def bin_(): + return b"\x00\x01\x02" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/bin") + assert r.status_code == 200 + assert r.content == b"\x00\x01\x02" + assert r.headers["content-type"] == "application/octet-stream" + lt.shutdown() + await lt.stopped + + async def test_native_response_passes_through(self, free_port): + """Handlers can drop to wire-format with Response. Body must match + the declared Content-Length (or set 0).""" + app = HttpApp() + + @app.get("/raw") + def raw(): + return Response( + status_code=418, + headers=[(b"content-type", b"text/plain"), (b"content-length", b"0")], + ) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/raw") + assert r.status_code == 418 + assert r.content == b"" + lt.shutdown() + await lt.stopped + + +# --- param injection ----------------------------------------------------- + + +class TestParamInjection: + async def test_path_arg(self, free_port): + app = HttpApp() + + @app.get("/users/{uid}") + def show(uid: str): + return f"u={uid}" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/users/alice") + assert r.text == "u=alice" + lt.shutdown() + await lt.stopped + + async def test_ctx_inject(self, free_port): + app = HttpApp() + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return read_body(ctx) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _post(f"http://127.0.0.1:{free_port}/echo", content=b"abcdef") + assert r.status_code == 200 + assert r.content == b"abcdef" + lt.shutdown() + await lt.stopped + + async def test_ctx_and_path_arg(self, free_port): + app = HttpApp() + + @app.post("/{name}/profile") + def update_profile(ctx: HTTPReqCtx, name: str): + payload = json.loads(read_body(ctx)) + return {"name": name, "payload": payload} + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _post( + f"http://127.0.0.1:{free_port}/alice/profile", + json={"theme": "dark"}, + ) + assert r.status_code == 200 + assert r.json() == {"name": "alice", "payload": {"theme": "dark"}} + lt.shutdown() + await lt.stopped + + async def test_unresolvable_param_raises_at_registration(self): + app = HttpApp() + with pytest.raises(ValueError, match="cannot resolve parameter"): + + @app.get("/x") + def bad(unresolved: str): + return "x" + + +# --- middleware --------------------------------------------------------- + + +def _add_marker(value: str) -> Middleware: + """Middleware: writes a marker into ctx.attrs before delegating.""" + + def mw(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx: HTTPReqCtx) -> None: + ctx.attrs.setdefault("markers", []).append(value) + inner(ctx) + + return wrapped + + return mw + + +def _short_circuit_with_status(status: int, body: bytes) -> Middleware: + def mw(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx: HTTPReqCtx) -> None: + ctx.complete( + Response( + status_code=status, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + # Do not call inner. + + return wrapped + + return mw + + +class TestMiddleware: + async def test_app_level_middleware_runs(self, free_port): + captured: list[str] = [] + + def capturing_mw(inner): + def wrapped(ctx): + captured.append("before") + inner(ctx) + captured.append("after") + + return wrapped + + app = HttpApp(middleware=[capturing_mw]) + + @app.get("/{name}") + def hello(name: str): + return f"hi {name}" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.text == "hi world" + lt.shutdown() + await lt.stopped + + assert captured == ["before", "after"] + + async def test_app_middleware_short_circuits_pre_body(self, free_port): + app = HttpApp(middleware=[_short_circuit_with_status(401, b"unauthorized")]) + + @app.get("/{name}") + def hello(name: str): + return f"hi {name}" # pragma: no cover + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.status_code == 401 + assert r.text == "unauthorized" + lt.shutdown() + await lt.stopped + + async def test_per_route_middleware(self, free_port): + app = HttpApp() + + @app.get("/protected", middleware=[_short_circuit_with_status(403, b"forbidden")]) + def protected(): + return "secret" # pragma: no cover + + @app.get("/public") + def public(): + return "open" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r1 = await _get(f"http://127.0.0.1:{free_port}/protected") + assert r1.status_code == 403 + r2 = await _get(f"http://127.0.0.1:{free_port}/public") + assert r2.status_code == 200 + assert r2.text == "open" + lt.shutdown() + await lt.stopped + + async def test_attrs_propagate_pre_to_post_body(self, free_port): + """Middleware writes to ctx.attrs in pre-body; post-body handler reads it.""" + app = HttpApp(middleware=[_add_marker("auth-ok")]) + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return { + "markers": ctx.attrs.get("markers", []), + "body": read_body(ctx).decode("utf-8"), + } + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _post(f"http://127.0.0.1:{free_port}/echo", content=b"hi") + assert r.json() == {"markers": ["auth-ok"], "body": "hi"} + lt.shutdown() + await lt.stopped + + +# --- 404 / 405 ----------------------------------------------------------- + + +class TestNotFoundAndMethodNotAllowed: + async def test_404_when_no_route(self, free_port): + app = HttpApp() + + @app.get("/known") + def known(): + return "x" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/missing") + assert r.status_code == 404 + lt.shutdown() + await lt.stopped + + async def test_405_with_allow_header(self, free_port): + app = HttpApp() + + @app.post("/r") + def post_only(): + return "ok" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/r") + assert r.status_code == 405 + assert r.headers.get("allow") == "POST" + lt.shutdown() + await lt.stopped + + +# --- pool / concurrency -------------------------------------------------- + + +class TestWorkerPool: + async def test_handlers_run_on_worker_threads(self, free_port): + """Default (``pooled=True``) — handlers run on worker threads, + not the selector thread.""" + seen: list[int] = [] + lock = threading.Lock() + # Hold each in-flight handler until a second one arrives, forcing + # the pool to spawn a second worker. Otherwise a single fast worker + # may serve all 8 requests sequentially (race seen on 3.12). + barrier = threading.Barrier(2, timeout=2.0) + + app = HttpApp() + + @app.get("/tid") + def tid(): + try: + barrier.wait() + except threading.BrokenBarrierError: + pass + with lock: + seen.append(threading.get_ident()) + return "x" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + + async with anyio.create_task_group() as tg: + for _ in range(8): + tg.start_soon(_get, f"http://127.0.0.1:{free_port}/tid") + + assert len(seen) == 8 + assert len(set(seen)) >= 2 # at least two distinct worker threads + + lt.shutdown() + await lt.stopped + + async def test_pooled_false_runs_inline(self, free_port): + """When the pool is disabled, handlers run on the selector thread.""" + seen: set[int] = set() + lock = threading.Lock() + + app = HttpApp(pooled=False) + + @app.get("/tid") + def tid(): + with lock: + seen.add(threading.get_ident()) + return "x" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + + for _ in range(5): + await _get(f"http://127.0.0.1:{free_port}/tid") + + assert len(seen) == 1 # all on the selector thread + + lt.shutdown() + await lt.stopped + + +# --- backend selection --------------------------------------------------- + + +class TestBackendSelection: + async def test_explicit_backend_basic_response(self, free_port, http_backend): + app = HttpApp() + + @app.get("/{name}") + def hello(name: str): + return f"hi {name}" + + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend=http_backend) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.status_code == 200 + assert r.text == "hi world" + lt.shutdown() + await lt.stopped + + def test_invalid_backend(self): + cfg = ServerConfig(host="127.0.0.1", port=0, backend=cast(Any, "bogus")) + with pytest.raises(ValueError, match="unknown backend"): + start_http_server(cfg, lambda ctx: None) + + +class TestStreamingRoutes: + async def test_streaming_route_reads_body_in_handler(self, free_port, http_backend): + """``buffer_body=False`` — handler runs in a worker on a borrowed + conn and reads body chunks via ``ctx.receive(...)``.""" + captured: dict = {} + + app = HttpApp() + + @app.post("/upload") + def upload(ctx: HTTPReqCtx): + chunks: list[bytes] = [] + while True: + chunk = ctx.receive(8192) + if not chunk: + break + chunks.append(chunk) + full = b"".join(chunks) + captured["body"] = full + captured["thread"] = threading.get_ident() + return f"got {len(full)} bytes" + + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend=http_backend) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + payload = b"a" * 1024 + r = await _post(f"http://127.0.0.1:{free_port}/upload", content=payload) + assert r.status_code == 200 + assert r.text == "got 1024 bytes" + lt.shutdown() + await lt.stopped + + assert captured["body"] == b"a" * 1024 + # Streaming handler runs on a worker thread, not the main thread. + assert captured["thread"] != threading.get_ident() + + async def test_streaming_route_oversized_chunked_body_returns_413(self, free_port, http_backend): + app = HttpApp() + + @app.post("/upload") + def upload(ctx: HTTPReqCtx): + while ctx.receive(8192): + pass + return "ok" + + cfg = ServerConfig(host="127.0.0.1", port=free_port, max_body_size=8, backend=http_backend) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as sock: + sock.sendall( + b"POST /upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" + b"10\r\n" + (b"x" * 16) + b"\r\n0\r\n\r\n" + ) + return drain_socket(sock, deadline=2.0) + + response = await to_thread.run_sync(hit) + assert b"HTTP/1.1 413" in response + assert b"Payload Too Large" in response + lt.shutdown() + await lt.stopped + + async def test_streaming_route_body_across_multiple_recvs_httptools(self, free_port): + """The client sends headers in one TCP write, body in subsequent + writes (so the parser only sees headers in the selector's first + feed). The worker must pull the rest through the parser via + ``ctx.receive`` → ``sock.recv`` → ``feed_data`` → ``on_body``.""" + pytest.importorskip("httptools") + captured: dict = {} + + app = HttpApp() + + @app.post("/upload") + def upload(ctx: HTTPReqCtx): + chunks: list[bytes] = [] + while True: + chunk = ctx.receive(8192) + if not chunk: + break + chunks.append(chunk) + captured["body"] = b"".join(chunks) + return f"got {len(captured['body'])} bytes" + + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(app.service(cfg)) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + payload = b"a" * 4096 + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: + s.sendall(b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\n") + # Send body in three smaller writes with brief pauses; + # the parser will see only headers in its first feed. + for i in (0, 1024, 2048): + time.sleep(0.05) + s.sendall(payload[i : i + 1024]) + time.sleep(0.05) + s.sendall(payload[3072:4096]) + buf = b"" + while b"got 4096 bytes" not in buf: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + return buf + + response = await to_thread.run_sync(hit) + assert b"HTTP/1.1 200" in response + assert b"got 4096 bytes" in response + lt.shutdown() + await lt.stopped + + assert captured["body"] == b"a" * 4096 + + async def test_httptools_deferred_streaming_dispatch_sees_current_feed_body(self, free_port): + """httptools starts streaming work after callbacks from the current feed finish.""" + pytest.importorskip("httptools") + captured: dict = {} + + def handler(ctx: HTTPReqCtx): + def dispatch(req_ctx: HTTPReqCtx) -> None: + conn = cast(Any, req_ctx).conn + conn.selector.stop_tracking(conn) + captured["buffer_before_receive"] = bytes(conn._body_buf) + captured["eom_before_receive"] = conn._body_eom + chunks: list[bytes] = [] + while True: + chunk = req_ctx.receive(8192) + if not chunk: + break + chunks.append(chunk) + captured["body"] = b"".join(chunks) + req_ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") + + cast(Any, ctx)._defer_streaming_dispatch(dispatch) + + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(http_server(cfg, handler)) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + payload = b"a" * 1024 + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: + s.sendall(b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 1024\r\n\r\n" + payload) + buf = b"" + while b"\r\n\r\nok" not in buf: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + return buf + + response = await to_thread.run_sync(hit) + assert b"HTTP/1.1 200" in response + assert b"\r\n\r\nok" in response + lt.shutdown() + await lt.stopped + + assert captured == { + "buffer_before_receive": b"a" * 1024, + "eom_before_receive": True, + "body": b"a" * 1024, + } + + async def test_streaming_then_keep_alive_request_httptools(self, free_port): + """After a streaming POST, the same connection serves a regular + GET. Verifies parser state is consistent after streaming — the + next request parses cleanly without any reset / replace.""" + pytest.importorskip("httptools") + captured: list[str] = [] + + app = HttpApp() + + @app.post("/upload") + def upload(ctx: HTTPReqCtx): + total = 0 + while True: + chunk = ctx.receive(8192) + if not chunk: + break + total += len(chunk) + captured.append(f"upload:{total}") + return f"got {total} bytes" + + @app.get("/ping") + def ping(): + captured.append("ping") + return "pong" + + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(app.service(cfg)) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: + # Streaming POST. + s.sendall(b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 1024\r\n\r\n" + b"a" * 1024) + buf1 = b"" + while b"got 1024 bytes" not in buf1: + chunk = s.recv(4096) + if not chunk: + break + buf1 += chunk + # Now reuse the same conn for a regular GET — would fail + # if streaming had left the parser in a stale state. + s.sendall(b"GET /ping HTTP/1.1\r\nHost: x\r\n\r\n") + buf2 = b"" + while b"pong" not in buf2: + chunk = s.recv(4096) + if not chunk: + break + buf2 += chunk + return buf1 + buf2 + + response = await to_thread.run_sync(hit) + assert b"got 1024 bytes" in response + assert b"pong" in response + lt.shutdown() + await lt.stopped + + assert captured == ["upload:1024", "ping"] + + +# Avoid "imported but unused" lints — the helper is part of the public smoke API. +_keep = (contextlib,) diff --git a/tests/http/asgi.py b/tests/http/asgi.py new file mode 100644 index 0000000..d0d83f8 --- /dev/null +++ b/tests/http/asgi.py @@ -0,0 +1,321 @@ +"""Tests for ``localpost.http.asgi`` — :func:`to_asgi` adapter and the +underlying :class:`_ASGIReqCtx` Protocol implementation. + +Drives the ASGI app directly via :class:`httpx.ASGITransport` (no real +ASGI server needed); also exercises the ctx in isolation by handing it +captured ``send`` / mocked ``receive`` channels. + +Symmetric with ``tests/http/wsgi_to.py`` for the sync side. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +import anyio +import httpx +import pytest + +from localpost.http import ( + AsyncHTTPReqCtx, + Response, + aread_body, + to_asgi, +) +from localpost.http._types import Request +from localpost.http.asgi import _ASGIReqCtx, addrs_from_scope, build_request_from_scope + +# --- Helpers ------------------------------------------------------------ + + +def _build_ctx( + *, + request: Request | None = None, + body: bytes = b"", + send=None, # type: ignore[no-untyped-def] +) -> _ASGIReqCtx: + """Build a minimal _ASGIReqCtx for unit-level checks. + + Pre-fills the body channel with ``body`` and closes it — the ctx + behaves as if the bridge had received exactly those bytes from + upstream and observed EOM. + """ + + async def _swallow(_event: dict[str, Any]) -> None: + pass + + body_send, body_recv = anyio.create_memory_object_stream[bytes](max_buffer_size=64) + if body: + body_send.send_nowait(body) + body_send.close() + + return _ASGIReqCtx( + request=request or Request(b"GET", b"/", b"/", b"", []), + remote_addr=None, + local_addr="0.0.0.0:0", + scheme="http", + _send=send or _swallow, + _disconnected=anyio.Event(), + _body_stream=body_recv, + ) + + +# --- Protocol conformance ----------------------------------------------- + + +def test_asgi_ctx_satisfies_async_protocol() -> None: + """Concrete ``_ASGIReqCtx`` should satisfy ``AsyncHTTPReqCtx`` via + structural typing — guard against drift.""" + ctx = _build_ctx() + assert isinstance(ctx, AsyncHTTPReqCtx) + + +# --- Scope translation -------------------------------------------------- + + +class TestScopeTranslation: + def test_request_built_from_scope(self) -> None: + scope: dict[str, Any] = { + "type": "http", + "method": "POST", + "path": "/items/42", + "raw_path": b"/items/42", + "query_string": b"q=1", + "headers": [(b"content-type", b"application/json")], + "http_version": "1.1", + } + req = build_request_from_scope(scope) + assert req.method == b"POST" + assert req.path == b"/items/42" + assert req.query_string == b"q=1" + assert req.target == b"/items/42?q=1" + assert (b"content-type", b"application/json") in req.headers + + def test_addrs_from_scope_with_client_and_server(self) -> None: + scope = {"client": ["1.2.3.4", 5555], "server": ["10.0.0.1", 8000]} + remote, local = addrs_from_scope(scope) + assert remote == "1.2.3.4:5555" + assert local == "10.0.0.1:8000" + + def test_addrs_from_scope_no_client(self) -> None: + scope: dict[str, Any] = {"server": ["10.0.0.1", 8000]} + remote, local = addrs_from_scope(scope) + assert remote is None + assert local == "10.0.0.1:8000" + + +# --- Ctx behaviour ------------------------------------------------------ + + +class TestCtxComplete: + @pytest.mark.anyio + async def test_complete_emits_start_then_body(self) -> None: + events: list[dict[str, Any]] = [] + + async def send(event: dict[str, Any]) -> None: + events.append(event) + + ctx = _build_ctx(send=send) + await ctx.complete(Response(status_code=200, headers=[(b"x-y", b"z")]), b"hello") + assert [e["type"] for e in events] == ["http.response.start", "http.response.body"] + assert events[0]["status"] == 200 + assert (b"x-y", b"z") in events[0]["headers"] + assert events[1]["body"] == b"hello" + assert events[1]["more_body"] is False + assert ctx.response_status == 200 + + @pytest.mark.anyio + async def test_complete_twice_raises(self) -> None: + ctx = _build_ctx() + await ctx.complete(Response(200), b"x") + with pytest.raises(RuntimeError, match="already started"): + await ctx.complete(Response(200), b"y") + + +class TestCtxStream: + @pytest.mark.anyio + async def test_stream_drains_chunks_and_finalises(self) -> None: + events: list[dict[str, Any]] = [] + + async def send(event: dict[str, Any]) -> None: + events.append(event) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + yield b"b" + + ctx = _build_ctx(send=send) + await ctx.stream(Response(200), chunks()) + types = [e["type"] for e in events] + assert types[0] == "http.response.start" + bodies = [e for e in events if e["type"] == "http.response.body"] + assert [e["body"] for e in bodies[:2]] == [b"a", b"b"] + # Final terminator: empty body, more_body=False. + assert bodies[-1] == {"type": "http.response.body", "body": b"", "more_body": False} + + @pytest.mark.anyio + async def test_stream_short_circuits_on_disconnect(self) -> None: + events: list[dict[str, Any]] = [] + + async def send(event: dict[str, Any]) -> None: + events.append(event) + + ctx = _build_ctx(send=send) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + ctx._disconnected.set() + yield b"b" # should not reach the wire + + await ctx.stream(Response(200), chunks()) + bodies = [e for e in events if e["type"] == "http.response.body"] + # Only the first chunk lands; no terminator (peer is gone). + assert [e["body"] for e in bodies] == [b"a"] + + +class TestCtxReceive: + """``ctx.receive(size)`` pulls from the in-process body queue, + splitting upstream chunks down to the caller's requested ``size``. + """ + + @pytest.mark.anyio + async def test_receive_splits_chunk_across_calls(self) -> None: + ctx = _build_ctx(body=b"abcdef") + assert await ctx.receive(2) == b"ab" + assert await ctx.receive(3) == b"cde" + assert await ctx.receive(10) == b"f" + assert await ctx.receive(10) == b"" + + +# --- to_asgi end-to-end (via httpx.ASGITransport) ----------------------- + + +def _client(asgi_app) -> httpx.AsyncClient: # type: ignore[no-untyped-def] + return httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi_app), base_url="http://t") + + +class TestToAsgi: + @pytest.mark.anyio + async def test_simple_complete_round_trip(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"hi") + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + resp = await c.get("/anything") + assert resp.status_code == 200 + assert resp.content == b"hi" + + @pytest.mark.anyio + async def test_body_read_via_aread_body(self) -> None: + captured: dict[str, bytes] = {} + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + captured["body"] = await aread_body(ctx) + await ctx.complete(Response(200), b"ok") + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + await c.post("/", content=b"payload") + assert captured["body"] == b"payload" + + @pytest.mark.anyio + async def test_content_length_pre_check_413(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + raise AssertionError("handler should not run for over-cap requests") + + asgi_app = to_asgi(handler, max_body_size=4) + async with _client(asgi_app) as c: + resp = await c.post("/", content=b"more-than-four-bytes") + assert resp.status_code == 413 + + @pytest.mark.anyio + async def test_stream_round_trip(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + yield b"b" + yield b"c" + + await ctx.stream(Response(200, headers=[(b"content-type", b"text/plain")]), chunks()) + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + async with c.stream("GET", "/") as resp: + assert resp.status_code == 200 + wire = b"".join([chunk async for chunk in resp.aiter_bytes()]) + assert wire == b"abc" + + @pytest.mark.anyio + async def test_unsupported_scope_raises(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + pass + + asgi_app = to_asgi(handler) + scope = {"type": "websocket"} + + async def receive() -> dict[str, Any]: + return {"type": "websocket.disconnect"} + + async def send(_event: dict[str, Any]) -> None: + pass + + with pytest.raises(ValueError, match="unsupported ASGI scope"): + await asgi_app(scope, receive, send) + + +class TestToAsgiReceive: + """End-to-end body-streaming: handlers pull chunks via + ``await ctx.receive(size)``.""" + + @pytest.mark.anyio + async def test_receive_drains_in_order(self) -> None: + captured: list[bytes] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + while True: + chunk = await ctx.receive(64) + if not chunk: + break + captured.append(chunk) + await ctx.complete(Response(200), b"ok") + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + resp = await c.post("/", content=b"hello-streaming-world") + assert resp.status_code == 200 + assert b"".join(captured) == b"hello-streaming-world" + + @pytest.mark.anyio + async def test_receive_honours_size_with_partial_chunk(self) -> None: + sizes: list[int] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + # Read 3 bytes at a time — chunks arriving on the channel + # may be larger; ctx.receive should split them. + while True: + chunk = await ctx.receive(3) + if not chunk: + break + sizes.append(len(chunk)) + await ctx.complete(Response(200), b"ok") + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + await c.post("/", content=b"abcdefghij") + assert all(s <= 3 for s in sizes) + assert sum(sizes) == 10 + + @pytest.mark.anyio + async def test_handler_can_skip_body_and_respond(self) -> None: + """Handler may respond without reading the body — the channel + pump drains stragglers; the response still completes.""" + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(204), b"") + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + resp = await c.post("/", content=b"ignored-body") + assert resp.status_code == 204 diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py new file mode 100644 index 0000000..40b291c --- /dev/null +++ b/tests/http/backend_parity.py @@ -0,0 +1,285 @@ +"""Shared HTTP behavior contract across the h11 and httptools backends.""" + +from __future__ import annotations + +import socket + +import httpx +import pytest + +from localpost.http import HTTPReqCtx, Response, ServerConfig, read_body +from tests.http._helpers import drain_socket, read_http_response, read_until + + +def _ok(body: bytes = b"hello"): + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + + return handler + + +def test_simple_get(serve_backend_in_thread): + with serve_backend_in_thread(_ok(b"hi")) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert r.status_code == 200 + assert r.text == "hi" + + +def test_buffered_post_body_handler(serve_backend_in_thread, http_backend): + if http_backend == "httptools": + # ``read_body`` from on-selector handlers re-enters httptools' + # ``parser.feed_data`` callback chain — only safe via a worker + # pool. The pooled equivalent lives in ``tests/http/service.py``; + # here we just exercise the h11 path. + pytest.skip("httptools requires worker-pool composition for body reads") + + received: list[bytes] = [] + + def handler(ctx: HTTPReqCtx) -> None: + body = read_body(ctx) + received.append(body) + out = b"echo:" + body + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(out)).encode("ascii")), + ], + ), + out, + ) + + with serve_backend_in_thread(handler) as port: + r = httpx.post(f"http://127.0.0.1:{port}/x", content=b"hello world", timeout=2) + assert r.status_code == 200 + assert r.text == "echo:hello world" + assert received == [b"hello world"] + + +def test_keep_alive_sequential_requests(serve_backend_in_thread): + counter = 0 + + def handler(ctx: HTTPReqCtx) -> None: + nonlocal counter + counter += 1 + body = str(counter).encode("ascii") + ctx.complete( + Response( + 200, + [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n") + data1 = read_http_response(sock) + + sock.sendall(b"GET /b HTTP/1.1\r\nHost: x\r\n\r\n") + data2 = read_http_response(sock) + + assert b"HTTP/1.1 200" in data1 + assert data1.endswith(b"1") + assert b"HTTP/1.1 200" in data2 + assert data2.endswith(b"2") + assert counter == 2 + + +def test_malformed_request_returns_400(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"NOT-AN-HTTP-REQUEST\r\n\r\n") + data = drain_socket(sock, deadline=1.0) + assert b"HTTP/1.1 400" in data, data + + +def test_expect_100_continue(serve_backend_in_thread, http_backend): + if http_backend == "httptools": + pytest.skip("httptools requires worker-pool composition for body reads") + + def handler(ctx: HTTPReqCtx) -> None: + # ``read_body`` drives the body recv loop, which sends 100 Continue + # when the parser sees the client is awaiting it. + body = read_body(ctx) + out = b"got:" + body + ctx.complete( + Response( + 200, + [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(out)).encode("ascii")), + ], + ), + out, + ) + + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.sendall(b"POST /x HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") + pre = read_until(sock, b"\r\n\r\n") + assert b"100 Continue" in pre, pre + + sock.sendall(b"hello") + final = read_until(sock, b"got:hello") + + assert b"HTTP/1.1 200" in final + assert b"got:hello" in final + + +def test_oversize_content_length_returns_413(serve_backend_in_thread): + called = False + + def handler(ctx: HTTPReqCtx) -> None: + nonlocal called + called = True + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + config = ServerConfig(host="127.0.0.1", port=0, max_body_size=100) + with serve_backend_in_thread(handler, config) as port: + r = httpx.post(f"http://127.0.0.1:{port}/", content=b"x" * 1024, timeout=2) + + assert r.status_code == 413 + assert r.text == "Payload Too Large" + assert called is False + + +def test_handler_exception_returns_500_and_server_survives(serve_backend_in_thread): + calls = 0 + + def handler(ctx: HTTPReqCtx) -> None: + nonlocal calls + calls += 1 + if ctx.request.target == b"/boom": + raise RuntimeError("handler crashed") + ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") + + with serve_backend_in_thread(handler) as port: + r1 = httpx.get(f"http://127.0.0.1:{port}/boom", timeout=2) + r2 = httpx.get(f"http://127.0.0.1:{port}/ok", timeout=2) + + assert r1.status_code == 500 + assert r1.text == "Internal Server Error" + assert r2.status_code == 200 + assert r2.text == "ok" + assert calls == 2 + + +def test_streaming_response_chunks(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.stream( + Response( + status_code=200, + headers=[], + ), + iter([b"chunk1", b"chunk2"]), + ) + + with serve_backend_in_thread(handler) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert r.status_code == 200 + assert r.content == b"chunk1chunk2" + + +def test_unframed_204_response_has_no_transfer_encoding(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete(Response(status_code=204, headers=[])) + + with serve_backend_in_thread(handler) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert r.status_code == 204 + assert "transfer-encoding" not in r.headers + assert r.content == b"" + + +def test_unframed_304_response_has_no_transfer_encoding(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete(Response(status_code=304, headers=[])) + + with serve_backend_in_thread(handler) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert r.status_code == 304 + assert "transfer-encoding" not in r.headers + assert r.content == b"" + + +def test_unframed_head_response_has_no_body_or_transfer_encoding(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete(Response(status_code=200, headers=[])) + + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"HEAD / HTTP/1.1\r\nHost: x\r\n\r\n") + data = read_until(sock, b"\r\n\r\n", deadline=2) + headers, _, body = data.partition(b"\r\n\r\n") + + sock.settimeout(0.2) + try: + extra = sock.recv(16) + except TimeoutError: + extra = b"" + + assert b"HTTP/1.1 200" in headers + assert b"transfer-encoding:" not in headers.lower() + assert body == b"" + assert extra == b"" + + +def test_user_supplied_chunked_te_frames_chunks(serve_backend_in_thread): + """Regression: a handler that explicitly sets ``Transfer-Encoding: chunked`` + must still get its ``send(...)`` chunks framed on the wire. Earlier the + httptools backend only set its internal ``_chunked`` flag inside the + auto-frame branch, so user-supplied TE bypassed framing and the wire + was malformed. + """ + import socket + + def handler(ctx: HTTPReqCtx) -> None: + ctx.stream( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"transfer-encoding", b"chunked"), + ], + ), + iter([b"chunk1", b"chunk2"]), + ) + + with serve_backend_in_thread(handler) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + s.settimeout(5) + buf = b"" + while True: + chunk = s.recv(8192) + if not chunk: + break + buf += chunk + + head, _, body = buf.partition(b"\r\n\r\n") + assert b"transfer-encoding: chunked" in head.lower() + # Each ``send`` produces one chunk on the wire, framed as + # ``\r\n\r\n``. Verify both are present, then the + # zero-length terminator. + assert b"6\r\nchunk1\r\n" in body + assert b"6\r\nchunk2\r\n" in body + assert body.endswith(b"0\r\n\r\n") diff --git a/tests/http/body.py b/tests/http/body.py new file mode 100644 index 0000000..d67956a --- /dev/null +++ b/tests/http/body.py @@ -0,0 +1,68 @@ +"""Tests for ``localpost.http.read_body`` / ``localpost.http.aread_body``.""" + +from __future__ import annotations + +import pytest + +from localpost.http import BodyTooLarge, aread_body, read_body + +# --- Sync ---------------------------------------------------------------- + + +class _SyncStubCtx: + """Minimal HTTPReqCtx stub satisfying just ``receive(size)``.""" + + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + def receive(self, _size: int = 0, /) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +class TestReadBody: + def test_drains_to_eof(self): + ctx = _SyncStubCtx([b"hello ", b"world"]) + assert read_body(ctx) == b"hello world" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + + def test_empty_body(self): + ctx = _SyncStubCtx([]) + assert read_body(ctx) == b"" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + + def test_max_size_exceeded(self): + ctx = _SyncStubCtx([b"a" * 5, b"b" * 5]) + with pytest.raises(BodyTooLarge): + read_body(ctx, max_size=8) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + + def test_max_size_exact_fit(self): + ctx = _SyncStubCtx([b"a" * 4, b"b" * 4]) + assert read_body(ctx, max_size=8) == b"aaaabbbb" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + + +# --- Async --------------------------------------------------------------- + + +class _AsyncStubCtx: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + async def receive(self, _size: int = 0, /) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +class TestAreadBody: + async def test_drains_to_eof(self): + ctx = _AsyncStubCtx([b"hello ", b"world"]) + assert await aread_body(ctx) == b"hello world" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + + async def test_empty_body(self): + ctx = _AsyncStubCtx([]) + assert await aread_body(ctx) == b"" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + + async def test_max_size_exceeded(self): + ctx = _AsyncStubCtx([b"a" * 5, b"b" * 5]) + with pytest.raises(BodyTooLarge): + await aread_body(ctx, max_size=8) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] diff --git a/tests/http/compress.py b/tests/http/compress.py new file mode 100644 index 0000000..565b993 --- /dev/null +++ b/tests/http/compress.py @@ -0,0 +1,699 @@ +"""Tests for localpost.http.compress — gzip/brotli response middleware.""" + +from __future__ import annotations + +import gzip +import importlib.util +import zlib +from collections.abc import Iterator +from pathlib import Path + +import httpx +import pytest + +from localpost.http import ( + HTTPReqCtx, + Response, + compress_handler, + static_handler, +) +from localpost.http.compress import ( + DEFAULT_COMPRESSIBLE_TYPES, + _is_compressible_response, + _is_streaming_eligible, + _merge_vary, + _negotiate, + _rewrite_headers, + _streaming_rewrite_headers, +) + +_HAS_BROTLI = importlib.util.find_spec("brotli") is not None + + +# --- Negotiation --------------------------------------------------------- + + +class TestNegotiate: + def test_empty_returns_none(self): + assert _negotiate(b"", ("br", "gzip")) is None + + def test_simple(self): + assert _negotiate(b"gzip", ("br", "gzip")) == "gzip" + + def test_preference_order(self): + # Server prefers br first; client lists both → pick br. + assert _negotiate(b"gzip, br", ("br", "gzip")) == "br" + + def test_unsupported_returns_none(self): + assert _negotiate(b"deflate", ("br", "gzip")) is None + + def test_q_zero_disables(self): + assert _negotiate(b"gzip;q=0, br", ("gzip", "br")) == "br" + + def test_only_q_zero_returns_none(self): + assert _negotiate(b"gzip;q=0", ("gzip",)) is None + + def test_wildcard(self): + assert _negotiate(b"*", ("br", "gzip")) == "br" + + def test_wildcard_with_explicit_disable(self): + # ``*;q=1, gzip;q=0`` → gzip explicitly disabled, br matches via wildcard. + assert _negotiate(b"gzip;q=0, *;q=1", ("gzip", "br")) == "br" + + def test_q_value_parsing_invalid_treated_as_zero(self): + assert _negotiate(b"gzip;q=abc", ("gzip",)) is None + + def test_whitespace_tolerant(self): + assert _negotiate(b" gzip ;q= 1.0 , br ", ("br", "gzip")) == "br" + + +# --- _is_compressible_response ------------------------------------------ + + +def _r(status: int = 200, headers=None) -> Response: + return Response(status_code=status, headers=list(headers or [])) + + +class TestIsCompressible: + def test_basic_yes(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_method_head(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"HEAD", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_body_none(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, None, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_below_min_size(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, b"x" * 100, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + @pytest.mark.parametrize("status", [100, 101, 199, 204, 304, 206]) + def test_no_body_statuses(self, status): + r = _r(status, headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_existing_content_encoding(self): + r = _r(headers=[(b"content-type", b"application/json"), (b"content-encoding", b"gzip")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_identity_encoding_treated_as_compressible(self): + r = _r(headers=[(b"content-type", b"application/json"), (b"content-encoding", b"identity")]) + assert _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_no_transform_directive(self): + r = _r(headers=[(b"content-type", b"application/json"), (b"cache-control", b"public, no-transform")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_content_type_with_charset(self): + # ``application/json; charset=utf-8`` — main type splits cleanly. + r = _r(headers=[(b"content-type", b"application/json; charset=utf-8")]) + assert _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_non_compressible_type(self): + r = _r(headers=[(b"content-type", b"image/png")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_no_content_type(self): + r = _r(headers=[]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_user_supplied_allowlist(self): + custom = frozenset({b"application/x-custom"}) + r = _r(headers=[(b"content-type", b"application/x-custom")]) + assert _is_compressible_response(r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=custom) + r2 = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response(r2, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=custom) + + +# --- Header rewrite ------------------------------------------------------ + + +class TestRewriteHeaders: + def test_replaces_content_length(self): + out = _rewrite_headers( + [(b"content-type", b"application/json"), (b"content-length", b"100")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"content-length"] == b"42" + + def test_adds_content_length_if_missing(self): + out = _rewrite_headers( + [(b"content-type", b"application/json")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"content-length"] == b"42" + + def test_adds_content_encoding(self): + out = _rewrite_headers( + [(b"content-type", b"application/json"), (b"content-length", b"100")], + encoding="br", + new_length=42, + ) + d = dict(out) + assert d[b"content-encoding"] == b"br" + + def test_vary_added_if_missing(self): + out = _rewrite_headers([(b"content-length", b"100")], encoding="gzip", new_length=42) + d = dict(out) + assert d[b"vary"] == b"Accept-Encoding" + + def test_vary_merged_with_existing(self): + out = _rewrite_headers( + [(b"content-length", b"100"), (b"vary", b"Cookie")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"vary"] == b"Cookie, Accept-Encoding" + + def test_vary_star_left_alone(self): + out = _rewrite_headers( + [(b"content-length", b"100"), (b"vary", b"*")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"vary"] == b"*" + + def test_vary_no_duplicate_when_already_listed(self): + out = _rewrite_headers( + [(b"content-length", b"100"), (b"vary", b"Accept-Encoding, Cookie")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"vary"] == b"Accept-Encoding, Cookie" + + +class TestMergeVary: + def test_empty(self): + assert _merge_vary(b"") == b"Accept-Encoding" + + def test_single(self): + assert _merge_vary(b"Cookie") == b"Cookie, Accept-Encoding" + + def test_already_present(self): + assert _merge_vary(b"Accept-Encoding, Cookie") == b"Accept-Encoding, Cookie" + assert _merge_vary(b"accept-encoding") == b"accept-encoding" # case-insensitive + + def test_star(self): + assert _merge_vary(b"*") == b"*" + + +# --- Construction -------------------------------------------------------- + + +class TestConstruction: + def test_empty_algorithms_rejected(self): + with pytest.raises(ValueError, match="non-empty"): + compress_handler(lambda ctx: None, algorithms=()) + + def test_unknown_algorithm_rejected(self): + with pytest.raises(ValueError, match="Unsupported"): + compress_handler(lambda ctx: None, algorithms=("deflate",)) + + def test_negative_min_size_rejected(self): + with pytest.raises(ValueError, match="min_size"): + compress_handler(lambda ctx: None, algorithms=("gzip",), min_size=-1) + + +# --- Integration --------------------------------------------------------- + + +def _emit(ctx: HTTPReqCtx, content_type: bytes, body: bytes, **headers: bytes) -> None: + hs: list[tuple[bytes, bytes]] = [ + (b"content-type", content_type), + (b"content-length", str(len(body)).encode("ascii")), + ] + for k, v in headers.items(): + hs.append((k.replace("_", "-").encode("ascii"), v)) + # HEAD must not carry a body on the wire; headers describe what GET would send. + payload: bytes | None = None if ctx.request.method == b"HEAD" else body + ctx.complete(Response(status_code=200, headers=hs), payload) + + +def _make_handler(content_type: bytes, body: bytes): + def handler(ctx: HTTPReqCtx) -> None: + _emit(ctx, content_type, body) + + return handler + + +class TestRoundTripGzip: + def test_compresses_json(self, serve_backend_in_thread): + body = b'{"data": ' + b"x" * 4096 + b"}" + h = compress_handler(_make_handler(b"application/json", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + # httpx auto-decodes Content-Encoding by default. + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "gzip" + assert r.headers.get("vary", "").lower().find("accept-encoding") >= 0 + # Content-Length describes the compressed body. + assert int(r.headers["content-length"]) < len(body) + # httpx decoded it back to the original. + assert r.content == body + + def test_skips_when_client_doesnt_accept(self, serve_backend_in_thread): + # ``Accept-Encoding: identity`` is how a client opts out of compression + # explicitly. (httpx sends a default Accept-Encoding when none is set, + # so omitting the header doesn't actually mean "no Accept-Encoding".) + body = b'{"data": ' + b"x" * 4096 + b"}" + h = compress_handler(_make_handler(b"application/json", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "identity"}, + timeout=5, + ) + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == body + + def test_skips_below_min_size(self, serve_backend_in_thread): + body = b'{"x": 1}' # tiny + h = compress_handler(_make_handler(b"application/json", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert "content-encoding" not in r.headers + assert r.content == body + + def test_skips_non_compressible_type(self, serve_backend_in_thread): + body = b"\x89PNG\r\n\x1a\n" + b"\x00" * 4096 # pretend PNG payload + h = compress_handler(_make_handler(b"image/png", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert "content-encoding" not in r.headers + assert r.content == body + + def test_head_passes_through(self, serve_backend_in_thread): + body = b"x" * 4096 + h = compress_handler(_make_handler(b"text/plain", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.head( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + # No body for HEAD; we don't pretend-compress to compute length. + assert "content-encoding" not in r.headers + + +@pytest.mark.skipif(not _HAS_BROTLI, reason="brotli optional extra not installed") +class TestRoundTripBrotli: + def test_compresses_with_br(self, serve_backend_in_thread): + body = b'{"data": ' + b"x" * 4096 + b"}" + h = compress_handler(_make_handler(b"application/json", body), algorithms=("br", "gzip")) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "br, gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "br" + assert r.content == body # httpx decodes br since brotli is installed + + +# --- Pass-through composition -------------------------------------------- + + +class TestComposeWithStatic: + def test_sendfile_passes_through_uncompressed(self, tmp_path: Path, serve_backend_in_thread): + # The static handler uses ctx.sendfile — compress_handler must not + # interpose on that path. The downloaded bytes should equal the + # file bytes verbatim, with no Content-Encoding. + payload = b"" + b"x" * 4096 + b"" + (tmp_path / "page.html").write_bytes(payload) + static = static_handler(tmp_path) + h = compress_handler(static, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/page.html", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == payload + + def test_round_trip_when_pool_wraps_compress(self, serve_backend_in_thread): + # Sanity-check: ``compress_handler`` proxies ``ctx.complete`` through + # the inner handler; the response body is compressed. + body = b'{"data": ' + b"x" * 4096 + b"}" + + def inner(ctx: HTTPReqCtx) -> None: + _emit(ctx, b"application/json", body) + + h = compress_handler(inner, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.post( + f"http://127.0.0.1:{port}/", + content=b"", + headers={"accept-encoding": "gzip", "content-length": "0"}, + timeout=5, + ) + assert r.headers["content-encoding"] == "gzip" + assert r.content == body + + +# --- Manual gzip verification -------------------------------------------- + + +class TestGzipBytes: + """Decode the response body manually to verify the bytes on the wire are + actually gzip-framed (httpx auto-decodes, so the round-trip tests above + can't tell). + """ + + def test_actual_gzip_bytes_on_wire(self, serve_backend_in_thread): + import socket + + body = b"hello world " * 1024 + h = compress_handler(_make_handler(b"text/plain", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n") + buf = b"" + while True: + chunk = s.recv(8192) + if not chunk: + break + buf += chunk + head, _, raw_body = buf.partition(b"\r\n\r\n") + assert b"content-encoding: gzip" in head.lower() + assert gzip.decompress(raw_body) == body + + +# --- Streaming eligibility (unit) ---------------------------------------- + + +class TestStreamingEligible: + def test_basic_yes(self): + r = _r(headers=[(b"content-type", b"text/event-stream")]) + assert _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_head_no(self): + r = _r(headers=[(b"content-type", b"text/event-stream")]) + assert not _is_streaming_eligible(r, method=b"HEAD", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_known_length_no(self): + # Handler declared exact size — must not compress mid-stream. + r = _r(headers=[(b"content-type", b"text/event-stream"), (b"content-length", b"100")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_no_transform_no(self): + r = _r(headers=[(b"content-type", b"text/event-stream"), (b"cache-control", b"no-transform")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_existing_encoding_no(self): + r = _r(headers=[(b"content-type", b"text/event-stream"), (b"content-encoding", b"gzip")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_non_compressible_type_no(self): + r = _r(headers=[(b"content-type", b"image/png")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + @pytest.mark.parametrize("status", [100, 199, 204, 304, 206]) + def test_no_body_status(self, status): + r = _r(status, headers=[(b"content-type", b"text/event-stream")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + +# --- Streaming header rewrite (unit) ------------------------------------- + + +class TestStreamingRewriteHeaders: + def test_drops_content_length(self): + out = _streaming_rewrite_headers( + [(b"content-type", b"text/event-stream"), (b"content-length", b"100")], + encoding="gzip", + ) + names = [n for n, _ in out] + assert b"content-length" not in names + + def test_does_not_add_transfer_encoding(self): + # Backends auto-frame chunked on HTTP/1.1 — adding TE here breaks + # httptools' _chunked detection. See _streaming_rewrite_headers' docstring. + out = _streaming_rewrite_headers([(b"content-type", b"text/event-stream")], encoding="gzip") + names = [n.lower() for n, _ in out] + assert b"transfer-encoding" not in names + + def test_adds_content_encoding(self): + out = _streaming_rewrite_headers([(b"content-type", b"text/event-stream")], encoding="br") + assert (b"content-encoding", b"br") in out + + def test_merges_vary(self): + out = _streaming_rewrite_headers( + [(b"content-type", b"text/event-stream"), (b"vary", b"Cookie")], + encoding="gzip", + ) + d = dict(out) + assert d[b"vary"] == b"Cookie, Accept-Encoding" + + +# --- Streaming round-trip ------------------------------------------------ + + +def _sse_handler(events: list[bytes]): + """Handler that emits each item in ``events`` via ``ctx.stream(...)`` — + the iterator-wrapping path the compression middleware intercepts. + """ + + def handler(ctx: HTTPReqCtx) -> None: + def chunks() -> Iterator[bytes]: + for ev in events: + yield b"data: " + ev + b"\n\n" + + # No Content-Length → streaming path eligible for compression. + ctx.stream( + Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]), + chunks(), + ) + + return handler + + +class TestStreamingRoundTripGzip: + def test_sse_compressed(self, serve_backend_in_thread): + events = [b"hello", b"world", b"x" * 4096, b"final"] + h = compress_handler(_sse_handler(events), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "gzip" + assert "transfer-encoding" in r.headers + # httpx auto-decodes; verify all events present in order. + decoded = r.content + for ev in events: + assert b"data: " + ev + b"\n\n" in decoded + # Total length ordering preserved. + positions = [decoded.find(b"data: " + ev) for ev in events] + assert positions == sorted(positions) + + def test_streaming_passthrough_when_content_length_set(self, serve_backend_in_thread): + # If the handler set Content-Length, we must not compress (would + # corrupt the contract). Pass through verbatim. + body_chunk = b"x" * 4096 + + def handler(ctx: HTTPReqCtx) -> None: + def chunks() -> Iterator[bytes]: + yield body_chunk + + ctx.stream( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body_chunk)).encode("ascii")), + ], + ), + chunks(), + ) + + h = compress_handler(handler, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == body_chunk + + def test_streaming_passthrough_when_no_transform(self, serve_backend_in_thread): + events = [b"a", b"b", b"c"] + + def handler(ctx: HTTPReqCtx) -> None: + def chunks() -> Iterator[bytes]: + for ev in events: + yield b"data: " + ev + b"\n\n" + + ctx.stream( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/event-stream"), + (b"cache-control", b"no-transform"), + ], + ), + chunks(), + ) + + h = compress_handler(handler, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert "content-encoding" not in r.headers + assert b"data: a" in r.content + + +@pytest.mark.skipif(not _HAS_BROTLI, reason="brotli optional extra not installed") +class TestStreamingRoundTripBrotli: + def test_sse_compressed(self, serve_backend_in_thread): + events = [b"hello", b"world", b"x" * 4096] + h = compress_handler(_sse_handler(events), algorithms=("br",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "br"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "br" + for ev in events: + assert b"data: " + ev in r.content + + +# --- Per-event flushing (raw socket) ------------------------------------- + + +class TestStreamingFlush: + """Verify SYNC_FLUSH semantics: the first event must be decodable + *before* the handler emits the second event. If we relied on the + full-stream end-flush, the test would deadlock — the handler is + holding a gate that only releases once the test reads the first event. + """ + + def test_first_event_decodes_before_stream_ends(self, serve_backend_in_thread): + import socket + import threading + + gate = threading.Event() + + def handler(ctx: HTTPReqCtx) -> None: + def chunks() -> Iterator[bytes]: + yield b"data: first\n\n" + # Block until the test has read+decoded "data: first". If + # SYNC_FLUSH didn't happen, this deadlocks at the 5s timeout. + gate.wait(timeout=5) + yield b"data: second\n\n" + + ctx.stream( + Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]), + chunks(), + ) + + h = compress_handler(handler, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n") + buf = bytearray() + s.settimeout(5) + while b"\r\n\r\n" not in buf: + buf.extend(s.recv(8192)) + head, _, after = bytes(buf).partition(b"\r\n\r\n") + assert b"content-encoding: gzip" in head.lower() + assert b"transfer-encoding: chunked" in head.lower() + + decompressor = zlib.decompressobj(wbits=31) + decoded = bytearray() + stream = bytearray(after) + + while b"data: first" not in decoded: + consumed = _consume_one_chunk(stream) + if consumed is None: + stream.extend(s.recv(8192)) + continue + decoded.extend(decompressor.decompress(consumed)) + + assert b"data: first" in decoded + gate.set() + # Drain remaining bytes (best-effort) so server-side loop + # logging stays clean for other tests. + try: + while s.recv(8192): + pass + except OSError: + pass + + +def _consume_one_chunk(stream: bytearray) -> bytes | None: + """Pop one HTTP/1.1 chunked-transfer chunk from ``stream``. Returns + the chunk's payload bytes, or ``None`` if a full chunk isn't present + yet (caller should ``recv`` more). + """ + idx = stream.find(b"\r\n") + if idx <= 0: + return None + try: + size = int(stream[:idx], 16) + except ValueError: + return None + if size == 0: + return None + start = idx + 2 + end = start + size + if len(stream) < end + 2: + return None + payload = bytes(stream[start:end]) + del stream[: end + 2] + return payload diff --git a/tests/http/conftest.py b/tests/http/conftest.py new file mode 100644 index 0000000..881961e --- /dev/null +++ b/tests/http/conftest.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import dataclasses +import socket +import threading +from collections.abc import Callable, Iterator +from typing import Literal, Protocol + +import pytest + +from localpost.http import RequestHandler, ServerConfig, start_http_server + +ServeInThread = Callable[[RequestHandler], "_ServerCM"] + + +class ServeBackendInThread(Protocol): + def __call__(self, handler: RequestHandler, config: ServerConfig | None = None) -> _ServerCM: ... + + +Backend = Literal["h11", "httptools"] + + +@pytest.fixture(params=("h11", "httptools")) +def http_backend(request) -> Backend: + name: Backend = request.param + if name == "httptools": + try: + import httptools # noqa: F401 + except ImportError as e: + pytest.skip(str(e)) + return name + + +@pytest.fixture +def free_port() -> int: + """Bind a temporary socket to port 0 and return the assigned port number. + + The socket is closed before returning; there is a tiny race window where the + OS may reuse the port. Good enough for tests, and avoids ``port=0`` read-back. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def server_config() -> ServerConfig: + """Default config for in-thread server tests: localhost + auto-assigned port.""" + return ServerConfig(host="127.0.0.1", port=0) + + +class _ServerCM: + """Returned by ``serve_in_thread(handler)``; use as a context manager. + + Yields the live port; on exit, signals the worker to stop, joins it, + and asserts no thread leak. Uncaught exceptions from the server loop + are captured in ``loop_error`` and re-raised on exit unless the test + sets ``expect_loop_error = True`` (used to characterize crash paths). + """ + + def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: + self._config = config + self._handler = handler + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._cm = start_http_server(config, handler) + self.loop_error: BaseException | None = None + self.expect_loop_error: bool = False + + def __enter__(self) -> int: + server = self._cm.__enter__() + stop = self._stop + + def loop() -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return # listening socket closed + except BaseException as e: # noqa: BLE001 + self.loop_error = e + return + + self._thread = threading.Thread(target=loop, daemon=True) + self._thread.start() + return server.port + + def __exit__(self, exc_type, exc, tb) -> None: + self._stop.set() + try: + t = self._thread + assert t is not None + t.join(timeout=5) + assert not t.is_alive(), "server thread did not stop within 5s" + finally: + self._cm.__exit__(exc_type, exc, tb) + + # Surface unexpected loop errors only if the test body succeeded — + # otherwise the original failure is more informative. + if exc_type is None: + if self.loop_error is not None and not self.expect_loop_error: + raise self.loop_error + if self.loop_error is None and self.expect_loop_error: + raise AssertionError("expected a server-loop error but none was raised") + + +@pytest.fixture +def serve_in_thread(server_config: ServerConfig) -> Iterator[ServeInThread]: + """Run an HTTP server in a background thread for the duration of a ``with`` block. + + Usage:: + + def test_thing(serve_in_thread): + def handler(ctx): ... + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/") + assert resp.status_code == 200 + + The worker thread reacts to shutdown within ~50 ms (selector poll interval), + so tests don't need magic iteration counts. + """ + active: list[_ServerCM] = [] + + def make(handler: RequestHandler) -> _ServerCM: + cm = _ServerCM(server_config, handler) + active.append(cm) + return cm + + yield make + + # Safety net for tests that forgot the ``with`` block. + for cm in active: + t = cm._thread + if t is not None and t.is_alive(): + cm._stop.set() + t.join(timeout=5) + + +@pytest.fixture +def serve_backend_in_thread(server_config: ServerConfig, http_backend: Backend) -> Iterator[ServeBackendInThread]: + active: list[_ServerCM] = [] + backend_config = dataclasses.replace(server_config, backend=http_backend) + + def make(handler: RequestHandler, config: ServerConfig | None = None) -> _ServerCM: + cm = _ServerCM( + dataclasses.replace(config, backend=http_backend) if config is not None else backend_config, + handler, + ) + active.append(cm) + return cm + + yield make + + for cm in active: + t = cm._thread + if t is not None and t.is_alive(): + cm._stop.set() + t.join(timeout=5) diff --git a/tests/http/flask_sentry_handler.py b/tests/http/flask_sentry_handler.py new file mode 100644 index 0000000..500ea29 --- /dev/null +++ b/tests/http/flask_sentry_handler.py @@ -0,0 +1,121 @@ +"""Tests for localpost.http.flask_sentry — focus on the streaming-in-same-transaction fix.""" + +from __future__ import annotations + +import httpx +import pytest +import sentry_sdk +from flask import Flask +from flask import Response as FlaskResponse +from flask import request as flask_request + +from localpost.http.flask_sentry import sentry_flask_handler + +from ._sentry_helpers import CapturingTransport, init_sentry, transactions + + +@pytest.fixture +def sentry_transport(): + transport = CapturingTransport() + init_sentry(transport) + yield transport + sentry_sdk.flush(timeout=2.0) + + +class TestSentryFlaskHandler: + def test_transaction_named_after_url_rule(self, serve_in_thread, sentry_transport): + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + return f"hi {name}" + + assert hello + + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/alice", timeout=5) + assert resp.status_code == 200 + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /hello/" + assert tx["transaction_info"]["source"] == "route" + assert tx["contexts"]["trace"]["op"] == "http.server" + + def test_unmatched_url_keeps_url_source(self, serve_in_thread, sentry_transport): + app = Flask(__name__) + + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/no-such-route", timeout=5) + assert resp.status_code == 404 + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + assert txs[0]["transaction"] == "GET /no-such-route" + assert txs[0]["transaction_info"]["source"] == "url" + + def test_streaming_span_lands_on_same_transaction(self, serve_in_thread, sentry_transport): + """The reason this adapter exists: spans emitted inside a streaming + generator must land on the same transaction as the request, not on a + new (or no) transaction. Stock Sentry FlaskIntegration ends the + transaction before the WSGI server iterates the body. + """ + app = Flask(__name__) + + @app.route("/stream/") + def stream(name: str): + def generate(): + # Span emitted DURING body iteration. Must land on the request transaction. + with sentry_sdk.start_span(op="custom.streaming-chunk", name="emit-body"): + yield f"hi {name}\n".encode() + # Touch flask.request to prove context is alive. + yield f"ua={flask_request.headers.get('User-Agent', '?')}\n".encode() + + return FlaskResponse(generate(), mimetype="text/plain") + + assert stream + + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get( + f"http://127.0.0.1:{port}/stream/alice", + headers={"User-Agent": "test"}, + timeout=5, + ) + assert resp.status_code == 200 + assert b"hi alice" in resp.content + assert b"ua=test" in resp.content + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /stream/" + + # The streaming span must be in this transaction's spans. + span_ops = [s.get("op") for s in tx.get("spans", [])] + assert "custom.streaming-chunk" in span_ops, ( + f"streaming span did not land on the transaction; spans={tx.get('spans')}" + ) + + def test_view_exception_records_500(self, serve_in_thread, sentry_transport): + app = Flask(__name__) + + @app.route("/boom") + def boom(): + raise RuntimeError("bang") + + assert boom + + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/boom", timeout=5) + assert resp.status_code == 500 + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + trace = txs[0]["contexts"]["trace"] + status = trace.get("data", {}).get("http.response.status_code") or txs[0]["tags"].get("http.status_code") + assert status in (500, "500") diff --git a/tests/http/flask_server.py b/tests/http/flask_server.py new file mode 100644 index 0000000..e3b6445 --- /dev/null +++ b/tests/http/flask_server.py @@ -0,0 +1,207 @@ +"""Tests for localpost.http.flask — the native Flask adapter.""" + +from __future__ import annotations + +import threading + +import httpx +from flask import Flask, Response, stream_with_context +from flask import request as flask_request + +from localpost.http.flask import flask_handler + + +class TestFlaskHandler: + def test_simple_200(self, serve_in_thread): + app = Flask(__name__) + + @app.route("/") + def index(): + return "hello flask" + + assert index + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "hello flask" + + def test_path_parameters(self, serve_in_thread): + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + return f"hi {name}" + + assert hello + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/alice", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "hi alice" + + def test_percent_encoded_path_parameters(self, serve_in_thread): + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + return f"hi {name}" + + assert hello + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/al%20ice", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "hi al ice" + + def test_post_body(self, serve_in_thread): + app = Flask(__name__) + captured: dict = {} + + @app.route("/echo", methods=["POST"]) + def echo(): + captured["body"] = flask_request.get_data() + return Response(captured["body"], mimetype="application/octet-stream") + + assert echo + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.post(f"http://127.0.0.1:{port}/echo", content=b"payload", timeout=5) + + assert captured.get("body") == b"payload", f"status={resp.status_code}, body={resp.content!r}" + assert resp.status_code == 200 + assert resp.content == b"payload" + + def test_flask_404(self, serve_in_thread): + app = Flask(__name__) + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/missing", timeout=5) + + assert resp.status_code == 404 + + def test_view_exception_returns_500(self, serve_in_thread): + app = Flask(__name__) + + @app.route("/boom") + def boom(): + raise RuntimeError("bang") + + assert boom + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/boom", timeout=5) + + assert resp.status_code == 500 + + def test_streaming_without_stream_with_context(self, serve_in_thread): + """Key behavior test: generator uses flask.request without @stream_with_context.""" + app = Flask(__name__) + + @app.route("/stream") + def stream(): + def generate(): + # Would normally raise "Working outside of request context" + # under standard WSGI without @stream_with_context. + yield "ua=" + yield flask_request.headers.get("User-Agent", "?") + "\n" + + return Response(generate(), mimetype="text/plain") + + assert stream + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get( + f"http://127.0.0.1:{port}/stream", + headers={"User-Agent": "test-client"}, + timeout=5, + ) + + assert resp.status_code == 200 + assert resp.text == "ua=test-client\n" + + def test_streaming_with_stream_with_context_still_works(self, serve_in_thread): + """Backwards compat: @stream_with_context is a no-op but must not break.""" + app = Flask(__name__) + + @app.route("/stream-ctx") + def stream_ctx(): + def generate(): + yield "ua=" + yield flask_request.headers.get("User-Agent", "?") + "\n" + + return Response(stream_with_context(generate()), mimetype="text/plain") + + assert stream_ctx + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get( + f"http://127.0.0.1:{port}/stream-ctx", + headers={"User-Agent": "test-client"}, + timeout=5, + ) + + assert resp.status_code == 200 + assert resp.text == "ua=test-client\n" + + def test_teardown_runs_after_body_sent(self, serve_in_thread): + """teardown_request fires AFTER response iteration completes, not before. + + This is the opposite of standard WSGI Flask behavior. + """ + app = Flask(__name__) + events: list[str] = [] + events_lock = threading.Lock() + + def log(ev: str) -> None: + with events_lock: + events.append(ev) + + @app.teardown_request + def _teardown(exc): + log("teardown") + + @app.route("/ordering") + def ordering(): + log("view-start") + + def generate(): + log("chunk-1") + yield b"one" + log("chunk-2") + yield b"two" + log("chunk-end") + + return Response(generate(), mimetype="text/plain") + + assert ordering + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/ordering", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"onetwo" + + # Expected order: view-start → (chunks iterate) → teardown + with events_lock: + captured = list(events) + assert captured == ["view-start", "chunk-1", "chunk-2", "chunk-end", "teardown"] + + def test_response_headers_are_forwarded(self, serve_in_thread): + app = Flask(__name__) + + @app.route("/with-header") + def with_header(): + return Response("body", mimetype="text/plain", headers={"X-Custom": "yes"}) + + assert with_header + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/with-header", timeout=5) + + assert resp.status_code == 200 + assert resp.headers.get("x-custom") == "yes" + assert resp.headers.get("content-type", "").startswith("text/plain") diff --git a/tests/http/integration.py b/tests/http/integration.py new file mode 100644 index 0000000..42eaaa2 --- /dev/null +++ b/tests/http/integration.py @@ -0,0 +1,194 @@ +"""End-to-end integration tests for the HTTP flow. + +Boots the full ``run`` + ``http_server`` stack in a subprocess (see +``_integration_app.py``), fires HTTP requests, and verifies clean shutdown +via SIGTERM and SIGINT, on both anyio backends, and across the router / +WSGI / Flask handlers. +""" + +from __future__ import annotations + +import concurrent.futures +import os +import signal +import socket +import subprocess +import sys +import time +from collections.abc import Iterator + +import httpx +import pytest + +pytestmark = pytest.mark.integration + + +def _pick_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_ready(port: int, deadline: float = 10.0) -> bool: + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + +def _spawn(port: int, *, backend: str = "asyncio", mode: str = "router", **extra_env: str) -> subprocess.Popen: + env = { + **os.environ, + "LP_TEST_PORT": str(port), + "LP_TEST_BACKEND": backend, + "LP_TEST_MODE": mode, + **extra_env, + } + return subprocess.Popen( + [sys.executable, "-u", "-m", "tests.http._integration_app"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +@pytest.fixture +def app_process(request) -> Iterator[tuple[int, subprocess.Popen]]: + """Start the integration app; parameterize via indirect request.param dict.""" + params: dict[str, str] = getattr(request, "param", {}) or {} + port = _pick_free_port() + proc = _spawn(port, **params) + try: + if not _wait_ready(port): + stdout, stderr = b"", b"" + if proc.poll() is not None: + stdout, stderr = proc.communicate(timeout=1) + pytest.fail( + f"Server did not become ready on port {port} (params={params}).\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + yield port, proc + finally: + if proc.poll() is None: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +# --- Router-handler integration (asyncio + trio) ------------------------------ + + +@pytest.mark.parametrize("app_process", [{"backend": "asyncio"}, {"backend": "trio"}], indirect=True) +class TestRouterIntegration: + def test_simple_get(self, app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2) + assert r.status_code == 200 + assert r.text == "pong" + + def test_path_params(self, app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=2) + assert r.status_code == 200 + assert r.text == "hi world" + + def test_404(self, app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/nonexistent", timeout=2) + assert r.status_code == 404 + + def test_concurrent_requests_span_multiple_threads(self, app_process): + port, _ = app_process + n = 8 + with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex: + futures = [ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) for _ in range(n)] + responses = [f.result() for f in futures] + + assert all(r.status_code == 200 for r in responses) + thread_ids = {r.text for r in responses} + assert len(thread_ids) >= 2, f"expected multiple worker threads, saw: {thread_ids}" + + +# --- Shutdown signaling ------------------------------------------------------- + + +@pytest.mark.parametrize("sig", [signal.SIGTERM, signal.SIGINT]) +def test_clean_shutdown_via_signal(sig): + port = _pick_free_port() + proc = _spawn(port) + try: + assert _wait_ready(port), "server did not start" + assert httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2).status_code == 200 + + proc.send_signal(sig) + rc = proc.wait(timeout=5) + assert rc == 0, f"process exited with code {rc} on {sig.name}" + + # Port should be free now. + with pytest.raises(ConnectionRefusedError): + socket.create_connection(("127.0.0.1", port), timeout=1) + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +def test_inflight_request_during_shutdown_completes_cleanly(): + """SIGTERM while the slow handler is sleeping → service exits cleanly. + + The handler's ``time.sleep`` is not cancellation-aware, so shutdown waits + for it to finish. When the handler eventually writes its response and + tries to re-register the connection, the server (in shutting-down state) + closes the connection instead. Exit code must be 0. + """ + port = _pick_free_port() + proc = _spawn(port, LP_TEST_SLOW_S="1.0") + stderr = b"" + try: + assert _wait_ready(port), "server did not start" + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + future = ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) + time.sleep(0.2) # request is in flight + proc.send_signal(signal.SIGTERM) + try: + _, stderr = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + _, stderr = proc.communicate() + raise + rc = proc.returncode + + try: + future.result(timeout=5) + except (httpx.HTTPError, OSError): + pass + + assert rc == 0, f"unexpected non-zero exit; stderr={stderr.decode()!r}" + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +# --- WSGI + Flask end-to-end smoke tests -------------------------------------- + + +@pytest.mark.parametrize( + "app_process", + [{"mode": "wsgi"}, {"mode": "flask"}], + indirect=True, + ids=["wsgi", "flask"], +) +def test_alternate_handler_modes_serve(app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2) + assert r.status_code == 200 + # Both modes return non-empty bodies; exact text differs by mode. + assert r.text diff --git a/tests/http/parser_parity_props.py b/tests/http/parser_parity_props.py new file mode 100644 index 0000000..45cab1f --- /dev/null +++ b/tests/http/parser_parity_props.py @@ -0,0 +1,237 @@ +"""Property-based parity tests across the h11 and httptools parser backends. + +The README explicitly does NOT unify the two backends behind a Protocol — they +are separate implementations populating the same neutral ``Request`` shape. +Parity is the contract; these tests fuzz wire bytes through both backends and +assert each backend parses the same input into an identical ``Request``. + +Companion to ``tests/http/backend_parity.py``, which holds the example-based +parity coverage (lifecycle, framing, error paths). Here we exercise the +shape of the parsed request itself across a much broader input space. +""" + +from __future__ import annotations + +import socket +import threading +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from typing import Literal + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from localpost.http import ( + HTTPReqCtx, + Request, + RequestHandler, + Response, + ServerConfig, + start_http_server, +) +from tests.http._helpers import read_http_response + +# --- Strategies for HTTP/1.1 wire bytes ------------------------------------- + +# httptools rejects lowercase methods at parse time (server_httptools.py:249); +# h11 case-folds inline (server_h11.py:236-237). Keep methods uppercase so the +# property compares the post-normalisation shape rather than this divergence. +_method = st.sampled_from([b"GET", b"POST", b"PUT", b"DELETE", b"PATCH", b"HEAD"]) + +# Chars that obscure path/query splitting or trigger pct-decode questions +# neither parser promises to normalise. Both backends accept them, but we +# strip them from the path strategy to keep the property tractable. +_PATH_UNSAFE = "/{}?#&= %" + +_path_segment = st.text( + st.characters(min_codepoint=0x21, max_codepoint=0x7E, blacklist_characters=_PATH_UNSAFE), + min_size=1, + max_size=8, +).map(lambda s: s.encode("ascii")) + +_query_token = st.text( + st.characters(min_codepoint=0x21, max_codepoint=0x7E, blacklist_characters=_PATH_UNSAFE), + min_size=1, + max_size=8, +).map(lambda s: s.encode("ascii")) + +_header_name = st.from_regex(r"\A[a-zA-Z][a-zA-Z0-9\-]{0,15}\Z").map(lambda s: s.encode("ascii")) + +_header_value = st.text( + st.characters(min_codepoint=0x20, max_codepoint=0x7E, blacklist_characters=":"), + min_size=0, + max_size=20, +).map(lambda s: s.encode("ascii")) + +# Headers that affect framing or connection lifecycle are excluded — they have +# their own example-based parity coverage in ``backend_parity.py``. +_FRAMING_HEADERS = frozenset( + (b"host", b"content-length", b"transfer-encoding", b"connection", b"expect", b"upgrade", b"trailer", b"te"), +) + + +@st.composite +def _path(draw) -> bytes: + n = draw(st.integers(min_value=1, max_value=4)) + return b"/" + b"/".join(draw(_path_segment) for _ in range(n)) + + +@st.composite +def _query(draw) -> bytes: + n = draw(st.integers(min_value=0, max_value=3)) + if n == 0: + return b"" + pairs = [draw(_query_token) + b"=" + draw(_query_token) for _ in range(n)] + return b"?" + b"&".join(pairs) + + +@st.composite +def _extra_headers(draw) -> list[tuple[bytes, bytes]]: + n = draw(st.integers(min_value=0, max_value=4)) + out: list[tuple[bytes, bytes]] = [] + seen: set[bytes] = set() + for _ in range(n): + name = draw(_header_name) + key = name.lower() + if key in _FRAMING_HEADERS or key in seen: + continue + seen.add(key) + out.append((name, draw(_header_value))) + return out + + +@st.composite +def _request_wire(draw) -> tuple[bytes, dict]: + """Build (wire_bytes, expected_shape) for a fuzzed HTTP/1.1 request. + + The expected shape is the normalised ``Request`` projection both backends + must produce — methods uppercase, header names lowercase, path/query + pre-split on the first ``?``. + """ + method = draw(_method) + path = draw(_path()) + query = draw(_query()) + extras = draw(_extra_headers()) + + target = path + query + headers = [(b"Host", b"example.com"), *extras] + request_line = method + b" " + target + b" HTTP/1.1\r\n" + header_block = b"".join(name + b": " + value + b"\r\n" for name, value in headers) + wire = request_line + header_block + b"\r\n" + + # Per RFC 7230 § 3.2.4, OWS (SP / HTAB) surrounding field-value is stripped + # by both backends; mirror that normalisation in the expected shape. + expected = { + "method": method, + "target": target, + "path": path, + "query_string": query[1:] if query else b"", + "headers": [(name.lower(), value.strip(b" \t")) for name, value in headers], + "http_version": b"1.1", + } + return wire, expected + + +# --- Server scaffolding ----------------------------------------------------- + + +@contextmanager +def _serve(handler: RequestHandler, backend: Literal["h11", "httptools"]) -> Generator[int]: + """Start an HTTP server on ``backend`` in a background thread; yield its port.""" + cfg = ServerConfig(host="127.0.0.1", port=0, backend=backend) + cm = start_http_server(cfg, handler) + server = cm.__enter__() + stop = threading.Event() + + def loop() -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return + + t = threading.Thread(target=loop, daemon=True) + t.start() + try: + yield server.port + finally: + stop.set() + t.join(timeout=5) + cm.__exit__(None, None, None) + + +@pytest.fixture +def parity_servers() -> Iterator[tuple[int, int, list[Request], list[Request]]]: + """Run h11 + httptools servers in parallel; both capture their parsed requests.""" + pytest.importorskip("httptools") + + h11_captured: list[Request] = [] + ht_captured: list[Request] = [] + + def make_handler(captured: list[Request]) -> RequestHandler: + def handler(ctx: HTTPReqCtx): + captured.append(ctx.request) + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + return handler + + with ( + _serve(make_handler(h11_captured), "h11") as h11_port, + _serve(make_handler(ht_captured), "httptools") as ht_port, + ): + yield h11_port, ht_port, h11_captured, ht_captured + + +def _send_one(port: int, wire: bytes) -> bytes: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(wire) + return read_http_response(sock) + + +def _shape(req: Request) -> dict: + return { + "method": req.method, + "target": req.target, + "path": req.path, + "query_string": req.query_string, + "headers": list(req.headers), + "http_version": req.http_version, + } + + +# --- Properties ------------------------------------------------------------- + + +class TestParserParity: + @given(payload=_request_wire()) + @settings( + max_examples=80, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], + ) + def test_request_shape_parity(self, parity_servers, payload): + """Both backends parse the same wire bytes into equal ``Request`` shapes.""" + h11_port, ht_port, h11_captured, ht_captured = parity_servers + h11_captured.clear() + ht_captured.clear() + + wire, expected = payload + h11_resp = _send_one(h11_port, wire) + ht_resp = _send_one(ht_port, wire) + + # Both backends must respond — confirms the wire was accepted. + assert b"HTTP/1.1 200" in h11_resp, h11_resp + assert b"HTTP/1.1 200" in ht_resp, ht_resp + + assert len(h11_captured) == 1, h11_captured + assert len(ht_captured) == 1, ht_captured + + h11_shape = _shape(h11_captured[0]) + ht_shape = _shape(ht_captured[0]) + + # Cross-backend parity. + assert h11_shape == ht_shape + # And both match the spec we built the wire from — locks intent, not + # just agreement-on-a-bug. + assert h11_shape == expected diff --git a/tests/http/router.py b/tests/http/router.py new file mode 100644 index 0000000..2f4744f --- /dev/null +++ b/tests/http/router.py @@ -0,0 +1,285 @@ +"""Tests for localpost.http.router — the lean dispatcher. + +The Router attaches a :class:`RouteMatch` to ``ctx.attrs[RouteMatch]`` +and delegates to the registered :class:`localpost.http.RequestHandler`. +404 / 405 are answered inline. Pythonic helpers (response converters, +param injection, etc.) live in ``HttpApp``; tests for those live elsewhere. +""" + +from __future__ import annotations + +from http import HTTPMethod +from unittest.mock import Mock + +import httpx +import pytest + +from localpost.http import ( + HTTPReqCtx, + Response, + RouteMatch, + Routes, + URITemplate, + route_match, +) + + +def _ok_handler(body: bytes = b"ok"): + """Build a minimal HTTPReqCtx-shape handler that emits 200 + ``body``.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + + return handler + + +# --- URITemplate --------------------------------------------------------- + + +class TestURITemplate: + def test_literal_match(self): + t = URITemplate.parse("/foo/bar") + assert t.match("/foo/bar") == {} + assert t.match("/foo/baz") is None + + def test_single_var_match(self): + t = URITemplate.parse("/books/{id}") + assert t.match("/books/42") == {"id": "42"} + assert t.match("/books") is None + assert t.match("/books/42/chapters") is None # variable doesn't span slashes + + def test_multiple_vars_match(self): + t = URITemplate.parse("/users/{uid}/posts/{pid}") + assert t.match("/users/alice/posts/17") == {"uid": "alice", "pid": "17"} + assert t.match("/users/alice") is None + + def test_variable_names_preserved(self): + t = URITemplate.parse("/a/{x}/b/{y}") + assert t.variable_names == ("x", "y") + + def test_non_matching_returns_none(self): + t = URITemplate.parse("/hello/{name}") + assert t.match("/goodbye/alice") is None + + +# --- Router.as_handler --------------------------------------------------- + + +class TestRouterDispatch: + def test_dispatch_200(self, serve_in_thread): + routes = Routes() + routes.get("/ping")(_ok_handler(b"pong")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/ping", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "pong" + + def test_404(self, serve_in_thread): + router = Routes().build() + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/does-not-exist", timeout=5) + assert resp.status_code == 404 + assert resp.text == "Not Found" + + def test_405_with_allow_header(self, serve_in_thread): + routes = Routes() + routes.post("/resource")(_ok_handler(b"x")) + router = routes.build() + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/resource", timeout=5) + assert resp.status_code == 405 + assert resp.headers.get("allow") == "POST" + + +class TestRouteMatchAttached: + def test_match_in_attrs(self, serve_in_thread): + captured: dict = {} + + def handler(ctx: HTTPReqCtx) -> None: + m = route_match(ctx) + assert isinstance(m, RouteMatch) + captured["method"] = m.method + captured["template"] = m.matched_template.template + captured["path_args"] = dict(m.path_args) + ctx.complete( + Response( + status_code=200, + headers=[(b"content-length", b"2")], + ), + b"ok", + ) + + routes = Routes() + routes.get("/books/{book_id}")(handler) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + httpx.get(f"http://127.0.0.1:{port}/books/xyz-123", timeout=5) + + assert captured["method"] == HTTPMethod.GET + assert captured["template"] == "/books/{book_id}" + assert captured["path_args"] == {"book_id": "xyz-123"} + + def test_route_match_outside_router_raises(self): + # ``route_match`` reads ``ctx.attrs[RouteMatch]``; outside a router + # the key is missing and we get the natural KeyError. Documented. + ctx = Mock() + ctx.attrs = {} + with pytest.raises(KeyError): + route_match(ctx) + + +class TestRoutesRegistration: + def test_explicit_add(self): + routes = Routes() + routes.add("GET", "/thing", _ok_handler()) + assert len(routes.paths) == 1 + + def test_same_template_multiple_methods_stored_once(self): + routes = Routes() + routes.get("/r")(_ok_handler(b"g")) + routes.post("/r")(_ok_handler(b"p")) + assert len(routes.paths) == 1 + methods = next(iter(routes.paths.values())) + assert set(methods) == {HTTPMethod.GET, HTTPMethod.POST} + + def test_decorator_returns_original_handler(self): + routes = Routes() + h = _ok_handler() + assert routes.get("/x")(h) is h + + def test_method_string_normalized(self): + routes = Routes() + routes.add("get", "/a", _ok_handler()) + routes.add("PoSt", "/a", _ok_handler()) + methods = next(iter(routes.paths.values())) + assert set(methods) == {HTTPMethod.GET, HTTPMethod.POST} + + +class TestRouterBuild: + def test_empty_routes_produce_empty_router(self, serve_in_thread): + """An empty Routes still builds a valid (never-matching) Router.""" + router = Routes().build() + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + assert resp.status_code == 404 + + def test_longest_literal_prefix_first(self): + """More specific routes (longer literal prefix before the first var) + match before less specific ones.""" + routes = Routes() + routes.get("/{anything}")(_ok_handler(b"any")) + routes.get("/specific")(_ok_handler(b"specific")) + router = routes.build() + # The first route in dispatch order should be ``/specific`` (longer literal prefix). + assert router.routes[0].template.template == "/specific" + assert router.routes[1].template.template == "/{anything}" + + def test_dispatch_order_specific_before_general(self, serve_in_thread): + routes = Routes() + routes.get("/{anything}")(_ok_handler(b"any")) + routes.get("/specific")(_ok_handler(b"specific")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + r_specific = httpx.get(f"http://127.0.0.1:{port}/specific", timeout=5) + r_other = httpx.get(f"http://127.0.0.1:{port}/foo", timeout=5) + + assert r_specific.text == "specific" + assert r_other.text == "any" + + +class TestMethodDispatch: + def test_get_post_separate_handlers(self, serve_in_thread): + routes = Routes() + routes.get("/item")(_ok_handler(b"got")) + routes.post("/item")(_ok_handler(b"posted")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + r_get = httpx.get(f"http://127.0.0.1:{port}/item", timeout=5) + r_post = httpx.post(f"http://127.0.0.1:{port}/item", timeout=5) + + assert r_get.text == "got" + assert r_post.text == "posted" + + def test_unknown_method_returns_405(self, serve_in_thread): + routes = Routes() + routes.get("/r")(_ok_handler()) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.request("PATCH", f"http://127.0.0.1:{port}/r", timeout=5) + assert resp.status_code == 405 + + def test_overlapping_templates_scan_for_matching_method(self, serve_in_thread): + routes = Routes() + routes.get("/users/{id}")(_ok_handler(b"get")) + routes.post("/users/{name}")(_ok_handler(b"post")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.post(f"http://127.0.0.1:{port}/users/42", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "post" + + def test_overlapping_templates_no_matching_method_returns_405(self, serve_in_thread): + routes = Routes() + routes.get("/users/{id}")(_ok_handler(b"get")) + routes.post("/users/{name}")(_ok_handler(b"post")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.patch(f"http://127.0.0.1:{port}/users/42", timeout=5) + + assert resp.status_code == 405 + + def test_overlapping_templates_405_allow_header_is_sorted_union(self, serve_in_thread): + routes = Routes() + routes.post("/users/{name}")(_ok_handler(b"post")) + routes.delete("/users/{id}")(_ok_handler(b"delete")) + routes.get("/users/{slug}")(_ok_handler(b"get")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.patch(f"http://127.0.0.1:{port}/users/42", timeout=5) + + assert resp.status_code == 405 + assert resp.headers.get("allow") == "DELETE, GET, POST" + + +class TestPathVariableEncoding: + def test_url_encoded_path_arg_passed_through(self, serve_in_thread): + captured: dict = {} + + def handler(ctx: HTTPReqCtx) -> None: + m = route_match(ctx) + captured["name"] = m.path_args["name"] + ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") + + routes = Routes() + routes.get("/u/{name}")(handler) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + # Two requests: simple value, and url-encoded value. + httpx.get(f"http://127.0.0.1:{port}/u/alice", timeout=5) + assert captured["name"] == "alice" + + httpx.get(f"http://127.0.0.1:{port}/u/al%20ice", timeout=5) + # Router doesn't decode — the handler sees the raw URL-encoded value. + # That's the lean-dispatcher contract; the framework layer can decode. + assert captured["name"] == "al%20ice" diff --git a/tests/http/router_props.py b/tests/http/router_props.py new file mode 100644 index 0000000..6f4a584 --- /dev/null +++ b/tests/http/router_props.py @@ -0,0 +1,242 @@ +"""Property-based tests for ``Router`` dispatch behavior. + +Mirrors the spec in :func:`localpost.http.router.Router._match` and locks in: + +- 200 / 404 / 405 outcome predicate — see ``router.py:241-287`` +- longest-literal-prefix sort — see ``router.py:209-213`` +- 405 ``Allow`` header is the sorted union of matching templates' methods + — see ``router.py:281-285`` + +Companion to ``tests/http/router.py``, which holds example-based coverage +exercised through the full HTTP wire. Here we drive the dispatcher +in-process (no socket, no httpx) so the property suite stays fast. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from http import HTTPMethod +from typing import Any, cast + +from hypothesis import assume, given, settings +from hypothesis import strategies as st + +from localpost.http import ( + HTTPReqCtx, + Request, + Response, + RouteMatch, + Router, + Routes, + URITemplate, +) +from tests.http.uri_template_props import _literal_segment, _template_with_values + +# --- Strategies ------------------------------------------------------------- + +_method = st.sampled_from(list(HTTPMethod)) + + +@st.composite +def _route_set(draw) -> list[tuple[str, frozenset[HTTPMethod], str]]: + """Draw a list of ``(template, methods, concrete_uri)`` triples. + + Templates are unique by string. ``concrete_uri`` is one valid URI built from + the template — used to drive the matching path through the router. + """ + n = draw(st.integers(min_value=1, max_value=5)) + triples: list[tuple[str, frozenset[HTTPMethod], str]] = [] + seen: set[str] = set() + for _ in range(n): + template, _values, concrete = draw(_template_with_values()) + if template in seen: + continue + seen.add(template) + methods = frozenset(draw(st.sets(_method, min_size=1, max_size=5))) + triples.append((template, methods, concrete)) + return triples + + +@st.composite +def _scenario(draw) -> tuple[list[tuple[str, frozenset[HTTPMethod], str]], HTTPMethod, str]: + """Draw ``(routes, method, path)``. + + Half the time ``path`` is a concrete URI from one of the routes (likely 200 + or 405); the other half it is a random path (typically 404, occasionally + matches a fully-variable template). + """ + routes = draw(_route_set()) + if draw(st.booleans()): + _tmpl, _methods, concrete = draw(st.sampled_from(routes)) + path = concrete + else: + n = draw(st.integers(min_value=1, max_value=4)) + path = "/" + "/".join(draw(_literal_segment) for _ in range(n)) + method = draw(_method) + return routes, method, path + + +# --- Synthetic dispatch ----------------------------------------------------- + + +@dataclass(eq=False, slots=True) +class _FakeCtx: + """Minimal :class:`HTTPReqCtx` stand-in for in-process router dispatch. + + The router's ``as_handler()`` only touches ``ctx.request``, ``ctx.attrs``, + and ``ctx.complete(...)``. We capture ``complete`` calls so the test can + inspect the response that would have been sent. + """ + + request: Request + body: bytes = b"" + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + completed_response: Response | None = None + completed_body: bytes = b"" + + def complete(self, response: Response, body: bytes | None = None) -> None: + self.response_status = response.status_code + self.completed_response = response + self.completed_body = body or b"" + + +@dataclass(frozen=True, slots=True) +class _Outcome: + status: int + route_match: RouteMatch | None + response: Response | None + + +def _route_handler(ctx: HTTPReqCtx) -> None: + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + +def _build_router(routes_list: list[tuple[str, frozenset[HTTPMethod], str]]) -> Router: + routes = Routes() + for template, methods, _concrete in routes_list: + for m in methods: + routes.add(m, template, _route_handler) + return routes.build() + + +def _dispatch(router: Router, method: HTTPMethod, path: str) -> _Outcome: + request = Request( + method=method.value.encode("ascii"), + target=path.encode("iso-8859-1"), + path=path.encode("iso-8859-1"), + query_string=b"", + headers=[], + ) + ctx = _FakeCtx(request=request) + handler = router.as_handler() + handler(cast(HTTPReqCtx, ctx)) + return _Outcome( + status=ctx.response_status or 0, + route_match=ctx.attrs.get(RouteMatch), + response=ctx.completed_response, + ) + + +# --- Helpers mirroring the source-of-truth (router.py) ---------------------- + +_VAR_PATTERN = re.compile(r"\{([^}]+)\}") + + +def _literal_prefix_len(template: str) -> int: + """Mirror of :func:`localpost.http.router._literal_prefix_len`.""" + m = _VAR_PATTERN.search(template) + return len(template) if m is None else m.start() + + +def _matches(template: str, path: str) -> bool: + return URITemplate.parse(template).match(path) is not None + + +def _allow_header(response: Response) -> str | None: + for name, value in response.headers: + if name.lower() == b"allow": + return value.decode("ascii") + return None + + +# --- Properties ------------------------------------------------------------- + + +class TestRouterDispatchProperties: + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_dispatch_outcome_matches_spec(self, scenario): + """200/404/405 outcome must match the spec at router.py:241-287.""" + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + + matching = [(t, m) for t, m, _ in routes if _matches(t, path)] + any_method_matches = any(method in methods for _, methods in matching) + + if not matching: + assert outcome.status == 404 + elif any_method_matches: + assert outcome.status == 200 + else: + assert outcome.status == 405 + + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_chosen_template_has_max_literal_prefix(self, scenario): + """On a 200, the chosen template has maximal literal prefix among method-supporting matchers. + + Mirrors the sort at router.py:209-213. Stable-sort means ties resolve + to insertion order; we assert ``==`` on the prefix length, which is + the only spec-level property without depending on insertion order. + """ + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + + if outcome.status != 200: + assume(False) + + assert outcome.route_match is not None + chosen = outcome.route_match.matched_template.template + method_supporting = [t for t, m, _ in routes if _matches(t, path) and method in m] + assert chosen in method_supporting + assert _literal_prefix_len(chosen) == max(_literal_prefix_len(t) for t in method_supporting) + + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_405_allow_header_is_sorted_method_union(self, scenario): + """On a 405, the Allow header is the sorted union of all matching templates' methods. + + Mirrors router.py:281-285 (single-route 405 uses the route's pre-built + Allow; multi-route 405 unions methods on the fly). + """ + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + + if outcome.status != 405: + assume(False) + + assert outcome.response is not None + allow = _allow_header(outcome.response) + assert allow is not None + + union: set[HTTPMethod] = set() + for t, m, _ in routes: + if _matches(t, path): + union |= m + expected = ", ".join(hm.value for hm in sorted(union, key=lambda hm: hm.value)) + assert allow == expected + + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_404_iff_no_template_matches(self, scenario): + """404 ⇔ no template's regex matches the path. Bidirectional.""" + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + any_match = any(_matches(t, path) for t, _, _ in routes) + assert (outcome.status == 404) == (not any_match) diff --git a/tests/http/router_sentry_handler.py b/tests/http/router_sentry_handler.py new file mode 100644 index 0000000..6c8847e --- /dev/null +++ b/tests/http/router_sentry_handler.py @@ -0,0 +1,102 @@ +"""Tests for localpost.http.router_sentry.""" + +from __future__ import annotations + +import httpx +import pytest +import sentry_sdk + +from localpost.http import HTTPReqCtx, Response, Routes, route_match +from localpost.http.router_sentry import sentry_router_handler + +from ._sentry_helpers import CapturingTransport, init_sentry, transactions + + +@pytest.fixture +def sentry_transport(): + transport = CapturingTransport() + init_sentry(transport) + yield transport + sentry_sdk.flush(timeout=2.0) + + +def _build_router(): + routes = Routes() + + @routes.get("/books/{id}") + def get_book(ctx: HTTPReqCtx) -> None: + body = f"book={route_match(ctx).path_args['id']}".encode() + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + + @routes.post("/books") + def create_book(ctx: HTTPReqCtx) -> None: + ctx.complete(Response(status_code=201, headers=[(b"content-length", b"0")]), b"") + + assert get_book is not None + assert create_book is not None + return routes.build() + + +class TestSentryRouterHandler: + def test_matched_route_uses_template_name(self, serve_in_thread, sentry_transport): + router = _build_router() + + with serve_in_thread(sentry_router_handler(router)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/42", timeout=5) + assert resp.status_code == 200 + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /books/{id}" + assert tx["transaction_info"]["source"] == "route" + # Op lives under contexts.trace.op + assert tx["contexts"]["trace"]["op"] == "http.server" + + def test_unmatched_uses_url_source(self, serve_in_thread, sentry_transport): + router = _build_router() + + with serve_in_thread(sentry_router_handler(router)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/does-not-exist", timeout=5) + assert resp.status_code == 404 + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /does-not-exist" + assert tx["transaction_info"]["source"] == "url" + + def test_status_code_is_recorded(self, serve_in_thread, sentry_transport): + router = _build_router() + + with serve_in_thread(sentry_router_handler(router)) as port: + httpx.post(f"http://127.0.0.1:{port}/books", timeout=5) + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + # Sentry stores http status under contexts.trace.data["http.response.status_code"] + # (older SDKs put it on tags). Check both. + trace_data = tx["contexts"]["trace"].get("data", {}) + status = trace_data.get("http.response.status_code") or tx.get("tags", {}).get("http.status_code") + assert status in (201, "201") + + def test_method_tag_recorded(self, serve_in_thread, sentry_transport): + router = _build_router() + + with serve_in_thread(sentry_router_handler(router)) as port: + httpx.get(f"http://127.0.0.1:{port}/books/1", timeout=5) + sentry_sdk.flush(timeout=2.0) + + txs = transactions(sentry_transport) + assert len(txs) == 1 + assert txs[0]["tags"].get("http.method") == "GET" diff --git a/tests/http/rsgi.py b/tests/http/rsgi.py new file mode 100644 index 0000000..014920b --- /dev/null +++ b/tests/http/rsgi.py @@ -0,0 +1,365 @@ +"""Tests for ``localpost.http.rsgi`` — :func:`to_rsgi` adapter and the +underlying :class:`_RSGIReqCtx` implementation. + +Drives the bridge against a mocked RSGI scope + proto pair (the proto +surface is small enough to fake reliably). End-to-end tests under a +real Granian via ``granian.server.embed`` live in +``tests/openapi/aio_rsgi_integration.py``. + +Symmetric with ``tests/http/asgi.py`` for the ASGI side. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterable +from dataclasses import dataclass, field +from typing import Any + +import anyio +import pytest + +from localpost.http import AsyncHTTPReqCtx, Response, aread_body, to_rsgi +from localpost.http.rsgi import _RSGIReqCtx, addrs_from_scope, build_request_from_scope + +# --- Mocks -------------------------------------------------------------- + + +class _FakeHeaders: + """Stand-in for ``granian.rsgi.RSGIHeaders``. Iterates ``items()`` + as ``(name, value)`` string pairs — that's the only surface our + bridge touches.""" + + def __init__(self, pairs: Iterable[tuple[str, str]]) -> None: + self._pairs = list(pairs) + + def items(self) -> list[tuple[str, str]]: + return list(self._pairs) + + +@dataclass(slots=True, eq=False) +class _FakeScope: + """Stand-in for ``granian.rsgi.Scope``. RSGI scope is just a struct + of strings; the bridge reads only what it needs.""" + + method: str = "GET" + path: str = "/" + query_string: str = "" + http_version: str = "1.1" + scheme: str = "http" + client: str = "127.0.0.1:54321" + server: str = "127.0.0.1:8000" + headers: _FakeHeaders = field(default_factory=lambda: _FakeHeaders([])) + + +@dataclass(slots=True, eq=False) +class _FakeStreamTransport: + """Stand-in for ``granian._granian.RSGIHTTPStreamTransport``.""" + + sent: list[bytes] = field(default_factory=list) + + async def send_bytes(self, data: bytes) -> None: + self.sent.append(bytes(data)) + + async def send_str(self, data: str) -> None: + self.sent.append(data.encode("utf-8")) + + +@dataclass(slots=True, eq=False) +class _FakeProto: + """Stand-in for ``granian.rsgi.HTTPProtocol``. + + Captures the response side via the ``response_*`` calls; serves the + request body via ``__call__`` (buffered) or ``__aiter__`` (streamed). + Exposes a ``disconnect_event`` that ``client_disconnect()`` awaits — + set it to simulate peer-gone. + """ + + body_chunks: list[bytes] = field(default_factory=list) + disconnect_event: anyio.Event = field(default_factory=anyio.Event) + response_kind: str | None = None + response_status: int | None = None + response_headers: list[tuple[str, str]] = field(default_factory=list) + response_body: bytes | str | None = None + response_file: tuple[str, int, int] | None = None + transport: _FakeStreamTransport | None = None + + async def __call__(self) -> bytes: + return b"".join(self.body_chunks) + + def __aiter__(self) -> AsyncIterator[bytes]: + async def gen() -> AsyncIterator[bytes]: + for c in self.body_chunks: + yield c + + return gen() + + async def client_disconnect(self) -> None: + await self.disconnect_event.wait() + + def response_empty(self, status: int, headers: list[tuple[str, str]]) -> None: + self.response_kind = "empty" + self.response_status = status + self.response_headers = list(headers) + + def response_bytes(self, status: int, headers: list[tuple[str, str]], body: bytes) -> None: + self.response_kind = "bytes" + self.response_status = status + self.response_headers = list(headers) + self.response_body = body + + def response_str(self, status: int, headers: list[tuple[str, str]], body: str) -> None: + self.response_kind = "str" + self.response_status = status + self.response_headers = list(headers) + self.response_body = body + + def response_file_range( + self, + status: int, + headers: list[tuple[str, str]], + file: str, + start: int, + end: int, + ) -> None: + self.response_kind = "file_range" + self.response_status = status + self.response_headers = list(headers) + self.response_file = (file, start, end) + + def response_stream(self, status: int, headers: list[tuple[str, str]]) -> _FakeStreamTransport: + self.response_kind = "stream" + self.response_status = status + self.response_headers = list(headers) + self.transport = _FakeStreamTransport() + return self.transport + + +# --- Helpers ------------------------------------------------------------ + + +def _build_ctx(*, body: bytes = b"", proto: _FakeProto | None = None) -> _RSGIReqCtx: + """Minimal _RSGIReqCtx for ctx-only checks. + + Pre-fills the body channel with ``body`` and closes it — the ctx + behaves as if the bridge had read exactly those bytes from upstream + and observed EOM. + """ + from localpost.http._types import Request + + request = Request(b"GET", b"/", b"/", b"", []) + + body_send, body_recv = anyio.create_memory_object_stream[bytes](max_buffer_size=64) + if body: + body_send.send_nowait(body) + body_send.close() + + return _RSGIReqCtx( + request=request, + remote_addr=None, + local_addr="127.0.0.1:8000", + scheme="http", + _proto=proto or _FakeProto(), + _disconnected=anyio.Event(), + _body_stream=body_recv, + ) + + +# --- Protocol conformance ----------------------------------------------- + + +def test_rsgi_ctx_satisfies_async_protocol() -> None: + """Concrete ``_RSGIReqCtx`` should satisfy ``AsyncHTTPReqCtx`` via + structural typing — guard against drift.""" + ctx = _build_ctx() + assert isinstance(ctx, AsyncHTTPReqCtx) + + +# --- Scope translation -------------------------------------------------- + + +class TestScopeTranslation: + def test_request_built_from_scope(self) -> None: + scope = _FakeScope( + method="POST", + path="/items/42", + query_string="q=1", + headers=_FakeHeaders([("content-type", "application/json")]), + ) + req = build_request_from_scope(scope) + assert req.method == b"POST" + assert req.path == b"/items/42" + assert req.query_string == b"q=1" + assert req.target == b"/items/42?q=1" + assert (b"content-type", b"application/json") in req.headers + + def test_addrs_pass_through(self) -> None: + scope = _FakeScope(client="1.2.3.4:5555", server="10.0.0.1:8000") + remote, local = addrs_from_scope(scope) + assert remote == "1.2.3.4:5555" + assert local == "10.0.0.1:8000" + + +# --- Ctx behaviour ------------------------------------------------------ + + +class TestCtxComplete: + @pytest.mark.anyio + async def test_complete_with_body_uses_response_bytes(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + await ctx.complete(Response(status_code=200, headers=[(b"x-y", b"z")]), b"hello") + assert proto.response_kind == "bytes" + assert proto.response_status == 200 + assert ("x-y", "z") in proto.response_headers + assert proto.response_body == b"hello" + + @pytest.mark.anyio + async def test_complete_empty_uses_response_empty(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + await ctx.complete(Response(status_code=204)) + assert proto.response_kind == "empty" + assert proto.response_status == 204 + + @pytest.mark.anyio + async def test_complete_twice_raises(self) -> None: + ctx = _build_ctx() + await ctx.complete(Response(200), b"x") + with pytest.raises(RuntimeError, match="already started"): + await ctx.complete(Response(200), b"y") + + +class TestCtxStream: + @pytest.mark.anyio + async def test_stream_drains_chunks(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + yield b"b" + + await ctx.stream(Response(200), chunks()) + assert proto.response_kind == "stream" + assert proto.transport is not None + assert proto.transport.sent == [b"a", b"b"] + + @pytest.mark.anyio + async def test_stream_short_circuits_on_disconnect(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + ctx._disconnected.set() + yield b"b" # should not reach the wire + + await ctx.stream(Response(200), chunks()) + assert proto.transport is not None + assert proto.transport.sent == [b"a"] + + +class TestCtxSendfile: + @pytest.mark.anyio + async def test_sendfile_uses_response_file_range_when_path_present(self, tmp_path) -> None: # type: ignore[no-untyped-def] + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + f = tmp_path / "x.bin" + f.write_bytes(b"abcdef") + with f.open("rb") as fh: + await ctx.sendfile(Response(200), fh, offset=1, count=3) + assert proto.response_kind == "file_range" + # Path passed through as-is; range expressed as (start, end). + assert proto.response_file == (str(f), 1, 4) + + @pytest.mark.anyio + async def test_sendfile_falls_back_to_stream_when_no_path(self) -> None: + import io + + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + # BytesIO has no ``name`` attribute → fallback path. + stream = io.BytesIO(b"abcdef") + await ctx.sendfile(Response(200), stream, offset=1, count=3) + assert proto.response_kind == "stream" + assert proto.transport is not None + assert b"".join(proto.transport.sent) == b"bcd" + + +class TestCtxReceive: + """``ctx.receive(size)`` pulls from the in-process body queue, + splitting upstream chunks down to the caller's requested ``size``. + """ + + @pytest.mark.anyio + async def test_receive_splits_chunk_across_calls(self) -> None: + ctx = _build_ctx(body=b"abcdef") + assert await ctx.receive(2) == b"ab" + assert await ctx.receive(3) == b"cde" + assert await ctx.receive(10) == b"f" + assert await ctx.receive(10) == b"" + + +# --- to_rsgi end-to-end (mocked proto) --------------------------------- + + +async def _drive(rsgi_app: Any, scope: _FakeScope, proto: _FakeProto) -> None: + """Invoke the RSGI app's __rsgi__ once.""" + await rsgi_app.__rsgi__(scope, proto) + + +class TestToRsgi: + @pytest.mark.anyio + async def test_simple_round_trip(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"hi") + + rsgi_app = to_rsgi(handler) + proto = _FakeProto() + await _drive(rsgi_app, _FakeScope(), proto) + assert proto.response_status == 200 + assert proto.response_body == b"hi" + + @pytest.mark.anyio + async def test_body_read_via_aread_body(self) -> None: + captured: dict[str, bytes] = {} + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + captured["body"] = await aread_body(ctx) + await ctx.complete(Response(200), b"ok") + + rsgi_app = to_rsgi(handler) + proto = _FakeProto(body_chunks=[b"hello", b"-world"]) + await _drive(rsgi_app, _FakeScope(method="POST"), proto) + assert captured["body"] == b"hello-world" + + @pytest.mark.anyio + async def test_chunks_arrive_in_order(self) -> None: + captured: list[bytes] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + while True: + chunk = await ctx.receive(64) + if not chunk: + break + captured.append(chunk) + await ctx.complete(Response(200), b"ok") + + rsgi_app = to_rsgi(handler) + proto = _FakeProto(body_chunks=[b"first", b"second", b"third"]) + await _drive(rsgi_app, _FakeScope(method="POST"), proto) + assert b"".join(captured) == b"firstsecondthird" + + @pytest.mark.anyio + async def test_content_length_pre_check_413(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + raise AssertionError("handler should not run") + + rsgi_app = to_rsgi(handler, max_body_size=4) + proto = _FakeProto() + scope = _FakeScope( + method="POST", + headers=_FakeHeaders([("content-length", "100")]), + ) + await _drive(rsgi_app, scope, proto) + assert proto.response_status == 413 diff --git a/tests/http/server.py b/tests/http/server.py new file mode 100644 index 0000000..f12a21b --- /dev/null +++ b/tests/http/server.py @@ -0,0 +1,638 @@ +"""Tests for the HTTP server (localpost.http).""" + +from __future__ import annotations + +import contextlib +import socket +import threading + +import httpx +import pytest + +from localpost.http import HTTPReqCtx, Response, ServerConfig, start_http_server +from tests.http._helpers import drain_socket + +# --- Listening-socket lifecycle (no requests) --------------------------------- + + +def _noop_handler(ctx: HTTPReqCtx) -> None: + pass + + +class TestStartHttpServer: + def test_creates_server_with_auto_port(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: + assert server.port > 0 + assert server.port != 8000 # auto-assigned, should differ from the default + + def test_server_socket_is_listening(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: + with socket.create_connection(("127.0.0.1", server.port), timeout=2): + pass + + def test_server_socket_closed_after_context(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: + port = server.port + with pytest.raises(ConnectionRefusedError): + socket.create_connection(("127.0.0.1", port), timeout=1) + + def test_handler_stored_on_server(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: + # ConnHandler owns the RequestHandler in the new chain layout + # (Selector → ConnHandler → RequestHandler → BodyHandler). + assert server.conn_handler.handler is _noop_handler # type: ignore[attr-defined] + + +# --- Request / response basics ----------------------------------------------- + + +class TestBasicRequestResponse: + def test_simple_200(self, serve_in_thread): + def handler(ctx: HTTPReqCtx): + ctx.complete( + Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), + b"OK", + ) + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "OK" + + def test_404_response(self, serve_in_thread): + def handler(ctx: HTTPReqCtx): + ctx.complete( + Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]), + b"Not Found", + ) + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 404 + assert resp.text == "Not Found" + + def test_empty_body(self, serve_in_thread): + def handler(ctx: HTTPReqCtx): + ctx.complete(Response(status_code=204, headers=[])) + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 204 + assert resp.content == b"" + + +class TestRequestRouting: + def test_handler_sees_method_and_target(self, serve_in_thread): + captured = {} + + def handler(ctx: HTTPReqCtx): + captured["method"] = ctx.request.method + captured["target"] = ctx.request.target + ctx.complete(Response(status_code=200, headers=[]), b"") + + with serve_in_thread(handler) as port: + httpx.post(f"http://127.0.0.1:{port}/api/items?q=1", timeout=5) + + assert captured["method"] == b"POST" + assert captured["target"] == b"/api/items?q=1" + + def test_handler_sees_headers(self, serve_in_thread): + captured_headers: dict[bytes, bytes] = {} + + def handler(ctx: HTTPReqCtx): + captured_headers.update(ctx.request.headers) + ctx.complete(Response(status_code=200, headers=[]), b"") + + with serve_in_thread(handler) as port: + httpx.get(f"http://127.0.0.1:{port}/", headers={"X-Custom": "hello"}, timeout=5) + + assert captured_headers[b"x-custom"] == b"hello" + + +class TestRequestBody: + def test_receive_post_body(self, serve_in_thread): + received_body = bytearray() + + def handler(ctx: HTTPReqCtx): + while True: + chunk = ctx.receive() + if not chunk: + break + received_body.extend(chunk) + ctx.complete(Response(status_code=200, headers=[]), b"ok") + + with serve_in_thread(handler) as port: + httpx.post(f"http://127.0.0.1:{port}/", content=b"hello world body", timeout=5) + + assert bytes(received_body) == b"hello world body" + + +class TestChunkedResponse: + def test_streaming_response(self, serve_in_thread): + def handler(ctx: HTTPReqCtx): + ctx.stream( + Response( + status_code=200, + headers=[(b"Transfer-Encoding", b"chunked")], + ), + iter([b"chunk1", b"chunk2"]), + ) + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"chunk1chunk2" + + +class TestBorrow: + def test_borrow_and_return(self, serve_in_thread): + borrow_states = [] + + def handler(ctx: HTTPReqCtx): + borrow_states.append(ctx.borrowed) # False — still tracked + with ctx.borrow(): + borrow_states.append(ctx.borrowed) # True — untracked + ctx.complete(Response(status_code=200, headers=[]), b"borrowed") + borrow_states.append(ctx.borrowed) # False — re-tracked after finish_response + + with serve_in_thread(handler) as port: + httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert borrow_states == [False, True, False] + + +class TestKeepAlive: + def test_multiple_requests_on_same_connection(self, serve_in_thread): + call_count = 0 + + def handler(ctx: HTTPReqCtx): + nonlocal call_count + call_count += 1 + ctx.complete( + Response(status_code=200, headers=[(b"Content-Length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + with httpx.Client(base_url=f"http://127.0.0.1:{port}", timeout=5) as client: + client.get("/") + client.get("/") + + assert call_count == 2 + + def test_connection_close_header(self, serve_in_thread): + """When client sends Connection: close, server should close after one request. + + Also pins the half-close behavior: the next ``recv`` must return a + clean EOF (b""), not raise ConnectionResetError, since the server + sends a FIN via shutdown(SHUT_WR) before closing. + """ + call_count = 0 + + def handler(ctx: HTTPReqCtx): + nonlocal call_count + call_count += 1 + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + # Read response. + data = drain_socket(sock, deadline=1.0) + assert b"HTTP/1.1 200" in data + # Now expect a clean EOF (FIN), not a reset. + sock.settimeout(2.0) + try: + eof = sock.recv(64) + except ConnectionResetError as e: + pytest.fail(f"server sent RST instead of FIN: {e}") + assert eof == b"" + + assert call_count == 1 + + +# --- Edge cases --------------------------------------------------------------- + + +def _ok_handler(ctx: HTTPReqCtx) -> None: + body = b"ok" + ctx.complete( + Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + body, + ) + + +class TestProtocolErrors: + """Server's reaction to malformed input / handler crashes.""" + + def test_malformed_request_returns_400_and_keeps_loop_alive(self, serve_in_thread): + """A garbage request line is caught; the server replies 400 and stays up.""" + with serve_in_thread(_ok_handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"NOT_A_VALID_HTTP_REQUEST\r\n\r\n") + data = drain_socket(sock, deadline=1.0) + assert b"HTTP/1.1 400" in data, f"expected 400 in response, got: {data!r}" + + # Sanity: a follow-up valid request is still served. + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert resp.status_code == 200 + + def test_handler_exception_returns_500(self, serve_in_thread): + """An unhandled handler exception is caught and returned as 500.""" + + def boom(_: HTTPReqCtx) -> None: + raise RuntimeError("handler crashed") + + with serve_in_thread(boom) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert resp.status_code == 500 + assert resp.text == "Internal Server Error" + + def test_handler_exception_after_start_response_closes_conn(self, serve_in_thread): + """If the handler crashes after start_response, the conn is closed cleanly + AND the accept loop keeps serving subsequent connections to the same server. + """ + crash_count = 0 + crash_lock = threading.Lock() + + def boom(ctx: HTTPReqCtx) -> None: + nonlocal crash_count + with crash_lock: + crash_count += 1 + + def chunks(): + yield b"first chunk" + raise RuntimeError("crashed mid-stream") + + ctx.stream( + Response( + status_code=200, + headers=[(b"transfer-encoding", b"chunked")], + ), + chunks(), + ) + + with serve_in_thread(boom) as port: + # Two separate TCP connections to the SAME server — the second one + # only succeeds in reaching the handler if the accept loop is + # still alive after the first crash. + for _ in range(2): + with contextlib.suppress(httpx.HTTPError): + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + # The response started with 200; httpx may still surface it + # despite the truncated body. + assert resp.status_code == 200 + + assert crash_count == 2, f"expected 2 handler invocations, got {crash_count}" + + +class TestClientDisconnects: + def test_client_closes_before_request_complete(self, serve_in_thread): + """Client opens the conn, sends nothing, closes — server keeps serving others.""" + with serve_in_thread(_ok_handler) as port: + sock = socket.create_connection(("127.0.0.1", port), timeout=2) + sock.close() + # Sanity: the next request still works. + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert resp.status_code == 200 + + def test_disconnected_starts_false_and_flips_on_peer_close(self, serve_in_thread): + """``ctx.disconnected`` is False on a connected peer and flips True after FIN. + + The handler runs on a worker (via ``borrow``) and polls + ``ctx.disconnected`` after the client closes its socket. + """ + before: list[bool] = [] + after: list[bool] = [] + client_closed = threading.Event() + observed = threading.Event() + + def handler(ctx: HTTPReqCtx) -> None: + with ctx.borrow(): + before.append(ctx.disconnected) + client_closed.wait(2.0) + # PEEK may need a moment after FIN; poll briefly. + for _ in range(50): + if ctx.disconnected: + break + threading.Event().wait(0.02) + after.append(ctx.disconnected) + observed.set() + # Best-effort response — the peer is gone, so writes may fail. + with contextlib.suppress(Exception): + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + sock = socket.create_connection(("127.0.0.1", port), timeout=2.0) + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + # Give the handler a moment to enter borrow + read ``before``. + threading.Event().wait(0.05) + sock.close() + client_closed.set() + assert observed.wait(3.0) + + assert before == [False] + assert after == [True] + + def test_client_half_close_after_partial_body_keeps_loop_alive(self, serve_in_thread): + """Headers say Content-Length: 100; client sends 5 bytes then half-closes. + + h11 raises RemoteProtocolError on the incomplete body. The server logs, + closes the conn, and the accept loop survives — verified by a follow-up + request. + """ + with serve_in_thread(_ok_handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nhello") + sock.shutdown(socket.SHUT_WR) + drain_socket(sock, deadline=1.0) + + # Server still serves a follow-up valid request. + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert resp.status_code == 200 + + +class TestExpect100Continue: + def test_handler_reads_body_triggers_100_continue(self, serve_in_thread): + """When the handler calls receive() with Expect:100, the server emits 100 first. + + The handler must ``borrow()`` so the socket is in blocking mode while + it waits for the body — otherwise the non-blocking recv races the + client's post-100 send. + """ + captured = bytearray() + + def handler(ctx: HTTPReqCtx) -> None: + with ctx.borrow(): + while True: + chunk = ctx.receive() + if not chunk: + break + captured.extend(chunk) + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.sendall(b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") + # Read the 100 Continue intermediate response. + sock.settimeout(2) + pre = b"" + while b"\r\n\r\n" not in pre: + chunk = sock.recv(4096) + assert chunk, "connection closed before 100 Continue" + pre += chunk + assert b"100 Continue" in pre, f"expected 100 Continue, got: {pre!r}" + + sock.sendall(b"hello") + final = drain_socket(sock, deadline=2.0) + + assert b"HTTP/1.1 200" in final + assert b"\r\n\r\nok" in final + assert bytes(captured) == b"hello" + + +class TestHeadAndPipelining: + def test_head_request(self, serve_in_thread): + """HEAD response carries headers but no body bytes. + + Note: a HEAD-aware handler must skip ``send(body)`` — h11 raises + ``Too much data for declared Content-Length`` if the server tries to + write a body for HEAD. This test pins that contract. + """ + body = b"this would be the body" + + def handler(ctx: HTTPReqCtx) -> None: + headers = [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode()), + ] + response = Response(status_code=200, headers=headers) + if ctx.request.method == b"HEAD": + ctx.complete(response, None) + else: + ctx.complete(response, body) + + with serve_in_thread(handler) as port: + resp = httpx.head(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.headers["content-length"] == str(len(body)) + assert resp.content == b"" + + def test_pipelined_requests_on_one_connection(self, serve_in_thread): + """Two requests written in one TCP send are both served in order.""" + served: list[bytes] = [] + served_lock = threading.Lock() + + def handler(ctx: HTTPReqCtx) -> None: + with served_lock: + served.append(ctx.request.target) + body = b"resp-for-" + ctx.request.target + ctx.complete( + Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + body, + ) + + with serve_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.sendall(b"GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n") + # Read until both bodies have arrived. + sock.settimeout(2) + data = b"" + while b"resp-for-/a" not in data or b"resp-for-/b" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + + assert served == [b"/a", b"/b"] + assert b"resp-for-/a" in data + assert b"resp-for-/b" in data + + +# --- Stale-connection cleanup ------------------------------------------------ + + +class TestStaleCleanup: + def test_idle_keep_alive_silently_closed_after_timeout(self): + """An idle keep-alive client sees an EOF (no 408) once keep_alive_timeout elapses.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + cfg = ServerConfig(host="127.0.0.1", port=0, keep_alive_timeout=0.1) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread(target=lambda: _run_until(server, stop), daemon=True) + t.start() + try: + with socket.create_connection(("127.0.0.1", server.port), timeout=2) as sock: + # First request — server then keeps the connection alive. + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + drain_socket(sock, deadline=0.3) + # Now sit idle. After keep_alive_timeout (0.1s) + a few iterations, + # _cleanup_stale should silently close the conn. + sock.settimeout(2.0) + data = sock.recv(64) + finally: + stop.set() + t.join(timeout=2) + + assert data == b"", f"expected EOF on idle close, got: {data!r}" + + def test_mid_request_stale_returns_408(self): + """Client starts sending headers and stops; server emits 408 once stale.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=0, keep_alive_timeout=0.1, rw_timeout=0.1) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread(target=lambda: _run_until(server, stop), daemon=True) + t.start() + try: + with socket.create_connection(("127.0.0.1", server.port), timeout=2) as sock: + # Send only part of a request and then sit idle. + sock.sendall(b"GET /slow HTTP/1.1\r\nHost: x\r\n") + sock.settimeout(2.0) + data = drain_socket(sock, deadline=1.5) + finally: + stop.set() + t.join(timeout=2) + + assert b"HTTP/1.1 408" in data, f"expected 408 in response, got: {data!r}" + + +# --- Body limit --------------------------------------------------------------- + + +class TestBodyLimit: + def test_oversized_content_length_returns_413(self): + """Server short-circuits on Content-Length > max_body_size.""" + captured: dict = {"called": False} + + def handler(ctx: HTTPReqCtx) -> None: + captured["called"] = True + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=0, max_body_size=10) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread( + target=lambda: _run_until(server, stop), + daemon=True, + ) + t.start() + try: + resp = httpx.post( + f"http://127.0.0.1:{server.port}/", + content=b"x" * 1000, + timeout=2, + ) + finally: + stop.set() + t.join(timeout=2) + + assert resp.status_code == 413 + assert resp.text == "Payload Too Large" + assert captured["called"] is False # handler never invoked + + def test_oversized_streaming_body_returns_413(self): + """A handler that reads the body sees BodyTooLarge once the cap is crossed.""" + captured: dict = {"raised": False} + + def handler(ctx: HTTPReqCtx) -> None: + try: + while ctx.receive(): + pass + except Exception as e: + captured["raised"] = type(e).__name__ + raise + + cfg = ServerConfig(host="127.0.0.1", port=0, max_body_size=8) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread(target=lambda: _run_until(server, stop), daemon=True) + t.start() + try: + # No Content-Length: send chunked to bypass the up-front check. + with socket.create_connection(("127.0.0.1", server.port), timeout=3) as sock: + sock.sendall( + b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" + b"10\r\n" + (b"x" * 16) + b"\r\n0\r\n\r\n" + ) + data = drain_socket(sock, deadline=2.0) + finally: + stop.set() + t.join(timeout=2) + + assert b"HTTP/1.1 413" in data, f"expected 413, got: {data!r}" + assert captured["raised"] == "BodyTooLarge" + + +def _run_until(server, stop: threading.Event) -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return + + +# --- Graceful shutdown ------------------------------------------------------- + + +class TestGracefulShutdown: + def test_shutting_down_flag_set_on_exit(self): + """``Server.shutting_down`` flips to True on context-manager exit.""" + captured: dict = {} + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: + captured["server"] = server + assert server.shutting_down is False + assert captured["server"].shutting_down is True + + def test_idle_keep_alive_connection_closed_on_exit(self, serve_in_thread): + """A keep-alive socket sees an EOF (recv → b"") once the server exits.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + sock = socket.create_connection(("127.0.0.1", port), timeout=2) + try: + # Send a request and read its response — connection is kept alive. + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + drain_socket(sock, deadline=0.5) + finally: + # Pull socket reference outside the with block so we can recv after server exit. + pass + + # Outside `serve_in_thread`: server has exited and closed the keep-alive conn. + sock.settimeout(2.0) + try: + data = sock.recv(64) + except (ConnectionResetError, OSError): + data = b"" + finally: + sock.close() + assert data == b"", f"expected EOF after shutdown, got: {data!r}" diff --git a/tests/http/service.py b/tests/http/service.py new file mode 100644 index 0000000..cc8cd02 --- /dev/null +++ b/tests/http/service.py @@ -0,0 +1,710 @@ +"""Tests for ``localpost.http._service`` (the hosted ``http_server`` service) +and ``localpost.http._pool`` (the ``thread_pool_handler`` async CM). +""" + +from __future__ import annotations + +import contextlib +import socket +import threading +import time +from collections import Counter +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager +from typing import cast + +import anyio +import httpx +import pytest +from anyio import to_thread + +from localpost.hosting import ServiceLifetimeView, serve +from localpost.http import ( + HTTPReqCtx, + Request, + RequestCancelled, + RequestHandler, + Response, + Routes, + ServerConfig, + check_cancelled, + http_server, + route_match, + streaming_pool_handler, + thread_pool_handler, +) +from localpost.threadtools import WorkerExecutor + +pytestmark = pytest.mark.anyio + + +@asynccontextmanager +async def _serve_pooled( + cfg: ServerConfig, + handler: RequestHandler, + *, + selectors: int = 1, + acceptor: bool = False, +) -> AsyncGenerator[ServiceLifetimeView]: + """Compose ``thread_pool_handler`` + ``http_server`` and yield the http_server lifetime view. + + Tests own shutdown via the yielded lifetime; the pool drains on exit. + """ + with WorkerExecutor() as executor: + async with thread_pool_handler(handler, executor) as wrapped: + async with serve(http_server(cfg, wrapped, selectors=selectors, acceptor=acceptor)) as lt: + yield lt + + +def _handler_200(body: bytes = b"ok"): + def handler(ctx: HTTPReqCtx): + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], + ), + body, + ) + + return handler + + +async def _get(url: str, **kw) -> httpx.Response: + """httpx.get from an async test — offload to a worker thread.""" + return await to_thread.run_sync(lambda: httpx.get(url, **kw)) + + +async def _wait_server_ready(port: int, deadline: float = 5.0): + """Poll the port until it accepts a connection.""" + + def probe(): + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + return await to_thread.run_sync(probe) + + +class TestHttpServerService: + async def test_serves_single_request_immediate(self, free_port): + """Immediate handler: no thread pool, runs on the selector thread.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"hi"))) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_serves_single_request_pooled(self, free_port): + """Pool-wrapped handler: same observable behaviour, but on a worker thread.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, _handler_200(b"hi")) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_each_request_becomes_a_task(self, free_port): + """Several slow handlers run in parallel on different worker threads.""" + thread_ids: list[int] = [] + lock = threading.Lock() + entered = threading.Semaphore(0) # used as a barrier signal + release = threading.Event() + + def body_handler(ctx: HTTPReqCtx): + with lock: + thread_ids.append(threading.get_ident()) + entered.release() + # Block until the test releases us; this forces parallelism. + release.wait(timeout=5.0) + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + def handler(_ctx: HTTPReqCtx) -> None: + body_handler(_ctx) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler) as lt: + await lt.started + await _wait_server_ready(free_port) + + async def fire(): + return await _get(f"http://127.0.0.1:{free_port}/") + + results: list[httpx.Response | None] = [None, None, None] + + async def do(i): + results[i] = await fire() + + async with anyio.create_task_group() as tg: + tg.start_soon(do, 0) + tg.start_soon(do, 1) + tg.start_soon(do, 2) + + # Wait until all three handlers have entered before releasing. + def wait_for_three(): + for _ in range(3): + assert entered.acquire(timeout=5.0) + + await to_thread.run_sync(wait_for_three) + release.set() + + for r in results: + assert r is not None + assert r.status_code == 200 + + assert len(thread_ids) == 3 + # Three requests served from different worker threads. + assert len(set(thread_ids)) >= 2 # at least two distinct threads + + lt.shutdown() + await lt.stopped + + async def test_shutdown_cancels_inflight(self, free_port): + """Triggering shutdown while a handler is running cancels it via the HTTP cancellation token. + + ``thread_pool_handler``'s exit sets the shared shutdown event ORed into + every in-flight ``RequestCancel``, so the next ``check_cancelled`` call + in the handler raises ``RequestCancelled``. + """ + handler_started = threading.Event() + handler_cancelled = threading.Event() + + def body_handler(ctx: HTTPReqCtx): + handler_started.set() + try: + for _ in range(100): + check_cancelled() + time.sleep(0.05) + except RequestCancelled: + handler_cancelled.set() + raise + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + def handler(_ctx: HTTPReqCtx) -> None: + body_handler(_ctx) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler) as lt: + await lt.started + await _wait_server_ready(free_port) + + # Fire and forget — we don't wait for the response since the handler will be cancelled. + async def fire_and_forget(): + with contextlib.suppress(Exception): + await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + + async with anyio.create_task_group() as tg: + tg.start_soon(fire_and_forget) + + # Wait for the handler to start + await to_thread.run_sync(lambda: handler_started.wait(5.0)) + + lt.shutdown() + + await lt.stopped + + # The handler's own loop observes cancellation via ``check_cancelled``. + assert handler_cancelled.is_set() + + async def test_router_dispatch_via_service(self, free_port): + routes = Routes() + + @routes.get("/books/{id}") + def get_book(ctx: HTTPReqCtx) -> None: + book_id = route_match(ctx).path_args["id"] + body = f"book={book_id}".encode() + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + + assert get_book is not None + router = routes.build() + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, router.as_handler()) as lt: + await lt.started + await _wait_server_ready(free_port) + + resp = await _get(f"http://127.0.0.1:{free_port}/books/42") + assert resp.status_code == 200 + assert resp.text == "book=42" + + resp = await _get(f"http://127.0.0.1:{free_port}/missing") + assert resp.status_code == 404 + + lt.shutdown() + await lt.stopped + + +class TestMultiSelector: + """``selectors=N > 1`` spawns N independent ``BaseServer`` threads bound + to the same address via ``SO_REUSEPORT``. Tests assert correctness; the + kernel-side distribution of incoming connections across selectors is a + deployment-time concern verified by the bench, not unit tests.""" + + async def test_invalid_selectors(self, free_port): + cfg = ServerConfig(host="127.0.0.1", port=free_port) + with pytest.raises(ValueError, match="selectors"): + http_server(cfg, _handler_200(), selectors=0) + + async def test_serves_requests_inline(self, free_port): + """selectors=4, no thread pool. Each request runs on whichever + selector accepted it; all must serve correctly.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"hi"), selectors=4)) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(5): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_serves_requests_pooled_concurrent(self, free_port): + """selectors=4 + thread pool. Multiple selectors push onto the + single shared channel; the pool drains across all producers.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + with WorkerExecutor() as executor: + async with thread_pool_handler(_handler_200(b"hi"), executor) as wrapped: + async with serve(http_server(cfg, wrapped, selectors=4)) as lt: + await lt.started + await _wait_server_ready(free_port) + + results: list[httpx.Response | None] = [None] * 10 + + async def fire(i: int) -> None: + results[i] = await _get(f"http://127.0.0.1:{free_port}/") + + async with anyio.create_task_group() as tg: + for i in range(10): + tg.start_soon(fire, i) + + for r in results: + assert r is not None + assert r.status_code == 200 + assert r.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_shutdown_stops_all_selectors(self, free_port): + """All N selector threads must exit on shutdown — no leaked threads + and a clean exit code.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"x"), selectors=4)) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + +class TestAcceptorTopology: + """``acceptor=True`` runs a dedicated acceptor thread that round-robins + new conns to N worker selectors via the cross-thread op queue.""" + + async def test_serves_requests(self, free_port): + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"hi"), selectors=4, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(8): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_distributes_conns_across_workers(self, free_port): + """N concurrent fresh conns should land on multiple worker selectors + (round-robin). We sample the worker thread id per request — with + N=4 workers and 8 fresh conns we expect at least 2 distinct workers + (loose bound to keep this stable under scheduling jitter).""" + threads_seen: set[int] = set() + lock = threading.Lock() + + def handler(ctx: HTTPReqCtx) -> None: + with lock: + threads_seen.add(threading.get_ident()) + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"hi", + ) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, handler, selectors=4, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + + # Use fresh connections — keep-alive would pin every request to + # the same worker. ``httpx.Client(... headers={"Connection": + # "close"})`` is the simplest way. + results: list[httpx.Response | None] = [None] * 8 + + async def fire(i: int) -> None: + async with httpx.AsyncClient(timeout=5) as client: + results[i] = await client.get( + f"http://127.0.0.1:{free_port}/", + headers={"Connection": "close"}, + ) + + async with anyio.create_task_group() as tg: + for i in range(8): + tg.start_soon(fire, i) + + for r in results: + assert r is not None + assert r.status_code == 200 + + assert len(threads_seen) >= 2, f"expected round-robin across workers, got {threads_seen}" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_shutdown_clean(self, free_port): + """Acceptor + workers shut down cleanly on lt.shutdown().""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"x"), selectors=4, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_serves_requests_httptools(self, free_port): + """Acceptor + httptools backend. + + The cross-thread handoff (``_OpTrack`` → ``Selector.post_track``) is + parser-agnostic at the type level, but httptools' push-callback model + means the parser is *constructed* on the acceptor thread (inside + ``RoundRobinAcceptor.__call__``) and *driven* on the worker thread. + Smoke-tests that the parser instance survives the move. + """ + pytest.importorskip("httptools") + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(http_server(cfg, _handler_200(b"hi"), selectors=4, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(8): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_serves_requests_pooled(self, free_port): + """Acceptor + thread_pool_handler — most production-like cell. + + Pool dispatch routes through ``ctx.conn.selector.stop_tracking(ctx.conn)``; + in acceptor mode that selector is the **worker**, not the acceptor. + Catches any regression where ``conn.selector`` is misaligned with the + actual owning selector. + """ + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled( + cfg, + _handler_200(b"hi"), + selectors=4, + acceptor=True, + ) as lt: + await lt.started + await _wait_server_ready(free_port) + + results: list[httpx.Response | None] = [None] * 10 + + async def fire(i: int) -> None: + results[i] = await _get(f"http://127.0.0.1:{free_port}/") + + async with anyio.create_task_group() as tg: + for i in range(10): + tg.start_soon(fire, i) + + for r in results: + assert r is not None + assert r.status_code == 200 + assert r.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + +class TestSelectorThreadFastPath: + """When the Router is passed directly to ``http_server`` (no ``thread_pool_handler`` + wrapping), every route — including the 404 / 405 paths — runs on the selector + thread. No thread pool is involved at all, so 404/405 cost is just a regex + match plus a static response payload.""" + + async def test_router_direct_runs_on_one_thread(self, free_port): + """Matched routes all share a single thread (the selector). 404 returns + normally even though there's no worker pool to dispatch it through.""" + threads_seen: set[int] = set() + lock = threading.Lock() + + routes = Routes() + + @routes.get("/hit") + def hit(ctx: HTTPReqCtx) -> None: + with lock: + threads_seen.add(threading.get_ident()) + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", b"2")], + ), + b"ok", + ) + + assert hit is not None + router = routes.build() + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, router.as_handler())) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(3): + r = await _get(f"http://127.0.0.1:{free_port}/hit") + assert r.status_code == 200 + + r = await _get(f"http://127.0.0.1:{free_port}/missing") + assert r.status_code == 404 + + # Every matched request was served from the same thread — the + # selector. With no ``thread_pool_handler`` in the composition, + # there are no worker threads to fan out to. + assert len(threads_seen) == 1, threads_seen + + lt.shutdown() + await lt.stopped + + +class _FakeSelector: + def __init__(self) -> None: + self.stopped = False + + def stop_tracking(self, conn: _FakeConn) -> None: + self.stopped = True + conn.tracked = False + + +class _FakeConn: + def __init__(self, sock: socket.socket, selector: _FakeSelector) -> None: + self.sock = sock + self.tracked = True + self.selector = selector + + +class _DeferringCtx: + def __init__(self, selector: _FakeSelector, conn: _FakeConn) -> None: + self.selector = selector + self.conn = conn + self.request = Request(method=b"POST", target=b"/upload", path=b"/upload", query_string=b"", headers=[]) + self.body = b"" + self.response_status = None + self.attrs = {} + self.deferred: Callable[[], None] | None = None + + def _defer_streaming_dispatch(self, dispatcher: Callable[[HTTPReqCtx], None]) -> None: + self.deferred = lambda: dispatcher(cast(HTTPReqCtx, self)) + + +class TestServiceRobustness: + async def test_streaming_pool_defers_dispatch_when_context_requests_it(self): + """Backends with parser callbacks can delay worker start until parsing unwinds.""" + selector = _FakeSelector() + sock, peer = socket.socketpair() + ctx = _DeferringCtx(selector, _FakeConn(sock, selector)) + ran = threading.Event() + + def work(_ctx: HTTPReqCtx) -> None: + ran.set() + + try: + with WorkerExecutor() as executor: + async with streaming_pool_handler(work, executor) as wrapped: + assert wrapped(cast(HTTPReqCtx, ctx)) is None + assert ctx.deferred is not None + assert selector.stopped is False + assert ran.wait(0.05) is False + + ctx.deferred() + assert await to_thread.run_sync(lambda: ran.wait(2.0)) + assert selector.stopped is True + assert ctx.conn.tracked is False + finally: + sock.close() + peer.close() + + async def test_handler_exception_returns_500_and_service_stays_up(self, free_port): + """A handler exception is caught at the connection level and returned as 500. + + The service must remain healthy and serve subsequent requests. + """ + + def boom(_: HTTPReqCtx) -> None: + raise RuntimeError("handler crashed") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, boom) as lt: + await lt.started + await _wait_server_ready(free_port) + + r1 = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + assert r1.status_code == 500 + r2 = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + assert r2.status_code == 500 # service still serving + + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + + async def test_repeated_requests_keep_working(self, free_port): + """Repeatedly hitting the pool must keep working — confirms workers + re-park (back into the shared idle deque) after each task.""" + + def handler(ctx: HTTPReqCtx): + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(5): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + + lt.shutdown() + await lt.stopped + + +class TestDispatchLoad: + async def test_many_requests_served_from_worker_threads(self, free_port): + cfg = ServerConfig(host="127.0.0.1", port=free_port) + + # Hold each handler in flight until the second one arrives so the pool + # is forced to spawn a second worker. Without this, the single worker + # can finish each request fast enough to handle all 10 sequentially. + barrier = threading.Barrier(2, timeout=2.0) + + def handler(ctx: HTTPReqCtx): + try: + barrier.wait() + except threading.BrokenBarrierError: + pass + tid = str(threading.get_ident()).encode() + ctx.complete( + Response(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), + tid, + ) + + async with _serve_pooled(cfg, handler) as lt: + await lt.started + await _wait_server_ready(free_port) + + results: list[str] = [] + + async def fire(): + r = await _get(f"http://127.0.0.1:{free_port}/") + results.append(r.text) + + async with anyio.create_task_group() as tg: + for _ in range(10): + tg.start_soon(fire) + + # Some distribution across worker threads — at least 2 distinct thread names. + counts = Counter(results) + assert sum(counts.values()) == 10 + assert len(counts) >= 2 + + +class TestRequestCancellation: + async def test_check_cancelled_outside_handler_raises_lookup_error(self): + with pytest.raises(LookupError, match="outside a request handler"): + check_cancelled() + + async def test_client_disconnect_cancels_handler(self, free_port): + """A handler doing slow work sees ``RequestCancelled`` when the client closes the socket. + + The watchdog only arms for requests without a body, which is the case for the + bare ``GET /`` we open here. We close the socket before reading the response so + the server detects EOF mid-handler. + """ + handler_started = threading.Event() + handler_cancelled = threading.Event() + + def body_handler(ctx: HTTPReqCtx): + handler_started.set() + try: + for _ in range(200): + check_cancelled() + time.sleep(0.02) + except RequestCancelled: + handler_cancelled.set() + raise + # Should not be reached + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + def handler(_ctx: HTTPReqCtx) -> None: + body_handler(_ctx) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler) as lt: + await lt.started + await _wait_server_ready(free_port) + + def hit_and_drop(): + # Open a raw socket, send a minimal GET, then close mid-handler. + s = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + handler_started.wait(2.0) + s.close() # peer FIN — selector watchdog should fire + + await to_thread.run_sync(hit_and_drop) + + # Wait for cancellation to propagate (selector poll interval + check_cancelled poll interval). + def wait_for_cancel(): + return handler_cancelled.wait(5.0) + + assert await to_thread.run_sync(wait_for_cancel) + + lt.shutdown() + await lt.stopped diff --git a/tests/http/static.py b/tests/http/static.py new file mode 100644 index 0000000..df5a1a5 --- /dev/null +++ b/tests/http/static.py @@ -0,0 +1,435 @@ +"""Tests for localpost.http.static — file serving via socket.sendfile().""" + +from __future__ import annotations + +import os +import time +from email.utils import formatdate +from pathlib import Path + +import httpx +import pytest + +from localpost.http import static_handler +from localpost.http.static import ( + _etag, + _if_modified_since_satisfied, + _if_none_match_matches, + _parse_range, + _resolve, +) + +# --- Helpers (unit) ------------------------------------------------------ + + +class TestResolve: + def test_simple(self, tmp_path: Path): + (tmp_path / "a.txt").write_bytes(b"x") + assert _resolve(tmp_path, b"a.txt", index=None) == tmp_path / "a.txt" + + def test_leading_slash_stripped(self, tmp_path: Path): + (tmp_path / "a.txt").write_bytes(b"x") + assert _resolve(tmp_path, b"/a.txt", index=None) == tmp_path / "a.txt" + + def test_percent_decode(self, tmp_path: Path): + (tmp_path / "a b.txt").write_bytes(b"x") + assert _resolve(tmp_path, b"a%20b.txt", index=None) == tmp_path / "a b.txt" + + def test_traversal_dotdot_segment_rejected(self, tmp_path: Path): + # Even before resolve(), explicit ``..`` segments are rejected. + assert _resolve(tmp_path, b"../etc/passwd", index=None) is None + assert _resolve(tmp_path, b"foo/../../etc/passwd", index=None) is None + + def test_traversal_via_encoded_dotdot(self, tmp_path: Path): + # %2e%2e decodes to ``..`` — caught by the segment check. + assert _resolve(tmp_path, b"%2e%2e/etc/passwd", index=None) is None + + def test_null_byte_rejected(self, tmp_path: Path): + assert _resolve(tmp_path, b"a\x00b", index=None) is None + + def test_invalid_utf8_rejected(self, tmp_path: Path): + # %ff is not valid UTF-8. + assert _resolve(tmp_path, b"%ff.txt", index=None) is None + + def test_directory_with_index(self, tmp_path: Path): + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "index.html").write_bytes(b"x") + assert _resolve(tmp_path, b"sub/", index="index.html") == tmp_path / "sub" / "index.html" + assert _resolve(tmp_path, b"sub", index="index.html") == tmp_path / "sub" / "index.html" + + def test_directory_no_index(self, tmp_path: Path): + (tmp_path / "sub").mkdir() + assert _resolve(tmp_path, b"sub/", index=None) is None + + def test_directory_index_disabled_when_missing(self, tmp_path: Path): + # Index resolution returns the joined path even when the index file + # does not exist; caller stats and 404s. + (tmp_path / "sub").mkdir() + result = _resolve(tmp_path, b"sub/", index="index.html") + assert result is not None + assert result == tmp_path / "sub" / "index.html" + assert not result.exists() + + +class TestETag: + def test_shape(self, tmp_path: Path): + p = tmp_path / "a.txt" + p.write_bytes(b"hello") + st = p.stat() + tag = _etag(st) + assert tag.startswith(b'"') + assert tag.endswith(b'"') + # Stable across re-stats of the same file. + assert _etag(p.stat()) == tag + + +class TestIfNoneMatch: + def test_strong_match(self): + assert _if_none_match_matches(b'"abc"', b'"abc"') + + def test_no_match(self): + assert not _if_none_match_matches(b'"abc"', b'"xyz"') + + def test_wildcard(self): + assert _if_none_match_matches(b"*", b'"anything"') + + def test_weak_match(self): + assert _if_none_match_matches(b'W/"abc"', b'"abc"') + assert _if_none_match_matches(b'"abc"', b'W/"abc"') + + def test_multi_value(self): + assert _if_none_match_matches(b'"x", "abc", "y"', b'"abc"') + assert not _if_none_match_matches(b'"x", "y"', b'"abc"') + + def test_whitespace_tolerant(self): + assert _if_none_match_matches(b' "abc" , "y"', b'"abc"') + + +class TestIfModifiedSince: + def test_after(self): + # File modified at t=1000, header says "since 2000" → not modified. + assert _if_modified_since_satisfied(b"Thu, 01 Jan 1970 00:00:01 GMT", 0.0) + + def test_before(self): + # File modified now, header says "since the past" → modified. + past = formatdate(time.time() - 3600, usegmt=True).encode("ascii") + assert not _if_modified_since_satisfied(past, time.time()) + + def test_invalid(self): + assert not _if_modified_since_satisfied(b"not a date", 100.0) + + +class TestParseRange: + def test_full_range(self): + assert _parse_range(b"bytes=0-99", 1000) == (0, 100) + + def test_mid_range(self): + assert _parse_range(b"bytes=100-199", 1000) == (100, 100) + + def test_open_ended(self): + assert _parse_range(b"bytes=500-", 1000) == (500, 500) + + def test_suffix(self): + assert _parse_range(b"bytes=-50", 1000) == (950, 50) + + def test_suffix_larger_than_size(self): + # Per RFC 7233 §2.1, clamped to full content. + assert _parse_range(b"bytes=-5000", 1000) == (0, 1000) + + def test_end_clamped(self): + # End past EOF clamps to size-1. + assert _parse_range(b"bytes=100-9999", 1000) == (100, 900) + + def test_start_past_eof_unsatisfiable(self): + assert _parse_range(b"bytes=2000-2999", 1000) == "unsatisfiable" + + def test_zero_size_unsatisfiable(self): + assert _parse_range(b"bytes=0-99", 0) == "unsatisfiable" + + def test_multi_range_falls_back(self): + # Multi-range → 200 fallback (we don't speak multipart/byteranges). + assert _parse_range(b"bytes=0-99,200-299", 1000) is None + + def test_unparsable(self): + assert _parse_range(b"bytes=abc-def", 1000) is None + assert _parse_range(b"bytes=", 1000) is None + assert _parse_range(b"items=0-99", 1000) is None + assert _parse_range(b"bytes=10-5", 1000) is None # end < start + + def test_case_insensitive_unit(self): + assert _parse_range(b"BYTES=0-9", 100) == (0, 10) + + +# --- Integration --------------------------------------------------------- + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + """A populated directory served by all integration tests.""" + (tmp_path / "hello.txt").write_bytes(b"Hello, world!\n") + (tmp_path / "page.html").write_bytes(b"") + (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) + (tmp_path / "weird.zzzz").write_bytes(b"unknown extension") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "index.html").write_bytes(b"sub-index") + return tmp_path + + +class TestStaticBasic: + def test_get_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + assert r.headers["content-type"] == "text/plain" + assert r.headers["content-length"] == "14" + assert r.headers["accept-ranges"] == "bytes" + assert r.headers["etag"].startswith('"') + assert "last-modified" in r.headers + + def test_404_missing(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/nope", timeout=5) + assert r.status_code == 404 + + def test_404_directory_traversal(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + # httpx normalises paths; use raw URL bytes via build_request. + req = httpx.Request("GET", f"http://127.0.0.1:{port}/../etc/passwd") + with httpx.Client(timeout=5) as client: + r = client.send(req) + # Whatever path httpx ends up sending, must not leak content outside root. + assert r.status_code in (400, 404) + + def test_405_wrong_method(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.put(f"http://127.0.0.1:{port}/hello.txt", content=b"x", timeout=5) + assert r.status_code == 405 + assert r.headers["allow"] == "GET, HEAD" + + def test_prefix_stripped(self, root: Path, serve_backend_in_thread): + h = static_handler(root, prefix=b"/static/") + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/static/hello.txt", timeout=5) + r_miss = httpx.get(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + assert r_miss.status_code == 404 + + def test_index_directory(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/sub/", timeout=5) + assert r.status_code == 200 + assert r.content == b"sub-index" + + def test_index_disabled(self, root: Path, serve_backend_in_thread): + h = static_handler(root, index=None) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/sub/", timeout=5) + assert r.status_code == 404 + + +class TestStaticHead: + def test_head_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.head(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.status_code == 200 + assert r.content == b"" + assert r.headers["content-length"] == "14" + assert r.headers["etag"].startswith('"') + + def test_head_404(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.head(f"http://127.0.0.1:{port}/nope", timeout=5) + assert r.status_code == 404 + + +class TestStaticConditionals: + def test_if_none_match_hit_returns_304(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + base = f"http://127.0.0.1:{port}/hello.txt" + r1 = httpx.get(base, timeout=5) + assert r1.status_code == 200 + etag = r1.headers["etag"] + r2 = httpx.get(base, headers={"if-none-match": etag}, timeout=5) + assert r2.status_code == 304 + assert r2.content == b"" + assert r2.headers["etag"] == etag + + def test_if_none_match_miss_returns_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"if-none-match": '"deadbeef"'}, + timeout=5, + ) + assert r.status_code == 200 + + def test_if_modified_since_after_mtime(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + # Set mtime to a known past second so the comparison is deterministic. + target = root / "hello.txt" + os.utime(target, (1_000_000, 1_000_000)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"if-modified-since": formatdate(2_000_000, usegmt=True)}, + timeout=5, + ) + assert r.status_code == 304 + + def test_if_modified_since_before_mtime(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + target = root / "hello.txt" + os.utime(target, (2_000_000, 2_000_000)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"if-modified-since": formatdate(1_000_000, usegmt=True)}, + timeout=5, + ) + assert r.status_code == 200 + + +class TestStaticRange: + def test_prefix_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=0-4"}, + timeout=5, + ) + assert r.status_code == 206 + assert r.content == b"Hello" + assert r.headers["content-length"] == "5" + assert r.headers["content-range"] == "bytes 0-4/14" + + def test_mid_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=7-11"}, + timeout=5, + ) + assert r.status_code == 206 + assert r.content == b"world" + assert r.headers["content-range"] == "bytes 7-11/14" + + def test_suffix_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=-7"}, + timeout=5, + ) + assert r.status_code == 206 + assert r.content == b"world!\n" + + def test_unsatisfiable_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=9999-"}, + timeout=5, + ) + assert r.status_code == 416 + assert r.headers["content-range"] == "bytes */14" + + def test_invalid_range_falls_back_to_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=abc-def"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + + def test_multi_range_falls_back_to_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=0-4,7-11"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + + +class TestStaticContentType: + def test_html(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/page.html", timeout=5) + assert r.headers["content-type"] == "text/html" + + def test_png(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/image.png", timeout=5) + assert r.headers["content-type"] == "image/png" + + def test_unknown_extension_octet_stream(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/weird.zzzz", timeout=5) + assert r.headers["content-type"] == "application/octet-stream" + + +class TestStaticCacheControl: + def test_passthrough_on_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root, cache_control="public, max-age=3600, immutable") + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.headers["cache-control"] == "public, max-age=3600, immutable" + + def test_passthrough_on_304(self, root: Path, serve_backend_in_thread): + h = static_handler(root, cache_control="public, max-age=3600") + with serve_backend_in_thread(h) as port: + base = f"http://127.0.0.1:{port}/hello.txt" + etag = httpx.get(base, timeout=5).headers["etag"] + r = httpx.get(base, headers={"if-none-match": etag}, timeout=5) + assert r.status_code == 304 + assert r.headers["cache-control"] == "public, max-age=3600" + + +class TestStaticLargeFile: + def test_round_trip_1mb(self, tmp_path: Path, serve_backend_in_thread): + # ≥ 1 MB so socket.sendfile makes multiple os.sendfile iterations. + payload = os.urandom(1 << 20) + (tmp_path / "blob.bin").write_bytes(payload) + h = static_handler(tmp_path) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/blob.bin", timeout=15) + assert r.status_code == 200 + assert int(r.headers["content-length"]) == len(payload) + assert r.content == payload + + def test_range_within_large_file(self, tmp_path: Path, serve_backend_in_thread): + payload = os.urandom(1 << 20) + (tmp_path / "blob.bin").write_bytes(payload) + h = static_handler(tmp_path) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/blob.bin", + headers={"range": "bytes=100-199"}, + timeout=10, + ) + assert r.status_code == 206 + assert r.content == payload[100:200] diff --git a/tests/http/uri_template_props.py b/tests/http/uri_template_props.py new file mode 100644 index 0000000..c121b3a --- /dev/null +++ b/tests/http/uri_template_props.py @@ -0,0 +1,106 @@ +"""Property-based tests for ``URITemplate`` (RFC 6570 Level 1).""" + +from __future__ import annotations + +import re + +from hypothesis import assume, given +from hypothesis import strategies as st + +from localpost.http import URITemplate + +# --- Strategies --------------------------------------------------------------- + +# Path segment chars: visible ASCII minus '/' and '{' / '}' (which would +# accidentally re-introduce template syntax). +_segment_chars = st.characters( + min_codepoint=33, + max_codepoint=126, + blacklist_characters="/{}", +) + +_literal_segment = st.text(_segment_chars, min_size=1, max_size=8) +_variable_name = st.from_regex(r"\A[a-zA-Z_][a-zA-Z0-9_]{0,7}\Z") + + +@st.composite +def _template_with_values(draw) -> tuple[str, dict[str, str], str]: + """Build (template, values, concrete_uri) where values substitute cleanly.""" + n_segments = draw(st.integers(min_value=1, max_value=5)) + template_parts: list[str] = [] + uri_parts: list[str] = [] + values: dict[str, str] = {} + used_names: set[str] = set() + + for _ in range(n_segments): + kind = draw(st.sampled_from(["literal", "variable"])) + if kind == "literal": + seg = draw(_literal_segment) + template_parts.append(seg) + uri_parts.append(seg) + else: + name = draw(_variable_name.filter(lambda n: n not in used_names)) + used_names.add(name) + value = draw(st.text(_segment_chars, min_size=1, max_size=8)) + template_parts.append("{" + name + "}") + uri_parts.append(value) + values[name] = value + + template = "/" + "/".join(template_parts) + uri = "/" + "/".join(uri_parts) + return template, values, uri + + +# --- Properties --------------------------------------------------------------- + + +class TestURITemplateProperties: + @given(payload=_template_with_values()) + def test_round_trip(self, payload): + """match(uri_built_from_values) should yield the same values back.""" + template, values, uri = payload + t = URITemplate.parse(template) + result = t.match(uri) + assert result == values, f"template={template!r} uri={uri!r} expected={values!r} got={result!r}" + + @given(payload=_template_with_values()) + def test_variable_names_in_declaration_order(self, payload): + template, values, _ = payload + t = URITemplate.parse(template) + expected_order = tuple(re.findall(r"\{([^}]+)\}", template)) + assert t.variable_names == expected_order + # And every name we substituted is present in variable_names. + assert set(values).issubset(set(t.variable_names)) + + @given(template=_literal_segment.map(lambda s: f"/{s}")) + def test_literal_template_matches_self_only(self, template): + t = URITemplate.parse(template) + assert t.match(template) == {} + assert t.match(template + "/extra") is None + assert t.match(template[:-1]) is None # one char short + + @given(payload=_template_with_values(), extra=_literal_segment) + def test_extra_segments_do_not_match(self, payload, extra): + """A path that has more segments than the template should not match.""" + template, _, uri = payload + t = URITemplate.parse(template) + assert t.match(uri + "/" + extra) is None + + @given(payload=_template_with_values()) + def test_variable_does_not_span_slash(self, payload): + """A value with a '/' in it should never match a single-var slot.""" + template, values, _ = payload + # Pick the test only when there is at least one variable. + assume(values) + # Build a uri where one var value contains '/' — should fail to match. + bad_values = {**values, next(iter(values)): "x/y"} + bad_uri_parts = [] + for part in re.split(r"(\{[^}]+\})", template): + if part.startswith("{") and part.endswith("}"): + name = part[1:-1] + bad_uri_parts.append(bad_values[name]) + else: + bad_uri_parts.append(part) + bad_uri = "".join(bad_uri_parts) + t = URITemplate.parse(template) + assert t.match(bad_uri) is None diff --git a/tests/http/wsgi.py b/tests/http/wsgi.py new file mode 100644 index 0000000..bfff6db --- /dev/null +++ b/tests/http/wsgi.py @@ -0,0 +1,134 @@ +"""Tests for localpost.http.wsgi.""" + +from __future__ import annotations + +import threading + +import httpx + +from localpost.http import wrap_wsgi + + +class TestWrapWSGI: + def test_simple_200(self, serve_in_thread): + def app(environ, start_response): + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "5")]) + return [b"hello"] + + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/plain" + assert resp.text == "hello" + + def test_multi_chunk_response(self, serve_in_thread): + def app(environ, start_response): + start_response("200 OK", [("Content-Type", "text/plain"), ("Transfer-Encoding", "chunked")]) + return [b"foo", b"bar", b"baz"] + + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"foobarbaz" + + def test_404(self, serve_in_thread): + def app(environ, start_response): + body = b"nope" + start_response("404 Not Found", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))]) + return [body] + + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/anything", timeout=5) + + assert resp.status_code == 404 + assert resp.text == "nope" + + def test_environ_path_and_query(self, serve_in_thread): + seen = {} + + def app(environ, start_response): + seen["method"] = environ["REQUEST_METHOD"] + seen["path"] = environ["PATH_INFO"] + seen["query"] = environ["QUERY_STRING"] + seen["content_type"] = environ.get("CONTENT_TYPE", "") + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "2")]) + return [b"ok"] + + with serve_in_thread(wrap_wsgi(app)) as port: + httpx.post( + f"http://127.0.0.1:{port}/api/items?q=1", + content=b"", + headers={"Content-Type": "application/json"}, + timeout=5, + ) + + assert seen["method"] == "POST" + assert seen["path"] == "/api/items" + assert seen["query"] == "q=1" + assert seen["content_type"] == "application/json" + + def test_environ_path_info_is_percent_decoded(self, serve_in_thread): + seen = {} + + def app(environ, start_response): + seen["path"] = environ["PATH_INFO"] + seen["query"] = environ["QUERY_STRING"] + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "2")]) + return [b"ok"] + + with serve_in_thread(wrap_wsgi(app)) as port: + httpx.get(f"http://127.0.0.1:{port}/users/al%20ice?q=a%20b", timeout=5) + + assert seen == {"path": "/users/al ice", "query": "q=a%20b"} + + def test_request_body_streaming(self, serve_in_thread): + received = bytearray() + + def app(environ, start_response): + wsgi_input = environ["wsgi.input"] + while True: + chunk = wsgi_input.read(4) + if not chunk: + break + received.extend(chunk) + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(received)))]) + return [bytes(received)] + + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.post(f"http://127.0.0.1:{port}/", content=b"hello wsgi body", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"hello wsgi body" + assert bytes(received) == b"hello wsgi body" + + def test_generator_close_called(self, serve_in_thread): + close_called = threading.Event() + + class Body: + def __init__(self): + self._chunks = iter([b"one", b"two"]) + + def __iter__(self): + return self + + def __next__(self): + return next(self._chunks) + + def close(self): + close_called.set() + + def app(environ, start_response): + start_response( + "200 OK", + [("Content-Type", "text/plain"), ("Transfer-Encoding", "chunked")], + ) + return Body() + + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"onetwo" + assert close_called.is_set() diff --git a/tests/http/wsgi_to.py b/tests/http/wsgi_to.py new file mode 100644 index 0000000..e93bee1 --- /dev/null +++ b/tests/http/wsgi_to.py @@ -0,0 +1,363 @@ +"""Tests for ``localpost.http.wsgi.to_wsgi`` — serving a localpost +``RequestHandler`` under a WSGI server. + +Drives the WSGI app directly (no real WSGI server needed); asserts on the +captured ``start_response`` arguments and the returned body iterable. +""" + +from __future__ import annotations + +import io +from collections.abc import Iterable, Iterator +from typing import Any + +from localpost.http import ( + HTTPReqCtx, + Response, + Routes, + read_body, + to_wsgi, +) + +# -------------------------------------------------------------------------- +# Helpers — drive the WSGI app and capture the response. +# -------------------------------------------------------------------------- + + +def _base_environ( + *, + method: str = "GET", + path: str = "/", + query: str = "", + headers: dict[str, str] | None = None, + body: bytes = b"", + server: tuple[str, str] = ("localhost", "8000"), + client: tuple[str, str] | None = ("203.0.113.4", "54321"), + scheme: str = "http", + file_wrapper: bool = False, +) -> dict[str, Any]: + environ: dict[str, Any] = { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "QUERY_STRING": query, + "SERVER_NAME": server[0], + "SERVER_PORT": server[1], + "SERVER_PROTOCOL": "HTTP/1.1", + "wsgi.version": (1, 0), + "wsgi.url_scheme": scheme, + "wsgi.input": io.BytesIO(body), + "wsgi.errors": io.StringIO(), + "wsgi.multithread": True, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + "CONTENT_LENGTH": str(len(body)) if body else "", + "CONTENT_TYPE": "", + } + if client is not None: + environ["REMOTE_ADDR"] = client[0] + environ["REMOTE_PORT"] = client[1] + if file_wrapper: + environ["wsgi.file_wrapper"] = lambda f, blksize=8192: _IterFile(f, blksize) + if headers: + for k, v in headers.items(): + if k.lower() == "content-type": + environ["CONTENT_TYPE"] = v + elif k.lower() == "content-length": + environ["CONTENT_LENGTH"] = v + else: + environ["HTTP_" + k.upper().replace("-", "_")] = v + return environ + + +class _IterFile: + """Minimal ``wsgi.file_wrapper`` stand-in — reads in ``blksize`` chunks.""" + + def __init__(self, fileobj: Any, blksize: int) -> None: + self._f = fileobj + self._blk = blksize + + def __iter__(self) -> Iterator[bytes]: + while True: + chunk = self._f.read(self._blk) + if not chunk: + return + yield chunk + + +def _drive(app: Any, environ: dict[str, Any]) -> tuple[str, list[tuple[str, str]], bytes]: + """Invoke the WSGI app once. Returns (status_line, headers, body).""" + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter: Iterable[bytes] = app(environ, start_response) + body = b"".join(body_iter) + close = getattr(body_iter, "close", None) + if close is not None: + close() + return captured["status"], captured["headers"], body + + +# -------------------------------------------------------------------------- +# Tests +# -------------------------------------------------------------------------- + + +class TestToWsgiComplete: + def test_simple_get(self): + def handler(ctx: HTTPReqCtx): + ctx.complete( + Response(200, [(b"content-type", b"text/plain"), (b"content-length", b"5")]), + b"hello", + ) + + status, headers, body = _drive(to_wsgi(handler), _base_environ()) + assert status.startswith("200 ") + assert ("content-type", "text/plain") in headers + assert body == b"hello" + + def test_204_no_body(self): + def handler(ctx: HTTPReqCtx): + ctx.complete(Response(204, [(b"content-length", b"0")])) + + status, _headers, body = _drive(to_wsgi(handler), _base_environ()) + assert status.startswith("204 ") + assert body == b"" + + def test_request_method_path_query(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["method"] = ctx.request.method + seen["path"] = ctx.request.path + seen["query"] = ctx.request.query_string + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(method="POST", path="/items/42", query="x=1&y=2") + _drive(to_wsgi(handler), environ) + assert seen["method"] == b"POST" + assert seen["path"] == b"/items/42" + assert seen["query"] == b"x=1&y=2" + + def test_request_body_lazy_via_read_body(self): + seen: dict[str, bytes] = {} + + def handler(ctx: HTTPReqCtx): + seen["body"] = read_body(ctx) + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ( + method="POST", + body=b'{"hello":"world"}', + headers={"Content-Type": "application/json"}, + ) + _drive(to_wsgi(handler), environ) + assert seen["body"] == b'{"hello":"world"}' + + def test_headers_lowercased_and_passed_through(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["headers"] = dict(ctx.request.headers) + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ( + headers={ + "Content-Type": "application/json", + "Content-Length": "0", + "Authorization": "Bearer abc", + "X-Trace-Id": "t-1", + } + ) + _drive(to_wsgi(handler), environ) + h = seen["headers"] + assert h[b"content-type"] == b"application/json" + assert h[b"authorization"] == b"Bearer abc" + assert h[b"x-trace-id"] == b"t-1" + + def test_remote_addr_local_addr_scheme(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["remote"] = ctx.remote_addr + seen["local"] = ctx.local_addr + seen["scheme"] = ctx.scheme + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(server=("api.example.com", "443"), scheme="https") + _drive(to_wsgi(handler), environ) + assert seen["remote"] == "203.0.113.4:54321" + assert seen["local"] == "api.example.com:443" + assert seen["scheme"] == "https" + + def test_remote_addr_none_when_unknown(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["remote"] = ctx.remote_addr + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(client=None) + _drive(to_wsgi(handler), environ) + assert seen["remote"] is None + + def test_disconnected_always_false(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["disconnected"] = ctx.disconnected + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ() + _drive(to_wsgi(handler), environ) + # WSGI has no socket handle — disconnects surface as BrokenPipeError + # from the host's per-chunk write, not as a poll signal here. + assert seen["disconnected"] is False + + +class TestToWsgiStream: + def test_stream_iterator_handed_off_lazily(self): + consumed: list[int] = [] + + def gen() -> Iterator[bytes]: + for i in range(3): + consumed.append(i) + yield f"chunk-{i}\n".encode() + + def handler(ctx: HTTPReqCtx): + ctx.stream( + Response(200, [(b"content-type", b"text/event-stream")]), + gen(), + ) + + environ = _base_environ() + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter = to_wsgi(handler)(environ, start_response) + # Lazy: nothing produced yet beyond what to_wsgi materialised. + assert consumed == [] + # Drain: WSGI server pacing. + chunks = list(body_iter) + assert consumed == [0, 1, 2] + assert b"".join(chunks) == b"chunk-0\nchunk-1\nchunk-2\n" + assert captured["status"].startswith("200 ") + + +class TestToWsgiSendfile: + def test_sendfile_with_file_wrapper(self, tmp_path): + path = tmp_path / "f.txt" + path.write_bytes(b"abcdefghij") + + def handler(ctx: HTTPReqCtx): + f = path.open("rb") + ctx.sendfile( + Response(200, [(b"content-length", b"4")]), + f, + offset=2, + count=4, + ) + + status, _headers, body = _drive(to_wsgi(handler), _base_environ(file_wrapper=True)) + assert status.startswith("200 ") + assert body == b"cdef" + + def test_sendfile_without_file_wrapper_falls_back_to_chunks(self, tmp_path): + path = tmp_path / "f.txt" + path.write_bytes(b"abcdefghij") + + def handler(ctx: HTTPReqCtx): + f = path.open("rb") + ctx.sendfile( + Response(200, [(b"content-length", b"5")]), + f, + offset=0, + count=5, + ) + + status, _headers, body = _drive(to_wsgi(handler), _base_environ(file_wrapper=False)) + assert status.startswith("200 ") + assert body == b"abcde" + + +class TestToWsgiRouter: + def test_router_dispatch_404(self): + routes = Routes() + + @routes.get("/known") + def known(ctx: HTTPReqCtx): + ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") + + app = routes.build().as_wsgi() + status, _headers, body = _drive(app, _base_environ(path="/missing")) + assert status.startswith("404 ") + assert body == b"Not Found" + + def test_router_dispatch_405(self): + routes = Routes() + + @routes.get("/x") + def get_x(ctx: HTTPReqCtx): + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + app = routes.build().as_wsgi() + status, headers, _body = _drive(app, _base_environ(method="POST", path="/x")) + assert status.startswith("405 ") + assert ("Allow", "GET") in headers + + def test_router_match_with_path_args(self): + routes = Routes() + + @routes.get("/items/{id}") + def get_item(ctx: HTTPReqCtx): + from localpost.http.router import route_match + + match = route_match(ctx) + body = match.path_args["id"].encode() + ctx.complete(Response(200, [(b"content-length", str(len(body)).encode())]), body) + + app = routes.build().as_wsgi() + status, _headers, body = _drive(app, _base_environ(path="/items/42")) + assert status.startswith("200 ") + assert body == b"42" + + +class TestToWsgiBorrow: + def test_borrowed_always_true(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["borrowed"] = ctx.borrowed + with ctx.borrow() as inner: + seen["inner"] = inner is ctx + seen["borrowed_inside"] = ctx.borrowed + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + _drive(to_wsgi(handler), _base_environ()) + assert seen["borrowed"] is True + assert seen["borrowed_inside"] is True + assert seen["inner"] is True + + +class TestToWsgiReceive: + def test_receive_slices_buffered_body(self): + chunks: list[bytes] = [] + + def handler(ctx: HTTPReqCtx): + while True: + c = ctx.receive(4) + if not c: + break + chunks.append(c) + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(method="POST", body=b"abcdefghij") + _drive(to_wsgi(handler), environ) + assert chunks == [b"abcd", b"efgh", b"ij"] diff --git a/tests/openapi/__init__.py b/tests/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/openapi/aio_app.py b/tests/openapi/aio_app.py new file mode 100644 index 0000000..9135aab --- /dev/null +++ b/tests/openapi/aio_app.py @@ -0,0 +1,586 @@ +"""Tests for ``localpost.openapi.HttpAsyncApp`` registration + ASGI dispatch. + +Two layers of coverage: + +1. **Operation-level**: feed an :class:`AsyncOperation` a fake + :class:`AsyncHTTPReqCtx`, run it, inspect the captured response. + Mirrors ``tests/openapi/app.py``'s sync ``run_op`` harness. +2. **ASGI-level**: drive the ASGI 3 callable from + :meth:`HttpAsyncApp.asgi` via :class:`httpx.ASGITransport` — same + surface a real ASGI server would call against, but in-process and + without binding sockets. End-to-end tests under a real uvicorn live + in :mod:`tests.openapi.aio_integration` (marked ``integration``). +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from http import HTTPMethod +from typing import Annotated, Any + +import httpx +import pytest + +from localpost.http import RouteMatch, URITemplate +from localpost.http._types import Request, Response +from localpost.openapi import ( + AsyncHttpBearerAuth, + AsyncHTTPReqCtx, + AsyncOpMiddleware, + BadRequest, + Created, + FromHeader, + HttpApp, + HttpAsyncApp, + NotFound, + OpResult, + async_op_middleware, +) +from localpost.openapi.aio.middleware import AsyncApiOperation +from localpost.openapi.aio.operation import AsyncOperation + +# --- Fakes --------------------------------------------------------------- + + +@dataclass(slots=True, eq=False) +class FakeAsyncCtx: + """Minimal async ctx for unit tests — captures the response.""" + + request: Request + _body_chunks: list[bytes] = field(default_factory=list) + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + completed: tuple[Response, bytes] | None = None + streamed: tuple[Response, list[bytes]] | None = None + + @property + def remote_addr(self) -> str | None: + return None + + @property + def local_addr(self) -> str: + return "127.0.0.1:0" + + @property + def scheme(self) -> str: + return "http" + + @property + def disconnected(self) -> bool: + return False + + async def complete(self, response: Response, body: bytes | None = None) -> None: + self.completed = (response, body or b"") + self.response_status = response.status_code + + async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: + captured: list[bytes] = [bytes(chunk) async for chunk in chunks] + self.streamed = (response, captured) + self.response_status = response.status_code + + async def receive(self, _size: int = 65536, /) -> bytes: + if not self._body_chunks: + return b"" + return self._body_chunks.pop(0) + + async def sendfile(self, response: Response, file, offset: int, count: int) -> None: + # Test fake — sendfile isn't exercised in this suite. + raise NotImplementedError + + +def make_async_ctx( + method: str = "GET", + path: str = "/", + query: bytes = b"", + headers: list[tuple[bytes, bytes]] | None = None, + body: bytes = b"", + path_args: dict[str, str] | None = None, + template: URITemplate | None = None, +) -> FakeAsyncCtx: + request = Request( + method=method.encode(), + target=path.encode(), + path=path.encode(), + query_string=query, + headers=headers or [], + http_version=b"1.1", + ) + ctx = FakeAsyncCtx(request=request, _body_chunks=[body] if body else []) + if path_args is not None: + ctx.attrs[RouteMatch] = RouteMatch( + method=HTTPMethod(method), + matched_template=template or URITemplate.parse(path), + path_args=dict(path_args), + ) + return ctx + + +async def run_async_op(op: AsyncOperation, ctx: FakeAsyncCtx) -> tuple[int, bytes, dict[str, str]]: + await op.run(ctx) + assert ctx.completed is not None, "handler did not complete the response" + response, body = ctx.completed + headers = {k.decode(): v.decode("iso-8859-1") for k, v in response.headers} + return response.status_code, body, headers + + +# --- Sample types -------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +# --- Registration & basic dispatch -------------------------------------- + + +class TestRegistration: + def test_async_handler_records_operation(self): + app = HttpAsyncApp() + + @app.get("/foo") + async def foo() -> str: + return "ok" + + assert len(app.operations) == 1 + op = app.operations[0] + assert op.method is HTTPMethod.GET + assert op.path == "/foo" + + def test_sync_handler_rejected(self): + app = HttpAsyncApp() + + with pytest.raises(TypeError, match="must be ``async def``"): + + @app.get("/foo") + def foo() -> str: + return "ok" + + def test_sync_middleware_rejected_at_construction(self): + from localpost.openapi import OpMiddleware + from localpost.openapi.middleware import _FunctionMiddleware, op_middleware + + @op_middleware + def sync_mw(ctx, call_next) -> OpResult: # type: ignore[no-untyped-def] + return call_next(ctx) + + assert isinstance(sync_mw, _FunctionMiddleware) + assert isinstance(sync_mw, OpMiddleware) + + with pytest.raises(TypeError, match="not an AsyncOpMiddleware"): + HttpAsyncApp(middlewares=[sync_mw]) # ty: ignore[invalid-argument-type] + + def test_async_middleware_accepted(self): + @async_op_middleware + async def mw(ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation) -> OpResult: + return await call_next(ctx) + + assert isinstance(mw, AsyncOpMiddleware) + app = HttpAsyncApp(middlewares=[mw]) + assert len(app._middlewares) == 1 + + +# --- Operation runtime -------------------------------------------------- + + +class TestRuntime: + @pytest.mark.anyio + async def test_str_return_emits_text(self): + app = HttpAsyncApp() + + @app.get("/hi") + async def hi() -> str: + return "hello" + + op = app.operations[0] + status, body, headers = await run_async_op(op, make_async_ctx(path="/hi")) + assert status == 200 + assert body == b"hello" + assert headers["content-type"].startswith("text/plain") + + @pytest.mark.anyio + async def test_dataclass_return_emits_json(self): + app = HttpAsyncApp() + + @app.get("/b") + async def get() -> Book: + return Book(id="1", title="t") + + op = app.operations[0] + status, body, headers = await run_async_op(op, make_async_ctx(path="/b")) + assert status == 200 + assert body == b'{"id":"1","title":"t"}' + assert headers["content-type"].startswith("application/json") + + @pytest.mark.anyio + async def test_path_var_implicit(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: str) -> str: + return item_id + + op = app.operations[0] + ctx = make_async_ctx(path="/items/abc", path_args={"item_id": "abc"}) + status, body, _ = await run_async_op(op, ctx) + assert status == 200 + assert body == b"abc" + + @pytest.mark.anyio + async def test_query_param_typed(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: str, page: int = 1) -> str: + return f"{item_id}/{page}" + + op = app.operations[0] + ctx = make_async_ctx(path="/items/x", query=b"page=3", path_args={"item_id": "x"}) + status, body, _ = await run_async_op(op, ctx) + assert status == 200 + assert body == b"x/3" + + @pytest.mark.anyio + async def test_body_decode_dataclass(self): + app = HttpAsyncApp() + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + op = app.operations[0] + ctx = make_async_ctx(method="POST", path="/b", body=b'{"id":"1","title":"t"}') + status, body, _ = await run_async_op(op, ctx) + assert status == 201 + assert body == b'{"id":"1","title":"t"}' + + @pytest.mark.anyio + async def test_op_result_short_circuit(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: str) -> Book | NotFound[str]: + return NotFound(f"missing {item_id}") + + op = app.operations[0] + ctx = make_async_ctx(path="/items/x", path_args={"item_id": "x"}) + status, body, _ = await run_async_op(op, ctx) + assert status == 404 + assert body == b"missing x" + + @pytest.mark.anyio + async def test_resolver_validation_error_short_circuits(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: int) -> str: + return str(item_id) + + op = app.operations[0] + ctx = make_async_ctx(path="/items/x", path_args={"item_id": "x"}) + status, _, _ = await run_async_op(op, ctx) + assert status == 400 + + @pytest.mark.anyio + async def test_async_middleware_short_circuits(self): + @async_op_middleware + async def block( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + x_block: Annotated[str, FromHeader("X-Block")] = "", + ) -> BadRequest[str] | OpResult: + if x_block == "yes": + return BadRequest("blocked by mw") + return await call_next(ctx) + + app = HttpAsyncApp(middlewares=[block]) + + @app.get("/x") + async def x() -> str: + return "ok" + + op = app.operations[0] + ctx = make_async_ctx(path="/x", headers=[(b"x-block", b"yes")]) + status, body, _ = await run_async_op(op, ctx) + assert status == 400 + assert body == b"blocked by mw" + + @pytest.mark.anyio + async def test_async_middleware_passthrough(self): + @async_op_middleware + async def passthrough(ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation) -> OpResult: + return await call_next(ctx) + + app = HttpAsyncApp(middlewares=[passthrough]) + + @app.get("/x") + async def x() -> str: + return "ok" + + op = app.operations[0] + status, body, _ = await run_async_op(op, make_async_ctx(path="/x")) + assert status == 200 + assert body == b"ok" + + +# --- SSE ----------------------------------------------------------------- + + +class TestSSE: + @pytest.mark.anyio + async def test_async_generator_streams_events(self): + app = HttpAsyncApp() + + @app.get("/events") + async def events() -> AsyncIterator[str]: + for i in range(3): + yield f"tick-{i}" + + op = app.operations[0] + ctx = make_async_ctx(path="/events") + await op.run(ctx) + assert ctx.streamed is not None + response, chunks = ctx.streamed + assert response.status_code == 200 + ct = next(v for k, v in response.headers if k == b"content-type") + assert ct.startswith(b"text/event-stream") + wire = b"".join(chunks) + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire + + @pytest.mark.anyio + async def test_sync_generator_rejected(self): + app = HttpAsyncApp() + + @app.get("/bad") + async def bad(): + def sync_gen(): + yield "x" + + return sync_gen() + + op = app.operations[0] + with pytest.raises(TypeError, match="sync iterator/generator"): + await op.run(make_async_ctx(path="/bad")) + + +# --- OpenAPI doc parity -------------------------------------------------- + + +class TestSpecParity: + def test_async_app_doc_matches_sync_for_same_routes(self): + # Same routes registered on both flavours should produce the same + # operationId / responses / parameters in the OpenAPI doc. + sync_app = HttpApp() + async_app = HttpAsyncApp() + + @sync_app.get("/items/{item_id}") + def get_item_sync(item_id: str) -> Book | NotFound[str]: + return NotFound("nope") + + @async_app.get("/items/{item_id}") + async def get_item_async(item_id: str) -> Book | NotFound[str]: + return NotFound("nope") + + sync_doc = sync_app.openapi_doc + async_doc = async_app.openapi_doc + s_op = sync_doc.paths["/items/{item_id}"].operations["get"] + a_op = async_doc.paths["/items/{item_id}"].operations["get"] + assert sorted(s_op.responses) == sorted(a_op.responses) + # ``parameters`` may contain ``Reference`` per OpenAPI; the test only + # uses inline ``Parameter`` instances, so a getattr fallback is safe. + assert [getattr(p, "name", "") for p in s_op.parameters] == [getattr(p, "name", "") for p in a_op.parameters] + + +# --- ASGI dispatch ------------------------------------------------------- + + +def _asgi_client(app: HttpAsyncApp) -> httpx.AsyncClient: + """Build an httpx AsyncClient bound to ``app``'s ASGI callable. + + ``ASGITransport`` drives the app in-process — no socket, no event loop + bridge. Wraps the same surface a real ASGI server would call against, + so this is closer to "real" than the mocked-scope helper that lived + here before. + """ + asgi_app: Any = app.asgi() + return httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi_app), base_url="http://testserver") + + +class TestAsgiDispatch: + @pytest.mark.anyio + async def test_simple_get(self): + app = HttpAsyncApp() + + @app.get("/hello/{name}") + async def hello(name: str) -> str: + return f"hi {name}" + + async with _asgi_client(app) as client: + resp = await client.get("/hello/world") + assert resp.status_code == 200 + assert resp.text == "hi world" + + @pytest.mark.anyio + async def test_404(self): + app = HttpAsyncApp() + + async with _asgi_client(app) as client: + resp = await client.get("/nope") + assert resp.status_code == 404 + assert resp.text == "Not Found" + + @pytest.mark.anyio + async def test_405_method_not_allowed(self): + app = HttpAsyncApp() + + @app.get("/foo") + async def foo() -> str: + return "ok" + + async with _asgi_client(app) as client: + resp = await client.post("/foo") + assert resp.status_code == 405 + assert "GET" in resp.headers["allow"] + + @pytest.mark.anyio + async def test_post_with_body(self): + app = HttpAsyncApp() + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + async with _asgi_client(app) as client: + resp = await client.post("/b", content=b'{"id":"1","title":"t"}') + assert resp.status_code == 201 + assert resp.content == b'{"id":"1","title":"t"}' + + @pytest.mark.anyio + async def test_openapi_endpoint(self): + app = HttpAsyncApp() + + @app.get("/x") + async def x() -> str: + return "ok" + + async with _asgi_client(app) as client: + resp = await client.get("/openapi.json") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + assert '"openapi"' in resp.text + + @pytest.mark.anyio + async def test_docs_endpoint(self): + app = HttpAsyncApp() + async with _asgi_client(app) as client: + resp = await client.get("/docs") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/html") + assert "swagger" in resp.text.lower() + + @pytest.mark.anyio + async def test_payload_too_large(self): + app = HttpAsyncApp(max_body_size=8) + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + async with _asgi_client(app) as client: + resp = await client.post("/b", content=b'{"id":"1","title":"way-too-long"}') + assert resp.status_code == 413 + + @pytest.mark.anyio + async def test_sse_round_trip(self): + """Real httpx round-trip over an SSE stream — the mocked helper + could only inspect the captured chunks, this drives the actual + chunked-response machinery in httpx.""" + app = HttpAsyncApp() + + @app.get("/events") + async def events() -> AsyncIterator[str]: + for i in range(3): + yield f"tick-{i}" + + async with _asgi_client(app) as client: + async with client.stream("GET", "/events") as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + wire = b"" + async for chunk in resp.aiter_bytes(): + wire += chunk + # Each event is "data: \n\n" — three of them. + assert wire.count(b"\n\n") == 3 + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire + + +# --- Async auth --------------------------------------------------------- + + +class TestAsyncAuth: + @pytest.mark.anyio + async def test_bearer_valid_token(self): + bearer = AsyncHttpBearerAuth(validator=lambda t: {"sub": t} if t == "good" else None) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_async_ctx(path="/me", headers=[(b"authorization", b"Bearer good")]) + status, body, _ = await run_async_op(op, ctx) + assert status == 200 + assert body == b"hello" + + @pytest.mark.anyio + async def test_bearer_invalid_token(self): + bearer = AsyncHttpBearerAuth(validator=lambda _t: None) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_async_ctx(path="/me", headers=[(b"authorization", b"Bearer bad")]) + status, _, _ = await run_async_op(op, ctx) + assert status == 401 + + @pytest.mark.anyio + async def test_bearer_async_validator(self): + async def validate(t: str) -> dict[str, str] | None: + return {"sub": t} if t == "good" else None + + bearer = AsyncHttpBearerAuth(validator=validate) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_async_ctx(path="/me", headers=[(b"authorization", b"Bearer good")]) + status, _, _ = await run_async_op(op, ctx) + assert status == 200 + + def test_bearer_registers_security_scheme(self): + bearer = AsyncHttpBearerAuth(validator=lambda _t: None) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hi" + + doc = app.openapi_doc + assert "bearerAuth" in doc.components.security_schemes + op_spec = doc.paths["/me"].operations["get"] + assert {"bearerAuth": ()} in op_spec.security + + +# Ctx-level unit tests live in tests/http/asgi.py — that's where the +# ``_ASGIReqCtx`` implementation lives now (moved out of localpost.openapi.aio). diff --git a/tests/openapi/aio_integration.py b/tests/openapi/aio_integration.py new file mode 100644 index 0000000..9851825 --- /dev/null +++ b/tests/openapi/aio_integration.py @@ -0,0 +1,266 @@ +"""End-to-end HTTP tests against ``HttpAsyncApp`` deployed under uvicorn. + +The unit suite (``aio_app.py``) drives the ASGI callable directly via +``httpx.ASGITransport`` — fast, no socket. These tests run the full stack +under a real uvicorn instance to catch what in-process drivers can't: + +- Header round-trip through h11 + uvicorn. +- ASGI lifespan negotiation. +- Real chunked SSE delivery (per-event flushing on the wire). +- Peer-disconnect mid-stream. + +uvicorn runs in a background thread per-test (its own asyncio loop); the +test reaches it over loopback. Mirrors ``tests/openapi/conftest.py``'s +sync ``serve_app`` shape so the fixtures read alike. +""" + +from __future__ import annotations + +import asyncio +import socket +import threading +import time +from collections.abc import AsyncIterator, Callable, Iterator +from dataclasses import dataclass + +import httpx +import pytest +import uvicorn + +from localpost.openapi import ( + BadRequest, + Created, + HttpAsyncApp, + NotFound, + spec, +) + +# Keep these tests off the unit run; they cost ~1-2s each. +pytestmark = pytest.mark.integration + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _UvicornThread: + """Run uvicorn in a daemon thread, surface its port to the test. + + uvicorn drives its own asyncio loop; we just hand it the ASGI app + (``HttpAsyncApp.asgi()``) and a free port. ``__enter__`` blocks until + the server is accepting connections, ``__exit__`` triggers a graceful + shutdown. + """ + + def __init__(self, app: HttpAsyncApp, port: int) -> None: + self._asgi = app.asgi() + self._port = port + self._server: uvicorn.Server | None = None + self._thread: threading.Thread | None = None + self._error: BaseException | None = None + + def __enter__(self) -> int: + config = uvicorn.Config( + app=self._asgi, + host="127.0.0.1", + port=self._port, + log_level="warning", + access_log=False, + loop="asyncio", + lifespan="on", + ) + server = uvicorn.Server(config) + self._server = server + + def run() -> None: + try: + asyncio.run(server.serve()) + except BaseException as e: # noqa: BLE001 + self._error = e + + self._thread = threading.Thread(target=run, daemon=True, name=f"uvicorn-{self._port}") + self._thread.start() + # Wait for "started" — uvicorn flips ``server.started`` once the + # socket is listening. Bound to ~5s so a stuck startup fails fast. + deadline = time.monotonic() + 5.0 + while not server.started and time.monotonic() < deadline: + if self._error is not None: + raise self._error + time.sleep(0.01) + if not server.started: + raise RuntimeError("uvicorn failed to start within 5s") + return self._port + + def __exit__(self, *_exc: object) -> None: + if self._server is not None: + self._server.should_exit = True + if self._thread is not None: + self._thread.join(timeout=5) + if self._error is not None: + raise self._error + + +ServeAsyncApp = Callable[[HttpAsyncApp], _UvicornThread] + + +@pytest.fixture +def serve_async_app() -> Iterator[ServeAsyncApp]: + active: list[_UvicornThread] = [] + + def make(app: HttpAsyncApp) -> _UvicornThread: + return _UvicornThread(app, _free_port()) + + yield make + + # If a test forgot to ``__exit__``, drain anything still in-flight. + for t in active: + if t._server is not None: + t._server.should_exit = True + if t._thread is not None and t._thread.is_alive(): + t._thread.join(timeout=5) + + +# --- Sample app ---------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +@pytest.fixture +def library_app() -> HttpAsyncApp: + app = HttpAsyncApp(info=spec.Info(title="Library API", version="1.0.0")) + library: dict[str, Book] = {"42": Book(id="42", title="HHGTTG")} + + @app.get("/hello/{name}") + async def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + async def get_book(book_id: str) -> Book | NotFound[str]: + book = library.get(book_id) + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + @app.post("/books") + async def create_book(book: Book) -> Created[Book]: + library[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + @app.get("/clock") + async def clock() -> AsyncIterator[str]: + # Three quick events for SSE round-trip / peer-disconnect tests. + # Cap the loop so a hung client doesn't keep the worker alive. + for i in range(3): + yield f"tick-{i}" + await asyncio.sleep(0.05) + + _ = (hello, get_book, create_book, clock) + return app + + +# --- Tests --------------------------------------------------------------- + + +class TestRoundTrip: + def test_hello_world(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + assert resp.status_code == 200 + assert resp.text == "Hello, world!" + assert resp.headers["content-type"].startswith("text/plain") + + def test_op_result_404_branch(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/missing", timeout=5) + assert resp.status_code == 404 + assert resp.text == "Book not found: missing" + + def test_post_json_round_trip(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + json={"id": "7", "title": "Dune"}, + timeout=5, + ) + assert resp.status_code == 201 + assert resp.headers["Location"] == "/books/7" + assert resp.json() == {"id": "7", "title": "Dune"} + + +class TestBuiltInRoutes: + def test_openapi_json(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + doc = resp.json() + assert doc["openapi"].startswith("3.2") + assert "/hello/{name}" in doc["paths"] + assert "Book" in doc["components"]["schemas"] + + def test_swagger_ui(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs", timeout=5) + assert resp.status_code == 200 + assert b"Swagger UI" in resp.content + + +class TestSSE: + def test_chunked_event_delivery(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + """Exercise the wire: each event must arrive as a separate + ``\\n\\n``-terminated block; ``content-length`` must be absent + (chunked transfer).""" + with serve_async_app(library_app) as port: + with httpx.stream("GET", f"http://127.0.0.1:{port}/clock", timeout=5) as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + assert "content-length" not in resp.headers + wire = b"".join(resp.iter_bytes()) + assert wire.count(b"\n\n") == 3 + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire + + def test_peer_disconnect_mid_stream(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + """Close the response stream after one event; the server-side + generator should be cancelled (no leaked tasks, server stays + responsive for the next request).""" + with serve_async_app(library_app) as port: + base = f"http://127.0.0.1:{port}" + # Read just one event then disconnect. + with httpx.stream("GET", f"{base}/clock", timeout=5) as resp: + assert resp.status_code == 200 + iterator: Iterator[bytes] = resp.iter_bytes() + first = next(iterator) + assert b"data: tick-0" in first + # Closing the response forces the underlying connection + # closed → uvicorn raises http.disconnect to the app. + resp.close() + # Sanity: the server is still alive for a follow-up request. + resp = httpx.get(f"{base}/hello/world", timeout=5) + assert resp.status_code == 200 + + +class TestPayloadLimits: + def test_413_on_oversized_body(self, serve_async_app: ServeAsyncApp) -> None: + app = HttpAsyncApp(max_body_size=8) + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + with serve_async_app(app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/b", + content=b'{"id":"1","title":"way-too-long"}', + headers={"content-type": "application/json"}, + timeout=5, + ) + assert resp.status_code == 413 diff --git a/tests/openapi/aio_rsgi_integration.py b/tests/openapi/aio_rsgi_integration.py new file mode 100644 index 0000000..7819d4e --- /dev/null +++ b/tests/openapi/aio_rsgi_integration.py @@ -0,0 +1,255 @@ +"""End-to-end RSGI tests under a real Granian server. + +The unit suites (``tests/http/rsgi.py`` and ``tests/hosting/rsgi.py``) +drive the bridge with a mocked proto. These tests run the full stack +under :class:`granian.server.embed.Server` to catch what in-process +mocks miss — header round-trip through the real RSGI implementation, +chunked SSE delivery, and Mode B (host-as-RSGI) with a background +service running alongside the HTTP handler. + +Each test spins Granian on a free loopback port for the duration of +the test, then shuts it down cleanly. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import socket +import threading +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import httpx +import pytest +from granian.constants import Interfaces +from granian.server.embed import Server + +from localpost import hosting +from localpost.hosting.rsgi import HostRSGIApp +from localpost.openapi import ( + BadRequest, + Created, + HttpAsyncApp, + NotFound, + spec, +) + +# These tests cost ~1-2s each; keep them off the unit run. +pytestmark = pytest.mark.integration + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _GranianThread: + """Run :class:`granian.server.embed.Server` on its own asyncio loop + in a daemon thread — same shape as the uvicorn fixture in + ``aio_integration.py``.""" + + def __init__(self, target: object, port: int) -> None: + self._target = target + self._port = port + self._loop: asyncio.AbstractEventLoop | None = None + self._serve_task: asyncio.Task[None] | None = None + self._server: Server | None = None + self._thread: threading.Thread | None = None + self._ready = threading.Event() + + def __enter__(self) -> int: + def run() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + + self._server = Server( + target=self._target, + address="127.0.0.1", + port=self._port, + interface=Interfaces.RSGI, + log_enabled=False, + log_access=False, + ) + + async def serve_then_signal() -> None: + # Wait briefly for the server to bind, then mark ready. + # Granian's embed Server doesn't expose a "started" event, + # so we poll the port instead. + pass + + try: + self._serve_task = loop.create_task(self._server.serve()) + self._ready.set() + loop.run_until_complete(self._serve_task) + except Exception: # noqa: BLE001 + self._ready.set() + finally: + loop.close() + + self._thread = threading.Thread(target=run, daemon=True, name=f"granian-{self._port}") + self._thread.start() + self._ready.wait(timeout=5) + # Wait for the port to actually be accepting connections. + deadline = asyncio.get_event_loop_policy().new_event_loop().time() + 5.0 + while True: + try: + with socket.create_connection(("127.0.0.1", self._port), timeout=0.1): + break + except OSError: + if asyncio.get_event_loop_policy().new_event_loop().time() > deadline: + raise RuntimeError("Granian failed to start within 5s") from None + threading.Event().wait(0.05) + return self._port + + def __exit__(self, *_exc: object) -> None: + loop = self._loop + srv = self._server + if loop is not None and srv is not None: + with contextlib.suppress(Exception): + fut = asyncio.run_coroutine_threadsafe(srv.shutdown(), loop) + fut.result(timeout=5) + with contextlib.suppress(Exception): + loop.call_soon_threadsafe(loop.stop) + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=5) + + +# --- Sample app ---------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +def _library_app() -> HttpAsyncApp: + app = HttpAsyncApp(info=spec.Info(title="Library API", version="1.0.0")) + library: dict[str, Book] = {"42": Book(id="42", title="HHGTTG")} + + @app.get("/hello/{name}") + async def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + async def get_book(book_id: str) -> Book | NotFound[str]: + book = library.get(book_id) + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + @app.post("/books") + async def create_book(book: Book) -> Created[Book]: + library[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + _ = (hello, get_book, create_book) + return app + + +# --- Tests --------------------------------------------------------------- + + +class TestModeARoundTrip: + """``HttpAsyncApp.as_rsgi()`` deployed under Granian.""" + + def test_hello_world(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + assert resp.status_code == 200 + assert resp.text == "Hello, world!" + + def test_op_result_404(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/missing", timeout=5) + assert resp.status_code == 404 + assert resp.text == "Book not found: missing" + + def test_post_json(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + json={"id": "7", "title": "Dune"}, + timeout=5, + ) + assert resp.status_code == 201 + assert resp.headers["Location"] == "/books/7" + assert resp.json() == {"id": "7", "title": "Dune"} + + def test_openapi_endpoint(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + assert resp.status_code == 200 + doc = resp.json() + assert doc["openapi"].startswith("3.2") + assert "/hello/{name}" in doc["paths"] + + +class TestModeBHostedApp: + """``HostRSGIApp`` — background service runs in the same Granian + worker as the HTTP handler.""" + + def test_background_service_runs_alongside_handler(self) -> None: + # A heartbeat service that ticks while the worker is up. + ticks: list[float] = [] + ticks_lock = threading.Lock() + + @hosting.service + async def heartbeat(sl: hosting.ServiceLifetime) -> None: + sl.set_started() + with contextlib.suppress(Exception): + while not sl.shutting_down.is_set(): + with ticks_lock: + ticks.append(0.0) + # Don't actually sleep on real time — just yield enough + # for a few ticks during the test. + await asyncio.sleep(0.05) + + app = _library_app() + rsgi_app = HostRSGIApp( + services=[heartbeat()], + rsgi_handler=app, + ) + + with _GranianThread(rsgi_app, _free_port()) as port: + # Hit the HTTP side a couple of times. + r1 = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + r2 = httpx.get(f"http://127.0.0.1:{port}/books/42", timeout=5) + + assert r1.status_code == 200 + assert r2.status_code == 200 + # The background service ticked at least once during the test. + with ticks_lock: + assert len(ticks) >= 1, "heartbeat service did not tick" + + +class TestSSE: + """Real chunked SSE delivery through Granian's response_stream path.""" + + def test_event_stream_round_trip(self) -> None: + app = HttpAsyncApp(openapi_path=None, docs_path=None) + + @app.get("/clock") + async def clock() -> AsyncIterator[str]: + for i in range(3): + yield f"tick-{i}" + await asyncio.sleep(0.05) + + _ = clock + with _GranianThread(app.as_rsgi(), _free_port()) as port: + with httpx.stream("GET", f"http://127.0.0.1:{port}/clock", timeout=5) as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + wire = b"".join(resp.iter_bytes()) + assert wire.count(b"\n\n") == 3 + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire diff --git a/tests/openapi/app.py b/tests/openapi/app.py new file mode 100644 index 0000000..a61e680 --- /dev/null +++ b/tests/openapi/app.py @@ -0,0 +1,472 @@ +"""Tests for ``localpost.openapi.HttpApp`` registration + dispatch. + +Operation handlers are exercised in isolation: we hand each ``Operation`` +a fake :class:`HTTPReqCtx`-like object, run it, and inspect the captured +``Response`` + body bytes. The full HTTP server path is covered by the +integration tests. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from http import HTTPMethod +from typing import Annotated, Any + +import msgspec +import pytest + +from localpost.http import RouteMatch, URITemplate +from localpost.http._types import Request, Response +from localpost.openapi import ( + BadRequest, + Created, + FromHeader, + FromPath, + HttpApp, + NoContent, + NotFound, +) +from localpost.openapi.operation import Operation + +# --- Fakes --------------------------------------------------------------- + + +@dataclass +class FakeCtx: + """Minimal stand-in for :class:`HTTPReqCtx` for unit tests.""" + + request: Request + _body_chunks: list[bytes] = field(default_factory=list) + attrs: dict[Any, Any] = field(default_factory=dict) + completed: tuple[Response, bytes] | None = None + + def complete(self, response: Response, body: bytes = b"") -> None: + self.completed = (response, body) + + def receive(self, _size: int = 0, /) -> bytes: + if not self._body_chunks: + return b"" + return self._body_chunks.pop(0) + + +def make_ctx( + method: str = "GET", + path: str = "/", + query: bytes = b"", + headers: list[tuple[bytes, bytes]] | None = None, + body: bytes = b"", + path_args: Mapping[str, str] | None = None, + template: URITemplate | None = None, +) -> FakeCtx: + request = Request( + method=method.encode(), + target=path.encode(), + path=path.encode(), + query_string=query, + headers=headers or [], + http_version=b"1.1", + ) + ctx = FakeCtx(request=request, _body_chunks=[body] if body else []) + if path_args is not None: + ctx.attrs[RouteMatch] = RouteMatch( + method=HTTPMethod(method), + matched_template=template or URITemplate.parse(path), + path_args=dict(path_args), + ) + return ctx + + +def run_op(op: Operation, ctx: FakeCtx) -> tuple[int, bytes, dict[str, str]]: + op._run(ctx) + assert ctx.completed is not None, "handler did not complete the response" + response, body = ctx.completed + headers = {k.decode(): v.decode("iso-8859-1") for k, v in response.headers} + return response.status_code, body, headers + + +# --- Sample types -------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +# Pydantic Pet lives at module scope so PEP 563 string annotations resolve via +# ``get_type_hints`` (closure cells aren't created for annotation-only refs). +try: + from pydantic import BaseModel + + class Pet(BaseModel): + name: str + age: int + +except ImportError: + Pet = None # type: ignore[assignment,misc] + + +# attrs Toy lives at module scope for the same reason: forward-ref resolution +# in ``attrs.resolve_types`` walks ``__annotations__`` against module globals. +try: + import attrs + + @attrs.define + class Toy: + name: str + weight: int + +except ImportError: + Toy = None # type: ignore[assignment,misc] + + +# --- Tests --------------------------------------------------------------- + + +class TestRegistration: + def test_get_decorator_records_operation(self): + app = HttpApp() + + @app.get("/foo") + def foo() -> str: + return "ok" + + assert len(app.operations) == 1 + op = app.operations[0] + assert op.method is HTTPMethod.GET + assert op.path == "/foo" + + def test_multiple_operations_on_same_path(self): + app = HttpApp() + + @app.get("/foo") + def get_foo() -> str: + return "g" + + @app.post("/foo") + def post_foo() -> str: + return "p" + + ops = app.operations + assert {o.method for o in ops} == {HTTPMethod.GET, HTTPMethod.POST} + + def test_path_var_resolved_implicitly(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str: + return item_id + + op = app.operations[0] + ctx = make_ctx(path="/items/abc", path_args={"item_id": "abc"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"abc" + + def test_query_param_implicit_when_not_in_path(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str, page: int = 1) -> str: + return f"{item_id}/{page}" + + op = app.operations[0] + ctx = make_ctx(path="/items/x", query=b"page=3", path_args={"item_id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"x/3" + + +class TestReturnTypeInference: + def test_str_return_emits_text_plain(self): + app = HttpApp() + + @app.get("/hi") + def hi() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx(path="/hi") + status, body, headers = run_op(op, ctx) + assert status == 200 + assert body == b"hello" + assert headers["content-type"].startswith("text/plain") + + def test_dataclass_return_emits_json(self): + app = HttpApp() + + @app.get("/b") + def get() -> Book: + return Book(id="1", title="t") + + op = app.operations[0] + status, body, headers = run_op(op, make_ctx(path="/b")) + assert status == 200 + assert msgspec.json.decode(body) == {"id": "1", "title": "t"} + assert headers["content-type"] == "application/json" + + def test_op_result_subclass_uses_its_status(self): + app = HttpApp() + + @app.get("/b/{id}") + def get(id: str) -> Book | NotFound[str]: # noqa: A002 + return NotFound(f"missing: {id}") + + op = app.operations[0] + ctx = make_ctx(path="/b/x", path_args={"id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 404 + assert body == b"missing: x" + + def test_no_content_emits_empty_body(self): + app = HttpApp() + + @app.delete("/b/{id}") + def delete(id: str) -> NoContent: # noqa: A002 + return NoContent() + + op = app.operations[0] + ctx = make_ctx(method="DELETE", path="/b/x", path_args={"id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 204 + assert body == b"" + + def test_created_with_custom_headers(self): + app = HttpApp() + + @app.post("/b") + def create() -> Created[Book]: + return Created(Book(id="1", title="t"), headers={"Location": "/b/1"}) + + op = app.operations[0] + status, body, headers = run_op(op, make_ctx(method="POST", path="/b")) + assert status == 201 + assert headers["Location"] == "/b/1" + assert msgspec.json.decode(body) == {"id": "1", "title": "t"} + + +class TestFromBody: + def test_dataclass_body_is_parsed(self): + app = HttpApp() + + @app.post("/b") + def create(book: Book) -> Book: + return book + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/b", body=b'{"id":"1","title":"X"}') + status, body, _ = run_op(op, ctx) + assert status == 200 + assert msgspec.json.decode(body) == {"id": "1", "title": "X"} + + def test_invalid_body_returns_bad_request(self): + app = HttpApp() + + @app.post("/b") + def create(book: Book) -> Book: + return book + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/b", body=b"not json") + status, body, _ = run_op(op, ctx) + assert status == 400 + assert b"Invalid request body" in body + + +class TestFromHeader: + def test_header_resolved_with_explicit_annotation(self): + app = HttpApp() + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")]) -> str: + return user_id + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"x-user-id", b"alice")]) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"alice" + + def test_missing_required_header_returns_bad_request(self): + app = HttpApp() + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")]) -> str: + return user_id + + op = app.operations[0] + ctx = make_ctx(path="/me") + status, body, _ = run_op(op, ctx) + assert status == 400 + assert b"Missing required header" in body + + def test_header_default_used_when_absent(self): + app = HttpApp() + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")] = "anon") -> str: + return user_id + + op = app.operations[0] + ctx = make_ctx(path="/me") + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"anon" + + +class TestPathTypeCoercion: + def test_int_path_arg_coerced(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get(item_id: int) -> int: + return item_id * 2 + + op = app.operations[0] + ctx = make_ctx(path="/items/21", path_args={"item_id": "21"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert msgspec.json.decode(body) == 42 + + def test_invalid_int_path_arg_returns_bad_request(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get(item_id: int) -> int: + return item_id + + op = app.operations[0] + ctx = make_ctx(path="/items/x", path_args={"item_id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 400 + assert b"Invalid path parameter" in body + + +class TestExplicitFromPath: + def test_explicit_from_path_overrides_query_default(self): + # Same param name as a path var → FromPath is implicit; this + # checks the explicit form still wires up correctly. + app = HttpApp() + + @app.get("/items/{item_id}") + def get(item_id: Annotated[str, FromPath()]) -> str: + return item_id + + op = app.operations[0] + ctx = make_ctx(path="/items/abc", path_args={"item_id": "abc"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"abc" + + +class TestOpenAPIDocCaching: + def test_doc_cached_until_new_op_registered(self): + app = HttpApp() + + @app.get("/a") + def a() -> str: + return "a" + + doc1 = app.openapi_doc + doc2 = app.openapi_doc + assert doc1 is doc2 + + @app.get("/b") + def b() -> str: + return "b" + + doc3 = app.openapi_doc + assert doc3 is not doc1 + assert "/b" in doc3.paths + + +class TestOpenAPIDocStructure: + def test_response_schemas_per_status(self): + app = HttpApp() + + @app.get("/b/{id}") + def get(id: str) -> Book | NotFound[str] | BadRequest[str]: # noqa: A002 + return Book(id=id, title="x") + + doc = app.openapi_doc.to_dict() + responses = doc["paths"]["/b/{id}"]["get"]["responses"] + assert set(responses) == {"200", "400", "404"} + assert responses["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/Book") + assert responses["404"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_request_body_schema_emitted(self): + app = HttpApp() + + @app.post("/b") + def create(book: Book) -> Book: + return book + + doc = app.openapi_doc.to_dict() + request_body = doc["paths"]["/b"]["post"]["requestBody"] + assert request_body["required"] is True + schema = request_body["content"]["application/json"]["schema"] + assert schema["$ref"].endswith("/Book") + + def test_components_populated(self): + app = HttpApp() + + @app.get("/b/{id}") + def get(id: str) -> Book: # noqa: A002 + return Book(id=id, title="x") + + doc = app.openapi_doc.to_dict() + assert "Book" in doc["components"]["schemas"] + + +class TestPydantic: + def test_pydantic_body_is_parsed_and_serialized(self): + pytest.importorskip("pydantic") + + app = HttpApp() + + @app.post("/pets") + def create(pet: Pet) -> Pet: + return pet + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/pets", body=b'{"name":"Rex","age":3}') + status, body, headers = run_op(op, ctx) + assert status == 200, body + assert msgspec.json.decode(body) == {"name": "Rex", "age": 3} + assert headers["content-type"] == "application/json" + + +class TestAttrs: + def test_attrs_body_is_parsed_and_serialized(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + app = HttpApp() + + @app.post("/toys") + def create(toy: Toy) -> Toy: + return toy + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/toys", body=b'{"name":"Bone","weight":2}') + status, body, headers = run_op(op, ctx) + assert status == 200, body + assert msgspec.json.decode(body) == {"name": "Bone", "weight": 2} + assert headers["content-type"] == "application/json" + + def test_attrs_invalid_body_is_400(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + app = HttpApp() + + @app.post("/toys") + def create(toy: Toy) -> Toy: + return toy + + op = app.operations[0] + # Missing required ``weight`` field — cattrs raises ClassValidationError. + ctx = make_ctx(method="POST", path="/toys", body=b'{"name":"Bone"}') + status, _body, _headers = run_op(op, ctx) + assert status == 400 diff --git a/tests/openapi/auth.py b/tests/openapi/auth.py new file mode 100644 index 0000000..9c94693 --- /dev/null +++ b/tests/openapi/auth.py @@ -0,0 +1,273 @@ +"""Tests for ``localpost.openapi.auth`` (HttpBearerAuth, HttpBasicAuth).""" + +from __future__ import annotations + +import base64 + +from localpost.openapi import HttpApp, HttpBasicAuth, HttpBearerAuth +from tests.openapi.app import make_ctx, run_op + + +def _basic_header(user: str, password: str) -> bytes: + return b"Basic " + base64.b64encode(f"{user}:{password}".encode()) + + +# --- HttpBearerAuth ------------------------------------------------------ + + +class TestHttpBearerAuthRuntime: + def test_valid_token_passes(self): + bearer = HttpBearerAuth(validator=lambda t: {"sub": t} if t == "good" else None) + app = HttpApp(middlewares=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"authorization", b"Bearer good")]) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"hello" + assert ctx.attrs[bearer] == {"sub": "good"} + + def test_invalid_token_returns_401(self): + bearer = HttpBearerAuth(validator=lambda _t: None) + app = HttpApp(middlewares=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"authorization", b"Bearer anything")]) + status, body, _ = run_op(op, ctx) + assert status == 401 + assert body == b"Invalid token" + + def test_missing_header_returns_401(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp(middlewares=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/me")) + assert status == 401 + assert b"Missing or malformed" in body + + def test_non_bearer_scheme_returns_401(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp(middlewares=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"authorization", _basic_header("u", "p"))]) + status, _, _ = run_op(op, ctx) + assert status == 401 + + +class TestHttpBearerAuthSpec: + def test_security_scheme_in_components(self): + bearer = HttpBearerAuth(validator=lambda _t: True, bearer_format="opaque") + app = HttpApp(middlewares=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + doc = app.openapi_doc.to_dict() + assert doc["components"]["securitySchemes"]["bearerAuth"] == { + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque", + } + + def test_operation_gets_security_and_401(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp(middlewares=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.openapi_doc.to_dict()["paths"]["/me"]["get"] + assert op["security"] == [{"bearerAuth": []}] + # 401 response is contributed by the @op_middleware wrapper from + # the middleware's `Unauthorized[str]` return annotation — body + # schema included. + assert op["responses"]["401"]["description"] == "Unauthorized" + assert op["responses"]["401"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_per_op_does_not_protect_other_ops(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp() + + @app.get("/protected", middlewares=[bearer]) + def protected() -> str: + return "x" + + @app.get("/public") + def public() -> str: + return "y" + + doc = app.openapi_doc.to_dict() + assert "security" in doc["paths"]["/protected"]["get"] + assert "security" not in doc["paths"]["/public"]["get"] + # Scheme is registered once at root regardless of attachment scope. + assert "bearerAuth" in doc["components"]["securitySchemes"] + + def test_custom_scheme_name(self): + bearer = HttpBearerAuth(validator=lambda _t: True, scheme_name="apiToken") + app = HttpApp(middlewares=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + doc = app.openapi_doc.to_dict() + assert "apiToken" in doc["components"]["securitySchemes"] + assert doc["paths"]["/me"]["get"]["security"] == [{"apiToken": []}] + + +# --- HttpBasicAuth ------------------------------------------------------- + + +class TestHttpBasicAuthRuntime: + def test_valid_credentials_pass(self): + basic = HttpBasicAuth(validator=lambda u, p: u if (u, p) == ("alice", "wonder") else None) + app = HttpApp(middlewares=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx( + path="/me", + headers=[(b"authorization", _basic_header("alice", "wonder"))], + ) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"ok" + assert ctx.attrs[basic] == "alice" + + def test_invalid_credentials_returns_401_with_challenge(self): + basic = HttpBasicAuth(validator=lambda _u, _p: None) + app = HttpApp(middlewares=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx( + path="/me", + headers=[(b"authorization", _basic_header("u", "p"))], + ) + status, _, headers = run_op(op, ctx) + assert status == 401 + assert headers["WWW-Authenticate"].startswith("Basic realm=") + + def test_missing_header_returns_401_with_challenge(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(middlewares=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + status, _, headers = run_op(op, make_ctx(path="/me")) + assert status == 401 + assert "WWW-Authenticate" in headers + + def test_malformed_base64_returns_401(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(middlewares=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"authorization", b"Basic !!!not-b64!!!")]) + status, body, _ = run_op(op, ctx) + assert status == 401 + assert b"Malformed" in body + + def test_credentials_without_colon_returns_401(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(middlewares=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + creds = base64.b64encode(b"no-colon-here").decode() + ctx = make_ctx( + path="/me", + headers=[(b"authorization", f"Basic {creds}".encode())], + ) + status, body, _ = run_op(op, ctx) + assert status == 401 + assert b"Malformed" in body + + +class TestHttpBasicAuthSpec: + def test_security_scheme_in_components(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(middlewares=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + assert doc["components"]["securitySchemes"]["basicAuth"] == { + "type": "http", + "scheme": "basic", + } + + def test_operation_gets_security_and_401(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(middlewares=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.openapi_doc.to_dict()["paths"]["/me"]["get"] + assert op["security"] == [{"basicAuth": []}] + assert op["responses"]["401"]["description"] == "Unauthorized" + assert op["responses"]["401"]["content"]["application/json"]["schema"] == {"type": "string"} + + +# --- Integration: mixing two middlewares in one app --------------------- + + +class TestMixedAuth: + def test_two_middlewares_register_both_schemes(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp() + + @app.get("/jwt", middlewares=[bearer]) + def jwt() -> str: + return "j" + + @app.get("/basic", middlewares=[basic]) + def b() -> str: + return "b" + + doc = app.openapi_doc.to_dict() + schemes = doc["components"]["securitySchemes"] + assert {"bearerAuth", "basicAuth"} <= set(schemes) + assert doc["paths"]["/jwt"]["get"]["security"] == [{"bearerAuth": []}] + assert doc["paths"]["/basic"]["get"]["security"] == [{"basicAuth": []}] diff --git a/tests/openapi/conftest.py b/tests/openapi/conftest.py new file mode 100644 index 0000000..6faa28d --- /dev/null +++ b/tests/openapi/conftest.py @@ -0,0 +1,96 @@ +"""Test fixtures for ``localpost.openapi`` integration tests. + +Tests use a synchronous in-thread server (no worker pool, no anyio host) +to keep the harness small. The operation handlers run on the selector +thread; this is fine for tests since the handlers are short and +non-blocking. +""" + +from __future__ import annotations + +import socket +import threading +from collections.abc import Callable, Iterator + +import pytest + +from localpost.http import RequestHandler, ServerConfig, start_http_server +from localpost.openapi import HttpApp + + +@pytest.fixture +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _ServerCM: + def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: + self._stop = threading.Event() + self._cm = start_http_server(config, handler) + self._thread: threading.Thread | None = None + self._error: BaseException | None = None + + def __enter__(self) -> int: + server = self._cm.__enter__() + stop = self._stop + + def loop() -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return + except BaseException as e: # noqa: BLE001 + self._error = e + return + + self._thread = threading.Thread(target=loop, daemon=True) + self._thread.start() + return server.port + + def __exit__(self, exc_type, exc, tb) -> None: + self._stop.set() + try: + t = self._thread + assert t is not None + t.join(timeout=5) + finally: + self._cm.__exit__(exc_type, exc, tb) + if exc_type is None and self._error is not None: + raise self._error + + +ServeApp = Callable[[HttpApp], _ServerCM] + + +@pytest.fixture +def serve_app() -> Iterator[ServeApp]: + """Spin up an :class:`HttpApp` on a random local port. Use as ``with``:: + + def test_x(serve_app): + app = HttpApp() + ... + with serve_app(app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/...") + """ + active: list[_ServerCM] = [] + + def make(app: HttpApp) -> _ServerCM: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + config = ServerConfig(host="127.0.0.1", port=port) + handler = app._build_router_handler() + cm = _ServerCM(config, handler) + active.append(cm) + return cm + + yield make + + for cm in active: + t = cm._thread + if t is not None and t.is_alive(): + cm._stop.set() + t.join(timeout=5) diff --git a/tests/openapi/integration.py b/tests/openapi/integration.py new file mode 100644 index 0000000..3a6eb9d --- /dev/null +++ b/tests/openapi/integration.py @@ -0,0 +1,181 @@ +"""End-to-end HTTP tests against a real ``localpost.openapi.HttpApp``. + +These spin up the server in-thread (no worker pool — see +``tests/openapi/conftest.py``) and exercise it with httpx. They cover the +plumbing tests in ``tests/openapi/app.py`` can't reach: actual socket +I/O, the built-in ``/openapi.json`` and ``/docs`` routes, and round-trip +serialisation of the OpenAPI 3.2 doc. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Annotated + +import httpx +import pytest + +from localpost.openapi import ( + BadRequest, + Created, + FromHeader, + HttpApp, + NotFound, + spec, +) + + +@dataclass +class Book: + id: str + title: str + + +@pytest.fixture +def library_app() -> HttpApp: + app = HttpApp(info=spec.Info(title="Library API", version="1.0.0")) + library: dict[str, Book] = { + "42": Book(id="42", title="HHGTTG"), + } + + @app.get("/hello/{name}") + def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: + book = library.get(book_id) + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + @app.post("/books") + def create_book(book: Book) -> Created[Book]: + library[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")]) -> str: + return user_id + + return app + + +class TestPathAndQuery: + def test_hello_world(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + assert resp.status_code == 200 + assert resp.text == "Hello, world!" + assert resp.headers["content-type"].startswith("text/plain") + + def test_bad_request_short_circuit(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/Donald", timeout=5) + assert resp.status_code == 400 + assert resp.text == "Sorry, you are not welcome here" + + +class TestOpResultUnion: + def test_200_branch(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/42", timeout=5) + assert resp.status_code == 200 + assert resp.json() == {"id": "42", "title": "HHGTTG"} + + def test_404_branch(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/missing", timeout=5) + assert resp.status_code == 404 + assert resp.text == "Book not found: missing" + + +class TestRequestBody: + def test_json_body_and_created_response(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + json={"id": "7", "title": "Dune"}, + timeout=5, + ) + assert resp.status_code == 201 + assert resp.headers["Location"] == "/books/7" + assert resp.json() == {"id": "7", "title": "Dune"} + + def test_invalid_body(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + content=b"not json", + headers={"Content-Type": "application/json"}, + timeout=5, + ) + assert resp.status_code == 400 + assert b"Invalid request body" in resp.content + + +class TestHeaderResolver: + def test_header_present(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get( + f"http://127.0.0.1:{port}/me", + headers={"X-User-Id": "alice"}, + timeout=5, + ) + assert resp.status_code == 200 + assert resp.text == "alice" + + def test_missing_header(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/me", timeout=5) + assert resp.status_code == 400 + assert b"Missing required header" in resp.content + + +class TestBuiltInRoutes: + def test_openapi_json_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + doc = resp.json() + assert doc["openapi"] == "3.2.0" + assert doc["info"]["title"] == "Library API" + assert "/hello/{name}" in doc["paths"] + assert "/books/{book_id}" in doc["paths"] + # Per-status response schemas land in the doc. + responses = doc["paths"]["/books/{book_id}"]["get"]["responses"] + assert set(responses) == {"200", "404"} + # Components for named types. + assert "Book" in doc["components"]["schemas"] + + def test_swagger_ui_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs", timeout=5) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + assert b"Swagger UI" in resp.content + + def test_redoc_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs/redoc", timeout=5) + assert resp.status_code == 200 + assert b"redoc" in resp.content.lower() + + def test_scalar_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs/scalar", timeout=5) + assert resp.status_code == 200 + assert b"scalar" in resp.content.lower() + + +class TestOpenAPIDocStructure: + def test_openapi_doc_is_valid_json(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + # Round-trip check. + doc = json.loads(resp.content) + assert doc["openapi"].startswith("3.2") diff --git a/tests/openapi/middleware.py b/tests/openapi/middleware.py new file mode 100644 index 0000000..326fa96 --- /dev/null +++ b/tests/openapi/middleware.py @@ -0,0 +1,499 @@ +"""Tests for ``OpMiddleware`` wiring through ``HttpApp`` and ``Operation``, +plus the ``@op_middleware`` decorator.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING, Annotated + +import pytest + +from localpost.http import HTTPReqCtx +from localpost.openapi import ( + ApiOperation, + BadRequest, + FromHeader, + FromQuery, + HttpApp, + Ok, + OpMiddleware, + OpResult, + TooManyRequests, + Unauthorized, + op_middleware, + spec, +) +from tests.openapi.app import make_ctx, run_op + +if TYPE_CHECKING: + from localpost.openapi.schemas import SchemaRegistry + + +# --- Test middleware helpers --------------------------------------------- + + +class _RecordingMiddleware: + """Middleware that just records calls; always forwards via ``call_next``.""" + + def __init__(self) -> None: + self.calls: list[str] = [] + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + self.calls.append("run") + return call_next(ctx) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + self.calls.append("contribute_root") + return doc + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + self.calls.append("contribute_operation") + return op + + +class _DenyMiddleware: + """Middleware that always short-circuits with 401.""" + + def __init__(self, message: str = "denied") -> None: + self.message = message + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + return Unauthorized(self.message) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + new_components = replace( + doc.components, + security_schemes={ + **doc.components.security_schemes, + "denyAuth": spec.SecurityScheme(type="http", scheme="bearer"), + }, + ) + return replace(doc, components=new_components) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + return replace( + op, + security=(*op.security, {"denyAuth": ()}), + responses={**op.responses, "401": spec.Response(description="Unauthorized")}, + ) + + +class TestMiddlewareIsRuntimeProtocol: + def test_recording_satisfies_protocol(self): + assert isinstance(_RecordingMiddleware(), OpMiddleware) + + def test_deny_satisfies_protocol(self): + assert isinstance(_DenyMiddleware(), OpMiddleware) + + +class TestAppLevelMiddlewareRuntime: + def test_middleware_runs_around_handler(self): + rec = _RecordingMiddleware() + app = HttpApp(middlewares=[rec]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/foo") + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"ok" + # The middleware ran for the request. + assert rec.calls == ["run"] + + def test_middleware_short_circuits(self): + app = HttpApp(middlewares=[_DenyMiddleware("nope")]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/foo") + status, body, _ = run_op(op, ctx) + assert status == 401 + assert body == b"nope" + + def test_middleware_can_post_process_op_result(self): + class _AddHeader: + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + result = call_next(ctx) + # Re-wrap with extra headers. (replace() doesn't work on + # OpResult subclasses because their __init__ is custom.) + return Ok(result.body, headers={**result.headers, "X-Trace": "abc"}) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + return doc + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + return op + + app = HttpApp(middlewares=[_AddHeader()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + status, _, headers = run_op(op, make_ctx(path="/foo")) + assert status == 200 + assert headers["X-Trace"] == "abc" + + +class TestPerOpMiddleware: + def test_per_op_middleware_only_affects_that_op(self): + deny = _DenyMiddleware("op-only") + app = HttpApp() + + @app.get("/protected", middlewares=[deny]) + def protected() -> str: + return "secret" + + @app.get("/public") + def public() -> str: + return "hi" + + protected_op = app.operations[0] + public_op = app.operations[1] + + status, _, _ = run_op(protected_op, make_ctx(path="/protected")) + assert status == 401 + + status, body, _ = run_op(public_op, make_ctx(path="/public")) + assert status == 200 + assert body == b"hi" + + def test_app_and_per_op_middlewares_compose(self): + order: list[str] = [] + + class _Tagging: + def __init__(self, tag: str) -> None: + self.tag = tag + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + order.append(self.tag) + return call_next(ctx) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + return doc + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + return op + + app = HttpApp(middlewares=[_Tagging("app")]) + + @app.get("/foo", middlewares=[_Tagging("op")]) + def foo() -> str: + return "ok" + + run_op(app.operations[0], make_ctx(path="/foo")) + # App-level wraps outermost; per-op runs inside it. + assert order == ["app", "op"] + + +class TestSpecContribution: + def test_contribute_root_called_once_per_doc_build(self): + rec = _RecordingMiddleware() + app = HttpApp(middlewares=[rec]) + + @app.get("/a") + def a() -> str: + return "a" + + @app.get("/b") + def b() -> str: + return "b" + + rec.calls.clear() + _ = app.openapi_doc + # contribute_root once, contribute_operation per operation. + assert rec.calls.count("contribute_root") == 1 + assert rec.calls.count("contribute_operation") == 2 + + def test_contribute_root_appears_in_doc(self): + app = HttpApp(middlewares=[_DenyMiddleware()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + assert "denyAuth" in doc["components"]["securitySchemes"] + assert doc["components"]["securitySchemes"]["denyAuth"] == { + "type": "http", + "scheme": "bearer", + } + + def test_contribute_operation_appears_in_doc(self): + app = HttpApp(middlewares=[_DenyMiddleware()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + op = doc["paths"]["/foo"]["get"] + assert op["security"] == [{"denyAuth": []}] + assert "401" in op["responses"] + assert op["responses"]["401"] == {"description": "Unauthorized"} + + def test_per_op_middleware_doc_contribution_is_op_scoped(self): + app = HttpApp() + + @app.get("/protected", middlewares=[_DenyMiddleware()]) + def protected() -> str: + return "secret" + + @app.get("/public") + def public() -> str: + return "hi" + + doc = app.openapi_doc.to_dict() + # Per-op middleware contributes to BOTH root (because contribute_root + # is always called once per middleware that sees the doc) and only + # to its own operation. + assert "denyAuth" in doc["components"]["securitySchemes"] + assert "security" in doc["paths"]["/protected"]["get"] + assert "security" not in doc["paths"]["/public"]["get"] + assert "401" not in doc["paths"]["/public"]["get"]["responses"] + + +# --- @op_middleware decorator ------------------------------------------ + + +class TestOpMiddlewareDecorator: + def test_decorator_produces_middleware(self): + @op_middleware + def f(ctx: HTTPReqCtx, call_next: ApiOperation) -> OpResult: + return call_next(ctx) + + assert isinstance(f, OpMiddleware) + + def test_missing_call_next_param_is_error(self): + with pytest.raises(ValueError, match="call_next"): + + @op_middleware + def bad() -> OpResult: # type: ignore[empty-body] + ... + + +class TestOpMiddlewareRuntime: + def test_resolved_args_are_available(self): + seen: list[str] = [] + + @op_middleware + def record( + ctx: HTTPReqCtx, + call_next: ApiOperation, + authorization: Annotated[str, FromHeader("Authorization")], + ) -> OpResult: + seen.append(authorization) + return call_next(ctx) + + app = HttpApp(middlewares=[record]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/x", headers=[(b"authorization", b"Bearer abc")]) + status, _, _ = run_op(op, ctx) + assert status == 200 + assert seen == ["Bearer abc"] + + def test_short_circuit_with_op_result(self): + @op_middleware + def deny( + ctx: HTTPReqCtx, + call_next: ApiOperation, + code: Annotated[str, FromQuery("code")] = "", + ) -> Unauthorized[str] | OpResult: + if code != "good": + return Unauthorized("Invalid code") + return call_next(ctx) + + app = HttpApp(middlewares=[deny]) + + @app.get("/x") + def x() -> str: + return "secret" + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/x", query=b"code=bad")) + assert status == 401 + assert body == b"Invalid code" + + def test_short_circuit_via_resolver(self): + # When a resolver itself returns an OpResult (e.g. missing required + # header → BadRequest), the middleware short-circuits with that + # result without calling the inner chain. + @op_middleware + def needs_header( + ctx: HTTPReqCtx, + call_next: ApiOperation, + x_id: Annotated[str, FromHeader("X-Id")], + ) -> OpResult: + return call_next(ctx) + + app = HttpApp(middlewares=[needs_header]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + # No X-Id header → resolver short-circuits with 400. + status, body, _ = run_op(op, make_ctx(path="/x")) + assert status == 400 + assert b"Missing required header" in body + + def test_non_op_result_return_is_error(self): + @op_middleware + def bad(ctx: HTTPReqCtx, call_next: ApiOperation) -> OpResult: + return "not allowed" # type: ignore[return-value] + + app = HttpApp(middlewares=[bad]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + with pytest.raises(TypeError, match="middlewares must return an OpResult"): + run_op(op, make_ctx(path="/x")) + + +class TestOpMiddlewareSpecContribution: + def test_header_param_appears_on_operation(self): + @op_middleware + def require_header( + ctx: HTTPReqCtx, + call_next: ApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")], + ) -> Unauthorized[str] | OpResult: + return call_next(ctx) if x_api_key else Unauthorized("missing") + + app = HttpApp(middlewares=[require_header]) + + @app.get("/x") + def x() -> str: + return "ok" + + params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] + names = [p["name"] for p in params] + assert "X-API-Key" in names + # Coming from the middleware, the param is required. + x_api_key_param = next(p for p in params if p["name"] == "X-API-Key") + assert x_api_key_param.get("required") is True + + def test_query_param_appears_on_operation(self): + @op_middleware + def require_token( + ctx: HTTPReqCtx, + call_next: ApiOperation, + token: Annotated[str, FromQuery("token")], + ) -> Unauthorized[str] | OpResult: + return call_next(ctx) if token else Unauthorized("missing") + + app = HttpApp(middlewares=[require_token]) + + @app.get("/x") + def x() -> str: + return "ok" + + params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] + names = [p["name"] for p in params] + assert "token" in names + + def test_response_codes_from_return_type_union(self): + @op_middleware + def rate_limit( + ctx: HTTPReqCtx, + call_next: ApiOperation, + ) -> TooManyRequests[str] | OpResult: + return call_next(ctx) + + app = HttpApp(middlewares=[rate_limit]) + + @app.get("/x") + def x() -> str: + return "ok" + + responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] + assert "429" in responses + assert responses["429"]["description"] == "Too Many Requests" + + def test_middleware_response_does_not_overwrite_operation_response(self): + # If the operation already declares 400 for its own reason, the + # middleware's 400 (if any) shouldn't clobber it. + @op_middleware + def always_pass( + ctx: HTTPReqCtx, + call_next: ApiOperation, + ) -> BadRequest[int] | OpResult: # different body type + return call_next(ctx) + + app = HttpApp(middlewares=[always_pass]) + + @app.get("/x") + def x() -> str | BadRequest[str]: # the op's own 400 — wins + return "ok" + + responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] + assert "400" in responses + # The op's BadRequest[str] wins over the middleware's BadRequest[int]. + assert responses["400"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_per_op_middleware_only_contributes_to_its_op(self): + @op_middleware + def deny( + ctx: HTTPReqCtx, + call_next: ApiOperation, + code: Annotated[str, FromQuery("code")] = "", + ) -> Unauthorized[str] | OpResult: + return Unauthorized("nope") if code != "good" else call_next(ctx) + + app = HttpApp() + + @app.get("/protected", middlewares=[deny]) + def protected() -> str: + return "x" + + @app.get("/public") + def public() -> str: + return "y" + + doc = app.openapi_doc.to_dict() + # /protected has the middleware's parameter and 401 response + assert any(p["name"] == "code" for p in doc["paths"]["/protected"]["get"]["parameters"]) + assert "401" in doc["paths"]["/protected"]["get"]["responses"] + # /public doesn't + assert "parameters" not in doc["paths"]["/public"]["get"] + assert "401" not in doc["paths"]["/public"]["get"]["responses"] + + +class TestCtxParam: + def test_ctx_param_is_passthrough_and_contributes_nothing(self): + seen: list[str] = [] + + @op_middleware + def stash(ctx: HTTPReqCtx, call_next: ApiOperation) -> OpResult: + seen.append(ctx.request.path.decode()) + return call_next(ctx) + + app = HttpApp(middlewares=[stash]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + run_op(op, make_ctx(path="/x")) + assert seen == ["/x"] + + # ctx parameter does NOT show up in the spec. + op_spec = app.openapi_doc.to_dict()["paths"]["/x"]["get"] + assert "parameters" not in op_spec diff --git a/tests/openapi/polish.py b/tests/openapi/polish.py new file mode 100644 index 0000000..0eac847 --- /dev/null +++ b/tests/openapi/polish.py @@ -0,0 +1,213 @@ +"""Tests for the resolver / Operation polish: Optional handling, +operationId derivation, registration-time validation.""" + +from __future__ import annotations + +from typing import Annotated + +import msgspec +import pytest + +from localpost.openapi import FromHeader, FromPath, FromQuery, HttpApp, NotFound +from tests.openapi.app import make_ctx, run_op + +# --- Optional handling --------------------------------------------------- + + +class TestOptionalQuery: + def test_optional_query_param_returns_none_when_absent(self): + app = HttpApp() + + @app.get("/items") + def items(q: str | None = None) -> str: + return "missing" if q is None else q + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items")) + assert status == 200 + assert body == b"missing" + + def test_optional_via_union_without_default(self): + app = HttpApp() + + @app.get("/items") + def items(q: str | None) -> str: + return "missing" if q is None else q + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items")) + assert status == 200 + assert body == b"missing" + + def test_optional_query_in_doc_marked_not_required(self): + app = HttpApp() + + @app.get("/items") + def items(q: str | None = None) -> str: + return q or "" + + doc = app.openapi_doc.to_dict() + params = doc["paths"]["/items"]["get"]["parameters"] + assert params[0]["name"] == "q" + # OpenAPI omits ``required`` when false. + assert params[0].get("required") is not True + + def test_optional_int_coerced_when_present(self): + app = HttpApp() + + @app.get("/items") + def items(limit: int | None = None) -> int: + return limit if limit is not None else -1 + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items", query=b"limit=5")) + assert status == 200 + assert msgspec.json.decode(body) == 5 + + +class TestOptionalHeader: + def test_optional_header_returns_none_when_absent(self): + app = HttpApp() + + @app.get("/me") + def me(x_id: Annotated[str | None, FromHeader("X-Id")] = None) -> str: + return "anon" if x_id is None else x_id + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/me")) + assert status == 200 + assert body == b"anon" + + def test_optional_header_in_doc(self): + app = HttpApp() + + @app.get("/me") + def me(x_id: Annotated[str | None, FromHeader("X-Id")] = None) -> str: + return x_id or "" + + params = app.openapi_doc.to_dict()["paths"]["/me"]["get"]["parameters"] + assert params[0]["name"] == "X-Id" + assert params[0].get("required") is not True + + +# --- operationId derivation ---------------------------------------------- + + +class TestOptionalReturnIsNotFound: + """``T | None`` return → implicit 404 NotFound when ``None`` is returned.""" + + def test_none_return_yields_404(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | None: + return None if item_id == "missing" else item_id + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items/missing", path_args={"item_id": "missing"})) + assert status == 404 + assert body == b"" + + def test_value_return_still_yields_200(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | None: + return item_id + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items/x", path_args={"item_id": "x"})) + assert status == 200 + assert body == b"x" + + def test_doc_includes_both_200_and_404(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | None: + return item_id + + responses = app.openapi_doc.to_dict()["paths"]["/items/{item_id}"]["get"]["responses"] + assert set(responses) == {"200", "404"} + assert responses["404"] == {"description": "Not Found"} + + def test_explicit_not_found_takes_precedence(self): + # If the user already declared NotFound[X], the implicit None→404 + # shouldn't overwrite it (and shouldn't add a body-less duplicate). + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | NotFound[str] | None: + return None if item_id == "missing" else item_id + + responses = app.openapi_doc.to_dict()["paths"]["/items/{item_id}"]["get"]["responses"] + # Only one 404 — the explicit NotFound[str] wins (has a JSON body schema). + assert "404" in responses + assert "content" in responses["404"] + assert responses["404"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_bare_none_return_is_not_404(self): + # ``-> None`` (no Union) should still mean "200 / empty success", + # not 404. + app = HttpApp() + + @app.get("/ping") + def ping() -> None: + return None + + op = app.operations[0] + status, _, _ = run_op(op, make_ctx(path="/ping")) + assert status == 200 + + +class TestOperationId: + def test_operation_id_from_function_name(self): + app = HttpApp() + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> str: + return book_id + + doc = app.openapi_doc.to_dict() + assert doc["paths"]["/books/{book_id}"]["get"]["operationId"] == "get_book" + + def test_lambda_falls_back_to_path_id(self): + app = HttpApp() + app.get("/foo")(lambda: "x") + + doc = app.openapi_doc.to_dict() + assert doc["paths"]["/foo"]["get"]["operationId"] == "get_foo" + + +# --- Registration-time validation ---------------------------------------- + + +class TestRegistrationValidation: + def test_from_path_with_unknown_var_errors(self): + app = HttpApp() + + with pytest.raises(ValueError, match="no such variable"): + + @app.get("/items") + def items(item_id: Annotated[str, FromPath("item_id")]) -> str: + return item_id + + def test_path_template_var_without_matching_param_errors(self): + app = HttpApp() + + with pytest.raises(ValueError, match="no parameter resolves"): + + @app.get("/items/{item_id}") + def items() -> str: + return "x" + + def test_explicit_from_query_for_path_var_errors_with_unbound(self): + # If the user accidentally uses FromQuery for a path var, the + # path var goes unbound — caught at registration time. + app = HttpApp() + + with pytest.raises(ValueError, match="no parameter resolves"): + + @app.get("/items/{item_id}") + def items(item_id: Annotated[str, FromQuery("item_id")]) -> str: + return item_id diff --git a/tests/openapi/schemas.py b/tests/openapi/schemas.py new file mode 100644 index 0000000..a40973a --- /dev/null +++ b/tests/openapi/schemas.py @@ -0,0 +1,258 @@ +"""Tests for the adapter-driven JSON Schema registry.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, Literal + +import msgspec +import pytest + +from localpost.openapi.adapters import AdapterRegistry, SchemaFor, default_registry +from localpost.openapi.adapters._msgspec import MsgspecAdapter +from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry + + +@dataclass +class Book: + id: str + title: str + + +class Author(msgspec.Struct): + name: str + age: int + + +class Status(StrEnum): + OPEN = "open" + CLOSED = "closed" + + +# attrs sample types live at module scope so ``attrs.resolve_types`` (which goes through +# ``typing.get_type_hints`` against the class's module globals) can resolve forward refs +# under ``from __future__ import annotations``. +try: + import attrs as _attrs + + @_attrs.define + class _AttrsPet: + name: str + age: int = 0 + + class _MsgspecTrinket(msgspec.Struct): + sku: str + + @_attrs.define + class _AttrsPetWithTrinket: + name: str + toy: _MsgspecTrinket + +except ImportError: + _AttrsPet = None # type: ignore[assignment,misc] + _MsgspecTrinket = None # type: ignore[assignment,misc] + _AttrsPetWithTrinket = None # type: ignore[assignment,misc] + + +class TestPrimitives: + def test_int(self): + registry = SchemaRegistry() + assert registry.schema_for(int) == {"type": "integer"} + + def test_str(self): + registry = SchemaRegistry() + assert registry.schema_for(str) == {"type": "string"} + + def test_bool(self): + registry = SchemaRegistry() + assert registry.schema_for(bool) == {"type": "boolean"} + + def test_none(self): + registry = SchemaRegistry() + assert registry.schema_for(None) == {"type": "null"} + assert registry.schema_for(type(None)) == {"type": "null"} + + +class TestNamedTypes: + def test_dataclass_returns_ref(self): + registry = SchemaRegistry() + schema = registry.schema_for(Book) + assert schema == {"$ref": REF_TEMPLATE.format(name="Book")} + + def test_struct_returns_ref(self): + registry = SchemaRegistry() + schema = registry.schema_for(Author) + assert schema == {"$ref": REF_TEMPLATE.format(name="Author")} + + def test_components_includes_referenced_types(self): + registry = SchemaRegistry() + registry.schema_for(Book) + registry.schema_for(Author) + components = registry.components() + + assert "Book" in components + assert "Author" in components + assert components["Book"]["type"] == "object" + assert "title" in components["Book"]["properties"] + + +class TestEnums: + def test_str_enum_emits_values(self): + registry = SchemaRegistry() + registry.schema_for(Status) + components = registry.components() + assert "Status" in components + assert set(components["Status"]["enum"]) == {"open", "closed"} + + +class TestLiteral: + def test_literal_inlines_enum(self): + registry = SchemaRegistry() + schema = registry.schema_for(Literal["a", "b"]) + # Literal types are inlined by msgspec, not registered as components. + assert "enum" in schema or "const" in schema or "$ref" in schema + + +class TestComponentCacheInvalidation: + def test_components_recomputed_after_new_registration(self): + registry = SchemaRegistry() + registry.schema_for(Book) + assert "Book" in registry.components() + + registry.schema_for(Author) + components = registry.components() + assert "Book" in components + assert "Author" in components + + +class TestAdapterDispatch: + def test_dataclass_routes_to_msgspec(self): + registry = default_registry() + assert registry.for_type(Book).name == "msgspec" + + def test_msgspec_struct_routes_to_msgspec(self): + registry = default_registry() + assert registry.for_type(Author).name == "msgspec" + + def test_pydantic_model_routes_to_pydantic(self): + BaseModel = pytest.importorskip("pydantic").BaseModel + + class Pet(BaseModel): + name: str + + registry = default_registry() + assert registry.for_type(Pet).name == "pydantic" + + def test_pydantic_schema_registered_in_components(self): + BaseModel = pytest.importorskip("pydantic").BaseModel + + class Pet(BaseModel): + name: str + age: int + + registry = SchemaRegistry() + schema = registry.schema_for(Pet) + assert schema == {"$ref": REF_TEMPLATE.format(name="Pet")} + + components = registry.components() + assert "Pet" in components + assert "name" in components["Pet"]["properties"] + + def test_attrs_class_routes_to_attrs(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + registry = default_registry() + assert registry.for_type(_AttrsPet).name == "attrs" + + def test_attrs_schema_registered_in_components(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + registry = SchemaRegistry() + schema = registry.schema_for(_AttrsPet) + assert schema == {"$ref": REF_TEMPLATE.format(name="_AttrsPet")} + + components = registry.components() + assert "_AttrsPet" in components + body = components["_AttrsPet"] + assert body["type"] == "object" + assert body["properties"]["name"] == {"type": "string"} + assert body["properties"]["age"] == {"type": "integer"} + # ``age`` has a default; only ``name`` is required. + assert body["required"] == ["name"] + + def test_attrs_with_nested_msgspec_struct_recurses_via_registry(self): + """attrs adapter must defer foreign nested types to the registry's schema_for callback.""" + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + registry = SchemaRegistry() + registry.schema_for(_AttrsPetWithTrinket) + components = registry.components() + assert "_AttrsPetWithTrinket" in components + # _MsgspecTrinket must have been registered as a side-effect of the schema_for callback. + assert "_MsgspecTrinket" in components + assert components["_AttrsPetWithTrinket"]["properties"]["toy"] == { + "$ref": REF_TEMPLATE.format(name="_MsgspecTrinket") + } + + +class TestCustomAdapter: + """Plug a fake adapter ahead of msgspec to confirm dispatch is extensible.""" + + class _Marker: + """Sentinel type the fake adapter claims.""" + + class _FakeAdapter: + name = "fake" + validation_errors: tuple[type[Exception], ...] = () + + def __init__(self) -> None: + self.schema_calls: list[Any] = [] + self.components_calls: list[Sequence[Any]] = [] + + def claims(self, t: Any, /) -> bool: + return t is TestCustomAdapter._Marker + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + del schema_for + self.schema_calls.append(t) + return {"$ref": ref_template.format(name="Marker")} + + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + del schema_for + self.components_calls.append(types) + return {"Marker": {"type": "object", "x-fake": True}} + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + raise NotImplementedError + + def encode(self, value: object, /) -> tuple[bytes, str]: + return b'"fake"', "application/json" + + def test_custom_adapter_wins_over_catch_all(self): + fake = self._FakeAdapter() + registry = AdapterRegistry([fake, MsgspecAdapter()]) + + schema_registry = SchemaRegistry(registry) + schema = schema_registry.schema_for(self._Marker) + assert schema == {"$ref": REF_TEMPLATE.format(name="Marker")} + assert fake.schema_calls == [self._Marker] + + components = schema_registry.components() + assert components["Marker"] == {"type": "object", "x-fake": True} + # And the catch-all is still active for non-claimed types. + schema_registry.schema_for(Book) + assert "Book" in schema_registry.components() diff --git a/tests/openapi/spec.py b/tests/openapi/spec.py new file mode 100644 index 0000000..82ae5ac --- /dev/null +++ b/tests/openapi/spec.py @@ -0,0 +1,108 @@ +"""Round-trip tests for the OpenAPI 3.2 dataclasses.""" + +from __future__ import annotations + +import json + +from localpost.openapi import spec + + +class TestOpenAPIVersion: + def test_default_version_is_3_2(self): + assert spec.OpenAPI().openapi == "3.2.0" + + +class TestOperationOnPath: + def test_add_operation_creates_path_item(self): + doc = spec.OpenAPI() + doc = doc.add_operation("/foo", "get", spec.Operation(operation_id="get_foo")) + + assert "/foo" in doc.paths + assert "get" in doc.paths["/foo"].operations + assert doc.paths["/foo"].operations["get"].operation_id == "get_foo" + + def test_add_operation_returns_new_instance(self): + doc = spec.OpenAPI() + doc2 = doc.add_operation("/foo", "get", spec.Operation()) + + assert doc is not doc2 + assert "/foo" not in doc.paths + assert "/foo" in doc2.paths + + def test_add_operation_lowercases_method(self): + doc = spec.OpenAPI().add_operation("/foo", "POST", spec.Operation()) + + assert "post" in doc.paths["/foo"].operations + assert "POST" not in doc.paths["/foo"].operations + + def test_add_operation_merges_methods_on_same_path(self): + doc = spec.OpenAPI() + doc = doc.add_operation("/foo", "get", spec.Operation(operation_id="get")) + doc = doc.add_operation("/foo", "post", spec.Operation(operation_id="post")) + + ops = doc.paths["/foo"].operations + assert set(ops) == {"get", "post"} + + +class TestSerialization: + def test_minimal_doc(self): + d = spec.OpenAPI().to_dict() + assert d == {"openapi": "3.2.0", "info": {"title": "API", "version": "0.1.0"}} + + def test_to_json_round_trips(self): + doc = spec.OpenAPI(info=spec.Info(title="X", version="1")) + loaded = json.loads(doc.to_json()) + assert loaded["openapi"] == "3.2.0" + assert loaded["info"] == {"title": "X", "version": "1"} + + def test_components_omitted_when_empty(self): + d = spec.OpenAPI().to_dict() + assert "components" not in d + + def test_components_emitted_when_populated(self): + doc = spec.OpenAPI() + doc = doc.with_components(spec.Components(schemas={"Book": {"type": "object"}})) + d = doc.to_dict() + assert d["components"] == {"schemas": {"Book": {"type": "object"}}} + + def test_parameter_emits_in_field(self): + param = spec.Parameter(name="id", location="path", required=True, schema={"type": "string"}) + assert param.to_dict() == { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + + +class TestTagGroups: + """tagGroups is a 3.2 addition.""" + + def test_tag_groups_emitted(self): + doc = spec.OpenAPI( + tags=(spec.Tag(name="A"), spec.Tag(name="B")), + tag_groups=(spec.TagGroup(name="Group", tags=("A", "B")),), + ) + d = doc.to_dict() + assert d["tagGroups"] == [{"name": "Group", "tags": ["A", "B"]}] + + +class TestSecurityScheme: + def test_http_bearer(self): + scheme = spec.SecurityScheme(type="http", scheme="bearer", bearer_format="JWT") + assert scheme.to_dict() == {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + + def test_oauth2_with_device_flow(self): + scheme = spec.SecurityScheme( + type="oauth2", + flows=spec.OAuthFlows( + device_authorization=spec.OAuthFlow( + device_authorization_url="https://auth.example/device", + token_url="https://auth.example/token", # noqa: S106 + scopes={"read": "Read access"}, + ) + ), + ) + d = scheme.to_dict() + assert d["type"] == "oauth2" + assert d["flows"]["deviceAuthorization"]["deviceAuthorizationUrl"] == "https://auth.example/device" diff --git a/tests/openapi/sse.py b/tests/openapi/sse.py new file mode 100644 index 0000000..3bf417d --- /dev/null +++ b/tests/openapi/sse.py @@ -0,0 +1,161 @@ +"""Tests for ``localpost.openapi.sse`` — encoder + Operation wiring + +end-to-end streaming.""" + +from __future__ import annotations + +from collections.abc import Generator, Iterator +from dataclasses import dataclass + +import httpx + +from localpost.openapi import Event, EventStream, HttpApp +from localpost.openapi.sse import encode_event, format_data_field, iter_events + + +@dataclass +class Update: + msg: str + + +# --- Encoder unit tests -------------------------------------------------- + + +class TestEncoder: + def test_string_payload_passes_through(self): + out = encode_event("hi") + assert out == b"data: hi\n\n" + + def test_dict_payload_json_encoded(self): + out = encode_event({"k": "v"}) + assert out == b'data: {"k":"v"}\n\n' + + def test_event_with_id_and_event_field(self): + out = encode_event(Event(data="payload", event="update", id="42")) + # Order: comment, event, id, retry, data + assert out == b"event: update\nid: 42\ndata: payload\n\n" + + def test_event_with_retry(self): + out = encode_event(Event(data="x", retry=5000)) + assert out == b"retry: 5000\ndata: x\n\n" + + def test_event_with_comment(self): + out = encode_event(Event(data="ping", comment="keep-alive")) + assert out == b": keep-alive\ndata: ping\n\n" + + def test_multi_line_payload_emits_multiple_data_lines(self): + out = encode_event("line1\nline2\nline3") + assert out == b"data: line1\ndata: line2\ndata: line3\n\n" + + def test_format_data_field_handles_bytes(self): + assert format_data_field(b"hi") == "data: hi" + + def test_dataclass_payload_json_encoded(self): + out = encode_event(Update(msg="hello")) + assert out == b'data: {"msg":"hello"}\n\n' + + +class TestIterEvents: + def test_iterates_events_from_generator(self): + def gen(): + yield "a" + yield "b" + + chunks = list(iter_events(gen())) + assert chunks == [b"data: a\n\n", b"data: b\n\n"] + + def test_iterates_events_from_event_stream(self): + def gen(): + yield Event(data="x", id="1") + yield Event(data="y", id="2") + + chunks = list(iter_events(EventStream(gen()))) + assert chunks == [b"id: 1\ndata: x\n\n", b"id: 2\ndata: y\n\n"] + + +# --- OpenAPI doc tests --------------------------------------------------- + + +class TestSseSpec: + def test_generator_return_emits_text_event_stream(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> Generator[Update]: + yield Update(msg="x") + + doc = app.openapi_doc.to_dict() + responses = doc["paths"]["/feed"]["get"]["responses"] + assert "text/event-stream" in responses["200"]["content"] + assert "application/json" not in responses["200"]["content"] + + def test_event_typed_generator_uses_event_schema(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> Generator[Event[Update]]: + yield Event(data=Update(msg="x")) + + doc = app.openapi_doc.to_dict() + schema = doc["paths"]["/feed"]["get"]["responses"]["200"]["content"]["text/event-stream"]["schema"] + assert schema["$ref"].startswith("#/components/schemas/Event") + + def test_iterator_return_also_works(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> Iterator[Update]: + return iter([Update(msg="x")]) + + doc = app.openapi_doc.to_dict() + assert "text/event-stream" in (doc["paths"]["/feed"]["get"]["responses"]["200"]["content"]) + + def test_event_stream_return_also_works(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> EventStream[Update]: + return EventStream(iter([Update(msg="x")])) + + doc = app.openapi_doc.to_dict() + assert "text/event-stream" in (doc["paths"]["/feed"]["get"]["responses"]["200"]["content"]) + + +# --- End-to-end integration ---------------------------------------------- + + +class TestSseIntegration: + def test_streams_events_over_http(self, serve_app): + app = HttpApp() + + @app.get("/feed") + def feed() -> Generator[Event[Update]]: + for i in range(3): + yield Event(data=Update(msg=f"tick {i}"), id=str(i)) + + with serve_app(app) as port: + with httpx.stream("GET", f"http://127.0.0.1:{port}/feed", timeout=5) as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + body = b"".join(resp.iter_bytes()) + + # Three events, separated by blank lines, in order. + events = body.split(b"\n\n") + non_empty = [e for e in events if e] + assert len(non_empty) == 3 + assert b"tick 0" in non_empty[0] + assert b"id: 0" in non_empty[0] + assert b"tick 2" in non_empty[2] + + def test_finite_generator_terminates_response(self, serve_app): + app = HttpApp() + + @app.get("/done") + def done() -> Generator[str]: + yield "first" + yield "last" + + with serve_app(app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/done", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"data: first\n\ndata: last\n\n" diff --git a/tests/openapi/wsgi.py b/tests/openapi/wsgi.py new file mode 100644 index 0000000..bf9b4e4 --- /dev/null +++ b/tests/openapi/wsgi.py @@ -0,0 +1,213 @@ +"""Tests for ``HttpApp.as_wsgi()`` — OpenAPI app under WSGI deployment. + +Drives the WSGI app directly (no live server). Covers the operation +pipeline, OpenAPI doc serving, and SSE streaming through ``ctx.stream``. +""" + +from __future__ import annotations + +import io +import json +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from typing import Any + +from localpost.openapi import Event, HttpApp, NotFound + + +@dataclass +class Pet: + name: str + age: int + + +@dataclass +class Book: + id: str + + +def _drive(app: Any, environ: dict[str, Any]) -> tuple[str, list[tuple[str, str]], bytes, Iterable[bytes]]: + """Returns (status, headers, joined_body_bytes, body_iter). + + Some tests need to inspect the iterator's chunk shape (lazy streaming), + so we return both the joined bytes and the underlying iter. + """ + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter: Iterable[bytes] = app(environ, start_response) + body = b"".join(body_iter) + close = getattr(body_iter, "close", None) + if close is not None: + close() + return captured["status"], captured["headers"], body, body_iter + + +def _get(path: str, query: str = "", headers: dict[str, str] | None = None) -> dict[str, Any]: + environ: dict[str, Any] = { + "REQUEST_METHOD": "GET", + "PATH_INFO": path, + "QUERY_STRING": query, + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "SERVER_PROTOCOL": "HTTP/1.1", + "wsgi.version": (1, 0), + "wsgi.url_scheme": "http", + "wsgi.input": io.BytesIO(b""), + "wsgi.errors": io.StringIO(), + "wsgi.multithread": True, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + "CONTENT_LENGTH": "", + "CONTENT_TYPE": "", + "REMOTE_ADDR": "127.0.0.1", + "REMOTE_PORT": "5555", + } + if headers: + for k, v in headers.items(): + environ["HTTP_" + k.upper().replace("-", "_")] = v + return environ + + +def _post_json(path: str, payload: bytes) -> dict[str, Any]: + environ = _get(path) + environ["REQUEST_METHOD"] = "POST" + environ["wsgi.input"] = io.BytesIO(payload) + environ["CONTENT_LENGTH"] = str(len(payload)) + environ["CONTENT_TYPE"] = "application/json" + return environ + + +# -------------------------------------------------------------------------- +# Tests +# -------------------------------------------------------------------------- + + +class TestHttpAppAsWsgiBasic: + def test_simple_get_string_return(self): + app = HttpApp() + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + wsgi_app = app.as_wsgi() + status, headers, body, _ = _drive(wsgi_app, _get("/hello/world")) + assert status.startswith("200 ") + assert ("content-type", "text/plain; charset=utf-8") in headers + assert body == b"Hello, world!" + + def test_post_json_body(self): + app = HttpApp() + + @app.post("/pets") + def create(pet: Pet) -> Pet: + return pet + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive(wsgi_app, _post_json("/pets", b'{"name":"Rex","age":3}')) + assert status.startswith("200 ") + assert json.loads(body) == {"name": "Rex", "age": 3} + + def test_query_param(self): + app = HttpApp() + + @app.get("/search") + def search(q: str, limit: int = 10) -> dict[str, Any]: + return {"q": q, "limit": limit} + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive(wsgi_app, _get("/search", "q=hello&limit=5")) + assert status.startswith("200 ") + assert json.loads(body) == {"q": "hello", "limit": 5} + + def test_404_via_null_return(self): + app = HttpApp() + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: + if book_id == "42": + return Book(id=book_id) + return NotFound(f"no such book: {book_id}") + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive(wsgi_app, _get("/books/99")) + assert status.startswith("404 ") + assert b"no such book: 99" in body + + def test_router_404_for_unknown_path(self): + app = HttpApp() + + @app.get("/x") + def x() -> str: + return "x" + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive(wsgi_app, _get("/y")) + assert status.startswith("404 ") + assert body == b"Not Found" + + +class TestHttpAppAsWsgiOpenAPIDoc: + def test_serves_openapi_json(self): + app = HttpApp() + + @app.get("/hello") + def hello() -> str: + return "hi" + + wsgi_app = app.as_wsgi() + status, headers, body, _ = _drive(wsgi_app, _get("/openapi.json")) + assert status.startswith("200 ") + assert ("content-type", "application/json") in headers + doc = json.loads(body) + assert doc["openapi"].startswith("3.") + assert "/hello" in doc["paths"] + + def test_serves_swagger_ui(self): + app = HttpApp() + wsgi_app = app.as_wsgi() + status, headers, body, _ = _drive(wsgi_app, _get("/docs")) + assert status.startswith("200 ") + assert any(k == "content-type" and "text/html" in v for k, v in headers) + assert b"swagger-ui" in body.lower() + + +class TestHttpAppAsWsgiStreaming: + def test_sse_generator_streams_lazily(self): + consumed: list[int] = [] + + app = HttpApp() + + @app.get("/feed") + def feed() -> Iterator[Event[str]]: + for i in range(3): + consumed.append(i) + yield Event(data=f"event-{i}") + + wsgi_app = app.as_wsgi() + environ = _get("/feed") + + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter = wsgi_app(environ, start_response) + # Pre-iteration: lazy — only what to_wsgi needed to materialise + # (which is nothing — stream() captures the iterator and returns it). + assert consumed == [] + # Drain — chunks come out one at a time as the WSGI host iterates. + chunks = list(body_iter) + assert consumed == [0, 1, 2] + assert captured["status"].startswith("200 ") + assert any(k == "content-type" and "text/event-stream" in v for k, v in captured["headers"]) + joined = b"".join(chunks) + assert b"data: event-0" in joined + assert b"data: event-2" in joined diff --git a/tests/scheduler/cond.py b/tests/scheduler/cond.py index ab404a7..8d28705 100644 --- a/tests/scheduler/cond.py +++ b/tests/scheduler/cond.py @@ -30,5 +30,5 @@ async def test_every_trigger(): mock_sleep.assert_awaited_once_with(5.0) -async def test_after_trigger(): - pass # TODO Implement +# ``after()`` end-to-end coverage lives in ``tests/scheduler/scheduler.py`` +# (test_after_trigger_chains_results / test_after_trigger_skips_failures). diff --git a/tests/scheduler/cond_cron.py b/tests/scheduler/cond_cron.py index 75b3407..2debd5a 100644 --- a/tests/scheduler/cond_cron.py +++ b/tests/scheduler/cond_cron.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime from unittest.mock import AsyncMock, Mock, patch import anyio @@ -16,7 +16,7 @@ async def test_cron_trigger(): - base = datetime(2022, 1, 1) + base = datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC) schedule = croniter("*/5 * * * *", base) scheduled_task_tpl = cron(schedule) diff --git a/tests/scheduler/scheduler.py b/tests/scheduler/scheduler.py new file mode 100644 index 0000000..c3e81b2 --- /dev/null +++ b/tests/scheduler/scheduler.py @@ -0,0 +1,210 @@ +import random +import time +from datetime import timedelta + +import anyio +import pytest +from anyio import fail_after + +from localpost.hosting import serve +from localpost.scheduler import Scheduler, Task, after, delay, every, scheduled_task, take_first + +pytestmark = pytest.mark.anyio + + +async def test_task_decorator(): + results = [] + scheduler = Scheduler() + + @scheduler.task(every(timedelta(seconds=1))) + def sample_task(): + results.append(random.randint(0, 10)) + + assert callable(sample_task) + + async with serve(scheduler) as lt: + await anyio.sleep(0.5) # "App is working" + lt.shutdown() + + assert len(results) == 1 # Only one initial run + + +async def test_scheduler_tasks(): + results = [] + scheduler = Scheduler() + + @scheduler.task(every(timedelta(seconds=1))) + def sample_task1(): + results.append("task1 result") + + @scheduler.task(every(timedelta(seconds=1))) + def sample_task2(): + results.append("task2 result") + + assert callable(sample_task1) + + async with serve(scheduler) as lt: + await anyio.sleep(0.5) # "App is working" + lt.shutdown() + + assert len(results) == 2 # Only one initial run for each task + assert "task1 result" in results + assert "task2 result" in results + + +# An empty scheduler (without any tasks) should complete immediately without errors +async def test_empty_scheduler(): + scheduler = Scheduler() + + with fail_after(1): + async with serve(scheduler): + pass + + +async def test_single_scheduled_task(): + """A single ``@scheduled_task``-decorated function is itself a ServiceF.""" + results = [] + + @scheduled_task(every(timedelta(seconds=1))) + def sample_task(): + results.append(1) + + async with serve(sample_task) as lt: + await anyio.sleep(0.5) + lt.shutdown() + + assert len(results) == 1 + + +async def test_handler_failure_does_not_kill_loop(): + """A raising handler must be logged and published as Result.failure, but the schedule loop must keep firing.""" + runs = 0 + + @scheduled_task(every(timedelta(seconds=0.05)) // take_first(3)) + def flaky(): + nonlocal runs + runs += 1 + raise RuntimeError("boom") + + with fail_after(2): + async with serve(flaky) as lt: + await lt.stopped # ``take_first(3)`` makes the service stop on its own + + assert runs == 3 + + +async def test_after_trigger_chains_results(): + """``after(task1)`` fires task2 with task1's return value on every successful run.""" + scheduler = Scheduler() + seen: list[int] = [] + + @scheduler.task(every(timedelta(seconds=0.05)) // take_first(3)) + def producer() -> int: + return 42 + + @scheduler.task(after(producer)) + def _consumer(value: int) -> None: + seen.append(value) + + with fail_after(2): + async with serve(scheduler) as lt: + await lt.stopped # producer stops after 3 firings → consumer sees EndOfStream → scheduler stops + + assert seen == [42, 42, 42] + + +async def test_after_trigger_skips_failures(): + """``after(task1)`` only fires when task1 succeeds; failures are dropped (``after_all`` would see them).""" + scheduler = Scheduler() + seen: list[int] = [] + n = 0 + + @scheduler.task(every(timedelta(seconds=0.05)) // take_first(4)) + def flaky() -> int: + nonlocal n + n += 1 + if n % 2 == 0: + raise RuntimeError("even runs fail") + return n + + @scheduler.task(after(flaky)) + def _consumer(value: int) -> None: + seen.append(value) + + with fail_after(2): + async with serve(scheduler) as lt: + await lt.stopped + + # n = 1, 3 succeed; n = 2, 4 raise. Only odd values reach ``after``. + assert seen == [1, 3] + + +async def test_delay_inserts_wait_between_events(): + """``delay(D)`` sleeps ``D`` between receiving an event and forwarding it. With ``every`` emitting + fast and ``take_first(N)`` capping the run, total elapsed time must be at least ``N * D``.""" + timestamps: list[float] = [] + + # ``every(1ms)`` keeps the source emitting fast enough that ``delay`` is the bottleneck; + # the actual cadence between handler firings is the delay duration. + @scheduled_task(every(timedelta(seconds=0.001)) // delay(timedelta(seconds=0.05)) // take_first(3)) + def tick(): + timestamps.append(time.monotonic()) + + with fail_after(2): + async with serve(tick) as lt: + await lt.stopped + + assert len(timestamps) == 3 + # Each consecutive event spent ~50ms inside ``delay``; allow generous slack for CI scheduling jitter. + intervals = [timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1)] + for interval in intervals: + assert interval >= 0.04, f"delay too short: {interval:.3f}s (expected >=0.04s)" + + +async def test_subscribe_after_task_finishes_raises(): + """``Task`` is one-shot: once the last user has exited, subscribers can't be added (the underlying + ExitStack is closed). The previous code raised a cryptic ``cannot reenter context`` deep in the + ExitStack; ``subscribe()`` now raises a clear ``RuntimeError`` up-front.""" + + def my_task() -> int: + return 7 + + task: Task[None, int] = Task(my_task) + # Drive the task through one full lifecycle. + async with task: + pass + + with pytest.raises(RuntimeError, match="already finished"): + task.subscribe() + + +async def test_shutdown_during_handler_lets_handler_finish(): + """When shutdown fires while a handler is in flight, the handler completes naturally, no further + events are dispatched, and the service stops cleanly. Locks in the current graceful-shutdown + behavior (forced cancellation requires ``lt.stop()``, not ``lt.shutdown()``).""" + handler_started = anyio.Event() + handler_can_finish = anyio.Event() + runs = 0 + + @scheduled_task(every(timedelta(seconds=0.02))) + async def slow(): + nonlocal runs + runs += 1 + if runs == 1: + handler_started.set() + await handler_can_finish.wait() + + with fail_after(2): + async with serve(slow) as lt: + await handler_started.wait() + lt.shutdown() + # Handler is blocked inside ``handler_can_finish.wait()``; the service must NOT be stopped + # yet — graceful shutdown waits for the in-flight handler to return. + await anyio.sleep(0.1) + assert not lt.stopped, "service stopped while handler was still running" + handler_can_finish.set() + await lt.stopped + + # Only the first event was dispatched; subsequent ``every`` ticks were dropped (consumer busy) + # and after shutdown the trigger closes without firing the handler again. + assert runs == 1, f"Expected exactly 1 handler invocation, got {runs}" diff --git a/tests/threadtools/__init__.py b/tests/threadtools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/threadtools/async_executor.py b/tests/threadtools/async_executor.py new file mode 100644 index 0000000..c056925 --- /dev/null +++ b/tests/threadtools/async_executor.py @@ -0,0 +1,114 @@ +"""Tests for :class:`localpost.threadtools.AsyncWorkerExecutor`. + +It's an async context manager and requires a caller-owned +:class:`localpost.Portal` running on the test's loop. + +We pin both backends (``asyncio`` and ``trio``) for the cross-backend +``check_cancelled`` story; ``stop()`` must work the same on each. +""" + +from __future__ import annotations + +import threading +import time +from collections.abc import AsyncIterator +from concurrent.futures import Future + +import anyio +import anyio.from_thread +import anyio.to_thread +import pytest + +from localpost import Portal +from localpost.threadtools import AsyncWorkerExecutor + +# Both backends, on every test in this module. +pytestmark = pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]) + + +@pytest.fixture +async def portal() -> AsyncIterator[Portal]: + """A :class:`Portal` bound to the test's running event loop. + + Submits to async executors are sync calls that schedule onto the + portal's loop. With :class:`Portal`, calls are safe from any thread — + on-loop they're direct, off-loop they hop through the underlying + :class:`BlockingPortal`. + """ + async with anyio.from_thread.BlockingPortal() as raw: + yield Portal(raw) + + +async def test_async_worker_executor_basic_submit(anyio_backend, portal: Portal): + async with AsyncWorkerExecutor(portal=portal) as ex: + # ``submit`` and ``Future.result`` must both run off the loop thread. + fut: Future[int] = await anyio.to_thread.run_sync(ex.submit, lambda: 42) + result = await anyio.to_thread.run_sync(fut.result, 5) + assert result == 42 + + +async def test_async_worker_executor_check_cancelled_callable_in_task(anyio_backend, portal: Portal): + """``from_thread.check_cancelled`` is callable inside a task running on an + :class:`AsyncWorkerExecutor` worker — i.e. AnyIO's threadlocals are wired up. + """ + polled = threading.Event() + + def task() -> str: + anyio.from_thread.check_cancelled() # raises if not wired + polled.set() + return "ok" + + async with AsyncWorkerExecutor(portal=portal) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, task) + result = await anyio.to_thread.run_sync(fut.result, 5) + assert result == "ok" + assert polled.is_set() + + +async def test_async_worker_executor_cancel_unblocks_idle_workers(anyio_backend, portal: Portal): + """Outer-scope cancellation must unblock idle workers stuck in ``cond.wait``. + + Correctness depends on a sync ordering invariant inside ``__aexit__``: + ``_mark_closed()`` (which wakes workers via ``notify_all``) runs *before* + any ``await``. If a future refactor sneaks an await between the close and + the ``await self._tg.__aexit__(...)``, this test will hang and + ``fail_after`` will surface it. + """ + with anyio.fail_after(2.0): + with anyio.move_on_after(0.1) as scope: + async with AsyncWorkerExecutor(portal=portal) as ex: + # Spawn a worker via a quick submit; let it return to cond.wait. + fut = await anyio.to_thread.run_sync(ex.submit, lambda: None) + await anyio.to_thread.run_sync(fut.result, 5) + assert ex.worker_count == 1 + # Worker is now idle in cond.wait. Sleep past the deadline. + await anyio.sleep(60) + assert scope.cancelled_caught + + +async def test_async_worker_executor_stop_propagates_to_running_task(anyio_backend, portal: Portal): + """:meth:`stop` cancels the internal task group; running tasks polling + ``check_cancelled`` raise on their next checkpoint.""" + saw_cancel = threading.Event() + started = threading.Event() + + def slow_task() -> None: + started.set() + try: + for _ in range(500): + anyio.from_thread.check_cancelled() + time.sleep(0.005) + except BaseException: + saw_cancel.set() + raise + + async with AsyncWorkerExecutor(portal=portal) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, slow_task) + await anyio.to_thread.run_sync(started.wait, 2.0) + await anyio.to_thread.run_sync(ex.stop) + # __aexit__ awaits the cancellation-driven drain. + + assert saw_cancel.is_set() + # The future ends up with a cancellation exception (backend-specific type). + assert fut.done() + assert fut.exception() is not None diff --git a/tests/threadtools/channel_props.py b/tests/threadtools/channel_props.py new file mode 100644 index 0000000..945f89d --- /dev/null +++ b/tests/threadtools/channel_props.py @@ -0,0 +1,226 @@ +"""Property-based tests for ``Channel`` (single-threaded). + +These complement the example-based / threaded tests in ``channels.py``. + +Scope: + Single-threaded ``RuleBasedStateMachine`` driving sequences of + ``put_nowait`` / ``get`` / ``close`` / ``clone`` against a small reference + model (deque + counters). Every assertion goes through the public + ``SendChannel`` / ``ReceiveChannel`` Protocol — no white-box access. The + machine catches state-tracking regressions only via observable behavior: + EOS after senders close, ``WouldBlock`` at capacity, ``ClosedResourceError`` + on closed handles, FIFO order under a single producer. + +Out of scope: + Rendezvous (``capacity=0``) and any property that depends on thread + interleavings. Those live in ``channels.py``. +""" + +from __future__ import annotations + +from collections import deque +from typing import ClassVar + +import pytest +from anyio import ClosedResourceError, EndOfStream, WouldBlock +from hypothesis import strategies as st +from hypothesis.stateful import ( + Bundle, + RuleBasedStateMachine, + consumes, + initialize, + invariant, + precondition, + rule, +) + +from localpost.threadtools import Channel + + +class _ChannelMachine(RuleBasedStateMachine): + """Drives a ``Channel`` with one capacity, one producer-mode flag. + + Subclasses set ``capacity`` and ``single_producer`` as class vars; the + machine is then exposed to pytest via ``Subclass.TestCase``. + """ + + capacity: ClassVar[int | None] = None + single_producer: ClassVar[bool] = False + + senders = Bundle("senders") + receivers = Bundle("receivers") + closed_senders = Bundle("closed_senders") + closed_receivers = Bundle("closed_receivers") + + def __init__(self) -> None: + super().__init__() + s, r = Channel.create(capacity=self.capacity) + self._first_sender = s + self._first_receiver = r + self.model_buffer: deque = deque() + self.model_open_senders = 1 + self.model_open_receivers = 1 + self.model_sent: list = [] + self.model_received: list = [] + + # --- seed bundles ------------------------------------------------------- + + @initialize(target=senders) + def _seed_sender(self): + return self._first_sender + + @initialize(target=receivers) + def _seed_receiver(self): + return self._first_receiver + + # --- clone -------------------------------------------------------------- + + @precondition(lambda self: not self.single_producer) + @rule(target=senders, s=senders) + def clone_sender(self, s): + new_s = s.clone() + self.model_open_senders += 1 + return new_s + + @rule(target=receivers, r=receivers) + def clone_receiver(self, r): + new_r = r.clone() + self.model_open_receivers += 1 + return new_r + + # --- put_nowait variants ------------------------------------------------ + + @precondition( + lambda self: self.model_open_receivers > 0 and (self.capacity is None or len(self.model_buffer) < self.capacity) + ) + @rule(s=senders, item=st.integers()) + def put_nowait_ok(self, s, item): + s.put_nowait(item) + self.model_buffer.append(item) + if self.single_producer: + self.model_sent.append(item) + + @precondition( + lambda self: ( + self.capacity is not None + and self.capacity > 0 + and len(self.model_buffer) >= self.capacity + and self.model_open_receivers > 0 + ) + ) + @rule(s=senders, item=st.integers()) + def put_nowait_full_blocks(self, s, item): + with pytest.raises(WouldBlock): + s.put_nowait(item) + + @precondition(lambda self: self.model_open_receivers == 0) + @rule(s=senders, item=st.integers()) + def put_nowait_no_receivers(self, s, item): + with pytest.raises(ClosedResourceError): + s.put_nowait(item) + + # --- get variants ------------------------------------------------------- + + @precondition(lambda self: len(self.model_buffer) > 0) + @rule(r=receivers) + def get_nonempty(self, r): + expected = self.model_buffer.popleft() + actual = r.get() + assert actual == expected + if self.single_producer: + self.model_received.append(actual) + + @precondition(lambda self: len(self.model_buffer) == 0 and self.model_open_senders == 0) + @rule(r=receivers) + def get_drained_raises_eos(self, r): + with pytest.raises(EndOfStream): + r.get() + + # --- close -------------------------------------------------------------- + + @rule(target=closed_senders, s=consumes(senders)) + def close_sender(self, s): + s.close() + self.model_open_senders -= 1 + return s + + @rule(target=closed_receivers, r=consumes(receivers)) + def close_receiver(self, r): + r.close() + self.model_open_receivers -= 1 + return r + + @rule(s=closed_senders, item=st.integers()) + def put_on_closed_sender_raises(self, s, item): + with pytest.raises(ClosedResourceError): + s.put_nowait(item) + + @rule(r=closed_receivers) + def get_on_closed_receiver_raises(self, r): + with pytest.raises(ClosedResourceError): + r.get() + + @rule(s=closed_senders) + def clone_closed_sender_raises(self, s): + with pytest.raises(ClosedResourceError): + s.clone() + + @rule(r=closed_receivers) + def clone_closed_receiver_raises(self, r): + with pytest.raises(ClosedResourceError): + r.clone() + + # --- invariants --------------------------------------------------------- + + @invariant() + def fifo_under_single_producer(self): + if not self.single_producer: + return + # Items received so far must be a prefix of items sent so far. + n = len(self.model_received) + assert self.model_sent[:n] == self.model_received + + +class _UnboundedMachine(_ChannelMachine): + capacity: ClassVar[int | None] = None + + +class _Bounded1Machine(_ChannelMachine): + capacity: ClassVar[int | None] = 1 + + +class _Bounded4Machine(_ChannelMachine): + capacity: ClassVar[int | None] = 4 + + +class _SingleProducerUnboundedMachine(_ChannelMachine): + capacity: ClassVar[int | None] = None + single_producer: ClassVar[bool] = True + + +class _SingleProducerBounded4Machine(_ChannelMachine): + capacity: ClassVar[int | None] = 4 + single_producer: ClassVar[bool] = True + + +# Expose the auto-generated unittest TestCases to pytest. + + +class TestChannelUnbounded(_UnboundedMachine.TestCase): + pass + + +class TestChannelBounded1(_Bounded1Machine.TestCase): + pass + + +class TestChannelBounded4(_Bounded4Machine.TestCase): + pass + + +class TestChannelSingleProducerUnbounded(_SingleProducerUnboundedMachine.TestCase): + pass + + +class TestChannelSingleProducerBounded4(_SingleProducerBounded4Machine.TestCase): + pass diff --git a/tests/threadtools/channels.py b/tests/threadtools/channels.py new file mode 100644 index 0000000..222b5c6 --- /dev/null +++ b/tests/threadtools/channels.py @@ -0,0 +1,641 @@ +import threading +import time +from typing import cast + +import pytest +from anyio import ClosedResourceError, EndOfStream, WouldBlock + +from localpost.threadtools import Channel, SendChannel +from localpost.threadtools._channel import ChannelState, _SendChannel + + +def _state_of[T](s: SendChannel[T]) -> ChannelState[T]: + """Reach into a sender's :class:`ChannelState` for white-box assertions. + + ``Channel.create`` returns the public ``SendChannel`` Protocol; tests + that need to observe internal counters (``waiting_receivers``, + ``pending_handoffs``) cast to the concrete impl through this helper so + the access is explicit in one place. + """ + return cast("_SendChannel[T]", s)._state + + +def test_basic_send_receive(): + """Test basic send and receive operations.""" + sender, receiver = Channel.create() + + # Send and receive a single item + sender.put(42) + assert receiver.get() == 42 + + # Send and receive multiple items + for i in range(5): + sender.put(i) + + for i in range(5): + assert receiver.get() == i + + sender.close() + receiver.close() + + +def test_multiple_senders_single_receiver(): + """Test multiple senders with a single receiver.""" + sender, receiver = Channel.create() + results = [] + num_senders = 3 + messages_per_sender = 3 + senders = [sender] + [sender.clone() for _ in range(num_senders - 1)] + + def send_messages(thread_sender: SendChannel[str], sender_id: int): + for i in range(messages_per_sender): + thread_sender.put(f"sender{sender_id}-msg{i}") + time.sleep(0.05) # Small delay to mix messages + thread_sender.close() + + # Start multiple sender threads + threads = [] + for i, ts in enumerate(senders): + t = threading.Thread(target=send_messages, args=(ts, i)) + t.start() + threads.append(t) + + # Receive all messages + try: + while True: + results.append(receiver.get()) + except EndOfStream: + pass + + # Wait for all threads to complete + for t in threads: + t.join() + + receiver.close() + + # Should have received all messages + assert len(results) == num_senders * messages_per_sender + # Check that we got messages from all senders + for i in range(num_senders): + sender_msgs = [msg for msg in results if msg.startswith(f"sender{i}")] + assert len(sender_msgs) == messages_per_sender + + +def test_single_sender_multiple_receivers(): + """Test single sender with multiple receivers.""" + sender, receiver = Channel.create() + results = {i: [] for i in range(3)} + receivers = [receiver] + [receiver.clone() for _ in range(2)] + + def receive_messages(recv, receiver_id: int): + try: + while True: + value = recv.get() + results[receiver_id].append(value) + except EndOfStream: + pass + recv.close() + + # Start multiple receiver threads + threads = [] + for i, recv in enumerate(receivers): + t = threading.Thread(target=receive_messages, args=(recv, i)) + t.start() + threads.append(t) + + # Send messages + for i in range(30): + sender.put(i) + time.sleep(0.001) # Small delay to allow receivers to compete + + sender.close() + + # Wait for all threads to complete + for t in threads: + t.join() + + # Check that all messages were received (distributed among receivers) + all_received = [] + for receiver_results in results.values(): + all_received.extend(receiver_results) + + assert sorted(all_received) == list(range(30)) + # Each receiver should have gotten some messages + for receiver_results in results.values(): + assert len(receiver_results) > 0 + + +def test_no_receivers_error(): + """Test that sending without receivers raises ClosedResourceError.""" + sender, receiver = Channel.create() + receiver.close() + + with pytest.raises(ClosedResourceError): + sender.put(42) + + sender.close() + + +def test_end_of_channel(): + """Test that receivers get EndOfStream when all senders close.""" + sender1, receiver = Channel.create() + sender2 = sender1.clone() + + sender1.put(1) + sender2.put(2) + + assert receiver.get() == 1 + assert receiver.get() == 2 + + # Close one sender - receiver should still work + sender1.close() + sender2.put(3) + assert receiver.get() == 3 + + # Close last sender - receiver should get EndOfStream + sender2.close() + with pytest.raises(EndOfStream): + receiver.get() + + receiver.close() + + +def test_closed_channel_errors(): + """Test operations on closed channels raise errors.""" + sender, receiver = Channel.create() + + # Close sender and try to use it + sender.close() + with pytest.raises(ClosedResourceError): + sender.put(42) + + # Close receiver and try to use it + receiver.close() + with pytest.raises(ClosedResourceError): + receiver.get() + + +def test_blocking_receive(): + """Test that receive blocks until item is available.""" + sender, receiver = Channel.create() + result = [] + + def delayed_send(): + time.sleep(0.1) + sender.put("delayed message") + sender.close() + + def receive(): + try: + result.append(receiver.get()) + except EndOfStream: + pass + receiver.close() + + # Start receiver first (will block) + receiver_thread = threading.Thread(target=receive) + receiver_thread.start() + + # Start sender after delay + sender_thread = threading.Thread(target=delayed_send) + sender_thread.start() + + # Wait for both threads + receiver_thread.join(timeout=1.0) + sender_thread.join(timeout=1.0) + + assert result == ["delayed message"] + + +def test_negative_capacity_rejected(): + """Channel capacities are None, 0, or a positive integer.""" + with pytest.raises(ValueError, match="capacity"): + Channel.create(capacity=-1) + + +def test_rendezvous_put_nowait_requires_waiting_receiver(): + """A capacity=0 channel has no spare buffer slot for put_nowait.""" + sender, receiver = Channel.create(capacity=0) + try: + with pytest.raises(WouldBlock): + sender.put_nowait("not-yet") + finally: + sender.close() + receiver.close() + + +def test_rendezvous_put_nowait_hands_off_to_waiting_receiver(): + sender, receiver = Channel.create(capacity=0) + received: list[str] = [] + + def receive() -> None: + received.append(receiver.get()) + + receiver_thread = threading.Thread(target=receive) + receiver_thread.start() + + deadline = time.monotonic() + 1.0 + while _state_of(sender).waiting_receivers == 0 and time.monotonic() < deadline: + time.sleep(0.001) + + try: + assert _state_of(sender).waiting_receivers == 1 + sender.put_nowait("ready") + receiver_thread.join(timeout=1.0) + assert not receiver_thread.is_alive() + assert received == ["ready"] + finally: + sender.close() + receiver.close() + + +def test_rendezvous_put_blocks_until_receiver_consumes(): + sender, receiver = Channel.create(capacity=0) + sent = threading.Event() + + def send() -> None: + sender.put("value") + sent.set() + + sender_thread = threading.Thread(target=send) + sender_thread.start() + + try: + time.sleep(0.05) + assert not sent.is_set() + assert receiver.get() == "value" + sender_thread.join(timeout=1.0) + assert not sender_thread.is_alive() + assert sent.is_set() + finally: + sender.close() + receiver.close() + + +def _wait_for(predicate, timeout: float = 1.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.001) + return predicate() + + +def test_rendezvous_put_nowait_with_multiple_receivers(): + """N concurrent put_nowait calls succeed when N receivers are waiting.""" + n = 4 + sender, receiver = Channel.create(capacity=0) + receivers = [receiver] + [receiver.clone() for _ in range(n - 1)] + received: list[int] = [] + received_lock = threading.Lock() + + def receive(r): + try: + value = r.get() + finally: + r.close() + with received_lock: + received.append(value) + + threads = [threading.Thread(target=receive, args=(r,)) for r in receivers] + for t in threads: + t.start() + + try: + assert _wait_for(lambda: _state_of(sender).waiting_receivers == n), ( + f"only {_state_of(sender).waiting_receivers} receivers waiting" + ) + + for i in range(n): + sender.put_nowait(i) + + with pytest.raises(WouldBlock): + sender.put_nowait(n) + + for t in threads: + t.join(timeout=1.0) + assert not t.is_alive() + assert sorted(received) == list(range(n)) + finally: + sender.close() + + +def test_rendezvous_put_nowait_decrements_after_consume(): + """pending_handoffs returns to 0 after each successful handoff.""" + sender, receiver = Channel.create(capacity=0) + + def receive_one(r, sink: list[str]) -> None: + sink.append(r.get()) + + try: + for round_idx in range(2): + received: list[str] = [] + t = threading.Thread(target=receive_one, args=(receiver, received)) + t.start() + assert _wait_for(lambda: _state_of(sender).waiting_receivers == 1) + + sender.put_nowait(f"msg-{round_idx}") + t.join(timeout=1.0) + assert not t.is_alive() + assert received == [f"msg-{round_idx}"] + assert _state_of(sender).pending_handoffs == 0 + finally: + sender.close() + receiver.close() + + +def test_rendezvous_concurrent_blocking_puts_pair_with_receivers(): + """N concurrent put() calls all return when N receivers are waiting.""" + n = 4 + sender, receiver = Channel.create(capacity=0) + senders = [sender] + [sender.clone() for _ in range(n - 1)] + receivers = [receiver] + [receiver.clone() for _ in range(n - 1)] + + received: list[int] = [] + received_lock = threading.Lock() + sends_done = threading.Event() + sends_completed = 0 + sends_lock = threading.Lock() + + def receive(r): + try: + value = r.get() + with received_lock: + received.append(value) + finally: + r.close() + + def send(s, value: int): + try: + s.put(value) + finally: + s.close() + nonlocal sends_completed + with sends_lock: + sends_completed += 1 + if sends_completed == n: + sends_done.set() + + receiver_threads = [threading.Thread(target=receive, args=(r,)) for r in receivers] + for t in receiver_threads: + t.start() + + try: + assert _wait_for(lambda: _state_of(sender).waiting_receivers == n) + + sender_threads = [threading.Thread(target=send, args=(s, i)) for i, s in enumerate(senders)] + for t in sender_threads: + t.start() + + assert sends_done.wait(timeout=2.0), "blocking puts did not all return" + for t in sender_threads: + t.join(timeout=1.0) + assert not t.is_alive() + for t in receiver_threads: + t.join(timeout=1.0) + assert not t.is_alive() + + assert sorted(received) == list(range(n)) + finally: + for s in senders: + try: + s.close() + except ClosedResourceError: + pass + + +def test_rendezvous_put_returns_when_its_own_item_consumed(): + """Blocking put waits for *its* item, not just any buffer drain.""" + sender, receiver = Channel.create(capacity=0) + sender_b = sender.clone() + received: list[str] = [] + b_returned = threading.Event() + + def receive_one(): + received.append(receiver.get()) + + receiver_thread = threading.Thread(target=receive_one) + receiver_thread.start() + + try: + assert _wait_for(lambda: _state_of(sender).waiting_receivers == 1) + + # "a" claims the only waiting receiver. + sender.put_nowait("a") + + def send_b(): + sender_b.put("b") + b_returned.set() + + sender_b_thread = threading.Thread(target=send_b) + sender_b_thread.start() + + # Receiver pops "a" first (FIFO). Sender B should still be in Phase 2 + # because its own item ("b") has not been consumed yet. + receiver_thread.join(timeout=1.0) + assert not receiver_thread.is_alive() + assert received == ["a"] + + time.sleep(0.05) + assert not b_returned.is_set(), "put('b') returned before 'b' was consumed" + + # New receiver consumes "b" — now B can return. + received_b: list[str] = [] + receiver_b_thread = threading.Thread(target=lambda: received_b.append(receiver.get())) + receiver_b_thread.start() + receiver_b_thread.join(timeout=1.0) + assert not receiver_b_thread.is_alive() + assert received_b == ["b"] + + assert b_returned.wait(timeout=1.0), "put('b') did not return after 'b' was consumed" + sender_b_thread.join(timeout=1.0) + assert not sender_b_thread.is_alive() + finally: + sender.close() + sender_b.close() + receiver.close() + + +def test_concurrent_stress(): + """Stress test with many concurrent senders and receivers.""" + num_senders = 5 + num_receivers = 3 + messages_per_sender = 100 + + sender, receiver = Channel.create() + senders = [sender] + [sender.clone() for _ in range(num_senders - 1)] + receivers = [receiver] + [receiver.clone() for _ in range(num_receivers - 1)] + + received = [] + received_lock = threading.Lock() + + def sender_work(s, sender_id: int): + try: + for i in range(messages_per_sender): + s.put(sender_id * 1000 + i) + finally: + s.close() + + def receiver_work(r): + local_received = [] + try: + while True: + local_received.append(r.get()) + except EndOfStream: + pass + finally: + r.close() + + with received_lock: + received.extend(local_received) + + # Start all threads + threads = [] + + # Start senders first to ensure at least one is open when receivers start + for i, s in enumerate(senders): + t = threading.Thread(target=sender_work, args=(s, i)) + t.start() + threads.append(t) + + # Give senders time to start + time.sleep(0.05) + + # Start receivers + for r in receivers: + t = threading.Thread(target=receiver_work, args=(r,)) + t.start() + threads.append(t) + + # Wait for all threads + for t in threads: + t.join(timeout=5.0) + assert not t.is_alive(), "Thread did not complete in time" + + # Verify all messages were received + assert len(received) == num_senders * messages_per_sender + + # Verify all messages are unique and accounted for + expected = [sender_id * 1000 + i for sender_id in range(num_senders) for i in range(messages_per_sender)] + + assert sorted(received) == sorted(expected) + + +def test_get_with_timeout_raises_timeout_error(): + sender, receiver = Channel.create() + started = time.monotonic() + with pytest.raises(TimeoutError): + receiver.get(timeout=0.05) + elapsed = time.monotonic() - started + assert 0.04 <= elapsed < 0.5 + sender.close() + receiver.close() + + +def test_get_with_timeout_zero_is_nonblocking(): + sender, receiver = Channel.create() + with pytest.raises(TimeoutError): + receiver.get(timeout=0) + sender.put(1) + assert receiver.get(timeout=0) == 1 + sender.close() + receiver.close() + + +def test_put_with_timeout_raises_when_buffer_full(): + sender, receiver = Channel.create(capacity=1) + sender.put(1) # fills the buffer + started = time.monotonic() + with pytest.raises(TimeoutError): + sender.put(2, timeout=0.05) + elapsed = time.monotonic() - started + assert 0.04 <= elapsed < 0.5 + sender.close() + receiver.close() + + +def test_get_nowait_empty_raises_would_block(): + sender, receiver = Channel.create() + with pytest.raises(WouldBlock): + receiver.get_nowait() + sender.put(1) + assert receiver.get_nowait() == 1 + sender.close() + receiver.close() + + +def test_get_nowait_after_senders_closed_raises_eos(): + sender, receiver = Channel.create() + sender.close() + with pytest.raises(EndOfStream): + receiver.get_nowait() + receiver.close() + + +def test_get_unblocks_when_receiver_closed_from_other_thread(): + sender, receiver = Channel.create() + raised: list[BaseException] = [] + + def receive(): + try: + receiver.get() + except BaseException as exc: # noqa: BLE001 + raised.append(exc) + + t = threading.Thread(target=receive) + t.start() + time.sleep(0.05) + receiver.close() + t.join(timeout=1.0) + assert not t.is_alive() + assert len(raised) == 1 + assert isinstance(raised[0], ClosedResourceError) + sender.close() + + +def test_close_broadcasts_to_cloned_receivers(): + """When senders close, every cloned receiver waiting in get() must wake + up and observe EndOfStream — not just one of them.""" + sender, receiver = Channel.create() + receivers = [receiver] + [receiver.clone() for _ in range(3)] + seen_eos = threading.Event() + eos_count = 0 + eos_lock = threading.Lock() + + def receive(r): + nonlocal eos_count + try: + r.get() + except EndOfStream: + with eos_lock: + eos_count += 1 + if eos_count == len(receivers): + seen_eos.set() + finally: + r.close() + + threads = [threading.Thread(target=receive, args=(r,)) for r in receivers] + for t in threads: + t.start() + time.sleep(0.05) + sender.close() + for t in threads: + t.join(timeout=1.0) + assert not t.is_alive() + assert seen_eos.is_set() + + +def test_channel_cleanup(): + """Test that channels clean up properly when references are dropped.""" + for _ in range(10): + sender, receiver = Channel.create() + sender.put(42) + assert receiver.get() == 42 + state = _state_of(sender) + sender.close() + receiver.close() + + # Verify state is clean + assert state.open_send_channels == 0 + assert state.open_receive_channels == 0 + assert len(state.buffer) == 0 diff --git a/tests/threadtools/conftest.py b/tests/threadtools/conftest.py new file mode 100644 index 0000000..8dc3b91 --- /dev/null +++ b/tests/threadtools/conftest.py @@ -0,0 +1,16 @@ +"""Shared fixtures for ``localpost.threadtools`` tests.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from localpost.threadtools import WorkerExecutor + + +@pytest.fixture +def executor() -> Iterator[WorkerExecutor]: + """A per-test :class:`WorkerExecutor` opened as a sync context manager.""" + with WorkerExecutor() as ex: + yield ex diff --git a/tests/threadtools/executor.py b/tests/threadtools/executor.py new file mode 100644 index 0000000..97a3fce --- /dev/null +++ b/tests/threadtools/executor.py @@ -0,0 +1,179 @@ +"""Tests for :class:`localpost.threadtools.WorkerExecutor` (sync, no AnyIO). + +The Async variant (``AsyncWorkerExecutor``) lives in ``async_executor.py`` +since it's an async context manager and needs a :class:`localpost.Portal`. +""" + +from __future__ import annotations + +import contextvars +import threading +import time + +import pytest + +from localpost.threadtools import WorkerExecutor + +# --------------------------------------------------------------------------- +# Submit / result +# --------------------------------------------------------------------------- + + +def test_submit_returns_future_with_result(executor: WorkerExecutor): + fut = executor.submit(lambda x: x * 2, 21) + assert fut.result(timeout=5) == 42 + + +def test_many_concurrent_submissions_all_complete(executor: WorkerExecutor): + n = 50 + + def work(i: int) -> int: + time.sleep(0.005) + return i * i + + futs = [executor.submit(work, i) for i in range(n)] + assert sorted(f.result() for f in futs) == [i * i for i in range(n)] + + +def test_submit_with_kwargs(executor: WorkerExecutor): + fut = executor.submit(lambda *, a, b: a + b, a=1, b=2) + assert fut.result(timeout=5) == 3 + + +def test_submit_propagates_exception_to_future(executor: WorkerExecutor): + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + fut = executor.submit(bad) + with pytest.raises(Boom): + fut.result(timeout=5) + + +# --------------------------------------------------------------------------- +# Lifecycle / spawn-on-demand +# --------------------------------------------------------------------------- + + +def test_spawn_on_demand_per_pending_task(): + """Concurrent submissions with no idle worker each get a fresh worker.""" + n = 8 + seen: set[int] = set() + barrier = threading.Barrier(n) + seen_lock = threading.Lock() + + def hold(): + # Sync at a barrier so all tasks must be running concurrently. + barrier.wait(timeout=5) + with seen_lock: + seen.add(threading.get_ident()) + + with WorkerExecutor() as ex: + futs = [ex.submit(hold) for _ in range(n)] + for f in futs: + f.result(timeout=5) + assert ex.worker_count == n + assert len(seen) == n + + +def test_idle_workers_are_reused(): + """Sequential submits with no concurrency reuse the single idle worker.""" + with WorkerExecutor() as ex: + idents = [ex.submit(threading.get_ident).result(timeout=5) for _ in range(20)] + assert ex.worker_count == 1 + assert len(set(idents)) == 1 + + +def test_workers_persist_through_idle(): + """Workers don't self-exit during idle stretches.""" + with WorkerExecutor() as ex: + a = ex.submit(threading.get_ident).result(timeout=5) + time.sleep(0.2) # idle stretch — must not self-exit + b = ex.submit(threading.get_ident).result(timeout=5) + assert ex.worker_count == 1 + assert a == b + + +def test_submit_after_close_raises(): + ex = WorkerExecutor() + with ex: + ex.submit(lambda: None).result(timeout=5) + with pytest.raises(RuntimeError): + ex.submit(lambda: None) + + +def test_executor_cannot_be_reused(): + ex = WorkerExecutor() + with ex: + pass + with pytest.raises(RuntimeError, match="cannot be reused"): + with ex: + pass + + +# --------------------------------------------------------------------------- +# stop() +# --------------------------------------------------------------------------- + + +def test_stop_makes_subsequent_submits_raise(): + with WorkerExecutor() as ex: + ex.submit(lambda: None).result(timeout=5) + ex.stop() + with pytest.raises(RuntimeError): + ex.submit(lambda: None) + + +def test_stop_wakes_idle_workers_so_exit_returns_promptly(): + """After ``stop()``, idle workers must wake — otherwise ``__exit__`` would hang.""" + ex = WorkerExecutor() + with ex: + ex.submit(lambda: None).result(timeout=5) + # Worker is now idle, blocked in cond.wait. + ex.stop() + # __exit__ joins the worker; if stop() didn't wake it, this would hang. + + +def test_stop_is_idempotent_and_safe_outside_with(): + ex = WorkerExecutor() + ex.stop() # not opened — no-op + with ex: + ex.stop() + ex.stop() # idempotent + ex.stop() # already closed — no-op + + +# --------------------------------------------------------------------------- +# ContextVar propagation +# --------------------------------------------------------------------------- + + +def test_task_sees_caller_context(executor: WorkerExecutor): + var: contextvars.ContextVar[str] = contextvars.ContextVar("tt_var") + var.set("caller-value") + fut = executor.submit(var.get) + assert fut.result(timeout=5) == "caller-value" + + +def test_task_mutation_does_not_leak_to_caller(executor: WorkerExecutor): + var: contextvars.ContextVar[str] = contextvars.ContextVar("tt_var", default="original") + + def mutate() -> str: + var.set("task-mutated") + return var.get() + + fut = executor.submit(mutate) + assert fut.result(timeout=5) == "task-mutated" + assert var.get() == "original" + + +def test_each_submit_captures_independently(executor: WorkerExecutor): + var: contextvars.ContextVar[int] = contextvars.ContextVar("tt_var", default=0) + var.set(1) + f1 = executor.submit(var.get) + var.set(2) + f2 = executor.submit(var.get) + assert f1.result(timeout=5) == 1 + assert f2.result(timeout=5) == 2 diff --git a/tests/threadtools/run_async.py b/tests/threadtools/run_async.py new file mode 100644 index 0000000..1ed9307 --- /dev/null +++ b/tests/threadtools/run_async.py @@ -0,0 +1,123 @@ +"""Tests for :func:`localpost.threadtools.run_async`. + +``run_async`` is the sync→async bridge: from a worker thread it dispatches a +coroutine onto the current service's loop and blocks for the result. The +portal it uses is resolved via :func:`localpost.hosting.current_service`, so +each test runs inside a hosted service. +""" + +from __future__ import annotations + +import threading + +import anyio +import pytest + +from localpost.hosting import ServiceLifetime, serve +from localpost.threadtools import run_async + +pytestmark = pytest.mark.anyio + + +async def test_run_async_returns_result_from_worker_thread(): + """A worker thread dispatches an async function back onto the loop and + receives its return value.""" + seen: dict = {} + + async def add(a: int, b: int) -> int: + seen["loop_thread"] = threading.get_ident() + return a + b + + def worker() -> int: + seen["worker_thread"] = threading.get_ident() + return run_async(add, 2, 3) + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + seen["result"] = await anyio.to_thread.run_sync(worker) + + async with serve(svc) as lt: + await lt.stopped + + assert seen["result"] == 5 + assert seen["worker_thread"] != seen["loop_thread"] + + +async def test_run_async_propagates_exception(): + """Exceptions raised by the coroutine surface in the calling thread.""" + + class Boom(Exception): + pass + + async def explode() -> None: + raise Boom("nope") + + captured: dict = {} + + def worker() -> None: + try: + run_async(explode) + except Boom as e: + captured["exc"] = e + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.to_thread.run_sync(worker) + + async with serve(svc) as lt: + await lt.stopped + + assert isinstance(captured.get("exc"), Boom) + + +async def test_run_async_supports_keyword_arguments(): + """``run_async`` forwards keyword arguments via :class:`functools.partial`.""" + + async def greet(name: str, *, greeting: str) -> str: + return f"{greeting}, {name}!" + + seen: dict = {} + + def worker() -> None: + seen["result"] = run_async(greet, "world", greeting="hi") + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.to_thread.run_sync(worker) + + async with serve(svc) as lt: + await lt.stopped + + assert seen["result"] == "hi, world!" + + +async def test_run_async_from_loop_thread_raises(): + """Calling :func:`run_async` from the loop thread is a deadlock; the + underlying portal raises ``RuntimeError``.""" + + async def noop() -> None: + return None + + seen: dict = {} + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + try: + run_async(noop) + except RuntimeError as e: + seen["exc"] = e + + async with serve(svc) as lt: + await lt.stopped + + assert isinstance(seen.get("exc"), RuntimeError) + + +def test_run_async_outside_hosting_raises(): + """No hosting context → :func:`current_service` raises ``RuntimeError``.""" + + async def noop() -> None: + return None + + with pytest.raises(RuntimeError, match="Not in hosting context"): + run_async(noop) diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py new file mode 100644 index 0000000..389f735 --- /dev/null +++ b/tests/threadtools/thread_task_group.py @@ -0,0 +1,347 @@ +"""Tests for ``localpost.threadtools.TaskGroup``. + +These run against a per-test :class:`WorkerExecutor` provided by +``conftest.py``. The TaskGroup itself is sync; the executor is sync. +No portal / async context is needed. +""" + +from __future__ import annotations + +import contextvars +import threading +import time +from concurrent.futures import Future + +import pytest + +from localpost.threadtools import TaskGroup, WorkerExecutor + +# --------------------------------------------------------------------------- +# Basic submit / result +# --------------------------------------------------------------------------- + + +def test_start_soon_returns_none(executor: WorkerExecutor): + with TaskGroup(executor) as tg: + result = tg.start_soon(lambda: 42) + assert result is None + + +def test_create_task_returns_future_with_result(executor: WorkerExecutor): + with TaskGroup(executor) as tg: + fut = tg.create_task(lambda x: x * 2, 21) + assert isinstance(fut, Future) + assert fut.result(timeout=5) == 42 + + +def test_create_task_with_kwargs(executor: WorkerExecutor): + with TaskGroup(executor) as tg: + fut = tg.create_task(lambda *, a, b: a + b, a=1, b=2) + assert fut.result(timeout=5) == 3 + + +def test_many_concurrent_tasks_all_complete(executor: WorkerExecutor): + n = 50 + + def work(i: int) -> int: + time.sleep(0.005) + return i * i + + with TaskGroup(executor) as tg: + futs = [tg.create_task(work, i) for i in range(n)] + + assert sorted(f.result() for f in futs) == [i * i for i in range(n)] + + +# --------------------------------------------------------------------------- +# Construction outside an entered context +# --------------------------------------------------------------------------- + + +def test_create_task_before_enter_raises(executor: WorkerExecutor): + tg = TaskGroup(executor) + with pytest.raises(RuntimeError, match="entered"): + tg.create_task(lambda: None) + + +# --------------------------------------------------------------------------- +# Exception capture +# --------------------------------------------------------------------------- + + +def test_create_task_exception_captured_in_future_and_raised_at_exit(executor: WorkerExecutor): + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 + with TaskGroup(executor) as tg: + fut = tg.create_task(bad) + with pytest.raises(Boom): + fut.result(timeout=5) + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], Boom) + + +def test_start_soon_exception_surfaces_in_group(executor: WorkerExecutor): + """Even fire-and-forget tasks have their errors escalate to the group.""" + + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + with pytest.raises(ExceptionGroup) as ei: + with TaskGroup(executor) as tg: + tg.start_soon(bad) + + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], Boom) + + +def test_create_task_result_reraise_is_deduped_in_group(executor: WorkerExecutor): + """When ``Future.result()`` re-raises a task exception into the body, + ``__exit__`` collapses the body copy with the group-recorded copy + (same instance) — surfacing the failure exactly once.""" + + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + with pytest.raises(ExceptionGroup) as ei: + with TaskGroup(executor) as tg: + tg.create_task(bad).result(timeout=5) + + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], Boom) + + +def test_distinct_body_and_task_exceptions_are_both_surfaced(executor: WorkerExecutor): + """Dedup is by identity — two different exception instances must both + appear in the ExceptionGroup, even if they're the same type.""" + + class Boom(Exception): + pass + + def bad(): + raise Boom("from-task") + + with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 + with TaskGroup(executor) as tg: + tg.start_soon(bad) + time.sleep(0.05) + raise Boom("from-body") + + assert len(ei.value.exceptions) == 2 + messages = sorted(str(e) for e in ei.value.exceptions) + assert messages == ["from-body", "from-task"] + + +def test_body_exception_alone_is_wrapped(executor: WorkerExecutor): + class BodyBoom(Exception): + pass + + with pytest.raises(ExceptionGroup) as ei: + with TaskGroup(executor): + raise BodyBoom("body") + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], BodyBoom) + + +def test_body_and_task_exceptions_are_merged(executor: WorkerExecutor): + class Body(Exception): + pass + + class Task(Exception): + pass + + def bad(): + raise Task("task") + + with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 + with TaskGroup(executor) as tg: + tg.start_soon(bad) + time.sleep(0.05) + raise Body("body") + types = {type(e) for e in ei.value.exceptions} + assert types == {Body, Task} + + +def test_named_group_uses_name_in_exception_message(executor: WorkerExecutor): + def bad(): + raise RuntimeError("x") + + with pytest.raises(ExceptionGroup, match="TaskGroup 'pool-x' failed"): + with TaskGroup(executor, name="pool-x") as tg: + tg.start_soon(bad) + + +# --------------------------------------------------------------------------- +# Drain semantics +# --------------------------------------------------------------------------- + + +def test_exit_blocks_until_all_tasks_complete(executor: WorkerExecutor): + started = threading.Event() + release = threading.Event() + finished = threading.Event() + + def slow(): + started.set() + release.wait(timeout=5) + finished.set() + + with TaskGroup(executor) as tg: + tg.start_soon(slow) + assert started.wait(timeout=2) + assert not finished.is_set() + release.set() + assert finished.is_set() + + +def test_nested_create_task_during_drain(executor: WorkerExecutor): + """A task can spawn more tasks; drain waits for the whole transitive set.""" + counter = 0 + counter_lock = threading.Lock() + + def leaf(): + nonlocal counter + with counter_lock: + counter += 1 + + def branch(tg: TaskGroup, depth: int): + if depth == 0: + leaf() + return + f1 = tg.create_task(branch, tg, depth - 1) + f2 = tg.create_task(branch, tg, depth - 1) + f1.result(timeout=5) + f2.result(timeout=5) + leaf() + + with TaskGroup(executor) as tg: + tg.start_soon(branch, tg, 3) + + # depth=3 → 1 + 2 + 4 + 8 = 15 leaves + assert counter == 15 + + +def test_start_soon_after_close_raises(executor: WorkerExecutor): + tg = TaskGroup(executor) + with tg: + tg.create_task(lambda: None).result(timeout=5) + with pytest.raises(RuntimeError, match="closed"): + tg.start_soon(lambda: None) + + +def test_group_cannot_be_reused(executor: WorkerExecutor): + tg = TaskGroup(executor) + with tg: + pass + with pytest.raises(RuntimeError, match="cannot be reused"): + with tg: + pass + + +# --------------------------------------------------------------------------- +# Cross-thread submission / stress +# --------------------------------------------------------------------------- + + +def test_start_soon_from_arbitrary_thread(executor: WorkerExecutor): + """``start_soon`` must be safe to call from any thread.""" + results: list[int] = [] + results_lock = threading.Lock() + + def producer(tg: TaskGroup): + for i in range(10): + tg.start_soon(_record, i, results, results_lock) + + with TaskGroup(executor) as tg: + threads = [threading.Thread(target=producer, args=(tg,)) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert sorted(results) == sorted(list(range(10)) * 5) + + +def _record(i: int, sink: list[int], lock: threading.Lock) -> None: + with lock: + sink.append(i) + + +# --------------------------------------------------------------------------- +# ContextVar propagation (delegated to the executor) +# --------------------------------------------------------------------------- + + +def test_task_sees_caller_context(executor: WorkerExecutor): + var: contextvars.ContextVar[str] = contextvars.ContextVar("test_var") + var.set("caller-value") + + with TaskGroup(executor) as tg: + fut = tg.create_task(var.get) + + assert fut.result(timeout=5) == "caller-value" + + +def test_task_mutation_does_not_leak_to_caller(executor: WorkerExecutor): + var: contextvars.ContextVar[str] = contextvars.ContextVar("test_var", default="original") + + def mutate(): + var.set("task-mutated") + return var.get() + + with TaskGroup(executor) as tg: + fut = tg.create_task(mutate) + + assert fut.result(timeout=5) == "task-mutated" + assert var.get() == "original" + + +def test_each_create_task_captures_independently(executor: WorkerExecutor): + var: contextvars.ContextVar[int] = contextvars.ContextVar("test_var", default=0) + + with TaskGroup(executor) as tg: + var.set(1) + f1 = tg.create_task(var.get) + var.set(2) + f2 = tg.create_task(var.get) + + assert f1.result(timeout=5) == 1 + assert f2.result(timeout=5) == 2 + + +# --------------------------------------------------------------------------- +# Custom executor isolation — TaskGroup uses whatever Executor it's given +# --------------------------------------------------------------------------- + + +def test_taskgroup_uses_passed_executor(): + """The same TaskGroup should dispatch to whatever executor it was + constructed with — not to any ambient or shared one.""" + seen_a: set[int] = set() + seen_b: set[int] = set() + seen_lock = threading.Lock() + + def record(sink: set[int]): + with seen_lock: + sink.add(threading.get_ident()) + + with WorkerExecutor() as ex_a, WorkerExecutor() as ex_b: + with TaskGroup(ex_a) as tg: + for _ in range(4): + tg.create_task(record, seen_a).result(timeout=5) + with TaskGroup(ex_b) as tg: + for _ in range(4): + tg.create_task(record, seen_b).result(timeout=5) + + # Different executors → distinct worker thread sets. + assert seen_a.isdisjoint(seen_b) diff --git a/tests/utils/core.py b/tests/utils/core.py index 6b9c749..82cde35 100644 --- a/tests/utils/core.py +++ b/tests/utils/core.py @@ -31,8 +31,8 @@ def test_fixed_delay(): delay = FixedDelay.create(None) assert delay() == timedelta(0) - with pytest.raises(ValueError): - FixedDelay.create("invalid") # noqa + with pytest.raises(ValueError, match="Invalid delay"): + FixedDelay.create("invalid") # type: ignore[arg-type] def test_random_delay(): @@ -77,7 +77,7 @@ def test_ensure_td(): assert ensure_td("1h") == timedelta(hours=1) # Test with invalid input - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid time period"): ensure_td("invalid") diff --git a/tests/utils/events.py b/tests/utils/events.py index b612daf..d003764 100644 --- a/tests/utils/events.py +++ b/tests/utils/events.py @@ -33,7 +33,8 @@ async def set_soon(event, delay): await wait_all([event1, event2]) tg.cancel_scope.cancel() - assert event1.is_set() and event2.is_set() + assert event1.is_set() + assert event2.is_set() assert tg.cancel_scope.cancel_called diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a83e7a0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1919 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "a2wsgi" +version = "1.10.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/cb/822c56fbea97e9eee201a2e434a80437f6750ebcb1ed307ee3a0a7505b14/a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45", size = 18799, upload-time = "2025-06-18T09:00:10.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/d5/349aba3dc421e73cbd4958c0ce0a4f1aa3a738bc0d7de75d2f40ed43a535/a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d", size = 17389, upload-time = "2025-06-18T09:00:09.676Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +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" } +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" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +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" } +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" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +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" } +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" }, +] + +[[package]] +name = "cheroot" +version = "11.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-functools" }, + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/e4/5c2020b60a55aca8d79ed55b62ad1cd7fc47ea44ad6b584e83f5f1bf58b0/cheroot-11.1.2.tar.gz", hash = "sha256:bfb70c49663f63b0440f2b54dbc6b0d1650e56dfe4e2641f59b2c6f727b44aca", size = 185716, upload-time = "2025-11-07T17:26:54.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/99/af65511a10c4212438ac52bc5e45e486e7a04d292201ad84dfd9208fe9a8/cheroot-11.1.2-py3-none-any.whl", hash = "sha256:0f6c0ba05c00fbc869fb46b1de4ec2384e1d85418ae963d3bc10ae83b688dbfa", size = 109248, upload-time = "2025-11-07T17:26:53.393Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "croniter" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/ea/98665cd116af6d3c4e79c8dc91bbd9a13746cb3c7d72efbfdef5b720c43b/croniter-3.0.4.tar.gz", hash = "sha256:f9dcd4bdb6c97abedb6f09d6ed3495b13ede4d4544503fa580b6372a56a0c520", size = 54500, upload-time = "2024-10-25T12:22:33.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f5/135d0e57e5bdd2f978388d77ee818f1ac5ac584eb48034362770001f4cad/croniter-3.0.4-py2.py3-none-any.whl", hash = "sha256:96e14cdd5dcb479dd48d7db14b53d8434b188dfb9210448bef6f65663524a6f0", size = 23220, upload-time = "2024-10-25T12:22:30.75Z" }, +] + +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "fastapi-slim" +version = "0.129.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/0c/2e0bcbca77701b740d188f265724c7cabe94553432b9cddba86e45582efa/fastapi_slim-0.129.1.tar.gz", hash = "sha256:c88ac964c7a804b5a739d809b8450a2eeb110ea5f59c8d02273452644fc7098d", size = 5957, upload-time = "2026-02-21T13:10:02.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/ae/026020a1c056978af8ea169edac7c96600c5515da06c8a9bc03fd91eb42d/fastapi_slim-0.129.1-py3-none-any.whl", hash = "sha256:8e6d734797dcfeec171714224e9cbbb1c4d34c861ed3fdd07800fe1cf8e8e862", size = 3231, upload-time = "2026-02-21T13:10:02.911Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-openapi" +version = "5.0.0rc1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/e4/035bd95dc5652391e61a2a04d9e34ba022479c4c9919c946e3312580319a/flask_openapi-5.0.0rc1.tar.gz", hash = "sha256:003a8df8232d56a718f47cba1687f02f658e0835aa64a17d2773adbf385de173", size = 574445, upload-time = "2026-02-09T07:36:15.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/11/66f0d176f578c512a917b925eab152d518b9981f7face129773463af780a/flask_openapi-5.0.0rc1-py3-none-any.whl", hash = "sha256:5cd00c6c26435a9e981c67b9580d5e30e1ee7557386752340289b11774691c65", size = 42894, upload-time = "2026-02-09T07:36:16.363Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + +[[package]] +name = "granian" +version = "2.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/0c/27aa25280b6c1f323312e83088304da8a7f3e5c1e568d3a560365ec6fa67/granian-2.7.4.tar.gz", hash = "sha256:1dc0530d7ae6b0ae43aafafe771ac0b8c38af68bbd71ab355828817faf13aac1", size = 128212, upload-time = "2026-04-23T11:55:55.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/d9/148024fd3a8bd974bb5c68a0cb48d15df7763fd1364bf090ccc2d423028a/granian-2.7.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c2f40aaecf2ba3d8232e55181c8f6db7bc68d9112a419ab8d5f9e2f33f631f5", size = 6374067, upload-time = "2026-04-23T11:54:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/c53b61a7cb67d33677d96913438eca3d79de1b1b7173a361fcdf2753ade7/granian-2.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a8111d5e74b27721e0fdda3edba7c154d44c41b469466857ca3c51b088e3846b", size = 6046338, upload-time = "2026-04-23T11:54:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/5c9dc91b9c9a05bf6ed0b795d30f4bb8f290d61502779a89ed2fd75f9fb6/granian-2.7.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74adbb6c1920dbf4271b824135639318b2a20ff5e33bc35639a8e2928a777234", size = 7000585, upload-time = "2026-04-23T11:54:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7c/c770593b24a472ab5265a44546f56079757efbf89f8e8b2229a8443e453b/granian-2.7.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b778d356b61e0389c823016ad2be50a634b80d3d28a33922f7ac39553e828ad", size = 6255544, upload-time = "2026-04-23T11:54:12.484Z" }, + { url = "https://files.pythonhosted.org/packages/15/46/796147587edb494a330294cb001cf68520ad8296a7da91d80ec672ac8615/granian-2.7.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3607b091c4ef225ee99150f3b02cb827de8d677b52fc75f0b28893244f7bab27", size = 6875124, upload-time = "2026-04-23T11:54:13.967Z" }, + { url = "https://files.pythonhosted.org/packages/c5/25/b867f624886e11053e7a6235244de26fd864a136e65d12295e728b3e5005/granian-2.7.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3d3cf4fe3cafd9b874d8b749c66c790cbf2b4225f2a7d9fb284c51b77a8e938d", size = 6982394, upload-time = "2026-04-23T11:54:15.733Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e1/5746bfe202bd2f6a1506346463ce52dd015c2b5d03d07a53ecf0fddefa3f/granian-2.7.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:846c9cbfea8684ab13d21d66855ad06dc077fb95b5590e7f5040e79994d6429d", size = 6991457, upload-time = "2026-04-23T11:54:17.325Z" }, + { url = "https://files.pythonhosted.org/packages/e0/45/fc6992839d367b6ae8fa8d88b5e70ec293162c3a2e0e6b90fc426f228df2/granian-2.7.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:d34d97cfe4a7805ecb5b1b1684f3f197bb4baf019d2a9f18e34fd1d697a03a7f", size = 7148499, upload-time = "2026-04-23T11:54:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/fe/12/16ffd64a1213858d4cf824767b398758be807dd1a6df5a303dc76994b6d6/granian-2.7.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f11336e4bcd8ef5c5143b075b5260e37e8431eb36d68564cc39416ca526c797f", size = 7006829, upload-time = "2026-04-23T11:54:20.804Z" }, + { url = "https://files.pythonhosted.org/packages/95/9a/f2fcda200f8739ddf25be72591b7a28897be0ffd952a76ec655e5f877144/granian-2.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:9e0a4370773ec4a0e92a55a33fc700b60003e335480e5c7fe941f4bc3dda2e18", size = 4026771, upload-time = "2026-04-23T11:54:22.36Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0f/fa7c63afedcb214edb96703cade360d946d5f1ca59ddb0b3d8e04587fb45/granian-2.7.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d11da4a4527ba8dc28b5533d5e3241d8d9212e593195d27c6e72c8a422010af5", size = 6373513, upload-time = "2026-04-23T11:54:24.246Z" }, + { url = "https://files.pythonhosted.org/packages/be/39/3088ce32d940f7982102ea3bdc230090e34ac56dc0bce04f2d03b56ea435/granian-2.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:057a3db87e93eca1a11255dd13b45b5dd83f798a750fd87f02e14d54db5741b6", size = 6045232, upload-time = "2026-04-23T11:54:25.708Z" }, + { url = "https://files.pythonhosted.org/packages/ac/61/588f6b5397ea4f5bd9fc8de4b8cc092c555b8d95371c03d149b3bc419277/granian-2.7.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb63d64c686799cea850c0c328d21adf75e323991a20be04923afc729432d2b5", size = 7001059, upload-time = "2026-04-23T11:54:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/58/63/2affbcecfe96f940744c2086ea3793935d5f6898207590a579c92fc8588f/granian-2.7.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f406648c47569e983f0c58bd0853bac30a2bcdc6227428255ee5cc65a8ee62b6", size = 6255487, upload-time = "2026-04-23T11:54:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/87/ac/31f7155a467020e7640e91af15ca3a70b0e7da210de42e3d3344e5eba8d0/granian-2.7.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd56306eed06e293f4848c5ea997e1d019d1ad13b8252dde1f0bc773aca85ef", size = 6875068, upload-time = "2026-04-23T11:54:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/99/22/402cc903e5c4e82bd363177392d4e1dcab8b27c1f7006c5316c37c597056/granian-2.7.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:732639e612e6b6e8d481f399f367e8c9bbb6f0e1b7b0aa74db340c574ee3dd98", size = 6982487, upload-time = "2026-04-23T11:54:32.704Z" }, + { url = "https://files.pythonhosted.org/packages/d3/92/3878f977bda82fc3a66fc7e95a54366a7b82edd53e6c9fdb3ec053693280/granian-2.7.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:47b8fdbfb369d52bb3fb884514a6a3a7e4d8e81c65fd26e5232985f2b46ebe0f", size = 6990683, upload-time = "2026-04-23T11:54:34.301Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/a1239f3bc4e9034e07cb32403e6a6d26db01bba1c244dd654f6a76bf2612/granian-2.7.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:b679086082bfd7c1aa8c248ef673b715616a4ce58eec6fbeef8b83b30ac84283", size = 7148570, upload-time = "2026-04-23T11:54:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/fef781ea7356b21f671615dd0d53adc00fad81031a9ea506f80d1f46a43d/granian-2.7.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a29191e949a99ffae2807abb7a864f7493f7a744e4fe2ddd2b5cd8db9b71378d", size = 7006976, upload-time = "2026-04-23T11:54:38.135Z" }, + { url = "https://files.pythonhosted.org/packages/56/54/ae2979fc45c06fbb37f595ee10eb6b138b6056202163b8e274d140d3f87b/granian-2.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:07d26325cc69371ea2dc9d3a9cd0cc851c1c8e3dce40aca90e8c204547b5ba7e", size = 4027044, upload-time = "2026-04-23T11:54:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/21/51/10344430e495bfa128dccc114957b33e712e971f91668788c08fe791df73/granian-2.7.4-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:4e093fe9511387313ad7ec9a76b0c78397cc584ef3dff47d46c336c5aee9cd8d", size = 6249290, upload-time = "2026-04-23T11:54:41.738Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/c7eda2e71a89a13e174598649f721c63ed3d908c0904b62621e8a433af0f/granian-2.7.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:227889f821526b8b60c5edf31b01fc987c4193bb0fc198c0998e0841e0cb719c", size = 5901799, upload-time = "2026-04-23T11:54:43.708Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/79e51f9f794389a9d6cab3d7c6b834b87d65fba72a43784eb5d2664a57a6/granian-2.7.4-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2b28d4aec5a9f2758a48da1897649a01b70ee1c00f2c4649db574527a3d00943", size = 6037594, upload-time = "2026-04-23T11:54:45.595Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d8/835873a407279435fa0c8e8ac52392d3ba5c9a652bb15c0036aa07d9c302/granian-2.7.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f708fea5024a40e0dfba1c17c1c4b09e02e00ac0ac9ac1e345b409f0c11b71e5", size = 6966672, upload-time = "2026-04-23T11:54:47.242Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/21eacdda27c38e4194de5f9bef36c4045058daf6d58533fadb7c54c70573/granian-2.7.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7006dfe9852cded794bc60008a168faf4dc2ecc18f1d74b5fde545685b699ec", size = 6563668, upload-time = "2026-04-23T11:54:49.751Z" }, + { url = "https://files.pythonhosted.org/packages/bd/06/9b19956d75277df44ee380e873a86b9890c431f2e2bcde32b3ba341f0efa/granian-2.7.4-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:77103af44034e30505fb5577b8214b0ad39cd6cbdc854ff980d4755faf93adaa", size = 6664285, upload-time = "2026-04-23T11:54:51.502Z" }, + { url = "https://files.pythonhosted.org/packages/85/33/740e0c9478be49c0778c4ea1773357680980e10e84b59bc19664033996dc/granian-2.7.4-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b23194e1e0652297086224212605edb4998442511637e732d6009506277f8ff9", size = 6820367, upload-time = "2026-04-23T11:54:53.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/3453fc1212268a01fee957122f2b1699af0efe50eca07ac570e11d1be12b/granian-2.7.4-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:f62941a4ffa1f1c2c5750cfc0b0ad96aa85d63b016125289779eef8888f5340d", size = 7132366, upload-time = "2026-04-23T11:54:55.123Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ca/8479e4d2a02f210ce68b5dc73c77953ec1dfd3769bf725d06e6ec420d502/granian-2.7.4-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:ea6f97d2ade676f1bf49b79088fa4b5640b8b9804b7470218486df3d4be50046", size = 6842094, upload-time = "2026-04-23T11:54:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/0d/96/71f95c73220726aee3e908b3ad2745c4c44fbfba508cb5ed615a9d4d367f/granian-2.7.4-cp313-cp313t-win_amd64.whl", hash = "sha256:759140ceef02ef72e57a184461927d72bcc2ddd3664c3cbbf4def7516f818041", size = 3974523, upload-time = "2026-04-23T11:54:58.541Z" }, + { url = "https://files.pythonhosted.org/packages/98/5d/a0c3d8778cd8aa68131974d34c439a38a00a32953e71e3b549759a5e3cdb/granian-2.7.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c19ebe797d7383cbb3497c599b8201af71f9fff6b18deaf9965d106f61588ab8", size = 6322736, upload-time = "2026-04-23T11:55:00.292Z" }, + { url = "https://files.pythonhosted.org/packages/5e/99/211da053030574f2402c750f3e3e5dc587f5192eac4888affe6ca8894a9f/granian-2.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4cee0bdba9179537669c2fa0afab2ce89327a372f1b2a82f280798da321c996c", size = 6052103, upload-time = "2026-04-23T11:55:02.797Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/23ec1fd519a4c0db961b05d1821869ed6371cbaf8b3d3a0a85c04f89e6ca/granian-2.7.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4bc5b54845bfb5f87537483f25c8f8e6003c3c1b4b0eadf6b93a432d0604265", size = 7000868, upload-time = "2026-04-23T11:55:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/b8798c98c90d3293d9c85580ea6021f148d5ab73ab99d1f82a0e66f73131/granian-2.7.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b550fb98b89465c8192b6e506993de6bfb956838e715ffb58e944aec1afdae99", size = 6257266, upload-time = "2026-04-23T11:55:06.962Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4f/5574db17193d90a5831120a0ce2a2dc64a711110ccb9af5a3630260c3597/granian-2.7.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7100a6a6d3835fec2a207fef536a259dd42d9efdb5c46933cf6f9d55d5bfaad", size = 6849667, upload-time = "2026-04-23T11:55:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/90b85cc6a31cbee772fc8ee731479429a64169e389444a5fdd685d44a342/granian-2.7.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:034ac1bfe8c19b5a7916d35a1ca426845db9ac11215f1b367566aec3b6530549", size = 6902612, upload-time = "2026-04-23T11:55:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/ba203ca40bd406db0412bca70281e44712f941bc6aafb59a628f4811d517/granian-2.7.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:baf1c390a25d3d9840204c39e7b801c909e99e896ae2713d898c46b563cbf962", size = 6927025, upload-time = "2026-04-23T11:55:12.663Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/77e2abfba54523943eea275ebbe733a6d186fe646304fe25f6d22b243d03/granian-2.7.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3bb99778ae05c1118cd694717d025cc0b85f5ee81f60cbcb2a8783692798db96", size = 7146800, upload-time = "2026-04-23T11:55:14.459Z" }, + { url = "https://files.pythonhosted.org/packages/1d/66/7209201856b7de8d3c643ba87e11272c4d651c216d05ea3fcbdce0da4ab0/granian-2.7.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13f0a39872afa81c6aaa8e29832371fd831373140f1f04de459ff862824f488b", size = 6999983, upload-time = "2026-04-23T11:55:16.236Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/bd1e521284714615996dcee48dad47d8b97ca2767a7e7cccd392f25fc176/granian-2.7.4-cp314-cp314-win_amd64.whl", hash = "sha256:97b5aeec98a9c6c0695bf8f068bd03aca83fc17c0d977a9c3a2e57bb5f10d47e", size = 3989433, upload-time = "2026-04-23T11:55:17.774Z" }, + { url = "https://files.pythonhosted.org/packages/45/a2/609f8f0dca7f596b5fb6e57b988b4b8f4d6579724b2720933c379d43301a/granian-2.7.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7b1aca6c654f0e61c9e493dd6d3ddb1698f47dc33ed04566a6635948b081b64", size = 6251034, upload-time = "2026-04-23T11:55:19.29Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/2eefa8ff477cce7b119ed2fe97fc1f3b2d108397d4755e83a5198149f2c8/granian-2.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4e0c8cc6850dec7180a26b6805b2c4cdbac4c1c48077fd7857a3cd8ff342d9d", size = 5912772, upload-time = "2026-04-23T11:55:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/40/9a5070badaed4ceecf4082855985840c320f7232b8c1ddc93e1732c63265/granian-2.7.4-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7e6b1f6e0fe873efa3393ef28803ff699a94254f2a7dc07422cc01d9849e2136", size = 6037318, upload-time = "2026-04-23T11:55:23.855Z" }, + { url = "https://files.pythonhosted.org/packages/95/52/1db412e63425cb12f5ca61877956583c6d12f21657b1a3e47eb3200e9c1b/granian-2.7.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dce110217825cff60f68da83280bc20471b10e004e720fa94b845e01925d8698", size = 6962778, upload-time = "2026-04-23T11:55:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/fcca39f617bf70e29ef903bb7a4d037970c637023484f2112d9ed6882516/granian-2.7.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:058f9a4ebfc7b9c2577569c6ecfd333628d0d045de272afaa65ee9933849778c", size = 6566618, upload-time = "2026-04-23T11:55:28.233Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/0da1bb552746d74275017e1ffc7fc419dd1a33345f132f6f5a90f9f41142/granian-2.7.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7c05f74fa5b5dcedc9f035a7c10b8afd90a3d941975a370f1e07c3f3095dd883", size = 6670850, upload-time = "2026-04-23T11:55:29.945Z" }, + { url = "https://files.pythonhosted.org/packages/11/2a/d0d9cdb10d2760e2f47bd4600c8eef02e326f8f7e253a80ce4ba384265e6/granian-2.7.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:8b992bbc667e3c74de4ad48ac8d735c7cddf3f709fc2097f7dd230ecc46fd7b3", size = 6824752, upload-time = "2026-04-23T11:55:32.066Z" }, + { url = "https://files.pythonhosted.org/packages/3d/79/0432f92f9df6e54394e4dd1c159c0d4814d255a2d2541fa9a5c187d19152/granian-2.7.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:df05e0f85712b3e90ddf28cb8be358664b1afa8cb8f09978141ca70052dca3a7", size = 7130809, upload-time = "2026-04-23T11:55:33.807Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11cc0e08f59f03a3cd6a1fe46d7632a0f8690ef945a495b1303140bb7541/granian-2.7.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:dbc620f35b67cf6b03d2b6a24b9b442d1bf52961eaebadb2c3ff214d3d0c8dc4", size = 6845920, upload-time = "2026-04-23T11:55:35.583Z" }, + { url = "https://files.pythonhosted.org/packages/b4/49/bcbaaeec0f68d3d1a3dd1fdd21e4a6963d303ae18027c42b2b53f87d6b89/granian-2.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:b9df8aead4d71562753788264db23d32db34147bb73294ddd90833bef1f4cf35", size = 3981107, upload-time = "2026-04-23T11:55:37.597Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0.dev0" +source = { git = "https://github.com/MagicStack/httptools.git#28d1db15eaeaab5bc7d376d2c2035d966b6e1378" } + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +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 = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.152.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/c7/3147bd903d6b18324a016d43a259cf5b4bb4545e1ead6773dc8a0374e70a/hypothesis-6.152.4.tar.gz", hash = "sha256:31c8f9ce619716f543e2710b489b1633c833586641d9e6c94cee03f109a5afc4", size = 466444, upload-time = "2026-04-27T20:18:37.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/89/0f50dd0d92e8a7dffc24f69ab910ff81db89b2f082ba42682bd57695e4d2/hypothesis-6.152.4-py3-none-any.whl", hash = "sha256:e730fd93c7578182efadc7f90b3c5437ee4d55edf738930eb5043c81ac1d97e8", size = 532145, upload-time = "2026-04-27T20:18:35.043Z" }, +] + +[[package]] +name = "icecream" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "colorama" }, + { name = "executing" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/84/6ebc95844feae8a6a29c7fd57e9e3a7ac4817ffab384dc4f0ed53b8e3c46/icecream-2.2.0.tar.gz", hash = "sha256:9d7f244187f00a13f4ac77d176990e187e9c279d6cac4f7548e338291ad97343", size = 14267, upload-time = "2026-04-03T17:42:51.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/82/9707c7b0336bca53b75f52fc350956a93da66eb6be632b370bc933216fb4/icecream-2.2.0-py3-none-any.whl", hash = "sha256:f8df7343b3e787023eec22f42fbe4722df2f93099d394fd820b91e16b2e6cb56", size = 16707, upload-time = "2026-04-03T17:42:50.001Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "localpost" +version = "0.6.0b1" +source = { editable = "." } +dependencies = [ + { name = "anyio" }, +] + +[package.optional-dependencies] +click = [ + { name = "click" }, +] +cron = [ + { name = "croniter" }, +] +http = [ + { name = "h11" }, +] +http-compress = [ + { name = "brotli" }, +] +http-fast = [ + { name = "httptools" }, +] +openapi = [ + { name = "msgspec" }, +] +openapi-attrs = [ + { name = "attrs" }, + { name = "cattrs" }, +] +rsgi = [ + { name = "granian" }, +] +scheduler = [ + { name = "humanize" }, + { name = "pytimeparse2" }, +] + +[package.dev-dependencies] +bench = [ + { name = "a2wsgi" }, + { name = "cheroot" }, + { name = "granian" }, + { name = "gunicorn" }, + { name = "pytest-benchmark" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +bench-openapi = [ + { name = "fastapi" }, + { name = "flask-openapi" }, + { name = "pydantic" }, +] +dev = [ + { name = "icecream" }, + { name = "structlog" }, + { name = "trio" }, +] +dev-hosting-services = [ + { name = "click" }, + { name = "grpcio" }, + { name = "hypercorn" }, + { name = "uvicorn" }, +] +dev-http = [ + { name = "flask" }, + { name = "httpx" }, +] +dev-openapi = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "pydantic" }, +] +dev-otel = [ + { name = "opentelemetry-exporter-otlp" }, +] +dev-sentry = [ + { name = "sentry-sdk" }, +] +dev-tools = [ + { name = "basedpyright" }, + { name = "ruff" }, + { name = "ty" }, +] +dev-types = [ + { name = "types-croniter" }, + { name = "types-grpcio" }, +] +docs = [ + { name = "zensical" }, +] +examples = [ + { name = "fastapi-slim" }, +] +tests = [ + { name = "pytest" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "setproctitle" }, +] +tests-unit = [ + { name = "coverage" }, + { name = "hypothesis" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = "~=4.12" }, + { name = "attrs", marker = "extra == 'openapi-attrs'", specifier = ">=23" }, + { name = "brotli", marker = "extra == 'http-compress'", specifier = "~=1.1" }, + { name = "cattrs", marker = "extra == 'openapi-attrs'", specifier = ">=24" }, + { name = "click", marker = "extra == 'click'", specifier = "~=8.0" }, + { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, + { name = "granian", marker = "extra == 'rsgi'", specifier = "~=2.7" }, + { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, + { name = "httptools", marker = "extra == 'http-fast'", git = "https://github.com/MagicStack/httptools.git" }, + { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, + { name = "msgspec", marker = "extra == 'openapi'", specifier = "~=0.19" }, + { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, +] +provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "rsgi", "click", "openapi", "openapi-attrs"] + +[package.metadata.requires-dev] +bench = [ + { name = "a2wsgi" }, + { name = "cheroot", specifier = "~=11.1" }, + { name = "granian", specifier = "~=2.7" }, + { name = "gunicorn", specifier = "~=25.0" }, + { name = "pytest-benchmark", specifier = "~=5.2" }, + { name = "starlette", specifier = "~=1.0" }, + { name = "uvicorn", specifier = "~=0.30" }, +] +bench-openapi = [ + { name = "fastapi", specifier = "~=0.136" }, + { name = "flask-openapi", specifier = ">=5.0.0rc1,<6" }, + { name = "pydantic", specifier = "~=2.7" }, +] +dev = [ + { name = "icecream", specifier = "~=2.1" }, + { name = "structlog", specifier = "~=25.0" }, + { name = "trio", specifier = "~=0.32" }, +] +dev-hosting-services = [ + { name = "click", specifier = "~=8.0" }, + { name = "grpcio", specifier = "~=1.68" }, + { name = "hypercorn", specifier = "~=0.17" }, + { name = "uvicorn", specifier = "~=0.30" }, +] +dev-http = [ + { name = "flask", specifier = "~=3.1" }, + { name = "httpx" }, +] +dev-openapi = [ + { name = "attrs", specifier = ">=23" }, + { name = "cattrs", specifier = ">=24" }, + { name = "pydantic", specifier = "~=2.7" }, +] +dev-otel = [{ name = "opentelemetry-exporter-otlp" }] +dev-sentry = [{ name = "sentry-sdk", specifier = "~=2.51" }] +dev-tools = [ + { name = "basedpyright", specifier = "~=1.39" }, + { name = "ruff", specifier = "~=0.15" }, + { name = "ty", specifier = "~=0.0.34" }, +] +dev-types = [ + { name = "types-croniter" }, + { name = "types-grpcio" }, +] +docs = [{ name = "zensical", specifier = ">=0.0.40,<0.1" }] +examples = [{ name = "fastapi-slim", specifier = "~=0.128" }] +tests = [ + { name = "pytest", specifier = "~=9.0" }, + { name = "pytest-timeout", specifier = "~=2.3" }, + { name = "pytest-xdist" }, + { name = "setproctitle" }, +] +tests-unit = [ + { name = "coverage", extras = ["toml"], specifier = "~=7.6" }, + { name = "hypothesis", specifier = "~=6.100" }, + { name = "pytest-cov", specifier = "~=7.0" }, + { name = "pytest-mock", specifier = "~=3.14" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/84/d55baf8e1a222f40282956083e67de9fa92d5fa451108df4839505fa2a24/opentelemetry_exporter_otlp-1.41.1.tar.gz", hash = "sha256:299a2f0541ca175df186f5ac58fd5db177ba1e9b72b0826049062f750d55b47f", size = 6152, upload-time = "2026-04-24T13:15:40.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/d5/ea4aa7dfc458fd537bd9519ea0e7226eef2a6212dfe952694984167daaba/opentelemetry_exporter_otlp-1.41.1-py3-none-any.whl", hash = "sha256:db276c5a80c02b063994e80950d00ca1bfddcf6520f608335b7dc2db0c0eb9c6", size = 7025, upload-time = "2026-04-24T13:15:17.839Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytimeparse2" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/10/cc63fecd69905eb4d300fe71bd580e4a631483e9f53fdcb8c0ad345ce832/pytimeparse2-1.7.1.tar.gz", hash = "sha256:98668cdcba4890e1789e432e8ea0059ccf72402f13f5d52be15bdfaeb3a8b253", size = 10431, upload-time = "2023-05-11T21:40:55.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9e/85abf91ef5df452f56498927affdb7128194d15644084f6c6722477c305b/pytimeparse2-1.7.1-py3-none-any.whl", hash = "sha256:a162ea6a7707fd0bb82dd99556efb783935f51885c8bdced0fce3fffe85ab002", size = 6136, upload-time = "2023-05-11T21:40:46.051Z" }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/b3/fb8291170d0e844173164709fc0fa0c221ed75a5da740c8746f2a83b4eb1/sentry_sdk-2.58.0.tar.gz", hash = "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f", size = 438764, upload-time = "2026-04-13T17:23:26.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" }, +] + +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +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 = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[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 = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "ty" +version = "0.0.35" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/53/440e7b1212c4b0abbd4adb7aed93f4971aa1f8dca386ac5515930afa9172/ty-0.0.35.tar.gz", hash = "sha256:8375c240ab38138a19db07996c9808fb7a92047c1492e1ce587c2ef5112ad3a9", size = 5629237, upload-time = "2026-05-10T18:25:17.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/84/19662ee881675815b7fafff940a365be1985730465afd9b75cb2edd5f8b3/ty-0.0.35-py3-none-linux_armv6l.whl", hash = "sha256:85ae1e59b9fb0b40e9d84fe61b29653c5f2f5e78b487ece371a7a38c20c781cf", size = 11198741, upload-time = "2026-05-10T18:24:49.378Z" }, + { url = "https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:709dbb7af4fcadb1196863c00b8791bbbbcc9dacbe15a0ff17f0af82b35d415b", size = 10948304, upload-time = "2026-05-10T18:24:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2cb0877419ab0c8708b6925cb0c2800b263842bd3c425113f200538772f3a0cc", size = 10407413, upload-time = "2026-05-10T18:24:37.422Z" }, + { url = "https://files.pythonhosted.org/packages/b6/23/fda6fae8a81ce0cb5f24cdfe63260e110c7af8844e31fa07d1e6e8ef0232/ty-0.0.35-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7afbcfc61904b7e82e7fe1a1db832a40d8f01e69dee1775f6594e552980536c", size = 10932614, upload-time = "2026-05-10T18:24:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/72/3d/b98d8d4aa1a5ed6daaf15864e838f605ca7b1e8b93b7e17b96ed4bc4dfed/ty-0.0.35-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b61498cc3e4178031c079951257fbdb209a891b4feb10ad6c40f615a51846f41", size = 10962982, upload-time = "2026-05-10T18:24:44.88Z" }, + { url = "https://files.pythonhosted.org/packages/18/c4/2881aad71bf6fb2f8df17fc8e4bc89e904e54490a3ee747b5ef73f98ac85/ty-0.0.35-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573b1eacda349fc8dba0d767b41631c3a6f66412363127c5bf2b1b40a1d898d2", size = 11476274, upload-time = "2026-05-10T18:24:42.4Z" }, + { url = "https://files.pythonhosted.org/packages/34/0f/7717650adaeaddd23eea70470e2c26d3f0b9b18fdc7f26ec9552d6001f17/ty-0.0.35-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7209746158d6393c1040aa64b3ca29622e212ea7d8bae22ba50dbcbb4f96f0a", size = 12012027, upload-time = "2026-05-10T18:25:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/22/c9/1a16cb4aab6f4707d8f550772e91abc26d1c8870f19b5e2453ad10bb8209/ty-0.0.35-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4466a1470aa4418d49a9aa45d9da7de42033addd0a2837c5b2b0eb71d3c2bcd3", size = 11648894, upload-time = "2026-05-10T18:25:12.44Z" }, + { url = "https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb44bb742d52c309dcaa6598bcf4d82eb4bf1241b9e4940461e522e30093fe8b", size = 11560482, upload-time = "2026-05-10T18:25:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/a5fb11227d5cc4ac3f29a115d8c8bc817578e8ef6907d1e4c914ddbf45ee/ty-0.0.35-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:34b219250736c989b2670a03782c61315f523f3a2be37f1f90b1207e2212c188", size = 11718495, upload-time = "2026-05-10T18:24:54.12Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cb/e92e4317388b6d1fd821a46941b448a8a1ff0bf13e22147c5167d8fa1b00/ty-0.0.35-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88e2ac497decc0940ef1a07571dee8a746112a93a09cdc7f8bca0099752e2e05", size = 10900815, upload-time = "2026-05-10T18:25:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4f/03bd87388a92567f262f35ac64e10d2be047d258f2dfcf1405f500fa2b90/ty-0.0.35-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:02cae51b53e6ec17d5d827ff1a3a76fd119705b56a92156e04399eda6e911596", size = 10998051, upload-time = "2026-05-10T18:25:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/b4/60/6edbc375ee6073973200096168f644e1081e5e55a7d42596826465b275de/ty-0.0.35-py3-none-musllinux_1_2_i686.whl", hash = "sha256:11871d730c9400d899ac0b9f3d660ed2e7e433377c8725549f8250a36a7f2620", size = 11148910, upload-time = "2026-05-10T18:24:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b1/a845d2066ed521c477450f436d4bd353d107e7c02dd6536a485944aaf892/ty-0.0.35-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ad0a2f0530d0933dcc99ad36ac556c63e384ea72ab9a18d23ad2e2c9fd61c73", size = 11671005, upload-time = "2026-05-10T18:24:56.223Z" }, + { url = "https://files.pythonhosted.org/packages/73/81/1d5912a54fb66b2f95ac828ae61d422ef5afeae1263e4d231e40796c229f/ty-0.0.35-py3-none-win32.whl", hash = "sha256:0e25d63ec4ab116e7f6757e44d16ca9216bca679d19ecc36d119cf80faada61a", size = 10481096, upload-time = "2026-05-10T18:24:39.976Z" }, + { url = "https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl", hash = "sha256:6a0a6d259f6f2f8f2f954c6f013d4e0b5eba68af6b353bf19a47d59ec254a3d5", size = 11555691, upload-time = "2026-05-10T18:25:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fb/59325221bce52f6e833d6865ce8360ef7d5e1e21151b38df6dc77c4327a7/ty-0.0.35-py3-none-win_arm64.whl", hash = "sha256:619c52c0fb2aa21961a848a1995135ad3b6d0a9aa54da0194e60f679cc200e13", size = 10925457, upload-time = "2026-05-10T18:25:10.352Z" }, +] + +[[package]] +name = "types-croniter" +version = "6.2.2.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/e4/89a0101471d6fe4e912dad24c54ae7afd90a9eaa5c74adef2c81f383f8da/types_croniter-6.2.2.20260408.tar.gz", hash = "sha256:a28a18908db371654990d30a3fd99856adc5137e475a23dbda4b10dce85525da", size = 12040, upload-time = "2026-04-08T04:27:20.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/05/b32e67944ff33e83c181cadf5835858d63f4292a2f2ff5bf6a1edb7f6fed/types_croniter-6.2.2.20260408-py3-none-any.whl", hash = "sha256:242087a5b6e201b7004e55f71ed34f466951b74551c64ef1c6a8a08c47d3cc0d", size = 9732, upload-time = "2026-04-08T04:27:19.229Z" }, +] + +[[package]] +name = "types-grpcio" +version = "1.0.0.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/e8af15d909e1f7bf46bac6f21e3faa0f16a92b4d5bd685d52e2c25ee26ca/types_grpcio-1.0.0.20260408.tar.gz", hash = "sha256:c8ebe07f91492a32e0f3a3810669d236828d5a2a1e540ab16cca8b5a46f8ee5d", size = 14551, upload-time = "2026-04-08T04:27:51.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/b2/965d99a23d94e4697a76c88c17afcaf8013332583087551697a391b972bd/types_grpcio-1.0.0.20260408-py3-none-any.whl", hash = "sha256:256f2738a4a3eb2ebabd6d027f4fc7eace024822afb3b629e82811e0b661fc35", size = 15209, upload-time = "2026-04-08T04:27:50.02Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.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" } +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" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "zensical" +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/88062f7e235f58a5f05d82005fc35d9dbaed27c024fe9ffae5bce7f33661/zensical-0.0.40.tar.gz", hash = "sha256:5c294751977a664614cb84e987186ad8e282af77ce0d0d800fe48ee57791279d", size = 3920555, upload-time = "2026-05-04T16:19:07.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/c4/3066f4442923ca1e49269147b70ca7c84467524e8f5228724693b9ac85c2/zensical-0.0.40-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b65a7143c9c6a460880bf3e65b777952bd2dcede9dd17a6c6bac9b4a0686ad9b", size = 12691533, upload-time = "2026-05-04T16:18:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/03e961cbd01620ea91aeb835b0b4e8848c7bcdf5a799a620fb3e57bfc277/zensical-0.0.40-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:045bdcb6d00a11ddcab7d379d0d986cdf78dba8e9287d8e628ef11958241507d", size = 12556486, upload-time = "2026-05-04T16:18:35.278Z" }, + { url = "https://files.pythonhosted.org/packages/60/76/7dde50220808bdc5f5e63b97866a684418410b3cae9d00cdae1d449bcc20/zensical-0.0.40-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48ec476c2e8ce3f8585a1278083aabc35ec80361f2c4fc4a53b9a525778f7fc", size = 12935602, upload-time = "2026-05-04T16:18:38.308Z" }, + { url = "https://files.pythonhosted.org/packages/51/55/6c8ef951c390b42249738f4338498e7a1fd64ff09e44d7cc19f5c948c45b/zensical-0.0.40-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c38e0ae314c25f2e5e64210bbad9be6e970f2d40fe9da106586ad90ce5e85e", size = 12904314, upload-time = "2026-05-04T16:18:41.007Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ae/95008f5dc2ee441efcdc2fab36ff29ce24d7477e53390fc340c8add39342/zensical-0.0.40-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25f62dcd61f6306cab890dfa34c81d2709f5db290b4c3f2675343771db28c90", size = 13269946, upload-time = "2026-05-04T16:18:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/cdbb2bf04255ccaaa07861bdda1ee8dd1630d2233fc2f09636abbd5e084c/zensical-0.0.40-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168fe3489dd93ae92978b4db11d9300c63e10d382b81634232c2872ce9e746c2", size = 12974962, upload-time = "2026-05-04T16:18:47.462Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ce/66e86f89fc15bbe667794ba67d7efc8fa72fe7a1be19e1efb4246ff55442/zensical-0.0.40-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8652ba203bd588ebf2d66bda4457a4a7d8e193c886960859c75081c0e3b946de", size = 13111599, upload-time = "2026-05-04T16:18:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/87/76/3d71ebdabb02d79a5c523b5e646141c362c9559947078c8d56a9f3bd7a30/zensical-0.0.40-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ffa6cf208b7ab6b771703be827d4d8c7f07f173abeffb35a8015a0b832b2a40", size = 13175406, upload-time = "2026-05-04T16:18:53.209Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/2bb5f730786d590f02cb0fef796c148d5ac0d5c1556f2d78c987ad4e1346/zensical-0.0.40-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7101ba0c739c78bc3a57d22130b59b9e6fdf96c21c8a6b4244070de6b34527d4", size = 13324783, upload-time = "2026-05-04T16:18:56.41Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8c/1d2ba1454360ee948dd0f0807b048c076d9578d0d9ebba2a438ecfa9f82f/zensical-0.0.40-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39bf728a68a5418feeda8f3385cd1063fdb8d896a6812c3dede4267b2868df12", size = 13260045, upload-time = "2026-05-04T16:18:59.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/61/efd51c5c5e15cfd5498d59df250f60294cc44d36d8ce4dc2a76fa3669c2f/zensical-0.0.40-cp310-abi3-win32.whl", hash = "sha256:bc750c3ba8d11833d9b9ac8fc14adc3435225b6d17314a21a91eb60209511ca5", size = 12244913, upload-time = "2026-05-04T16:19:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/f3f2118fbcfd1c2dc705491c8864c596b1a748b67ffe2a024e512b9201ab/zensical-0.0.40-cp310-abi3-win_amd64.whl", hash = "sha256:c5c86ac468df2dfe515ff54ffa97725c38226f1e5c970059b7e88078abab89ab", size = 12475762, upload-time = "2026-05-04T16:19:05.025Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +]