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.
- fastapi_streams/__init__.py +4 -0
- fastapi_streams/codegen/__init__.py +0 -0
- fastapi_streams/codegen/cli.py +120 -0
- fastapi_streams/codegen/enums.py +172 -0
- fastapi_streams/codegen/ir.py +524 -0
- fastapi_streams/codegen/names.py +88 -0
- fastapi_streams/codegen/openapi.py +154 -0
- fastapi_streams/codegen/python.py +473 -0
- fastapi_streams/codegen/schemas/LICENSE +201 -0
- fastapi_streams/codegen/schemas/README.md +8 -0
- fastapi_streams/codegen/schemas/openapi-3.2.json +1729 -0
- fastapi_streams/codegen/templates/errors.ts +26 -0
- fastapi_streams/codegen/templates/python/api.py.j2 +52 -0
- fastapi_streams/codegen/templates/python/client.py.j2 +40 -0
- fastapi_streams/codegen/templates/python/runtime.py +217 -0
- fastapi_streams/codegen/templates/sse.ts +264 -0
- fastapi_streams/codegen/templates/typescript/api.ts.j2 +52 -0
- fastapi_streams/codegen/templates/typescript/client.ts.j2 +15 -0
- fastapi_streams/codegen/templating.py +19 -0
- fastapi_streams/codegen/typescript.py +220 -0
- fastapi_streams/codegen/writer.py +64 -0
- fastapi_streams/py.typed +0 -0
- fastapi_streams/router.py +133 -0
- fastapi_streams-0.0.1.dist-info/METADATA +238 -0
- fastapi_streams-0.0.1.dist-info/RECORD +28 -0
- fastapi_streams-0.0.1.dist-info/WHEEL +4 -0
- fastapi_streams-0.0.1.dist-info/entry_points.txt +2 -0
- fastapi_streams-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
fastapi_streams/py.typed
ADDED
|
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
|
+
[](https://github.com/mathisarends/fastapi-streams/actions/workflows/ci.yml)
|
|
22
|
+
[](pyproject.toml)
|
|
23
|
+
[](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,,
|