diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..d4ed901 Binary files /dev/null and b/.DS_Store differ diff --git a/docs/en/dev/changelog.md b/docs/en/dev/changelog.md index 539eaba..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)) @@ -405,4 +412,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..eb3860f --- /dev/null +++ b/scripts/_reference/render.py @@ -0,0 +1,1269 @@ +"""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_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 (", + " AuthConfig,", + " AuthContext,", + " HTTPError,", + " ImproperlyConfigured,", + " SignedCookieSigner,", + ")", + "```", + "", + ] + ) + symbol_intro( + lines, + package, + "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, "AuthConfig"))) + lines.extend(field_table(package, "AuthConfig")) + 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).", + ), + ( + "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."), + ) + ) + ) + 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..12c3f62 --- /dev/null +++ b/scripts/_reference/tables.py @@ -0,0 +1,708 @@ +"""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.", + }, + "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.", + "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."), + ( + "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).", + ), + ( + "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..be9c7fb 100644 --- a/scripts/generate_reference.py +++ b/scripts/generate_reference.py @@ -1,675 +1,35 @@ -"""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."), - ), -} - -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."), -) +_SCRIPTS_DIR = str(Path(__file__).resolve().parent) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) -CLI_TEST_CLIENT_OPTIONS: tuple[tuple[str, str, str], ...] = ( - ("client", "TestClient", "HTTP test client used for CLI action requests."), +import griffe # noqa: E402 +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, ) - -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 +61,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())