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,154 @@
1
+ import copy
2
+ import json
3
+ from importlib.resources import files
4
+ from typing import Any, cast
5
+ from urllib.parse import unquote
6
+
7
+ from fastapi import FastAPI
8
+
9
+ type Document = dict[str, Any]
10
+
11
+
12
+ class ContractError(ValueError):
13
+ """An invalid or unsupported OpenAPI contract."""
14
+
15
+
16
+ def resolve_pointer(document: Document, ref: str) -> Any:
17
+ if not ref.startswith("#/"):
18
+ raise ContractError(f"Only local OpenAPI references are supported: {ref!r}")
19
+ target: Any = document
20
+ try:
21
+ for part in unquote(ref[2:]).split("/"):
22
+ key = part.replace("~1", "/").replace("~0", "~")
23
+ target = (
24
+ cast(list[Any], target)[int(key)]
25
+ if isinstance(target, list)
26
+ else target[key]
27
+ )
28
+ except (KeyError, TypeError, IndexError, ValueError) as exc:
29
+ raise ContractError(f"Unresolved reference: {ref}") from exc
30
+ return target
31
+
32
+
33
+ def resolve_ref(document: Document, value: Document) -> Document:
34
+ """Resolve local JSON Pointer references, detecting reference-only cycles."""
35
+ seen: set[str] = set()
36
+ while "$ref" in value:
37
+ ref: str = value["$ref"]
38
+ if ref in seen:
39
+ raise ContractError(f"Cyclic reference: {ref}")
40
+ seen.add(ref)
41
+ target = resolve_pointer(document, ref)
42
+ if not isinstance(target, dict):
43
+ raise ContractError(f"Reference does not point to an object: {ref}")
44
+ value = {**target, **{k: v for k, v in value.items() if k != "$ref"}}
45
+ return value
46
+
47
+
48
+ def validate_contract(document: Document) -> None:
49
+ """Validate structure, JSON Schemas, and local reference closure offline.
50
+
51
+ The official OpenAPI meta-schema deliberately omits Schema Object validation;
52
+ those objects are checked separately with JSON Schema Draft 2020-12.
53
+ """
54
+ try:
55
+ from jsonschema import Draft202012Validator
56
+ from jsonschema.exceptions import SchemaError
57
+ except ImportError as exc:
58
+ raise ContractError(
59
+ "Install fastapi-streams[codegen] to validate contracts"
60
+ ) from exc
61
+
62
+ meta = json.loads(
63
+ files(__package__).joinpath("schemas/openapi-3.2.json").read_text()
64
+ )
65
+ errors = sorted(
66
+ Draft202012Validator(meta).iter_errors(document), # pyright: ignore[reportUnknownMemberType]
67
+ key=lambda e: str(e.json_path),
68
+ )
69
+ if errors:
70
+ error = errors[0]
71
+ raise ContractError(
72
+ f"Invalid OpenAPI 3.2 at {error.json_path}: {error.message}"
73
+ )
74
+ dialect = document.get("jsonSchemaDialect", "")
75
+ if dialect and dialect not in {
76
+ "https://spec.openapis.org/oas/3.2/dialect/2026-02-26",
77
+ "https://json-schema.org/draft/2020-12/schema",
78
+ }:
79
+ raise ContractError(f"Unsupported JSON Schema dialect: {dialect}")
80
+
81
+ def walk_schema(node: Any, path: str) -> None:
82
+ try:
83
+ Draft202012Validator.check_schema(node)
84
+ except SchemaError as exc:
85
+ raise ContractError(
86
+ f"Invalid JSON Schema at {path}: {exc.message}"
87
+ ) from exc
88
+ if not isinstance(node, dict):
89
+ return
90
+ schema = cast(Document, node)
91
+ if "$ref" in schema:
92
+ resolve_pointer(document, schema["$ref"])
93
+ for keyword in ("$defs", "properties", "patternProperties", "dependentSchemas"):
94
+ for name, child in schema.get(keyword, {}).items():
95
+ walk_schema(child, f"{path}/{keyword}/{name}")
96
+ for keyword in ("allOf", "anyOf", "oneOf", "prefixItems"):
97
+ for index, child in enumerate(schema.get(keyword, [])):
98
+ walk_schema(child, f"{path}/{keyword}/{index}")
99
+ for keyword in (
100
+ "items",
101
+ "additionalProperties",
102
+ "unevaluatedProperties",
103
+ "unevaluatedItems",
104
+ "contains",
105
+ "not",
106
+ "if",
107
+ "then",
108
+ "else",
109
+ "propertyNames",
110
+ "contentSchema",
111
+ ):
112
+ if keyword in schema:
113
+ walk_schema(schema[keyword], f"{path}/{keyword}")
114
+
115
+ def walk(node: Any, path: str) -> None:
116
+ if isinstance(node, dict):
117
+ if "$ref" in node:
118
+ resolve_ref(document, cast(Document, node))
119
+ for key, value in cast(Document, node).items():
120
+ if key in {"example", "examples", "default", "enum", "const"}:
121
+ continue
122
+ if path == "$/components/schemas" or key in {"schema", "itemSchema"}:
123
+ walk_schema(value, f"{path}/{key}")
124
+ else:
125
+ walk(value, f"{path}/{key}")
126
+ elif isinstance(node, list):
127
+ for index, value in enumerate(cast(list[Any], node)):
128
+ walk(value, f"{path}/{index}")
129
+
130
+ walk(document, "$")
131
+
132
+
133
+ def export_schema(app: FastAPI) -> Document:
134
+ """Copy native OpenAPI output, promote compatible 3.1 output, then validate.
135
+
136
+ FastAPI emits the 3.2 itemSchema/contentSchema structure under a 3.1 version.
137
+ Its JSON Schema dialect is already Draft 2020-12. No stream schemas are
138
+ synthesized and the application's cached document is never modified.
139
+ Incompatible custom OpenAPI extensions fail validation instead of being lost.
140
+ """
141
+ document = copy.deepcopy(app.openapi())
142
+ version = document.get("openapi", "")
143
+ if not version.startswith(("3.1.", "3.2.")):
144
+ raise ContractError(f"Expected OpenAPI 3.1 or 3.2, got {version!r}")
145
+ if (
146
+ document.get("jsonSchemaDialect")
147
+ == "https://spec.openapis.org/oas/3.1/dialect/base"
148
+ ):
149
+ document["jsonSchemaDialect"] = (
150
+ "https://spec.openapis.org/oas/3.2/dialect/2026-02-26"
151
+ )
152
+ document["openapi"] = "3.2.0"
153
+ validate_contract(document)
154
+ return document
@@ -0,0 +1,473 @@
1
+ import re
2
+ from dataclasses import replace
3
+ from importlib.resources import files
4
+ from typing import Literal
5
+
6
+ from fastapi_streams.codegen.ir import (
7
+ Array,
8
+ Contract,
9
+ Declaration,
10
+ EnumAlias,
11
+ EnumValue,
12
+ Field,
13
+ Intersection,
14
+ LiteralType,
15
+ Object,
16
+ Operation,
17
+ Parameter,
18
+ Reference,
19
+ Scalar,
20
+ StringEnum,
21
+ Type,
22
+ Union,
23
+ )
24
+ from fastapi_streams.codegen.names import identifier, python_identifier
25
+ from fastapi_streams.codegen.openapi import ContractError
26
+ from fastapi_streams.codegen.templating import add_filters, environment
27
+
28
+ _HEADER = "# Generated by fastapi-streams. Do not edit.\n"
29
+ _MODEL_SYMBOLS = {
30
+ "BaseModel",
31
+ "ConfigDict",
32
+ "Field",
33
+ "JsonValue",
34
+ "StrEnum",
35
+ "Literal",
36
+ "Annotated",
37
+ "field_validator",
38
+ "int",
39
+ "str",
40
+ "float",
41
+ "bool",
42
+ "list",
43
+ "dict",
44
+ "object",
45
+ "classmethod",
46
+ }
47
+
48
+
49
+ def generate_python(
50
+ contract: Contract, *, http_client: Literal["httpx", "httpx2"] = "httpx"
51
+ ) -> dict[str, str]:
52
+ if http_client not in {"httpx", "httpx2"}:
53
+ raise ContractError(f"Unsupported Python HTTP client: {http_client!r}")
54
+ http_import = (
55
+ "import httpx2 as httpx" if http_client == "httpx2" else "import httpx"
56
+ )
57
+ models = _Models(contract.declarations)
58
+ grouped: dict[str, list[Operation]] = {}
59
+ for operation in contract.operations:
60
+ name = identifier(operation.operation_id, upper=True)
61
+ seen = {"self", "options", "query", "headers", "content", "adapter"}
62
+ parameters: list[Parameter] = []
63
+ for parameter in operation.parameters:
64
+ argument = python_identifier(parameter.name)
65
+ if argument in seen or (operation.body and argument == "body"):
66
+ raise ContractError(
67
+ f"Reserved or duplicate Python argument: {argument}"
68
+ )
69
+ seen.add(argument)
70
+ type_ = parameter.type
71
+ if not isinstance(type_, Scalar | Reference):
72
+ type_ = models.add(name + identifier(parameter.name, upper=True), type_)
73
+ parameters.append(replace(parameter, type=type_))
74
+ body = operation.body
75
+ if body:
76
+ body = replace(body, type=models.add(name + "Body", body.type))
77
+ event = models.add(name + "Event", operation.event)
78
+ if operation.errors:
79
+ models.add(
80
+ name + "Error", Union(tuple(error.type for error in operation.errors))
81
+ )
82
+ operation = replace(
83
+ operation, parameters=tuple(parameters), body=body, event=event
84
+ )
85
+ grouped.setdefault(python_identifier(operation.group), []).append(operation)
86
+ env = environment()
87
+
88
+ def render_type(value: Type) -> str:
89
+ return models.render_type(value, hint="Anonymous", prefix="Models.")
90
+
91
+ add_filters(
92
+ env,
93
+ snake=python_identifier,
94
+ repr=repr,
95
+ py_type=render_type,
96
+ py_path=_path,
97
+ )
98
+ groups: list[dict[str, str]] = []
99
+ output: dict[str, str] = {}
100
+ for name, operations in sorted(grouped.items()):
101
+ if name in {"models", "runtime", "client", "aclose"}:
102
+ raise ContractError(f"Reserved Python tag: {name}")
103
+ if len(
104
+ {python_identifier(operation.operation_id) for operation in operations}
105
+ ) != len(operations):
106
+ raise ContractError(f"Python operation name collision in {name}")
107
+ class_name = identifier(name, upper=True) + "Api"
108
+ groups.append(dict(name=name, directory=name, class_name=class_name))
109
+ functions = ["RequestOptions", "Transport"]
110
+ for location, function in (
111
+ ("path", "encode_path"),
112
+ ("query", "append_query"),
113
+ ("header", "set_header"),
114
+ ):
115
+ if any(
116
+ parameter.location == location
117
+ for op in operations
118
+ for parameter in op.parameters
119
+ ):
120
+ functions.append(function)
121
+ output[f"{name}/api.py"] = env.get_template("python/api.py.j2").render(
122
+ class_name=class_name,
123
+ operations=sorted(operations, key=lambda op: op.name),
124
+ runtime_imports=sorted(functions),
125
+ )
126
+ output[f"{name}/__init__.py"] = (
127
+ _HEADER + f"from .api import {class_name} as {class_name}\n"
128
+ )
129
+ output["models.py"] = models.render()
130
+ output["client.py"] = env.get_template("python/client.py.j2").render(
131
+ groups=groups, http_import=http_import
132
+ )
133
+ output["runtime.py"] = (
134
+ files(__package__)
135
+ .joinpath("templates/python/runtime.py")
136
+ .read_text(encoding="utf-8")
137
+ .replace("\nimport httpx\n", f"\n{http_import}\n", 1)
138
+ )
139
+ output["__init__.py"] = _HEADER + (
140
+ "from .client import Client as Client\n"
141
+ "from .runtime import (\n"
142
+ " HttpError as HttpError, RequestOptions as RequestOptions,\n"
143
+ " StreamParseError as StreamParseError, TransportError as TransportError,\n"
144
+ ")\n"
145
+ )
146
+ for name, type_ in sorted(models.declarations.items()):
147
+ if isinstance(type_, StringEnum | EnumAlias):
148
+ if name in {
149
+ "Client",
150
+ "RequestOptions",
151
+ "TransportError",
152
+ "HttpError",
153
+ "StreamParseError",
154
+ }:
155
+ raise ContractError(f"Reserved Python enum name: {name}")
156
+ output["__init__.py"] += f"from .models import {name} as {name}\n"
157
+ output["py.typed"] = ""
158
+ return output
159
+
160
+
161
+ class _Models:
162
+ def __init__(self, declarations: tuple[Declaration, ...]) -> None:
163
+ if collisions := _MODEL_SYMBOLS & {
164
+ declaration.name for declaration in declarations
165
+ }:
166
+ raise ContractError(f"Reserved Python model names: {sorted(collisions)}")
167
+ self.declarations = {
168
+ declaration.name: declaration.type for declaration in declarations
169
+ }
170
+
171
+ def add(self, name: str, type_: Type) -> Reference:
172
+ if isinstance(type_, Reference):
173
+ return type_
174
+ while name in self.declarations:
175
+ name += "Value"
176
+ self.declarations[name] = type_
177
+ return Reference(name)
178
+
179
+ def render_type(self, type_: Type, *, hint: str, prefix: str = "") -> str:
180
+ match type_:
181
+ case Scalar(name="JsonValue"):
182
+ return "JsonValue"
183
+ case Scalar(name="never"):
184
+ raise ContractError(
185
+ "The Python generator does not support never schemas"
186
+ )
187
+ case Scalar(name=name):
188
+ return {
189
+ "string": "str",
190
+ "integer": "int",
191
+ "number": "float",
192
+ "boolean": "bool",
193
+ "null": "None",
194
+ }[name]
195
+ case LiteralType(value=None):
196
+ return "None"
197
+ case LiteralType(value=value):
198
+ return f"Literal[{value!r}]"
199
+ case EnumValue(enum=enum, member=member):
200
+ return f"Literal[{prefix}{enum}.{member}]"
201
+ case EnumAlias(target=target):
202
+ return prefix + target
203
+ case StringEnum():
204
+ raise ContractError("Enums must be named declarations")
205
+ case Reference(name=name):
206
+ return prefix + name
207
+ case Array(item=item):
208
+ return (
209
+ f"list[{self.render_type(item, hint=hint + 'Item', prefix=prefix)}]"
210
+ )
211
+ case Union(members=members, discriminator=discriminator):
212
+ if members and all(isinstance(part, EnumValue) for part in members):
213
+ values = ", ".join(
214
+ f"{prefix}{part.enum}.{part.member}"
215
+ for part in members
216
+ if isinstance(part, EnumValue)
217
+ )
218
+ return f"Literal[{values}]"
219
+ union = " | ".join(
220
+ self.render_type(member, hint=hint + str(index), prefix=prefix)
221
+ for index, member in enumerate(members)
222
+ )
223
+ if discriminator:
224
+ discriminator = _field_name(discriminator)
225
+ return f"Annotated[{union}, Field(discriminator={discriminator!r})]"
226
+ return union if len(members) == 1 else f"({union})"
227
+ case Object() | Intersection():
228
+ reference = self.add(hint, type_)
229
+ return prefix + reference.name
230
+
231
+ def nullable(self, type_: Type, seen: frozenset[str] = frozenset()) -> bool:
232
+ match type_:
233
+ case Scalar(name="null" | "JsonValue") | LiteralType(value=None):
234
+ return True
235
+ case Union(members=members):
236
+ return any(self.nullable(member, seen) for member in members)
237
+ case Reference(name=name) if name not in seen:
238
+ return self.nullable(self.declarations[name], seen | {name})
239
+ case _:
240
+ return False
241
+
242
+ def _object(self, type_: Type, seen: frozenset[str] = frozenset()) -> Object:
243
+ match type_:
244
+ case Object():
245
+ return type_
246
+ case Reference(name=name) if name not in seen:
247
+ return self._object(self.declarations[name], seen | {name})
248
+ case Intersection(members=members):
249
+ fields: dict[str, Field] = {}
250
+ additional: Type | None = None
251
+ objects = [self._object(member, seen) for member in members]
252
+ for obj in objects:
253
+ for field in obj.fields:
254
+ if (
255
+ field.name in fields
256
+ and fields[field.name].type != field.type
257
+ ):
258
+ raise ContractError(
259
+ f"Conflicting allOf field: {field.name}"
260
+ )
261
+ previous = fields.get(field.name)
262
+ fields[field.name] = replace(
263
+ field,
264
+ required=field.required
265
+ or bool(previous and previous.required),
266
+ )
267
+ if obj.additional is not None and obj.additional != Scalar(
268
+ "JsonValue"
269
+ ):
270
+ if additional not in (
271
+ None,
272
+ Scalar("JsonValue"),
273
+ obj.additional,
274
+ ):
275
+ raise ContractError(
276
+ "Conflicting allOf additionalProperties schemas"
277
+ )
278
+ additional = obj.additional
279
+ elif additional is None:
280
+ additional = obj.additional
281
+ forbidden = any(obj.additional_forbidden for obj in objects)
282
+ for obj in objects:
283
+ undeclared = set(fields) - {field.name for field in obj.fields}
284
+ if obj.additional_forbidden and undeclared:
285
+ raise ContractError(
286
+ "Closed-object allOf cannot introduce fields forbidden by "
287
+ f"another member: {sorted(undeclared)}"
288
+ )
289
+ if obj.additional not in (None, Scalar("JsonValue")) and any(
290
+ fields[name].type != obj.additional for name in undeclared
291
+ ):
292
+ raise ContractError(
293
+ "Conflicting allOf additionalProperties field types"
294
+ )
295
+ return Object(
296
+ tuple(fields[name] for name in sorted(fields)),
297
+ None if forbidden else additional,
298
+ forbidden,
299
+ )
300
+ case _:
301
+ raise ContractError("Python allOf supports object intersections only")
302
+
303
+ def render(self) -> str:
304
+ blocks: dict[str, str] = {}
305
+ classes: list[str] = []
306
+ while pending := sorted(set(self.declarations) - set(blocks)):
307
+ for name in pending:
308
+ type_ = self.declarations[name]
309
+ if isinstance(type_, StringEnum):
310
+ members = "\n".join(
311
+ f" {member.name} = {member.value!r}"
312
+ for member in type_.members
313
+ )
314
+ blocks[name] = f"class {name}(StrEnum):\n{members}"
315
+ elif isinstance(type_, EnumAlias):
316
+ blocks[name] = f"{name} = {type_.target}"
317
+ elif isinstance(type_, Object | Intersection):
318
+ obj = self._object(type_)
319
+ if obj.additional is not None and not obj.fields:
320
+ value = self.render_type(obj.additional, hint=name + "Value")
321
+ blocks[name] = f"type {name} = dict[str, {value}]"
322
+ continue
323
+ lines = [f"class {name}(BaseModel):"]
324
+ if obj.additional_forbidden:
325
+ lines.append(
326
+ " model_config = ConfigDict("
327
+ 'populate_by_name=True, extra="forbid")'
328
+ )
329
+ elif obj.additional is not None:
330
+ lines.append(
331
+ " model_config = ConfigDict("
332
+ 'populate_by_name=True, extra="allow")'
333
+ )
334
+ else:
335
+ lines.append(
336
+ " model_config = ConfigDict(populate_by_name=True)"
337
+ )
338
+ field_names: set[str] = set()
339
+ nonnullable_optional: list[str] = []
340
+ for field in obj.fields:
341
+ field_name = _field_name(field.name)
342
+ if field_name in field_names:
343
+ raise ContractError(
344
+ f"Python field name collision in {name}: {field_name}"
345
+ )
346
+ field_names.add(field_name)
347
+ annotation = self.render_type(
348
+ field.type, hint=name + identifier(field.name, upper=True)
349
+ )
350
+ literals = (
351
+ field.type.members
352
+ if isinstance(field.type, Union)
353
+ else (field.type,)
354
+ )
355
+ enum_values = [
356
+ part for part in literals if isinstance(part, EnumValue)
357
+ ]
358
+ constant_default = (
359
+ len(enum_values) == len(literals) and not field.required
360
+ )
361
+ if (
362
+ not field.required
363
+ and not self.nullable(field.type)
364
+ and not constant_default
365
+ ):
366
+ annotation = f"{annotation} | None"
367
+ nonnullable_optional.append(field_name)
368
+ default = "None" if not field.required else "..."
369
+ if constant_default:
370
+ selected = enum_values[0]
371
+ for value in enum_values:
372
+ enum = self.declarations[value.enum]
373
+ assert isinstance(enum, StringEnum)
374
+ if field.default and any(
375
+ member.name == value.member
376
+ and member.value == field.default.value
377
+ for member in enum.members
378
+ ):
379
+ selected = value
380
+ break
381
+ default = f"{selected.enum}.{selected.member}"
382
+ assignment = (
383
+ f" = Field(default={default}, alias={field.name!r})"
384
+ if field_name != field.name
385
+ else (f" = {default}" if not field.required else "")
386
+ )
387
+ lines.append(f" {field_name}: {annotation}{assignment}")
388
+ if obj.additional is not None:
389
+ annotation = self.render_type(
390
+ obj.additional, hint=name + "Extra"
391
+ )
392
+ lines.append(
393
+ f" __pydantic_extra__: dict[str, {annotation}]"
394
+ " = Field(init=False)"
395
+ " # pyright: ignore[reportIncompatibleVariableOverride]"
396
+ )
397
+ if nonnullable_optional:
398
+ args = ", ".join(repr(name) for name in nonnullable_optional)
399
+ lines.extend(
400
+ [
401
+ "",
402
+ f" @field_validator({args}, mode='before')",
403
+ " @classmethod",
404
+ " def _reject_explicit_null("
405
+ "cls, value: object) -> object:",
406
+ " if value is None:",
407
+ " raise ValueError("
408
+ "'Field may be omitted but cannot be null')",
409
+ " return value",
410
+ ]
411
+ )
412
+ blocks[name] = "\n".join(lines)
413
+ classes.append(name)
414
+ else:
415
+ annotation = self.render_type(type_, hint=name)
416
+ blocks[name] = f"type {name} = {annotation}"
417
+ text = "\n".join(blocks.values())
418
+ typing_names = [name for name in ("Annotated", "Literal") if name + "[" in text]
419
+ pydantic_names = [
420
+ name
421
+ for name in (
422
+ "BaseModel",
423
+ "ConfigDict",
424
+ "Field",
425
+ "JsonValue",
426
+ "field_validator",
427
+ )
428
+ if re.search(r"\b" + name + r"\b", text)
429
+ ]
430
+ imports = [_HEADER, "from __future__ import annotations", ""]
431
+ if any(isinstance(type_, StringEnum) for type_ in self.declarations.values()):
432
+ imports.append("from enum import StrEnum")
433
+ if typing_names:
434
+ imports.append("from typing import " + ", ".join(typing_names))
435
+ if pydantic_names:
436
+ imports.extend(["", "from pydantic import " + ", ".join(pydantic_names)])
437
+ return (
438
+ "\n\n".join(
439
+ [
440
+ "\n".join(imports),
441
+ *(
442
+ blocks[name]
443
+ for name in sorted(
444
+ blocks,
445
+ key=lambda name: (
446
+ not isinstance(
447
+ self.declarations[name], StringEnum | EnumAlias
448
+ ),
449
+ name,
450
+ ),
451
+ )
452
+ ),
453
+ *(f"{name}.model_rebuild()" for name in sorted(classes)),
454
+ ]
455
+ )
456
+ + "\n"
457
+ )
458
+
459
+
460
+ def _field_name(value: str) -> str:
461
+ name = python_identifier(value)
462
+ return name + "_" if name in _MODEL_SYMBOLS else name
463
+
464
+
465
+ def _path(operation: Operation) -> str:
466
+ parts: list[str] = []
467
+ cursor = 0
468
+ for match in re.finditer(r"\{([^{}]+)\}", operation.path):
469
+ parts.append(repr(operation.path[cursor : match.start()]))
470
+ parts.append(f"encode_path({python_identifier(match[1])})")
471
+ cursor = match.end()
472
+ parts.append(repr(operation.path[cursor:]))
473
+ return " + ".join(parts)