fastapi-streams 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ import json
2
+ import re
3
+ from importlib.resources import files
4
+
5
+ from fastapi_streams.codegen.ir import (
6
+ Array,
7
+ Contract,
8
+ EnumAlias,
9
+ EnumValue,
10
+ Intersection,
11
+ LiteralType,
12
+ Object,
13
+ Operation,
14
+ Reference,
15
+ Scalar,
16
+ StringEnum,
17
+ Type,
18
+ Union,
19
+ )
20
+ from fastapi_streams.codegen.names import identifier
21
+ from fastapi_streams.codegen.openapi import ContractError
22
+ from fastapi_streams.codegen.templating import add_filters, environment
23
+
24
+ _HEADER = "// Generated by fastapi-streams. Do not edit.\n"
25
+ _JSON = (
26
+ "export type JsonValue = string | number | boolean | null "
27
+ "| JsonValue[] | { [key: string]: JsonValue };"
28
+ )
29
+
30
+
31
+ def render_type(type_: Type, prefix: str = "") -> str:
32
+ match type_:
33
+ case Scalar(name="JsonValue"):
34
+ return prefix + "JsonValue"
35
+ case Scalar(name="integer"):
36
+ return "number"
37
+ case Scalar(name=name):
38
+ return name
39
+ case LiteralType(value=value):
40
+ return json.dumps(value, ensure_ascii=False)
41
+ case EnumValue(enum=enum, member=member):
42
+ return f"typeof {prefix}{enum}.{member}"
43
+ case StringEnum(members=members):
44
+ return " | ".join(json.dumps(member.value) for member in members)
45
+ case EnumAlias(target=target):
46
+ return prefix + target
47
+ case Reference(name=name):
48
+ return prefix + name
49
+ case Array(item=item):
50
+ return f"Array<{render_type(item, prefix=prefix)}>"
51
+ case Union(members=members):
52
+ return (
53
+ "("
54
+ + " | ".join(render_type(part, prefix=prefix) for part in members)
55
+ + ")"
56
+ )
57
+ case Intersection(members=members):
58
+ return (
59
+ "("
60
+ + " & ".join(render_type(part, prefix=prefix) for part in members)
61
+ + ")"
62
+ )
63
+ case Object(fields=fields, additional=additional):
64
+ parts = [
65
+ f"{json.dumps(field.name)}{'' if field.required else '?'}: "
66
+ f"{render_type(field.type, prefix=prefix)};"
67
+ for field in fields
68
+ ]
69
+ if additional is not None:
70
+ values = {render_type(additional, prefix=prefix)}
71
+ values.update(
72
+ render_type(field.type, prefix=prefix) for field in fields
73
+ )
74
+ if any(not field.required for field in fields):
75
+ values.add("undefined")
76
+ parts.append(f"[key: string]: {' | '.join(sorted(values))};")
77
+ # An empty model is a JSON object, not TypeScript's non-nullish {} type.
78
+ return (
79
+ "{\n" + "\n".join(" " + part for part in parts) + "\n}"
80
+ if parts
81
+ else "{ [key: string]: never }"
82
+ )
83
+
84
+
85
+ def generate_typescript(contract: Contract) -> dict[str, str]:
86
+ public_names = {
87
+ "Client",
88
+ "ClientOptions",
89
+ "RequestOptions",
90
+ "HttpError",
91
+ "StreamParseError",
92
+ "TransportError",
93
+ }
94
+ for declaration in contract.declarations:
95
+ if declaration.name in public_names:
96
+ raise ContractError(f"Reserved TypeScript model name: {declaration.name}")
97
+ public_names.add(declaration.name)
98
+ for operation in contract.operations:
99
+ names = (
100
+ [_input_name(operation)] if operation.parameters or operation.body else []
101
+ )
102
+ if operation.errors:
103
+ names.append(identifier(operation.operation_id, upper=True) + "Error")
104
+ for name in names:
105
+ if name in public_names:
106
+ raise ContractError(f"TypeScript public name collision: {name}")
107
+ public_names.add(name)
108
+ env = environment()
109
+
110
+ def pascal(value: str) -> str:
111
+ return identifier(value, upper=True)
112
+
113
+ add_filters(
114
+ env,
115
+ ts_type=render_type,
116
+ json=json.dumps,
117
+ pascal=pascal,
118
+ input_name=_input_name,
119
+ required=_required,
120
+ error_type=_error_type,
121
+ path=_path,
122
+ )
123
+ output: dict[str, str] = {}
124
+ declarations: list[str] = []
125
+ for declaration in contract.declarations:
126
+ name, type_ = declaration.name, declaration.type
127
+ if isinstance(type_, StringEnum):
128
+ members = "\n".join(
129
+ f" {member.name}: {json.dumps(member.value)},"
130
+ for member in type_.members
131
+ )
132
+ declarations.append(
133
+ f"export const {name} = {{\n{members}\n}} as const;\n"
134
+ f"export type {name} = (typeof {name})[keyof typeof {name}];"
135
+ )
136
+ elif isinstance(type_, EnumAlias):
137
+ declarations.append(
138
+ f"export const {name} = {type_.target};\n"
139
+ f"export type {name} = {type_.target};"
140
+ )
141
+ else:
142
+ declarations.append(f"export type {name} = {render_type(type_)};")
143
+ output["types.ts"] = "\n".join([_HEADER, _JSON, "", *declarations]) + "\n"
144
+ for template in ("sse.ts", "errors.ts"):
145
+ output[f"transport/{template}"] = (
146
+ files(__package__)
147
+ .joinpath(f"templates/{template}")
148
+ .read_text(encoding="utf-8")
149
+ )
150
+ grouped: dict[str, list[Operation]] = {}
151
+ for operation in contract.operations:
152
+ grouped.setdefault(operation.group, []).append(operation)
153
+ groups: list[dict[str, str]] = []
154
+ exports = [
155
+ _HEADER,
156
+ 'export { createClient } from "./client.js";',
157
+ 'export type { Client } from "./client.js";',
158
+ 'export * from "./types.js";',
159
+ 'export type { ClientOptions, RequestOptions } from "./transport/sse.js";',
160
+ "export { HttpError, TransportError, StreamParseError } "
161
+ 'from "./transport/errors.js";',
162
+ ]
163
+ for group, operations in sorted(grouped.items()):
164
+ operations.sort(key=lambda operation: operation.name)
165
+ directory = operations[0].directory
166
+ factory = "create" + group[:1].upper() + group[1:] + "Api"
167
+ groups.append(dict(name=group, factory=factory, directory=directory))
168
+ functions = ["stream"]
169
+ if any(
170
+ param.location == "path" for op in operations for param in op.parameters
171
+ ):
172
+ functions.append("encodePath")
173
+ if any(
174
+ param.location == "query" for op in operations for param in op.parameters
175
+ ):
176
+ functions.append("appendQuery")
177
+ if any(
178
+ param.location == "header" for op in operations for param in op.parameters
179
+ ):
180
+ functions.append("setHeader")
181
+ output[f"{directory}/api.ts"] = env.get_template("typescript/api.ts.j2").render(
182
+ operations=operations,
183
+ functions=functions,
184
+ factory=factory,
185
+ )
186
+ output[f"{directory}/index.ts"] = _HEADER + 'export * from "./api.js";\n'
187
+ exports.append(f'export type * from "./{directory}/api.js";')
188
+ output["client.ts"] = env.get_template("typescript/client.ts.j2").render(
189
+ groups=groups
190
+ )
191
+ output["index.ts"] = "\n".join(exports) + "\n"
192
+ return output
193
+
194
+
195
+ def _input_name(operation: Operation) -> str:
196
+ return identifier(operation.operation_id, upper=True) + "Params"
197
+
198
+
199
+ def _path(operation: Operation) -> str:
200
+ parts: list[str] = []
201
+ cursor = 0
202
+ by_name = {param.name: param.argument for param in operation.parameters}
203
+ for match in re.finditer(r"\{([^{}]+)\}", operation.path):
204
+ parts.append(json.dumps(operation.path[cursor : match.start()]))
205
+ parts.append(f"encodePath(input.{by_name[match[1]]})")
206
+ cursor = match.end()
207
+ parts.append(json.dumps(operation.path[cursor:]))
208
+ return " + ".join(parts)
209
+
210
+
211
+ def _required(operation: Operation) -> bool:
212
+ return any(param.required for param in operation.parameters) or (
213
+ operation.body is not None and operation.body.required
214
+ )
215
+
216
+
217
+ def _error_type(operation: Operation) -> str:
218
+ return " | ".join(
219
+ sorted({render_type(error.type, prefix="Types.") for error in operation.errors})
220
+ )
@@ -0,0 +1,64 @@
1
+ import json
2
+ import tempfile
3
+ from collections.abc import Mapping
4
+ from pathlib import Path, PurePosixPath
5
+ from typing import Any, cast
6
+
7
+ MANIFEST = ".fastapi-streams.json"
8
+
9
+
10
+ def write_files(
11
+ output: Path, files: Mapping[str, str], *, check: bool = False
12
+ ) -> tuple[Path, ...]:
13
+ """Write deterministic output; only remove stale files owned by the manifest.
14
+
15
+ Check mode is read-only, including when output does not yet exist.
16
+ """
17
+ expected = dict(files)
18
+ if MANIFEST in expected:
19
+ raise ValueError(f"{MANIFEST} is reserved")
20
+ expected[MANIFEST] = json.dumps({"files": sorted(files)}, indent=2) + "\n"
21
+ manifest = _owned_path(output, MANIFEST)
22
+ previous: set[str] = set()
23
+ if manifest.exists():
24
+ value = json.loads(manifest.read_text(encoding="utf-8"))
25
+ names = value.get("files")
26
+ if not isinstance(names, list) or not all(
27
+ isinstance(name, str) for name in cast(list[Any], names)
28
+ ):
29
+ raise ValueError(f"Invalid generated manifest: {manifest}")
30
+ previous = set(cast(list[str], names))
31
+ # Preflight every target, including stale paths, before modifying anything.
32
+ paths = {name: _owned_path(output, name) for name in set(expected) | previous}
33
+ changed: list[Path] = []
34
+ for name, content in sorted(expected.items()):
35
+ path = paths[name]
36
+ if path.is_file() and path.read_bytes() == content.encode("utf-8"):
37
+ continue
38
+ changed.append(path)
39
+ if not check:
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
42
+ temporary = Path(handle.name)
43
+ handle.write(content.encode("utf-8"))
44
+ try:
45
+ temporary.replace(path)
46
+ finally:
47
+ temporary.unlink(missing_ok=True)
48
+ for name in sorted(previous - set(expected)):
49
+ path = paths[name]
50
+ if path.is_file():
51
+ changed.append(path)
52
+ if not check:
53
+ path.unlink()
54
+ return tuple(changed)
55
+
56
+
57
+ def _owned_path(root: Path, relative: str) -> Path:
58
+ path = PurePosixPath(relative)
59
+ if path.is_absolute() or ".." in path.parts or "\\" in relative or ":" in relative:
60
+ raise ValueError(f"Unsafe generated path: {relative!r}")
61
+ resolved = (root / relative).resolve()
62
+ if not resolved.is_relative_to(root.resolve()) or resolved == root.resolve():
63
+ raise ValueError(f"Generated path escapes output directory: {relative!r}")
64
+ return resolved
File without changes
@@ -0,0 +1,133 @@
1
+ import inspect
2
+ from collections.abc import (
3
+ AsyncGenerator,
4
+ AsyncIterable,
5
+ AsyncIterator,
6
+ Callable,
7
+ Generator,
8
+ Iterable,
9
+ Iterator,
10
+ )
11
+ from typing import Any, TypeAliasType, get_args, get_origin, get_type_hints
12
+
13
+ from fastapi import APIRouter
14
+ from fastapi.sse import EventSourceResponse, ServerSentEvent
15
+ from pydantic import TypeAdapter
16
+
17
+ _ASYNC = (AsyncIterator, AsyncIterable, AsyncGenerator)
18
+ _SYNC = (Iterator, Iterable, Generator)
19
+
20
+
21
+ class StreamRouter(APIRouter):
22
+ """An APIRouter with typed SSE routes; normal FastAPI routes work unchanged."""
23
+
24
+ def stream[Endpoint: Callable[..., Any]](
25
+ self,
26
+ path: str,
27
+ *,
28
+ methods: list[str] | None = None,
29
+ **options: Any,
30
+ ) -> Callable[[Endpoint], Endpoint]:
31
+ """Register a GET (default) or POST JSON-data SSE generator.
32
+
33
+ Other options are forwarded to ``APIRouter.api_route``, including tags,
34
+ dependencies, responses and operation_id. Serialization must preserve
35
+ the exported event schema: include must be None, exclude must be empty,
36
+ by_alias must be True, and exclude_none/unset/defaults must be False.
37
+ Put HTTP precondition checks in dependencies: a generator's body executes
38
+ after the response has started, following native FastAPI behavior.
39
+ """
40
+ selected = [
41
+ method.upper() for method in (methods if methods is not None else ["GET"])
42
+ ]
43
+ if not selected or not set(selected) <= {"GET", "POST"}:
44
+ raise ValueError("Streams support GET and POST only")
45
+ if "response_class" in options or "response_model" in options:
46
+ raise TypeError(
47
+ "Streams infer their response model and use EventSourceResponse"
48
+ )
49
+ for option, changes_contract in (
50
+ (
51
+ "response_model_include",
52
+ options.get("response_model_include") is not None,
53
+ ),
54
+ ("response_model_exclude", bool(options.get("response_model_exclude"))),
55
+ (
56
+ "response_model_by_alias",
57
+ options.get("response_model_by_alias", True) is not True,
58
+ ),
59
+ (
60
+ "response_model_exclude_none",
61
+ bool(options.get("response_model_exclude_none")),
62
+ ),
63
+ (
64
+ "response_model_exclude_unset",
65
+ bool(options.get("response_model_exclude_unset")),
66
+ ),
67
+ (
68
+ "response_model_exclude_defaults",
69
+ bool(options.get("response_model_exclude_defaults")),
70
+ ),
71
+ ):
72
+ if changes_contract:
73
+ raise TypeError(
74
+ f"{option} would change the stream's exported event contract"
75
+ )
76
+ status = options.get("status_code", 200)
77
+ if status is not None and (not 200 <= status < 300 or status in (204, 205)):
78
+ raise ValueError("A stream must use a successful status code with a body")
79
+ options.setdefault("response_description", "Stream of JSON events")
80
+
81
+ def decorator(endpoint: Endpoint) -> Endpoint:
82
+ _validate_endpoint(endpoint)
83
+ self.api_route(
84
+ path,
85
+ methods=selected,
86
+ response_class=EventSourceResponse,
87
+ **options,
88
+ )(endpoint)
89
+ return endpoint
90
+
91
+ return decorator
92
+
93
+
94
+ def _has_any(annotation: Any, seen: frozenset[int] = frozenset()) -> bool:
95
+ if annotation is Any or any(
96
+ annotation is item for item in (object, dict, list, tuple, set)
97
+ ):
98
+ return True
99
+ if id(annotation) in seen:
100
+ return False
101
+ seen = seen | {id(annotation)}
102
+ if isinstance(annotation, TypeAliasType):
103
+ return _has_any(annotation.__value__, seen)
104
+ return any(_has_any(arg, seen) for arg in get_args(annotation))
105
+
106
+
107
+ def _validate_endpoint(endpoint: Callable[..., Any]) -> None:
108
+ try:
109
+ annotation = get_type_hints(endpoint, include_extras=True).get("return")
110
+ except (NameError, TypeError) as exc:
111
+ raise TypeError(
112
+ f"Cannot resolve stream annotation for {endpoint.__name__}"
113
+ ) from exc
114
+ if inspect.isasyncgenfunction(endpoint):
115
+ origins = _ASYNC
116
+ elif inspect.isgeneratorfunction(endpoint):
117
+ origins = _SYNC
118
+ else:
119
+ raise TypeError("A stream endpoint must be a generator function using yield")
120
+ args = get_args(annotation)
121
+ if get_origin(annotation) not in origins or not args:
122
+ raise TypeError(
123
+ "A stream must declare AsyncIterator[Event] (or Iterable[Event] for def)"
124
+ )
125
+ item = args[0]
126
+ if _has_any(item) or item is ServerSentEvent:
127
+ raise TypeError(
128
+ "Declare a concrete JSON event type, not Any or ServerSentEvent"
129
+ )
130
+ try:
131
+ TypeAdapter(item).json_schema(mode="serialization")
132
+ except Exception as exc:
133
+ raise TypeError(f"Unsupported stream item type: {item!r}") from exc
@@ -0,0 +1,238 @@
1
+ Metadata-Version: 2.5
2
+ Name: fastapi-streams
3
+ Version: 0.0.1
4
+ Summary: Typed FastAPI streams with generated TypeScript and Python clients.
5
+ Project-URL: Documentation, https://github.com/mathisarends/fastapi-streams/tree/main/docs
6
+ Project-URL: Source, https://github.com/mathisarends/fastapi-streams
7
+ Project-URL: Issues, https://github.com/mathisarends/fastapi-streams/issues
8
+ Author: Mathis Arends
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: fastapi<1.0,>=0.143.3
13
+ Requires-Dist: pydantic<3,>=2.11.2
14
+ Provides-Extra: codegen
15
+ Requires-Dist: jinja2<4,>=3.1; extra == 'codegen'
16
+ Requires-Dist: jsonschema<5,>=4.23; extra == 'codegen'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # fastapi-streams
20
+
21
+ [![CI](https://github.com/mathisarends/fastapi-streams/actions/workflows/ci.yml/badge.svg)](https://github.com/mathisarends/fastapi-streams/actions/workflows/ci.yml)
22
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](pyproject.toml)
23
+ [![MIT License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
24
+
25
+ **Decorate a FastAPI endpoint. Get typed streaming clients in TypeScript and Python.**
26
+
27
+ Define your events with Pydantic and add `@router.stream()`. `fastapi-streams`
28
+ turns that definition into an OpenAPI 3.2 stream contract and generated SDKs with
29
+ named event values, typed async iteration, and shared SSE transport code.
30
+
31
+ ```text
32
+ FastAPI decorator → OpenAPI 3.2 → shared HTTP IR → TypeScript + Python clients
33
+ ```
34
+
35
+ ## Why this exists
36
+
37
+ An endpoint labeled `text/event-stream` does not give consumers a typed SDK.
38
+ They still need to know which JSON events arrive, how to parse a fragmented
39
+ stream, and how to stop it cleanly.
40
+
41
+ OpenAPI 3.2 can describe each SSE event with `itemSchema` and its JSON payload
42
+ with `contentSchema`. `fastapi-streams` connects that contract to both clients,
43
+ so event models, endpoint parameters, and client types come from one definition.
44
+ A CI check catches drift between the Python app, the contract, and generated code.
45
+
46
+ ## 1. Decorate your FastAPI endpoint
47
+
48
+ Install from GitHub with the optional codegen extra:
49
+
50
+ ```sh
51
+ uv add 'fastapi-streams[codegen] @ git+https://github.com/mathisarends/fastapi-streams.git'
52
+ ```
53
+
54
+ Save this as `app.py`:
55
+
56
+ ```python
57
+ from collections.abc import AsyncIterator
58
+ from typing import Annotated, Literal
59
+
60
+ from fastapi import FastAPI
61
+ from pydantic import BaseModel, Field
62
+
63
+ from fastapi_streams import StreamRouter
64
+
65
+
66
+ class TokenDelta(BaseModel):
67
+ type: Literal["token.delta"]
68
+ text: str
69
+
70
+
71
+ class RunCompleted(BaseModel):
72
+ type: Literal["run.completed"]
73
+ run_id: str
74
+
75
+
76
+ type RunEvent = Annotated[TokenDelta | RunCompleted, Field(discriminator="type")]
77
+
78
+ router = StreamRouter(prefix="/runs", tags=["agentRuns"])
79
+
80
+
81
+ @router.stream("/{run_id}/events", operation_id="stream_run_events")
82
+ async def stream_run_events(run_id: str) -> AsyncIterator[RunEvent]:
83
+ yield TokenDelta(type="token.delta", text="Hello")
84
+ yield RunCompleted(type="run.completed", run_id=run_id)
85
+
86
+
87
+ app = FastAPI()
88
+ app.include_router(router, prefix="/v1")
89
+ ```
90
+
91
+ The result is `GET /v1/runs/{run_id}/events`. `StreamRouter` extends `APIRouter`:
92
+ normal `Depends`, security dependencies, validation, middleware, and nested
93
+ routers keep working. FastAPI handles SSE framing, serialization, and heartbeats.
94
+ Use `methods=["POST"]` for request-body-driven streams.
95
+
96
+ ## 2. Export once, generate both clients
97
+
98
+ ```sh
99
+ uv run fastapi-streams schema \
100
+ --app app:app --output openapi.json
101
+
102
+ uv run fastapi-streams generate \
103
+ --schema openapi.json --language typescript --output generated/typescript
104
+
105
+ uv run fastapi-streams generate \
106
+ --schema openapi.json --language python --output generated/python
107
+ ```
108
+
109
+ These commands import the app and read its contract; no running server is needed.
110
+ The exporter validates OpenAPI 3.2 and the event schemas. Both generators consume
111
+ one HTTP intermediate representation (IR), carrying operations, parameters, and
112
+ event types into the generated SDKs.
113
+
114
+ ## 3. Consume typed events
115
+
116
+ The `RunEvent` union produces `RunEventType`, with the same named values in both
117
+ languages. The discriminator narrows each event to its matching model.
118
+
119
+ **TypeScript — Fetch, `AsyncIterable<RunEvent>`, no runtime dependencies:**
120
+
121
+ ```typescript
122
+ import { createClient, RunEventType } from "./generated/typescript/index.js";
123
+
124
+ const client = createClient({ baseUrl: "http://localhost:8000" });
125
+
126
+ for await (const event of client.agentRuns.streamRunEvents({ runId: "demo" })) {
127
+ switch (event.type) {
128
+ case RunEventType.TOKEN_DELTA:
129
+ console.log(event.text); // TokenDelta
130
+ break;
131
+ case RunEventType.RUN_COMPLETED:
132
+ console.log(event.run_id); // RunCompleted
133
+ break;
134
+ default: {
135
+ const exhaustive: never = event;
136
+ throw new Error(`Unexpected event: ${exhaustive}`);
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ **Python — HTTPX, Pydantic models, and `StrEnum` values:**
143
+
144
+ ```python
145
+ import asyncio
146
+ from typing import assert_never
147
+
148
+ from generated.python import Client, RunEventType
149
+
150
+
151
+ async def main() -> None:
152
+ async with Client("http://localhost:8000") as client:
153
+ async for event in client.agent_runs.stream_run_events(run_id="demo"):
154
+ match event.type:
155
+ case RunEventType.TOKEN_DELTA:
156
+ print(event.text)
157
+ case RunEventType.RUN_COMPLETED:
158
+ print(event.run_id)
159
+ case _:
160
+ assert_never(event)
161
+
162
+
163
+ asyncio.run(main())
164
+ ```
165
+
166
+ Install `httpx` and `pydantic` in a Python consumer (`uv add httpx pydantic`).
167
+ HTTPX remains the default; opt in to HTTPX2 at generation time with
168
+ `--python-http-client httpx2` and install `httpx2` instead. See
169
+ [Python HTTP library selection](docs/clients.md#python-http-library).
170
+ Both SDKs handle fragmented SSE frames, UTF-8 decoding, HTTP errors, and resource
171
+ cleanup. TypeScript supports `AbortSignal`; Python supports task cancellation and
172
+ explicit async-generator closure. See [client usage](docs/clients.md).
173
+
174
+ ## 4. Catch contract drift in CI
175
+
176
+ Commit the exported schema and generated clients. Then check the complete chain:
177
+
178
+ ```sh
179
+ uv run fastapi-streams check \
180
+ --app app:app --schema openapi.json \
181
+ --language typescript --output generated/typescript
182
+
183
+ uv run fastapi-streams check \
184
+ --app app:app --schema openapi.json \
185
+ --language python --output generated/python
186
+ ```
187
+
188
+ `check` fails if the app's contract changed or generated files are stale. It
189
+ writes nothing and needs no running server. Pair it with TypeScript's compiler
190
+ and Pyright to catch invalid parameters and event access in consumer code.
191
+
192
+ | Check | What it verifies |
193
+ | --- | --- |
194
+ | Contract validation | OpenAPI structure, JSON Schemas, and local references. |
195
+ | `fastapi-streams check` | App → committed contract → generated client consistency. |
196
+ | TypeScript / Pyright | Parameter types, event fields, and exhaustive narrowing. |
197
+
198
+ [Codegen and CI reference →](docs/codegen.md)
199
+
200
+ ## Run the complete example
201
+
202
+ ```sh
203
+ git clone https://github.com/mathisarends/fastapi-streams.git
204
+ cd fastapi-streams
205
+ uv sync --all-extras
206
+ npm ci
207
+ uv run uvicorn examples.agent_runs:app --reload
208
+ ```
209
+
210
+ In another terminal:
211
+
212
+ ```sh
213
+ # TypeScript
214
+ npx tsc -p tests/typescript/tsconfig.json
215
+ node tests/typescript/dist/examples/agent_runs/consume.js
216
+
217
+ # Python
218
+ uv run python -m examples.agent_runs.consume
219
+ ```
220
+
221
+ The example includes GET and authenticated POST streams, both generated SDKs,
222
+ and committed OpenAPI regression fixtures in `tests/fixtures/`. Verify all generated artifacts with
223
+ `uv run python scripts/codegen.py --check`.
224
+
225
+ ## Documentation
226
+
227
+ | Guide | Topics |
228
+ | --- | --- |
229
+ | [Quickstart](docs/quickstart.md) | Setup, event models, and FastAPI integration. |
230
+ | [Contracts and codegen](docs/codegen.md) | OpenAPI 3.2, both generators, and drift checks. |
231
+ | [Client usage](docs/clients.md) | Enums, authentication, cancellation, and errors. |
232
+ | [Architecture](docs/architecture.md) | Native SSE, shared IR, templates, and transports. |
233
+ | [Development](docs/development.md) | Tests, supported scope, and roadmap. |
234
+
235
+ v0.0.1 supports GET/POST JSON SSE streams. Ordinary REST endpoints stay in the
236
+ contract for your existing REST SDK. See the [supported scope](docs/development.md#supported-scope-and-roadmap).
237
+
238
+ Python 3.12+ · FastAPI 0.143.3+ · Pydantic v2 · [MIT](LICENSE)
@@ -0,0 +1,28 @@
1
+ fastapi_streams/__init__.py,sha256=Cx2hFW54h2SV9F8cNGqb_tEgpxBKn0wGx-Gi46q9Zq8,98
2
+ fastapi_streams/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ fastapi_streams/router.py,sha256=u9rq1KTJhJpDpngPi-51Fh4ej_CggisgqGfYvMXYhaY,5011
4
+ fastapi_streams/codegen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ fastapi_streams/codegen/cli.py,sha256=Zj6bxudZnRV6lej3lAoG6PiHqldhOlisgBCUNTT6HDk,4926
6
+ fastapi_streams/codegen/enums.py,sha256=Fw-eUyvYGm-R6_2siYFmZRfsv0akE5yOXLQKe1vcqIE,6804
7
+ fastapi_streams/codegen/ir.py,sha256=MCUtejThPhmGzWmAD-pbUFfO_DHkLCXihD8NJNTezdY,18297
8
+ fastapi_streams/codegen/names.py,sha256=hApsdr0RFjlaT6ufoZakVlajMVDDityoBvq47a59MbU,1765
9
+ fastapi_streams/codegen/openapi.py,sha256=Y9JLKHySX2QYH7Mk5OC0PcYDWMlbPEbsUtgj9fSP_pE,5797
10
+ fastapi_streams/codegen/python.py,sha256=IMsazyN1nzSVAL7haz99c5zy2kjcXqe0epLOO2OjlYI,20170
11
+ fastapi_streams/codegen/templating.py,sha256=g5tVd4ch-kEnitpzTwo_Rsc5BFe9x51ZCWXJ0Qkx68Q,562
12
+ fastapi_streams/codegen/typescript.py,sha256=11aMysC13bVz3ruvSpH-sDuxKC6upEBRAE-ziwV5LRk,8071
13
+ fastapi_streams/codegen/writer.py,sha256=2C1z4VzP67WKEX0XuUUNQIA8cGl2tH2rNNNnY4iSAfI,2560
14
+ fastapi_streams/codegen/schemas/LICENSE,sha256=SUg2fGXhzgZpDiytxuhvzhpqbbVe-HTOS3jA9HLOXxM,11345
15
+ fastapi_streams/codegen/schemas/README.md,sha256=v-U902pyRk_S_qG8clMC2rhBMZl19jCyRXKBbx1EwyM,399
16
+ fastapi_streams/codegen/schemas/openapi-3.2.json,sha256=7FPbbhFLdo_y0nOyMr2iEPUbcMXlfVesja9-hJam4HM,41089
17
+ fastapi_streams/codegen/templates/errors.ts,sha256=a7EVMCMpt4yXNciBegOWffeIlxAdBW4g-xFpzNFwtPE,690
18
+ fastapi_streams/codegen/templates/sse.ts,sha256=v3CikQUUu9YB3W7FFZw9xwm_ZHjo4G1CluYZIL5vgQA,7996
19
+ fastapi_streams/codegen/templates/python/api.py.j2,sha256=06IekC4WwAhvFqk5K4c9M10c085UQZJ25tiwbBPcQkk,2083
20
+ fastapi_streams/codegen/templates/python/client.py.j2,sha256=M2TwtO_L-myMxQJL2d-4nPnfNmrXi3fgxwMFlZ_9inU,1045
21
+ fastapi_streams/codegen/templates/python/runtime.py,sha256=lA6s-eII8cm1Lat0CY5QMLqAxN3xzPfedAALCwi5JFk,7013
22
+ fastapi_streams/codegen/templates/typescript/api.ts.j2,sha256=IpYFDpS2KQBGRYwm6n9fJQpKb0DeS4eCFo0xmse-L9Y,2031
23
+ fastapi_streams/codegen/templates/typescript/client.ts.j2,sha256=G85wKsfcZjRtKC84JrK4hPmh-gpzgFB2bFG6fI-Air4,432
24
+ fastapi_streams-0.0.1.dist-info/METADATA,sha256=BSvnCR0QuxtQSrXkZKpxH5OVFffnHiNRPHp61ZMB6gM,8279
25
+ fastapi_streams-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
26
+ fastapi_streams-0.0.1.dist-info/entry_points.txt,sha256=fnJJEzaZB-8o3hjMMa-oww9fzN_xFi0CTHRaGmZIchU,69
27
+ fastapi_streams-0.0.1.dist-info/licenses/LICENSE,sha256=H0UaMIIkGhEKOyOZdL2EJFxiMJw3mhtXvQ7ZgrYn-Y8,1070
28
+ fastapi_streams-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any