From bc6e61179777ca231267cb149aa09209b830d78d Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Tue, 7 Jul 2026 20:36:03 +0530 Subject: [PATCH 1/7] Refactor inject parameter handling in route definitions and add validation tests for inject values --- src/quater/_route_definition.py | 2 +- src/quater/groups.py | 2 +- tests/unit/test_app_config.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/quater/_route_definition.py b/src/quater/_route_definition.py index b49f9de..1918dde 100644 --- a/src/quater/_route_definition.py +++ b/src/quater/_route_definition.py @@ -71,7 +71,7 @@ def build_route_definition( cli=cli, route_name=route_name, ), - inject=dict(inject or {}), + inject=normalize_inject(inject), metadata=dict(metadata or {}), middleware=MiddlewareStack.from_parts( before=before, diff --git a/src/quater/groups.py b/src/quater/groups.py index 6539a50..ce96c52 100644 --- a/src/quater/groups.py +++ b/src/quater/groups.py @@ -138,7 +138,7 @@ def add_route( cli=cli, needs_approval=needs_approval, public=public, - inject=normalize_inject(inject), + inject=inject, metadata=metadata, before=before, after=after, diff --git a/tests/unit/test_app_config.py b/tests/unit/test_app_config.py index ff04558..d976b8c 100644 --- a/tests/unit/test_app_config.py +++ b/tests/unit/test_app_config.py @@ -6,7 +6,7 @@ import pytest -from quater import AuthConfig, Quater, Request +from quater import AuthConfig, Quater, Request, Resource from quater.config import AppConfig from quater.exceptions import ConfigurationError, RouteConflictError from quater.response import Response @@ -486,3 +486,34 @@ async def handler() -> dict[str, bool]: assert route.cli is False assert route.needs_approval is False assert route.public == () + + +def test_app_add_route_rejects_invalid_inject_parameter_names() -> None: + async def provider() -> str: + return "bad" + + async def handler(value: str) -> dict[str, str]: + return {"value": value} + + app = Quater() + with pytest.raises(ConfigurationError, match="Invalid injected parameter name"): + app.add_route( + "GET", + "/", + handler, + inject={"bad-name": Resource(provider)}, + ) + + +def test_app_add_route_rejects_non_resource_inject_values() -> None: + async def handler(value: str) -> dict[str, str]: + return {"value": value} + + app = Quater() + with pytest.raises(TypeError, match="inject values must be Resource instances"): + app.add_route( + "GET", + "/ok", + handler, + inject=cast(dict[str, Resource[str]], {"value": object()}), + ) From 30d71e5e7b702c03c0198edef17e734ee2d762a5 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Tue, 7 Jul 2026 20:55:24 +0530 Subject: [PATCH 2/7] Updated changelog and docs --- docs/en/dev/changelog.md | 10 ++++++++++ docs/en/dev/resources.md | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/docs/en/dev/changelog.md b/docs/en/dev/changelog.md index 3097416..75be732 100644 --- a/docs/en/dev/changelog.md +++ b/docs/en/dev/changelog.md @@ -16,6 +16,16 @@ Read [Stability](/en/dev/stability) before upgrading Quater versions. Unreleased changes on `main`. Renamed to the version number when the release is cut. +### Changed + +- `Quater.add_route()` now validates the `inject` mapping at registration time, + matching `RouteGroup` behavior. Invalid injected parameter names (e.g. + `bad-name`) raise `ConfigurationError` and non-`Resource` values raise + `TypeError` when the route is added, instead of surfacing later during route + compilation. Deeper handler-plan checks (unused injected values, path/param + marker conflicts) remain compile-time. + ([#131](https://github.com/DevilsAutumn/quater/issues/131)) + ## 0.2.1 ### Changed diff --git a/docs/en/dev/resources.md b/docs/en/dev/resources.md index 6d3bca1..ba6cc25 100644 --- a/docs/en/dev/resources.md +++ b/docs/en/dev/resources.md @@ -278,6 +278,12 @@ decorator says where its value comes from. This is the only form that a [`RouteGroup`](#groups) can share across several routes, so prefer it when a whole feature needs the same resource. +The `inject` map is validated when the route is registered (via +`app.add_route()`, `@app.get(...)`, `RouteGroup(...)`, or `group.add_route()`). +Keys must be valid Python identifiers and values must be `Resource` instances, +otherwise registration raises `ConfigurationError` or `TypeError` immediately +instead of failing later during route compilation. + ### In the type annotation (`Annotated[T, resource]`) Put the `Resource` in the parameter's annotation. The parameter type is still @@ -413,6 +419,14 @@ The generated MCP and CLI schemas include `order_id`, not `session`. `Resource provider 'db_session' yielded more than once` : Use one `yield`, then cleanup after it. +`Invalid injected parameter name: 'bad-name'` +: An `inject={...}` key is not a valid Python identifier. Rename the key. + Raised at route registration. + +`inject values must be Resource instances` +: An `inject={...}` value is not a `Resource`. Wrap the provider with + `Resource(...)`. Raised at route registration. + `Duplicate injected parameter: session` : A group and a route both define `session` with different `Resource` objects. Use the same object or rename one parameter. From b1e55a167e3f0db5f34cb7a3ed9d4305c5ff6733 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Wed, 15 Jul 2026 15:08:30 +0530 Subject: [PATCH 3/7] Updated changelog to remove issue reference and adjusted Quickstart documentation entry formatting. --- .DS_Store | Bin 0 -> 8196 bytes GETTING_STARTED.md | 359 +++++ docs/en/dev/changelog.md | 3 +- scripts/_reference/__init__.py | 1 + scripts/_reference/manual.py | 40 + scripts/_reference/output.py | 38 + scripts/_reference/pages.py | 164 ++ scripts/_reference/paths.py | 13 + scripts/_reference/render.py | 1266 ++++++++++++++++ scripts/_reference/signatures.py | 185 +++ scripts/_reference/tables.py | 711 +++++++++ scripts/_reference/types.py | 121 ++ scripts/generate_reference.py | 2442 +----------------------------- 13 files changed, 2922 insertions(+), 2421 deletions(-) create mode 100644 .DS_Store create mode 100644 GETTING_STARTED.md create mode 100644 scripts/_reference/__init__.py create mode 100644 scripts/_reference/manual.py create mode 100644 scripts/_reference/output.py create mode 100644 scripts/_reference/pages.py create mode 100644 scripts/_reference/paths.py create mode 100644 scripts/_reference/render.py create mode 100644 scripts/_reference/signatures.py create mode 100644 scripts/_reference/tables.py create mode 100644 scripts/_reference/types.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d4ed901ed82ead1f1d6a68f6470b39ceb8d916c6 GIT binary patch literal 8196 zcmeHM+invv5It@~6KGW~5>g*myf+emur!HKBq$Os&yZ}Ql6KQYX^P4V+HdFw^dtB% zegV$dF0%K6#0#QgEZO5-&+*Kh@vh?y0CR9Tdk*XY=&_1?w9TfW$hg#6+37;#i zu(3w~$_{Va!amOd7L$0HdRaPYp`6p59*l?CmkHd!C|~*<4-8Y zS|={C>M*I3+A0IeK%D_OyYFCv6U=cQo!@g@jPoL!=Eamc@>m_P_J7#I9`FEDi^}j3 zOU|+4$-j#ER>KUe<_QY)`IVqRhO^j?dG=e(nwTZ>3g)_kd58ht;1I`ni!nWa$KIIN zFKKm&S&ZJ0mq{2|2Q%wgQ#3aL13kun#dwzZ#8~Fl=xrpS{uA0C(UV(W-s6b<7xeL% z_lHfs8Hp^Mql;IJWI+8xqDKvpgxXuo>V$b+;4|j2uH@6~2GKlb6cfn@TUihLxA~!8 zr~Ix{;Jh*evm4E@Q<UjSyYh zncI9#7*!K1UjtUU)X9ig(7$yx!w?2S?M-U0ki|UNctfo%&Lq!^u)MD5GT+gR_o&O2 zRktmxHqz|kl38wfs%*Bu2XSr7iV$Bd7cH5x@r9_8tC3r<-G$l6gCiHZ{|gaXlz}_M zz^3zcPtO0N_V539C?a)L8BhlPlmXKj9u4_p(Cz9CXxZfx)(@;wWcM$fl&)MyKLmLF c>kmWr6R(qcSvtuP7X1)lY0yR)_^S;31dV(wF8}}l literal 0 HcmV?d00001 diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 0000000..8886ab1 --- /dev/null +++ b/GETTING_STARTED.md @@ -0,0 +1,359 @@ +# Getting Started with Quater + +## What is Quater? + +Quater is a Python backend framework where **one route serves three surfaces**: + +| Surface | Who uses it | How | +|---------|------------|-----| +| **HTTP API** | Browsers, frontends, services | Standard REST calls | +| **MCP (Model Context Protocol)** | AI agents (Claude, GPT, etc.) | JSON-RPC 2.0 over `POST /mcp` | +| **CLI** | Developers, operators, scripts | `quater call ` | + +### The Core Problem + +Traditional frameworks serve data to frontends — humans click buttons to get work done. But AI agents shouldn't navigate UIs. They need **direct, safe, structured backend access** with proper auth, audit trails, and approval gates. + +Building separate APIs for agents means duplicating logic across HTTP handlers, agent tool definitions, and CLI scripts. Three codebases doing the same thing. + +### The Solution + +Quater lets you write **one handler** and expose it to all three surfaces with flags: + +```python +@app.get("/orders/{order_id}", tool=True, cli=True) +async def get_order(order_id: str) -> dict: + return ORDERS[order_id] +``` + +- `tool=True` → available as an MCP tool for AI agents +- `cli=True` → available as a CLI action for operators +- HTTP works by default + +Auth, validation, and schemas are shared. No duplication. + +--- + +## Prerequisites + +- **Python 3.11+** +- **Rust toolchain** (for native router — install via [rustup](https://rustup.rs/)) +- **uv** (fast Python package manager) + +```bash +# Install uv +pip install uv + +# Install Rust (if not present) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +--- + +## Installation + +### From PyPI (use as a dependency) + +```bash +pip install quater +# or +uv add quater +``` + +### From Source (development) + +```bash +git clone https://github.com/DevilsAutumn/quater.git +cd quater + +# Install all dependencies + build Rust extension +uv sync --group dev +``` + +--- + +## Running the Application + +### Development Mode + +```bash +# Auto-reload enabled, debug logging, relaxed safety checks +quater dev examples/basic_app.py:app + +# Or with uv (from source) +uv run quater dev examples.basic_app:app +``` + +### Production Mode + +```bash +# Strict safety checks — requires allowed_hosts to be configured +quater run main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +### Quick Dev Mode (skip host validation) + +```bash +uv run quater run --allow-insecure --reload examples.basic_app:app +``` + +Server starts at **http://127.0.0.1:8000** by default. + +### Server Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--host` | `127.0.0.1` | Bind address | +| `--port` | `8000` | Bind port | +| `--workers` | `1` | Worker processes | +| `--reload / --no-reload` | off | Auto-reload on file changes | +| `--interface` | `rsgi` | Server interface: `rsgi`, `asgi`, `wsgi` | +| `--loop` | `auto` | Event loop: `asyncio`, `uvloop`, `rloop` | +| `--log-level` | `info` | `debug`, `info`, `warning`, `error`, `critical` | +| `--access-log` | off | Enable access logging | +| `--allow-insecure` | off | Skip production safety checks | +| `--factory` | off | Treat target as app factory callable | + +--- + +## Documentation Endpoints + +Once the server is running, these documentation pages are available: + +### Swagger UI (OpenAPI Docs) + +``` +http://127.0.0.1:8000/docs +``` + +Interactive API documentation. Browse all routes, try requests, see request/response schemas. + +### OpenAPI JSON Schema + +``` +http://127.0.0.1:8000/openapi.json +``` + +Raw OpenAPI 3.1.1 schema. Import into Postman, Insomnia, or any OpenAPI-compatible tool. + +### MCP Tools Documentation + +``` +http://127.0.0.1:8000/mcp/docs +``` + +Human-readable page listing all MCP-exposed tools with their schemas, descriptions, and example JSON-RPC requests. + +> **Disable docs** in production by setting `docs_path=None`, `openapi_path=None`, or `mcp_docs_path=None` in the app constructor. + +--- + +## Calling Your API — Three Ways + +Given this route: + +```python +@app.get("/orders/{order_id}", tool=True, cli=True, description="Fetch one order by id.") +async def get_order(order_id: str, request: Request) -> dict: + ... +``` + +### 1. HTTP (curl / browser / frontend) + +```bash +curl -H "Authorization: Bearer admin-token" \ + http://127.0.0.1:8000/orders/ord_1001 +``` + +### 2. MCP (AI agents like Claude) + +```bash +curl -X POST http://127.0.0.1:8000/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer admin-token" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_order", + "arguments": {"order_id": "ord_1001"} + } + }' +``` + +### 3. CLI (operators / scripts) + +```bash +# Local (imports app directly) +quater call get_order --order-id ord_1001 + +# Remote (calls a running server) +quater connect prod https://api.example.com +quater login prod # paste token +quater call get_order --order-id ord_1001 --remote prod +``` + +--- + +## Key Features + +### Per-Surface Authentication + +```python +app = Quater(auth=[ + AuthConfig(verify_jwt, surfaces=["api"]), # JWT for HTTP + AuthConfig(verify_api_key, surfaces=["mcp"]), # API key for agents + AuthConfig(verify_local_user, surfaces=["cli"]), # Local user for CLI +]) +``` + +Each surface can have its own auth strategy. Routes without auth config are deliberately public. + +### Request Safety (On by Default) + +- **Host checking** — `allowed_hosts` required in production +- **CORS** — configurable via `CORSConfig` +- **Body size limits** — 2MB default, configurable +- **Request IDs** — `x-request-id` header auto-generated +- **Security headers** — `X-Content-Type-Options`, `Referrer-Policy`, etc. + +### Audit & Observability + +Every request carries metadata: + +```python +request.context.source # "api", "mcp", or "cli" +request.context.entrypoint # "server" or "local" +request.context.request_id # unique per request +``` + +MCP tool calls support audit hooks: + +```python +app = Quater(mcp_audit=my_audit_hook) +``` + +### Middleware + +Four types: **before**, **after**, **around**, **exception** — composable at global and route level. + +### Resource Injection + +```python +@app.resource +async def db(): + async with get_session() as session: + yield session + +@app.get("/users") +async def list_users(request: Request): + session = await request.resolve(db) + ... +``` + +### Approval Gates (Human-in-the-Loop) + +For sensitive operations, require explicit user approval before execution via CLI actions. + +--- + +## Example Apps + +| File | What it shows | +|------|--------------| +| `examples/basic_app.py` | Minimal app — health, hello, echo | +| `examples/auth_and_mcp.py` | Auth + MCP tools + audit hooks | +| `examples/asgi_compat.py` | ASGI adapter usage | +| `examples/wsgi_compat.py` | WSGI adapter usage | + +Run any example: + +```bash +uv run quater dev examples.basic_app:app +uv run quater dev examples.auth_and_mcp:app +``` + +--- + +## Running Tests + +```bash +uv run pytest # all tests +uv run pytest tests/unit/ # unit only +uv run pytest tests/integration/ # integration only +uv run pytest tests/security/ # security tests +uv run pytest --cov # with coverage +``` + +--- + +## Project Structure + +``` +src/quater/ +├── app.py # Main Quater class +├── auth.py # Per-surface authentication +├── config.py # AppConfig (security, limits, docs paths) +├── request.py # Request object +├── response.py # JSON, HTML, Stream, Redirect responses +├── middleware.py # Before/After/Around/Exception middleware +├── security.py # Host validation, CORS, headers +├── docs/ # OpenAPI + Swagger UI generation +├── tools/ # MCP tool registry & handler +├── actions/ # CLI action registry & executor +├── cli/ # CLI commands (dev, run, call, connect) +├── adapters/ # RSGI, ASGI, WSGI server adapters +└── _router.pyd # Rust-compiled native route matcher +``` + +--- + +## Configuration Reference + +```python +app = Quater( + # Security + debug=False, + security="strict", # "strict" | "relaxed" | "off" + allowed_hosts=["api.example.com"], + trusted_proxies=("10.0.0.0/8",), + + # Auth + auth=[AuthConfig(my_auth, surfaces=["api", "mcp"])], + + # CORS + cors=CORSConfig( + allowed_origins=["https://app.example.com"], + allow_credentials=True, + max_age=3600, + ), + + # MCP + mcp_allowed_origins=["http://localhost:3000"], + mcp_audit=my_audit_hook, + + # Docs + docs_path="/docs", # Swagger UI (None to disable) + openapi_path="/openapi.json", # OpenAPI schema (None to disable) + mcp_docs_path="/mcp/docs", # MCP docs (None to disable) + + # Limits + max_body_size="50mb", + max_file_size="10mb", + max_tool_response_size="2mb", + + # Observability + request_id_header="x-request-id", +) +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `QUATER_ENV` | `development` or `production` | +| `QUATER_APP` | Default app module (e.g., `main:app`) | +| `QUATER_TOKEN` | Auth token for CLI remote calls | +| `QUATER_MAX_BODY_SIZE` | Override max body size | +| `QUATER_MAX_FILE_SIZE` | Override max file size | diff --git a/docs/en/dev/changelog.md b/docs/en/dev/changelog.md index 3859022..79f0148 100644 --- a/docs/en/dev/changelog.md +++ b/docs/en/dev/changelog.md @@ -33,7 +33,6 @@ cut. `TypeError` when the route is added, instead of surfacing later during route compilation. Deeper handler-plan checks (unused injected values, path/param marker conflicts) remain compile-time. - ([#131](https://github.com/DevilsAutumn/quater/issues/131)) ([#140](https://github.com/DevilsAutumn/quater/issues/140)) ### Fixed @@ -406,4 +405,4 @@ No public API is deprecated in this alpha. - [Known Limitations](/en/dev/known-limitations): current gaps. - [Stability](/en/dev/stability): version pinning and upgrade expectations. -- [Quickstart](/en/dev/quickstart): first working app. +- [Quickstart](/en/dev/quickstart): first working app. \ No newline at end of file diff --git a/scripts/_reference/__init__.py b/scripts/_reference/__init__.py new file mode 100644 index 0000000..aa1a6a8 --- /dev/null +++ b/scripts/_reference/__init__.py @@ -0,0 +1 @@ +"""Internal helpers for scripts/generate_reference.py.""" diff --git a/scripts/_reference/manual.py b/scripts/_reference/manual.py new file mode 100644 index 0000000..13db7df --- /dev/null +++ b/scripts/_reference/manual.py @@ -0,0 +1,40 @@ +"""Loader for manually-authored reference pages. + +If every reference file exists and none carries the generated header, the +generator hands the manual content back unchanged (after checking each +public symbol has an anchor on its assigned page). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from _reference.pages import PAGES, ReferencePage +from _reference.paths import GENERATED_HEADER, REFERENCE_DIR +from _reference.types import symbol_anchor + + +def read_manual_reference( + public_api: tuple[str, ...], + pages_by_symbol: Mapping[str, ReferencePage], +) -> dict[Path, str] | None: + paths = {REFERENCE_DIR / "index.md", *(page.path for page in PAGES)} + outputs: dict[Path, str] = {} + for path in paths: + if not path.exists(): + return None + content = path.read_text(encoding="utf-8") + if content.startswith(GENERATED_HEADER): + return None + outputs[path] = content + + for name in public_api: + page = pages_by_symbol[name] + content = outputs[page.path] + anchor = symbol_anchor(name) + if anchor not in content: + raise SystemExit( + f"Manual reference page {page.path} does not document {name!r}" + ) + return outputs diff --git a/scripts/_reference/output.py b/scripts/_reference/output.py new file mode 100644 index 0000000..507e13a --- /dev/null +++ b/scripts/_reference/output.py @@ -0,0 +1,38 @@ +"""Write generated reference files or diff them in --check mode.""" + +from __future__ import annotations + +import difflib +import sys +from collections.abc import Mapping +from pathlib import Path + +from _reference.paths import REFERENCE_DIR + + +def write_outputs(outputs: Mapping[Path, str]) -> None: + REFERENCE_DIR.mkdir(parents=True, exist_ok=True) + for path, content in sorted(outputs.items()): + path.write_text(content, encoding="utf-8") + + +def check_outputs(outputs: Mapping[Path, str]) -> int: + stale = False + for path, expected in sorted(outputs.items()): + if not path.exists(): + print(f"Missing generated reference file: {path}", file=sys.stderr) + stale = True + continue + actual = path.read_text(encoding="utf-8") + if actual == expected: + continue + stale = True + diff = difflib.unified_diff( + actual.splitlines(), + expected.splitlines(), + fromfile=str(path), + tofile=f"{path} (generated)", + lineterm="", + ) + print("\n".join(diff), file=sys.stderr) + return 1 if stale else 0 diff --git a/scripts/_reference/pages.py b/scripts/_reference/pages.py new file mode 100644 index 0000000..21fa5fe --- /dev/null +++ b/scripts/_reference/pages.py @@ -0,0 +1,164 @@ +"""Reference page definitions and public-API validation.""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from _reference.paths import PACKAGE_INIT, REFERENCE_DIR +from _reference.signatures import object_for + +MIN_PUBLIC_DOCSTRING_WORDS = 8 +PLACEHOLDER_DOCSTRING_WORDS = frozenset({"todo", "tbd", "fixme", "placeholder"}) + + +@dataclass(frozen=True, slots=True) +class ReferencePage: + slug: str + title: str + description: str + symbols: tuple[str, ...] + + @property + def path(self) -> Path: + return REFERENCE_DIR / f"{self.slug}.md" + + +PAGES: tuple[ReferencePage, ...] = ( + ReferencePage( + slug="application", + title="Application", + description="App objects, route groups, and configuration.", + symbols=("Quater", "RouteGroup", "AppConfig", "CORSConfig", "__version__"), + ), + ReferencePage( + slug="resources", + title="Resources", + description="Request-scoped resources injected into handlers.", + symbols=("Resource",), + ), + ReferencePage( + slug="request", + title="Request", + description="Request data and state passed through handlers.", + symbols=("Request", "State", "FormData", "UploadFile"), + ), + ReferencePage( + slug="parameters", + title="Parameters", + description="Handler parameter markers for request data binding.", + symbols=("Path", "Query", "Body", "Form", "File", "Header", "Cookie"), + ), + ReferencePage( + slug="responses", + title="Responses", + description="Return values and explicit response classes.", + symbols=( + "Response", + "JSONResponse", + "TextResponse", + "HTMLResponse", + "BytesResponse", + "StreamResponse", + "RedirectResponse", + "EmptyResponse", + ), + ), + ReferencePage( + slug="auth", + title="Auth and Security", + description=( + "Auth hooks, approval hooks, framework errors, and signed cookies." + ), + symbols=( + "AuthConfig", + "AuthContext", + "ApprovalRequest", + "ActionApproval", + "HTTPError", + "ImproperlyConfigured", + "SignedCookieSigner", + ), + ), + ReferencePage( + slug="observability", + title="Observability", + description="Access-log and MCP audit event types.", + symbols=("AccessLogEvent", "AccessLogHook", "ToolAuditEvent"), + ), + ReferencePage( + slug="testing", + title="Testing", + description="In-process HTTP, MCP, and CLI test clients.", + symbols=("TestClient", "TestResponse", "MCPTestClient", "CliTestClient"), + ), +) + + +def page_symbols(slug: str) -> tuple[str, ...]: + for page in PAGES: + if page.slug == slug: + return page.symbols + raise KeyError(slug) + + +def page_map(pages: Iterable[ReferencePage]) -> dict[str, ReferencePage]: + mapped: dict[str, ReferencePage] = {} + for page in pages: + for symbol in page.symbols: + if symbol in mapped: + raise SystemExit(f"Duplicate reference symbol: {symbol}") + mapped[symbol] = page + return mapped + + +def read_public_api() -> tuple[str, ...]: + module = ast.parse(PACKAGE_INIT.read_text(encoding="utf-8")) + for node in module.body: + if not isinstance(node, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "__all__" + for target in node.targets + ): + continue + value = ast.literal_eval(node.value) + if not isinstance(value, list) or not all( + isinstance(item, str) for item in value + ): + raise SystemExit("__all__ must be a list of strings") + return tuple(value) + raise SystemExit("Could not find quater.__all__") + + +def validate_public_docstrings(package: Any, public_api: tuple[str, ...]) -> None: + missing: list[str] = [] + for name in public_api: + obj = object_for(package, name) + kind_name = str(getattr(obj, "kind", "")) + if not kind_name.endswith(("CLASS", "FUNCTION")): + continue + docstring = getattr(obj, "docstring", None) + value = getattr(docstring, "value", None) + if not meaningful_docstring(value): + missing.append(name) + + if missing: + raise SystemExit( + "Public classes/functions need meaningful docstrings: " + + ", ".join(sorted(missing)) + ) + + +def meaningful_docstring(value: object) -> bool: + if not isinstance(value, str): + return False + words = re.findall(r"[A-Za-z0-9_]+", value) + if len(words) < MIN_PUBLIC_DOCSTRING_WORDS: + return False + lowered = {word.lower() for word in words} + return not bool(lowered & PLACEHOLDER_DOCSTRING_WORDS) diff --git a/scripts/_reference/paths.py b/scripts/_reference/paths.py new file mode 100644 index 0000000..6d22739 --- /dev/null +++ b/scripts/_reference/paths.py @@ -0,0 +1,13 @@ +"""Filesystem paths and the shared generated-file header.""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SOURCE_ROOT = REPO_ROOT / "src" +PACKAGE_INIT = SOURCE_ROOT / "quater" / "__init__.py" +REFERENCE_DIR = REPO_ROOT / "docs" / "en" / "dev" / "reference" +GENERATED_HEADER = ( + "" +) diff --git a/scripts/_reference/render.py b/scripts/_reference/render.py new file mode 100644 index 0000000..0a26c27 --- /dev/null +++ b/scripts/_reference/render.py @@ -0,0 +1,1266 @@ +"""Per-page markdown renderers and the top-level orchestration helper.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from _reference.manual import read_manual_reference +from _reference.pages import PAGES, ReferencePage, page_symbols +from _reference.paths import GENERATED_HEADER, REFERENCE_DIR +from _reference.signatures import ( + attribute_value, + callable_signature, + class_signature, + code_block, + method_signature, + signature_block, +) +from _reference.tables import ( + CLI_CALL_OPTIONS, + CLI_TEST_CLIENT_OPTIONS, + DELETE_COOKIE_OPTIONS, + GROUP_ROUTE_OPTIONS, + HTTP_ERROR_OPTIONS, + MCP_TEST_CLIENT_OPTIONS, + MCP_TOOLS_CALL_OPTIONS, + PARAMETER_DOCS, + PARAMETER_OPTIONS, + QUATER_OPTIONS, + REQUEST_CONSTRUCTOR_OPTIONS, + RESPONSE_DOCS, + RESPONSE_OPTIONS, + ROUTE_GROUP_OPTIONS, + ROUTE_OPTIONS, + SET_COOKIE_OPTIONS, + SIGNED_COOKIE_SIGNER_OPTIONS, + TEST_CLIENT_OPTIONS, + TEST_CLIENT_REQUEST_OPTIONS, + TEST_RESPONSE_OPTIONS, + field_table, + type_section, + validated_function_option_table, + validated_option_table, +) +from _reference.types import context_row, import_link, symbol_anchor, symbol_heading + + +def new_page(title: str) -> list[str]: + return [GENERATED_HEADER, "", f"# {title}", ""] + + +def symbol_intro( + lines: list[str], + package: Any, + symbol: str, + summary: str, + details: Sequence[str], +) -> None: + lines.extend( + [symbol_heading(symbol), "", source_line(package, symbol), "", summary, ""] + ) + if details: + lines.extend([*details, ""]) + + +def source_line(package: Any, symbol: str) -> str: + if symbol == "__version__": + return "Public import: `from quater import __version__`." + return f"Public import: `from quater import {symbol}`." + + +def finish(lines: list[str]) -> str: + return "\n".join(lines).rstrip() + "\n" + + +def render_reference( + package: Any, + public_api: tuple[str, ...], + pages_by_symbol: Mapping[str, ReferencePage], +) -> dict[Path, str]: + manual_outputs = read_manual_reference(public_api, pages_by_symbol) + if manual_outputs is not None: + return manual_outputs + + return { + REFERENCE_DIR / "index.md": render_index(public_api, pages_by_symbol), + REFERENCE_DIR / "application.md": render_application(package), + REFERENCE_DIR / "resources.md": render_resources(package), + REFERENCE_DIR / "request.md": render_request(package), + REFERENCE_DIR / "parameters.md": render_parameters(package), + REFERENCE_DIR / "responses.md": render_responses(package), + REFERENCE_DIR / "auth.md": render_auth(package), + REFERENCE_DIR / "observability.md": render_observability(package), + REFERENCE_DIR / "testing.md": render_testing(package), + } + + +def render_index( + public_api: tuple[str, ...], + pages_by_symbol: Mapping[str, ReferencePage], +) -> str: + lines = new_page("Reference") + lines.extend( + [ + "These pages document the public objects you can import from `quater`.", + "They are meant for quick lookup after you understand the concept.", + "", + "If you are still learning the framework, start with the", + "[Quickstart](/en/dev/quickstart) and then come back here for", + "exact names and signatures.", + "", + "For import guarantees, read [Stability](/en/dev/stability).", + "", + "## Pages", + "", + "| Page | Use it for |", + "| --- | --- |", + ] + ) + for page in PAGES: + lines.append(f"| [{page.title}](./{page.slug}) | {page.description} |") + + lines.extend( + [ + "", + "## Public Imports", + "", + "Use top-level imports for normal app code. These are the documented", + "symbols Quater expects application code to use.", + "", + ] + ) + for page in PAGES: + names = [name for name in public_api if pages_by_symbol[name] == page] + imports = ", ".join(import_link(name, page) for name in names) + lines.append(f"- **{page.title}:** {imports}") + return finish(lines) + + +def render_application(package: Any) -> str: + lines = new_page("Application Reference") + lines.extend( + [ + "Use these objects to create an app, group routes by feature, and", + "configure Quater's built-in docs and safety defaults.", + "", + "For the route model, read [Public API](/en/dev/api). For production", + "settings, read [Security](/en/dev/security).", + "", + "```python", + "from quater import AppConfig, CORSConfig, Quater, Resource, RouteGroup", + "```", + "", + ] + ) + symbol_intro( + lines, + package, + "Quater", + "The application object.", + [ + "`Quater` owns the route registry, middleware, lifespan hooks, and", + "server adapters. A route can stay HTTP-only, or it can opt into MCP", + "and CLI surfaces with `tool=True` and `cli=True`.", + ], + ) + lines.extend(signature_block(class_signature(package, "Quater"))) + lines.extend( + validated_option_table( + package, "Quater", None, "Constructor options", QUATER_OPTIONS + ) + ) + lines.extend( + [ + "### App state", + "", + "`app.state` is a [`State`](./request#symbol-state) container for", + "resources that belong to the app instance. It is available from", + "handlers as `request.app.state`.", + "", + "### Route decorators", + "", + "Use decorators for normal route registration. `get`, `post`, `put`,", + "`patch`, and `delete` use the same options as `route`.", + "", + ] + ) + lines.extend(signature_block(method_signature(package, "Quater", "route"))) + lines.extend( + validated_option_table( + package, "Quater", "route", "Route options", ROUTE_OPTIONS + ) + ) + lines.extend( + [ + "Descriptions matter for `tool=True` and `cli=True` routes. They are the", + "text an agent or operator sees before deciding whether to call the", + "operation.", + "", + "### Common app methods", + "", + "| Method | Use it for |", + "| --- | --- |", + "| `include(group)` | Include a " + "[`RouteGroup`](#symbol-routegroup) in the app. |", + "| `add_route(...)` | Register a route without decorator syntax. |", + "| `before_request(...)` | Register global before-request middleware. |", + "| `after_response(...)` | Register global after-response middleware. |", + "| `around_request(...)` | Wrap the request handler pipeline. |", + "| `exception_handler(...)` | Register a global exception handler. |", + "| `on_startup(...)` / `on_shutdown(...)` | Register lifespan hooks. |", + "| `startup()` / `shutdown()` | Run lifespan hooks manually in tests. |", + "| `handle(request)` | Handle an in-process request. |", + "| `validate_production()` | Fail fast on unsafe production config. |", + "", + ] + ) + symbol_intro( + lines, + package, + "RouteGroup", + "A compile-time group for related routes.", + [ + "A group lets you share a prefix, tags, auth, middleware, and exception", + "handlers across a feature area. Included groups are flattened into", + "normal routes before matching, so grouping does not add a router layer", + "to the hot path.", + ], + ) + lines.extend(signature_block(class_signature(package, "RouteGroup"))) + lines.extend( + validated_option_table( + package, + "RouteGroup", + None, + "Constructor options", + ROUTE_GROUP_OPTIONS, + ) + ) + lines.extend( + [ + "Use `app.include(group)` after all routes are declared. Quater locks the", + "group after inclusion so routes cannot silently disappear later.", + "", + "Route groups expose the same route decorators as the app.", + "", + ] + ) + lines.extend(signature_block(method_signature(package, "RouteGroup", "route"))) + lines.extend( + validated_option_table( + package, + "RouteGroup", + "route", + "Group route options", + GROUP_ROUTE_OPTIONS, + ) + ) + lines.extend( + [ + "The route options mean the same thing on", + "[`RouteGroup`](#symbol-routegroup) as they do on", + "[`Quater`](#symbol-quater). Group-level auth, resources, metadata, and", + "middleware are merged into the final route before the app compiles", + "routes.", + "", + ] + ) + symbol_intro( + lines, + package, + "AppConfig", + "Immutable application configuration.", + [ + "Most apps pass simple keyword overrides to", + "[`Quater(...)`](#symbol-quater). Use `AppConfig` when you want to", + "build configuration once and pass it around explicitly.", + ], + ) + lines.extend(signature_block(class_signature(package, "AppConfig"))) + lines.extend(field_table(package, "AppConfig")) + symbol_intro( + lines, + package, + "CORSConfig", + "Browser CORS policy.", + [ + "CORS controls which browser origins may read responses. It is not an", + "authentication system; use `auth=...`, `mcp_auth`, and `cli_auth` for", + "access control.", + ], + ) + lines.extend(signature_block(class_signature(package, "CORSConfig"))) + lines.extend(field_table(package, "CORSConfig")) + symbol_intro( + lines, + package, + "__version__", + "Installed Quater version.", + ["Use this for diagnostics or support output."], + ) + lines.extend(code_block(f"__version__ = {attribute_value(package, '__version__')}")) + lines.extend( + type_section( + ( + ( + "SecurityMode", + "Literal config value: `strict`, `relaxed`, or `off`.", + ), + ( + "MaxBodySize", + 'Either bytes as `int` or a string such as `"2mb"`.', + ), + ( + "Authenticate", + "Async auth hook. See " + "[Auth and Security](./auth#symbol-authrequest).", + ), + ( + "ActionApproval", + "Async approval hook. See " + "[ActionApproval](./auth#symbol-actionapproval).", + ), + ( + "AuditHook", + "Async MCP audit hook receiving " + "[ToolAuditEvent](./observability#symbol-toolauditevent).", + ), + ( + "AccessLogHook", + "Async access-log hook. See " + "[Observability](./observability#symbol-accessloghook).", + ), + ( + "BeforeMiddleware", + "Runs before the handler. It can short-circuit by returning " + "a response.", + ), + ( + "AfterMiddleware", + "Runs after the handler and can adjust the response.", + ), + ( + "AroundMiddleware", + "Wraps the handler pipeline for timing, tracing, or similar " + "cross-cutting behavior.", + ), + ( + "ExceptionHandlerEntry", + "Internal wrapper for exception handlers passed through " + "decorators.", + ), + ) + ) + ) + return finish(lines) + + +def render_resources(package: Any) -> str: + lines = new_page("Resources Reference") + lines.extend( + [ + "Use these objects when a handler needs an app-owned value, such as a", + "database session, cache handle, tenant object, or request-scoped service.", + "", + "For the guide, read [Resources and Injection](/en/dev/resources).", + "", + "```python", + "from quater import Resource", + "```", + "", + ] + ) + symbol_intro( + lines, + package, + "Resource", + "A request-scoped injectable value.", + [ + "`Resource` wraps a provider callable and lets a route inject the", + "provider result into a named handler parameter.", + ], + ) + lines.extend(signature_block(class_signature(package, "Resource"))) + lines.extend( + [ + "### Constructor options", + "", + "| Name | Type | Meaning |", + "| --- | --- | --- |", + "| `provider` | [`ResourceProvider[T]`](#type-resourceprovider) | " + "Callable that creates the value. |", + "| `scope` | [`ResourceScope`](#type-resourcescope) | " + "Resource lifetime. Currently only `request` is supported. |", + "| `name` | `str \\| None` | Optional name used in resource error " + "messages. |", + "", + "### Provider forms", + "", + "The provider may accept no arguments:", + "", + "```python", + "async def settings() -> Settings:", + " return Settings.from_env()", + "```", + "", + "Or it may accept the current [`Request`](./request#symbol-request):", + "", + "```python", + "async def current_tenant(request: Request) -> Tenant:", + " return await request.app.state.tenants.load(", + ' request.headers.get("x-tenant-id")', + " )", + "```", + "", + "The provider can return a plain value, an awaitable value, a sync or " + "async", + "context manager, or yield one value from a sync or async generator.", + "`Resource` is generic: `Resource(provider)` carries the provider's " + "resolved value type, so `await request.resolve(resource)` returns " + "that value type.", + "", + "```python", + "async def db_session(request: Request) -> AsyncIterator[DatabaseSession]:", + " async with request.app.state.database.session() as session:", + " yield session", + "```", + "", + "Quater closes context-manager and generator resources after the handler", + "finishes. Cleanup also runs when the handler raises.", + "", + "### Route usage", + "", + "```python", + "db = Resource(db_session)", + "", + "", + '@app.get("/orders/{order_id}", inject={"session": db})', + "async def get_order(", + " order_id: str,", + " session: DatabaseSession,", + ") -> dict[str, object]:", + " ...", + "```", + "", + "Injected parameters are not included in OpenAPI request parameters, MCP", + "input schemas, or CLI action schemas.", + "", + "## Types", + "", + "| Type | Meaning |", + "| --- | --- |", + '| `ResourceProvider` | ' + "Callable used by [`Resource`](#symbol-resource). It may return a " + "plain value, awaitable value, sync or async context manager, or a " + "sync or async generator that yields one value. |", + '| `ResourceMap` | Mapping of ' + "handler parameter name to [`Resource`](#symbol-resource). This is " + "the type accepted by `inject`. |", + '| `ResourceScope` | Literal ' + "resource lifetime. Currently `request`. |", + ] + ) + return finish(lines) + + +def render_request(package: Any) -> str: + lines = new_page("Request Reference") + lines.extend( + [ + "`Request` is the object to ask for when a handler needs headers,", + "cookies, body access, auth context, or call-source information.", + "", + "For simple path/query/body parameters, let Quater bind the function", + "arguments directly instead.", + "", + "```python", + "from quater import Request, State", + "```", + "", + ] + ) + symbol_intro( + lines, + package, + "Request", + "Transport-neutral request data.", + [ + "The same request object is used after RSGI, ASGI, WSGI, MCP, and CLI", + "calls have been normalized into Quater's internal request flow.", + ], + ) + lines.extend(signature_block(class_signature(package, "Request"))) + lines.extend( + validated_option_table( + package, + "Request", + None, + "Constructor parameters", + REQUEST_CONSTRUCTOR_OPTIONS, + ) + ) + lines.extend( + [ + "In normal app code, Quater creates the request and passes it to your", + "handler. The table above matches the constructor signature. The sections", + "below explain the objects you usually read from inside handlers.", + "", + "## Reading Request Data", + "", + "Most handlers use `request.headers`, `request.query`, `request.cookies`,", + "`request.auth`, `request.context`, `await request.body()`, or", + "`await request.json()`.", + "", + "Helper objects such as [`Headers`](#headers),", + "[`QueryParams`](#queryparams), and [`Cookies`](#cookies) are not", + "top-level public imports. Treat them as read-only request views.", + "", + ] + ) + symbol_intro( + lines, + package, + "State", + "Attribute storage for application and request-local state.", + [ + "`app.state` is shared by the application instance. Use it for", + "resources created at startup, such as database pools or clients.", + "`request.state` is created fresh for each request and is useful for", + "middleware that needs to pass values to handlers.", + ], + ) + lines.extend(signature_block("State()")) + lines.extend( + [ + "Keep per-request data on `request.state`, not `app.state`. If you", + "store shared objects on `app.state`, make sure those objects are safe", + "for your concurrency and deployment model.", + "", + "```python", + "@app.on_startup", + "async def startup() -> None:", + " app.state.db = await open_database_pool()", + "", + '@app.get("/users/{id}")', + "async def get_user(id: str, request: Request) -> dict[str, object]:", + " assert request.app is not None", + " user = await request.app.state.db.fetch_user(id)", + ' return {"id": user.id}', + "```", + "", + "## Call Context", + "", + "`RequestContext` is the small object stored at `request.context`. You do", + "not need to create it in normal app code, but it is useful when a handler", + "can be reached from more than one surface.", + "", + "`request.context` tells you how the handler was reached:", + "", + '- `source="api"` for normal HTTP routes.', + '- `source="mcp"` for MCP protocol and tool calls.', + '- `source="cli"` for Quater CLI actions.', + '- `entrypoint="server"` for hosted calls.', + '- `entrypoint="local"` for local CLI calls.', + "", + "```python", + "async def whoami(request: Request) -> dict[str, object]:", + " return {", + ' "source": request.context.source,', + ' "entrypoint": request.context.entrypoint,', + ' "subject": request.auth.subject if request.auth else None,', + " }", + "```", + "", + "Context fields:", + "", + "| Field | Type | Meaning |", + "| --- | --- | --- |", + context_row( + "source", + '"api" | "mcp" | "cli"', + "Which surface reached the handler.", + ), + context_row( + "entrypoint", + '"server" | "local"', + "Hosted request or local CLI call.", + ), + "| `request_id` | `str \\| None` | Correlation id assigned by Quater. |", + "| `tool_name` | `str \\| None` | MCP tool name for tool calls. |", + "| `action_name` | `str \\| None` | CLI action name for action calls. |", + "", + "## Header, Query, and Cookie Views", + "", + "These objects behave like small read-only mappings. You can use common", + "mapping methods such as `get()`, `in`, iteration, and `[...]` lookup.", + "", + "### Headers", + "", + "`request.headers` is case-insensitive. Header names are normalized for", + "lookup, so these are equivalent:", + "", + "```python", + 'request.headers.get("authorization")', + 'request.headers.get("Authorization")', + "```", + "", + "Use `get_all(name)` when a header may appear more than once. Use `.raw`", + "when you need the normalized `(name, value)` pairs.", + "", + "```python", + 'authorization = request.headers.get("authorization")', + 'set_cookie_headers = request.headers.get_all("set-cookie")', + "raw_headers = request.headers.raw", + "```", + "", + "### QueryParams", + "", + "`request.query` is a parsed query-string mapping. Normal lookup returns", + "the last value for a repeated key, which matches normal dictionary", + "behavior. Use `get_all(name)` when repeated query parameters matter.", + "", + "```python", + "# /search?tag=python&tag=api", + 'first_value = request.query.get("tag")', + 'all_values = request.query.get_all("tag")', + "```", + "", + "Use `.raw` when you need all `(name, value)` pairs in order.", + "", + "### Cookies", + "", + "`request.cookies` is a parsed mapping of cookie names to cookie values.", + "Use `.get()` when a cookie is optional.", + "", + "```python", + 'session_id = request.cookies.get("session")', + "```", + "", + "### AuthContext", + "", + "`request.auth` is either `None` or the", + "[`AuthContext`](./auth#symbol-authcontext) returned by the route", + "auth hook. Always check it before reading `subject`.", + "", + "```python", + "subject = request.auth.subject if request.auth else None", + "```", + "", + ] + ) + lines.extend( + type_section( + ( + ( + "State", + "Attribute container exposed as `app.state` and `request.state`.", + ), + ( + "Quater", + "Application object available as `request.app` after a " + "request enters an app.", + ), + ( + "HeaderItems", + "`(name, value)` header pairs. Names and values may be " + "`str` or `bytes`.", + ), + ( + "RequestBody", + "`bytes`, an async body reader, or `None`. App handlers " + "usually use `await request.body()`.", + ), + ( + "AuthContext", + "Auth result returned by a route auth hook. See " + "[AuthContext](./auth#symbol-authcontext).", + ), + ( + "RequestContext", + "Call-source context explained in [Call Context](#call-context).", + ), + ( + "Headers", + "Read-only, case-insensitive header view available as " + "`request.headers`.", + ), + ( + "QueryParams", + "Read-only query-string view available as `request.query`.", + ), + ( + "Cookies", + "Read-only cookie mapping available as `request.cookies`.", + ), + ) + ) + ) + return finish(lines) + + +def render_parameters(package: Any) -> str: + lines = new_page("Parameter Reference") + lines.extend( + [ + "Use parameter markers when handler arguments need explicit request", + "locations, aliases, defaults, or generated schema descriptions.", + "", + "For the binding model, read [Public API](/en/dev/api#parameters).", + "For raw request access, read [Request](./request).", + "", + "```python", + "from quater import Body, Cookie, File, Form, Header, Path, Query", + "```", + "", + "Markers can be used as defaults or inside `typing.Annotated`. The", + "default form is shorter. `Annotated` keeps the Python default separate.", + "`Query`, `Header`, `Cookie`, and `Form` bind scalar values only:", + "`str`, `int`, `float`, or `bool`. Use `Body` for structured JSON", + "input and `File` for multipart file uploads.", + "", + "```python", + "from typing import Annotated", + "", + "from quater import Query", + "", + "async def search(", + ' q: str = Query(description="Search text"),', + ' page: Annotated[int, Query(alias="p")] = 1,', + ") -> dict[str, object]:", + ' return {"q": q, "page": page}', + "```", + "", + ] + ) + for symbol in page_symbols("parameters"): + summary, details = PARAMETER_DOCS[symbol] + symbol_intro(lines, package, symbol, summary, details) + lines.extend(signature_block(callable_signature(package, symbol))) + lines.extend( + validated_function_option_table( + package, + symbol, + "Parameters", + PARAMETER_OPTIONS[symbol], + ) + ) + lines.extend( + [ + "## Action and Tool Names", + "", + "Aliases describe the HTTP wire name. MCP tools and CLI actions keep the", + "Python handler parameter name as the action argument name, except for", + "`Body(alias=...)`, which renames the body action argument. That keeps", + '`Header(alias="X-Request-ID")` readable in OpenAPI without forcing', + "agents to send a JSON key named `X-Request-ID`.", + "", + ] + ) + return finish(lines) + + +def render_responses(package: Any) -> str: + lines = new_page("Responses Reference") + lines.extend( + [ + "Most handlers do not need to create response objects. Return plain Python", + "values when that is enough; use explicit response classes when you need", + "status codes, headers, streaming, redirects, or a specific content type.", + "", + "```python", + "from quater import JSONResponse, RedirectResponse, StreamResponse", + "```", + "", + "## Automatic Return Values", + "", + "| Handler returns | Quater sends |", + "| --- | --- |", + "| `dict`, `list`, dataclass, `msgspec.Struct` | JSON response |", + "| `str` | UTF-8 text response |", + "| `bytes`, `bytearray`, `memoryview` | Byte response |", + "| `None` | Empty `204` response |", + "| [`Response`](#symbol-response) instance | Sent as-is |", + "", + "## Response Classes", + "", + ] + ) + for symbol in page_symbols("responses"): + symbol_intro(lines, package, symbol, RESPONSE_DOCS[symbol], []) + lines.extend(signature_block(class_signature(package, symbol))) + lines.extend( + validated_option_table( + package, + symbol, + None, + "Parameters", + RESPONSE_OPTIONS[symbol], + ) + ) + lines.extend( + [ + "## Cookie helpers", + "", + "Both helpers are available on every response class.", + "", + "### `set_cookie`", + "", + ] + ) + lines.extend(signature_block(method_signature(package, "Response", "set_cookie"))) + lines.extend( + validated_option_table( + package, + "Response", + "set_cookie", + "Parameters", + SET_COOKIE_OPTIONS, + ) + ) + lines.extend( + [ + "### `delete_cookie`", + "", + ] + ) + lines.extend( + signature_block(method_signature(package, "Response", "delete_cookie")) + ) + lines.extend( + validated_option_table( + package, + "Response", + "delete_cookie", + "Parameters", + DELETE_COOKIE_OPTIONS, + ) + ) + lines.extend( + type_section( + ( + ( + "HeaderItems", + "`(name, value)` header pairs. See " + "[Request headers](./request#headers).", + ), + ( + "ResponseBody", + "Bytes-like value accepted by " + "[`BytesResponse`](#symbol-bytesresponse).", + ), + ("AsyncIterable[bytes]", "Async stream of response chunks."), + ) + ) + ) + return finish(lines) + + +def render_auth(package: Any) -> str: + lines = new_page("Auth and Security Reference") + lines.extend( + [ + "Quater keeps the three access surfaces explicit:", + "", + "- normal routes use `auth=...` on the route or route group.", + "- MCP tools require `mcp_auth` when any tool is exposed.", + "- CLI actions require `cli_auth` when any action is exposed.", + "", + "The auth hook shape is the same across those surfaces. For the full", + "security model, read [Security](/en/dev/security).", + "", + "```python", + "from quater import (", + " AuthContext,", + " AuthRequest,", + " HTTPError,", + " ImproperlyConfigured,", + " SignedCookieSigner,", + ")", + "```", + "", + ] + ) + symbol_intro( + lines, + package, + "AuthRequest", + "Input passed to an auth hook.", + ["Use it to inspect method, path, headers, and Quater call context."], + ) + lines.extend(signature_block(class_signature(package, "AuthRequest"))) + lines.extend(field_table(package, "AuthRequest")) + symbol_intro( + lines, + package, + "AuthContext", + "Authenticated subject returned by an auth hook.", + [ + "Return `None` from an auth hook when the request is not authenticated.", + "Return `AuthContext` when it is authenticated.", + ], + ) + lines.extend(signature_block(class_signature(package, "AuthContext"))) + lines.extend(field_table(package, "AuthContext")) + symbol_intro( + lines, + package, + "ApprovalRequest", + "Input passed to the approval hook for protected tools and actions.", + [ + "Use this when a route has `needs_approval=True`. Approval is separate", + "from auth: auth identifies the caller, approval confirms a sensitive", + "operation should run.", + ], + ) + lines.extend(signature_block(class_signature(package, "ApprovalRequest"))) + lines.extend(field_table(package, "ApprovalRequest")) + symbol_intro( + lines, + package, + "ActionApproval", + "Callable type for approval hooks.", + [ + "Return `True` to allow the protected operation.", + "Return `False` to deny it.", + ], + ) + lines.extend( + code_block(f"ActionApproval = {attribute_value(package, 'ActionApproval')}") + ) + symbol_intro( + lines, + package, + "HTTPError", + "Exception that becomes an HTTP-style error response.", + ["Raise it when app code needs to stop with a specific status and detail."], + ) + lines.extend(signature_block(class_signature(package, "HTTPError"))) + lines.extend( + validated_option_table( + package, + "HTTPError", + None, + "Constructor parameters", + HTTP_ERROR_OPTIONS, + ) + ) + symbol_intro( + lines, + package, + "ImproperlyConfigured", + "Exception raised for invalid framework configuration.", + [ + "Catch this when app setup should fail loudly before serving traffic.", + "`ConfigurationError` remains as a backward-compatible subclass.", + ], + ) + lines.extend(code_block('raise ImproperlyConfigured("bad setup")')) + symbol_intro( + lines, + package, + "SignedCookieSigner", + "HMAC signer for small cookie values.", + [ + "Use fallback secrets during key rotation. Verification uses constant-time", + "signature comparison.", + ], + ) + lines.extend(signature_block(class_signature(package, "SignedCookieSigner"))) + lines.extend( + validated_option_table( + package, + "SignedCookieSigner", + None, + "Constructor parameters", + SIGNED_COOKIE_SIGNER_OPTIONS, + ) + ) + lines.extend( + [ + "Common methods:", + "", + "| Method | Use it for |", + "| --- | --- |", + "| `sign(value)` | Return a signed string safe to store in a cookie. |", + "| `verify(signed_value)` | Return the original value, or `None`. |", + "", + ] + ) + lines.extend( + type_section( + ( + ( + "RequestContext", + "Call-source context. See [Request](./request#call-context).", + ), + ( + "Authenticate", + "Async callable that receives " + "[`AuthRequest`](#symbol-authrequest) and returns " + "[`AuthContext`](#symbol-authcontext) or `None`.", + ), + ("SecretValue", "`str` or `bytes` cookie signing secret."), + ) + ) + ) + return finish(lines) + + +def render_observability(package: Any) -> str: + lines = new_page("Observability Reference") + lines.extend( + [ + "These types are used by access logging and MCP tool auditing. They are", + "small by design so apps can send them to logs, metrics, or tracing", + "systems without depending on Quater internals.", + "", + "```python", + "from quater import AccessLogEvent, AccessLogHook, ToolAuditEvent", + "```", + "", + ] + ) + symbol_intro( + lines, + package, + "AccessLogEvent", + "Structured event emitted after a request is handled.", + ["Configure `access_logger=` on `Quater(...)` to receive these events."], + ) + lines.extend(signature_block(class_signature(package, "AccessLogEvent"))) + lines.extend(field_table(package, "AccessLogEvent")) + lines.extend( + [ + "`AccessLogEvent.to_dict()` returns a plain dictionary for loggers that", + "expect JSON-like data.", + "", + ] + ) + symbol_intro( + lines, + package, + "AccessLogHook", + "Callable type for access-log hooks.", + ["The hook is async so it can write to async logging or telemetry clients."], + ) + lines.extend( + code_block(f"AccessLogHook = {attribute_value(package, 'AccessLogHook')}") + ) + symbol_intro( + lines, + package, + "ToolAuditEvent", + "Structured event emitted for MCP tool calls.", + [ + "Tool arguments are redacted before the event reaches your audit hook.", + "Use this for visibility, not for authorization.", + ], + ) + lines.extend(signature_block(class_signature(package, "ToolAuditEvent"))) + lines.extend(field_table(package, "ToolAuditEvent")) + return finish(lines) + + +def render_testing(package: Any) -> str: + lines = new_page("Testing Reference") + lines.extend( + [ + "Use the in-process clients to test Quater apps without starting Granian", + "or opening a socket. This keeps tests fast and makes auth, cookies,", + "lifespan hooks, MCP tools, and response bodies straightforward to assert.", + "", + "For examples, read the [Testing guide](/en/dev/testing).", + "", + "```python", + "from quater import CliTestClient, MCPTestClient, TestClient, TestResponse", + "```", + "", + ] + ) + symbol_intro( + lines, + package, + "TestClient", + "Async in-process client for HTTP-style tests.", + ["Use it with `async with` when your app has startup or shutdown hooks."], + ) + lines.extend(signature_block(class_signature(package, "TestClient"))) + lines.extend( + validated_option_table( + package, + "TestClient", + None, + "Constructor parameters", + TEST_CLIENT_OPTIONS, + ) + ) + lines.extend( + [ + "Common methods:", + "", + "| Method | Use it for |", + "| --- | --- |", + "| `request(method, path, ...)` | Send any method. |", + "| `get`, `post`, `put`, `patch`, `delete` | Convenience methods. |", + "| `set_cookie(name, value)` | Store a cookie for later requests. |", + "| `clear_cookies()` | Clear stored cookies. |", + "| `startup()` / `shutdown()` | Run lifespan manually. |", + "", + ] + ) + lines.extend(signature_block(method_signature(package, "TestClient", "request"))) + lines.extend( + validated_option_table( + package, + "TestClient", + "request", + "`request()` parameters", + TEST_CLIENT_REQUEST_OPTIONS, + ) + ) + symbol_intro( + lines, + package, + "TestResponse", + "Response returned by [`TestClient`](#symbol-testclient).", + ["It stores the collected body, headers, status code, and JSON helpers."], + ) + lines.extend(signature_block(class_signature(package, "TestResponse"))) + lines.extend( + validated_option_table( + package, + "TestResponse", + None, + "Constructor parameters", + TEST_RESPONSE_OPTIONS, + ) + ) + lines.extend( + [ + "| Property or method | What it returns |", + "| --- | --- |", + "| `status_code` | Integer response status. |", + "| `headers` | Parsed response headers. |", + "| `body` | Raw response bytes. |", + "| `text` | UTF-8 decoded body. |", + "| `is_success` | `True` for `2xx` and `3xx` responses. |", + "| `json()` | Parsed JSON body. |", + "", + ] + ) + symbol_intro( + lines, + package, + "MCPTestClient", + "Small JSON-RPC helper bound to a [`TestClient`](#symbol-testclient).", + [ + "Use `client.mcp` in tests. It sends MCP requests through the same app", + "pipeline as a real MCP client.", + ], + ) + lines.extend(signature_block(class_signature(package, "MCPTestClient"))) + lines.extend( + validated_option_table( + package, + "MCPTestClient", + None, + "Constructor parameters", + MCP_TEST_CLIENT_OPTIONS, + ) + ) + lines.extend( + [ + "Common methods:", + "", + "| Method | Use it for |", + "| --- | --- |", + "| `initialize(...)` | Send MCP `initialize`. |", + "| `tools_list(...)` | List exposed tools. |", + "| `tools_call(name, arguments, ...)` | Call an exposed tool. |", + "| `request(payload, ...)` | Send a custom JSON-RPC payload. |", + "", + ] + ) + lines.extend( + signature_block(method_signature(package, "MCPTestClient", "tools_call")) + ) + lines.extend( + validated_option_table( + package, + "MCPTestClient", + "tools_call", + "`tools_call()` parameters", + MCP_TOOLS_CALL_OPTIONS, + ) + ) + symbol_intro( + lines, + package, + "CliTestClient", + "Remote-action helper bound to a [`TestClient`](#symbol-testclient).", + [ + "Use `client.cli` in tests. It calls actions and reads the action", + "manifest through the same remote-action endpoints as the Quater CLI.", + ], + ) + lines.extend(signature_block(class_signature(package, "CliTestClient"))) + lines.extend( + validated_option_table( + package, + "CliTestClient", + None, + "Constructor parameters", + CLI_TEST_CLIENT_OPTIONS, + ) + ) + lines.extend( + [ + "Common methods:", + "", + "| Method | Use it for |", + "| --- | --- |", + "| `call(action, arguments, ...)` | Call an exposed CLI action. |", + "| `manifest(...)` | Read the action manifest. |", + "", + "Both methods return the raw [`TestResponse`](#symbol-testresponse). A", + "successful action body is the `{ok, status_code, body}` envelope, and a", + "`dry_run=True` call returns the preflight payload instead of running the", + "handler.", + "", + ] + ) + lines.extend(signature_block(method_signature(package, "CliTestClient", "call"))) + lines.extend( + validated_option_table( + package, + "CliTestClient", + "call", + "`call()` parameters", + CLI_CALL_OPTIONS, + ) + ) + lines.extend( + type_section( + ( + ( + "HeaderItems", + "`(name, value)` header pairs. See " + "[Request headers](./request#headers).", + ), + ( + "QueryParams", + "Mapping or sequence accepted by " + "[`TestClient`](#symbol-testclient).", + ), + ( + "RequestContent", + "`bytes`, `bytearray`, `memoryview`, or `str` request body " + "content.", + ), + ("JSONRPCID", "MCP JSON-RPC request id, either `str` or `int`."), + ) + ) + ) + return finish(lines) diff --git a/scripts/_reference/signatures.py b/scripts/_reference/signatures.py new file mode 100644 index 0000000..aea7424 --- /dev/null +++ b/scripts/_reference/signatures.py @@ -0,0 +1,185 @@ +"""Signature and type formatting helpers. + +Handles griffe object resolution, signature/type string cleanup, and +the wrapping rules for callables shown in the reference pages. +""" + +from __future__ import annotations + +from typing import Any + + +def object_for(package: Any, symbol: str) -> Any: + return resolve(package[symbol]) + + +def resolve(obj: Any) -> Any: + return getattr(obj, "target", obj) + + +def annotation(obj: Any) -> str: + value = getattr(obj, "annotation", None) + if value is None: + return "object" + return str(value) + + +def init_annotations(obj: Any) -> dict[str, str]: + init = getattr(obj, "members", {}).get("__init__") + if init is None: + return {} + target = resolve(init) + parameters = getattr(target, "parameters", ()) + annotations: dict[str, str] = {} + for parameter in parameters: + name = getattr(parameter, "name", "") + value = getattr(parameter, "annotation", None) + if not name or name == "self" or value is None: + continue + annotations[name] = str(value) + return annotations + + +def attribute_value(package: Any, symbol: str) -> str: + value = getattr(object_for(package, symbol), "value", None) + if value is None: + return clean_signature(annotation(object_for(package, symbol))) + return clean_signature(str(value)) + + +def function_signature(obj: Any) -> str | None: + signature_method = getattr(obj, "signature", None) + if signature_method is None: + return None + return format_signature(clean_signature(str(signature_method()))) + + +def class_signature(package: Any, symbol: str) -> str: + obj = object_for(package, symbol) + init = getattr(obj, "members", {}).get("__init__") + if init is None: + return symbol + signature = function_signature(resolve(init)) + if signature is None: + return symbol + signature = signature.replace("__init__(", f"{symbol}(", 1) + signature = signature.removesuffix(" -> None") + return signature + + +def method_signature(package: Any, symbol: str, method: str) -> str: + obj = object_for(package, symbol) + member = getattr(obj, "members", {}).get(method) + if member is None: + raise SystemExit(f"Could not find {symbol}.{method}") + signature = function_signature(resolve(member)) + if signature is None: + raise SystemExit(f"Could not read signature for {symbol}.{method}") + return signature + + +def callable_signature(package: Any, symbol: str) -> str: + signature = function_signature(resolve(object_for(package, symbol))) + if signature is None: + raise SystemExit(f"Could not read signature for {symbol}") + return signature + + +def code_block(code: str) -> list[str]: + return ["```python", code, "```", ""] + + +def signature_block(signature: str) -> list[str]: + return code_block(format_signature(signature)) + + +def format_signature(signature: str, *, max_width: int = 88) -> str: + if len(signature) <= max_width: + return signature + + open_index = signature.find("(") + close_index = signature.rfind(")") + if open_index == -1 or close_index == -1 or close_index < open_index: + return signature + + head = signature[:open_index] + args = split_top_level_commas(signature[open_index + 1 : close_index]) + tail = signature[close_index + 1 :] + if not args: + return signature + + lines = [f"{head}("] + lines.extend(f" {argument}," for argument in args) + lines.append(f"){tail}") + return "\n".join(lines) + + +def clean_signature(value: str) -> str: + replacements = { + "mcp_docs_path: str | None | _Unset = _UNSET": ( + "mcp_docs_path: str | None = '/mcp/docs'" + ), + "docs_path: str | None | _Unset = _UNSET": ("docs_path: str | None = '/docs'"), + "openapi_path: str | None | _Unset = _UNSET": ( + "openapi_path: str | None = '/openapi.json'" + ), + "request_id_header: str | None | _Unset = _UNSET": ( + "request_id_header: str | None = 'x-request-id'" + ), + "_empty_str_map()": "...", + "_empty_metadata()": "...", + "_MCP_PROTOCOL_VERSION": "'2025-11-25'", + "Callable[['AccessLogEvent'], Awaitable[None]]": ( + "Callable[[AccessLogEvent], Awaitable[None]]" + ), + "_DEFAULT_METHODS": ( + "('DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT')" + ), + } + for old, new in replacements.items(): + value = value.replace(old, new) + return value + + +def clean_type(value: str) -> str: + replacements = { + "str | None | _Unset": "str | None", + } + for old, new in replacements.items(): + value = value.replace(old, new) + return value + + +def split_top_level_commas(value: str) -> list[str]: + parts: list[str] = [] + start = 0 + depth = 0 + quote: str | None = None + escaped = False + for index, char in enumerate(value): + if quote is not None: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + continue + if char in "([{": + depth += 1 + continue + if char in ")]}": + depth -= 1 + continue + if char == "," and depth == 0: + part = value[start:index].strip() + if part: + parts.append(part) + start = index + 1 + tail = value[start:].strip() + if tail: + parts.append(tail) + return parts diff --git a/scripts/_reference/tables.py b/scripts/_reference/tables.py new file mode 100644 index 0000000..717ac33 --- /dev/null +++ b/scripts/_reference/tables.py @@ -0,0 +1,711 @@ +"""Table content constants and table-building / validation helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from _reference.signatures import annotation, clean_type, object_for, resolve +from _reference.types import ( + is_private_member, + table_text, + type_cell, + type_name_cell, +) + +FIELD_DOCS: Mapping[str, Mapping[str, str]] = { + "AppConfig": { + "debug": "Return detailed error responses while developing.", + "security": "`strict`, `relaxed`, or `off` security defaults.", + "allowed_hosts": "Host names the app should accept.", + "trusted_proxies": "Proxy IPs or CIDR ranges trusted for forwarded headers.", + "max_body_size": "Maximum request body size in bytes.", + "max_form_parts": "Maximum number of form fields and file parts.", + "max_form_field_size": "Maximum size for one string form field.", + "max_file_size": "Maximum size for one uploaded file.", + "upload_spool_size": "Per-file size before upload data rolls to disk.", + "max_tool_response_size": "Maximum MCP tool response body size.", + "max_action_response_size": "Maximum CLI action response body size.", + "cors": "Optional CORS policy.", + "content_security_policy": "Optional Content-Security-Policy header value.", + "docs_path": "Swagger UI path. Set to `None` to disable it.", + "openapi_path": "OpenAPI JSON path. Set to `None` to disable it.", + "mcp_docs_path": "Human-readable MCP docs path. Set to `None` to disable it.", + "mcp_allowed_origins": ( + "Origins allowed to call the MCP endpoint from browsers." + ), + "request_id_header": "Header used for incoming and outgoing request ids.", + }, + "CORSConfig": { + "allowed_origins": "Origins allowed to read browser responses.", + "allowed_methods": "Methods allowed during browser preflight checks.", + "allowed_headers": ( + "Request headers allowed during preflight. Empty reflects sanitized " + "requested headers." + ), + "expose_headers": "Response headers browsers may expose to client code.", + "allow_credentials": "Whether browsers may include credentials.", + "max_age": "How long browsers may cache a preflight result.", + }, + "AuthRequest": { + "method": "HTTP method for the route or protocol request.", + "path": "Path being accessed.", + "headers": "Normalized request headers.", + "context": "Quater context describing the source of the call.", + }, + "AuthContext": { + "subject": "Stable id for the authenticated user, service, or agent.", + "metadata": "Small extra values your app wants to carry with the request.", + }, + "ApprovalRequest": { + "action": "Tool or CLI action name.", + "arguments_hash": "Hash of the action name and canonical bound arguments.", + "token": "Approval token supplied by the caller.", + "auth": "Authenticated subject, when the action request was authenticated.", + "context": "Quater context for the tool or CLI call.", + }, + "AccessLogEvent": { + "request_id": "Request id used for correlation.", + "method": "HTTP method handled by Quater.", + "path": "Path handled by Quater.", + "status_code": "Final response status code.", + "duration_ms": "Time spent handling the request.", + "source": "`api`, `mcp`, or `cli`.", + "entrypoint": "`server` for hosted calls, `local` for local CLI.", + "scheme": "Request scheme.", + "client": "Client address when available.", + "tool_name": "MCP tool name when the request came from a tool call.", + "action_name": "CLI action name when the request came from an action call.", + }, + "ToolAuditEvent": { + "tool_name": "MCP tool that was called.", + "subject": "Authenticated subject, when present.", + "success": "Whether the tool call completed successfully.", + "duration_ms": "Tool call duration.", + "arguments": "Redacted argument map passed to the audit hook.", + }, +} + +REQUEST_CONSTRUCTOR_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("method", "str", "HTTP method used to create the normalized request."), + ("path", "str", "Request path without the query string."), + ("scheme", "str", "`http` or `https`. Defaults to `http`."), + ( + "headers", + "HeaderItems | Mapping[str, str]", + "Incoming request headers. Exposed later as `request.headers`.", + ), + ( + "query_string", + "str | bytes", + "Raw query string. Exposed later as parsed `request.query`.", + ), + ("body", "RequestBody", "Raw bytes, async body reader, or `None`."), + ( + "auth", + "AuthContext | None", + "Initial auth context. Most apps let route auth set this.", + ), + ("client", "str | None", "Client address when available."), + ( + "context", + "RequestContext | None", + "Call-source context. Quater creates a default when omitted.", + ), + ( + "app", + "Quater | None", + "Application handling the request. Quater sets this at the app boundary.", + ), + ("max_body_size", "int | None", "Optional body-size limit for this request."), + ("max_form_parts", "int | None", "Optional form part count limit."), + ( + "max_form_field_size", + "int | None", + "Optional per-field form size limit.", + ), + ("max_file_size", "int | None", "Optional per-file upload size limit."), + ("upload_spool_size", "int | None", "Optional upload spool threshold."), +) + +RESPONSE_DOCS: Mapping[str, str] = { + "Response": "Use this when you already have bytes and want full control.", + "JSONResponse": "Use this when you need explicit status or headers for JSON.", + "TextResponse": "Use this for plain text.", + "HTMLResponse": "Use this for HTML.", + "BytesResponse": "Use this for raw bytes.", + "StreamResponse": "Use this for async byte streams.", + "RedirectResponse": "Use this for redirects.", + "EmptyResponse": "Use this for responses with no body.", +} + +PARAMETER_DOCS: Mapping[str, tuple[str, tuple[str, ...]]] = { + "Path": ( + "Bind a value from a route path segment.", + ( + "`Path` is useful when the Python parameter name differs from the", + "name in the route path, or when you want descriptions in OpenAPI", + "and action schemas.", + ), + ), + "Query": ( + "Bind a value from the query string.", + ( + "`Query` makes query parameters explicit and lets you set aliases,", + "defaults, and descriptions without changing handler logic.", + ), + ), + "Body": ( + "Bind the JSON request body.", + ( + "`Body` documents the body parameter and feeds the same schema into", + "OpenAPI, MCP tools, and CLI actions.", + ), + ), + "Form": ( + "Bind a scalar field from a submitted form.", + ( + "`Form` reads fields from `application/x-www-form-urlencoded` or", + "`multipart/form-data` requests. It is useful for browser forms,", + "OAuth-style token endpoints, and compatibility with existing clients.", + ), + ), + "File": ( + "Bind uploaded files from multipart form data.", + ( + "`File` reads uploaded files from `multipart/form-data`. HTTP routes", + "can receive files, but MCP tools and CLI actions cannot expose file", + "parameters in this release.", + ), + ), + "Header": ( + "Bind a value from an HTTP request header.", + ( + "`Header` reads case-insensitive HTTP headers. When no alias is", + "provided, underscores in the Python parameter name become hyphens.", + ), + ), + "Cookie": ( + "Bind a value from a request cookie.", + ( + "`Cookie` reads the parsed `Cookie` header and passes the selected", + "cookie value to the handler.", + ), + ), +} + +PARAMETER_OPTIONS: Mapping[str, tuple[tuple[str, str, str], ...]] = { + "Path": ( + ("default", "object", "Path parameters are always required. Leave unset."), + ("alias", "str | None", "Route path variable name when it differs."), + ("description", "str | None", "Human description used in generated schemas."), + ), + "Query": ( + ("default", "object", "Default value. Omit it to make the parameter required."), + ("alias", "str | None", "Query-string name when it differs."), + ("description", "str | None", "Human description used in generated schemas."), + ), + "Body": ( + ("default", "object", "Default body value. Omit it to make the body required."), + ( + "alias", + "str | None", + "MCP and CLI argument name. HTTP still reads the full body.", + ), + ("description", "str | None", "Human description used in generated schemas."), + ), + "Form": ( + ("default", "object", "Default value. Omit it to make the field required."), + ("alias", "str | None", "Form field name when it differs."), + ("description", "str | None", "Human description used in generated schemas."), + ), + "File": ( + ("default", "object", "Default value. Omit it to make the file required."), + ("alias", "str | None", "Multipart field name when it differs."), + ("description", "str | None", "Human description used in generated schemas."), + ), + "Header": ( + ("default", "object", "Default value. Omit it to make the header required."), + ("alias", "str | None", "HTTP header name, such as `X-Request-ID`."), + ("description", "str | None", "Human description used in generated schemas."), + ( + "convert_underscores", + "bool", + "Convert `user_agent` to `user-agent` when no alias is set.", + ), + ), + "Cookie": ( + ("default", "object", "Default value. Omit it to make the cookie required."), + ("alias", "str | None", "Cookie name when it differs."), + ("description", "str | None", "Human description used in generated schemas."), + ), +} + +QUATER_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("name", "str | None", "Optional app name used by generated metadata."), + ( + "config", + "AppConfig | None", + "Base [AppConfig](#symbol-appconfig) to start from.", + ), + ("debug", "bool | None", "Override `config.debug`. Use only while developing."), + ("security", "SecurityMode | None", "Override the security mode."), + ("allowed_hosts", "Iterable[str] | None", "Hosts accepted by host checks."), + ( + "trusted_proxies", + "Iterable[str] | None", + "Proxy IPs trusted for forwarded headers.", + ), + ("max_body_size", "MaxBodySize | None", "Maximum request body size."), + ("max_form_parts", "int | None", "Maximum form field and file count."), + ( + "max_form_field_size", + "MaxBodySize | None", + "Maximum size for one string form field.", + ), + ("max_file_size", "MaxBodySize | None", "Maximum size for one uploaded file."), + ( + "upload_spool_size", + "MaxBodySize | None", + "Per-file size before upload data rolls to disk.", + ), + ( + "max_tool_response_size", + "MaxBodySize | None", + "Maximum MCP tool response body size.", + ), + ( + "max_action_response_size", + "MaxBodySize | None", + "Maximum CLI action response body size.", + ), + ("cors", "CORSConfig | None", "Optional [CORSConfig](#symbol-corsconfig)."), + ("content_security_policy", "str | None", "Optional CSP response header."), + ("mcp_docs_path", "str | None", "MCP docs path. `None` disables it."), + ("mcp_allowed_origins", "Iterable[str] | None", "Browser origins allowed for MCP."), + ( + "mcp_auth", + "Authenticate | None", + "Required when any route has `tool=True`. See [Auth](./auth).", + ), + ( + "mcp_audit", + "AuditHook | None", + "Receives redacted [MCP audit events](./observability#symbol-toolauditevent).", + ), + ( + "cli_auth", + "Authenticate | None", + "Required when any route has `cli=True`. See [Auth](./auth).", + ), + ( + "action_approval", + "ActionApproval | None", + "Required for protected tools/actions. See " + "[ActionApproval](./auth#symbol-actionapproval).", + ), + ( + "access_logger", + "AccessLogHook | None", + "Receives " + "[structured access log events](./observability#symbol-accesslogevent).", + ), + ("docs_path", "str | None", "Swagger UI path. `None` disables it."), + ("openapi_path", "str | None", "OpenAPI JSON path. `None` disables it."), + ("request_id_header", "str | None", "Correlation header name. `None` disables it."), +) + +ROUTE_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("method", "str", "HTTP method. Only passed to `route()` or `add_route()`."), + ("path", "str", "Route path, such as `/orders/{order_id}`."), + ( + "name", + "str | None", + "Operation name used in docs and exposed metadata. " + "Defaults to the handler name.", + ), + ( + "description", + "str | None", + "Human text used by MCP tools, CLI actions, and docs.", + ), + ("tool", "bool", "Expose this route as an MCP tool."), + ("cli", "bool", "Expose this route as a Quater CLI action."), + ("needs_approval", "bool", "Require approval before MCP or CLI execution."), + ("auth", "Authenticate | None", "Route-level auth hook. See [Auth](./auth)."), + ( + "inject", + "ResourceMap | None", + "Handler resources created by Quater. See [Resources](/en/dev/resources).", + ), + ( + "metadata", + "dict[str, Any] | None", + "Extra metadata used by docs and extensions.", + ), + ("before", "Iterable[BeforeMiddleware]", "Route before-request middleware."), + ("after", "Iterable[AfterMiddleware]", "Route after-response middleware."), + ("around", "Iterable[AroundMiddleware]", "Route wrapper middleware."), + ( + "exception_handlers", + "Iterable[ExceptionHandlerEntry]", + "Route-specific exception handlers.", + ), +) + +GROUP_ROUTE_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("method", "str", "HTTP method. Only passed to `route()` or `add_route()`."), + ("path", "str", "Group-relative route path, such as `/{order_id}`."), + ( + "name", + "str | None", + "Operation name used in docs and exposed metadata. " + "Defaults to the handler name.", + ), + ( + "description", + "str | None", + "Human text used by MCP tools, CLI actions, and docs.", + ), + ("tool", "bool", "Expose this route as an MCP tool."), + ("cli", "bool", "Expose this route as a Quater CLI action."), + ("needs_approval", "bool", "Require approval before MCP or CLI execution."), + ("auth", "Authenticate | None", "Route-level auth hook. See [Auth](./auth)."), + ( + "inject", + "ResourceMap | None", + "Handler resources created by Quater. See [Resources](/en/dev/resources).", + ), + ( + "metadata", + "Mapping[str, Any] | None", + "Extra metadata inherited into the final route.", + ), + ("before", "Iterable[BeforeMiddleware]", "Route before-request middleware."), + ("after", "Iterable[AfterMiddleware]", "Route after-response middleware."), + ("around", "Iterable[AroundMiddleware]", "Route wrapper middleware."), + ( + "exception_handlers", + "Iterable[ExceptionHandlerEntry]", + "Route-specific exception handlers.", + ), +) + +ROUTE_GROUP_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("prefix", "str", "Path prefix applied to child routes."), + ("tags", "Iterable[str]", "OpenAPI tags inherited by child routes."), + ( + "auth", + "Authenticate | None", + "Auth hook inherited by child routes. See [Auth](./auth).", + ), + ( + "inject", + "ResourceMap | None", + "Resources inherited by child routes. See [Resources](/en/dev/resources).", + ), + ("metadata", "Mapping[str, Any] | None", "Metadata inherited by child routes."), + ("before", "Iterable[BeforeMiddleware]", "Before middleware inherited by routes."), + ("after", "Iterable[AfterMiddleware]", "After middleware inherited by routes."), + ("around", "Iterable[AroundMiddleware]", "Around middleware inherited by routes."), + ( + "exception_handlers", + "Iterable[ExceptionHandlerEntry]", + "Exception handlers inherited by routes.", + ), +) + +RESPONSE_OPTIONS: Mapping[str, tuple[tuple[str, str, str], ...]] = { + "Response": ( + ("body", "bytes", "Raw response body."), + ("status_code", "int", "HTTP status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ("content_type", "str | None", "Sets `content-type` when not already set."), + ), + "JSONResponse": ( + ("content", "object", "Value serialized as JSON with msgspec."), + ("status_code", "int", "HTTP status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ), + "TextResponse": ( + ("content", "str", "Text encoded as UTF-8."), + ("status_code", "int", "HTTP status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ("content_type", "str", "Text content type."), + ), + "HTMLResponse": ( + ("content", "str", "HTML encoded as UTF-8."), + ("status_code", "int", "HTTP status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ), + "BytesResponse": ( + ("content", "ResponseBody", "Bytes-like body value."), + ("status_code", "int", "HTTP status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ("content_type", "str", "Byte response content type."), + ), + "StreamResponse": ( + ("body_iterator", "AsyncIterable[bytes]", "Async iterator yielding bytes."), + ("status_code", "int", "HTTP status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ("content_type", "str", "Stream content type."), + ), + "RedirectResponse": ( + ("location", "str", "Redirect target."), + ("status_code", "int", "Redirect status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ), + "EmptyResponse": ( + ("status_code", "int", "HTTP status code."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), + ), +} + +SET_COOKIE_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("key", "str", "Cookie name. Must be a valid RFC 6265 token."), + ("value", "str", "Cookie value. Must be a valid RFC 6265 cookie-octet string."), + ("max_age", "int | None", "Max age in seconds."), + ("expires", "int | None", "Expiry as a Unix timestamp."), + ("path", "str | None", "Cookie path. Defaults to `/`."), + ("domain", "str | None", "Cookie domain."), + ("secure", "bool", "Set the `Secure` flag."), + ("httponly", "bool", "Set the `HttpOnly` flag."), + ( + "samesite", + "Literal['lax', 'strict', 'none'] | None", + 'SameSite policy. `"none"` requires `secure=True`. Defaults to `"lax"`.', + ), +) + +DELETE_COOKIE_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("key", "str", "Cookie name to delete."), + ("path", "str | None", "Must match the path used when the cookie was set."), + ("domain", "str | None", "Must match the domain used when the cookie was set."), + ("secure", "bool", "Required for `__Secure-` and `__Host-` prefixed cookies."), + ("httponly", "bool", "Set the `HttpOnly` flag."), + ( + "samesite", + "Literal['lax', 'strict', 'none'] | None", + "Required when the deletion response is sent cross-site." + ' `"none"` requires `secure=True`.', + ), +) + +HTTP_ERROR_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("detail", "str | None", "Error message returned to the client."), + ("status_code", "int | None", "HTTP status code for the error response."), +) + +SIGNED_COOKIE_SIGNER_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("secret", "SecretValue", "Current signing secret."), + ( + "fallback_secrets", + "Iterable[SecretValue]", + "Old secrets accepted during rotation.", + ), + ("salt", "str", "Purpose-specific salt for cookie signatures."), +) + +TEST_CLIENT_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("app", "object", "Quater app under test."), + ("host", "str", "Host header used for requests."), + ("scheme", "Literal['http', 'https']", "Request scheme."), + ("client", "str", "Client address attached to requests."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Default headers."), + ("cookies", "Mapping[str, str] | None", "Initial cookie jar."), +) + +TEST_CLIENT_REQUEST_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("method", "str", "HTTP method."), + ("path", "str", "Request path, optionally with a query string."), + ("params", "QueryParams | None", "Extra query parameters."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Request headers."), + ("cookies", "Mapping[str, str] | None", "Request cookies."), + ("json", "object", "JSON body to encode."), + ("content", "RequestContent | None", "Raw request body content."), + ( + "data", + "FormDataInput | None", + "Form fields for URL-encoded or multipart requests.", + ), + ("files", "FilesInput | None", "Uploaded files for multipart requests."), +) + +TEST_RESPONSE_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("status_code", "int", "Response status code."), + ("headers", "HeaderItems", "Collected response headers."), + ("body", "bytes", "Collected response body."), +) + +MCP_TEST_CLIENT_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("client", "TestClient", "HTTP test client used for MCP requests."), +) + +MCP_TOOLS_CALL_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("name", "str", "Tool name to call."), + ("arguments", "Mapping[str, object] | None", "Tool arguments."), + ("request_id", "JSONRPCID", "JSON-RPC request id."), + ("token", "str | None", "Bearer token used for MCP auth."), + ("origin", "str | None", "Origin header used for MCP origin checks."), + ("approval_token", "str | None", "Approval token for protected tools."), + ("meta", "Mapping[str, object] | None", "Optional MCP `_meta` payload."), + ("protocol_version", "str", "MCP protocol version header."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Extra request headers."), +) + +CLI_TEST_CLIENT_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("client", "TestClient", "HTTP test client used for CLI action requests."), +) + +CLI_CALL_OPTIONS: tuple[tuple[str, str, str], ...] = ( + ("action", "str", "Action name to call."), + ("arguments", "Mapping[str, object] | None", "Action arguments."), + ("token", "str | None", "Bearer token used for CLI auth."), + ( + "dry_run", + "bool", + "Return the preflight payload instead of running the handler.", + ), + ("approval_token", "str | None", "Approval token for protected actions."), + ("headers", "HeaderItems | Mapping[str, str] | None", "Extra request headers."), +) + + +def parameter_table( + package: Any, + symbol: str, + method: str | None, +) -> tuple[tuple[str, str], ...]: + obj = object_for(package, symbol) + if method is None: + member = getattr(obj, "members", {}).get("__init__") + else: + member = getattr(obj, "members", {}).get(method) + if member is None: + target = f"{symbol}.{method}" if method is not None else f"{symbol}.__init__" + raise SystemExit(f"Could not find {target}") + + resolved = resolve(member) + parameters = getattr(resolved, "parameters", ()) + rows: list[tuple[str, str]] = [] + for parameter in parameters: + name = getattr(parameter, "name", "") + if not name or name == "self": + continue + annotation_value = getattr(parameter, "annotation", None) + type_name = "object" if annotation_value is None else str(annotation_value) + rows.append((name, clean_type(type_name))) + return tuple(rows) + + +def function_parameter_table(package: Any, symbol: str) -> tuple[tuple[str, str], ...]: + obj = resolve(object_for(package, symbol)) + parameters = getattr(obj, "parameters", ()) + rows: list[tuple[str, str]] = [] + for parameter in parameters: + name = getattr(parameter, "name", "") + if not name: + continue + annotation_value = getattr(parameter, "annotation", None) + type_name = "object" if annotation_value is None else str(annotation_value) + rows.append((name, clean_type(type_name))) + return tuple(rows) + + +def field_table(package: Any, symbol: str) -> list[str]: + from _reference.signatures import init_annotations + + obj = object_for(package, symbol) + annotations = init_annotations(obj) + rows: list[str] = [] + actual_fields: set[str] = set() + for name, member in getattr(obj, "members", {}).items(): + if is_private_member(name) or name in {"__slots__", "__test__"}: + continue + target = resolve(member) + kind = str(getattr(target, "kind", "")) + if not kind.endswith("ATTRIBUTE"): + continue + actual_fields.add(name) + type_name = annotation(target) + if type_name == "object": + type_name = annotations.get(name, type_name) + description = FIELD_DOCS.get(symbol, {}).get(name) + if description is None: + continue + rows.append( + f"| `{name}` | {type_cell(type_name)} | {table_text(description)} |" + ) + + documented_fields = set(FIELD_DOCS.get(symbol, {})) + if actual_fields != documented_fields: + missing = sorted(actual_fields - documented_fields) + extra = sorted(documented_fields - actual_fields) + raise SystemExit( + f"{symbol} field docs mismatch; missing={missing}, extra={extra}" + ) + + if not rows: + return [] + + return [ + "Fields:", + "", + "| Field | Type | Meaning |", + "| --- | --- | --- |", + *rows, + "", + ] + + +def option_table( + title: str, + rows: Sequence[tuple[str, str, str]], +) -> list[str]: + lines = [f"### {title}", "", "| Name | Type | Meaning |", "| --- | --- | --- |"] + for name, type_name, description in rows: + lines.append( + f"| `{name}` | {type_cell(type_name)} | {table_text(description)} |" + ) + lines.append("") + return lines + + +def validated_option_table( + package: Any, + symbol: str, + method: str | None, + title: str, + rows: Sequence[tuple[str, str, str]], +) -> list[str]: + actual = parameter_table(package, symbol, method) + expected = tuple((name, type_name) for name, type_name, _ in rows) + if actual != expected: + target = f"{symbol}.{method}" if method is not None else symbol + raise SystemExit( + f"{target} reference table mismatch; actual={actual}, expected={expected}" + ) + return option_table(title, rows) + + +def validated_function_option_table( + package: Any, + symbol: str, + title: str, + rows: Sequence[tuple[str, str, str]], +) -> list[str]: + actual = function_parameter_table(package, symbol) + expected = tuple((name, type_name) for name, type_name, _ in rows) + if actual != expected: + raise SystemExit( + f"{symbol} reference table mismatch; actual={actual}, expected={expected}" + ) + return option_table(title, rows) + + +def type_section(rows: Sequence[tuple[str, str]]) -> list[str]: + lines = ["## Type Names Used Here", "", "| Name | Meaning |", "| --- | --- |"] + for name, description in rows: + lines.append(f"| {type_name_cell(name)} | {table_text(description)} |") + lines.append("") + return lines diff --git a/scripts/_reference/types.py b/scripts/_reference/types.py new file mode 100644 index 0000000..ecab178 --- /dev/null +++ b/scripts/_reference/types.py @@ -0,0 +1,121 @@ +"""Type name rendering, links, and small string helpers.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable + +from _reference.pages import PAGES, ReferencePage +from _reference.signatures import clean_type + + +def slug(value: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()) + return normalized.strip("-") + + +def symbol_anchor(value: str) -> str: + if value == "__version__": + return "symbol-version" + return f"symbol-{slug(value)}" + + +def symbol_heading(value: str) -> str: + return f"## {value} {{#{symbol_anchor(value)}}}" + + +def is_private_member(name: str) -> bool: + return name.startswith("_") + + +def import_link(name: str, page: ReferencePage) -> str: + target = f"./{page.slug}#{symbol_anchor(name)}" + return f"[`{name}`]({target})" + + +def type_cell(value: str) -> str: + text = clean_type(value) + if not any(type_reference(match.group(0)) for match in type_words(text)): + return f"`{table_code(text)}`" + + escaped = table_text(text) + return re.sub( + r"\b[A-Za-z_][A-Za-z0-9_]*\b", + lambda match: type_token(match.group(0)), + escaped, + ) + + +def type_name_cell(name: str) -> str: + anchor = f'' + link = public_type_reference(name) + if link is not None: + return f"{anchor}[`{name}`]({link})" + return f"{anchor}`{name}`" + + +def type_token(name: str) -> str: + link = type_reference(name) + if link is None: + return name + return f"[`{name}`]({link})" + + +def type_words(value: str) -> Iterable[re.Match[str]]: + return re.finditer(r"\b[A-Za-z_][A-Za-z0-9_]*\b", value) + + +def type_reference(name: str) -> str | None: + public_link = public_type_reference(name) + if public_link is not None: + return public_link + + internal_links = { + "HeaderItems": "/en/dev/reference/request#type-headeritems", + "RequestBody": "/en/dev/reference/request#type-requestbody", + "RequestContext": "/en/dev/reference/request#call-context", + "Headers": "/en/dev/reference/request#headers", + "QueryParams": "/en/dev/reference/request#queryparams", + "Cookies": "/en/dev/reference/request#cookies", + "SecurityMode": "/en/dev/reference/application#type-securitymode", + "MaxBodySize": "/en/dev/reference/application#type-maxbodysize", + "Authenticate": "/en/dev/reference/auth#type-authenticate", + "AuditHook": "/en/dev/reference/application#type-audithook", + "BeforeMiddleware": "/en/dev/reference/application#type-beforemiddleware", + "AfterMiddleware": "/en/dev/reference/application#type-aftermiddleware", + "AroundMiddleware": "/en/dev/reference/application#type-aroundmiddleware", + "ExceptionHandlerEntry": ( + "/en/dev/reference/application#type-exceptionhandlerentry" + ), + "ResourceMap": "/en/dev/reference/resources#type-resourcemap", + "ResourceProvider": "/en/dev/reference/resources#type-resourceprovider", + "ResourceScope": "/en/dev/reference/resources#type-resourcescope", + "ResponseBody": "/en/dev/reference/responses#type-responsebody", + "RequestContent": "/en/dev/reference/testing#type-requestcontent", + "JSONRPCID": "/en/dev/reference/testing#type-jsonrpcid", + "SecretValue": "/en/dev/reference/auth#type-secretvalue", + } + return internal_links.get(name) + + +def public_type_reference(name: str) -> str | None: + for page in PAGES: + if name in page.symbols and name != "__version__": + return f"/en/dev/reference/{page.slug}#{symbol_anchor(name)}" + return None + + +def type_anchor(name: str) -> str: + return f"type-{slug(name)}" + + +def table_code(value: str) -> str: + return value.replace("|", "\\|") + + +def table_text(value: str) -> str: + return value.replace("|", "\\|") + + +def context_row(name: str, type_name: str, description: str) -> str: + return f"| `{name}` | {type_cell(type_name)} | {table_text(description)} |" diff --git a/scripts/generate_reference.py b/scripts/generate_reference.py index 304b676..19e7683 100644 --- a/scripts/generate_reference.py +++ b/scripts/generate_reference.py @@ -1,675 +1,36 @@ -"""Generate the VitePress API reference from Quater's public Python API.""" +"""Generate the VitePress API reference from Quater's public Python API. + +The heavy lifting lives in the internal ``_reference`` package next to this +script. This file only wires up the CLI, calls the pipeline, and re-exports +the signature helpers exercised by ``tests/unit/test_generate_reference.py``. +""" from __future__ import annotations import argparse -import ast -import difflib -import re import sys -from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass +from collections.abc import Sequence from pathlib import Path -from typing import Any - -import griffe - -REPO_ROOT = Path(__file__).resolve().parents[1] -SOURCE_ROOT = REPO_ROOT / "src" -PACKAGE_INIT = SOURCE_ROOT / "quater" / "__init__.py" -REFERENCE_DIR = REPO_ROOT / "docs" / "en" / "dev" / "reference" -GENERATED_HEADER = ( - "" -) -MIN_PUBLIC_DOCSTRING_WORDS = 8 -PLACEHOLDER_DOCSTRING_WORDS = frozenset({"todo", "tbd", "fixme", "placeholder"}) - - -@dataclass(frozen=True, slots=True) -class ReferencePage: - slug: str - title: str - description: str - symbols: tuple[str, ...] - - @property - def path(self) -> Path: - return REFERENCE_DIR / f"{self.slug}.md" - - -PAGES: tuple[ReferencePage, ...] = ( - ReferencePage( - slug="application", - title="Application", - description="App objects, route groups, and configuration.", - symbols=("Quater", "RouteGroup", "AppConfig", "CORSConfig", "__version__"), - ), - ReferencePage( - slug="resources", - title="Resources", - description="Request-scoped resources injected into handlers.", - symbols=("Resource",), - ), - ReferencePage( - slug="request", - title="Request", - description="Request data and state passed through handlers.", - symbols=("Request", "State", "FormData", "UploadFile"), - ), - ReferencePage( - slug="parameters", - title="Parameters", - description="Handler parameter markers for request data binding.", - symbols=("Path", "Query", "Body", "Form", "File", "Header", "Cookie"), - ), - ReferencePage( - slug="responses", - title="Responses", - description="Return values and explicit response classes.", - symbols=( - "Response", - "JSONResponse", - "TextResponse", - "HTMLResponse", - "BytesResponse", - "StreamResponse", - "RedirectResponse", - "EmptyResponse", - ), - ), - ReferencePage( - slug="auth", - title="Auth and Security", - description=( - "Auth hooks, approval hooks, framework errors, and signed cookies." - ), - symbols=( - "AuthConfig", - "AuthContext", - "ApprovalRequest", - "ActionApproval", - "HTTPError", - "ImproperlyConfigured", - "SignedCookieSigner", - ), - ), - ReferencePage( - slug="observability", - title="Observability", - description="Access-log and MCP audit event types.", - symbols=("AccessLogEvent", "AccessLogHook", "ToolAuditEvent"), - ), - ReferencePage( - slug="testing", - title="Testing", - description="In-process HTTP, MCP, and CLI test clients.", - symbols=("TestClient", "TestResponse", "MCPTestClient", "CliTestClient"), - ), -) - - -def page_symbols(slug: str) -> tuple[str, ...]: - for page in PAGES: - if page.slug == slug: - return page.symbols - raise KeyError(slug) - - -FIELD_DOCS: Mapping[str, Mapping[str, str]] = { - "AppConfig": { - "debug": "Return detailed error responses while developing.", - "security": "`strict`, `relaxed`, or `off` security defaults.", - "allowed_hosts": "Host names the app should accept.", - "trusted_proxies": "Proxy IPs or CIDR ranges trusted for forwarded headers.", - "max_body_size": "Maximum request body size in bytes.", - "max_form_parts": "Maximum number of form fields and file parts.", - "max_form_field_size": "Maximum size for one string form field.", - "max_file_size": "Maximum size for one uploaded file.", - "upload_spool_size": "Per-file size before upload data rolls to disk.", - "max_tool_response_size": "Maximum MCP tool response body size.", - "max_action_response_size": "Maximum CLI action response body size.", - "cors": "Optional CORS policy.", - "content_security_policy": "Optional Content-Security-Policy header value.", - "docs_path": "Swagger UI path. Set to `None` to disable it.", - "openapi_path": "OpenAPI JSON path. Set to `None` to disable it.", - "mcp_docs_path": "Human-readable MCP docs path. Set to `None` to disable it.", - "mcp_allowed_origins": ( - "Origins allowed to call the MCP endpoint from browsers." - ), - "request_id_header": "Header used for incoming and outgoing request ids.", - }, - "CORSConfig": { - "allowed_origins": "Origins allowed to read browser responses.", - "allowed_methods": "Methods allowed during browser preflight checks.", - "allowed_headers": ( - "Request headers allowed during preflight. Empty reflects sanitized " - "requested headers." - ), - "expose_headers": "Response headers browsers may expose to client code.", - "allow_credentials": "Whether browsers may include credentials.", - "max_age": "How long browsers may cache a preflight result.", - }, - "AuthRequest": { - "method": "HTTP method for the route or protocol request.", - "path": "Path being accessed.", - "headers": "Normalized request headers.", - "context": "Quater context describing the source of the call.", - }, - "AuthContext": { - "subject": "Stable id for the authenticated user, service, or agent.", - "metadata": "Small extra values your app wants to carry with the request.", - }, - "ApprovalRequest": { - "action": "Tool or CLI action name.", - "arguments_hash": "Hash of the action name and canonical bound arguments.", - "token": "Approval token supplied by the caller.", - "auth": "Authenticated subject, when the action request was authenticated.", - "context": "Quater context for the tool or CLI call.", - }, - "AccessLogEvent": { - "request_id": "Request id used for correlation.", - "method": "HTTP method handled by Quater.", - "path": "Path handled by Quater.", - "status_code": "Final response status code.", - "duration_ms": "Time spent handling the request.", - "source": "`api`, `mcp`, or `cli`.", - "entrypoint": "`server` for hosted calls, `local` for local CLI.", - "scheme": "Request scheme.", - "client": "Client address when available.", - "tool_name": "MCP tool name when the request came from a tool call.", - "action_name": "CLI action name when the request came from an action call.", - }, - "ToolAuditEvent": { - "tool_name": "MCP tool that was called.", - "subject": "Authenticated subject, when present.", - "success": "Whether the tool call completed successfully.", - "duration_ms": "Tool call duration.", - "arguments": "Redacted argument map passed to the audit hook.", - }, -} - -REQUEST_CONSTRUCTOR_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("method", "str", "HTTP method used to create the normalized request."), - ("path", "str", "Request path without the query string."), - ("scheme", "str", "`http` or `https`. Defaults to `http`."), - ( - "headers", - "HeaderItems | Mapping[str, str]", - "Incoming request headers. Exposed later as `request.headers`.", - ), - ( - "query_string", - "str | bytes", - "Raw query string. Exposed later as parsed `request.query`.", - ), - ("body", "RequestBody", "Raw bytes, async body reader, or `None`."), - ( - "auth", - "AuthContext | None", - "Initial auth context. Most apps let route auth set this.", - ), - ("client", "str | None", "Client address when available."), - ( - "context", - "RequestContext | None", - "Call-source context. Quater creates a default when omitted.", - ), - ( - "app", - "Quater | None", - "Application handling the request. Quater sets this at the app boundary.", - ), - ("max_body_size", "int | None", "Optional body-size limit for this request."), - ("max_form_parts", "int | None", "Optional form part count limit."), - ( - "max_form_field_size", - "int | None", - "Optional per-field form size limit.", - ), - ("max_file_size", "int | None", "Optional per-file upload size limit."), - ("upload_spool_size", "int | None", "Optional upload spool threshold."), -) - -RESPONSE_DOCS: Mapping[str, str] = { - "Response": "Use this when you already have bytes and want full control.", - "JSONResponse": "Use this when you need explicit status or headers for JSON.", - "TextResponse": "Use this for plain text.", - "HTMLResponse": "Use this for HTML.", - "BytesResponse": "Use this for raw bytes.", - "StreamResponse": "Use this for async byte streams.", - "RedirectResponse": "Use this for redirects.", - "EmptyResponse": "Use this for responses with no body.", -} - -PARAMETER_DOCS: Mapping[str, tuple[str, tuple[str, ...]]] = { - "Path": ( - "Bind a value from a route path segment.", - ( - "`Path` is useful when the Python parameter name differs from the", - "name in the route path, or when you want descriptions in OpenAPI", - "and action schemas.", - ), - ), - "Query": ( - "Bind a value from the query string.", - ( - "`Query` makes query parameters explicit and lets you set aliases,", - "defaults, and descriptions without changing handler logic.", - ), - ), - "Body": ( - "Bind the JSON request body.", - ( - "`Body` documents the body parameter and feeds the same schema into", - "OpenAPI, MCP tools, and CLI actions.", - ), - ), - "Form": ( - "Bind a scalar field from a submitted form.", - ( - "`Form` reads fields from `application/x-www-form-urlencoded` or", - "`multipart/form-data` requests. It is useful for browser forms,", - "OAuth-style token endpoints, and compatibility with existing clients.", - ), - ), - "File": ( - "Bind uploaded files from multipart form data.", - ( - "`File` reads uploaded files from `multipart/form-data`. HTTP routes", - "can receive files, but MCP tools and CLI actions cannot expose file", - "parameters in this release.", - ), - ), - "Header": ( - "Bind a value from an HTTP request header.", - ( - "`Header` reads case-insensitive HTTP headers. When no alias is", - "provided, underscores in the Python parameter name become hyphens.", - ), - ), - "Cookie": ( - "Bind a value from a request cookie.", - ( - "`Cookie` reads the parsed `Cookie` header and passes the selected", - "cookie value to the handler.", - ), - ), -} -PARAMETER_OPTIONS: Mapping[str, tuple[tuple[str, str, str], ...]] = { - "Path": ( - ("default", "object", "Path parameters are always required. Leave unset."), - ("alias", "str | None", "Route path variable name when it differs."), - ("description", "str | None", "Human description used in generated schemas."), - ), - "Query": ( - ("default", "object", "Default value. Omit it to make the parameter required."), - ("alias", "str | None", "Query-string name when it differs."), - ("description", "str | None", "Human description used in generated schemas."), - ), - "Body": ( - ("default", "object", "Default body value. Omit it to make the body required."), - ( - "alias", - "str | None", - "MCP and CLI argument name. HTTP still reads the full body.", - ), - ("description", "str | None", "Human description used in generated schemas."), - ), - "Form": ( - ("default", "object", "Default value. Omit it to make the field required."), - ("alias", "str | None", "Form field name when it differs."), - ("description", "str | None", "Human description used in generated schemas."), - ), - "File": ( - ("default", "object", "Default value. Omit it to make the file required."), - ("alias", "str | None", "Multipart field name when it differs."), - ("description", "str | None", "Human description used in generated schemas."), - ), - "Header": ( - ("default", "object", "Default value. Omit it to make the header required."), - ("alias", "str | None", "HTTP header name, such as `X-Request-ID`."), - ("description", "str | None", "Human description used in generated schemas."), - ( - "convert_underscores", - "bool", - "Convert `user_agent` to `user-agent` when no alias is set.", - ), - ), - "Cookie": ( - ("default", "object", "Default value. Omit it to make the cookie required."), - ("alias", "str | None", "Cookie name when it differs."), - ("description", "str | None", "Human description used in generated schemas."), - ), -} +_SCRIPTS_DIR = str(Path(__file__).resolve().parent) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) -QUATER_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("name", "str | None", "Optional app name used by generated metadata."), - ( - "config", - "AppConfig | None", - "Base [AppConfig](#symbol-appconfig) to start from.", - ), - ("debug", "bool | None", "Override `config.debug`. Use only while developing."), - ("security", "SecurityMode | None", "Override the security mode."), - ("allowed_hosts", "Iterable[str] | None", "Hosts accepted by host checks."), - ( - "trusted_proxies", - "Iterable[str] | None", - "Proxy IPs trusted for forwarded headers.", - ), - ("max_body_size", "MaxBodySize | None", "Maximum request body size."), - ("max_form_parts", "int | None", "Maximum form field and file count."), - ( - "max_form_field_size", - "MaxBodySize | None", - "Maximum size for one string form field.", - ), - ("max_file_size", "MaxBodySize | None", "Maximum size for one uploaded file."), - ( - "upload_spool_size", - "MaxBodySize | None", - "Per-file size before upload data rolls to disk.", - ), - ( - "max_tool_response_size", - "MaxBodySize | None", - "Maximum MCP tool response body size.", - ), - ( - "max_action_response_size", - "MaxBodySize | None", - "Maximum CLI action response body size.", - ), - ("cors", "CORSConfig | None", "Optional [CORSConfig](#symbol-corsconfig)."), - ("content_security_policy", "str | None", "Optional CSP response header."), - ("mcp_docs_path", "str | None", "MCP docs path. `None` disables it."), - ("mcp_allowed_origins", "Iterable[str] | None", "Browser origins allowed for MCP."), - ( - "mcp_auth", - "Authenticate | None", - "Required when any route has `tool=True`. See [Auth](./auth).", - ), - ( - "mcp_audit", - "AuditHook | None", - "Receives redacted [MCP audit events](./observability#symbol-toolauditevent).", - ), - ( - "cli_auth", - "Authenticate | None", - "Required when any route has `cli=True`. See [Auth](./auth).", - ), - ( - "action_approval", - "ActionApproval | None", - "Required for protected tools/actions. See " - "[ActionApproval](./auth#symbol-actionapproval).", - ), - ( - "access_logger", - "AccessLogHook | None", - "Receives " - "[structured access log events](./observability#symbol-accesslogevent).", - ), - ("docs_path", "str | None", "Swagger UI path. `None` disables it."), - ("openapi_path", "str | None", "OpenAPI JSON path. `None` disables it."), - ("request_id_header", "str | None", "Correlation header name. `None` disables it."), -) - -ROUTE_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("method", "str", "HTTP method. Only passed to `route()` or `add_route()`."), - ("path", "str", "Route path, such as `/orders/{order_id}`."), - ( - "name", - "str | None", - "Operation name used in docs and exposed metadata. " - "Defaults to the handler name.", - ), - ( - "description", - "str | None", - "Human text used by MCP tools, CLI actions, and docs.", - ), - ("tool", "bool", "Expose this route as an MCP tool."), - ("cli", "bool", "Expose this route as a Quater CLI action."), - ("needs_approval", "bool", "Require approval before MCP or CLI execution."), - ("auth", "Authenticate | None", "Route-level auth hook. See [Auth](./auth)."), - ( - "inject", - "ResourceMap | None", - "Handler resources created by Quater. See [Resources](/en/dev/resources).", - ), - ( - "metadata", - "dict[str, Any] | None", - "Extra metadata used by docs and extensions.", - ), - ("before", "Iterable[BeforeMiddleware]", "Route before-request middleware."), - ("after", "Iterable[AfterMiddleware]", "Route after-response middleware."), - ("around", "Iterable[AroundMiddleware]", "Route wrapper middleware."), - ( - "exception_handlers", - "Iterable[ExceptionHandlerEntry]", - "Route-specific exception handlers.", - ), -) - -GROUP_ROUTE_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("method", "str", "HTTP method. Only passed to `route()` or `add_route()`."), - ("path", "str", "Group-relative route path, such as `/{order_id}`."), - ( - "name", - "str | None", - "Operation name used in docs and exposed metadata. " - "Defaults to the handler name.", - ), - ( - "description", - "str | None", - "Human text used by MCP tools, CLI actions, and docs.", - ), - ("tool", "bool", "Expose this route as an MCP tool."), - ("cli", "bool", "Expose this route as a Quater CLI action."), - ("needs_approval", "bool", "Require approval before MCP or CLI execution."), - ("auth", "Authenticate | None", "Route-level auth hook. See [Auth](./auth)."), - ( - "inject", - "ResourceMap | None", - "Handler resources created by Quater. See [Resources](/en/dev/resources).", - ), - ( - "metadata", - "Mapping[str, Any] | None", - "Extra metadata inherited into the final route.", - ), - ("before", "Iterable[BeforeMiddleware]", "Route before-request middleware."), - ("after", "Iterable[AfterMiddleware]", "Route after-response middleware."), - ("around", "Iterable[AroundMiddleware]", "Route wrapper middleware."), - ( - "exception_handlers", - "Iterable[ExceptionHandlerEntry]", - "Route-specific exception handlers.", - ), -) - -ROUTE_GROUP_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("prefix", "str", "Path prefix applied to child routes."), - ("tags", "Iterable[str]", "OpenAPI tags inherited by child routes."), - ( - "auth", - "Authenticate | None", - "Auth hook inherited by child routes. See [Auth](./auth).", - ), - ( - "inject", - "ResourceMap | None", - "Resources inherited by child routes. See [Resources](/en/dev/resources).", - ), - ("metadata", "Mapping[str, Any] | None", "Metadata inherited by child routes."), - ("before", "Iterable[BeforeMiddleware]", "Before middleware inherited by routes."), - ("after", "Iterable[AfterMiddleware]", "After middleware inherited by routes."), - ("around", "Iterable[AroundMiddleware]", "Around middleware inherited by routes."), - ( - "exception_handlers", - "Iterable[ExceptionHandlerEntry]", - "Exception handlers inherited by routes.", - ), -) - -RESPONSE_OPTIONS: Mapping[str, tuple[tuple[str, str, str], ...]] = { - "Response": ( - ("body", "bytes", "Raw response body."), - ("status_code", "int", "HTTP status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ("content_type", "str | None", "Sets `content-type` when not already set."), - ), - "JSONResponse": ( - ("content", "object", "Value serialized as JSON with msgspec."), - ("status_code", "int", "HTTP status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ), - "TextResponse": ( - ("content", "str", "Text encoded as UTF-8."), - ("status_code", "int", "HTTP status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ("content_type", "str", "Text content type."), - ), - "HTMLResponse": ( - ("content", "str", "HTML encoded as UTF-8."), - ("status_code", "int", "HTTP status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ), - "BytesResponse": ( - ("content", "ResponseBody", "Bytes-like body value."), - ("status_code", "int", "HTTP status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ("content_type", "str", "Byte response content type."), - ), - "StreamResponse": ( - ("body_iterator", "AsyncIterable[bytes]", "Async iterator yielding bytes."), - ("status_code", "int", "HTTP status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ("content_type", "str", "Stream content type."), - ), - "RedirectResponse": ( - ("location", "str", "Redirect target."), - ("status_code", "int", "Redirect status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ), - "EmptyResponse": ( - ("status_code", "int", "HTTP status code."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Response headers."), - ), -} - -SET_COOKIE_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("key", "str", "Cookie name. Must be a valid RFC 6265 token."), - ("value", "str", "Cookie value. Must be a valid RFC 6265 cookie-octet string."), - ("max_age", "int | None", "Max age in seconds."), - ("expires", "int | None", "Expiry as a Unix timestamp."), - ("path", "str | None", "Cookie path. Defaults to `/`."), - ("domain", "str | None", "Cookie domain."), - ("secure", "bool", "Set the `Secure` flag."), - ("httponly", "bool", "Set the `HttpOnly` flag."), - ( - "samesite", - "Literal['lax', 'strict', 'none'] | None", - 'SameSite policy. `"none"` requires `secure=True`. Defaults to `"lax"`.', - ), -) +import griffe # noqa: E402 -DELETE_COOKIE_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("key", "str", "Cookie name to delete."), - ("path", "str | None", "Must match the path used when the cookie was set."), - ("domain", "str | None", "Must match the domain used when the cookie was set."), - ("secure", "bool", "Required for `__Secure-` and `__Host-` prefixed cookies."), - ("httponly", "bool", "Set the `HttpOnly` flag."), - ( - "samesite", - "Literal['lax', 'strict', 'none'] | None", - "Required when the deletion response is sent cross-site." - ' `"none"` requires `secure=True`.', - ), +from _reference.output import check_outputs, write_outputs # noqa: E402 +from _reference.pages import ( # noqa: E402 + PAGES, + page_map, + read_public_api, + validate_public_docstrings, ) - -HTTP_ERROR_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("detail", "str | None", "Error message returned to the client."), - ("status_code", "int | None", "HTTP status code for the error response."), -) - -SIGNED_COOKIE_SIGNER_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("secret", "SecretValue", "Current signing secret."), - ( - "fallback_secrets", - "Iterable[SecretValue]", - "Old secrets accepted during rotation.", - ), - ("salt", "str", "Purpose-specific salt for cookie signatures."), -) - -TEST_CLIENT_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("app", "object", "Quater app under test."), - ("host", "str", "Host header used for requests."), - ("scheme", "Literal['http', 'https']", "Request scheme."), - ("client", "str", "Client address attached to requests."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Default headers."), - ("cookies", "Mapping[str, str] | None", "Initial cookie jar."), -) - -TEST_CLIENT_REQUEST_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("method", "str", "HTTP method."), - ("path", "str", "Request path, optionally with a query string."), - ("params", "QueryParams | None", "Extra query parameters."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Request headers."), - ("cookies", "Mapping[str, str] | None", "Request cookies."), - ("json", "object", "JSON body to encode."), - ("content", "RequestContent | None", "Raw request body content."), - ( - "data", - "FormDataInput | None", - "Form fields for URL-encoded or multipart requests.", - ), - ("files", "FilesInput | None", "Uploaded files for multipart requests."), -) - -TEST_RESPONSE_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("status_code", "int", "Response status code."), - ("headers", "HeaderItems", "Collected response headers."), - ("body", "bytes", "Collected response body."), -) - -MCP_TEST_CLIENT_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("client", "TestClient", "HTTP test client used for MCP requests."), -) - -MCP_TOOLS_CALL_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("name", "str", "Tool name to call."), - ("arguments", "Mapping[str, object] | None", "Tool arguments."), - ("request_id", "JSONRPCID", "JSON-RPC request id."), - ("token", "str | None", "Bearer token used for MCP auth."), - ("origin", "str | None", "Origin header used for MCP origin checks."), - ("approval_token", "str | None", "Approval token for protected tools."), - ("meta", "Mapping[str, object] | None", "Optional MCP `_meta` payload."), - ("protocol_version", "str", "MCP protocol version header."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Extra request headers."), -) - -CLI_TEST_CLIENT_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("client", "TestClient", "HTTP test client used for CLI action requests."), -) - -CLI_CALL_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("action", "str", "Action name to call."), - ("arguments", "Mapping[str, object] | None", "Action arguments."), - ("token", "str | None", "Bearer token used for CLI auth."), - ( - "dry_run", - "bool", - "Return the preflight payload instead of running the handler.", - ), - ("approval_token", "str | None", "Approval token for protected actions."), - ("headers", "HeaderItems | Mapping[str, str] | None", "Extra request headers."), +from _reference.paths import SOURCE_ROOT # noqa: E402 +from _reference.render import render_reference # noqa: E402 +from _reference.signatures import ( # noqa: E402,F401 + clean_signature, + format_signature, + split_top_level_commas, ) @@ -701,1762 +62,5 @@ def main(argv: Sequence[str] | None = None) -> int: return 0 -def read_public_api() -> tuple[str, ...]: - module = ast.parse(PACKAGE_INIT.read_text(encoding="utf-8")) - for node in module.body: - if not isinstance(node, ast.Assign): - continue - if not any( - isinstance(target, ast.Name) and target.id == "__all__" - for target in node.targets - ): - continue - value = ast.literal_eval(node.value) - if not isinstance(value, list) or not all( - isinstance(item, str) for item in value - ): - raise SystemExit("__all__ must be a list of strings") - return tuple(value) - raise SystemExit("Could not find quater.__all__") - - -def page_map(pages: Iterable[ReferencePage]) -> dict[str, ReferencePage]: - mapped: dict[str, ReferencePage] = {} - for page in pages: - for symbol in page.symbols: - if symbol in mapped: - raise SystemExit(f"Duplicate reference symbol: {symbol}") - mapped[symbol] = page - return mapped - - -def validate_public_docstrings(package: Any, public_api: tuple[str, ...]) -> None: - missing: list[str] = [] - for name in public_api: - obj = object_for(package, name) - kind_name = str(getattr(obj, "kind", "")) - if not kind_name.endswith(("CLASS", "FUNCTION")): - continue - docstring = getattr(obj, "docstring", None) - value = getattr(docstring, "value", None) - if not meaningful_docstring(value): - missing.append(name) - - if missing: - raise SystemExit( - "Public classes/functions need meaningful docstrings: " - + ", ".join(sorted(missing)) - ) - - -def meaningful_docstring(value: object) -> bool: - if not isinstance(value, str): - return False - words = re.findall(r"[A-Za-z0-9_]+", value) - if len(words) < MIN_PUBLIC_DOCSTRING_WORDS: - return False - lowered = {word.lower() for word in words} - return not bool(lowered & PLACEHOLDER_DOCSTRING_WORDS) - - -def render_reference( - package: Any, - public_api: tuple[str, ...], - pages_by_symbol: Mapping[str, ReferencePage], -) -> dict[Path, str]: - manual_outputs = read_manual_reference(public_api, pages_by_symbol) - if manual_outputs is not None: - return manual_outputs - - return { - REFERENCE_DIR / "index.md": render_index(public_api, pages_by_symbol), - REFERENCE_DIR / "application.md": render_application(package), - REFERENCE_DIR / "resources.md": render_resources(package), - REFERENCE_DIR / "request.md": render_request(package), - REFERENCE_DIR / "parameters.md": render_parameters(package), - REFERENCE_DIR / "responses.md": render_responses(package), - REFERENCE_DIR / "auth.md": render_auth(package), - REFERENCE_DIR / "observability.md": render_observability(package), - REFERENCE_DIR / "testing.md": render_testing(package), - } - - -def read_manual_reference( - public_api: tuple[str, ...], - pages_by_symbol: Mapping[str, ReferencePage], -) -> dict[Path, str] | None: - paths = {REFERENCE_DIR / "index.md", *(page.path for page in PAGES)} - outputs: dict[Path, str] = {} - for path in paths: - if not path.exists(): - return None - content = path.read_text(encoding="utf-8") - if content.startswith(GENERATED_HEADER): - return None - outputs[path] = content - - for name in public_api: - page = pages_by_symbol[name] - content = outputs[page.path] - anchor = symbol_anchor(name) - if anchor not in content: - raise SystemExit( - f"Manual reference page {page.path} does not document {name!r}" - ) - return outputs - - -def render_index( - public_api: tuple[str, ...], - pages_by_symbol: Mapping[str, ReferencePage], -) -> str: - lines = new_page("Reference") - lines.extend( - [ - "These pages document the public objects you can import from `quater`.", - "They are meant for quick lookup after you understand the concept.", - "", - "If you are still learning the framework, start with the", - "[Quickstart](/en/dev/quickstart) and then come back here for", - "exact names and signatures.", - "", - "For import guarantees, read [Stability](/en/dev/stability).", - "", - "## Pages", - "", - "| Page | Use it for |", - "| --- | --- |", - ] - ) - for page in PAGES: - lines.append(f"| [{page.title}](./{page.slug}) | {page.description} |") - - lines.extend( - [ - "", - "## Public Imports", - "", - "Use top-level imports for normal app code. These are the documented", - "symbols Quater expects application code to use.", - "", - ] - ) - for page in PAGES: - names = [name for name in public_api if pages_by_symbol[name] == page] - imports = ", ".join(import_link(name, page) for name in names) - lines.append(f"- **{page.title}:** {imports}") - return finish(lines) - - -def render_application(package: Any) -> str: - lines = new_page("Application Reference") - lines.extend( - [ - "Use these objects to create an app, group routes by feature, and", - "configure Quater's built-in docs and safety defaults.", - "", - "For the route model, read [Public API](/en/dev/api). For production", - "settings, read [Security](/en/dev/security).", - "", - "```python", - "from quater import AppConfig, CORSConfig, Quater, Resource, RouteGroup", - "```", - "", - ] - ) - symbol_intro( - lines, - package, - "Quater", - "The application object.", - [ - "`Quater` owns the route registry, middleware, lifespan hooks, and", - "server adapters. A route can stay HTTP-only, or it can opt into MCP", - "and CLI surfaces with `tool=True` and `cli=True`.", - ], - ) - lines.extend(signature_block(class_signature(package, "Quater"))) - lines.extend( - validated_option_table( - package, "Quater", None, "Constructor options", QUATER_OPTIONS - ) - ) - lines.extend( - [ - "### App state", - "", - "`app.state` is a [`State`](./request#symbol-state) container for", - "resources that belong to the app instance. It is available from", - "handlers as `request.app.state`.", - "", - "### Route decorators", - "", - "Use decorators for normal route registration. `get`, `post`, `put`,", - "`patch`, and `delete` use the same options as `route`.", - "", - ] - ) - lines.extend(signature_block(method_signature(package, "Quater", "route"))) - lines.extend( - validated_option_table( - package, "Quater", "route", "Route options", ROUTE_OPTIONS - ) - ) - lines.extend( - [ - "Descriptions matter for `tool=True` and `cli=True` routes. They are the", - "text an agent or operator sees before deciding whether to call the", - "operation.", - "", - "### Common app methods", - "", - "| Method | Use it for |", - "| --- | --- |", - "| `include(group)` | Include a " - "[`RouteGroup`](#symbol-routegroup) in the app. |", - "| `add_route(...)` | Register a route without decorator syntax. |", - "| `before_request(...)` | Register global before-request middleware. |", - "| `after_response(...)` | Register global after-response middleware. |", - "| `around_request(...)` | Wrap the request handler pipeline. |", - "| `exception_handler(...)` | Register a global exception handler. |", - "| `on_startup(...)` / `on_shutdown(...)` | Register lifespan hooks. |", - "| `startup()` / `shutdown()` | Run lifespan hooks manually in tests. |", - "| `handle(request)` | Handle an in-process request. |", - "| `validate_production()` | Fail fast on unsafe production config. |", - "", - ] - ) - symbol_intro( - lines, - package, - "RouteGroup", - "A compile-time group for related routes.", - [ - "A group lets you share a prefix, tags, auth, middleware, and exception", - "handlers across a feature area. Included groups are flattened into", - "normal routes before matching, so grouping does not add a router layer", - "to the hot path.", - ], - ) - lines.extend(signature_block(class_signature(package, "RouteGroup"))) - lines.extend( - validated_option_table( - package, - "RouteGroup", - None, - "Constructor options", - ROUTE_GROUP_OPTIONS, - ) - ) - lines.extend( - [ - "Use `app.include(group)` after all routes are declared. Quater locks the", - "group after inclusion so routes cannot silently disappear later.", - "", - "Route groups expose the same route decorators as the app.", - "", - ] - ) - lines.extend(signature_block(method_signature(package, "RouteGroup", "route"))) - lines.extend( - validated_option_table( - package, - "RouteGroup", - "route", - "Group route options", - GROUP_ROUTE_OPTIONS, - ) - ) - lines.extend( - [ - "The route options mean the same thing on", - "[`RouteGroup`](#symbol-routegroup) as they do on", - "[`Quater`](#symbol-quater). Group-level auth, resources, metadata, and", - "middleware are merged into the final route before the app compiles", - "routes.", - "", - ] - ) - symbol_intro( - lines, - package, - "AppConfig", - "Immutable application configuration.", - [ - "Most apps pass simple keyword overrides to", - "[`Quater(...)`](#symbol-quater). Use `AppConfig` when you want to", - "build configuration once and pass it around explicitly.", - ], - ) - lines.extend(signature_block(class_signature(package, "AppConfig"))) - lines.extend(field_table(package, "AppConfig")) - symbol_intro( - lines, - package, - "CORSConfig", - "Browser CORS policy.", - [ - "CORS controls which browser origins may read responses. It is not an", - "authentication system; use `auth=...`, `mcp_auth`, and `cli_auth` for", - "access control.", - ], - ) - lines.extend(signature_block(class_signature(package, "CORSConfig"))) - lines.extend(field_table(package, "CORSConfig")) - symbol_intro( - lines, - package, - "__version__", - "Installed Quater version.", - ["Use this for diagnostics or support output."], - ) - lines.extend(code_block(f"__version__ = {attribute_value(package, '__version__')}")) - lines.extend( - type_section( - ( - ( - "SecurityMode", - "Literal config value: `strict`, `relaxed`, or `off`.", - ), - ( - "MaxBodySize", - 'Either bytes as `int` or a string such as `"2mb"`.', - ), - ( - "Authenticate", - "Async auth hook. See " - "[Auth and Security](./auth#symbol-authrequest).", - ), - ( - "ActionApproval", - "Async approval hook. See " - "[ActionApproval](./auth#symbol-actionapproval).", - ), - ( - "AuditHook", - "Async MCP audit hook receiving " - "[ToolAuditEvent](./observability#symbol-toolauditevent).", - ), - ( - "AccessLogHook", - "Async access-log hook. See " - "[Observability](./observability#symbol-accessloghook).", - ), - ( - "BeforeMiddleware", - "Runs before the handler. It can short-circuit by returning " - "a response.", - ), - ( - "AfterMiddleware", - "Runs after the handler and can adjust the response.", - ), - ( - "AroundMiddleware", - "Wraps the handler pipeline for timing, tracing, or similar " - "cross-cutting behavior.", - ), - ( - "ExceptionHandlerEntry", - "Internal wrapper for exception handlers passed through " - "decorators.", - ), - ) - ) - ) - return finish(lines) - - -def render_resources(package: Any) -> str: - lines = new_page("Resources Reference") - lines.extend( - [ - "Use these objects when a handler needs an app-owned value, such as a", - "database session, cache handle, tenant object, or request-scoped service.", - "", - "For the guide, read [Resources and Injection](/en/dev/resources).", - "", - "```python", - "from quater import Resource", - "```", - "", - ] - ) - symbol_intro( - lines, - package, - "Resource", - "A request-scoped injectable value.", - [ - "`Resource` wraps a provider callable and lets a route inject the", - "provider result into a named handler parameter.", - ], - ) - lines.extend(signature_block(class_signature(package, "Resource"))) - lines.extend( - [ - "### Constructor options", - "", - "| Name | Type | Meaning |", - "| --- | --- | --- |", - "| `provider` | [`ResourceProvider[T]`](#type-resourceprovider) | " - "Callable that creates the value. |", - "| `scope` | [`ResourceScope`](#type-resourcescope) | " - "Resource lifetime. Currently only `request` is supported. |", - "| `name` | `str \\| None` | Optional name used in resource error " - "messages. |", - "", - "### Provider forms", - "", - "The provider may accept no arguments:", - "", - "```python", - "async def settings() -> Settings:", - " return Settings.from_env()", - "```", - "", - "Or it may accept the current [`Request`](./request#symbol-request):", - "", - "```python", - "async def current_tenant(request: Request) -> Tenant:", - " return await request.app.state.tenants.load(", - ' request.headers.get("x-tenant-id")', - " )", - "```", - "", - "The provider can return a plain value, an awaitable value, a sync or " - "async", - "context manager, or yield one value from a sync or async generator.", - "`Resource` is generic: `Resource(provider)` carries the provider's " - "resolved value type, so `await request.resolve(resource)` returns " - "that value type.", - "", - "```python", - "async def db_session(request: Request) -> AsyncIterator[DatabaseSession]:", - " async with request.app.state.database.session() as session:", - " yield session", - "```", - "", - "Quater closes context-manager and generator resources after the handler", - "finishes. Cleanup also runs when the handler raises.", - "", - "### Route usage", - "", - "```python", - "db = Resource(db_session)", - "", - "", - '@app.get("/orders/{order_id}", inject={"session": db})', - "async def get_order(", - " order_id: str,", - " session: DatabaseSession,", - ") -> dict[str, object]:", - " ...", - "```", - "", - "Injected parameters are not included in OpenAPI request parameters, MCP", - "input schemas, or CLI action schemas.", - "", - "## Types", - "", - "| Type | Meaning |", - "| --- | --- |", - '| `ResourceProvider` | ' - "Callable used by [`Resource`](#symbol-resource). It may return a " - "plain value, awaitable value, sync or async context manager, or a " - "sync or async generator that yields one value. |", - '| `ResourceMap` | Mapping of ' - "handler parameter name to [`Resource`](#symbol-resource). This is " - "the type accepted by `inject`. |", - '| `ResourceScope` | Literal ' - "resource lifetime. Currently `request`. |", - ] - ) - return finish(lines) - - -def render_request(package: Any) -> str: - lines = new_page("Request Reference") - lines.extend( - [ - "`Request` is the object to ask for when a handler needs headers,", - "cookies, body access, auth context, or call-source information.", - "", - "For simple path/query/body parameters, let Quater bind the function", - "arguments directly instead.", - "", - "```python", - "from quater import Request, State", - "```", - "", - ] - ) - symbol_intro( - lines, - package, - "Request", - "Transport-neutral request data.", - [ - "The same request object is used after RSGI, ASGI, WSGI, MCP, and CLI", - "calls have been normalized into Quater's internal request flow.", - ], - ) - lines.extend(signature_block(class_signature(package, "Request"))) - lines.extend( - validated_option_table( - package, - "Request", - None, - "Constructor parameters", - REQUEST_CONSTRUCTOR_OPTIONS, - ) - ) - lines.extend( - [ - "In normal app code, Quater creates the request and passes it to your", - "handler. The table above matches the constructor signature. The sections", - "below explain the objects you usually read from inside handlers.", - "", - "## Reading Request Data", - "", - "Most handlers use `request.headers`, `request.query`, `request.cookies`,", - "`request.auth`, `request.context`, `await request.body()`, or", - "`await request.json()`.", - "", - "Helper objects such as [`Headers`](#headers),", - "[`QueryParams`](#queryparams), and [`Cookies`](#cookies) are not", - "top-level public imports. Treat them as read-only request views.", - "", - ] - ) - symbol_intro( - lines, - package, - "State", - "Attribute storage for application and request-local state.", - [ - "`app.state` is shared by the application instance. Use it for", - "resources created at startup, such as database pools or clients.", - "`request.state` is created fresh for each request and is useful for", - "middleware that needs to pass values to handlers.", - ], - ) - lines.extend(signature_block("State()")) - lines.extend( - [ - "Keep per-request data on `request.state`, not `app.state`. If you", - "store shared objects on `app.state`, make sure those objects are safe", - "for your concurrency and deployment model.", - "", - "```python", - "@app.on_startup", - "async def startup() -> None:", - " app.state.db = await open_database_pool()", - "", - '@app.get("/users/{id}")', - "async def get_user(id: str, request: Request) -> dict[str, object]:", - " assert request.app is not None", - " user = await request.app.state.db.fetch_user(id)", - ' return {"id": user.id}', - "```", - "", - "## Call Context", - "", - "`RequestContext` is the small object stored at `request.context`. You do", - "not need to create it in normal app code, but it is useful when a handler", - "can be reached from more than one surface.", - "", - "`request.context` tells you how the handler was reached:", - "", - '- `source="api"` for normal HTTP routes.', - '- `source="mcp"` for MCP protocol and tool calls.', - '- `source="cli"` for Quater CLI actions.', - '- `entrypoint="server"` for hosted calls.', - '- `entrypoint="local"` for local CLI calls.', - "", - "```python", - "async def whoami(request: Request) -> dict[str, object]:", - " return {", - ' "source": request.context.source,', - ' "entrypoint": request.context.entrypoint,', - ' "subject": request.auth.subject if request.auth else None,', - " }", - "```", - "", - "Context fields:", - "", - "| Field | Type | Meaning |", - "| --- | --- | --- |", - context_row( - "source", - '"api" | "mcp" | "cli"', - "Which surface reached the handler.", - ), - context_row( - "entrypoint", - '"server" | "local"', - "Hosted request or local CLI call.", - ), - "| `request_id` | `str \\| None` | Correlation id assigned by Quater. |", - "| `tool_name` | `str \\| None` | MCP tool name for tool calls. |", - "| `action_name` | `str \\| None` | CLI action name for action calls. |", - "", - "## Header, Query, and Cookie Views", - "", - "These objects behave like small read-only mappings. You can use common", - "mapping methods such as `get()`, `in`, iteration, and `[...]` lookup.", - "", - "### Headers", - "", - "`request.headers` is case-insensitive. Header names are normalized for", - "lookup, so these are equivalent:", - "", - "```python", - 'request.headers.get("authorization")', - 'request.headers.get("Authorization")', - "```", - "", - "Use `get_all(name)` when a header may appear more than once. Use `.raw`", - "when you need the normalized `(name, value)` pairs.", - "", - "```python", - 'authorization = request.headers.get("authorization")', - 'set_cookie_headers = request.headers.get_all("set-cookie")', - "raw_headers = request.headers.raw", - "```", - "", - "### QueryParams", - "", - "`request.query` is a parsed query-string mapping. Normal lookup returns", - "the last value for a repeated key, which matches normal dictionary", - "behavior. Use `get_all(name)` when repeated query parameters matter.", - "", - "```python", - "# /search?tag=python&tag=api", - 'first_value = request.query.get("tag")', - 'all_values = request.query.get_all("tag")', - "```", - "", - "Use `.raw` when you need all `(name, value)` pairs in order.", - "", - "### Cookies", - "", - "`request.cookies` is a parsed mapping of cookie names to cookie values.", - "Use `.get()` when a cookie is optional.", - "", - "```python", - 'session_id = request.cookies.get("session")', - "```", - "", - "### AuthContext", - "", - "`request.auth` is either `None` or the", - "[`AuthContext`](./auth#symbol-authcontext) returned by the route", - "auth hook. Always check it before reading `subject`.", - "", - "```python", - "subject = request.auth.subject if request.auth else None", - "```", - "", - ] - ) - lines.extend( - type_section( - ( - ( - "State", - "Attribute container exposed as `app.state` and `request.state`.", - ), - ( - "Quater", - "Application object available as `request.app` after a " - "request enters an app.", - ), - ( - "HeaderItems", - "`(name, value)` header pairs. Names and values may be " - "`str` or `bytes`.", - ), - ( - "RequestBody", - "`bytes`, an async body reader, or `None`. App handlers " - "usually use `await request.body()`.", - ), - ( - "AuthContext", - "Auth result returned by a route auth hook. See " - "[AuthContext](./auth#symbol-authcontext).", - ), - ( - "RequestContext", - "Call-source context explained in [Call Context](#call-context).", - ), - ( - "Headers", - "Read-only, case-insensitive header view available as " - "`request.headers`.", - ), - ( - "QueryParams", - "Read-only query-string view available as `request.query`.", - ), - ( - "Cookies", - "Read-only cookie mapping available as `request.cookies`.", - ), - ) - ) - ) - return finish(lines) - - -def render_parameters(package: Any) -> str: - lines = new_page("Parameter Reference") - lines.extend( - [ - "Use parameter markers when handler arguments need explicit request", - "locations, aliases, defaults, or generated schema descriptions.", - "", - "For the binding model, read [Public API](/en/dev/api#parameters).", - "For raw request access, read [Request](./request).", - "", - "```python", - "from quater import Body, Cookie, File, Form, Header, Path, Query", - "```", - "", - "Markers can be used as defaults or inside `typing.Annotated`. The", - "default form is shorter. `Annotated` keeps the Python default separate.", - "`Query`, `Header`, `Cookie`, and `Form` bind scalar values only:", - "`str`, `int`, `float`, or `bool`. Use `Body` for structured JSON", - "input and `File` for multipart file uploads.", - "", - "```python", - "from typing import Annotated", - "", - "from quater import Query", - "", - "async def search(", - ' q: str = Query(description="Search text"),', - ' page: Annotated[int, Query(alias="p")] = 1,', - ") -> dict[str, object]:", - ' return {"q": q, "page": page}', - "```", - "", - ] - ) - for symbol in page_symbols("parameters"): - summary, details = PARAMETER_DOCS[symbol] - symbol_intro(lines, package, symbol, summary, details) - lines.extend(signature_block(callable_signature(package, symbol))) - lines.extend( - validated_function_option_table( - package, - symbol, - "Parameters", - PARAMETER_OPTIONS[symbol], - ) - ) - lines.extend( - [ - "## Action and Tool Names", - "", - "Aliases describe the HTTP wire name. MCP tools and CLI actions keep the", - "Python handler parameter name as the action argument name, except for", - "`Body(alias=...)`, which renames the body action argument. That keeps", - '`Header(alias="X-Request-ID")` readable in OpenAPI without forcing', - "agents to send a JSON key named `X-Request-ID`.", - "", - ] - ) - return finish(lines) - - -def render_responses(package: Any) -> str: - lines = new_page("Responses Reference") - lines.extend( - [ - "Most handlers do not need to create response objects. Return plain Python", - "values when that is enough; use explicit response classes when you need", - "status codes, headers, streaming, redirects, or a specific content type.", - "", - "```python", - "from quater import JSONResponse, RedirectResponse, StreamResponse", - "```", - "", - "## Automatic Return Values", - "", - "| Handler returns | Quater sends |", - "| --- | --- |", - "| `dict`, `list`, dataclass, `msgspec.Struct` | JSON response |", - "| `str` | UTF-8 text response |", - "| `bytes`, `bytearray`, `memoryview` | Byte response |", - "| `None` | Empty `204` response |", - "| [`Response`](#symbol-response) instance | Sent as-is |", - "", - "## Response Classes", - "", - ] - ) - for symbol in page_symbols("responses"): - symbol_intro(lines, package, symbol, RESPONSE_DOCS[symbol], []) - lines.extend(signature_block(class_signature(package, symbol))) - lines.extend( - validated_option_table( - package, - symbol, - None, - "Parameters", - RESPONSE_OPTIONS[symbol], - ) - ) - lines.extend( - [ - "## Cookie helpers", - "", - "Both helpers are available on every response class.", - "", - "### `set_cookie`", - "", - ] - ) - lines.extend(signature_block(method_signature(package, "Response", "set_cookie"))) - lines.extend( - validated_option_table( - package, - "Response", - "set_cookie", - "Parameters", - SET_COOKIE_OPTIONS, - ) - ) - lines.extend( - [ - "### `delete_cookie`", - "", - ] - ) - lines.extend( - signature_block(method_signature(package, "Response", "delete_cookie")) - ) - lines.extend( - validated_option_table( - package, - "Response", - "delete_cookie", - "Parameters", - DELETE_COOKIE_OPTIONS, - ) - ) - lines.extend( - type_section( - ( - ( - "HeaderItems", - "`(name, value)` header pairs. See " - "[Request headers](./request#headers).", - ), - ( - "ResponseBody", - "Bytes-like value accepted by " - "[`BytesResponse`](#symbol-bytesresponse).", - ), - ("AsyncIterable[bytes]", "Async stream of response chunks."), - ) - ) - ) - return finish(lines) - - -def render_auth(package: Any) -> str: - lines = new_page("Auth and Security Reference") - lines.extend( - [ - "Quater keeps the three access surfaces explicit:", - "", - "- normal routes use `auth=...` on the route or route group.", - "- MCP tools require `mcp_auth` when any tool is exposed.", - "- CLI actions require `cli_auth` when any action is exposed.", - "", - "The auth hook shape is the same across those surfaces. For the full", - "security model, read [Security](/en/dev/security).", - "", - "```python", - "from quater import (", - " AuthContext,", - " AuthRequest,", - " HTTPError,", - " ImproperlyConfigured,", - " SignedCookieSigner,", - ")", - "```", - "", - ] - ) - symbol_intro( - lines, - package, - "AuthRequest", - "Input passed to an auth hook.", - ["Use it to inspect method, path, headers, and Quater call context."], - ) - lines.extend(signature_block(class_signature(package, "AuthRequest"))) - lines.extend(field_table(package, "AuthRequest")) - symbol_intro( - lines, - package, - "AuthContext", - "Authenticated subject returned by an auth hook.", - [ - "Return `None` from an auth hook when the request is not authenticated.", - "Return `AuthContext` when it is authenticated.", - ], - ) - lines.extend(signature_block(class_signature(package, "AuthContext"))) - lines.extend(field_table(package, "AuthContext")) - symbol_intro( - lines, - package, - "ApprovalRequest", - "Input passed to the approval hook for protected tools and actions.", - [ - "Use this when a route has `needs_approval=True`. Approval is separate", - "from auth: auth identifies the caller, approval confirms a sensitive", - "operation should run.", - ], - ) - lines.extend(signature_block(class_signature(package, "ApprovalRequest"))) - lines.extend(field_table(package, "ApprovalRequest")) - symbol_intro( - lines, - package, - "ActionApproval", - "Callable type for approval hooks.", - [ - "Return `True` to allow the protected operation.", - "Return `False` to deny it.", - ], - ) - lines.extend( - code_block(f"ActionApproval = {attribute_value(package, 'ActionApproval')}") - ) - symbol_intro( - lines, - package, - "HTTPError", - "Exception that becomes an HTTP-style error response.", - ["Raise it when app code needs to stop with a specific status and detail."], - ) - lines.extend(signature_block(class_signature(package, "HTTPError"))) - lines.extend( - validated_option_table( - package, - "HTTPError", - None, - "Constructor parameters", - HTTP_ERROR_OPTIONS, - ) - ) - symbol_intro( - lines, - package, - "ImproperlyConfigured", - "Exception raised for invalid framework configuration.", - [ - "Catch this when app setup should fail loudly before serving traffic.", - "`ConfigurationError` remains as a backward-compatible subclass.", - ], - ) - lines.extend(code_block('raise ImproperlyConfigured("bad setup")')) - symbol_intro( - lines, - package, - "SignedCookieSigner", - "HMAC signer for small cookie values.", - [ - "Use fallback secrets during key rotation. Verification uses constant-time", - "signature comparison.", - ], - ) - lines.extend(signature_block(class_signature(package, "SignedCookieSigner"))) - lines.extend( - validated_option_table( - package, - "SignedCookieSigner", - None, - "Constructor parameters", - SIGNED_COOKIE_SIGNER_OPTIONS, - ) - ) - lines.extend( - [ - "Common methods:", - "", - "| Method | Use it for |", - "| --- | --- |", - "| `sign(value)` | Return a signed string safe to store in a cookie. |", - "| `verify(signed_value)` | Return the original value, or `None`. |", - "", - ] - ) - lines.extend( - type_section( - ( - ( - "RequestContext", - "Call-source context. See [Request](./request#call-context).", - ), - ( - "Authenticate", - "Async callable that receives " - "[`AuthRequest`](#symbol-authrequest) and returns " - "[`AuthContext`](#symbol-authcontext) or `None`.", - ), - ("SecretValue", "`str` or `bytes` cookie signing secret."), - ) - ) - ) - return finish(lines) - - -def render_observability(package: Any) -> str: - lines = new_page("Observability Reference") - lines.extend( - [ - "These types are used by access logging and MCP tool auditing. They are", - "small by design so apps can send them to logs, metrics, or tracing", - "systems without depending on Quater internals.", - "", - "```python", - "from quater import AccessLogEvent, AccessLogHook, ToolAuditEvent", - "```", - "", - ] - ) - symbol_intro( - lines, - package, - "AccessLogEvent", - "Structured event emitted after a request is handled.", - ["Configure `access_logger=` on `Quater(...)` to receive these events."], - ) - lines.extend(signature_block(class_signature(package, "AccessLogEvent"))) - lines.extend(field_table(package, "AccessLogEvent")) - lines.extend( - [ - "`AccessLogEvent.to_dict()` returns a plain dictionary for loggers that", - "expect JSON-like data.", - "", - ] - ) - symbol_intro( - lines, - package, - "AccessLogHook", - "Callable type for access-log hooks.", - ["The hook is async so it can write to async logging or telemetry clients."], - ) - lines.extend( - code_block(f"AccessLogHook = {attribute_value(package, 'AccessLogHook')}") - ) - symbol_intro( - lines, - package, - "ToolAuditEvent", - "Structured event emitted for MCP tool calls.", - [ - "Tool arguments are redacted before the event reaches your audit hook.", - "Use this for visibility, not for authorization.", - ], - ) - lines.extend(signature_block(class_signature(package, "ToolAuditEvent"))) - lines.extend(field_table(package, "ToolAuditEvent")) - return finish(lines) - - -def render_testing(package: Any) -> str: - lines = new_page("Testing Reference") - lines.extend( - [ - "Use the in-process clients to test Quater apps without starting Granian", - "or opening a socket. This keeps tests fast and makes auth, cookies,", - "lifespan hooks, MCP tools, and response bodies straightforward to assert.", - "", - "For examples, read the [Testing guide](/en/dev/testing).", - "", - "```python", - "from quater import CliTestClient, MCPTestClient, TestClient, TestResponse", - "```", - "", - ] - ) - symbol_intro( - lines, - package, - "TestClient", - "Async in-process client for HTTP-style tests.", - ["Use it with `async with` when your app has startup or shutdown hooks."], - ) - lines.extend(signature_block(class_signature(package, "TestClient"))) - lines.extend( - validated_option_table( - package, - "TestClient", - None, - "Constructor parameters", - TEST_CLIENT_OPTIONS, - ) - ) - lines.extend( - [ - "Common methods:", - "", - "| Method | Use it for |", - "| --- | --- |", - "| `request(method, path, ...)` | Send any method. |", - "| `get`, `post`, `put`, `patch`, `delete` | Convenience methods. |", - "| `set_cookie(name, value)` | Store a cookie for later requests. |", - "| `clear_cookies()` | Clear stored cookies. |", - "| `startup()` / `shutdown()` | Run lifespan manually. |", - "", - ] - ) - lines.extend(signature_block(method_signature(package, "TestClient", "request"))) - lines.extend( - validated_option_table( - package, - "TestClient", - "request", - "`request()` parameters", - TEST_CLIENT_REQUEST_OPTIONS, - ) - ) - symbol_intro( - lines, - package, - "TestResponse", - "Response returned by [`TestClient`](#symbol-testclient).", - ["It stores the collected body, headers, status code, and JSON helpers."], - ) - lines.extend(signature_block(class_signature(package, "TestResponse"))) - lines.extend( - validated_option_table( - package, - "TestResponse", - None, - "Constructor parameters", - TEST_RESPONSE_OPTIONS, - ) - ) - lines.extend( - [ - "| Property or method | What it returns |", - "| --- | --- |", - "| `status_code` | Integer response status. |", - "| `headers` | Parsed response headers. |", - "| `body` | Raw response bytes. |", - "| `text` | UTF-8 decoded body. |", - "| `is_success` | `True` for `2xx` and `3xx` responses. |", - "| `json()` | Parsed JSON body. |", - "", - ] - ) - symbol_intro( - lines, - package, - "MCPTestClient", - "Small JSON-RPC helper bound to a [`TestClient`](#symbol-testclient).", - [ - "Use `client.mcp` in tests. It sends MCP requests through the same app", - "pipeline as a real MCP client.", - ], - ) - lines.extend(signature_block(class_signature(package, "MCPTestClient"))) - lines.extend( - validated_option_table( - package, - "MCPTestClient", - None, - "Constructor parameters", - MCP_TEST_CLIENT_OPTIONS, - ) - ) - lines.extend( - [ - "Common methods:", - "", - "| Method | Use it for |", - "| --- | --- |", - "| `initialize(...)` | Send MCP `initialize`. |", - "| `tools_list(...)` | List exposed tools. |", - "| `tools_call(name, arguments, ...)` | Call an exposed tool. |", - "| `request(payload, ...)` | Send a custom JSON-RPC payload. |", - "", - ] - ) - lines.extend( - signature_block(method_signature(package, "MCPTestClient", "tools_call")) - ) - lines.extend( - validated_option_table( - package, - "MCPTestClient", - "tools_call", - "`tools_call()` parameters", - MCP_TOOLS_CALL_OPTIONS, - ) - ) - symbol_intro( - lines, - package, - "CliTestClient", - "Remote-action helper bound to a [`TestClient`](#symbol-testclient).", - [ - "Use `client.cli` in tests. It calls actions and reads the action", - "manifest through the same remote-action endpoints as the Quater CLI.", - ], - ) - lines.extend(signature_block(class_signature(package, "CliTestClient"))) - lines.extend( - validated_option_table( - package, - "CliTestClient", - None, - "Constructor parameters", - CLI_TEST_CLIENT_OPTIONS, - ) - ) - lines.extend( - [ - "Common methods:", - "", - "| Method | Use it for |", - "| --- | --- |", - "| `call(action, arguments, ...)` | Call an exposed CLI action. |", - "| `manifest(...)` | Read the action manifest. |", - "", - "Both methods return the raw [`TestResponse`](#symbol-testresponse). A", - "successful action body is the `{ok, status_code, body}` envelope, and a", - "`dry_run=True` call returns the preflight payload instead of running the", - "handler.", - "", - ] - ) - lines.extend(signature_block(method_signature(package, "CliTestClient", "call"))) - lines.extend( - validated_option_table( - package, - "CliTestClient", - "call", - "`call()` parameters", - CLI_CALL_OPTIONS, - ) - ) - lines.extend( - type_section( - ( - ( - "HeaderItems", - "`(name, value)` header pairs. See " - "[Request headers](./request#headers).", - ), - ( - "QueryParams", - "Mapping or sequence accepted by " - "[`TestClient`](#symbol-testclient).", - ), - ( - "RequestContent", - "`bytes`, `bytearray`, `memoryview`, or `str` request body " - "content.", - ), - ("JSONRPCID", "MCP JSON-RPC request id, either `str` or `int`."), - ) - ) - ) - return finish(lines) - - -def new_page(title: str) -> list[str]: - return [GENERATED_HEADER, "", f"# {title}", ""] - - -def symbol_intro( - lines: list[str], - package: Any, - symbol: str, - summary: str, - details: Sequence[str], -) -> None: - lines.extend( - [symbol_heading(symbol), "", source_line(package, symbol), "", summary, ""] - ) - if details: - lines.extend([*details, ""]) - - -def source_line(package: Any, symbol: str) -> str: - if symbol == "__version__": - return "Public import: `from quater import __version__`." - return f"Public import: `from quater import {symbol}`." - - -def class_signature(package: Any, symbol: str) -> str: - obj = object_for(package, symbol) - init = getattr(obj, "members", {}).get("__init__") - if init is None: - return symbol - signature = function_signature(resolve(init)) - if signature is None: - return symbol - signature = signature.replace("__init__(", f"{symbol}(", 1) - signature = signature.removesuffix(" -> None") - return signature - - -def method_signature(package: Any, symbol: str, method: str) -> str: - obj = object_for(package, symbol) - member = getattr(obj, "members", {}).get(method) - if member is None: - raise SystemExit(f"Could not find {symbol}.{method}") - signature = function_signature(resolve(member)) - if signature is None: - raise SystemExit(f"Could not read signature for {symbol}.{method}") - return signature - - -def callable_signature(package: Any, symbol: str) -> str: - signature = function_signature(resolve(object_for(package, symbol))) - if signature is None: - raise SystemExit(f"Could not read signature for {symbol}") - return signature - - -def parameter_table( - package: Any, - symbol: str, - method: str | None, -) -> tuple[tuple[str, str], ...]: - obj = object_for(package, symbol) - if method is None: - member = getattr(obj, "members", {}).get("__init__") - else: - member = getattr(obj, "members", {}).get(method) - if member is None: - target = f"{symbol}.{method}" if method is not None else f"{symbol}.__init__" - raise SystemExit(f"Could not find {target}") - - resolved = resolve(member) - parameters = getattr(resolved, "parameters", ()) - rows: list[tuple[str, str]] = [] - for parameter in parameters: - name = getattr(parameter, "name", "") - if not name or name == "self": - continue - annotation_value = getattr(parameter, "annotation", None) - type_name = "object" if annotation_value is None else str(annotation_value) - rows.append((name, clean_type(type_name))) - return tuple(rows) - - -def function_parameter_table(package: Any, symbol: str) -> tuple[tuple[str, str], ...]: - obj = resolve(object_for(package, symbol)) - parameters = getattr(obj, "parameters", ()) - rows: list[tuple[str, str]] = [] - for parameter in parameters: - name = getattr(parameter, "name", "") - if not name: - continue - annotation_value = getattr(parameter, "annotation", None) - type_name = "object" if annotation_value is None else str(annotation_value) - rows.append((name, clean_type(type_name))) - return tuple(rows) - - -def function_signature(obj: Any) -> str | None: - signature_method = getattr(obj, "signature", None) - if signature_method is None: - return None - return format_signature(clean_signature(str(signature_method()))) - - -def field_table(package: Any, symbol: str) -> list[str]: - obj = object_for(package, symbol) - annotations = init_annotations(obj) - rows: list[str] = [] - actual_fields: set[str] = set() - for name, member in getattr(obj, "members", {}).items(): - if is_private_member(name) or name in {"__slots__", "__test__"}: - continue - target = resolve(member) - kind = str(getattr(target, "kind", "")) - if not kind.endswith("ATTRIBUTE"): - continue - actual_fields.add(name) - type_name = annotation(target) - if type_name == "object": - type_name = annotations.get(name, type_name) - description = FIELD_DOCS.get(symbol, {}).get(name) - if description is None: - continue - rows.append( - f"| `{name}` | {type_cell(type_name)} | {table_text(description)} |" - ) - - documented_fields = set(FIELD_DOCS.get(symbol, {})) - if actual_fields != documented_fields: - missing = sorted(actual_fields - documented_fields) - extra = sorted(documented_fields - actual_fields) - raise SystemExit( - f"{symbol} field docs mismatch; missing={missing}, extra={extra}" - ) - - if not rows: - return [] - - return [ - "Fields:", - "", - "| Field | Type | Meaning |", - "| --- | --- | --- |", - *rows, - "", - ] - - -def option_table( - title: str, - rows: Sequence[tuple[str, str, str]], -) -> list[str]: - lines = [f"### {title}", "", "| Name | Type | Meaning |", "| --- | --- | --- |"] - for name, type_name, description in rows: - lines.append( - f"| `{name}` | {type_cell(type_name)} | {table_text(description)} |" - ) - lines.append("") - return lines - - -def validated_option_table( - package: Any, - symbol: str, - method: str | None, - title: str, - rows: Sequence[tuple[str, str, str]], -) -> list[str]: - actual = parameter_table(package, symbol, method) - expected = tuple((name, type_name) for name, type_name, _ in rows) - if actual != expected: - target = f"{symbol}.{method}" if method is not None else symbol - raise SystemExit( - f"{target} reference table mismatch; actual={actual}, expected={expected}" - ) - return option_table(title, rows) - - -def validated_function_option_table( - package: Any, - symbol: str, - title: str, - rows: Sequence[tuple[str, str, str]], -) -> list[str]: - actual = function_parameter_table(package, symbol) - expected = tuple((name, type_name) for name, type_name, _ in rows) - if actual != expected: - raise SystemExit( - f"{symbol} reference table mismatch; actual={actual}, expected={expected}" - ) - return option_table(title, rows) - - -def type_section(rows: Sequence[tuple[str, str]]) -> list[str]: - lines = ["## Type Names Used Here", "", "| Name | Meaning |", "| --- | --- |"] - for name, description in rows: - lines.append(f"| {type_name_cell(name)} | {table_text(description)} |") - lines.append("") - return lines - - -def init_annotations(obj: Any) -> dict[str, str]: - init = getattr(obj, "members", {}).get("__init__") - if init is None: - return {} - target = resolve(init) - parameters = getattr(target, "parameters", ()) - annotations: dict[str, str] = {} - for parameter in parameters: - name = getattr(parameter, "name", "") - value = getattr(parameter, "annotation", None) - if not name or name == "self" or value is None: - continue - annotations[name] = str(value) - return annotations - - -def object_for(package: Any, symbol: str) -> Any: - return resolve(package[symbol]) - - -def resolve(obj: Any) -> Any: - return getattr(obj, "target", obj) - - -def annotation(obj: Any) -> str: - value = getattr(obj, "annotation", None) - if value is None: - return "object" - return str(value) - - -def attribute_value(package: Any, symbol: str) -> str: - value = getattr(object_for(package, symbol), "value", None) - if value is None: - return clean_signature(annotation(object_for(package, symbol))) - return clean_signature(str(value)) - - -def code_block(code: str) -> list[str]: - return ["```python", code, "```", ""] - - -def signature_block(signature: str) -> list[str]: - return code_block(format_signature(signature)) - - -def import_link(name: str, page: ReferencePage) -> str: - target = f"./{page.slug}#{symbol_anchor(name)}" - return f"[`{name}`]({target})" - - -def type_cell(value: str) -> str: - text = clean_type(value) - if not any(type_reference(match.group(0)) for match in type_words(text)): - return f"`{table_code(text)}`" - - escaped = table_text(text) - return re.sub( - r"\b[A-Za-z_][A-Za-z0-9_]*\b", - lambda match: type_token(match.group(0)), - escaped, - ) - - -def type_name_cell(name: str) -> str: - anchor = f'' - link = public_type_reference(name) - if link is not None: - return f"{anchor}[`{name}`]({link})" - return f"{anchor}`{name}`" - - -def type_token(name: str) -> str: - link = type_reference(name) - if link is None: - return name - return f"[`{name}`]({link})" - - -def type_words(value: str) -> Iterable[re.Match[str]]: - return re.finditer(r"\b[A-Za-z_][A-Za-z0-9_]*\b", value) - - -def type_reference(name: str) -> str | None: - public_link = public_type_reference(name) - if public_link is not None: - return public_link - - internal_links = { - "HeaderItems": "/en/dev/reference/request#type-headeritems", - "RequestBody": "/en/dev/reference/request#type-requestbody", - "RequestContext": "/en/dev/reference/request#call-context", - "Headers": "/en/dev/reference/request#headers", - "QueryParams": "/en/dev/reference/request#queryparams", - "Cookies": "/en/dev/reference/request#cookies", - "SecurityMode": "/en/dev/reference/application#type-securitymode", - "MaxBodySize": "/en/dev/reference/application#type-maxbodysize", - "Authenticate": "/en/dev/reference/auth#type-authenticate", - "AuditHook": "/en/dev/reference/application#type-audithook", - "BeforeMiddleware": "/en/dev/reference/application#type-beforemiddleware", - "AfterMiddleware": "/en/dev/reference/application#type-aftermiddleware", - "AroundMiddleware": "/en/dev/reference/application#type-aroundmiddleware", - "ExceptionHandlerEntry": ( - "/en/dev/reference/application#type-exceptionhandlerentry" - ), - "ResourceMap": "/en/dev/reference/resources#type-resourcemap", - "ResourceProvider": "/en/dev/reference/resources#type-resourceprovider", - "ResourceScope": "/en/dev/reference/resources#type-resourcescope", - "ResponseBody": "/en/dev/reference/responses#type-responsebody", - "RequestContent": "/en/dev/reference/testing#type-requestcontent", - "JSONRPCID": "/en/dev/reference/testing#type-jsonrpcid", - "SecretValue": "/en/dev/reference/auth#type-secretvalue", - } - return internal_links.get(name) - - -def public_type_reference(name: str) -> str | None: - for page in PAGES: - if name in page.symbols and name != "__version__": - return f"/en/dev/reference/{page.slug}#{symbol_anchor(name)}" - return None - - -def type_anchor(name: str) -> str: - return f"type-{slug(name)}" - - -def table_code(value: str) -> str: - return value.replace("|", "\\|") - - -def table_text(value: str) -> str: - return value.replace("|", "\\|") - - -def context_row(name: str, type_name: str, description: str) -> str: - return f"| `{name}` | {type_cell(type_name)} | {table_text(description)} |" - - -def format_signature(signature: str, *, max_width: int = 88) -> str: - if len(signature) <= max_width: - return signature - - open_index = signature.find("(") - close_index = signature.rfind(")") - if open_index == -1 or close_index == -1 or close_index < open_index: - return signature - - head = signature[:open_index] - args = split_top_level_commas(signature[open_index + 1 : close_index]) - tail = signature[close_index + 1 :] - if not args: - return signature - - lines = [f"{head}("] - lines.extend(f" {argument}," for argument in args) - lines.append(f"){tail}") - return "\n".join(lines) - - -def clean_signature(value: str) -> str: - replacements = { - "mcp_docs_path: str | None | _Unset = _UNSET": ( - "mcp_docs_path: str | None = '/mcp/docs'" - ), - "docs_path: str | None | _Unset = _UNSET": ("docs_path: str | None = '/docs'"), - "openapi_path: str | None | _Unset = _UNSET": ( - "openapi_path: str | None = '/openapi.json'" - ), - "request_id_header: str | None | _Unset = _UNSET": ( - "request_id_header: str | None = 'x-request-id'" - ), - "_empty_str_map()": "...", - "_empty_metadata()": "...", - "_MCP_PROTOCOL_VERSION": "'2025-11-25'", - "Callable[['AccessLogEvent'], Awaitable[None]]": ( - "Callable[[AccessLogEvent], Awaitable[None]]" - ), - "_DEFAULT_METHODS": ( - "('DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT')" - ), - } - for old, new in replacements.items(): - value = value.replace(old, new) - return value - - -def clean_type(value: str) -> str: - replacements = { - "str | None | _Unset": "str | None", - } - for old, new in replacements.items(): - value = value.replace(old, new) - return value - - -def split_top_level_commas(value: str) -> list[str]: - parts: list[str] = [] - start = 0 - depth = 0 - quote: str | None = None - escaped = False - for index, char in enumerate(value): - if quote is not None: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == quote: - quote = None - continue - if char in {"'", '"'}: - quote = char - continue - if char in "([{": - depth += 1 - continue - if char in ")]}": - depth -= 1 - continue - if char == "," and depth == 0: - part = value[start:index].strip() - if part: - parts.append(part) - start = index + 1 - tail = value[start:].strip() - if tail: - parts.append(tail) - return parts - - -def is_private_member(name: str) -> bool: - return name.startswith("_") - - -def slug(value: str) -> str: - normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()) - return normalized.strip("-") - - -def symbol_anchor(value: str) -> str: - if value == "__version__": - return "symbol-version" - return f"symbol-{slug(value)}" - - -def symbol_heading(value: str) -> str: - return f"## {value} {{#{symbol_anchor(value)}}}" - - -def finish(lines: list[str]) -> str: - return "\n".join(lines).rstrip() + "\n" - - -def write_outputs(outputs: Mapping[Path, str]) -> None: - REFERENCE_DIR.mkdir(parents=True, exist_ok=True) - for path, content in sorted(outputs.items()): - path.write_text(content, encoding="utf-8") - - -def check_outputs(outputs: Mapping[Path, str]) -> int: - stale = False - for path, expected in sorted(outputs.items()): - if not path.exists(): - print(f"Missing generated reference file: {path}", file=sys.stderr) - stale = True - continue - actual = path.read_text(encoding="utf-8") - if actual == expected: - continue - stale = True - diff = difflib.unified_diff( - actual.splitlines(), - expected.splitlines(), - fromfile=str(path), - tofile=f"{path} (generated)", - lineterm="", - ) - print("\n".join(diff), file=sys.stderr) - return 1 if stale else 0 - - if __name__ == "__main__": raise SystemExit(main()) From a45021d055107f7201257587c84682d903ea3161 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Wed, 15 Jul 2026 15:09:15 +0530 Subject: [PATCH 4/7] Refactor documentation generation: split `scripts/generate_reference.py` into focused internal modules for improved organization while maintaining existing functionality. --- docs/en/dev/changelog.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/en/dev/changelog.md b/docs/en/dev/changelog.md index 79f0148..7175678 100644 --- a/docs/en/dev/changelog.md +++ b/docs/en/dev/changelog.md @@ -18,6 +18,13 @@ cut. ### Changed +- Docs: split `scripts/generate_reference.py` into focused internal modules + under `scripts/_reference/` (page definitions, manual page loading, signature + formatting, type and default rendering, table and reference validation, and + output/check-mode). `npm run docs:reference` and + `npm run docs:reference:check` behavior, generated files, and the docs + generator's public surface are unchanged. + - CLI: split parser setup, command dispatch, local action handling, and remote action handling into focused internal modules. Command syntax and behavior are unchanged. ([#167](https://github.com/DevilsAutumn/quater/issues/167)) From c3ba6c82c7d407b09cf2331e62c535a28bb1a955 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Wed, 15 Jul 2026 15:13:43 +0530 Subject: [PATCH 5/7] deleted file to streamline documentation and focus on essential resources. --- GETTING_STARTED.md | 359 --------------------------------------------- 1 file changed, 359 deletions(-) delete mode 100644 GETTING_STARTED.md diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md deleted file mode 100644 index 8886ab1..0000000 --- a/GETTING_STARTED.md +++ /dev/null @@ -1,359 +0,0 @@ -# Getting Started with Quater - -## What is Quater? - -Quater is a Python backend framework where **one route serves three surfaces**: - -| Surface | Who uses it | How | -|---------|------------|-----| -| **HTTP API** | Browsers, frontends, services | Standard REST calls | -| **MCP (Model Context Protocol)** | AI agents (Claude, GPT, etc.) | JSON-RPC 2.0 over `POST /mcp` | -| **CLI** | Developers, operators, scripts | `quater call ` | - -### The Core Problem - -Traditional frameworks serve data to frontends — humans click buttons to get work done. But AI agents shouldn't navigate UIs. They need **direct, safe, structured backend access** with proper auth, audit trails, and approval gates. - -Building separate APIs for agents means duplicating logic across HTTP handlers, agent tool definitions, and CLI scripts. Three codebases doing the same thing. - -### The Solution - -Quater lets you write **one handler** and expose it to all three surfaces with flags: - -```python -@app.get("/orders/{order_id}", tool=True, cli=True) -async def get_order(order_id: str) -> dict: - return ORDERS[order_id] -``` - -- `tool=True` → available as an MCP tool for AI agents -- `cli=True` → available as a CLI action for operators -- HTTP works by default - -Auth, validation, and schemas are shared. No duplication. - ---- - -## Prerequisites - -- **Python 3.11+** -- **Rust toolchain** (for native router — install via [rustup](https://rustup.rs/)) -- **uv** (fast Python package manager) - -```bash -# Install uv -pip install uv - -# Install Rust (if not present) -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -``` - ---- - -## Installation - -### From PyPI (use as a dependency) - -```bash -pip install quater -# or -uv add quater -``` - -### From Source (development) - -```bash -git clone https://github.com/DevilsAutumn/quater.git -cd quater - -# Install all dependencies + build Rust extension -uv sync --group dev -``` - ---- - -## Running the Application - -### Development Mode - -```bash -# Auto-reload enabled, debug logging, relaxed safety checks -quater dev examples/basic_app.py:app - -# Or with uv (from source) -uv run quater dev examples.basic_app:app -``` - -### Production Mode - -```bash -# Strict safety checks — requires allowed_hosts to be configured -quater run main:app --host 0.0.0.0 --port 8000 --workers 4 -``` - -### Quick Dev Mode (skip host validation) - -```bash -uv run quater run --allow-insecure --reload examples.basic_app:app -``` - -Server starts at **http://127.0.0.1:8000** by default. - -### Server Options - -| Flag | Default | Description | -|------|---------|-------------| -| `--host` | `127.0.0.1` | Bind address | -| `--port` | `8000` | Bind port | -| `--workers` | `1` | Worker processes | -| `--reload / --no-reload` | off | Auto-reload on file changes | -| `--interface` | `rsgi` | Server interface: `rsgi`, `asgi`, `wsgi` | -| `--loop` | `auto` | Event loop: `asyncio`, `uvloop`, `rloop` | -| `--log-level` | `info` | `debug`, `info`, `warning`, `error`, `critical` | -| `--access-log` | off | Enable access logging | -| `--allow-insecure` | off | Skip production safety checks | -| `--factory` | off | Treat target as app factory callable | - ---- - -## Documentation Endpoints - -Once the server is running, these documentation pages are available: - -### Swagger UI (OpenAPI Docs) - -``` -http://127.0.0.1:8000/docs -``` - -Interactive API documentation. Browse all routes, try requests, see request/response schemas. - -### OpenAPI JSON Schema - -``` -http://127.0.0.1:8000/openapi.json -``` - -Raw OpenAPI 3.1.1 schema. Import into Postman, Insomnia, or any OpenAPI-compatible tool. - -### MCP Tools Documentation - -``` -http://127.0.0.1:8000/mcp/docs -``` - -Human-readable page listing all MCP-exposed tools with their schemas, descriptions, and example JSON-RPC requests. - -> **Disable docs** in production by setting `docs_path=None`, `openapi_path=None`, or `mcp_docs_path=None` in the app constructor. - ---- - -## Calling Your API — Three Ways - -Given this route: - -```python -@app.get("/orders/{order_id}", tool=True, cli=True, description="Fetch one order by id.") -async def get_order(order_id: str, request: Request) -> dict: - ... -``` - -### 1. HTTP (curl / browser / frontend) - -```bash -curl -H "Authorization: Bearer admin-token" \ - http://127.0.0.1:8000/orders/ord_1001 -``` - -### 2. MCP (AI agents like Claude) - -```bash -curl -X POST http://127.0.0.1:8000/mcp \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer admin-token" \ - -d '{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "get_order", - "arguments": {"order_id": "ord_1001"} - } - }' -``` - -### 3. CLI (operators / scripts) - -```bash -# Local (imports app directly) -quater call get_order --order-id ord_1001 - -# Remote (calls a running server) -quater connect prod https://api.example.com -quater login prod # paste token -quater call get_order --order-id ord_1001 --remote prod -``` - ---- - -## Key Features - -### Per-Surface Authentication - -```python -app = Quater(auth=[ - AuthConfig(verify_jwt, surfaces=["api"]), # JWT for HTTP - AuthConfig(verify_api_key, surfaces=["mcp"]), # API key for agents - AuthConfig(verify_local_user, surfaces=["cli"]), # Local user for CLI -]) -``` - -Each surface can have its own auth strategy. Routes without auth config are deliberately public. - -### Request Safety (On by Default) - -- **Host checking** — `allowed_hosts` required in production -- **CORS** — configurable via `CORSConfig` -- **Body size limits** — 2MB default, configurable -- **Request IDs** — `x-request-id` header auto-generated -- **Security headers** — `X-Content-Type-Options`, `Referrer-Policy`, etc. - -### Audit & Observability - -Every request carries metadata: - -```python -request.context.source # "api", "mcp", or "cli" -request.context.entrypoint # "server" or "local" -request.context.request_id # unique per request -``` - -MCP tool calls support audit hooks: - -```python -app = Quater(mcp_audit=my_audit_hook) -``` - -### Middleware - -Four types: **before**, **after**, **around**, **exception** — composable at global and route level. - -### Resource Injection - -```python -@app.resource -async def db(): - async with get_session() as session: - yield session - -@app.get("/users") -async def list_users(request: Request): - session = await request.resolve(db) - ... -``` - -### Approval Gates (Human-in-the-Loop) - -For sensitive operations, require explicit user approval before execution via CLI actions. - ---- - -## Example Apps - -| File | What it shows | -|------|--------------| -| `examples/basic_app.py` | Minimal app — health, hello, echo | -| `examples/auth_and_mcp.py` | Auth + MCP tools + audit hooks | -| `examples/asgi_compat.py` | ASGI adapter usage | -| `examples/wsgi_compat.py` | WSGI adapter usage | - -Run any example: - -```bash -uv run quater dev examples.basic_app:app -uv run quater dev examples.auth_and_mcp:app -``` - ---- - -## Running Tests - -```bash -uv run pytest # all tests -uv run pytest tests/unit/ # unit only -uv run pytest tests/integration/ # integration only -uv run pytest tests/security/ # security tests -uv run pytest --cov # with coverage -``` - ---- - -## Project Structure - -``` -src/quater/ -├── app.py # Main Quater class -├── auth.py # Per-surface authentication -├── config.py # AppConfig (security, limits, docs paths) -├── request.py # Request object -├── response.py # JSON, HTML, Stream, Redirect responses -├── middleware.py # Before/After/Around/Exception middleware -├── security.py # Host validation, CORS, headers -├── docs/ # OpenAPI + Swagger UI generation -├── tools/ # MCP tool registry & handler -├── actions/ # CLI action registry & executor -├── cli/ # CLI commands (dev, run, call, connect) -├── adapters/ # RSGI, ASGI, WSGI server adapters -└── _router.pyd # Rust-compiled native route matcher -``` - ---- - -## Configuration Reference - -```python -app = Quater( - # Security - debug=False, - security="strict", # "strict" | "relaxed" | "off" - allowed_hosts=["api.example.com"], - trusted_proxies=("10.0.0.0/8",), - - # Auth - auth=[AuthConfig(my_auth, surfaces=["api", "mcp"])], - - # CORS - cors=CORSConfig( - allowed_origins=["https://app.example.com"], - allow_credentials=True, - max_age=3600, - ), - - # MCP - mcp_allowed_origins=["http://localhost:3000"], - mcp_audit=my_audit_hook, - - # Docs - docs_path="/docs", # Swagger UI (None to disable) - openapi_path="/openapi.json", # OpenAPI schema (None to disable) - mcp_docs_path="/mcp/docs", # MCP docs (None to disable) - - # Limits - max_body_size="50mb", - max_file_size="10mb", - max_tool_response_size="2mb", - - # Observability - request_id_header="x-request-id", -) -``` - -### Environment Variables - -| Variable | Description | -|----------|-------------| -| `QUATER_ENV` | `development` or `production` | -| `QUATER_APP` | Default app module (e.g., `main:app`) | -| `QUATER_TOKEN` | Auth token for CLI remote calls | -| `QUATER_MAX_BODY_SIZE` | Override max body size | -| `QUATER_MAX_FILE_SIZE` | Override max file size | From 26c5aa28a0407f3063bf94129af89e4e9629752d Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Wed, 15 Jul 2026 15:22:52 +0530 Subject: [PATCH 6/7] Clean up imports in `generate_reference.py` and `render.py` for improved readability and maintainability. --- scripts/_reference/render.py | 2 +- scripts/generate_reference.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/_reference/render.py b/scripts/_reference/render.py index 0a26c27..6bf4b7d 100644 --- a/scripts/_reference/render.py +++ b/scripts/_reference/render.py @@ -43,7 +43,7 @@ validated_function_option_table, validated_option_table, ) -from _reference.types import context_row, import_link, symbol_anchor, symbol_heading +from _reference.types import context_row, import_link, symbol_heading def new_page(title: str) -> list[str]: diff --git a/scripts/generate_reference.py b/scripts/generate_reference.py index 19e7683..be9c7fb 100644 --- a/scripts/generate_reference.py +++ b/scripts/generate_reference.py @@ -17,7 +17,6 @@ sys.path.insert(0, _SCRIPTS_DIR) import griffe # noqa: E402 - from _reference.output import check_outputs, write_outputs # noqa: E402 from _reference.pages import ( # noqa: E402 PAGES, From def2fa19dd488dece9103ce028b8f6acfb8b1fa9 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Wed, 15 Jul 2026 15:27:27 +0530 Subject: [PATCH 7/7] Refactor authentication documentation: replaced `AuthRequest` with `AuthConfig` in `render.py` and updated related field documentation in `tables.py` for clarity on authentication surfaces and usage. --- scripts/_reference/render.py | 21 ++++++++++++--------- scripts/_reference/tables.py | 23 ++++++++++------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/scripts/_reference/render.py b/scripts/_reference/render.py index 6bf4b7d..eb3860f 100644 --- a/scripts/_reference/render.py +++ b/scripts/_reference/render.py @@ -872,8 +872,8 @@ def render_auth(package: Any) -> str: "", "```python", "from quater import (", + " AuthConfig,", " AuthContext,", - " AuthRequest,", " HTTPError,", " ImproperlyConfigured,", " SignedCookieSigner,", @@ -885,12 +885,15 @@ def render_auth(package: Any) -> str: symbol_intro( lines, package, - "AuthRequest", - "Input passed to an auth hook.", - ["Use it to inspect method, path, headers, and Quater call context."], + "AuthConfig", + "One authenticator bound to one or more request surfaces.", + [ + "Pass a list of `AuthConfig` objects to `Quater(auth=...)`. Each surface", + "(`api`, `mcp`, `cli`) may be covered by at most one `AuthConfig`.", + ], ) - lines.extend(signature_block(class_signature(package, "AuthRequest"))) - lines.extend(field_table(package, "AuthRequest")) + lines.extend(signature_block(class_signature(package, "AuthConfig"))) + lines.extend(field_table(package, "AuthConfig")) symbol_intro( lines, package, @@ -996,9 +999,9 @@ def render_auth(package: Any) -> str: "Call-source context. See [Request](./request#call-context).", ), ( - "Authenticate", - "Async callable that receives " - "[`AuthRequest`](#symbol-authrequest) and returns " + "Authenticator", + "Async callable that receives a " + "[`Request`](./request#symbol-request) and returns an " "[`AuthContext`](#symbol-authcontext) or `None`.", ), ("SecretValue", "`str` or `bytes` cookie signing secret."), diff --git a/scripts/_reference/tables.py b/scripts/_reference/tables.py index 717ac33..12c3f62 100644 --- a/scripts/_reference/tables.py +++ b/scripts/_reference/tables.py @@ -47,11 +47,12 @@ "allow_credentials": "Whether browsers may include credentials.", "max_age": "How long browsers may cache a preflight result.", }, - "AuthRequest": { - "method": "HTTP method for the route or protocol request.", - "path": "Path being accessed.", - "headers": "Normalized request headers.", - "context": "Quater context describing the source of the call.", + "AuthConfig": { + "authenticator": "Async callable invoked for requests on covered surfaces.", + "surfaces": ( + "Surfaces this authenticator covers (`api`, `mcp`, and/or `cli`)." + ), + "name": "Optional display name used in error and log messages.", }, "AuthContext": { "subject": "Stable id for the authenticated user, service, or agent.", @@ -284,20 +285,16 @@ ("mcp_docs_path", "str | None", "MCP docs path. `None` disables it."), ("mcp_allowed_origins", "Iterable[str] | None", "Browser origins allowed for MCP."), ( - "mcp_auth", - "Authenticate | None", - "Required when any route has `tool=True`. See [Auth](./auth).", + "auth", + "Iterable[AuthConfig] | None", + "Per-surface [`AuthConfig`](./auth#symbol-authconfig) list. See " + "[Auth](./auth).", ), ( "mcp_audit", "AuditHook | None", "Receives redacted [MCP audit events](./observability#symbol-toolauditevent).", ), - ( - "cli_auth", - "Authenticate | None", - "Required when any route has `cli=True`. See [Auth](./auth).", - ), ( "action_approval", "ActionApproval | None",